Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: support vw/vh and percents in SVG dimensions #119

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ cython_debug/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
.idea/

### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
Expand Down
111 changes: 98 additions & 13 deletions app/svg_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
from pathlib import Path
from uuid import uuid4

from defusedxml import ElementTree as ET

SPECIAL_UNITS = ("vw", "vh", "%") # Special units that require viewBox context

IMAGE_PNG = "image/png"
IMAGE_SVG = "image/svg+xml"

Expand Down Expand Up @@ -64,11 +68,11 @@ def replace_img_base64(match: re.Match[str]) -> str:

# Checks that base64 encoded content is a svg image and replaces it with the png screenshot made by chromium
def replace_svg_with_png(svg_content: str) -> tuple[str, str | bytes]:
width, height = extract_svg_dimensions_as_px(svg_content)
width, height, updated_svg_content = extract_svg_dimensions_as_px(svg_content)
if not width or not height:
return IMAGE_SVG, svg_content

svg_filepath, png_filepath = prepare_temp_files(svg_content)
svg_filepath, png_filepath = prepare_temp_files(updated_svg_content)
if not svg_filepath or not png_filepath:
return IMAGE_SVG, svg_content

Expand All @@ -82,21 +86,98 @@ def replace_svg_with_png(svg_content: str) -> tuple[str, str | bytes]:
return IMAGE_PNG, png_content


# Extract the width and height from the SVG tag (and convert it to px)
def extract_svg_dimensions_as_px(svg_content: str) -> tuple[int | None, int | None]:
width_match = re.search(r'<svg[^>]+?width="(?P<width>[\d.]+)(?P<unit>\w+)?', svg_content)
height_match = re.search(r'<svg[^>]+?height="(?P<height>[\d.]+)(?P<unit>\w+)?', svg_content)
def extract_svg_dimensions_as_px(svg_content: str) -> tuple[int | None, int | None, str]:
"""
Extract width and height from the SVG tag and convert them to px.
If units are vw/vh/% and viewBox exists, compute their pixel equivalents.
Returns updated SVG content with replaced width/height if necessary.
"""
width, width_unit = parse_svg_dimension(svg_content, "width")
height, height_unit = parse_svg_dimension(svg_content, "height")
vb_width, vb_height = parse_viewbox(svg_content)

width_px = calculate_dimension(width, width_unit, vb_width)
height_px = calculate_dimension(height, height_unit, vb_height)

if vb_width is not None and vb_height is not None:
if width_px is None:
width_px = math.ceil(vb_width)
if height_px is None:
height_px = math.ceil(vb_height)
svg_content = replace_svg_size_attributes(svg_content, width_px, height_px)

if width_px is None or height_px is None:
return None, None, svg_content

return width_px, height_px, svg_content


def parse_svg_dimension(svg_content: str, dimension: str) -> tuple[str | None, str | None]:
match = re.search(
rf'<svg[^>]*?\b{dimension}\s*=\s*["\'](?P<value>[\d.]+)(?P<unit>\w+|%)?["\']',
svg_content,
flags=re.IGNORECASE,
)
if match:
return match.group("value"), match.group("unit")
return None, None


def parse_viewbox(svg_content: str) -> tuple[float | None, float | None]:
match = re.search(
r'<svg[^>]*?\bviewBox\s*=\s*["\']'
r"[\d.\-]+\s+[\d.\-]+\s+"
r"(?P<vb_width>[\d.\-]+)\s+"
r"(?P<vb_height>[\d.\-]+)"
r'["\']',
svg_content,
flags=re.IGNORECASE,
)
if match:
return float(match.group("vb_width")), float(match.group("vb_height"))
return None, None


def calculate_dimension(value: str | None, unit: str | None, vb_dimension: float | None) -> int | None:
if value is None:
return None

width = width_match.group("width") if width_match else None
height = height_match.group("height") if height_match else None
if unit in SPECIAL_UNITS:
if vb_dimension is None:
raise ValueError(f"{unit} units require a viewBox to be defined")
return calculate_special_unit(value, unit, vb_dimension)

