From 4b9e4c762f2034214652fbb29e22d93d3ddc7072 Mon Sep 17 00:00:00 2001 From: Dan Krieger Date: Mon, 19 Oct 2026 17:12:10 -0700 Subject: [PATCH 01/13] docs: multi-format export spec, and a golden PDF-bytes gate The spec for exporting one Papercut document as PDF, HTML or DOCX: a layout-free tree walk shared by every format, an Exporter/Output API, a fidelity contract (supported, approximated, dropped, n/a) and the capability table that says which is which per format. Before any refactor touches layout, a golden gate pins the exact PDF bytes of every v0.1 fixture and example in both packages. Record mode may only add missing keys; an existing key with a different hash fails even when recording, so the byte-identical promise can't be waved through. Squashes: - docs: multi-format export design spec (PDF, HTML, DOCX) - test: golden PDF bytes gate for the multi-format refactor - test: harden golden gate record mode and key coverage Co-Authored-By: Claude Opus 5.5 --- .../2026-10-19-multi-format-export-design.md | 370 ++++++++++++++++++ .../core/tests/Feature/GoldenBytesTest.php | 103 +++++ .../core/tests/Feature/golden/pdf-v0.1.json | 12 + .../laravel/tests/Feature/GoldenBytesTest.php | 93 +++++ .../tests/Feature/golden/pdf-v0.1.json | 5 + 5 files changed, 583 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-19-multi-format-export-design.md create mode 100644 packages/core/tests/Feature/GoldenBytesTest.php create mode 100644 packages/core/tests/Feature/golden/pdf-v0.1.json create mode 100644 packages/laravel/tests/Feature/GoldenBytesTest.php create mode 100644 packages/laravel/tests/Feature/golden/pdf-v0.1.json diff --git a/docs/superpowers/specs/2026-10-19-multi-format-export-design.md b/docs/superpowers/specs/2026-10-19-multi-format-export-design.md new file mode 100644 index 0000000..c48bfa6 --- /dev/null +++ b/docs/superpowers/specs/2026-10-19-multi-format-export-design.md @@ -0,0 +1,370 @@ +# Papercut: Multi-Format Export (PDF, HTML, DOCX) — Design Spec + +**Date:** 2026-10-19 +**Status:** Draft for review +**Target release:** 0.2.0 (`papercut/core`, `papercut/laravel`, new `papercut/docx`) +**Builds on:** [2026-10-18-papercut-design.md](2026-10-18-papercut-design.md) (v0.1) and [RULINGS.md](../RULINGS.md) + +## 1. Summary + +The same Papercut template produces three formats: PDF (the reference layout, unchanged), HTML for viewing in a browser, and Word (.docx). Exporters read the node tree and its resolved styles, which don't depend on layout. Only the PDF exporter runs the page-aware layout engine, painter and backend. HTML hands layout to the browser. DOCX hands vertical flow and pagination to Word, but takes its horizontal geometry (column widths) from our own layout calculation, so it matches the PDF side to side. + +With this release the Blade prefix becomes ``, the facade becomes `Papercut`, and DOCX ships as an optional package. + +## 2. Goals and success criteria + +**Use cases** +- **HTML: web view and preview.** A developer who already has an invoice template can show the same document in a browser without writing it twice. "Close enough" is the bar, not pixel-identical. This is the use case the README leads with. +- **DOCX: a faithful copy in Word format.** Many recipients insist on .docx, and the file must import cleanly into Google Docs and Office 365. Looking like the PDF matters more than being easy to edit. Being editable is a welcome side effect of the chosen approach (§5.2), not a goal. A more editable mode is welcome as a contribution. + +**0.2.0 succeeds when:** +1. `Papercut::view('invoice', $data)` can return `->pdf()`, `->html()` or `->docx()` from the same Blade view, with no template changes. +2. PDF output for every v0.1 fixture is byte-identical to v0.1. +3. The three examples (invoice, transcript, report) render in a browser looking close to the PDF, and open in Word, LibreOffice, Google Docs (import) and Office 365 web with header rows, page numbers and column widths intact. +4. The same text, in the same order, comes out of all three formats (the content parity test, §9). +5. Every approximation and every dropped feature is reported with its node path. Strict mode throws on dropped features. + +**Non-goals for 0.2.0:** +- Pixel-matching HTML against the PDF. +- Matching the PDF's page breaks in DOCX. +- Email-safe HTML: table layout, Outlook quirks. +- Browser print-to-PDF as a third route to PDF. +- A positioned or "frame" DOCX mode. +- Raw HTML input (§12). +- Bookmarks, which move from v0.2 to v0.3. + +## 3. Prior art (checked 2026-10-19) + +- **No PHP or JS library renders one component tree to PDF, DOCX and HTML.** + - Laravel DOCX packages (`dskripchenko/laravel-php-docx`, `ananikomlanmh/word-for-laravel`, `santwer/exporter`) use rendered HTML or Word templates as the shared model, not a typed tree. + - In JS, `@react-pdf/renderer` targets PDF only. The React-to-docx reconciler (`revolunet/react-docx`) was last pushed in 2021. +- **One model, many writers.** + - PHPWord (latest release 1.4.0, 2025-06-05; `master` still active) has Word2007, ODT, RTF and HTML writers. Its PDF writer goes through HTML and DomPDF/mPDF/TCPDF. + - Pandoc writes DOCX as flowing content, separate from its PDF path. + - Typst's HTML export is still experimental as of 0.15. +- **Lay out first, then export (Jasper, Telerik, DevExpress page-by-page).** + - JasperReports' DOCX exporter is documented as "grid-based". + - Telerik exports nested tables. When a user complained they are hard to edit, Telerik replied that changes belong in the report design. + - DevExpress makes users choose between a continuous "editable" mode and a frame-based "faithful" mode. + - Lesson: positioned output is brittle and fights Word's repagination. +- **Flowing exporters (Pandoc, PHPWord, docx.js, Aspose) leave pagination to Word through native features:** + - `w:tblHeader` (repeating header rows) + - `w:cantSplit` (rows that don't split) + - `keepNext` / `keepLines` + - PAGE / NUMPAGES fields + - section headers and footers + + Papercut's DOCX mapping relies on exactly these features. +- **Testing practice:** Pandoc compares unzipped DOCX XML against golden copies. OOXML schema validators can run from npx without .NET. + +Papercut is the first to put a typed component tree in front of all three formats. The DOCX design (§5.2) takes the table approach from the layout-first exporters but builds it from the tree, not from positioned boxes. + +## 4. Decisions + +| # | Decision | Alternatives rejected | +|---|---|---| +| D1 | Exporters read the node tree plus resolved styles. Word and HTML are **not** new `Backend`s. | A DOCX backend after layout (text boxes nobody can edit) | +| D2 | HTML is for viewing in a browser; "close enough" | standalone archive file, email HTML, print-to-PDF | +| D3 | DOCX is a faithful copy, built as **B2**: tables sized by our layout, Word paginates | B1 positioned frames (imports badly into Google Docs, breaks when Word's text metrics differ); a per-document mode switch | +| D4 | DOCX writer: `dskripchenko/php-docx`, behind `Papercut\Docx\*` only | PHPWord (slow releases, no deterministic output, no font embedding); our own OOXML writer | +| D5 | Shared `Expander` + `StyleResolver` + `TreeWalker`; nodes unchanged | a separate `ResolvedNode` tree; a format-neutral flow model (Pandoc style) | +| D6 | Packages: core keeps PDF and adds HTML; DOCX is a separate `papercut/docx` | everything in core; four-way split | +| D7 | Facade `Papercut`; tag prefix `doc` (configurable) | `Doc` facade; per-format facades; `x-papercut`, `x-paper`, `x-pc` | +| D8 | Order: seams → HTML → DOCX, with the upstream PR running alongside | DOCX first | + +## 5. Architecture + +### 5.1 Pipeline + +``` +Node tree ─► Expander + StyleResolver (shared, no layout) ─► TreeWalker + ├─ PdfExporter: LayoutEngine ─► Painter ─► Backend (php-pdf) [core] + ├─ HtmlExporter: walk ─► HTML + CSS [core] + └─ DocxExporter: walk (+ WidthResolver) ─► php-docx ─► .docx bytes [papercut/docx] +``` + +### 5.2 Core units (new or changed) + +| Unit | Responsibility | Depends on | +|---|---|---| +| `Expander` | Turns `Component`s into `Node`s, with the existing depth guard. Moved out of `LayoutEngine::expand`. | Node | +| `StyleResolver` | `(Node, parent ComputedStyle) → ComputedStyle`: theme presets, named styles, the node's own style, inheritance. Moved out of `LayoutContext::resolveStyle`, keeping its per-node cache. `LayoutEngine` uses it unchanged. | Node, Style | +| `TreeWalker` | Depth-first walk that yields `(Node, ComputedStyle, path)` for page body, header, footer and background. Builds node paths (`page > row[2] > column[0]`) the same way layout does, and attaches them to `InvalidValue` / `UnknownAttribute`. | Expander, StyleResolver | +| `WidthResolver` | The layout engine's horizontal calculation exposed on its own: given a node and an available width, it returns the resolved widths of flex children (basis, grow, shrink, gap) and table columns (fixed, fr, auto via content min/max). No pagination. Uses `FontRegistry` for content-dependent widths. | Layout, Text | +| `Exporter` | `export(Document $doc, ExportOptions $opts): Output` | — | +| `Output` | `bytes(): string`, `mime(): string`, `extension(): string`, `save(string $path): void`, `stream(): int` | — | +| `Fidelity` | Collects `FidelityReport(level, format, feature, path)`. Levels: `Approximated`, `Dropped`. Forwards to `onWarning`. In strict mode, throws `UnsupportedFeature` on the first `Dropped`. | — | +| `FontSet` | Format-neutral font configuration: family → file per weight and italic, plus fallback families. PDF loads it into `FontRegistry`; HTML emits `@font-face`; DOCX writes family names. | Value | +| `PdfExporter` | Wraps the existing pipeline (`Paginator` → `Painter` → `Backend`). | Layout, Paint, Backend | +| `HtmlExporter` | §6.1 | TreeWalker, Fidelity | + +**Fidelity levels** +- **Supported:** emitted as specified. +- **Approximated (≈):** emitted with a documented substitute. Reported, never thrown. +- **Dropped (✗):** not emitted. Reported, and thrown in strict mode. +- **n/a:** the feature has no meaning in the format (for example page numbers on screen). Left out silently, never reported, never thrown. + +**Options** +- `ExportOptions` (shared): `theme`, `fonts: FontSet`, `onWarning: ?Closure`, `strict: bool = false`. +- `PdfOptions extends ExportOptions`: `backend`, `fallbackFamilies`, `cacheDir`, `overflow: OverflowMode`. This replaces `RenderOptions`. +- `HtmlOptions extends ExportOptions`: + - `fullPage: bool = false` + - `fontMode: 'link'|'embed'|'none' = 'embed'` (Laravel config key `html.fonts`) + - `imageUrl: ?Closure(Image): ?string` (null, or a null return, means embed as a data URI) +- `DocxOptions extends ExportOptions`: no extra settings in 0.2.0. + +**Removing layout and PDF assumptions from the tree** +- `Image::handle(ImageHandle)` is removed. `Image` sources are `file` and `bytes` only. `ImageHandle` stays inside the backend, where it caches decoded images. +- `OverflowMode` moves from the shared options to `PdfOptions`. +- The other page and layout concepts (`PageNumber` placeholders, fixed `height`, `Spacer::flex`, `auto` columns, the background slot) stay in the tree unchanged. Each format handles them through the capability table (§7). + +**Document API** +- `Document::render()`, `toString()`, `save()` and `stream()` are replaced by: + - `export(Exporter $e, ?ExportOptions $o = null): Output` + - `toPdf(?PdfOptions $o = null): Output` + - `toHtml(?HtmlOptions $o = null): Output` +- DOCX is `$doc->export(new \Papercut\Docx\DocxExporter())`, because core does not depend on the DOCX package. + +### 5.3 DOCX mapping (approach B2) + +- **Document:** core properties (title, author, subject, keywords, created/modified from `createdAt` or a fixed date, so output is deterministic) and the default language. +- **Page → one Word section:** + - `pgSz` and orientation from the page; `pgMar` from its margins. + - The header/footer distance is approximated from `headerGap`/`footerGap`. Word measures from the page edge, while Papercut puts the header inside the top margin. + - The header and footer slots become the section's header and footer parts. +- **Column and the page body:** content flows top to bottom as paragraphs and tables. `gap` becomes paragraph spacing (space after) between children. If the column has padding, a border or a background, its content is wrapped in a single-cell table carrying those properties. +- **Row:** + - Becomes a one-row, borderless, **fixed-layout** table. + - `gridCol` widths come from `WidthResolver` at the width actually available (the page content width minus enclosing padding). + - `gap` becomes empty spacer columns. + - `align-items` becomes cell vertical alignment (`top`/`center`/`bottom`). `stretch` is the default behavior of table rows. + - `wrap` uses `WidthResolver`'s line assignment: one table row per flex line. +- **Spacer:** in a row, an empty cell with its resolved width. In a column, an empty paragraph with an exact line height of the spacer's size. A flexible spacer in a column has no size of its own, so it is approximated as zero. +- **Text:** a paragraph. + - Supported directly: alignment (including justify), line spacing (exact or multiple), letter spacing, font size, weight (≥ 600 is bold, otherwise not; ≈ for in-between weights), italic, color, underline and strike. + - Spans become runs; `
` becomes a line break; `href` becomes a hyperlink. + - Presets h1–h4 and p become Word paragraph styles (`Heading1`–`Heading4`, `Normal`) with Papercut's resolved values, so a document outline appears in Word and Google Docs. +- **Table:** + - `gridCol` widths come from `WidthResolver` (the same column calculation as PDF). + - Header rows get `w:tblHeader`; every row gets `w:cantSplit`. + - `colspan` becomes `gridSpan`. Cell padding, borders and background map directly. +- **Pagination styles:** + - `keep-with-next` → `keepNext` on the node's last paragraph. + - `keep-together` → `keepLines` and `keepNext` on every paragraph but the last; for tables, `cantSplit` on every row. + - `break-before` → `pageBreakBefore`; `break-after` → a page break after the node. + - `page-break` → `w:br w:type="page"`. + - `min-lines` ≥ 2 → widow control on (≈). +- **PageNumber:** the format string is split on `{page}` and `{total}` into text runs and PAGE / NUMPAGES fields. +- **Image:** + - The size comes from our image layouter's `contain`/`fill` calculation at the available width. + - `cover` is approximated as `contain`. + - Block `link` applies its hyperlink to the text runs inside it (≈). Images inside a link are not linked. +- **Colors:** an alpha below 1 is blended onto white (≈). + +**Upstream dependency:** `w:cantSplit` (and `w:keepLines` if absent) is contributed to php-docx. If that isn't merged and released by the end of phase 2 (§10), `Papercut\Docx` adds it with a small, tested post-processing step on the XML, kept inside the package, until it is. This follows the pattern of the contained php-pdf workaround in the rulings. + +### 5.4 Error handling + +- New exceptions (all extend `PapercutException`): + - `UnsupportedFeature`: strict mode. The message names the format, feature and node path, for example `Strict mode: docx cannot render radius at page > row[2] > column[0].` + - `MissingExporter`: the format's package is not installed, for example `DOCX export needs papercut/docx: run "composer require papercut/docx".` +- Validation errors (`InvalidValue`, `UnknownAttribute`, Laravel `InvalidStructure`) are raised the same way in every format, because they come from the shared `StyleResolver` and `TreeWalker`. + +## 6. Exporters + +### 6.1 HTML + +- **Structure:** + - A `
` root contains one `
` per `page`. + - Each page box has the page width as `max-width`, the margins as padding, and a "paper" look on screen (shadow, gap between pages). + - Header, body and footer render in order inside each page box. The background slot is an absolutely positioned layer behind the content. +- **Elements:** + - `view` / `row` / `column` → `
` with flexbox. + - `text` → `

