From 5893cb85315dd07d251533f1f4df159d7055e215 Mon Sep 17 00:00:00 2001 From: transitrix Date: Wed, 2 Sep 2026 22:39:14 +0400 Subject: [PATCH 1/2] feat(document-renderer): implement paged-media CSS generation for running footers Add render-vivliostyle.mjs (dependency-free) with: - generatePagedMediaCss() for @page rules with running footer - wrapHtmlForPrintRendering() to embed CSS in HTML - Support for issuer, date, identity, page number in footer - Landscape pages for wide diagrams, typography rules, page breaks Implements the paged-media CSS generation specified in PDF_RENDERING_ENGINE.md. All tests pass; ready for Vivliostyle integration in the document rendering pipeline. Co-Authored-By: Claude Haiku 4.5 --- .../document-renderer/PDF_RENDERING_ENGINE.md | 52 +++- packages/document-renderer/README.md | 42 ++- .../src/render-vivliostyle.mjs | 246 ++++++++++++++++++ .../tests/test_paged_media_css.mjs | 118 +++++++++ 4 files changed, 447 insertions(+), 11 deletions(-) create mode 100644 packages/document-renderer/src/render-vivliostyle.mjs create mode 100644 packages/document-renderer/tests/test_paged_media_css.mjs diff --git a/packages/document-renderer/PDF_RENDERING_ENGINE.md b/packages/document-renderer/PDF_RENDERING_ENGINE.md index 7b8d79fb..1f892553 100644 --- a/packages/document-renderer/PDF_RENDERING_ENGINE.md +++ b/packages/document-renderer/PDF_RENDERING_ENGINE.md @@ -125,6 +125,56 @@ Conformance = engine produces the expected PDF on all fixtures. **Accessibility:** PDF/A compliance, tagged PDFs, and screen-reader support are out of scope for this task. Studio's choice of engine may support these; Methodology documents the contract only. +## CSS generation and Vivliostyle integration + +**Status: In progress (task #538)** + +Methodology provides a dependency-free module for generating paged-media CSS with running footers, landscape pages, and typography rules. The module lives in `packages/document-renderer/src/render-vivliostyle.mjs`. + +### What the module provides + +- `generatePagedMediaCss(metadata)` — generates CSS with @page rules, running footer, and base typography +- `wrapHtmlForPrintRendering(html, metadata)` — wraps HTML content with embedded CSS for rendering + +### Paged-media CSS features implemented + +| Feature | Status | Notes | +|---------|--------|-------| +| Running footer | ✓ Done | Shows issuer, date, identity, commit, page number | +| Page margins and size | ✓ Done | A4 (595 × 842 pt), 20mm margins | +| Page counters | ✓ Done | `counter(page)` and `counter(pages)` available | +| Landscape pages | ✓ Done | `@page landscape` rule for wide diagrams | +| Typography rules | ✓ Done | Headings, paragraphs, lists, code, blockquotes, tables | +| Page break avoidance | ✓ Done | Headings and figures use `page-break-after/inside: avoid` | +| First page footer suppression | ✓ Done | Title page has no footer | + +### How to integrate with Vivliostyle + +For adopters or tools that need to render HTML to PDF: + +```javascript +import { wrapHtmlForPrintRendering } from '@transitrix/document-renderer/src/render-vivliostyle.mjs'; + +// Your HTML content +const html = '

Title

Content...

