Skip to content

Papercut 0.2: multi-format export (HTML now, DOCX designed), font sources - #3

Merged
serpentblade merged 13 commits into
mainfrom
papercut-v0.2-pr
Oct 20, 2026
Merged

serpentblade merged 13 commits into
mainfrom
papercut-v0.2-pr

Conversation

@serpentblade

Copy link
Copy Markdown
Contributor

Papercut 0.2 groundwork: one document, several formats. PDF output is byte-identical to v0.1; HTML export is new; DOCX is designed but on hold until php-docx gains the features it needs (docs/superpowers/php-docx-1.15-gaps.md).

  • Layout-free seams in core (Expander, StyleResolver, TreeWalker, WidthResolver), guarded by a golden gate on the exact PDF bytes of every v0.1 fixture and example
  • Exporter/Output API with toPdf() / toHtml(), PdfOptions/HtmlOptions, FontSet, and fidelity reporting with a strict mode
  • HTML exporter: fragment or full page, inline computed styles, print CSS, tables, images, font modes; golden HTML files and a PDF↔HTML content-parity test
  • Laravel: <x-doc::*> tags (configurable prefix), the Papercut facade, ->html() (Htmlable fragment / Responsable full page, plus page() and fragment()), URL resolver classes in config, ?format=html preview
  • HTML font sources: an ordered stack of sources per font family, with Google Fonts as the reference source (Bunny is a host change) and local font files as a source; opt-in, with the privacy note in the docs
  • Bench: HTML export timing with its own 20% gate

Breaking (vs the unpublished v0.1): Pdf → Papercut, <x-pdf::…> → <x-doc::…>, render()/save() → toPdf()->bytes()/->save(), RenderOptions → PdfOptions, Image::handle() removed. The migration table is in CHANGELOG.md.

The 59-commit build log is condensed into 13 commits, one per reviewable claim. Each commit's Squashes: block lists what it absorbed, and every commit is an exact tree from the original history (minus the internal build plans). One small core fix (layout error paths naming header/footer/background) sits inside the Laravel x-doc commit because that is where it happened. RULINGS.md and spec §10 still mention plan files that aren't in this PR.

Specs: docs/superpowers/specs/2026-10-19-multi-format-export-design.md, docs/superpowers/specs/2026-10-19-html-font-sources-design.md. Decisions made during the build: docs/superpowers/RULINGS.md.

🤖 Generated with Claude Code

serpentblade and others added 13 commits October 19, 2026 17:12
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 <noreply@anthropic.com>
…er, WidthResolver

Pulls the parts of layout that other formats need out of LayoutEngine,
without changing a byte of PDF output:

- Expander turns components into nodes, with the existing depth guard.
- StyleResolver resolves theme, named and node styles with inheritance.
  Table-cell defaults apply only through resolveInTable(), so a bare
  cell outside a table keeps its v0.1 styling.
- TreeWalker walks page body, header, footer and background depth-first
  with node paths, for exporters that never paginate.
- WidthResolver exposes flex and table column widths on their own, for
  formats that must match PDF horizontally (DOCX).

Squashes:
  - refactor(core): extract Expander, NodePath and StyleResolver from layout
  - fix(core): keep v0.1 styling for table cells outside tables; resolveInTable
  - feat(core): TreeWalker for layout-free exporters
  - test(core): pin TreeWalker leave contract
  - feat(core): WidthResolver exposes flex and table widths without pagination

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ty reporting and strict mode

Document::render()/toString()/save()/stream() give way to
export(Exporter, ?ExportOptions): Output, with toPdf() and toHtml()
shortcuts. RenderOptions becomes ExportOptions plus PdfOptions;
FontSet is the format-neutral font configuration. Image::handle() is
removed: images come from a file or bytes only.

Fidelity collects what a format approximates or drops, forwards it to
onWarning, and in strict mode throws UnsupportedFeature on the first
drop. MissingExporter names the package to install when an exporter
isn't available. Laravel's internals follow the new API with no public
change yet.