`; presets h1–h4 → `

`–`

`. + - Spans → ``, ``, ``, ``, ``, ``, `
`. + - `table`, `thead`, `tr`, `th`, `td` → the same HTML elements with `colgroup` widths: lengths as-is, `fr` as proportional `%` of the remaining width, `auto` as auto. + - `image` → `` with `object-fit`. + - Block `link` → `
` around the block. + - `spacer` → `
` with a fixed size or `flex-grow`. +- **Styles:** + - Computed styles are written **inline** on each element, so the output doesn't depend on the host page's cascade. + - A small fixed stylesheet scoped to `.papercut` provides the page box and a minimal reset (margins, box-sizing, list and heading defaults), so host CSS does not leak in. + - Units are emitted in `pt`. +- **Print CSS:** `@page` size and margins from the first page; `break-before: page` for page breaks and `break-before`/`break-after`; `break-inside: avoid` for `keep-together` and table rows. The page box's screen styling is removed in print. +- **Page-only features:** `page-number`, keep rules and `min-lines` are n/a on screen. Page numbers are left out of the output, not rendered as "1". +- **Fonts (`fontMode`):** `embed` (default) inlines each used `FontSet` file as a base64 `@font-face`. `link` uses a URL from the font config. `none` emits family names only. +- **Images:** data URIs by default; an `imageUrl` callback can return a URL instead. Laravel embeds by default too. +- **Security:** + - All text and attribute values are escaped. + - `href`s go through the same scheme allowlist as PDF (http, https, mailto, tel, `#fragment`). + - Style values come only from typed `ComputedStyle` values, never from raw strings, so nothing can inject CSS. +- **Fragment and full page:** with `fullPage: false` the output is the root `
` plus a `

Northwind Studio

48 Harbor Street, Suite 210

Portland, OR 97204

billing@northwind.example

INVOICE

No. INV-2026-0142

Issued 2026-03-02 / Due 2026-04-01

BILL TO

Marlowe & Finch Publishing

Attn: Accounts Payable

900 Meridian Avenue

Seattle, WA 98101

PROJECT

Report Platform Rebuild

PO NUMBER

PO-77310

Description

Hours

Rate

Amount

Layout engine consulting

Flexbox-style design review and API workshop

12

$140.00

$1,680.00

PDF backend integration

Font subsetting and deterministic output

8

$140.00

$1,120.00

Table pagination

Repeating headers, keep-together rules

6

$140.00

$840.00

Template migration

Port 14 legacy reports to components

20

$120.00

$2,400.00

Quality assurance

Visual regression suite and PDF validation

10

$110.00

$1,100.00

Documentation

Developer guide and examples

5

$95.00

$475.00

Subtotal

$7,615.00

Tax (8.25%)

$628.24

Total due

$8,243.24

\ No newline at end of file diff --git a/examples/html/render.php b/examples/html/render.php new file mode 100644 index 0000000..f381446 --- /dev/null +++ b/examples/html/render.php @@ -0,0 +1,27 @@ +path) ?: $face->path); + return '../../' . ltrim(str_starts_with($path, $root) ? substr($path, strlen($root)) : $path, '/'); +}; + +// The document files assign their own variables, so each is loaded inside a function scope. +$load = static function (string $name): Document { + return require __DIR__ . '/../core/documents/' . $name . '.php'; +}; + +foreach (['invoice', 'transcript', 'report'] as $name) { + $load($name)->toHtml(new HtmlOptions(fullPage: true, fontMode: FontMode::Link, fontUrl: $fontUrl))->save(__DIR__ . '/' . $name . '.html'); + echo 'Wrote examples/html/' . $name . ".html\n"; +} diff --git a/examples/html/report.html b/examples/html/report.html new file mode 100644 index 0000000..021794c --- /dev/null +++ b/examples/html/report.html @@ -0,0 +1,17 @@ +Designing Papercut

Papercut Design Report

Confidential

Designing Papercut

A short report on layout, pagination, and deterministic output

SECTIONS

4

plus a glossary appendix

WORDS OF PROSE

509

across 10 justified paragraphs

ATTRIBUTES

28

listed in the reference table

1. Overview

Papercut starts from a simple observation: most business documents are layouts, not drawings. An invoice is a column of blocks, a letterhead is a row with a flexible gap, and a transcript is a table that happens to be long. The library therefore models documents as trees of components and leaves the coordinate arithmetic to a layout engine.

Every node is immutable. A modifier such as padding or bold returns a new instance, so a fragment can be reused in a dozen places without one caller changing what another sees. This also makes documents safe to build once and render many times, which matters in long-running workers.

Layout follows a measure-then-place model. Each element reports the space it wants given the width on offer, and containers distribute the remainder using grow factors, gaps, and alignment. The vocabulary is deliberately close to flexbox, so anyone who has written modern CSS can predict the result.

2. Text and pagination

Text is shaped with real font metrics. Lines are broken at word boundaries, justified paragraphs stretch the gaps between words, and inline markup such as bold or italic spans is carried through the line breaker without losing its style. Widths come from the embedded font program, never from an estimate.

Pagination is a first-class concern rather than an afterthought. Blocks can ask to be kept together, a heading can insist on staying with the paragraph that follows it, and tables repeat their header on every page they cross. When nothing else works, the engine splits a block at a line boundary and keeps going.

Output is deterministic. Given the same input and a fixed creation date, two runs produce identical bytes. That property makes golden-file tests practical: a visual regression becomes a byte comparison, and a diff in the PDF points at a diff in the code.

3. Backend and failure modes

The backend is a thin seam. The layout engine produces a display list of rectangles, lines, text runs, and links in plain points with a top-left origin, and one adapter turns that list into PDF objects. Nothing above the adapter knows how a PDF is encoded, so the engine can be tested without parsing a single file.

Failure is explicit. If a block cannot fit on a page even after every permitted split, the engine throws by default and names the offending node. Callers who prefer degraded output can opt into clipping, which keeps the rest of the document flowing and reports every clipped node through a warning callback; silent corruption is never the default.

Styling is data. Colors resolve from a named palette, sizes accept points, millimetres, or percentages, and a theme supplies defaults for family, size, weight, and line height. Because every style property can also be set from a string attribute, templates written in markup and code written in PHP describe the same documents.

The result is a library that stays out of the way for simple documents and still holds together for hard ones: a forty-page ledger, a certificate with a decorative background, or a report whose tables must not be orphaned from their captions.

Rule of thumb: measure first, then place, and only then paint.

4. Style attributes

Attribute

Example

Effect

padding

12pt 6mm

Space inside the border; one to four values, like CSS.

margin

0 0 6pt

Space outside the border; one to four values.

gap

8pt

Space between the children of a row or column.

grow

1

Share of the free space a child takes along the main axis.

shrink

0

Share of the overflow a child gives back along the main axis.

basis

40%

Starting main size of a flex child before grow and shrink.

width

60mm

Border-box width: a length, a percentage, or auto.

height

120pt

Border-box height; a fixed height makes the box monolithic.

min-width / max-width

20mm

Clamps applied after the width is resolved.

align-items

center

Cross-axis placement of the children.

justify

space-between

Main-axis distribution of the free space.

background

slate-50

Fill colour: a palette name or a hex value.

border-width

1pt

Border width; one to four values.

border-color

slate-200

Border colour: a palette name or a hex value.

radius

8pt

Corner radius of the background and border.

opacity

0.5

Opacity of the box and everything inside it.

font

Inter

Font family, resolved through the font registry.

font-size

10pt

Font size.

weight

700

Font weight, such as 400 or 700.

color

slate-900

Text colour: a palette name or a hex value.

line-height

1.4

Line height as a multiple of the font size.

letter-spacing

0.5pt

Extra space after every character.

text-align

justify

Left, center, right or justify.

underline / strike

true

Text decorations drawn under or through the run.

keep-together

true

Moves the whole block to the next page rather than splitting it.

keep-with-next

true

Keeps a block on the same page as the block that follows it.

break-before / break-after

true

Forces a page break before or after the block.

min-lines

2

Fewest lines of a paragraph left on either side of a page break.

Appendix A: Glossary

Display list: The ordered drawing operations produced by layout.

Fragment: The part of a node that fits on one page.

Track: A column width definition in a table.

\ No newline at end of file diff --git a/examples/html/transcript.html b/examples/html/transcript.html new file mode 100644 index 0000000..b2b181e --- /dev/null +++ b/examples/html/transcript.html @@ -0,0 +1,17 @@ +Official Transcript

Official Transcript

One Learning Community

Ada Lovelace

Student ID 2019-04417 | B.Sc. Computer Science | Enrolled 2019

CREDITS

210

GPA

3.20

STANDING

Good

COURSES

60

Course history

Code

Course

Term

Credits

Grade

CS 100

Introduction to Calculus

Term 1

3

A

CS 107

Introduction to Linear Algebra

Term 1

4

C+

CS 114

Introduction to Data Structures

Term 1

3

B-

CS 121

Introduction to Algorithms

Term 1

4

B-

CS 128

Introduction to Physics

Term 1

3

B

CS 135

Introduction to Statistics

Term 1

4

B+

CS 142

Introduction to Databases

Term 1

3

B+

CS 149

Introduction to Compilers

Term 1

4

A-

CS 156