'; + +// Metadata for the footer +const metadata = { + issuer: 'Acme Corp', + issued_at: '2026-09-02T15:30:00Z', + document_identity: 'PRODUCT-MRD-1.0', + repository_commit: 'a1b2c3d4', +}; + +// Get HTML with embedded CSS +const htmlWithCss = wrapHtmlForPrintRendering(html, metadata); + +// Pass to Vivliostyle (or your chosen engine) for PDF rendering +// E.g., via documents-cli or a headless browser +``` + +The HTML output is ready for any CSS Paged Media Module Level 3 compliant renderer. + ## Decisions record | Date | Decision | Rationale | @@ -132,4 +182,4 @@ Conformance = engine produces the expected PDF on all fixtures. | 2026-09-02 | Methodology owns engine choice | Studio is consumer, Methodology owns spec | | 2026-09-02 | Reference = Vivliostyle | Open-source, all paged-media features, Node.js native, no licensing barrier | | 2026-09-02 | Standing shape = caller-supplied | Adopters may choose if they satisfy feature matrix | -| (pending) | Implement HTML → PDF pipeline | Paged-media CSS integration work, Studio primary user | +| 2026-09-02 | Paged-media CSS in methodology | Dependency-free module in document-renderer; Vivliostyle integration in caller's pipeline | diff --git a/packages/document-renderer/README.md b/packages/document-renderer/README.md index 4be6defa..4ba23db4 100644 --- a/packages/document-renderer/README.md +++ b/packages/document-renderer/README.md @@ -477,17 +477,39 @@ placeholder instead — see [PDF output](#pdf-output)). ## PDF rendering engine and paged-media CSS -The current `render-pdf.mjs` is a simple, plain-text PDF generator with no external dependencies. Future work requires **paged-media CSS support** for landscape pages, running headers/footers, and named-page styling to support issued document rendering. +**Status: Part 1 (engine spec) complete, Part 2 (running footer) in progress** -This requires an **HTML-to-PDF rendering engine** (PrinceXML, WeasyPrint, Chromium, etc.) that supports CSS Paged Media Module Level 3. +The package has two PDF rendering paths: -**Specification:** [`PDF_RENDERING_ENGINE.md`](./PDF_RENDERING_ENGINE.md) -**Covers:** Engine choice, paged-media feature matrix, conformance testing, integration contract +1. **Simple path** (`render-pdf.mjs`) — hand-rolled, dependency-free PDF generator for plain Markdown +2. **Paged-media path** (`render-vivliostyle.mjs`) — CSS generation for Vivliostyle or other CSS Paged Media engines + +### Paged-media CSS support + +`render-vivliostyle.mjs` (dependency-free) generates CSS and HTML structures for: +- Running footers (issuer, date, identity, page number) +- Landscape pages for wide diagrams +- Page breaks and typography rules +- First-page suppression (no footer on title page) + +**Usage:** + +```javascript +import { wrapHtmlForPrintRendering } from './src/render-vivliostyle.mjs'; -The specification documents: -- Why PrinceXML is the **reference renderer** (strong paged-media support) -- How adopters may substitute an engine (if feature matrix is met) -- Required paged-media CSS features (named pages, break rules, running elements) -- Integration points and responsibility split with caller/Studio +const html = wrapHtmlForPrintRendering('

Title

Content...

', { + issuer: 'Your Corp', + issued_at: '2026-09-02T15:30:00Z', + document_identity: 'PRODUCT-MRD-1.0', + repository_commit: 'a1b2c3d', +}); + +// Pass html to Vivliostyle or another CSS Paged Media renderer +``` + +**Specification:** [`PDF_RENDERING_ENGINE.md`](./PDF_RENDERING_ENGINE.md) +**Engine reference:** Vivliostyle (option 2), caller-supplied supported (option 3) +**Feature matrix:** Named pages, landscape pages, break rules, running elements, counters +**Integration:** Vivliostyle rendering happens in documents-cli or caller's pipeline (not in this package) -Current state: Engine choice is documented. Implementation of paged-media CSS support is future work. +Current state: Engine choice documented, paged-media CSS generation implemented and tested. diff --git a/packages/document-renderer/src/render-vivliostyle.mjs b/packages/document-renderer/src/render-vivliostyle.mjs new file mode 100644 index 00000000..2a8c69e7 --- /dev/null +++ b/packages/document-renderer/src/render-vivliostyle.mjs @@ -0,0 +1,246 @@ +// Paged-media CSS generation for documents with running footers. +// +// This module (dependency-free) generates CSS and HTML structures needed +// for paged-media rendering via Vivliostyle (or any CSS Paged Media Module +// Level 3 compliant engine). +// +// The actual PDF rendering happens in documents-cli or a caller's own +// pipeline, which can depend on Vivliostyle or another engine. +// +// What this provides: +// - generatePagedMediaCss() - CSS with @page rules for footers, landscape pages +// - wrapHtmlForPrintRendering() - HTML structure with embedded CSS for rendering + +/** + * Escape HTML special characters to safely embed in HTML content. + */ +function escapeHtml(text) { + const map = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + }; + return String(text).replace(/[&<>"']/g, (c) => map[c]); +} + +/** + * Generate CSS for paged-media including running footer. + * Defines @page rules for footer styling, landscape pages, and typography. + * + * @param {object} metadata - Document metadata + * @param {string} metadata.issuer - Who created the document + * @param {string} metadata.issued_at - ISO 8601 timestamp + * @param {string} metadata.document_identity - Recipe ID or document identifier + * @param {string} [metadata.repository_commit] - Git commit hash + * @returns {string} CSS text with @page rules and base typography + */ +export function generatePagedMediaCss(metadata = {}) { + const issuer = escapeHtml(metadata.issuer || 'Unknown'); + const repository_commit = metadata.repository_commit || ''; + const commit = repository_commit ? repository_commit.slice(0, 7) : ''; + const identity = escapeHtml(metadata.document_identity || 'Unknown'); + + // Format issued date in short form + let issued = 'Unknown date'; + if (metadata.issued_at) { + try { + const date = new Date(metadata.issued_at); + issued = date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } catch { + // ignore invalid date + } + } + + // Footer content. Shows: issuer, date, identity, commit, page number. + const footerParts = [issuer, issued, identity]; + if (commit) footerParts.push(commit); + const footerText = footerParts.join(' • '); + + return `/* Paged Media CSS — footer, typography, page breaks */ + +@page { + size: A4; + margin: 20mm 15mm; + + @bottom-center { + content: "${footerText} • page " counter(page) " of " counter(pages); + font-family: Helvetica, sans-serif; + font-size: 10pt; + text-align: center; + padding-top: 10mm; + border-top: 1px solid #ccc; + } +} + +@page :first { + @bottom-center { + content: ""; + } +} + +@page :left { + margin-right: 20mm; +} + +@page :right { + margin-left: 20mm; +} + +@page landscape { + size: A4 landscape; + margin: 20mm 15mm; + + @bottom-center { + content: "${footerText} • page " counter(page) " of " counter(pages); + font-family: Helvetica, sans-serif; + font-size: 10pt; + text-align: center; + padding-top: 10mm; + border-top: 1px solid #ccc; + } +} + +html { + font-family: Helvetica, Arial, sans-serif; + font-size: 11pt; + line-height: 1.4; + color: #333; +} + +h1 { + font-size: 18pt; + font-weight: bold; + margin: 1.6em 0 0.3em; + page-break-after: avoid; +} + +h2 { + font-size: 15pt; + font-weight: bold; + margin: 1.3em 0 0.3em; + page-break-after: avoid; +} + +h3 { + font-size: 13pt; + font-weight: bold; + margin: 1em 0 0.3em; + page-break-after: avoid; +} + +p { + margin: 0.9em 0; + orphans: 2; + widows: 2; +} + +/* Diagrams: mark wide ones for landscape pages */ +.diagram, .diagram-wide { + page: landscape; + margin: 1em 0; + text-align: center; + page-break-inside: avoid; +} + +.figure-caption { + font-size: 10pt; + color: #666; + margin: 0.5em 0; + font-style: italic; +} + +figure, .figure { + page-break-inside: avoid; + margin: 1em 0; +} + +ul, ol { + margin: 0.9em 0; + padding-left: 2em; +} + +li { + margin: 0.3em 0; +} + +code, pre { + font-family: monospace; + font-size: 10pt; + background-color: #f5f5f5; + padding: 2px 4px; +} + +pre { + padding: 10px; + page-break-inside: avoid; + margin: 1em 0; +} + +table { + margin: 1em 0; + page-break-inside: avoid; + border-collapse: collapse; +} + +th, td { + padding: 6px 10px; + border: 1px solid #ccc; + text-align: left; +} + +th { + background-color: #f0f0f0; + font-weight: bold; +} + +blockquote { + margin: 1em 0 1em 2em; + padding-left: 10px; + border-left: 3px solid #ccc; + color: #666; +} + +a { + color: #0066cc; + text-decoration: underline; +} +`; +} + +/** + * Wrap Markdown or HTML content with paged-media CSS for rendering. + * Returns a complete HTML document ready to pass to an HTML-to-PDF engine. + * + * @param {string} content - HTML or Markdown content + * @param {object} metadata - Document metadata + * @returns {string} Complete HTML document with embedded CSS + */ +export function wrapHtmlForPrintRendering(content, metadata = {}) { + const css = generatePagedMediaCss(metadata); + + const safeMeta = { + issuer: escapeHtml(metadata.issuer || ''), + document_identity: escapeHtml(metadata.document_identity || ''), + }; + + return ` + + + + + ${safeMeta.document_identity} + + + + ${content} + +`; +} diff --git a/packages/document-renderer/tests/test_paged_media_css.mjs b/packages/document-renderer/tests/test_paged_media_css.mjs new file mode 100644 index 00000000..c12d4b9f --- /dev/null +++ b/packages/document-renderer/tests/test_paged_media_css.mjs @@ -0,0 +1,118 @@ +// Tests for paged-media CSS generation (render-vivliostyle.mjs) +// Verifies running footer, page breaks, typography rules + +import { generatePagedMediaCss, wrapHtmlForPrintRendering } from '../src/render-vivliostyle.mjs'; + +let failures = 0; + +function check(condition, message) { + if (condition) { + console.log(` ✓ ${message}`); + } else { + console.log(` ✗ ${message}`); + failures += 1; + } +} + +// Test 1: CSS includes @page rule +{ + const css = generatePagedMediaCss(); + check(css.includes('@page {'), 'CSS includes @page rule'); + check(css.includes('size: A4'), 'CSS declares A4 page size'); + check(css.includes('margin: 20mm 15mm'), 'CSS declares page margins'); +} + +// Test 2: CSS includes running footer +{ + const css = generatePagedMediaCss({ + issuer: 'Test Corp', + issued_at: '2026-09-02T15:30:00Z', + document_identity: 'PRODUCT-MRD-1.0', + }); + check(css.includes('@bottom-center'), 'CSS includes @bottom-center running footer'); + check(css.includes('counter(page)'), 'CSS includes page counter'); + check(css.includes('counter(pages)'), 'CSS includes pages counter'); +} + +// Test 3: Footer content includes metadata +{ + const css = generatePagedMediaCss({ + issuer: 'Acme Corp', + issued_at: '2026-09-02T15:30:00Z', + document_identity: 'SPEC-v2.0', + repository_commit: 'a1b2c3d4e5f6g7h8', + }); + check(css.includes('Acme Corp'), 'CSS includes issuer name'); + check(css.includes('SPEC-v2.0'), 'CSS includes document identity'); + check(css.includes('a1b2c3d'), 'CSS includes commit hash (short form)'); +} + +// Test 4: Footer content is escaped (no unescaped quotes) +{ + const css = generatePagedMediaCss({ + issuer: 'Corp "Test"