Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 51 additions & 1 deletion packages/document-renderer/PDF_RENDERING_ENGINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,11 +125,61 @@ 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:** Implemented in this package

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 = '<h1>Title</h1><p>Content...</p>';

// 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 |
|---|---|---|
| 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 |
42 changes: 32 additions & 10 deletions packages/document-renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:** Engine spec and running-footer CSS generation implemented

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('<h1>Title</h1><p>Content...</p>', {
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.
246 changes: 246 additions & 0 deletions packages/document-renderer/src/render-vivliostyle.mjs
Original file line number Diff line number Diff line change
@@ -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 = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
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 `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${safeMeta.document_identity}</title>
<style>
${css}
</style>
</head>
<body>
${content}
</body>
</html>`;
}
Loading
Loading