Source code for mighty_miter.core.export.pdf

# 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)