# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at https://mozilla.org/MPL/2.0/.
#
# Copyright (c) 2026 Jan Hettenkofer
r"""A minimal, dependency-free PDF writer for 1:1 printable templates (PRD 8.2).
Why this exists at all:
Fusion's PDF export is licence-gated, and that gate has already moved once (PRD 8.1).
The add-in therefore authors its own file. A vector-only PDF with a single content
stream of path operators is a few hundred lines, so this is cheaper than owning a
vendored dependency across three platforms — and it removes the licence question
entirely rather than betting on it (ADR-013).
The other half of that bargain is not optional: a hand-written format **must** be
differentially tested against an independent implementation (CLAUDE.md section 8, T14).
``tests/unit/test_pdf_export.py`` parses every output with ``pypdf`` and checks the
page geometry it reports, so a malformed file fails CI rather than a printer.
Units:
The public API takes **millimeters** with the origin at the bottom-left of the page,
which is PDF's own orientation and avoids a flip nobody would remember. Internally
everything converts once, through :data:`MM_TO_POINTS`, into PDF user space of
1/72 inch (PRD 7.6 — the conversion happens in one place here, as it does at the
Fusion boundary).
Determinism (ADR-012, T10):
Output is byte-identical for identical input. There are no timestamps, no object
ordering that depends on dict iteration, and no floating-point text that varies with
platform: every number goes through the module's ``_format_number``, which emits a
fixed decimal form. ``created`` is the single variable field, is optional, and is omitted
entirely unless the caller passes one — so the default output is stable and the golden
file needs no masking.
Scope:
Straight-line paths, rectangles and base-14 Helvetica text. Curves are dense polylines
rather than Béziers, which PRD 8.2 chooses deliberately: simpler, larger, and equally
correct at print resolution. No font embedding, no images, no transparency, no
compression — a template is a few thousand line segments and the file stays small.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from mighty_miter.core.errors import ExportError
__all__ = [
"MM_TO_POINTS",
"PAGE_SIZES_MM",
"PdfCanvas",
]
#: PDF user space is 1/72 inch, so one millimeter is ``72 / 25.4`` units. This is the only
#: place the conversion appears (PRD 7.6).
MM_TO_POINTS = 72.0 / 25.4
#: Named page sizes in millimeters, ``(width, height)`` portrait.
#:
#: A4 is 210 x 297 mm exactly; US Letter is 8.5 x 11 inches, which is 215.9 x 279.4 mm
#: exactly, not a rounded figure. PRD 11.x measured the A4 MediaBox as exact against
#: ``pypdf``, so these values are the ones that were checked.
PAGE_SIZES_MM: dict[str, tuple[float, float]] = {
"A4": (210.0, 297.0),
"A3": (297.0, 420.0),
"LETTER": (215.9, 279.4),
}
#: Decimal places used for every number in the content stream.
#:
#: Four decimals of a PDF point is 72/25.4 * 1e-4 mm, about **0.35 micrometers** — three
#: orders below FR-4.1's 0.01 mm template budget, and far below what any printer resolves
#: (a 1200 dpi device has a 21 um dot). Fixing the precision is also what makes the output
#: byte-deterministic: ``repr(float)`` varies in its last digit across platforms, which
#: would break the golden-file comparison (T10) for no geometric reason.
_DECIMALS = 4
def _format_number(value: float) -> str:
"""Format a number for a PDF content stream, deterministically.
Emits a fixed-point decimal with :data:`_DECIMALS` places, trailing zeros and any
trailing point removed, and negative zero normalized to ``"0"``.
Args:
value: The number, in PDF points.
Returns:
The shortest exact decimal representation at the fixed precision.
Raises:
ExportError: If the value is not finite. A NaN or infinity reaching the content
stream would produce a file that opens but draws nothing, which is worse than
failing.
Example:
>>> _format_number(1.0)
'1'
>>> _format_number(-0.0)
'0'
>>> _format_number(12.34567)
'12.3457'
"""
if value != value or value in (float("inf"), float("-inf")):
raise ExportError(
f"cannot write a non-finite coordinate to a PDF: {value!r}",
remediation=(
"Check the template geometry for a degenerate point before exporting; "
"a NaN usually means a zero-length normalization upstream."
),
)
text = f"{value:.{_DECIMALS}f}"
if "." in text:
text = text.rstrip("0").rstrip(".")
return "0" if text in ("", "-0", "-") else text
def _escape_text(text: str) -> str:
r"""Escape a string for a PDF literal string object (PRD 13.3 T15).
Backslashes and the two parenthesis characters must be escaped inside ``(...)``.
Everything above ASCII is emitted as a ``\ooo`` **octal escape** of its CP1252 byte,
which keeps the content stream itself pure ASCII while the viewer decodes it through
the ``/WinAnsiEncoding`` this writer declares on its font.
Why octal rather than refusal:
The previous version rejected every non-ASCII character with the message *"the
base-14 Helvetica encoding cannot represent"* them. **That was factually wrong.**
``/WinAnsiEncoding`` is CP1252, which represents ``Ø``, ``ä``, ``ö``, ``ü``,
``é``, ``å`` and the rest of Latin-1 perfectly well; it was the *escaping* that
was ASCII-only, not the encoding. Refusing them also diverged from T15, which
requires non-ASCII annotation text to **round-trip**, and the failure landed at
the worst possible moment: :mod:`mighty_miter.ui.command` passes Fusion's
``document.name`` straight through, so a design saved as ``Rahmen Ø28.f3d`` --
the normal case for a German, Nordic or French speaker -- failed the export
*after* the user had chosen the edge, the face, the name and the file location.
What is still refused, and why that is honest:
A character CP1252 genuinely has no byte for -- ``ł``, ``ř``, Greek, Cyrillic,
CJK, an em-dash's neighbours outside the CP1252 additions. Representing those
needs an embedded font with a ``/ToUnicode`` map, which is a different feature
(PRD 8.2 keeps this writer to the base-14 fonts). The error names the characters
and says what to do, rather than claiming an encoding limit that does not exist.
T15's "characters outside Latin-1" clause is therefore **partially met**: the
Latin-1 and CP1252 range round-trips, the rest is refused with an accurate
message. Recorded in ``CHANGELOG.md``.
Args:
text: The string to escape.
Returns:
The escaped string, pure ASCII, safe to place between parentheses.
Raises:
ExportError: If the string contains a character CP1252 cannot encode.
Example:
>>> _escape_text("100.0 x 50.0 mm (measure me)")
'100.0 x 50.0 mm \\(measure me\\)'
>>> _escape_text("Rahmen \u00d828") # CP1252 byte 0xD8, octal 330
'Rahmen \\33028'
>>> _escape_text("Gr\u00f6\u00dfe")
'Gr\\366\\337e'
"""
unencodable = sorted({ch for ch in text if not _is_cp1252(ch)})
if unencodable:
raise ExportError(
f"template text contains {unencodable!r}, which the WinAnsi (CP1252) "
"encoding of the base-14 Helvetica font has no byte for",
remediation=(
"Use characters from the Latin-1 / Windows-1252 range, or transliterate "
"the annotation before export. Accented Latin letters, umlauts and the "
"Scandinavian letters are all supported."
),
)
out = []
for char in text:
if char in "\\()":
out.append("\\" + char)
elif char.isascii():
out.append(char)
else:
# The stream is written as ASCII, so a high byte has to travel as an octal
# escape. Three digits always, so a following literal digit cannot be
# swallowed into the escape: "\3308" would otherwise be ambiguous.
out.append(f"\\{ord(char.encode('cp1252')):03o}")
return "".join(out)
def _is_cp1252(char: str) -> bool:
"""Return whether one character has a byte in the WinAnsi (CP1252) encoding.
Args:
char: A single character.
Returns:
``True`` if it can be encoded, ``False`` otherwise. Note that CP1252 leaves five
byte values undefined, so this is a genuine test rather than a range check.
Example:
>>> _is_cp1252("\u00f6"), _is_cp1252("\u20ac"), _is_cp1252("\u0142")
(True, True, False)
"""
try:
char.encode("cp1252")
except UnicodeEncodeError:
return False
return True
[docs]
@dataclass
class PdfCanvas:
"""Accumulates drawing operations and renders a single-page PDF (PRD 8.2).
Coordinates are in **millimeters** with the origin at the bottom-left of the page.
Nothing is drawn until :meth:`to_bytes` is called, and the canvas may be rendered more
than once — rendering does not consume it.
Attributes:
width_mm: Page width in millimeters.
height_mm: Page height in millimeters.
Raises:
ExportError: If either page dimension is not strictly positive.
Example:
>>> canvas = PdfCanvas(210.0, 297.0)
>>> canvas.polyline([(10.0, 10.0), (100.0, 10.0)], width_mm=0.35)
>>> canvas.to_bytes(title="demo").startswith(b"%PDF-1.4")
True
"""
width_mm: float
height_mm: float
_operations: list[str] = field(default_factory=list, repr=False)
def __post_init__(self) -> None:
"""Reject a degenerate page.
Raises:
ExportError: If width or height is not strictly positive.
"""
if not (self.width_mm > 0.0 and self.height_mm > 0.0):
raise ExportError(
"page dimensions must be positive; got "
f"{self.width_mm!r} x {self.height_mm!r} mm",
remediation="Choose a page size from PAGE_SIZES_MM, or pass positive "
"dimensions in millimeters.",
)
[docs]
@classmethod
def of_size(cls, name: str, *, landscape: bool = False) -> PdfCanvas:
"""Build a canvas for a named page size.
Args:
name: A key of :data:`PAGE_SIZES_MM`, case-insensitive.
landscape: Swap width and height.
Returns:
A new, empty canvas.
Raises:
ExportError: If the name is not a known page size.
Example:
>>> PdfCanvas.of_size("a4").width_mm
210.0
>>> PdfCanvas.of_size("a4", landscape=True).width_mm
297.0
"""
try:
width, height = PAGE_SIZES_MM[name.upper()]
except KeyError:
raise ExportError(
f"unknown page size {name!r}",
remediation=(
"Use one of " + ", ".join(sorted(PAGE_SIZES_MM)) + ", or construct "
"PdfCanvas with explicit millimeter dimensions."
),
) from None
return cls(height, width) if landscape else cls(width, height)
[docs]
def polyline(
self,
points: list[tuple[float, float]] | tuple[tuple[float, float], ...],
*,
width_mm: float,
dash_mm: tuple[float, float] | None = None,
) -> None:
"""Stroke an open polyline.
Args:
points: At least two ``(x, y)`` pairs in millimeters.
width_mm: Stroke width in millimeters. PRD 8.2 fixes the weights: miter curve
0.35, datum 0.5, ticks 0.18, dashed overlap 0.25.
dash_mm: ``(on, off)`` dash pattern in millimeters, or ``None`` for solid.
Raises:
ExportError: If fewer than two points are given, or a coordinate is
non-finite.
"""
if len(points) < 2:
raise ExportError(
f"a polyline needs at least two points; got {len(points)}",
remediation="Check that the curve survived stroking and unrolling.",
)
self._operations.append("q")
self._apply_stroke(width_mm, dash_mm)
first_x, first_y = points[0]
self._operations.append(f"{self._pt(first_x)} {self._pt(first_y)} m")
for x, y in points[1:]:
self._operations.append(f"{self._pt(x)} {self._pt(y)} l")
self._operations.append("S")
self._operations.append("Q")
[docs]
def rectangle(
self,
x_mm: float,
y_mm: float,
width_mm_size: float,
height_mm_size: float,
*,
width_mm: float,
) -> None:
"""Stroke an axis-aligned rectangle.
Used for the calibration rectangle (FR-6.6), which is what makes 1:1 *verifiable*
rather than merely intended — printer drivers scale silently (PRD 8.4).
Args:
x_mm: Left edge in millimeters.
y_mm: Bottom edge in millimeters.
width_mm_size: Rectangle width in millimeters.
height_mm_size: Rectangle height in millimeters.
width_mm: Stroke width in millimeters.
Raises:
ExportError: If the rectangle has non-positive extent.
"""
if not (width_mm_size > 0.0 and height_mm_size > 0.0):
raise ExportError(
"rectangle extent must be positive; got "
f"{width_mm_size!r} x {height_mm_size!r} mm",
remediation="Pass positive width and height in millimeters.",
)
self._operations.append("q")
self._apply_stroke(width_mm, None)
self._operations.append(
f"{self._pt(x_mm)} {self._pt(y_mm)} "
f"{self._pt(width_mm_size)} {self._pt(height_mm_size)} re"
)
self._operations.append("S")
self._operations.append("Q")
[docs]
def text(self, x_mm: float, y_mm: float, content: str, *, size_pt: float) -> None:
"""Draw a single line of Helvetica text.
Args:
x_mm: Left edge of the baseline, in millimeters.
y_mm: Baseline height, in millimeters.
content: Text in the WinAnsi (CP1252) range -- ASCII plus Latin-1 and the
CP1252 additions. Newlines are not interpreted; call once per line.
size_pt: Font size in **points**, not millimeters — type is specified in
points everywhere else in the world, and converting would surprise.
Raises:
ExportError: If the text contains a character CP1252 cannot encode, is
empty, or the size is not positive.
"""
if not content:
raise ExportError(
"refusing to draw empty text",
remediation="Omit the annotation instead of drawing an empty string.",
)
if not size_pt > 0.0:
raise ExportError(
f"font size must be positive; got {size_pt!r} pt",
remediation="Pass a positive font size in points.",
)
self._operations.append("BT")
self._operations.append(f"/F1 {_format_number(size_pt)} Tf")
self._operations.append(f"{self._pt(x_mm)} {self._pt(y_mm)} Td")
self._operations.append(f"({_escape_text(content)}) Tj")
self._operations.append("ET")
def _apply_stroke(self, width_mm: float, dash_mm: tuple[float, float] | None) -> None:
"""Emit the line-width and dash operators for a stroked path.
Args:
width_mm: Stroke width in millimeters. Must be positive: a zero width means
"thinnest the device can draw" in PDF, which is device-dependent and so
not reproducible on paper.
dash_mm: ``(on, off)`` in millimeters, or ``None`` for solid.
Raises:
ExportError: If the width is not positive or a dash element is negative.
"""
if not width_mm > 0.0:
raise ExportError(
f"stroke width must be positive; got {width_mm!r} mm",
remediation=(
"Use the PRD 8.2 weights: 0.35 mm miter curve, 0.5 mm datum, "
"0.18 mm ticks, 0.25 mm dashed overlap. A zero width renders as the "
"device's thinnest line, which differs between printers."
),
)
self._operations.append(f"{self._pt(width_mm)} w")
if dash_mm is not None:
on, off = dash_mm
if on <= 0.0 or off < 0.0:
raise ExportError(
f"dash pattern must have positive on and non-negative off; got {dash_mm!r}",
remediation="Pass something like (2.0, 1.0) millimeters.",
)
self._operations.append(f"[{self._pt(on)} {self._pt(off)}] 0 d")
@staticmethod
def _pt(value_mm: float) -> str:
"""Convert millimeters to a formatted PDF point value.
Args:
value_mm: A length in millimeters.
Returns:
The value in points, formatted deterministically.
"""
return _format_number(value_mm * MM_TO_POINTS)
[docs]
def to_bytes(self, *, title: str, created: str | None = None) -> bytes:
"""Render the canvas to a complete PDF 1.4 file.
Args:
title: Document title, stored in the Info dictionary. WinAnsi (CP1252)
range; see the ``_escape_text`` notes in this module.
created: Optional PDF date string, e.g. ``"D:20260905T120000Z"``. **The only
variable field in the output** (ADR-012). Omitted entirely when ``None``,
which is the default and is what makes the output byte-deterministic
without masking.
Returns:
The complete file.
Raises:
ExportError: If nothing has been drawn, or the title contains a character
CP1252 cannot encode.
Note:
The page carries **no scaling metadata**. PRD 8.2 requires this: any hint a
viewer could interpret as "fit to page" defeats 1:1, so the instruction to
disable scaling is printed on the template itself instead (PRD 8.4).
Example:
>>> canvas = PdfCanvas(210.0, 297.0)
>>> canvas.rectangle(10.0, 10.0, 100.0, 50.0, width_mm=0.35)
>>> pdf = canvas.to_bytes(title="calibration")
>>> pdf.startswith(b"%PDF-1.4") and pdf.rstrip().endswith(b"%%EOF")
True
>>> canvas.to_bytes(title="calibration") == pdf # deterministic
True
"""
if not self._operations:
raise ExportError(
"refusing to write an empty PDF",
remediation="Draw the miter curve before exporting the template.",
)
content = "\n".join(self._operations).encode("ascii")
width_pt = _format_number(self.width_mm * MM_TO_POINTS)
height_pt = _format_number(self.height_mm * MM_TO_POINTS)
info = f"/Title ({_escape_text(title)}) /Producer (Mighty Miter)"
if created is not None:
info += f" /CreationDate ({_escape_text(created)})"
objects: list[bytes] = [
b"<< /Type /Catalog /Pages 2 0 R >>",
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
(
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {width_pt} {height_pt}] "
f"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
).encode("ascii"),
b"<< /Length "
+ str(len(content)).encode("ascii")
+ b" >>\nstream\n"
+ content
+ b"\nendstream",
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica "
b"/Encoding /WinAnsiEncoding >>",
info.encode("ascii").join((b"<< ", b" >>")),
]
# The header's second line is a comment of high-bit bytes. It is required by the
# spec's own advice so that naive tools transferring the file in text mode detect
# it as binary and stop mangling line endings.
out = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
offsets: list[int] = []
for number, body in enumerate(objects, start=1):
offsets.append(len(out))
out += f"{number} 0 obj\n".encode("ascii") + body + b"\nendobj\n"
xref_offset = len(out)
count = len(objects) + 1
out += f"xref\n0 {count}\n".encode("ascii")
out += b"0000000000 65535 f \n"
for offset in offsets:
out += f"{offset:010d} 00000 n \n".encode("ascii")
out += (
f"trailer\n<< /Size {count} /Root 1 0 R /Info {len(objects)} 0 R >>\n"
f"startxref\n{xref_offset}\n%%EOF\n"
).encode("ascii")
return bytes(out)