if not width or not height:
logging.error(f"Cannot find SVG dimensions. Width: {width}, Height: {height}")
return convert_to_px(value, unit)


def replace_svg_size_attributes(svg_content: str, width_px: int, height_px: int) -> str:
try:
root = ET.fromstring(svg_content)
except ET.ParseError as e:
raise ValueError("Invalid SVG content") from e

# Set or replace width and height attributes
root.set("width", f"{width_px}px")
root.set("height", f"{height_px}px")

width_unit = width_match.group("unit") if width_match else None
height_unit = height_match.group("unit") if height_match else None
# Convert XML tree back to a string
svg_with_attributes = ET.tostring(root, encoding="unicode")

return convert_to_px(width, width_unit), convert_to_px(height, height_unit)
return svg_with_attributes


# Calculates the pixel value for vw, vh, or % units based on viewBox dimensions
def calculate_special_unit(value: str, unit: str | None, viewbox_dimension: float) -> int:
val = float(value)

if unit in SPECIAL_UNITS:
return math.ceil((val / 100) * viewbox_dimension)

fallback = convert_to_px(value, unit)
if fallback is None:
raise ValueError(f"Cannot convert unit '{unit}' to px")

return fallback


# Save the SVG content to a temporary file and return the file paths for the SVG and PNG.
Expand Down Expand Up @@ -185,6 +266,10 @@ def convert_to_px(value: str | None, unit: str | None) -> int | None:
if value is None:
raise ValueError()
value_f64 = float(value)

if unit in SPECIAL_UNITS:
return None

return math.ceil(value_f64 * get_px_conversion_ratio(unit))
except ValueError:
logging.error(f"Invalid value for conversion: {value}")
Expand Down
7 changes: 6 additions & 1 deletion app/weasyprint_service_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@

from app import weasyprint_controller # type: ignore

if __name__ == "__main__":

def main() -> None:
parser = argparse.ArgumentParser(description="Weasyprint service")
parser.add_argument("--port", default=9080, type=int, required=False, help="Service port")
args = parser.parse_args()
Expand All @@ -13,3 +14,7 @@
logging.getLogger().setLevel(logging.WARN)

weasyprint_controller.start_server(args.port)


if __name__ == "__main__":
main()
26 changes: 25 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ requires-python=">=3.13"
dependencies = [
"flask (>=3.1.0,<4.0.0)",
"gevent (>=24.11.1,<25.0.0)",
"weasyprint (>=64.0,<65)"
"weasyprint (>=64.0,<65)",
"defusedxml (>=0.7.1,<0.8.0)"
]

[tool.poetry]
Expand All @@ -25,6 +26,7 @@ python = "^3.13"
pre-commit = "4.1.0"
ruff = "0.11.0"
mypy = "1.15.0"
types-defusedxml = "^0.7.0.20240218"

[tool.poetry.group.test.dependencies]
tox = "^4.18.0"
Expand Down Expand Up @@ -73,3 +75,5 @@ line-ending = "lf"
explicit_package_bases = true
disallow_untyped_defs = true
exclude = "tests/.*"
install_types = true
non_interactive = true
104 changes: 98 additions & 6 deletions tests/test_svg_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,104 @@ def test_replace_svg_with_png():

@setup_env_variables
def test_extract_svg_dimensions_as_px():
# Return width and height with valid input
svg_content = r'<svg height="200px" width="100px">'
width, height = extract_svg_dimensions_as_px(svg_content)
# Valid width and height with absolute units
svg_content = r'<svg height="200px" width="100px"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 100 and height == 200
assert 'width="100px"' in updated_svg
assert 'height="200px"' in updated_svg

# Return None values for incorrectly formatted svg tag attributes
svg_content = r'<svg height=200px" width "100px'
width, height = extract_svg_dimensions_as_px(svg_content)
# Invalid width and height attribute formatting → None
svg_content = r'<svg height=200px" width "100px></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width is None and height is None
assert updated_svg == svg_content

