cardreport 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cardreport/__init__.py ADDED
@@ -0,0 +1,94 @@
1
+ """Compose static reports using a small renderer-independent API."""
2
+
3
+ from ._inputs import BindingError, DataError, TemplateError
4
+ from .data import (
5
+ DataLimits,
6
+ Dataset,
7
+ DatasetSchema,
8
+ FieldSpec,
9
+ FieldType,
10
+ datasets_from_json,
11
+ datasets_to_json,
12
+ )
13
+ from .document import ChartData, ChartSeries, ReportDocument
14
+ from .model import (
15
+ KPI,
16
+ BarChart,
17
+ Block,
18
+ Card,
19
+ CardContent,
20
+ Column,
21
+ Component,
22
+ Currency,
23
+ DependencyError,
24
+ Format,
25
+ Funnel,
26
+ LayoutOverflowError,
27
+ LineChart,
28
+ Number,
29
+ Page,
30
+ PageBreak,
31
+ RenderError,
32
+ RenderTimeoutError,
33
+ Report,
34
+ ReportError,
35
+ Row,
36
+ Table,
37
+ TableColumn,
38
+ Text,
39
+ Theme,
40
+ UnsupportedFeatureError,
41
+ ValidationError,
42
+ Value,
43
+ )
44
+ from .renderers import ChromiumRenderer, Renderer
45
+ from .templates import ReportTemplate
46
+ from .weasyprint import WeasyPrintRenderer
47
+
48
+ __all__ = [
49
+ "ReportTemplate",
50
+ "BindingError",
51
+ "DataError",
52
+ "TemplateError",
53
+ "DataLimits",
54
+ "Dataset",
55
+ "DatasetSchema",
56
+ "FieldSpec",
57
+ "FieldType",
58
+ "datasets_from_json",
59
+ "datasets_to_json",
60
+ "WeasyPrintRenderer",
61
+ "ReportDocument",
62
+ "ChartData",
63
+ "ChartSeries",
64
+ "BarChart",
65
+ "Block",
66
+ "CardContent",
67
+ "Component",
68
+ "Format",
69
+ "Number",
70
+ "Value",
71
+ "UnsupportedFeatureError",
72
+ "ChromiumRenderer",
73
+ "Renderer",
74
+ "Card",
75
+ "Column",
76
+ "Currency",
77
+ "DependencyError",
78
+ "Funnel",
79
+ "KPI",
80
+ "LayoutOverflowError",
81
+ "LineChart",
82
+ "Page",
83
+ "PageBreak",
84
+ "RenderError",
85
+ "RenderTimeoutError",
86
+ "Report",
87
+ "ReportError",
88
+ "Row",
89
+ "Table",
90
+ "TableColumn",
91
+ "Text",
92
+ "Theme",
93
+ "ValidationError",
94
+ ]
cardreport/_inputs.py ADDED
@@ -0,0 +1,158 @@
1
+ """Small bounded JSON and scalar parsing helpers for the interchange layer."""
2
+
3
+ import json
4
+ import re
5
+ from collections.abc import Mapping
6
+ from datetime import date, datetime
7
+ from decimal import Decimal, InvalidOperation
8
+ from typing import cast
9
+
10
+ from .model import ReportError, ValidationError
11
+
12
+
13
+ class LocatedError(Exception):
14
+ dataset: str | None
15
+ row: int | None
16
+ field: str | None
17
+ template_path: str | None
18
+
19
+ def __init__(
20
+ self,
21
+ message: str,
22
+ *,
23
+ dataset: str | None = None,
24
+ row: int | None = None,
25
+ field: str | None = None,
26
+ template_path: str | None = None,
27
+ ) -> None:
28
+ self.dataset, self.row, self.field = dataset, row, field
29
+ self.template_path = template_path
30
+ super().__init__(message)
31
+
32
+
33
+ class DataError(LocatedError, ReportError):
34
+ """Invalid dataset, data transport or adapter input."""
35
+
36
+
37
+ class TemplateError(LocatedError, ValidationError):
38
+ """Invalid presentation definition."""
39
+
40
+
41
+ class BindingError(LocatedError, ValidationError):
42
+ """Data does not satisfy a template's contract."""
43
+
44
+
45
+ def mapping(value: object) -> dict[str, object]:
46
+ if not isinstance(value, Mapping):
47
+ raise ValueError("expected an object")
48
+ result: dict[str, object] = {}
49
+ for key, item in cast(Mapping[object, object], value).items():
50
+ if not isinstance(key, str):
51
+ raise ValueError("object keys must be strings")
52
+ result[key] = item
53
+ return result
54
+
55
+
56
+ def array(value: object) -> list[object]:
57
+ if not isinstance(value, (list, tuple)):
58
+ raise ValueError("expected an array")
59
+ return list(cast(list[object] | tuple[object, ...], value))
60
+
61
+
62
+ def keys(obj: Mapping[str, object], required: set[str], optional: set[str]) -> None:
63
+ if not required <= obj.keys() or obj.keys() - required - optional:
64
+ raise ValueError("missing required or unknown object keys")
65
+
66
+
67
+ def name(value: object) -> str:
68
+ if not isinstance(value, str) or not 1 <= len(value) <= 100:
69
+ raise ValueError("expected a nonempty name up to 100 characters")
70
+ try:
71
+ value.encode("utf-8")
72
+ except UnicodeEncodeError as exc:
73
+ raise ValueError("name must be valid Unicode") from exc
74
+ return value
75
+
76
+
77
+ def _pairs(pairs: list[tuple[str, object]]) -> dict[str, object]:
78
+ result: dict[str, object] = {}
79
+ for key, value in pairs:
80
+ if key in result:
81
+ raise ValueError("duplicate JSON key")
82
+ result[key] = value
83
+ return result
84
+
85
+
86
+ def _constant(value: str) -> object:
87
+ raise ValueError("nonfinite JSON number")
88
+
89
+
90
+ def load_json(source: str | bytes, max_bytes: int) -> object:
91
+ if not isinstance(source, (str, bytes)):
92
+ raise ValueError("expected JSON text or bytes")
93
+ if len(source) > max_bytes:
94
+ raise ValueError("JSON byte limit exceeded")
95
+ if isinstance(source, str) and len(source.encode("utf-8")) > max_bytes:
96
+ raise ValueError("JSON byte limit exceeded")
97
+ if isinstance(source, bytes):
98
+ source = source.decode("utf-8")
99
+ result: object = json.loads(
100
+ source, object_pairs_hook=_pairs, parse_constant=_constant
101
+ )
102
+ check_tree(result)
103
+ return result
104
+
105
+
106
+ def check_tree(value: object) -> None:
107
+ # Iterative traversal bounds native trees too, including cycles.
108
+ stack = [(value, 0)]
109
+ count = 0
110
+ while stack:
111
+ item, depth = stack.pop()
112
+ count += 1
113
+ if depth > 64 or count > 500_000:
114
+ raise ValueError("input tree depth/node limit exceeded")
115
+ if isinstance(item, Mapping):
116
+ stack.extend((v, depth + 1) for v in mapping(item).values())
117
+ elif isinstance(item, (list, tuple)):
118
+ stack.extend((v, depth + 1) for v in array(item))
119
+ elif type(item) not in (str, int, float, bool, type(None)):
120
+ raise ValueError("expected JSON-compatible input")
121
+
122
+
123
+ def plain_tree(value: object) -> object:
124
+ """Normalize mappings after check_tree has bounded depth and rejected cycles."""
125
+ if isinstance(value, Mapping):
126
+ return {key: plain_tree(item) for key, item in mapping(value).items()}
127
+ if isinstance(value, (list, tuple)):
128
+ return [plain_tree(item) for item in array(value)]
129
+ return value
130
+
131
+
132
+ INTEGER = re.compile(r"[+-]?[0-9]+\Z")
133
+ DECIMAL = re.compile(r"[+-]?(?:[0-9]+(?:\.[0-9]+)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\Z")
134
+ DATE = re.compile(r"[0-9]{4}-[0-9]{2}-[0-9]{2}\Z")
135
+ DATETIME = re.compile(
136
+ r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}"
137
+ r"(?:\.[0-9]{1,6})?(?:Z|[+-][0-9]{2}:[0-9]{2})\Z"
138
+ )
139
+
140
+
141
+ def parse_scalar(text: str, kind: str) -> str | int | Decimal | date:
142
+ if kind == "text":
143
+ return text
144
+ if kind == "integer" and INTEGER.fullmatch(text):
145
+ return int(text)
146
+ if kind in ("number", "decimal") and DECIMAL.fullmatch(text):
147
+ try:
148
+ return Decimal(text)
149
+ except InvalidOperation:
150
+ pass
151
+ if kind == "date" and DATE.fullmatch(text):
152
+ return date.fromisoformat(text)
153
+ if kind == "datetime" and DATETIME.fullmatch(text):
154
+ # fromisoformat accepts out-of-range offset minutes by normalizing them.
155
+ if text[-1] != "Z" and (int(text[-5:-3]) > 23 or int(text[-2:]) > 59):
156
+ raise ValueError("invalid datetime offset")
157
+ return datetime.fromisoformat(text)
158
+ raise ValueError(f"expected {kind} in the documented lexical format")
@@ -0,0 +1,82 @@
1
+ """Inspect all PDF resource scopes, including nested SVG form resources."""
2
+
3
+ from collections.abc import Iterator
4
+ from importlib.resources import files
5
+ from io import BytesIO
6
+ from typing import Any
7
+
8
+ from fontTools.ttLib import TTFont
9
+ from pypdf import PdfReader
10
+
11
+ from .model import LayoutOverflowError, RenderError
12
+
13
+
14
+ def resource_scopes(resources: Any, seen: set[int] | None = None) -> Iterator[Any]:
15
+ seen = set() if seen is None else seen
16
+ resources = resources.get_object()
17
+ if id(resources) in seen:
18
+ return
19
+ seen.add(id(resources))
20
+ yield resources
21
+ if "/XObject" in resources:
22
+ for ref in resources["/XObject"].get_object().values():
23
+ obj = ref.get_object()
24
+ if "/Resources" in obj:
25
+ yield from resource_scopes(obj["/Resources"], seen)
26
+
27
+
28
+ def font_identity(data: bytes) -> frozenset[tuple[int, str]]:
29
+ with TTFont(BytesIO(data)) as font:
30
+ return frozenset(
31
+ (n.nameID, n.toUnicode())
32
+ for n in font["name"].names
33
+ if n.nameID in (1, 3, 5)
34
+ )
35
+
36
+
37
+ def verify_fonts(reader: PdfReader) -> None:
38
+ expected = {
39
+ font_identity(files("cardreport").joinpath("assets", name).read_bytes())
40
+ for name in ("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")
41
+ }
42
+ for page in reader.pages:
43
+ for resources in resource_scopes(page["/Resources"]):
44
+ if "/Font" not in resources:
45
+ continue
46
+ for ref in resources["/Font"].get_object().values():
47
+ font = ref.get_object()
48
+ descendants = (
49
+ font["/DescendantFonts"] if "/DescendantFonts" in font else [font]
50
+ )
51
+ for descendant in descendants:
52
+ descriptor = descendant.get_object().get("/FontDescriptor")
53
+ if (
54
+ descriptor is None
55
+ or "/FontFile2" not in descriptor.get_object()
56
+ ):
57
+ raise RenderError("Expected embedded bundled font")
58
+ embedded = descriptor.get_object()["/FontFile2"].get_data()
59
+ if font_identity(embedded) not in expected:
60
+ raise RenderError("Unexpected font identity or version")
61
+
62
+
63
+ def verify_chart_bounds(
64
+ data: bytes, width_px: float, height_px: float, path: str
65
+ ) -> None:
66
+ """Validate glyph bounds in the native SVG render, in PDF points."""
67
+ import pdfplumber
68
+
69
+ with pdfplumber.open(BytesIO(data)) as pdf:
70
+ if len(pdf.pages) != 1 or not pdf.pages[0].chars:
71
+ raise RenderError(f"{path}: native SVG text was lost")
72
+ width, height = width_px * 0.75, height_px * 0.75
73
+ for char in pdf.pages[0].chars:
74
+ if (
75
+ char["x0"] < 0
76
+ or char["top"] < 0
77
+ or char["x1"] > width
78
+ or char["bottom"] > height
79
+ ):
80
+ raise LayoutOverflowError(
81
+ f"{path}: native chart label exceeds available space"
82
+ )
@@ -0,0 +1,129 @@
1
+ """Experimental SVG bars, using Matplotlib for axes and text measurement."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ from io import StringIO
7
+ from typing import Any
8
+ from xml.etree import ElementTree as ET
9
+
10
+ from .model import Currency, Format, LayoutOverflowError, format_value
11
+
12
+
13
+ def render_svg(spec: dict[str, Any], width: float, height: float, path: str) -> str:
14
+ from matplotlib import rc_context, rcdefaults, rcParams
15
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
16
+ from matplotlib.figure import Figure
17
+ from matplotlib.font_manager import FontProperties
18
+ from matplotlib.layout_engine import ConstrainedLayoutEngine
19
+ from matplotlib.text import Text
20
+ from matplotlib.ticker import FuncFormatter, MaxNLocator
21
+
22
+ if width < 40 or height < 35 * 96 / 25.4 - 1:
23
+ raise LayoutOverflowError(f"{path}: chart has insufficient allocated space")
24
+ font = FontProperties(
25
+ fname=str(files("cardreport").joinpath("assets", "DejaVuSans.ttf")),
26
+ size=spec["font_size"],
27
+ )
28
+ fmt: Format = (
29
+ Currency(spec["format"]) if len(spec["format"]) == 3 else spec["format"]
30
+ )
31
+ with rc_context():
32
+ rcdefaults()
33
+ rcParams.update(
34
+ {
35
+ "svg.fonttype": "none",
36
+ "text.usetex": False,
37
+ "text.parse_math": False,
38
+ "font.size": spec["font_size"],
39
+ }
40
+ )
41
+ fig = Figure(figsize=(width / 96, height / 96), dpi=96, layout="constrained")
42
+ layout = fig.get_layout_engine()
43
+ assert isinstance(layout, ConstrainedLayoutEngine)
44
+ layout.set(w_pad=12 / 96, h_pad=8 / 96)
45
+ canvas = FigureCanvasAgg(fig)
46
+ ax = fig.subplots()
47
+ positions = list(range(len(spec["labels"])))
48
+ horizontal = spec["horizontal"]
49
+ if horizontal:
50
+ bars = ax.barh(positions, spec["values"], color=spec["color"])
51
+ ax.set_yticks(positions, spec["labels"])
52
+ ax.invert_yaxis()
53
+ ax.xaxis.set_major_formatter(
54
+ FuncFormatter(lambda v, _: format_value(v, fmt))
55
+ )
56
+ ax.margins(x=0.22)
57
+ else:
58
+ bars = ax.bar(positions, spec["values"], color=spec["color"])
59
+ ax.set_xticks(positions, spec["labels"])
60
+ ax.yaxis.set_major_formatter(
61
+ FuncFormatter(lambda v, _: format_value(v, fmt))
62
+ )
63
+ ax.margins(y=0.22)
64
+ if fmt == "integer":
65
+ (ax.xaxis if horizontal else ax.yaxis).set_major_locator(
66
+ MaxNLocator(integer=True)
67
+ )
68
+ labels = (
69
+ ax.bar_label(bars, labels=spec["value_labels"], padding=4)
70
+ if spec["show_labels"]
71
+ else []
72
+ )
73
+ ax.spines[["top", "right"]].set_visible(False)
74
+ ax.set_axisbelow(True)
75
+ ax.grid(axis="x" if horizontal else "y", alpha=0.2)
76
+ ax.set_facecolor("none")
77
+ fig.patch.set_alpha(0)
78
+ # Force tick creation before applying the exact bundled font and literal text.
79
+ canvas.draw() # type: ignore[no-untyped-call]
80
+ for label in fig.findobj(Text):
81
+ label.set_fontproperties(font)
82
+ label.set_parse_math(False)
83
+ label.set_color(spec["text_color"])
84
+ canvas.draw() # type: ignore[no-untyped-call]
85
+ renderer = canvas.get_renderer() # type: ignore[no-untyped-call]
86
+ area = ax.get_window_extent(renderer)
87
+ if area.width < 40 or area.height < 40:
88
+ raise LayoutOverflowError(
89
+ f"{path}: labels leave insufficient plotting area"
90
+ )
91
+ texts: list[Text] = list(labels)
92
+ for axis, limits in ((ax.xaxis, ax.get_xlim()), (ax.yaxis, ax.get_ylim())):
93
+ lo, hi = sorted(limits)
94
+ texts.extend(
95
+ t.label1 for t in axis.get_major_ticks() if lo <= t.get_loc() <= hi
96
+ )
97
+ boxes = []
98
+ for label in texts:
99
+ if not label.get_visible() or not label.get_text():
100
+ continue
101
+ b = label.get_window_extent(renderer)
102
+ if b.x0 < 2 or b.y0 < 2 or b.x1 > width - 2 or b.y1 > height - 2:
103
+ raise LayoutOverflowError(
104
+ f"{path}: chart label exceeds available space"
105
+ )
106
+ boxes.append(b)
107
+ for i, a in enumerate(boxes):
108
+ for b in boxes[i + 1 :]:
109
+ if (
110
+ min(a.x1, b.x1) - max(a.x0, b.x0) > 1
111
+ and min(a.y1, b.y1) - max(a.y0, b.y0) > 1
112
+ ):
113
+ raise LayoutOverflowError(f"{path}: chart labels overlap")
114
+ fig.set_layout_engine("none")
115
+ out = StringIO()
116
+ fig.savefig(out, format="svg")
117
+ svg = out.getvalue()
118
+ svg = svg[svg.index("<svg") :]
119
+ # Only style attributes are changed; literal user labels stay untouched.
120
+ root = ET.fromstring(svg)
121
+ namespace = "http://www.w3.org/2000/svg"
122
+ ET.register_namespace("", namespace)
123
+ ET.register_namespace("xlink", "http://www.w3.org/1999/xlink")
124
+ for element in root.iter():
125
+ if element.tag in (f"{{{namespace}}}text", f"{{{namespace}}}tspan"):
126
+ element.set(
127
+ "style", element.get("style", "") + ";font-family:CardReport"
128
+ )
129
+ return ET.tostring(root, encoding="unicode")
@@ -0,0 +1,109 @@
1
+ """The only adapter to WeasyPrint private boxes. Pinned and guarded by canaries."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from base64 import b64encode
6
+ from collections.abc import Iterator
7
+ from importlib.resources import files
8
+ from typing import Any
9
+
10
+ from .model import DependencyError, LayoutOverflowError
11
+
12
+ VERSION = "70.0"
13
+ TOLERANCE = 1.0 # CSS px; rounding only, never a text-fit heuristic.
14
+
15
+
16
+ def walk(box: Any) -> Iterator[Any]:
17
+ yield box
18
+ for child in getattr(box, "children", ()):
19
+ yield from walk(child)
20
+
21
+
22
+ def bounds(box: Any) -> tuple[float, float, float, float]:
23
+ x, y = float(box.position_x), float(box.position_y)
24
+ return x, y, x + float(box.margin_width()), y + float(box.margin_height())
25
+
26
+
27
+ def inspect(
28
+ document: Any, expected_pages: int, expected_paths: set[str] | None = None
29
+ ) -> dict[str, tuple[float, float]]:
30
+ """Check page/content bounds and fragmentation, return chart content sizes."""
31
+ if len(document.pages) != expected_pages:
32
+ raise LayoutOverflowError("report: dashboard content produced unexpected pages")
33
+ charts: dict[str, tuple[float, float]] = {}
34
+ locations: dict[str, set[int]] = {}
35
+ try:
36
+ for page_number, page in enumerate(document.pages):
37
+ root = page._page_box
38
+ for box in walk(root):
39
+ element = box.element
40
+ if element is None:
41
+ continue
42
+ path = element.get("data-path")
43
+ # Inline text/line boxes repeat their element; inspect the principal box.
44
+ if not path or type(box).__name__ in ("TextBox", "LineBox"):
45
+ continue
46
+ locations.setdefault(path, set()).add(page_number)
47
+ x, y = float(box.content_box_x()), float(box.content_box_y())
48
+ right, bottom = x + float(box.width), y + float(box.height)
49
+ if element.get("data-chart"):
50
+ charts[path] = float(box.width), float(box.height)
51
+ for child in walk(box):
52
+ if child is box:
53
+ continue
54
+ left, top, r, b = bounds(child)
55
+ if (
56
+ r > right + TOLERANCE
57
+ or b > bottom + TOLERANCE
58
+ or left < x - TOLERANCE
59
+ or top < y - TOLERANCE
60
+ ):
61
+ raise LayoutOverflowError(
62
+ f"{path}: content exceeds allocated width/height"
63
+ )
64
+ if expected_paths is not None and set(locations) != expected_paths:
65
+ raise LayoutOverflowError("report: components missing from measured layout")
66
+ if any(len(pages) > 1 for pages in locations.values()):
67
+ raise LayoutOverflowError(
68
+ "report: indivisible dashboard block was fragmented"
69
+ )
70
+ except (AttributeError, TypeError, ValueError) as exc:
71
+ raise DependencyError(
72
+ "WeasyPrint box adapter is incompatible with this runtime"
73
+ ) from exc
74
+ return charts
75
+
76
+
77
+ def verify_adapter(html_class: Any, font_config: Any, fetcher: Any) -> None:
78
+ """A native text-overflow canary must be rejected before trusting private boxes."""
79
+ import weasyprint
80
+
81
+ if weasyprint.__version__ != VERSION:
82
+ raise DependencyError(f"WeasyPrintRenderer requires WeasyPrint {VERSION}")
83
+ font = b64encode(
84
+ files("cardreport").joinpath("assets", "DejaVuSans.ttf").read_bytes()
85
+ ).decode()
86
+ css = f"@font-face{{font-family:Canary;src:url(data:font/ttf;base64,{font})}}@page{{size:200px 200px;margin:0}}body{{margin:0;font:12px Canary}}"
87
+ good = html_class(
88
+ string=f'<style>{css}</style><div data-path="canary" style="width:150px;height:50px">Fits</div>',
89
+ url_fetcher=fetcher,
90
+ ).render(font_config=font_config)
91
+ try:
92
+ inspect(good, 1)
93
+ except LayoutOverflowError as exc:
94
+ raise DependencyError("WeasyPrint detector rejected a fitting canary") from exc
95
+ for style, text in (
96
+ ("width:25px;height:8px", "word " * 30),
97
+ ("width:10px;height:80px;white-space:nowrap", "unbreakableword"),
98
+ ):
99
+ html = (
100
+ f'<style>{css}</style><div data-path="canary" style="{style}">{text}</div>'
101
+ )
102
+ doc = html_class(string=html, url_fetcher=fetcher).render(
103
+ font_config=font_config
104
+ )
105
+ try:
106
+ inspect(doc, 1)
107
+ except LayoutOverflowError:
108
+ continue
109
+ raise DependencyError("WeasyPrint overflow detector failed its canary")
@@ -0,0 +1,115 @@
1
+ """Owned WeasyPrint process; imports native dependencies only inside the worker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import json
7
+ import re
8
+ import sys
9
+ from base64 import b64encode
10
+ from importlib.resources import files
11
+ from typing import Any
12
+
13
+ from .model import DependencyError, RenderError, ReportError
14
+
15
+
16
+ def main() -> None:
17
+ try:
18
+ from weasyprint import HTML
19
+ from weasyprint.text.fonts import FontConfiguration
20
+ except (ImportError, OSError) as exc:
21
+ raise DependencyError(
22
+ "WeasyPrint native libraries unavailable; see README native-library setup"
23
+ ) from exc
24
+ from ._svg_charts import render_svg
25
+ from ._weasy_boxes import inspect, verify_adapter
26
+
27
+ payload = json.loads(sys.stdin.buffer.read())
28
+ fonts = {}
29
+ for name in ("DejaVuSans.ttf", "DejaVuSans-Bold.ttf"):
30
+ data = files("cardreport").joinpath("assets", name).read_bytes()
31
+ fonts["data:font/ttf;base64," + b64encode(data).decode()] = data
32
+
33
+ class BundledFonts:
34
+ _fail_on_errors = False # Record denial; fail explicitly after native layout.
35
+ blocked: bool = False
36
+
37
+ def __call__(self, url: str) -> Any:
38
+ from weasyprint.urls import URLFetcherResponse
39
+
40
+ if url not in fonts:
41
+ self.blocked = True
42
+ raise ValueError("External resource blocked")
43
+ return URLFetcherResponse(url, fonts[url], {"Content-Type": "font/ttf"})
44
+
45
+ fetch = BundledFonts()
46
+
47
+ config = FontConfiguration()
48
+ verify_adapter(HTML, config, fetch)
49
+ html = payload["html"]
50
+ expected_paths = set(re.findall(r'data-path="([^"]+)"', html))
51
+ first = HTML(string=html, url_fetcher=fetch).render(font_config=config)
52
+ if fetch.blocked:
53
+ raise RenderError("Unexpected external resource request")
54
+ sizes = inspect(first, payload["pages"], expected_paths)
55
+ if set(sizes) != set(payload["charts"]):
56
+ raise RenderError("Measured chart set differs from the input")
57
+ for path, spec in payload["charts"].items():
58
+ width, height = sizes[path]
59
+ svg = render_svg(spec, width, height, path)
60
+ # The measured CSS box is the SVG viewport; SVG viewBox retains its coordinates.
61
+ svg = svg.replace(
62
+ "<svg ", f'<svg style="width:{width}px;height:{height}px" ', 1
63
+ )
64
+ # Check actual Pango-drawn glyphs as well as Matplotlib measurements.
65
+ # A standalone viewport uses the same font bytes, SVG and CSS pixel size.
66
+ from ._pdf_checks import verify_chart_bounds
67
+
68
+ font_css = "".join(
69
+ f"@font-face{{font-family:CardReport;src:url({url});font-weight:{weight}}}"
70
+ for url, weight in zip(fonts, (400, 700), strict=True)
71
+ )
72
+ chart_html = f"<style>{font_css}@page{{size:{width}px {height}px;margin:0}}body{{margin:0}}svg{{display:block}}</style>{svg}"
73
+ chart_pdf = HTML(string=chart_html, url_fetcher=fetch).write_pdf(
74
+ font_config=config
75
+ )
76
+ verify_chart_bounds(chart_pdf, width, height, path)
77
+ html = html.replace(f"<!--chart:{path}-->", svg)
78
+ final = HTML(string=html, url_fetcher=fetch).render(font_config=config)
79
+ final_sizes = inspect(final, payload["pages"], expected_paths)
80
+ if set(final_sizes) != set(sizes) or any(
81
+ abs(a - b) > 1
82
+ for key in sizes
83
+ for a, b in zip(sizes[key], final_sizes[key], strict=True)
84
+ ):
85
+ raise RenderError("Chart geometry changed after SVG insertion")
86
+ if fetch.blocked:
87
+ raise RenderError("Unexpected external resource request")
88
+ if payload["pdf"]:
89
+ pdf_data: bytes = final.write_pdf()
90
+ from pypdf import PdfReader
91
+
92
+ reader = PdfReader(io.BytesIO(pdf_data))
93
+ if len(reader.pages) != payload["pages"]:
94
+ raise RenderError("PDF page count differs from measured layout")
95
+ from ._pdf_checks import verify_fonts
96
+
97
+ verify_fonts(reader)
98
+ sys.stdout.buffer.write(pdf_data)
99
+ else:
100
+ sys.stdout.buffer.write(html.encode("utf-8"))
101
+
102
+
103
+ if __name__ == "__main__":
104
+ try:
105
+ main()
106
+ except Exception as exc:
107
+ # This boundary converts native-library failures into the worker protocol.
108
+ kind = type(exc).__name__ if isinstance(exc, ReportError) else "RenderError"
109
+ message = (
110
+ str(exc)
111
+ if isinstance(exc, ReportError)
112
+ else f"WeasyPrint renderer failed ({type(exc).__name__})"
113
+ )
114
+ sys.stderr.write("\n" + json.dumps({"kind": kind, "message": message}) + "\n")
115
+ sys.exit(1)