# A4 Free-Positioning Report Designer + Print / Save-as-PDF ## Context Some customers print on A4 laser printers rather than dot-matrix printers. The existing report designer is built entirely around an 80×66 **character grid** — elements snap to cells, frames and lines are drawn with Unicode box-drawing characters, coordinates are integer columns/rows. None of that maps onto an A4 page where a field must sit at an arbitrary millimetre position. A second discovery shapes this as much as the first: **the app cannot print at all today.** "Print Notice" in `Main.jsx` opens `ReportPreviewModal`, which renders the report as HTML in an on-screen modal. There is no `window.print()`, no print CSS, no PDF library anywhere. Confirmed by grep: zero matches for `@page`, `@media print`, `window.print` in `frontend/src`. So the output path is new code regardless. **Outcome:** a second designer where elements are positioned freely in millimetres, with alignment guides, that can be printed on A4 — and saved as a PDF when the operator wants a file. ### Decisions taken | Decision | Choice | Why | |---|---|---| | Positioning | Millimetres (floats), free | No grid; the mm typed is the mm printed | | Alignment aids | Smart guides on drag + page centre/margin snapping | Not ruler guides, not a background grid | | Bands | Kept, incl. multi-page overflow | Needed for list reports, not just one-page tickets | | Fonts | **Any font installed on the station** | See below | | Print | `window.print()` + `@page` CSS | Chrome renders with the machine's real fonts | | PDF | **Operator picks "Save as PDF"** in the same print dialog | A file, real fonts, zero dependencies | | Puppeteer | **Deferred**, socket left open | Only needed if code must make a PDF with no human | ### Why browser printing, not a PDF library The requirement is *"the operator gets a PDF when they want one."* Chrome's print dialog already has **Save as PDF** built in (as does Windows' "Microsoft Print to PDF"). Same `window.print()` call — the operator picks a different destination and gets a file. This matters because it settles the font question the right way round. A client-side PDF library (pdfmake, pdf-lib) can only use fonts whose **bytes it embeds** — JavaScript cannot read `C:\Windows\Fonts`. That would force Calibri and Segoe UI to substitute with metric clones. But **Chrome prints with the fonts installed on the station**. The customer paid for those fonts; they should be usable. So: no PDF library, no embedded fonts, no substitution, no per-font upload step. Design in Calibri → prints in Calibri → saves to PDF in Calibri. **Millimetre accuracy comes from CSS**, which has real physical units: ```css @page { size: A4 portrait; margin: 0; } .a4-page { width: 210mm; height: 297mm; position: relative; } .a4-el { position: absolute; } /* left: 20mm; top: 50mm */ ``` Chrome maps `mm` to physical millimetres at 100% scale. ### Why Puppeteer is deferred (not rejected) Puppeteer is real Chrome driven by code — best fidelity, and it uses installed fonts too. It is the right tool **the day code must generate a PDF with no human in the loop** (auto-archiving to the vehicle record, emailed tickets, nightly batches). Nothing on the table needs that now. Deferred because: ~150–300 MB of Chromium plus a Node runtime inside an **offline `Setup.exe`**, and a third service to install and supervise at every site — the most expensive thing in the box, for a one-page ticket. And server-side Puppeteer uses the **server's** fonts; at the reference site (1 server, 10 stations) that is the wrong machine, so fonts would have to be installed server-side to match — a problem browser printing simply does not have. **The socket:** `layout()` is a pure function and `PrintDocument` emits plain HTML+CSS with no React-only tricks. Adding Puppeteer later means POSTing that same HTML to a small Node service (~60 lines) and returning bytes. An afternoon, not a redesign. ### Known rough edges (one-time per station, belong in the install checklist) - Chrome adds its own header/footer (URL, date) unless **"Headers and footers"** is unticked in the print dialog. Sticky per machine once set. - Scale must stay at **100% / "Actual size"**, not "Fit to page", or millimetres stop being millimetres. Neither is a code problem. If they become a field support burden, *that* is the trigger to revisit Puppeteer — and the argument will be about silent printing, not fonts. --- ## Architecture One layout engine, renderers downstream of it are dumb. ``` Report JSON (elements in mm) + vehicle data │ ▼ layout() pure function — no React, no DOM │ resolves bindings, iterates bands, │ paginates → pages[] of {element, x, y} in mm ▼ ┌────────┴────────┐ ▼ ▼ PrintDocument (future) Puppeteer mm → CSS mm same HTML → page.pdf() ``` `layout()` returning `pages[]` is the load-bearing decision: **the same output drives the on-screen preview and the printed page.** That is what makes "preview page count equals printed page count" a real test rather than a coincidence, and it is what keeps a future PDF renderer cheap. ### The matrix editor is not modified The A4 designer is a **sibling tree** under `frontend/src/components/ReportEditor/a4/`. `EditorCanvas.jsx`, `models/Element.jsx`, `ResizeHandles.jsx`, `ObjectInspector.jsx` are **not refactored** — they are the code path the one working report feature depends on, and CLAUDE.md §3 asks for surgical changes over DRY. Three things genuinely cannot be shared, which settles it: - [`models/Element.jsx:141-147`](frontend/src/components/ReportEditor/models/Element.jsx#L141-L147) — `getBounds()` is **inclusive** (`x + width - 1`). Right for cells, wrong for mm. Everything funnels through it via `elementUtils.js`, so changing it changes matrix behaviour. - [`ResizeHandles.jsx:120-121`](frontend/src/components/ReportEditor/ResizeHandles.jsx#L120-L121) — rounds pixel deltas to integer cells; line 55 hardcodes `80 * charWidth` for bands. - [`ObjectInspector.jsx:24-29`](frontend/src/components/ReportEditor/ObjectInspector.jsx#L24-L29) — `parseInt` only; A4 needs `parseFloat`. Cost: ~200 duplicated lines of inspector data-binding/band sections. Benefit: zero regression risk on the working editor. Only **5 existing files** are touched, all additively. --- ## 1. Units and geometry **Storage is mm floats, always. Never px, never zoom-affected values.** ```js // a4/units.js export const PX_PER_MM = 96 / 25.4; // 3.7795 — CSS px at 100% zoom export const mmToPx = (mm, zoom = 1) => mm * PX_PER_MM * zoom; export const pxToMm = (px, zoom = 1) => px / (PX_PER_MM * zoom); export const roundMm = (mm) => Math.round(mm * 10) / 10; // 0.1 mm precision export const PAGE = { A4: { portrait: { w: 210, h: 297 }, landscape: { w: 297, h: 210 } } }; ``` - A4 portrait at zoom 1 → **793.7 × 1122.5 px**; fits a 1080p station screen. - Zoom 50/75/100/150 %, one `zoom` prop threaded down. Every render path already goes through `mmToPx(v, zoom)`, so zoom stays a one-line concern. - **Font size in pt** (what users expect). On screen `pt * (96/72) * zoom` px; in print CSS, `pt` directly — CSS has real `pt` units, so no conversion at all on the print path. - `roundMm` on every commit prevents `12.700000000000001` in the JSON. **Bounds are exclusive**, in a new `a4/geometry.js`. A 40 mm box at x=10 occupies [10, 50) and its right edge — what guides align to — is **50**, not 49. `getA4Bounds(el)` handles the `hline`/`vline` cases. **Float input:** `A4Inspector` uses `parseFloat` + `step="0.1"` in a small `` with local draft-string state — otherwise typing `"12."` yields `NaN`, the guard drops it, and the decimal point appears not to type. --- ## 2. Backend Reuse `Report`; add a discriminator. This copies the in-repo pattern from `ScaleProtocol` ([`models.py:66-100`](backend/api/models.py#L66-L100)), which already pairs a `kind` CharField with a `config` JSONField. **Do not repurpose `page_width`/`page_height`** — they are `IntegerField` meaning character columns/rows. Overloading them with mm makes `80` ambiguous and risks a matrix report rendering as an 80 mm page. ### [`backend/api/models.py:45-63`](backend/api/models.py#L45-L63) ```python class Report(models.Model): MATRIX = 'matrix' A4 = 'a4' KIND_CHOICES = [(MATRIX, 'Character matrix'), (A4, 'A4 free positioning')] name = models.CharField(max_length=255) kind = models.CharField(max_length=10, choices=KIND_CHOICES, default=MATRIX) page_width = models.IntegerField(default=80) # chars — matrix only page_height = models.IntegerField(default=66) # chars — matrix only # mm page setup for kind='a4': page, orientation, margins{top,right,bottom,left}, # defaultFont, defaultFontSize page_setup = models.JSONField(default=dict, blank=True) ... rest unchanged ... ``` `default=MATRIX` is the point: **every existing row keeps working untouched.** - **New** `backend/api/migrations/0009_report_kind_page_setup.py` — two additive `AddField`s with defaults. No data migration, no backfill. (Latest is `0008_license_stationslot.py`.) - [`serializers.py:487-504`](backend/api/serializers.py#L487-L504) — add `'kind'` and `'page_setup'` to `fields`; add `validate_page_setup` (must be a dict). - [`views.py:621-644`](backend/api/views.py#L621-L644) — `filterset_fields = ['name', 'kind']`, enabling `?kind=a4` for the load dialog and `?name=notice_report` for Main.jsx. --- ## 3. Files ### New ``` frontend/src/components/ReportEditor/a4/ A4ReportEditor.jsx Orchestrator: state, save/load (sends kind + page_setup), load dialog (?kind=a4), shortcuts, zoom, Print button. Wraps the reused DataProvider + BandProvider. A4Canvas.jsx Core: page canvas, click-to-create, drag via useElementDrag, smart-guide integration, rubber-band select, margin/centre overlay, preview driven by layout(). A4Toolbar.jsx Tools, zoom, orientation, Preview, Save, Load, Print A4Inspector.jsx Float-mm geometry (MmInput) + font controls + verbatim copies of the dbtext/expr/band sections A4PageSetup.jsx Orientation, 4× margin mm, default font + size units.js Conversions, roundMm, PAGE dims geometry.js getA4Bounds (exclusive), a4IntersectsRect fonts.js Font list + CSS fallback stacks guides.js Smart-guide engine — pure, no React (§4) layout.js Band instancing + pagination + data resolution (§5) A4Canvas.css Page/shadow/guide/overlay styles (screen only) a4Elements.css Per-type styles — CSS borders, not box characters elements/ A4TextField, A4DBTextField, A4ExprField, A4Rect, A4HLine, A4VLine, A4Band, A4ResizeHandles print/ PrintDocument.jsx pages[] → absolutely-positioned mm HTML. No React-only tricks — this is the exact markup a future Puppeteer eats. print.css @page rules + .print-only visibility (§6) printReport.js Non-React entry for Main.jsx: report + data → layout() → mount PrintDocument → window.print() → unmount backend/api/migrations/0009_report_kind_page_setup.py ``` ### Modified — 5 files, all additive | File | Change | |---|---| | [`backend/api/models.py`](backend/api/models.py) | `kind` + `page_setup` on `Report` | | [`backend/api/serializers.py`](backend/api/serializers.py) | both fields + `validate_page_setup` | | [`backend/api/views.py`](backend/api/views.py) | `filterset_fields = ['name', 'kind']` | | [`frontend/src/App.jsx:69`](frontend/src/App.jsx#L69) | add `/report-editor-a4` route, same `can_edit_documents` gate | | [`frontend/src/components/Main.jsx:585-601`](frontend/src/components/Main.jsx#L585-L601) | kind-aware Print Notice dispatch (§7) | No new npm dependencies. ### Reused untouched `DataContext.jsx`, `BandContext.jsx`, [`hooks/useElementDrag.jsx`](frontend/src/components/ReportEditor/hooks/useElementDrag.jsx) (emits **raw pixel deltas** at lines 35-37, leaving snapping to the consumer — exactly the seam smart guides need), `hooks/useElementSelection.jsx`, `utils/dataResolver.js`, `utils/expressionEvaluator.js`, `utils/bindingParser.js`, `ReportEditor.css` (dialog shell), `services/api.js`. **One low-risk extraction:** move the sample-data block ([`ReportEditor.jsx:71-116`](frontend/src/components/ReportEditor/ReportEditor.jsx#L71-L116)) into `utils/sampleData.js` as a pure `buildSampleVehicleData(definitions, entries)`. Move, don't change — both editors call it so sample data can't drift. If it feels invasive mid-build, duplicate instead. ### A4 element JSON Types prefixed (`a4-text`, …) so a mis-routed renderer fails loudly rather than silently mis-rendering. Positions in **mm floats**, `fontSize` in **pt**. ```js { id, type:'a4-text', x, y, width, height, content, fontFamily:'Arial', fontSize:10, bold, italic, align:'left', color:'#000000' } { id, type:'a4-dbtext', x, y, width, height, objectKey, fieldPath, dataType, ...font } { id, type:'a4-expr', x, y, width, height, expression, dataType, ...font } { id, type:'a4-rect', x, y, width, height, borderWidth:0.3, borderColor, fillColor } { id, type:'a4-hline', x, y, width, thickness:0.3, color } { id, type:'a4-vline', x, y, height, thickness:0.3, color } { id, type:'a4-band', x:0, y, width, height, bandType, dataSource, caption, children:[], parentBandId } ``` Lines use `width`/`height` rather than the matrix `length`, keeping bounds and resize uniform. Band children store `y` **relative to the band**, same as matrix ([`ReportEditor.jsx:130-134`](frontend/src/components/ReportEditor/ReportEditor.jsx#L130-L134)), so the binding mental model carries over. `page_setup`: `{ page:'A4', orientation:'portrait', margins:{top:15,right:15,bottom:15,left:20}, defaultFont:'Arial', defaultFontSize:10 }` ### Fonts Since Chrome prints with installed fonts, the dropdown offers the standard Windows set — Arial, Times New Roman, Courier New, **Calibri, Segoe UI, Tahoma, Verdana** — all of which ship with Windows and cover Cyrillic. Each is emitted with a fallback stack (`"Calibri, Arial, sans-serif"`). Free-text entry for a customer's own corporate font is a one-line addition if asked for; not built speculatively. Same `font-family` string is used on screen and in print, so **WYSIWYG holds by construction** — one font stack, one renderer engine. --- ## 4. Smart guides Pure functions in `a4/guides.js` — no React, no DOM; testable, cheap per mousemove. **Candidates**, built **once per drag** (held in a `useRef`, not recomputed per mousemove): for every non-dragged element, 3 vertical (left / centreX / right) and 3 horizontal (top / middleY / bottom) from its exclusive bounds. Plus page-level: `0`, `pageW/2`, `pageW` and each margin boundary; same for the vertical axis. **Snap resolution:** the dragged element offers 3 of its own edges per axis; for each (myEdge × candidate) pair take `|myEdgePos − candidate.pos|`, keep the per-axis minimum, snap if under threshold. ```js export const SNAP_PX = 6; // threshold in SCREEN px const tolMm = SNAP_PX / (PX_PER_MM * zoom); // → identical feel at every zoom ``` Converting the threshold **through the current zoom** is what makes the magnet feel the same at 50 % and 150 % — what users actually expect. At 100 % it is ≈1.6 mm. **Rendering & performance:** - Guides are 2 absolutely-positioned 1 px divs in a `pointer-events:none` overlay, driven by local `A4Canvas` state — a drag re-renders the overlay, not the element tree. - 100 elements ≈ 600 candidates; inner loop is 3×600 float comparisons per axis per move. Sub-millisecond; no spatial index needed at report scale. - **Hold `Alt` to suppress snapping** — standard convention, essential escape hatch. - Multi-select drag: snap the union bounding rect, apply the delta to all selected. **Arrow-key nudge** in the A4 keyboard handler: arrow = 1 mm, Shift+arrow = 0.1 mm. Highest -value precision affordance on a free-positioning designer, ~10 lines. --- ## 5. `layout()` — bands, data, pagination Ports `calculateBandInstances` from [`utils/bandRenderer.js`](frontend/src/components/ReportEditor/utils/bandRenderer.js) with three changes: 1. All arithmetic in mm floats. 2. **Drop the inclusive `-1`/`+1`.** The matrix version uses `band.y - previousBandDesignEnd - 1` ([line 89](frontend/src/components/ReportEditor/utils/bandRenderer.js#L89)) and `previousBandPreviewEnd + 1 + designGap` ([line 91](frontend/src/components/ReportEditor/utils/bandRenderer.js#L91)), tracking ends as `band.y + band.height - 1` and `currentY - 1` ([lines 170-171](frontend/src/components/ReportEditor/utils/bandRenderer.js#L170-L171)). In mm: `designGap = band.y - previousBandDesignEnd`, with `previousBandDesignEnd = band.y + band.height`. 3. `calculateTotalContentHeight`'s hardcoded `minHeight = 66` ([line 185](frontend/src/components/ReportEditor/utils/bandRenderer.js#L185)) becomes the page content height in mm. Nested-band support (`parentBandId`) ports structurally unchanged — already recursive and unit-agnostic apart from those offsets. **Data resolution happens inside `layout()`**, where the band's current row is known — so `PrintDocument` receives finished strings and stays dumb. Call `resolveDBTextValue` and `evaluateExpression` as plain functions; no React needed. Copy `formatValue` from [`DBTextField.jsx:11-33`](frontend/src/components/ReportEditor/DBTextField.jsx#L11-L33) into `a4/formatValue.js` — it is already duplicated verbatim in `ExpressionField.jsx`, so this consolidates an existing duplicate rather than creating one. **Pagination** — single pass, deliberately simple: 1. Walk instances in flow order tracking `cursorY` and `pageIndex`. 2. **Band instances are atomic** — never split. If `cursorY + height > contentBottom`, start a new page and **re-emit every `bandType === 'header'` band at the top**. 3. `footer` bands emit at `contentBottom - height` on **every** page. 4. `summary` emits once, after all detail instances. 5. **Static (non-band) elements emit on page 0 only.** Anything wanted on every page belongs in a header/footer band — say so in the band-type dropdown's help text. 6. Guard: an instance taller than the content area emits on a fresh page and clips rather than looping forever. Returns `{ pages: [[{ el, x, y, text }]] }` — pure, and consumed by both the preview and `PrintDocument`. --- ## 6. The print path `PrintDocument.jsx` maps `pages[]` to absolutely-positioned mm HTML: ```jsx
{pages.map((els, i) => (
{els.map(({ el, x, y, text }) => (
{text}
))}
))}
``` `print.css`: ```css @page { size: A4 portrait; margin: 0; } /* landscape variant when set */ .a4-page { width: 210mm; height: 297mm; position: relative; overflow: hidden; page-break-after: always; } .a4-page:last-child { page-break-after: auto; } .a4-el { position: absolute; } @media print { body > *:not(.print-root) { display: none !important; } .print-root { display: block !important; } } .print-root { display: none; } /* hidden on screen */ ``` `printReport.js` is the non-React entry (so `Main.jsx` can call it directly): take report + data → `layout()` → render `PrintDocument` into a detached root → `window.print()` → unmount on `afterprint`. It must work **outside React context** — which is exactly why `layout()` is pure. **Save as PDF is the same call.** The operator picks "Save as PDF" (Chrome) or "Microsoft Print to PDF" as the destination. No second code path. --- ## 7. UI entry points **Routing: a separate `/report-editor-a4` route, not a `:kind` param.** A param would force `ReportEditor.jsx` to become a dispatcher — modifying the one file we most want to leave alone. Two routes, two trees, zero coupling. `React.lazy` the A4 tree so `Main.jsx` doesn't pay for it. **Creating:** the load dialog's "+ New Report" opens a two-button choice — *Character matrix (dot-matrix)* / *A4 page (laser & PDF)* — navigating to the right editor. **Load dialog:** each editor fetches its own kind (`?kind=a4`), but shows cross-kind reports in a dimmed "Other reports" group with a badge, clicking which navigates across. Users will save an A4 report and wonder where it went; ~15 lines prevents that support call. **Main.jsx Print Notice** becomes kind-aware: ```jsx const res = await api.get('/api/reports/?name=notice_report'); // uses the new filter const report = (res.data.results || res.data)[0]; if (report?.kind === 'a4') { const { printA4Report } = await import('./ReportEditor/a4/print/printReport'); await printA4Report(report, vehicleReportData); // layout → PrintDocument → print() } else { setShowNoticeReport(true); // existing modal, unchanged } ``` This also drops the current fetch-all-then-`find` pattern in favour of a server-side filter. **Inside the A4 editor:** a 🖨 Print button operating on the current in-memory report — no save required first, which matters for iteration speed while designing. --- ## 8. Verification ### The ruler test — acceptance test for mm correctness Do this in Phase 4, before building anything on top of the coordinate system: 1. Place a 1 mm hline at **x=20, y=50, width=170** and a text field at **x=20, y=100** containing `АА1234ВВ` (Cyrillic). 2. Corner crosshairs: 10 mm hlines at (10,10), (190,10), (10,287), (190,287). 3. **Print at 100% / "Actual size"** (not "Fit to page") on real A4. Measure with a steel rule from the paper edge → 20 mm ± 0.5 mm (printer margin tolerance); top → 50 mm. 4. **Save as PDF** from the same dialog; measure in Acrobat → **20.0 / 50.0 / 170.0 mm**. 5. A consistent offset points at the `@page margin: 0` interaction with the printer's unprintable area — a page-setup issue, not architecture. ### Fonts — the point of the whole exercise - Design in **Calibri** and **Segoe UI**; print → the output is really Calibri and Segoe UI, not a substitute. Compare against the same text in Word if in doubt. - `АА1234ВВ` renders as Cyrillic in every offered font, **including bold**. - Save as PDF → **select and copy the text** in the viewer; clipboard must contain `АА1234ВВ` (proves real text, not raster). Ctrl+F for `1234` must hit. - Screen and print use the same `font-family` stack → visually identical. ### Print / Save-as-PDF parity - Same report → Print, then Save as PDF: identical page count and layout. - With "Headers and footers" **on**, confirm the Chrome header appears (so we know what the install checklist is preventing); untick it and confirm it is gone. - Test at "Fit to page" to see the mm error appear — confirms the 100 % requirement is real and worth documenting. ### Smart guides Edge-to-edge, centre-to-centre, page-centre and margin snapping each show a guide and snap. **Zoom to 50 % and 150 % — the grab distance must feel identical** (proves the px-threshold-through-zoom conversion). Alt suppresses. 100+ elements drag with no dropped frames in a Performance trace. ### Bands and pagination - 60 rows × 8 mm = 480 mm → **2 pages**; header band on **both**; footer on both. - No instance split across a page boundary. - **On-screen preview page count must equal printed page count** — the one check that proves the shared `layout()` pipeline really is shared. - Nested detail + subdetail renders and paginates. ### Matrix regression — mandatory; this is the risk being managed - Load `notice_report` at `/report-editor`; screenshot-diff against a pre-change capture. - Add/move/resize/delete each matrix element type; save → reload → unchanged. - Confirm the payload now carries `kind:'matrix'` and the server stores it. - Main.jsx → Print Notice with a **matrix** report → old modal, unchanged. - `python manage.py migrate` against a DB with existing rows → all get `kind='matrix'`, `page_setup={}`, and still load. --- ## Phases | # | Scope | Testable at the end | |---|---|---| | **1** | Backend `kind`/`page_setup` + migration + filter; `/report-editor-a4` stub | `?kind=a4` filters; existing reports unaffected; route loads | | **2** | `units.js`, `geometry.js`, `A4Canvas` + CSS, `A4Toolbar`, `A4ReportEditor` save/load, `A4TextField`, `A4ResizeHandles`, `A4Inspector` + `MmInput`, zoom, static margin/centre overlay | Place a field, type exact mm, see it move; drag/resize in mm; zoom preserves positions; save/reload persists floats | | **3** | `guides.js` + drag integration + overlay + Alt-suppress + arrow nudge | Full guides checklist | | **4** | `layout.js` (single-page only), `PrintDocument`, `print.css`, `printReport.js`, Print button | **The ruler test.** Calibri prints as Calibri. Save as PDF works | | **5** | Remaining element types + inspector sections + font controls | A complete single-page notice, on screen and on paper, with live data | | **6** | `layout()` bands + pagination; `A4Band`, band inspector; preview driven by `layout()` | Multi-page checklist; preview pages == printed pages | | **7** | Main.jsx dispatch, kind chooser, cross-kind nav, `A4PageSetup`, `React.lazy`, regression sweep | Operator designs → saves → Print Notice → paper; matrix regression passes | **Phase 4 deliberately precedes the remaining element types.** If exact mm printing can't be achieved, everything built on top is wasted — so it is de-risked early, on the simplest document, with a physical ruler. --- ## Risks | Risk | Mitigation | |---|---| | Chrome header/footer, or non-100% scale, breaks mm fidelity | One-time per-station settings; documented in the install checklist and verified in Phase 4 | | `@page margin: 0` vs the printer's unprintable area clips edge content | Default `page_setup` margins of 15–20 mm keep content well inside; Phase 4 ruler test catches it | | Customer station lacks a font used in the design | Offered list is Windows-bundled fonts only; every family emitted with a fallback stack | | Inspector sections drift between matrix and A4 | Accepted deliberately — the price of not touching working code. Note it in the A4 file header | | Matrix editor regression | It is not modified; the Phase 7 sweep is belt-and-braces | | Later need for unattended PDF generation | `layout()` is pure and `PrintDocument` emits plain HTML+CSS — POST it to a small Puppeteer service; ~60 lines, no redesign |