Introduction to Networks

Term 2

3

A

CS 163

Introduction to Ethics

Term 2

4

A

CS 170

Introduction to Logic

Term 2

3

C+

CS 177

Introduction to Numerical Methods

Term 2

4

B-

CS 184

Applied Calculus

Term 2

3

B-

CS 191

Applied Linear Algebra

Term 2

4

B

CS 198

Applied Data Structures

Term 2

3

B+

CS 205

Applied Algorithms

Term 2

4

B+

CS 212

Applied Physics

Term 3

3

A-

CS 219

Applied Statistics

Term 3

4

A

CS 226

Applied Databases

Term 3

3

A

CS 233

Applied Compilers

Term 3

4

C+

CS 240

Applied Networks

Term 3

3

B-

CS 247

Applied Ethics

Term 3

4

B-

CS 254

Applied Logic

Term 3

3

B

CS 261

Applied Numerical Methods

Term 3

4

B+

CS 268

Topics in Calculus

Term 4

3

B+

CS 275

Topics in Linear Algebra

Term 4

4

A-

CS 282

Topics in Data Structures

Term 4

3

A

CS 289

Topics in Algorithms

Term 4

4

A

CS 296

Topics in Physics

Term 4

3

C+

CS 303

Topics in Statistics

Term 4

4

B-

CS 310

Topics in Databases

Term 4

3

B-

CS 317

Topics in Compilers

Term 4

4

B

CS 324

Topics in Networks

Term 5

3

B+

CS 331

Topics in Ethics

Term 5

4

B+

CS 338

Topics in Logic

Term 5

3

A-

CS 345

Topics in Numerical Methods

Term 5

4

A

CS 352

Advanced Calculus

Term 5

3

A

CS 359

Advanced Linear Algebra

Term 5

4

C+

CS 366

Advanced Data Structures

Term 5

3

B-

CS 373

Advanced Algorithms

Term 5

4

B-

CS 380

Advanced Physics

Term 6

3

B

CS 387

Advanced Statistics

Term 6

4

B+

CS 394

Advanced Databases

Term 6

3

B+

CS 401

Advanced Compilers

Term 6

4

A-

CS 408

Advanced Networks

Term 6

3

A

CS 415

Advanced Ethics

Term 6

4

A

CS 422

Advanced Logic

Term 6

3

C+

CS 429

Advanced Numerical Methods

Term 6

4

B-

CS 436

Seminar in Calculus

Term 7

3

B-

CS 443

Seminar in Linear Algebra

Term 7

4

B

CS 450

Seminar in Data Structures

Term 7

3

B+

CS 457

Seminar in Algorithms

Term 7

4

B+

CS 464

Seminar in Physics

Term 7

3

A-

CS 471

Seminar in Statistics

Term 7

4

A

CS 478

Seminar in Databases

Term 7

3

A

CS 485

Seminar in Compilers

Term 7

4

C+

CS 492

Seminar in Networks

Term 8

3

B-

CS 499

Seminar in Ethics

Term 8

4

B-

CS 106

Seminar in Logic

Term 8

3

B

CS 113

Seminar in Numerical Methods

Term 8

4

B+

Cumulative summary

210 credits attempted, 671.3 quality points.

GPA 3.20

