"""Optional resvg/svg2pdf publication backend, always consuming the scene SVG."""
from __future__ import annotations
import math
import struct
import xml.etree.ElementTree as ET
import zlib
from typing import Any
from vibesketch.render import RenderRefused, RenderUnavailable
from vibesketch.render.fonts import FONT, NS, check_text
MAX_PIXELS = 40_000_000
INSTALL = "Install the publication extra: pip install 'vibesketch[publication]'"
[docs]
def dimensions(svg: bytes, dpi: int) -> tuple[float, float, int, int]:
if isinstance(dpi, bool) or not isinstance(dpi, int) or not 36 <= dpi <= 2400:
raise RenderRefused("PNG DPI must be an integer from 36 to 2400")
root = ET.fromstring(svg)
width, height = (float(root.attrib[key].removesuffix("pt")) for key in ("width", "height"))
if not all(math.isfinite(v) and 0 < v <= 14400 for v in (width, height)):
raise RenderRefused("publication dimensions must be positive and at most 14400 pt")
pixels = (max(1, math.ceil(width * dpi / 72)), max(1, math.ceil(height * dpi / 72)))
if pixels[0] * pixels[1] > MAX_PIXELS:
raise RenderRefused("PNG exceeds the 40 million pixel limit; lower DPI or split the figure")
return width, height, *pixels
def _png_density(png: bytes, dpi: int) -> bytes:
"""Store print resolution in the standard PNG pHYs chunk (metres)."""
body = b"pHYs" + struct.pack(">IIB", round(dpi / 0.0254), round(dpi / 0.0254), 1)
chunk = struct.pack(">I", 9) + body + struct.pack(">I", zlib.crc32(body))
offset = 8
chunks = []
while offset < len(png):
length = struct.unpack_from(">I", png, offset)[0] + 12
part = png[offset : offset + length]
if part[4:8] != b"pHYs":
chunks.append(part)
offset += length
return png[:8] + chunks[0] + chunk + b"".join(chunks[1:])
[docs]
def convert(svg: bytes, fmt: str, *, dpi: int = 300) -> bytes:
width, height, _, _ = dimensions(svg, dpi if fmt == "png" else 72)
check_text(svg)
# Native parsers receive only generated SVG, with no external resources or system fonts.
root = ET.fromstring(svg)
for node in list(root):
if node.tag == f"{NS}defs":
root.remove(node) # Font is loaded explicitly; converters ignore CSS font-face.
ET.register_namespace("", NS[1:-1])
try:
if fmt == "png":
import resvg_py
content = resvg_py.svg_to_bytes(
svg_string=ET.tostring(root, encoding="unicode"),
dpi=dpi,
background="white",
skip_system_fonts=True,
font_files=[str(FONT)],
font_family="Roboto",
sans_serif_family="Roboto",
)
return _png_density(bytes(content), dpi)
import svg2pdf_py
# svg2pdf-py treats CSS pixels as PDF points: strip only root pt units,
# preserving viewBox and all drawing coordinates so physical dimensions agree.
root.set("width", f"{width:g}")
root.set("height", f"{height:g}")
database: Any = svg2pdf_py.FontDatabase()
database.load_font_file(str(FONT))
options = svg2pdf_py.ConversionOptions(embed_text=True)
return bytes(
svg2pdf_py.svg_to_pdf(ET.tostring(root, encoding="unicode"), database, options)
)
except ImportError as error:
raise RenderUnavailable(
f"{fmt.upper()} publication export is unavailable. {INSTALL}"
) from error
except (ValueError, RuntimeError, OSError) as error:
raise RenderRefused(f"{fmt.upper()} publication conversion failed: {error}") from error