Source code for vibesketch.render.publication

"""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