# Valid viewBox fallback when width and height are missing
svg_content = r'<svg viewBox="0 0 300 150"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 300 and height == 150
assert 'width="300px"' in updated_svg
assert 'height="150px"' in updated_svg

# viewBox with incorrect format → None
svg_content = r'<svg viewBox="0 0 300"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width is None and height is None
assert updated_svg == svg_content

# Missing everything → None, None
svg_content = r"<svg></svg>"
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width is None and height is None
assert updated_svg == svg_content

# Partial attributes: width only, no height, but with valid viewBox
svg_content = r'<svg width="100px" viewBox="0 0 400 200"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 100 and height == 200
assert 'width="100px"' in updated_svg
assert 'height="200px"' in updated_svg

# Partial attributes: height only, no width, but with valid viewBox
svg_content = r'<svg height="50px" viewBox="0 0 400 200"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 400 and height == 50
assert 'width="400px"' in updated_svg
assert 'height="50px"' in updated_svg

# Non-numeric width and height → None
svg_content = r'<svg width="abc" height="xyz"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width is None and height is None
assert updated_svg == svg_content


@setup_env_variables
def test_extract_svg_dimensions_as_px_relative():
# width/height in vw/vh without viewBox → raise ValueError
svg_content = r'<svg width="100vw" height="100vh"></svg>'
try:
extract_svg_dimensions_as_px(svg_content)
raise AssertionError("Expected ValueError due to missing viewBox for vw/vh units")
except ValueError as e:
assert "vw units require a viewBox to be defined" in str(e)

# width/height in % without viewBox → raise ValueError
svg_content = r'<svg width="100%" height="100%"></svg>'
try:
extract_svg_dimensions_as_px(svg_content)
raise AssertionError("Expected ValueError due to missing viewBox for vw/vh units")
except ValueError as e:
assert "% units require a viewBox to be defined" in str(e)

# width/height in vw/vh with valid viewBox
svg_content = r'<svg width="100vw" height="100vh" viewBox="0 0 800 600"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 800 and height == 600
assert 'width="800px"' in updated_svg
assert 'height="600px"' in updated_svg

# width/height in vw/vh with valid viewBox
svg_content = r'<svg width="50vw" height="50vh" viewBox="0 0 800 600"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 400 and height == 300
assert 'width="400px"' in updated_svg
assert 'height="300px"' in updated_svg

# 100% / 100% with viewBox = 800x600
svg_content = r'<svg width="100%" height="100%" viewBox="0 0 800 600"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 800 and height == 600
assert 'width="800px"' in updated_svg
assert 'height="600px"' in updated_svg

# 50% / 25% with viewBox = 800x600
svg_content = r'<svg width="50%" height="25%" viewBox="0 0 800 600"></svg>'
width, height, updated_svg = extract_svg_dimensions_as_px(svg_content)
assert width == 400 and height == 150
assert 'width="400px"' in updated_svg
assert 'height="150px"' in updated_svg


@setup_env_variables
Expand Down Expand Up @@ -189,6 +278,9 @@ def test_convert_to_px():
assert convert_to_px("1", "mm") == 378
assert convert_to_px(None, "px") is None
assert convert_to_px("abc", "px") is None
assert convert_to_px("100", "vh") is None
assert convert_to_px("100", "vw") is None
assert convert_to_px("100", "%") is None


@setup_env_variables
Expand Down
17 changes: 17 additions & 0 deletions tests/test_weasyprint_service_application.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import logging
import sys

from app import weasyprint_service_application


def test_main_runs(monkeypatch):
monkeypatch.setattr(sys, "argv", ["weasyprint_service_application.py", "--port", "9999"])

logger = logging.getLogger("test")

def fake_start_server(port):
logger.info(f"Fake server started on port {port}")

monkeypatch.setattr(weasyprint_service_application.weasyprint_controller, "start_server", fake_start_server)

weasyprint_service_application.main()
Loading