\ No newline at end of file diff --git a/packages/core/src/Document.php b/packages/core/src/Document.php index 5c1d8a3..7a44e5e 100644 --- a/packages/core/src/Document.php +++ b/packages/core/src/Document.php @@ -4,7 +4,7 @@ namespace Papercut; -use Papercut\Export\{ExportOptions, Exporter, Output, PdfExporter, PdfOptions}; +use Papercut\Export\{ExportOptions, Exporter, HtmlExporter, HtmlOptions, Output, PdfExporter, PdfOptions}; use Papercut\Paint\DocumentMeta; final class Document @@ -94,4 +94,9 @@ public function toPdf(?PdfOptions $options = null): Output { return $this->export(new PdfExporter(), $options); } + + public function toHtml(?HtmlOptions $options = null): Output + { + return $this->export(new HtmlExporter(), $options); + } } diff --git a/packages/core/src/Export/Fidelity.php b/packages/core/src/Export/Fidelity.php index 721b822..727565b 100644 --- a/packages/core/src/Export/Fidelity.php +++ b/packages/core/src/Export/Fidelity.php @@ -39,6 +39,12 @@ public function dropped(string $feature, string $path): void $this->record($report); } + /** A plain warning that is not a fidelity report; the callback is handed the message only. */ + public function warn(string $message): void + { + $this->options->onWarning()?->__invoke($message); + } + /** @return list */ public function reports(): array { diff --git a/packages/core/src/Export/FontMode.php b/packages/core/src/Export/FontMode.php new file mode 100644 index 0000000..e9f2fef --- /dev/null +++ b/packages/core/src/Export/FontMode.php @@ -0,0 +1,12 @@ +fontMode() === FontMode::Link && $options->fontUrl() === null) { + throw new \InvalidArgumentException('FontMode::Link needs a fontUrl callback'); + } + $styles = new StyleResolver($options->theme()); + $writer = new HtmlWriter($doc, new Fidelity('html', $options), $options); + (new TreeWalker($styles))->walk($doc, $writer); + + $meta = $doc->meta(); + $root = $styles->root(); + $families = array_values(array_unique([...$writer->usedFamilies(), $root->fontFamily, ...$options->fonts()->fallbacks])); + $css = Stylesheet::css($doc, $options, FontFaces::css($families, $options->fonts(), $options->fontMode(), $options->fontUrl())); + $lang = $meta->lang !== null ? ' lang="' . HtmlWriter::esc($meta->lang) . '"' : ''; + $rootDiv = '
' . $writer->html() . '
'; + $style = ''; + + if (!$options->fullPage()) { + return new Output($style . $rootDiv, 'text/html; charset=UTF-8', 'html'); + } + + $head = '' . HtmlWriter::esc($meta->title ?? '') . ''; + foreach (['author' => $meta->author, 'description' => $meta->subject, 'keywords' => $meta->keywords] as $name => $value) { + if ($value !== null) { + $head .= ''; + } + } + + return new Output( + '' . $head . $style . '' . $rootDiv . '', + 'text/html; charset=UTF-8', + 'html', + ); + } + + public function defaultOptions(): ExportOptions + { + return new HtmlOptions(); + } +} diff --git a/packages/core/src/Export/HtmlOptions.php b/packages/core/src/Export/HtmlOptions.php new file mode 100644 index 0000000..e785a6f --- /dev/null +++ b/packages/core/src/Export/HtmlOptions.php @@ -0,0 +1,69 @@ +imageUrl = $imageUrl; + $this->fontUrl = $fontUrl; + } + + public function fullPage(): bool + { + return $this->fullPage; + } + + public function fontMode(): FontMode + { + return $this->fontMode; + } + + /** @return (\Closure(Image): ?string)|null */ + public function imageUrl(): ?\Closure + { + return $this->imageUrl; + } + + /** @return (\Closure(FontFace): ?string)|null */ + public function fontUrl(): ?\Closure + { + return $this->fontUrl; + } + + public function withFullPage(bool $on): self + { + return new self($this->theme(), $this->fonts(), $this->onWarning(), $this->strict(), $on, $this->fontMode, $this->imageUrl, $this->fontUrl); + } + + public static function from(ExportOptions $o): self + { + return $o instanceof self ? $o : new self($o->theme(), $o->fonts(), $o->onWarning(), $o->strict()); + } +} diff --git a/packages/core/src/Html/Css.php b/packages/core/src/Html/Css.php new file mode 100644 index 0000000..461b728 --- /dev/null +++ b/packages/core/src/Html/Css.php @@ -0,0 +1,236 @@ + self::pt($v->points), + $v instanceof Percent => self::num($v->value) . '%', + default => null, + }; + } + + public static function color(Color $c): string + { + if ($c->isOpaque()) { + return sprintf('#%02x%02x%02x', $c->r, $c->g, $c->b); + } + return sprintf('rgba(%d, %d, %d, %s)', $c->r, $c->g, $c->b, self::num($c->alpha)); + } + + /** A CSS string literal. `<` is escaped too, so `` can never appear inside a style block. */ + public static function string(string $s): string + { + $out = preg_replace_callback( + '/[\\\\"<\x00-\x1f\x7f]/', + static fn (array $m): string => match ($m[0]) { + '\\' => '\\\\', + '"' => '\\"', + default => '\\' . dechex(ord($m[0])) . ' ', + }, + $s, + ); + return '"' . ($out ?? '') . '"'; + } + + /** @param list $fallbacks families tried before the generic ones */ + public static function fontFamily(string $family, array $fallbacks = []): string + { + return implode(', ', [...array_map(self::string(...), [$family, ...$fallbacks]), 'system-ui', 'sans-serif']); + } + + /** @return array */ + public static function box(ComputedStyle $s): array + { + $d = []; + foreach (['width' => $s->width, 'height' => $s->height, 'min-width' => $s->minWidth, 'max-width' => $s->maxWidth, 'min-height' => $s->minHeight, 'max-height' => $s->maxHeight] as $k => $v) { + $css = $v === null ? null : self::length($v); + if ($css !== null) { + $d[$k] = $css; + } + } + if (!self::zero($s->padding)) { + $d['padding'] = self::four($s->padding); + } + if (!self::zero($s->margin)) { + $d['margin'] = self::four($s->margin); + } + if (!self::zero($s->borderWidth)) { + $d['border-width'] = self::four($s->borderWidth); + $d['border-style'] = $s->borderStyle->value; + $d['border-color'] = self::color($s->borderColor); + } + if ($s->radius !== 0.0) { + $d['border-radius'] = self::pt($s->radius); + } + if ($s->background !== null) { + $d['background'] = self::color($s->background); + } + if ($s->opacity !== 1.0) { + $d['opacity'] = self::num($s->opacity); + } + return $d; + } + + /** + * @param list $fallbacks + * @return array every property when $parent is null, otherwise those that differ + */ + public static function text(ComputedStyle $s, ?ComputedStyle $parent, array $fallbacks = []): array + { + $d = []; + if ($parent === null || $s->fontFamily !== $parent->fontFamily) { + $d['font-family'] = self::fontFamily($s->fontFamily, $fallbacks); + } + if ($parent === null || $s->fontSize !== $parent->fontSize) { + $d['font-size'] = self::pt($s->fontSize); + } + if ($parent === null || $s->fontWeight !== $parent->fontWeight) { + $d['font-weight'] = (string) $s->fontWeight; + } + if ($parent === null || $s->italic !== $parent->italic) { + $d['font-style'] = $s->italic ? 'italic' : 'normal'; + } + if ($parent === null || self::color($s->color) !== self::color($parent->color)) { + $d['color'] = self::color($s->color); + } + if ($parent === null || $s->lineHeight !== $parent->lineHeight) { + $d['line-height'] = self::num($s->lineHeight); + } + if ($parent === null || $s->letterSpacing !== $parent->letterSpacing) { + $d['letter-spacing'] = self::pt($s->letterSpacing); + } + if ($parent === null || $s->textAlign !== $parent->textAlign) { + $d['text-align'] = $s->textAlign->value; + } + if ($parent === null || $s->underline !== $parent->underline || $s->strike !== $parent->strike) { + $d['text-decoration'] = match (true) { + $s->underline && $s->strike => 'underline line-through', + $s->underline => 'underline', + $s->strike => 'line-through', + default => 'none', + }; + } + return $d; + } + + /** @return array */ + public static function flexContainer(ComputedStyle $s): array + { + $d = ['display' => 'flex', 'flex-direction' => $s->direction->value]; + if ($s->gap !== 0.0) { + $d['gap'] = self::pt($s->gap); + } + if ($s->justify !== Justify::Start) { + $d['justify-content'] = self::justify($s->justify); + } + if ($s->alignItems !== Align::Stretch) { + $d['align-items'] = self::align($s->alignItems); + } + if ($s->wrap) { + $d['flex-wrap'] = 'wrap'; + } + return $d; + } + + /** @return array */ + public static function flexItem(ComputedStyle $s): array + { + $d = []; + if ($s->grow !== 0.0 || $s->shrink !== 1.0 || !$s->basis instanceof Auto) { + $d['flex'] = self::num($s->grow) . ' ' . self::num($s->shrink) . ' ' . (self::length($s->basis) ?? 'auto'); + } + if ($s->alignSelf !== null) { + $d['align-self'] = self::align($s->alignSelf); + } + return $d; + } + + /** @return array */ + public static function pagination(ComputedStyle $s, ?ComputedStyle $parent = null): array + { + $d = []; + if ($s->breakBefore) { + $d['break-before'] = 'page'; + } + if ($s->breakAfter) { + $d['break-after'] = 'page'; + } + if ($s->keepTogether) { + $d['break-inside'] = 'avoid'; + } + if ($s->minLines !== ($parent === null ? self::rootMinLines() : $parent->minLines)) { + $d['orphans'] = (string) $s->minLines; + $d['widows'] = (string) $s->minLines; + } + return $d; + } + + private static function rootMinLines(): int + { + static $v = null; + return $v ??= ComputedStyle::root(Theme::default())->minLines; + } + + /** @param array $decls */ + public static function attr(array $decls): string + { + $parts = []; + foreach ($decls as $k => $v) { + $parts[] = $k . ':' . $v; + } + return implode(';', $parts); + } + + private static function zero(Edges $e): bool + { + return $e->top->points === 0.0 && $e->right->points === 0.0 && $e->bottom->points === 0.0 && $e->left->points === 0.0; + } + + private static function four(Edges $e): string + { + return self::pt($e->top->points) . ' ' . self::pt($e->right->points) . ' ' . self::pt($e->bottom->points) . ' ' . self::pt($e->left->points); + } + + private static function justify(Justify $j): string + { + return match ($j) { + Justify::Start => 'flex-start', + Justify::End => 'flex-end', + Justify::Center => 'center', + Justify::SpaceBetween => 'space-between', + Justify::SpaceAround => 'space-around', + Justify::SpaceEvenly => 'space-evenly', + }; + } + + private static function align(Align $a): string + { + return match ($a) { + Align::Start => 'flex-start', + Align::End => 'flex-end', + Align::Center => 'center', + Align::Stretch => 'stretch', + }; + } +} diff --git a/packages/core/src/Html/FontFaces.php b/packages/core/src/Html/FontFaces.php new file mode 100644 index 0000000..b205196 --- /dev/null +++ b/packages/core/src/Html/FontFaces.php @@ -0,0 +1,71 @@ + $families families used by the document + * @param (\Closure(FontFace): ?string)|null $fontUrl + * @return string one rule per line, each ending in a newline; empty for FontMode::None + */ + public static function css(array $families, FontSet $set, FontMode $mode, ?\Closure $fontUrl): string + { + if ($mode === FontMode::None) { + return ''; + } + $used = array_map('strtolower', $families); + $out = ''; + foreach (self::faces($set) as $face) { + if (!in_array(strtolower($face->family), $used, true)) { + continue; + } + $src = $mode === FontMode::Embed ? self::data($face) : self::link($face, $fontUrl); + if ($src === null) { + continue; + } + $out .= '@font-face{font-family:' . Css::string($face->family) . ';src:url(' . $src . ') format("truetype");font-weight:' . $face->weight + . ';font-style:' . ($face->style === FontStyle::Italic ? 'italic' : 'normal') . "}\n"; + } + + return $out; + } + + /** @return list bundled then custom faces; the last registration of a family, weight and style wins */ + private static function faces(FontSet $set): array + { + $byKey = []; + foreach ([...FontSet::bundled(), ...$set->faces] as $face) { + $key = strtolower($face->family) . '|' . $face->weight . '|' . $face->style->value; + unset($byKey[$key]); + $byKey[$key] = $face; + } + + return array_values($byKey); + } + + private static function data(FontFace $face): string + { + $bytes = is_file($face->path) && is_readable($face->path) ? file_get_contents($face->path) : false; + if ($bytes === false) { + throw new FontNotFound('Font file not found: ' . $face->path); + } + + return 'data:font/ttf;base64,' . base64_encode($bytes); + } + + /** @param (\Closure(FontFace): ?string)|null $fontUrl */ + private static function link(FontFace $face, ?\Closure $fontUrl): ?string + { + $url = $fontUrl === null ? null : $fontUrl($face); + + return $url === null ? null : Css::string($url); + } +} diff --git a/packages/core/src/Html/HtmlWriter.php b/packages/core/src/Html/HtmlWriter.php new file mode 100644 index 0000000..633b052 --- /dev/null +++ b/packages/core/src/Html/HtmlWriter.php @@ -0,0 +1,388 @@ + */ + private array $styles = []; + /** @var list closing markup, one per open node */ + private array $closers = []; + /** @var list the open thead/tbody of each open table */ + private array $sections = []; + /** @var \WeakMap cells laid out as plain columns because no row holds them */ + private \WeakMap $strays; + /** @var array */ + private array $families = []; + + public function __construct( + Document $doc, + private readonly Fidelity $fidelity, + private readonly HtmlOptions $options, + ) { + $this->strays = new \WeakMap(); + $this->pageCount = count($doc->pages()); + } + + public static function esc(string $s): string + { + return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML5, 'UTF-8'); + } + + /** @return list font families of every style written, in first-seen order */ + public function usedFamilies(): array + { + return array_keys($this->families); + } + + public function html(): string + { + return $this->out; + } + + public function enterPage(Page $page, int $index): void + { + $this->page = $page; + $path = $this->pageCount > 1 ? sprintf('page[%d]', $index) : 'page'; + $this->fidelity->approximated('page box', $path); + if ($page->headerNodes() !== [] || $page->footerNodes() !== []) { + $this->fidelity->approximated('header and footer once per page', $path); + } + if ($page->backgroundNodes() !== []) { + $this->fidelity->approximated('background layer', $path); + } + $m = $page->margins(); + $this->out .= '
'; + } + + public function leavePage(Page $page, int $index): void + { + $this->out .= '
'; + } + + public function enterRegion(Region $region, ComputedStyle $root): void + { + $this->styles = [$root]; + $this->families[$root->fontFamily] = true; + $style = match ($region) { + Region::Header => ' style="margin-bottom:' . Css::pt($this->page?->headerGapLength()->points ?? 0.0) . '"', + Region::Footer => ' style="margin-top:' . Css::pt($this->page?->footerGapLength()->points ?? 0.0) . '"', + default => '', + }; + $this->out .= '
'; + } + + public function leaveRegion(Region $region): void + { + $this->out .= '
'; + $this->styles = []; + } + + public function enter(Node $node, ComputedStyle $style, Visit $at): bool + { + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + $closer = match (true) { + $node instanceof View => $this->view($style, $parent, $at), + $node instanceof Text => $this->text($node, $style, $parent, $at), + $node instanceof Link => $this->link($node, $style, $parent, $at), + $node instanceof Spacer => $this->spacer($node, $style), + $node instanceof PageBreak => $this->pageBreak(), + $node instanceof PageNumber => '', + $node instanceof Image => $this->image($node, $style, $at), + $node instanceof Table => $this->table($node, $style, $at), + $node instanceof TableRow => $this->tableRow($node, $style, $at), + $node instanceof TableCell => $this->tableCell($node, $style, $at), + default => throw new \LogicException('HTML export of ' . $node->debugName() . ' is not supported'), + }; + $this->styles[] = $style; + $this->closers[] = $closer; + + return true; + } + + public function leave(Node $node, ComputedStyle $style, Visit $at): void + { + array_pop($this->styles); + if ($node instanceof Table) { + $open = array_pop($this->sections); + $this->out .= $open === null ? '' : ''; + } + $this->out .= (string) array_pop($this->closers); + } + + /** @return array */ + private function textCss(ComputedStyle $s, ?ComputedStyle $parent): array + { + $this->families[$s->fontFamily] = true; + + return Css::text($s, $parent, $this->options->fonts()->fallbacks); + } + + private function view(ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + $this->checkDecoration($s, $parent, $at->path()); + $decls = [...Css::box($s), ...Css::flexContainer($s)]; + if ($this->parentIsFlex($at)) { + $decls += Css::flexItem($s); + } + $decls = [...$decls, ...Css::pagination($s, $parent), ...$this->textCss($s, $parent)]; + $this->out .= ''; + + return '
'; + } + + private function text(Text $t, ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + $override = self::overridesDecoration($s, $parent); + $tag = in_array($t->preset(), ['h1', 'h2', 'h3', 'h4'], true) ? $t->preset() : 'p'; + $decls = [...Css::box($s), ...$this->textCss($s, $parent), ...($this->parentIsFlex($at) ? Css::flexItem($s) : []), ...Css::pagination($s, $parent)]; + $this->out .= '<' . $tag . self::styleAttr($decls) . '>'; + foreach ($t->spanList() as $span) { + $override = $this->span($span, $s) || $override; + } + if ($override) { + $this->fidelity->approximated('text decoration override', $at->path()); + } + + return ''; + } + + /** @return bool whether the span turns off a decoration the paragraph has on */ + private function span(\Papercut\Span $span, ComputedStyle $text): bool + { + if ($span->lineBreak) { + $this->out .= '
'; + return false; + } + $style = $text->child($span->style); + $diff = $this->textCss($style, $text); + $body = self::esc($span->text); + if ($diff !== []) { + $body = '' . $body . ''; + } + if ($span->href !== null) { + $body = '
' . $body . ''; + } + $this->out .= $body; + + return self::overridesDecoration($style, $text); + } + + private function link(Link $l, ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + $href = self::href($l->uri()); + $this->checkDecoration($s, $parent, $at->path()); + $decls = ['display' => 'flex', 'flex-direction' => 'column', ...Css::box($s), ...($this->parentIsFlex($at) ? Css::flexItem($s) : []), ...Css::pagination($s, $parent), ...$this->textCss($s, $parent)]; + $this->out .= ''; + + return ''; + } + + private function spacer(Spacer $sp, ComputedStyle $s): string + { + $fixed = $sp->fixedSize(); + $flex = $fixed !== null ? '0 0 ' . Css::pt($fixed->points) : Css::num($s->grow) . ' 0 0'; + $this->out .= ' $flex]) . '>
'; + + return ''; + } + + private function pageBreak(): string + { + $this->out .= '
'; + + return ''; + } + + private function image(Image $n, ComputedStyle $s, Visit $at): string + { + $decls = Css::box($s); + if ($this->parentIsFlex($at)) { + $decls += Css::flexItem($s); + } + $decls['object-fit'] = $n->fitMode()->value; + $this->out .= ''; + + return ''; + } + + private function table(Table $n, ComputedStyle $s, Visit $at): string + { + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + $tracks = self::tracks($n); + $fixed = 0.0; + $percent = 0.0; + $frs = 0.0; + foreach ($tracks as $t) { + $fixed += $t instanceof Length ? $t->points : 0.0; + $percent += $t instanceof Percent ? $t->value : 0.0; + $frs += $t instanceof Fr ? $t->value : 0.0; + } + if ($frs > 0.0) { + $this->fidelity->approximated('fr column widths', $at->path()); + } + $hasAuto = in_array(true, array_map(static fn (object $t): bool => $t instanceof Auto, $tracks), true); + if ($hasAuto) { + $this->fidelity->approximated('auto column widths', $at->path()); + } + + $decls = [...Css::box($s), ...$this->textCss($s, $parent)]; + if ($this->parentIsFlex($at)) { + $decls += Css::flexItem($s); + } + if ($hasAuto) { + $decls['table-layout'] = 'auto'; + } + $this->out .= ''; + foreach ($tracks as $t) { + $width = match (true) { + $t instanceof Length => Css::pt($t->points), + $t instanceof Percent => Css::num($t->value) . '%', + $t instanceof Fr => $hasAuto ? null : self::frWidth($fixed, $percent, $t->value / $frs), + default => null, + }; + $this->out .= ' $width]) . '>'; + } + $this->out .= ''; + $this->sections[] = null; + + return ''; + } + + private static function frWidth(float $fixed, float $percent, float $share): string + { + $minus = ($fixed > 0.0 ? ' - ' . Css::pt($fixed) : '') . ($percent > 0.0 ? ' - ' . Css::num($percent) . '%' : ''); + + return ($minus === '' ? 'calc(100%' : 'calc((100%' . $minus . ')') . ' * ' . Css::num($share) . ')'; + } + + /** + * One track per column, as the PDF layout counts them: the widest row's total span; missing tracks are 1fr, extra ones ignored. + * + * @return list + */ + private static function tracks(Table $table): array + { + $columns = 0; + foreach ([...$table->headerRows(), ...$table->bodyRows()] as $row) { + $columns = max($columns, array_sum(array_map(static fn (TableCell $c): int => $c->span(), $row->cells()))); + } + $given = $table->trackList()->tracks ?? []; + $tracks = []; + for ($k = 0; $k < $columns; $k++) { + $tracks[] = $given[$k] ?? Fr::of(1); + } + + return $tracks; + } + + private function tableRow(TableRow $n, ComputedStyle $s, Visit $at): string + { + if (!$at->parent() instanceof Table) { + throw new \LogicException(sprintf('No layouter registered for %s at "%s".', TableRow::class, $at->path())); + } + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + $want = $at->inHeader() ? 'thead' : 'tbody'; + $open = end($this->sections); + if ($open !== $want) { + $this->out .= ($open === null ? '' : '') . '<' . $want . '>'; + array_pop($this->sections); + $this->sections[] = $want; + } + $this->out .= 'textCss($s, $parent)]) . '>'; + + return ''; + } + + private function tableCell(TableCell $n, ComputedStyle $s, Visit $at): string + { + $parent = end($this->styles); + \assert($parent instanceof ComputedStyle); + if (!$at->parent() instanceof TableRow) { + return $this->strayCell($n, $s, $parent, $at); + } + $this->checkDecoration($s, $parent, $at->path()); + $tag = $n->header() ? 'th' : 'td'; + $span = $n->span() > 1 ? ' colspan="' . $n->span() . '"' : ''; + $this->out .= '<' . $tag . $span . self::styleAttr([...Css::box($s), ...$this->textCss($s, $parent)]) . '>'; + + return ''; + } + + /** A cell outside a table is laid out as a column; it becomes a div and warns like the PDF layout does. */ + private function strayCell(TableCell $n, ComputedStyle $s, ComputedStyle $parent, Visit $at): string + { + if (!isset($this->strays[$n])) { + $this->strays[$n] = true; + $this->fidelity->warn(sprintf('[%s] A table cell outside a table is laid out as a column', $at->path())); + } + $this->checkDecoration($s, $parent, $at->path()); + $decls = [...Css::box($s), ...($this->parentIsFlex($at) ? Css::flexItem($s) : []), ...Css::flexContainer($s), ...Css::pagination($s, $parent), ...$this->textCss($s, $parent)]; + $this->out .= ''; + + return '

'; + } + + /** Flex items only make sense inside flex containers: Views, Links and region roots. */ + private function parentIsFlex(Visit $at): bool + { + $p = $at->parent(); + + return $p === null || $p instanceof View || $p instanceof Link || ($p instanceof TableCell && isset($this->strays[$p])); + } + + /** CSS cannot switch off a decoration an ancestor drew, so turning one off is only approximated. */ + private function checkDecoration(ComputedStyle $s, ComputedStyle $parent, string $path): void + { + if (self::overridesDecoration($s, $parent)) { + $this->fidelity->approximated('text decoration override', $path); + } + } + + private static function overridesDecoration(ComputedStyle $s, ComputedStyle $parent): bool + { + return ($parent->underline && !$s->underline) || ($parent->strike && !$s->strike); + } + + private static function href(string $uri): string + { + if (!\Papercut\Paint\Link::allowed($uri)) { + throw InvalidValue::for('link', $uri)->because('only http, https, mailto, tel and #fragment links are allowed'); + } + + return ltrim($uri); + } + + /** @param array $decls */ + private static function attr(array $decls): string + { + return self::esc(Css::attr($decls)); + } + + /** @param array $decls */ + private static function styleAttr(array $decls): string + { + return $decls === [] ? '' : ' style="' . self::attr($decls) . '"'; + } +} diff --git a/packages/core/src/Html/ImageSource.php b/packages/core/src/Html/ImageSource.php new file mode 100644 index 0000000..75b0310 --- /dev/null +++ b/packages/core/src/Html/ImageSource.php @@ -0,0 +1,65 @@ + $kind, 'value' => $value] = $image->source(); + if ($kind === 'file') { + $bytes = is_file($value) && is_readable($value) ? file_get_contents($value) : false; + if ($bytes === false) { + throw new ImageUnreadable(sprintf('Cannot read image "%s": file not found or not readable', $value)); + } + $mime = self::mime($bytes) ?? throw new ImageUnreadable(sprintf('Cannot read image "%s": Unsupported image format (expected PNG or JPEG).', $value)); + } else { + $bytes = $value; + $mime = self::mime($bytes) ?? throw new ImageUnreadable('Cannot read image bytes: Unsupported image format (expected PNG or JPEG).'); + } + + return 'data:' . $mime . ';base64,' . base64_encode($bytes); + } + + private static function mime(string $bytes): ?string + { + return match (true) { + str_starts_with($bytes, "\x89PNG\r\n\x1a\n") => 'image/png', + str_starts_with($bytes, "\xFF\xD8\xFF") => 'image/jpeg', + default => null, + }; + } + + /** An allowed link scheme, a data:image/ URI, or a relative path. Control characters and edge whitespace are refused: browsers drop them inside schemes. */ + private static function safe(string $url): bool + { + if ($url === '' || preg_match('/[\x00-\x1f\x7f]/', $url) === 1 || trim($url) !== $url) { + return false; + } + + return Link::allowed($url) + || preg_match('~^data:image/~i', $url) === 1 + || preg_match('~^(?![a-z][a-z0-9+.-]*:|[/\\\\]{2}|\\\\)~i', $url) === 1; + } +} diff --git a/packages/core/src/Html/Stylesheet.php b/packages/core/src/Html/Stylesheet.php new file mode 100644 index 0000000..e54e6a9 --- /dev/null +++ b/packages/core/src/Html/Stylesheet.php @@ -0,0 +1,41 @@ +pages()[0] ?? null; + $w = $page === null ? 595.28 : $page->width(); + $h = $page === null ? 841.89 : $page->height(); + $m = $page === null ? '0pt' : self::margins($page->margins()); + + return $fontFaces . implode("\n", [ + '.papercut{background:#e5e7eb;padding:24px 0}', + '.papercut *,.papercut *::before,.papercut *::after{box-sizing:border-box}', + '.papercut p,.papercut h1,.papercut h2,.papercut h3,.papercut h4{margin:0;font:inherit;overflow-wrap:anywhere}', + '.papercut a{color:inherit;text-decoration:inherit}', + '.papercut img{display:block;max-width:100%}', + '.papercut table{border-collapse:collapse;table-layout:fixed;width:100%}', + '.papercut td,.papercut th{vertical-align:top;text-align:inherit;font-weight:inherit}', + '.papercut .pc-page{position:relative;z-index:0;margin:0 auto 24px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.15)}', + '.papercut .pc-header,.papercut .pc-body,.papercut .pc-footer,.papercut .pc-background{display:flex;flex-direction:column;align-items:stretch}', + '.papercut .pc-background{position:absolute;inset:0;z-index:-1}', + '.papercut .pc-break{break-before:page}', + '@page{size:' . Css::pt($w) . ' ' . Css::pt($h) . ';margin:' . $m . '}', + '@media print{.papercut{background:none;padding:0}.papercut .pc-page{max-width:none;min-height:0;margin:0;padding:0;box-shadow:none;break-after:page}}', + ]); + } + + private static function margins(\Papercut\Value\Edges $e): string + { + return Css::pt($e->top->points) . ' ' . Css::pt($e->right->points) . ' ' . Css::pt($e->bottom->points) . ' ' . Css::pt($e->left->points); + } +} diff --git a/packages/core/src/Text/FontRegistry.php b/packages/core/src/Text/FontRegistry.php index 977ee67..f968db7 100644 --- a/packages/core/src/Text/FontRegistry.php +++ b/packages/core/src/Text/FontRegistry.php @@ -20,12 +20,11 @@ public function __construct(private readonly Backend $backend) public static function withDefaults(Backend $backend): self { - $dir = __DIR__ . '/../../resources/fonts/Inter'; - return (new self($backend)) - ->register('Inter', "$dir/Inter-Regular.ttf", 400) - ->register('Inter', "$dir/Inter-Italic.ttf", 400, FontStyle::Italic) - ->register('Inter', "$dir/Inter-Bold.ttf", 700) - ->register('Inter', "$dir/Inter-BoldItalic.ttf", 700, FontStyle::Italic); + $registry = new self($backend); + foreach (FontSet::bundled() as $face) { + $registry->register($face->family, $face->path, $face->weight, $face->style); + } + return $registry; } public static function fromSet(FontSet $set, Backend $backend): self diff --git a/packages/core/src/Text/FontSet.php b/packages/core/src/Text/FontSet.php index 1bafba6..f588d93 100644 --- a/packages/core/src/Text/FontSet.php +++ b/packages/core/src/Text/FontSet.php @@ -17,6 +17,18 @@ public function __construct( ) { } + /** @return list the bundled Inter faces, in registration order */ + public static function bundled(): array + { + $dir = __DIR__ . '/../../resources/fonts/Inter'; + return [ + new FontFace('Inter', "$dir/Inter-Regular.ttf", 400), + new FontFace('Inter', "$dir/Inter-Italic.ttf", 400, FontStyle::Italic), + new FontFace('Inter', "$dir/Inter-Bold.ttf", 700), + new FontFace('Inter', "$dir/Inter-BoldItalic.ttf", 700, FontStyle::Italic), + ]; + } + public function with(FontFace ...$faces): self { return new self([...$this->faces, ...array_values($faces)], $this->fallbacks); diff --git a/packages/core/tests/Export/HtmlOptionsTest.php b/packages/core/tests/Export/HtmlOptionsTest.php new file mode 100644 index 0000000..4a8ba3f --- /dev/null +++ b/packages/core/tests/Export/HtmlOptionsTest.php @@ -0,0 +1,53 @@ +fullPage()); + self::assertSame(FontMode::None, $o->fontMode()); + self::assertNull($o->imageUrl()); + self::assertNull($o->fontUrl()); + self::assertFalse($o->strict()); + } + + public function test_from_copies_shared_fields_and_keeps_html_options(): void + { + $theme = Theme::default(); + $fonts = new FontSet(); + $warn = static function (string $m): void { + }; + $o = HtmlOptions::from(new ExportOptions($theme, $fonts, $warn, true)); + self::assertSame($theme, $o->theme()); + self::assertSame($fonts, $o->fonts()); + self::assertSame($warn, $o->onWarning()); + self::assertTrue($o->strict()); + self::assertFalse($o->fullPage()); + self::assertSame(FontMode::None, $o->fontMode()); + + $html = new HtmlOptions(fullPage: true, fontMode: FontMode::Embed); + self::assertSame($html, HtmlOptions::from($html)); + } + + public function test_with_full_page_copies_everything(): void + { + $img = static fn ($i): ?string => null; + $o = new HtmlOptions(strict: true, fontMode: FontMode::Link, imageUrl: $img); + $p = $o->withFullPage(true); + self::assertTrue($p->fullPage()); + self::assertFalse($o->fullPage()); + self::assertTrue($p->strict()); + self::assertSame(FontMode::Link, $p->fontMode()); + self::assertSame($img, $p->imageUrl()); + } +} diff --git a/packages/core/tests/Feature/ContentParityTest.php b/packages/core/tests/Feature/ContentParityTest.php new file mode 100644 index 0000000..dabb3da --- /dev/null +++ b/packages/core/tests/Feature/ContentParityTest.php @@ -0,0 +1,102 @@ + */ + public static function documents(): iterable + { + foreach (['fixtures' => __DIR__ . '/../Visual/fixtures', 'examples' => __DIR__ . '/../../../../examples/core/documents'] as $kind => $dir) { + $files = glob("$dir/*.php") ?: []; + sort($files); + foreach ($files as $file) { + yield $kind . '-' . basename($file, '.php') => [$file]; + } + } + } + + protected function setUp(): void + { + exec('command -v pdftotext 2>/dev/null', $out, $code); + if ($code !== 0) { + self::markTestSkipped('pdftotext (poppler) is not installed.'); + } + } + + #[DataProvider('documents')] + public function test_pdf_and_html_have_the_same_words(string $file): void + { + $source = require $file; + self::assertInstanceOf(Document::class, $source); + $pages = array_map(static fn (Page $p): Page => Page::size(pt($p->width()), pt($p->height()))->margin($p->margins())->body(...$p->bodyNodes()), $source->pages()); + $document = Document::make(...$pages); + + self::assertSame($this->words($this->pdfText($document)), $this->words($this->htmlText($document))); + } + + #[DataProvider('documents')] + public function test_html_export_does_not_change_pdf_bytes(string $file): void + { + $document = require $file; + self::assertInstanceOf(Document::class, $document); + $document = $document->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $before = $document->toPdf()->bytes(); + $document->toHtml(); + $document->toHtml(new \Papercut\Export\HtmlOptions(fontMode: \Papercut\Export\FontMode::Embed)); + self::assertSame($before, $document->toPdf()->bytes()); + } + + private function pdfText(Document $document): string + { + $path = tempnam(sys_get_temp_dir(), 'pc') ?: self::fail('tempnam'); + try { + file_put_contents($path, $document->toPdf()->bytes()); + return (string) shell_exec('pdftotext -enc UTF-8 ' . escapeshellarg($path) . ' - 2>/dev/null'); + } finally { + @unlink($path); + } + } + + private function htmlText(Document $document): string + { + $dom = \Dom\HTMLDocument::createFromString($document->toHtml(new \Papercut\Export\HtmlOptions(fullPage: true))->bytes()); + return $dom->body === null ? '' : $this->textOf($dom->body); + } + + /** textContent with a space around every block-level element, as a browser would lay it out (only span and a are inline). */ + private function textOf(\Dom\Node $node): string + { + $text = ''; + foreach ($node->childNodes as $child) { + if ($child instanceof \Dom\Text) { + $text .= $child->data; + } elseif ($child instanceof \Dom\Element) { + $inline = in_array($child->localName, ['span', 'a'], true); + $text .= ($inline ? '' : ' ') . $this->textOf($child) . ($inline ? '' : ' '); + } + } + return $text; + } + + /** @return list */ + private function words(string $text): array + { + // pdftotext dehyphenates a hyphenated word that wraps at the hyphen ("break-after" becomes "breakafter"), which + // the HTML text cannot show, so hyphens are ignored on both sides. This is the only normalisation applied. + $text = str_replace('-', '', $text); + $words = preg_split('/\s+/u', trim($text), -1, PREG_SPLIT_NO_EMPTY) ?: []; + $words = array_values(array_unique($words)); + sort($words, SORT_STRING); + return $words; + } +} diff --git a/packages/core/tests/Feature/ExamplesTest.php b/packages/core/tests/Feature/ExamplesTest.php index 760aec2..2f5b3d7 100644 --- a/packages/core/tests/Feature/ExamplesTest.php +++ b/packages/core/tests/Feature/ExamplesTest.php @@ -39,7 +39,7 @@ public function test_example_produces_a_valid_pdf(string $name, int $minPages, i public function test_report_example_makes_no_unbacked_claims(): void { - $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/report.php'); + $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/documents/report.php'); foreach (['PAGES PER SECOND', '38 KB', 'Benchmark', 'shrinking', 'every modifier also accepts a string'] as $claim) { self::assertStringNotContainsString($claim, $source); } @@ -47,7 +47,7 @@ public function test_report_example_makes_no_unbacked_claims(): void public function test_transcript_heading_is_a_sibling_of_the_summary_and_courses_are_distinct(): void { - $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/transcript.php'); + $source = (string) file_get_contents(dirname(__DIR__, 4) . '/examples/core/documents/transcript.php'); self::assertDoesNotMatchRegularExpression('/\$summary = Column::make\(\s*Text::h3/', $source); self::assertStringContainsString("Text::h3('Cumulative summary')->keepWithNext(), \$summary", $source); $root = dirname(__DIR__, 4); diff --git a/packages/core/tests/Html/CssTest.php b/packages/core/tests/Html/CssTest.php new file mode 100644 index 0000000..cf03944 --- /dev/null +++ b/packages/core/tests/Html/CssTest.php @@ -0,0 +1,172 @@ +root())->child($style); + } + + public function test_num_formatting(): void + { + self::assertSame('12', Css::num(12.0)); + self::assertSame('0.333', Css::num(0.3333)); + self::assertSame('0', Css::num(-0.0)); + self::assertSame('0', Css::num(-0.0001)); + self::assertSame('1.5', Css::num(1.5)); + self::assertSame('12pt', Css::pt(12.0)); + } + + public function test_length(): void + { + self::assertSame('10pt', Css::length(Length::parse('10pt'))); + self::assertSame('50%', Css::length(Percent::of(50.0))); + self::assertNull(Css::length(Auto::instance())); + } + + public function test_color(): void + { + self::assertSame('#0a1b2c', Css::color(Color::rgb(10, 27, 44))); + self::assertSame('rgba(17, 34, 51, 0.502)', Css::color(Color::hex('#11223380'))); + self::assertSame('rgba(0, 0, 0, 0)', Css::color(Color::transparent())); + } + + public function test_string_escapes_quotes_backslashes_and_control_chars(): void + { + self::assertSame('"Inter"', Css::string('Inter')); + $evil = Css::string('Evil"; background:url(x)'); + self::assertSame('"Evil\\"; background:url(x)"', $evil); + self::assertSame('"a\\\\b"', Css::string('a\\b')); + self::assertSame('"a\\a b"', Css::string("a\nb")); + self::assertSame('"a\\d b"', Css::string("a\rb")); + self::assertSame('"\\0 "', Css::string("\0")); + self::assertSame('"\\3c /style>"', Css::string('')); + self::assertSame('"Zürich"', Css::string('Zürich')); + self::assertSame('"Evil\\"; background:url(x)", system-ui, sans-serif', Css::fontFamily('Evil"; background:url(x)')); + } + + public function test_box_omits_defaults_and_emits_borders_only_with_width(): void + { + self::assertSame([], Css::box($this->child(new Style()))); + + $s = $this->child(new Style( + width: Length::parse('100pt'), + maxWidth: Percent::of(80.0), + padding: Edges::of(Length::parse('1pt'), Length::parse('2pt')), + margin: Edges::all(Length::parse('3pt')), + radius: Length::parse('4pt'), + background: Color::hex('#ff0000'), + opacity: 0.5, + )); + self::assertSame([ + 'width' => '100pt', + 'max-width' => '80%', + 'padding' => '1pt 2pt 1pt 2pt', + 'margin' => '3pt 3pt 3pt 3pt', + 'border-radius' => '4pt', + 'background' => '#ff0000', + 'opacity' => '0.5', + ], Css::box($s)); + + $b = $this->child(new Style(borderWidth: Edges::of(Length::parse('1pt'), Length::zero()), borderColor: Color::hex('#00ff00'), borderStyle: BorderStyle::Dashed)); + $box = Css::box($b); + self::assertSame('1pt 0pt 1pt 0pt', $box['border-width']); + self::assertSame('dashed', $box['border-style']); + self::assertSame('#00ff00', $box['border-color']); + + $noWidth = Css::box($this->child(new Style(borderColor: Color::hex('#00ff00'), borderStyle: BorderStyle::Dashed))); + self::assertSame([], $noWidth); + } + + public function test_text_diffs_against_parent(): void + { + $parent = $this->root(); + $all = Css::text($parent, null); + foreach (['font-family', 'font-size', 'font-weight', 'font-style', 'color', 'line-height', 'letter-spacing', 'text-align', 'text-decoration'] as $k) { + self::assertArrayHasKey($k, $all); + } + self::assertSame('normal', $all['font-style']); + self::assertSame('none', $all['text-decoration']); + + $child = $this->child(new Style(color: Color::hex('#ff0000')), $parent); + self::assertSame(['color' => '#ff0000'], Css::text($child, $parent)); + + $rich = $this->child(new Style(italic: true, underline: true, strike: true, textAlign: TextAlign::Center, letterSpacing: Length::parse('1pt'), fontWeight: 700), $parent); + $d = Css::text($rich, $parent); + self::assertSame('italic', $d['font-style']); + self::assertSame('underline line-through', $d['text-decoration']); + self::assertSame('center', $d['text-align']); + self::assertSame('1pt', $d['letter-spacing']); + self::assertSame('700', $d['font-weight']); + self::assertSame(['text-decoration' => 'underline'], Css::text($this->child(new Style(underline: true), $parent), $parent)); + self::assertSame(['text-decoration' => 'line-through'], Css::text($this->child(new Style(strike: true), $parent), $parent)); + } + + public function test_flex_maps_enums(): void + { + self::assertSame(['display' => 'flex', 'flex-direction' => 'column'], Css::flexContainer($this->child(new Style()))); + + $s = $this->child(new Style(direction: Direction::Row, gap: Length::parse('6pt'), justify: Justify::SpaceBetween, alignItems: Align::Center, wrap: true)); + self::assertSame([ + 'display' => 'flex', + 'flex-direction' => 'row', + 'gap' => '6pt', + 'justify-content' => 'space-between', + 'align-items' => 'center', + 'flex-wrap' => 'wrap', + ], Css::flexContainer($s)); + + $map = [Justify::Start->value => 'flex-start', Justify::End->value => 'flex-end', Justify::Center->value => 'center', Justify::SpaceBetween->value => 'space-between', Justify::SpaceAround->value => 'space-around', Justify::SpaceEvenly->value => 'space-evenly']; + foreach (Justify::cases() as $j) { + if ($j === Justify::Start) { + continue; + } + $d = Css::flexContainer($this->child(new Style(justify: $j))); + self::assertSame($map[$j->value], $d['justify-content']); + } + self::assertSame('flex-start', Css::flexContainer($this->child(new Style(alignItems: Align::Start)))['align-items']); + self::assertSame('flex-end', Css::flexContainer($this->child(new Style(alignItems: Align::End)))['align-items']); + + self::assertSame([], Css::flexItem($this->child(new Style()))); + self::assertSame(['flex' => '1 1 auto'], Css::flexItem($this->child(new Style(grow: 1.0)))); + self::assertSame(['flex' => '0 0 30%', 'align-self' => 'flex-end'], Css::flexItem($this->child(new Style(shrink: 0.0, basis: Percent::of(30.0), alignSelf: Align::End)))); + self::assertSame(['align-self' => 'stretch'], Css::flexItem($this->child(new Style(alignSelf: Align::Stretch)))); + } + + public function test_pagination_maps_keep_and_breaks(): void + { + self::assertSame([], Css::pagination($this->child(new Style()))); + $s = $this->child(new Style(breakBefore: true, breakAfter: true, keepTogether: true, minLines: 5)); + self::assertSame([ + 'break-before' => 'page', + 'break-after' => 'page', + 'break-inside' => 'avoid', + 'orphans' => '5', + 'widows' => '5', + ], Css::pagination($s)); + + $parent = $this->child(new Style(minLines: 5)); + self::assertSame([], Css::pagination($this->child(new Style(), $parent), $parent)); + self::assertSame(['orphans' => '1', 'widows' => '1'], Css::pagination($this->child(new Style(minLines: 1), $parent), $parent)); + } + + public function test_attr(): void + { + self::assertSame('', Css::attr([])); + self::assertSame('a:1;b:2', Css::attr(['a' => '1', 'b' => '2'])); + } +} diff --git a/packages/core/tests/Html/GoldenHtmlTest.php b/packages/core/tests/Html/GoldenHtmlTest.php new file mode 100644 index 0000000..22c7f27 --- /dev/null +++ b/packages/core/tests/Html/GoldenHtmlTest.php @@ -0,0 +1,59 @@ + */ + public static function documents(): iterable + { + foreach (['Visual/fixtures' => 'fixtures', '../../../examples/core/documents' => 'examples'] as $dir => $kind) { + $files = glob(__DIR__ . "/../$dir/*.php") ?: []; + sort($files); + foreach ($files as $file) { + yield $kind . '-' . basename($file, '.php') => [$file, $kind]; + } + } + } + + #[DataProvider('documents')] + public function test_matches_golden(string $file, string $kind): void + { + $document = require $file; + self::assertInstanceOf(Document::class, $document); + $document = $document->createdAt(new \DateTimeImmutable('2026-01-01T00:00:00Z')); + $html = $document->toHtml(new HtmlOptions(fullPage: true))->bytes(); + + $canonical = $this->canonicalize($html); + $name = ($kind === 'examples' ? 'example-' : '') . basename($file, '.php'); + $golden = __DIR__ . "/golden/$name.html"; + if (getenv('UPDATE_HTML_GOLDEN') === '1') { + file_put_contents($golden, $canonical); + } + self::assertFileExists($golden); + self::assertSame(file_get_contents($golden), $canonical); + } + + private function canonicalize(string $html): string + { + set_error_handler(static function (int $no, string $msg): never { + throw new \RuntimeException("HTML parse warning: $msg"); + }); + try { + return \Dom\HTMLDocument::createFromString($html)->saveHtml(); + } finally { + restore_error_handler(); + } + } +} diff --git a/packages/core/tests/Html/HtmlExporterTest.php b/packages/core/tests/Html/HtmlExporterTest.php new file mode 100644 index 0000000..164b569 --- /dev/null +++ b/packages/core/tests/Html/HtmlExporterTest.php @@ -0,0 +1,326 @@ +toHtml($o)->bytes(); + } + + private function parse(string $html): \Dom\HTMLDocument + { + if (!str_starts_with($html, 't' . $html . ''; + } + set_error_handler(static function (int $no, string $msg): never { + throw new \RuntimeException($msg); + }); + try { + return \Dom\HTMLDocument::createFromString($html); + } finally { + restore_error_handler(); + } + } + + /** @return list<\Dom\Node> */ + private function xpath(string $html, string $query): array + { + $nodes = []; + $xp = new \Dom\XPath($this->parse($html)); + $xp->registerNamespace('h', 'http://www.w3.org/1999/xhtml'); + $query = (string) preg_replace('~(?<=/)([a-z][a-z0-9]*)(?![a-z0-9(\-])~', 'h:$1', $query); + foreach ($xp->query($query) as $n) { + $nodes[] = $n; + } + return $nodes; + } + + private function one(string $html, string $query): \Dom\Element + { + $found = $this->xpath($html, $query); + self::assertCount(1, $found, $query); + self::assertInstanceOf(\Dom\Element::class, $found[0]); + return $found[0]; + } + + private function attr(\Dom\Node $e, string $name): string + { + self::assertInstanceOf(\Dom\Element::class, $e); + + return (string) $e->getAttribute($name); + } + + private function simple(): Document + { + return Document::make(Page::a4()->body(Text::make('Hello'))); + } + + public function test_fragment_and_full_page_shapes(): void + { + $doc = $this->simple()->title('T')->lang('fr'); + $out = $doc->toHtml(); + self::assertSame('text/html; charset=UTF-8', $out->mime()); + self::assertSame('html', $out->extension()); + self::assertStringStartsWith('
', $full); + self::assertSame('T', $this->one($full, '//head/title')->textContent); + self::assertCount(1, $this->xpath($full, '//head/style')); + self::assertCount(1, $this->xpath($full, '//body/div[@class="papercut"]/section[@class="pc-page"]')); + self::assertStringContainsString('', $this->html($this->simple(), new HtmlOptions(fullPage: true))); + } + + public function test_row_is_flex_row_with_gap_and_justify(): void + { + $doc = Document::make(Page::a4()->body(Row::make(Text::make('a'), Text::make('b'))->gap(pt(8))->justify(Justify::SpaceBetween))); + $row = $this->one($this->html($doc), '//div[@class="pc-body"]/div'); + $style = $this->attr($row, 'style'); + self::assertStringContainsString('display:flex', $style); + self::assertStringContainsString('flex-direction:row', $style); + self::assertStringContainsString('gap:8pt', $style); + self::assertStringContainsString('justify-content:space-between', $style); + } + + public function test_text_presets_map_to_headings_and_paragraphs(): void + { + $doc = Document::make(Page::a4()->body(Text::h1('A'), Text::h2('B'), Text::h3('C'), Text::h4('D'), Text::p('E'), Text::make('F'))); + $html = $this->html($doc); + foreach (['h1', 'h2', 'h3', 'h4'] as $tag) { + self::assertCount(1, $this->xpath($html, "//div[@class='pc-body']/$tag")); + } + self::assertCount(2, $this->xpath($html, "//div[@class='pc-body']/p")); + } + + public function test_spans_emit_diffs_links_and_breaks(): void + { + $doc = Document::make(Page::a4()->body(Text::spans( + Span::text('plain '), + Span::text('em', new Style(italic: true)), + Span::break(), + new Span('go', new Style(), 'https://example.com/?a=1&b=2'), + ))); + $html = $this->html($doc); + $p = $this->one($html, '//div[@class="pc-body"]/p'); + self::assertSame('plain emgo', $p->textContent); + self::assertStringContainsString('font-style:italic', $this->attr($this->one($html, '//p/span'), 'style')); + self::assertCount(1, $this->xpath($html, '//p/br')); + self::assertSame('https://example.com/?a=1&b=2', $this->attr($this->one($html, '//p/a'), 'href')); + self::assertCount(1, $this->xpath($html, '//p/text()[starts-with(., "plain")]')); + } + + public function test_block_link_and_spacers_and_flex_item(): void + { + $doc = Document::make(Page::a4()->body(Column::make( + Link::to('#top', Text::make('x')), + Spacer::size(pt(12)), + Spacer::flex(2), + Text::make('g')->grow(1), + ))); + $html = $this->html($doc); + $a = $this->one($html, '//a[@class="pc-link"]'); + self::assertSame('#top', $this->attr($a, 'href')); + self::assertStringStartsWith('display:flex;flex-direction:column', $this->attr($a, 'style')); + $spacers = $this->xpath($html, '//div[@class="pc-body"]/div/div'); + self::assertCount(2, $spacers); + self::assertSame('flex:0 0 12pt', $this->attr($spacers[0], 'style')); + self::assertSame('flex:2 0 0', $this->attr($spacers[1], 'style')); + self::assertStringContainsString('flex:1 1 auto', $this->attr($this->one($html, '//div[@class="pc-body"]/div/p'), 'style')); + } + + public function test_page_number_is_omitted_silently(): void + { + $reports = []; + $doc = Document::make(Page::a4()->body(Text::make('x'))->footer(PageNumber::make())); + $html = $this->html($doc, new HtmlOptions(onWarning: function (string $m) use (&$reports): void { + $reports[] = $m; + })); + self::assertCount(0, $this->xpath($html, '//div[@class="pc-footer"]/*')); + self::assertSame('', $this->one($html, '//div[@class="pc-footer"]')->textContent); + self::assertStringNotContainsString('Page 1', (string) $this->parse($html)->body?->textContent); + foreach ($reports as $m) { + self::assertStringNotContainsString('page-number', $m); + } + } + + public function test_page_break_and_keep_rules_become_print_css(): void + { + $doc = Document::make(Page::a4()->body( + Text::make('a')->keepTogether()->breakBefore()->breakAfter()->minLines(3), + PageBreak::make(), + Text::make('b'), + )); + $html = $this->html($doc); + $style = $this->attr($this->one($html, '//div[@class="pc-body"]/p[1]'), 'style'); + foreach (['break-before:page', 'break-after:page', 'break-inside:avoid', 'orphans:3', 'widows:3'] as $d) { + self::assertStringContainsString($d, $style); + } + self::assertCount(1, $this->xpath($html, '//div[@class="pc-body"]/div[@class="pc-break"]')); + } + + public function test_header_footer_background_regions_and_reports(): void + { + $messages = []; + $doc = Document::make(Page::a4()->header(Text::make('H'))->footer(Text::make('F'))->background(Text::make('B'))->body(Text::make('x'))); + $html = $this->html($doc, new HtmlOptions(onWarning: function (string $m, ?FidelityReport $r = null) use (&$messages): void { + $messages[] = $m; + })); + self::assertSame([ + 'html: approximated page box at page', + 'html: approximated header and footer once per page at page', + 'html: approximated background layer at page', + ], $messages); + $regions = array_map(static fn (\Dom\Node $n): string => $n instanceof \Dom\Element ? $n->getAttribute('class') ?? '' : '', $this->xpath($html, '//section/div')); + self::assertSame(['pc-background', 'pc-header', 'pc-body', 'pc-footer'], $regions); + self::assertStringContainsString('margin-bottom:', $this->attr($this->one($html, '//div[@class="pc-header"]'), 'style')); + self::assertStringContainsString('margin-top:', $this->attr($this->one($html, '//div[@class="pc-footer"]'), 'style')); + } + + public function test_page_paths_are_indexed_for_several_pages(): void + { + $messages = []; + $doc = Document::make(Page::a4()->body(Text::make('a')), Page::a4()->footer(Text::make('f'))->body(Text::make('b'))); + $this->html($doc, new HtmlOptions(onWarning: function (string $m) use (&$messages): void { + $messages[] = $m; + })); + self::assertSame([ + 'html: approximated page box at page[0]', + 'html: approximated page box at page[1]', + 'html: approximated header and footer once per page at page[1]', + ], $messages); + } + + public function test_underline_override_is_approximated(): void + { + $messages = []; + $doc = Document::make(Page::a4()->body( + Column::make(Text::make('a'), Text::spans(Span::text('x', new Style(underline: false)), Span::text('y', new Style(underline: false))))->underline(), + )); + $html = $this->html($doc, new HtmlOptions(onWarning: function (string $m) use (&$messages): void { + $messages[] = $m; + })); + self::assertContains('html: approximated text decoration override at page > body > column[0] > text[1]', $messages); + self::assertCount(1, array_filter($messages, static fn (string $m): bool => str_contains($m, 'decoration'))); + self::assertCount(2, $this->xpath($html, '//span[contains(@style, "text-decoration:none")]')); + } + + public function test_strict_mode_never_throws_for_html(): void + { + $doc = Document::make(Page::a4()->header(Text::make('H'))->background(Text::make('B'))->body(Text::make('x'))->footer(PageNumber::make())); + self::assertNotSame('', $this->html($doc, new HtmlOptions(strict: true))); + } + + public function test_hostile_text_is_escaped(): void + { + $evil = '"\'&'; + $doc = Document::make(Page::a4()->body(Text::make($evil)))->title($evil)->author($evil)->subject($evil)->keywords($evil)->lang('en">', Text::make('t')))); + $html = $this->html($doc); + self::assertStringNotContainsString('', $this->attr($this->one($html, '//a'), 'href')); + } + + public function test_disallowed_href_throws_like_pdf(): void + { + foreach ([ + Text::spans(new Span('x', href: 'javascript:alert(1)')), + Link::to('javascript:alert(1)', Text::make('x')), + ] as $node) { + $doc = Document::make(Page::a4()->body($node)); + try { + $doc->toPdf(); + self::fail('expected InvalidValue'); + } catch (InvalidValue $pdfError) { + $pdf = $pdfError->getMessage(); + } + try { + $doc->toHtml(); + self::fail('expected InvalidValue'); + } catch (InvalidValue $htmlError) { + self::assertSame($pdf, $htmlError->getMessage()); + } + } + } + + public function test_hostile_font_family_stays_inert(): void + { + $doc = Document::make(Page::a4()->body(Text::make('x')->font('Evil"; background:url(x)'))); + $html = $this->html($doc); + self::assertCount(0, $this->xpath($html, '//b')); + self::assertCount(1, $this->xpath($html, '//style')); + $p = $this->one($html, '//div[@class="pc-body"]/p'); + self::assertStringNotContainsString('', $this->attr($p, 'style')); + self::assertCount(1, $this->xpath($html, '//div[@class="pc-body"]/p[@style]')); + self::assertSame('x', $p->textContent); + } + + public function test_fragment_root_has_no_lang_without_document_lang(): void + { + self::assertNull($this->one($this->html($this->simple()), '//div[@class="papercut"]')->getAttribute('lang')); + } + + public function test_entities_are_escaped_once(): void + { + $doc = Document::make(Page::a4()->body(Text::make('a & b < c'))); + self::assertStringContainsString('a &amp; b &lt; c', $this->html($doc)); + self::assertSame('a & b < c', $this->one($this->html($doc), '//p')->textContent); + } + + public function test_empty_body_and_header_only_pages_are_valid(): void + { + $html = $this->html(Document::make(Page::a4()), new HtmlOptions(fullPage: true)); + self::assertCount(1, $this->xpath($html, '//section[@class="pc-page"]')); + self::assertCount(0, $this->xpath($html, '//section/div')); + + $html = $this->html(Document::make(Page::a4()->header(Text::make('H')))); + self::assertCount(1, $this->xpath($html, '//section/div[@class="pc-header"]')); + self::assertCount(0, $this->xpath($html, '//div[@class="pc-body"]')); + + self::assertStringContainsString('
html(Document::make())); + } + + public function test_metadata_in_full_page_head(): void + { + $doc = $this->simple()->title('Title')->author('Au')->subject('Sub')->keywords('k1, k2'); + $html = $this->html($doc, new HtmlOptions(fullPage: true)); + self::assertSame('Au', $this->attr($this->one($html, '//head/meta[@name="author"]'), 'content')); + self::assertSame('Sub', $this->attr($this->one($html, '//head/meta[@name="description"]'), 'content')); + self::assertSame('k1, k2', $this->attr($this->one($html, '//head/meta[@name="keywords"]'), 'content')); + self::assertSame('width=device-width, initial-scale=1', $this->attr($this->one($html, '//head/meta[@name="viewport"]'), 'content')); + self::assertCount(0, $this->xpath($this->html($this->simple(), new HtmlOptions(fullPage: true)), '//meta[@name="author"]')); + } + + public function test_stylesheet_uses_first_page_box(): void + { + $html = $this->html($this->simple()); + self::assertStringContainsString('@page{size:595.28pt 841.89pt;margin:56.693pt 56.693pt 56.693pt 56.693pt}', $html); + self::assertStringContainsString('max-width:595.28pt;min-height:841.89pt;padding:56.693pt', $html); + } +} diff --git a/packages/core/tests/Html/HtmlFontsTest.php b/packages/core/tests/Html/HtmlFontsTest.php new file mode 100644 index 0000000..aa6dc02 --- /dev/null +++ b/packages/core/tests/Html/HtmlFontsTest.php @@ -0,0 +1,109 @@ +body($family === null ? $text : $text->font($family))); + } + + public function test_none_emits_family_stack_only(): void + { + $html = $this->doc()->toHtml()->bytes(); + self::assertStringNotContainsString('@font-face', $html); + self::assertStringContainsString('font-family:"Inter", system-ui, sans-serif', $html); + } + + public function test_embed_emits_only_used_families(): void + { + $extra = new FontFace('Other', __DIR__ . '/../../resources/fonts/Inter/Inter-Regular.ttf'); + $o = new HtmlOptions(fonts: new FontSet([$extra]), fontMode: FontMode::Embed); + $html = $this->doc()->toHtml($o)->bytes(); + self::assertSame(4, substr_count($html, '@font-face')); + self::assertStringNotContainsString('"Other"', $html); + self::assertStringContainsString('@font-face{font-family:"Inter";src:url(data:font/ttf;base64,', $html); + self::assertStringContainsString('format("truetype");font-weight:700;font-style:italic}', $html); + self::assertLessThan(strpos($html, '.papercut{'), strpos($html, '@font-face')); + } + + public function test_embed_includes_a_used_custom_family_and_last_registration_wins(): void + { + $dir = __DIR__ . '/../../resources/fonts/Inter'; + $set = new FontSet([new FontFace('Inter', "$dir/Inter-Bold.ttf", 400), new FontFace('Mine', "$dir/Inter-Bold.ttf", 400)]); + $o = new HtmlOptions(fonts: $set, fontMode: FontMode::Embed); + $html = $this->doc('Mine')->toHtml($o)->bytes(); + self::assertSame(1, substr_count($html, 'font-family:"Mine"')); + self::assertSame(4, substr_count($html, 'font-family:"Inter"')); + self::assertSame(1, preg_match('/font-family:"Inter";src:url\(data:font\/ttf;base64,([^)]+)\) format\("truetype"\);font-weight:400;font-style:normal/', $html, $m)); + self::assertSame(base64_encode((string) file_get_contents("$dir/Inter-Bold.ttf")), $m[1] ?? null); + } + + public function test_embed_includes_fallback_families(): void + { + $face = new FontFace('Fall', __DIR__ . '/../../resources/fonts/Inter/Inter-Regular.ttf'); + $o = new HtmlOptions(fonts: (new FontSet([$face]))->withFallbacks('Fall'), fontMode: FontMode::Embed); + $html = $this->doc()->toHtml($o)->bytes(); + self::assertSame(1, substr_count($html, 'font-family:"Fall";src:url(data:font/ttf;base64,')); + } + + public function test_fallback_that_is_also_used_gives_no_duplicate_font_face(): void + { + $face = new FontFace('Fall', __DIR__ . '/../../resources/fonts/Inter/Inter-Regular.ttf'); + $o = new HtmlOptions(fonts: (new FontSet([$face]))->withFallbacks('Fall'), fontMode: FontMode::Embed); + $html = $this->doc('Fall')->toHtml($o)->bytes(); + self::assertSame(1, substr_count($html, 'font-family:"Fall";src:')); + } + + public function test_link_mode_emits_the_fallback_face_url(): void + { + $face = new FontFace('Fall', '/fonts/fall.ttf'); + $o = new HtmlOptions(fonts: (new FontSet([$face]))->withFallbacks('Fall'), fontMode: FontMode::Link, fontUrl: static fn (FontFace $f): ?string => $f->family === 'Fall' ? '/u/fall.ttf' : null); + $html = $this->doc()->toHtml($o)->bytes(); + self::assertStringContainsString('font-family:"Fall";src:url("/u/fall.ttf")', $html); + } + + public function test_embed_with_missing_file_throws(): void + { + $o = new HtmlOptions(fonts: new FontSet([new FontFace('Gone', '/nope/gone.ttf')]), fontMode: FontMode::Embed); + $this->expectException(FontNotFound::class); + $this->expectExceptionMessage('Font file not found: /nope/gone.ttf'); + $this->doc('Gone')->toHtml($o); + } + + public function test_link_uses_callback_and_skips_null(): void + { + $o = new HtmlOptions(fontMode: FontMode::Link, fontUrl: static fn (FontFace $f): ?string => $f->style === FontStyle::Italic ? null : '/f/a"b.ttf?w=' . $f->weight); + $html = $this->doc()->toHtml($o)->bytes(); + self::assertSame(2, substr_count($html, '@font-face')); + self::assertStringContainsString('src:url("/f/a\\"b.ttf?w=400") format("truetype")', $html); + self::assertStringNotContainsString('base64', $html); + } + + public function test_link_without_callback_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('FontMode::Link needs a fontUrl callback'); + $this->doc()->toHtml(new HtmlOptions(fontMode: FontMode::Link)); + } + + public function test_fallback_families_join_the_stack(): void + { + $o = new HtmlOptions(fonts: (new FontSet())->withFallbacks('Noto Sans', 'Arial')); + $doc = Document::make(Page::a4()->body(Text::spans(new Span('y', Style::make(fontFamily: 'Georgia'))))); + $html = $doc->toHtml($o)->bytes(); + self::assertStringContainsString('"Inter", "Noto Sans", "Arial", system-ui, sans-serif', $html); + self::assertStringContainsString('"Georgia", "Noto Sans", "Arial", system-ui, sans-serif', $html); + } +} diff --git a/packages/core/tests/Html/HtmlTablesImagesTest.php b/packages/core/tests/Html/HtmlTablesImagesTest.php new file mode 100644 index 0000000..e95d51e --- /dev/null +++ b/packages/core/tests/Html/HtmlTablesImagesTest.php @@ -0,0 +1,343 @@ +toHtml($o)->bytes(); + } + + private function dom(string $html): \Dom\HTMLDocument + { + return \Dom\HTMLDocument::createFromString('t' . $html . ''); + } + + /** @return list<\Dom\Element> */ + private function xpath(string $html, string $query): array + { + $xp = new \Dom\XPath($this->dom($html)); + $xp->registerNamespace('h', 'http://www.w3.org/1999/xhtml'); + $query = (string) preg_replace('~(?<=/)([a-z][a-z0-9]*)(?![a-z0-9(\-])~', 'h:$1', $query); + $found = []; + foreach ($xp->query($query) as $n) { + self::assertInstanceOf(\Dom\Element::class, $n); + $found[] = $n; + } + return $found; + } + + private function style(\Dom\Element $e): string + { + return (string) $e->getAttribute('style'); + } + + private function body(\Papercut\Node|\Papercut\Component ...$nodes): Document + { + return Document::make(Page::a4()->body(...$nodes)); + } + + public function test_table_has_colgroup_widths_thead_and_colspan(): void + { + $messages = []; + $table = Table::make()->columns('30mm 2fr 1fr auto') + ->head('A', 'B', 'C', 'D') + ->row(TableCell::make('wide')->colspan(3), 'x') + ->row('1', '2', '3', '4'); + $html = $this->html($this->body($table), new HtmlOptions(onWarning: function (string $m) use (&$messages): void { + $messages[] = $m; + })); + $cols = $this->xpath($html, '//table/colgroup/col'); + $fixed = 30 * 72 / 25.4; + $fixedCss = rtrim(rtrim(number_format($fixed, 3, '.', ''), '0'), '.'); + self::assertCount(4, $cols); + self::assertSame('width:' . $fixedCss . 'pt', $this->style($cols[0])); + self::assertSame('', $this->style($cols[1])); + self::assertSame('', $this->style($cols[2])); + self::assertSame('', $this->style($cols[3])); + self::assertStringContainsString('table-layout:auto', $this->style($this->xpath($html, '//table')[0])); + self::assertCount(1, $this->xpath($html, '//table/thead/tr')); + self::assertCount(2, $this->xpath($html, '//table/tbody/tr')); + $wide = $this->xpath($html, '//tbody/tr[1]/td[1]'); + self::assertSame('3', $wide[0]->getAttribute('colspan')); + self::assertNull($this->xpath($html, '//tbody/tr[1]/td[2]')[0]->getAttribute('colspan')); + self::assertCount(2, array_filter($messages, static fn (string $m): bool => str_contains($m, 'column widths'))); + self::assertContains('html: approximated fr column widths at page > body > table[0]', $messages); + self::assertContains('html: approximated auto column widths at page > body > table[0]', $messages); + } + + public function test_missing_tracks_are_fr_and_extra_ignored(): void + { + $html = $this->html($this->body(Table::make()->columns('10pt 20pt 30pt 40pt')->row('a', 'b'))); + self::assertCount(2, $this->xpath($html, '//colgroup/col')); + $html = $this->html($this->body(Table::make()->columns('10pt')->row('a', 'b'))); + $cols = $this->xpath($html, '//colgroup/col'); + self::assertSame('width:calc((100% - 10pt) * 1)', $this->style($cols[1])); + } + + public function test_header_row_cells_are_th_and_bold_from_theme(): void + { + $html = $this->html($this->body(Table::make()->columns('1fr 1fr')->head('H1', 'H2')->row('a', 'b'))); + $th = $this->xpath($html, '//thead/tr/th'); + self::assertCount(2, $th); + self::assertStringContainsString('font-weight:700', $this->style($th[0])); + self::assertStringContainsString('padding:', $this->style($th[0])); + $td = $this->xpath($html, '//tbody/tr/td'); + self::assertCount(2, $td); + self::assertStringNotContainsString('font-weight', $this->style($td[0])); + self::assertCount(0, $this->xpath($html, '//thead/tr/td')); + } + + public function test_cell_children_render_as_blocks_and_tables_nest(): void + { + $inner = Table::make()->columns('1fr')->row('deep'); + $html = $this->html($this->body(Table::make()->columns('1fr 1fr')->row(Column::make(Text::make('one'), Text::make('two')), $inner))); + self::assertCount(2, $this->xpath($html, '//td[1]/div/p')); + self::assertCount(1, $this->xpath($html, '//td[2]/table/tbody/tr/td')); + self::assertCount(1, $this->xpath($html, '//table//table')); + } + + public function test_row_style_is_emitted(): void + { + $html = $this->html($this->body(Table::make()->columns('1fr')->bodyRow(TableRow::make('a')->background(\Papercut\Value\Color::hex('#ff0000'))))); + self::assertStringContainsString('background:#ff0000', $this->style($this->xpath($html, '//tbody/tr')[0])); + } + + public function test_png_and_jpeg_embed_as_data_uris(): void + { + $png = TestImages::png(8, 4); + $im = imagecreatetruecolor(8, 4); + self::assertNotFalse($im); + ob_start(); + imagejpeg($im); + $jpeg = (string) ob_get_clean(); + $html = $this->html($this->body(Image::bytes($png), Image::bytes($jpeg))); + $imgs = $this->xpath($html, '//div[@class="pc-body"]/img'); + self::assertCount(2, $imgs); + self::assertSame('data:image/png;base64,' . base64_encode($png), $imgs[0]->getAttribute('src')); + self::assertSame('data:image/jpeg;base64,' . base64_encode($jpeg), $imgs[1]->getAttribute('src')); + self::assertSame('', $imgs[0]->getAttribute('alt')); + } + + public function test_file_image_is_read_and_embedded(): void + { + $path = tempnam(sys_get_temp_dir(), 'pc') ?: throw new \RuntimeException('tmp'); + $png = TestImages::png(); + file_put_contents($path, $png); + try { + $html = $this->html($this->body(Image::file($path))); + } finally { + unlink($path); + } + self::assertSame('data:image/png;base64,' . base64_encode($png), $this->xpath($html, '//img')[0]->getAttribute('src')); + } + + public function test_image_url_callback_wins(): void + { + $opts = new HtmlOptions(imageUrl: static fn (Image $i): string => 'https://cdn.example/a.png?x=1&y="2"'); + $html = $this->html($this->body(Image::file('/nonexistent/never-read.png')), $opts); + self::assertStringContainsString('src="https://cdn.example/a.png?x=1&y="2""', $html); + self::assertSame('https://cdn.example/a.png?x=1&y="2"', $this->xpath($html, '//img')[0]->getAttribute('src')); + + foreach (['img/a.png', 'data:image/gif;base64,R0lGOD'] as $ok) { + $html = $this->html($this->body(Image::bytes(TestImages::png())), new HtmlOptions(imageUrl: static fn (Image $i): string => $ok)); + self::assertSame($ok, $this->xpath($html, '//img')[0]->getAttribute('src')); + } + } + + public function test_null_or_unsafe_callback_result_embeds(): void + { + $png = TestImages::png(); + foreach ([null, 'javascript:alert(1)', "java\tscript:alert(1)", 'data:text/html,