Squashes:
  - feat(core)!: Exporter/Output API, PdfOptions and FontSet; remove RenderOptions and Image::handle
  - refactor(laravel): adapt internals to the core export API
  - feat(core): fidelity reporting, strict mode and MissingExporter

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Tags become <x-doc::*>, with the prefix configurable as
papercut.prefix. Pdf/PdfManager become Papercut/PapercutManager:
view() and make() return a PendingDocument, and ->pdf() returns a
PendingOutput (toString, save, download, inline, Responsable). The
changelog carries the v0.1 → 0.2 migration table.

Also here, because it landed between these changes: layout error paths
now name the header, footer and background slots the same way the tree
walk does, so PDF and other formats report identical paths.

Squashes:
  - feat(laravel)!: x-doc tag prefix, configurable via papercut.prefix
  - docs: replace x-pdf with x-doc in core docblocks and tests
  - feat(laravel)!: Papercut facade with PendingDocument and PendingOutput
  - fix(core): name header/footer/background in layout error paths
  - docs: clarify 0.2.0 migration notes; architecture guard for Tree/Style
  - docs: record multi-format phase 0 rulings and phase 1 carry-overs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…allbacks

Settles the API shape before the HTML exporter builds on it:

- Output and the option classes expose accessor methods with private
  state (bytes(), mime(), theme(), fonts() …), as the spec lists.
- TreeVisitor receives a Visit carrying the node path, sibling index,
  parent, header-row flag and region, which HTML and DOCX both need.
- Fidelity callbacks are called with as many arguments as they accept,
  so a native one-argument callable such as error_log(...) works.
  A table cell outside a table now warns, as PDF does.

Squashes:
  - refactor!: methods for Output and options accessors
  - test(core): make the cacheDir false check meaningful
  - feat(core)!: Visit context for TreeVisitor
  - fix(core): arity-aware fidelity callbacks; warn on table cells outside tables

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
$doc->toHtml() writes a fragment (a scoped <style> plus a .papercut
root) or, with fullPage, a complete HTML5 document. Each page is a
paper-look box on screen and a printed page under @page. Styles are
inline from computed values only, never raw strings, so nothing can
inject CSS; text and attributes are escaped and hrefs go through the
PDF scheme allowlist.

Views map to flexbox, text to p/h1–h4 with spans, tables to real tables
with colgroup widths, images to data URIs (or an imageUrl callback).
Fonts: none (system stack, the default), embed (base64 @font-face) or
link (fontUrl callback). Anything HTML can't express is reported
through Fidelity at its node path.

Golden HTML files pin every fixture and example; a content-parity test
checks the same words come out of PDF and HTML.

Squashes:
  - feat(core): HtmlOptions and CSS mapping; spec amendments for phase 1
  - fix(core): pagination diffs minLines against parent; spec wording
  - feat(core): HtmlExporter for pages, flex, text and links
  - fix(html): link errors match PDF; flex-item gating; Link pagination
  - feat(core): HTML tables and images
  - fix(html): table text styles, percent/auto columns, image error parity
  - feat(core): HTML font modes
  - fix(html): embed fallback font families
  - test(core): golden HTML, PDF/HTML content parity, HTML example renders

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Papercut::view(...)->html() renders the same Blade document as HTML,
sharing theme, fonts, named styles and strict mode with ->pdf(). It is
Htmlable, so {!! !!} and {{ }} print the fragment, and Responsable,
so returning it from a route sends a full page. html.fonts selects the
font mode, and the preview route takes ?format=pdf|html.

Squashes:
  - feat(laravel): html() output, shared export options, preview format
  - fix(laravel): restore README features heading; document html() string forms

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the end-of-phase review caught in the HTML exporter:

- fr columns are plain percentages of an estimated table width; Chrome
  ignores mixed %/pt calc() on <col>.
- Real table cells and block links lay out as columns, as PDF does;
  nested links render as spans and are reported.
- Corrupt images fail with the same error as PDF.
- Content parity compares word multisets, with repeated header rows
  removed, so a dropped body row can't hide.

Squashes:
  - fix(html): pure-percentage fr columns, column cells and links, nested links, corrupt image parity
  - test(core): multiset content parity; docs
  - docs: record multi-format phase 1 rulings and phase 2 carry-overs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes the carry-overs that don't wait on DOCX (which is now on hold
