Repository navigation
Papercut 0.2: multi-format export (HTML now, DOCX designed), font sources - #3
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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).Exporter/OutputAPI withtoPdf()/toHtml(),PdfOptions/HtmlOptions,FontSet, and fidelity reporting with a strict mode<x-doc::*>tags (configurable prefix), thePapercutfacade,->html()(Htmlablefragment /Responsablefull page, pluspage()andfragment()), URL resolver classes in config,?format=htmlpreviewBreaking (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 inCHANGELOG.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 Laravelx-doccommit because that is where it happened.RULINGS.mdand 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