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 +94 -0
- cardreport/_inputs.py +158 -0
- cardreport/_pdf_checks.py +82 -0
- cardreport/_svg_charts.py +129 -0
- cardreport/_weasy_boxes.py +109 -0
- cardreport/_weasy_worker.py +115 -0
- cardreport/_worker.py +73 -0
- cardreport/adapters/__init__.py +1 -0
- cardreport/adapters/csv.py +84 -0
- cardreport/adapters/pandas.py +69 -0
- cardreport/assets/DejaVuSans-Bold.ttf +0 -0
- cardreport/assets/DejaVuSans.ttf +0 -0
- cardreport/assets/LICENSE_DEJAVU +99 -0
- cardreport/assets/layout.js +106 -0
- cardreport/assets/report.html +5 -0
- cardreport/assets/style.css.j2 +18 -0
- cardreport/charts.py +124 -0
- cardreport/data.py +379 -0
- cardreport/document.py +31 -0
- cardreport/html.py +185 -0
- cardreport/model.py +408 -0
- cardreport/output.py +25 -0
- cardreport/pdf.py +125 -0
- cardreport/py.typed +0 -0
- cardreport/renderers.py +43 -0
- cardreport/templates.py +535 -0
- cardreport/validation.py +311 -0
- cardreport/weasyprint.py +112 -0
- cardreport-0.2.1.dist-info/METADATA +92 -0
- cardreport-0.2.1.dist-info/RECORD +32 -0
- cardreport-0.2.1.dist-info/WHEEL +4 -0
- cardreport-0.2.1.dist-info/licenses/LICENSE +21 -0
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)
|