until php-docx gains the features listed in
docs/superpowers/php-docx-1.15-gaps.md):

- HTML validates images through the Backend, like PDF, so both formats
  refuse the same files, and each image decodes once per export.
- Table track counting and font-face dedupe each have one home
  (TableLayouter via WidthResolver, FontSet::effectiveFaces).
- HTML reports cell justify it can't express and decoration overrides
  on tables and rows.
- The bench times HTML export too, with its own 20% gate.

Squashes:
  - docs: put DOCX on hold pending php-docx; record H2/H3 decisions
  - docs: fix PendingOutput line and carry-over tense
  - fix(core): validate HTML images through the backend, like PDF
  - test(core): truncated-image parity input that PDF refuses
  - refactor(core): single source for table tracks and font faces; warn on reused bare cells
  - feat(bench): HTML export timing with its own 20% gate
  - fix(core): report cell justify and table decoration overrides in HTML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A string must not mean two things: PendingHtml no longer extends
PendingOutput. (string) and toHtml() give the fragment, toResponse()
the full page, and page()/fragment() each return a PendingOutput for
toString(), save(), download() and inline().

html.image_url and html.font_url name invokable resolver classes built
by the container (closures don't survive config:cache and are rejected
by key). html.fonts accepts link, which needs font_url. Bad html.*
config is reported at the first HTML export, not at boot, and an
unbuildable class names its config key.

Squashes:
  - feat(laravel)!: html()->page() and fragment()
  - feat(laravel): image_url and font_url resolver classes; link font mode from config
  - docs: record hardening rulings and finish carry-over markers
  - fix(laravel): name the config key when a resolver class cannot be built

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
FontMode::Sources takes an ordered stack of font sources. Each family
the document uses is offered top to bottom and the first source that
answers wins. A source answers with a stylesheet URL (a <link> in a
full page, an @import first in a fragment's <style>) or with typed
@font-face faces; sources never return raw CSS.

GoogleFonts builds css2 URLs from the exact weights and italics each
family needs (registered faces, or the document's usage for families
it never registered), with tuples in the order Google requires. A
different host makes it Bunny Fonts or a mirror. LocalFonts serves
registered font files, embedded or linked, behind an allowlist, so a
licensed brand font can sit above Google in the stack.

Papercut keeps no font catalog: a family no source accepts is reported
as "font not linked" and falls back to the system stack. Refused URLs
fall through to the next source with a warning. The mode is opt-in;
the docs carry the privacy note on third-party font hosts.

Squashes:
  - docs: design font sources for HTML export (Google Fonts and friends)
  - docs: amend the export spec for HTML font sources; record S1–S5
  - feat(core): font source types and the GoogleFonts source
  - feat(core): LocalFonts source for registered font files
  - feat(core): track font usage per family for font sources
  - feat(core): FontMode::Sources, an ordered font source stack for HTML

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
html.fonts = sources works with no other setting: font_sources
defaults to [GoogleFonts::class]. google_fonts (host, display,
families) and local_fonts (mode, families) configure the bundled
sources through container bindings, so switching to Bunny or putting
local fonts first is a config change.

Nothing new is built or checked unless the mode is sources, and each
source block is checked only when its class is listed, so a typo in an
unused block never breaks exports. Every error names its full key,
including the list index.

Squashes:
  - feat(laravel): html.fonts = sources with font_sources, google_fonts and local_fonts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What the end-of-branch review caught:

- The privacy docs no longer imply Bunny avoids sending visitor IPs to
  a third party; the fully private routes are named as LocalFonts and
  the embed and link modes.
- The CSP notes list style-src, font-src for each host, and font-src
  data: for embedded faces.
- GoogleFonts rejects hosts with whitespace, control characters or
  quotes at construction (and Laravel names the key).
- A mixed-stack fragment golden pins @import before @font-face before
  the fixed stylesheet.

Squashes:
  - docs: correct Bunny privacy wording, list all CSP directives, document FontMode::Sources
  - fix(core): reject unsafe GoogleFonts hosts; add mixed-fragment golden and sources tests
  - docs(laravel): name embed and link modes as private font routes in config

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@serpentblade
serpentblade merged commit a2dd43f into main Oct 20, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant