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
28 changes: 23 additions & 5 deletions packages/document-renderer/PDF_RENDERING_ENGINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ Methodology provides a dependency-free module for generating paged-media CSS wit
### What the module provides

- `generatePagedMediaCss(metadata)` — generates CSS with @page rules, running footer, landscape page detection, and base typography
- `formatFigureCaptions(html, snapshots)` — embeds snapshot build dates in figure captions
- `wrapHtmlForPrintRendering(html, metadata)` — wraps HTML content with embedded CSS for rendering

### Paged-media CSS features implemented
Expand All @@ -148,6 +149,7 @@ Methodology provides a dependency-free module for generating paged-media CSS wit
| 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 |
| Figure caption snapshot dates | ✓ Done | Captions include build date from snapshot metadata |

### How to integrate with Vivliostyle

Expand All @@ -156,16 +158,19 @@ 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 (with id attributes on diagrams)
// Your HTML content with figures and wide diagrams
const html = `
<h1>Title</h1>
<p>Content...</p>
<h1>Architecture</h1>
<div id="diagram-architecture">
<svg><!-- wide diagram, 1200 x 600 --></svg>
</div>
<figure data-snapshot-id="snap-001">
<img src="diagram.svg" />
<figcaption>System Architecture</figcaption>
</figure>
`;

// Metadata for the footer and view dimensions
// Metadata for the footer, view dimensions, and snapshots
const metadata = {
issuer: 'Acme Corp',
issued_at: '2026-09-02T15:30:00Z',
Expand All @@ -174,18 +179,31 @@ const metadata = {
views: [
{ id: 'diagram-architecture', width: 1200, height: 600 }, // wide: uses landscape page
],
snapshots: {
'snap-001': {
generated_at: '2026-09-02T14:45:00Z'
}
}
};

// Get HTML with embedded CSS
// Get HTML with embedded CSS, landscape pages, and figure captions
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 wide diagram automatically renders on a landscape page.
// Figure captions include snapshot build dates.
```

**Wide view handling:** Pass a `views` array in metadata. For each view with `width > height`, the CSS generator creates a rule that applies landscape page layout automatically. The HTML element must have an `id` matching the view's `id`.

**Figure captions with snapshot dates:** When figures are sourced from model views (rendered diagrams), include:
- The figure's `data-snapshot-id` attribute referencing a snapshot object
- The snapshot's `generated_at` timestamp (ISO 8601)
- The caption automatically includes the build date: "Figure title (Sep 2, 2026)"

This ensures every diagram in the PDF is traceable to its source snapshot and build time.

The HTML output is ready for any CSS Paged Media Module Level 3 compliant renderer.

## Decisions record
Expand Down
58 changes: 57 additions & 1 deletion packages/document-renderer/src/render-vivliostyle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -235,12 +235,65 @@ ${wideViewsCss}
`;
}

/**
* Format figure captions to include snapshot build dates.
* Finds all <figure> elements with data-snapshot-id attributes and appends
* the snapshot's generated_at timestamp to the caption.
*
* @param {string} html - HTML content with figure elements
* @param {object} snapshots - Map of snapshot IDs to snapshot objects with generated_at
* @returns {string} HTML with formatted captions
*/
export function formatFigureCaptions(html, snapshots = {}) {
if (!snapshots || Object.keys(snapshots).length === 0) {
return html;
}

return html.replace(
/<figure([^>]*data-snapshot-id="([^"]*)"[^>]*)>([\s\S]*?)<\/figure>/g,
(match, attributes, snapshotId, content) => {
const snapshot = snapshots[snapshotId];
if (!snapshot || !snapshot.generated_at) {
return match;
}

let dateStr = 'Unknown date';
try {
const date = new Date(snapshot.generated_at);
if (!isNaN(date.getTime())) {
dateStr = date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
} catch {
// ignore invalid date, use default
}

const figcaptionRegex = /<figcaption[^>]*>([\s\S]*?)<\/figcaption>/;
const captionMatch = figcaptionRegex.exec(content);

if (!captionMatch) {
return match;
}

const originalCaption = captionMatch[1];
const newCaption = `${originalCaption} (${dateStr})`;
const updatedContent = content.replace(figcaptionRegex, `<figcaption>${newCaption}</figcaption>`);

return `<figure${attributes}>${updatedContent}</figure>`;
}
);
}

/**
* 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
* @param {object} [metadata.snapshots] - Map of snapshot IDs to snapshot objects
* @returns {string} Complete HTML document with embedded CSS
*/
export function wrapHtmlForPrintRendering(content, metadata = {}) {
Expand All @@ -251,6 +304,9 @@ export function wrapHtmlForPrintRendering(content, metadata = {}) {
document_identity: escapeHtml(metadata.document_identity || ''),
};

// Format figure captions with snapshot dates if snapshots are provided
const processedContent = formatFigureCaptions(content, metadata.snapshots);

return `<!DOCTYPE html>
<html lang="en">
<head>
Expand All @@ -262,7 +318,7 @@ export function wrapHtmlForPrintRendering(content, metadata = {}) {
</style>
</head>
<body>
${content}
${processedContent}
</body>
</html>`;
}
222 changes: 222 additions & 0 deletions packages/document-renderer/tests/test_render_vivliostyle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
// Tests for figure caption formatting and HTML wrapping (render-vivliostyle.mjs)
// Verifies formatFigureCaptions() and wrapHtmlForPrintRendering() functions

import { formatFigureCaptions, 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: formatFigureCaptions processes figures with snapshot IDs
{
const html = `
<figure data-snapshot-id="snap-001">
<img src="diagram.svg" />
<figcaption>System Architecture</figcaption>
</figure>
`;
const snapshots = {
'snap-001': {
generated_at: '2026-09-02T14:45:00Z'
}
};
const result = formatFigureCaptions(html, snapshots);
check(result.includes('System Architecture (Sep 2, 2026)'), 'Caption includes snapshot date');
check(result.includes('data-snapshot-id="snap-001"'), 'Snapshot ID preserved');
}

// Test 2: formatFigureCaptions handles missing snapshots gracefully
{
const html = `
<figure data-snapshot-id="snap-missing">
<figcaption>Orphaned diagram</figcaption>
</figure>
`;
const snapshots = {};
const result = formatFigureCaptions(html, snapshots);
check(result === html, 'HTML unchanged when snapshot missing');
}

// Test 3: formatFigureCaptions skips figures without snapshot IDs
{
const html = `
<figure>
<figcaption>Unsourced diagram</figcaption>
</figure>
`;
const snapshots = { 'snap-001': { generated_at: '2026-09-02T14:45:00Z' } };
const result = formatFigureCaptions(html, snapshots);
check(result === html, 'Figures without snapshot-id unchanged');
}

// Test 4: formatFigureCaptions handles empty snapshots
{
const html = `<figure data-snapshot-id="snap-001"><figcaption>Test</figcaption></figure>`;
const result = formatFigureCaptions(html, {});
check(result === html, 'HTML unchanged when snapshots object is empty');
}

// Test 5: formatFigureCaptions handles null/undefined snapshots
{
const html = `<figure data-snapshot-id="snap-001"><figcaption>Test</figcaption></figure>`;
const result1 = formatFigureCaptions(html, null);
const result2 = formatFigureCaptions(html, undefined);
check(result1 === html, 'HTML unchanged when snapshots is null');
check(result2 === html, 'HTML unchanged when snapshots is undefined');
}

// Test 6: formatFigureCaptions handles invalid dates gracefully
{
const html = `
<figure data-snapshot-id="snap-bad">
<figcaption>Bad date test</figcaption>
</figure>
`;
const snapshots = {
'snap-bad': {
generated_at: 'not-a-date'
}
};
const result = formatFigureCaptions(html, snapshots);
check(result.includes('Bad date test (Unknown date)'), 'Invalid date uses fallback');
}

// Test 7: formatFigureCaptions handles multiple figures
{
const html = `
<figure data-snapshot-id="snap-001">
<figcaption>First diagram</figcaption>
</figure>
<figure data-snapshot-id="snap-002">
<figcaption>Second diagram</figcaption>
</figure>
`;
const snapshots = {
'snap-001': { generated_at: '2026-09-02T14:45:00Z' },
'snap-002': { generated_at: '2026-09-03T10:30:00Z' }
};
const result = formatFigureCaptions(html, snapshots);
check(result.includes('First diagram (Sep 2, 2026)'), 'First caption formatted');
check(result.includes('Second diagram (Sep 3, 2026)'), 'Second caption formatted');
}

// Test 8: formatFigureCaptions preserves figure content
{
const html = `
<figure data-snapshot-id="snap-001">
<img src="diagram.svg" alt="Test diagram" />
<figcaption>Original caption</figcaption>
<p>Additional content</p>
</figure>
`;
const snapshots = {
'snap-001': { generated_at: '2026-09-02T14:45:00Z' }
};
const result = formatFigureCaptions(html, snapshots);
check(result.includes('src="diagram.svg"'), 'Image source preserved');
check(result.includes('<p>Additional content</p>'), 'Additional content preserved');
check(result.includes('Original caption (Sep 2, 2026)'), 'Caption appended with date');
}

// Test 9: wrapHtmlForPrintRendering generates complete HTML document
{
const html = '<p>Test content</p>';
const metadata = {
issuer: 'Test Org',
document_identity: 'TEST-DOC',
};
const result = wrapHtmlForPrintRendering(html, metadata);
check(result.includes('<!DOCTYPE html>'), 'Includes DOCTYPE');
check(result.includes('<html lang="en">'), 'Includes html tag');
check(result.includes('<style>'), 'Includes style tag');
check(result.includes('<p>Test content</p>'), 'Preserves content');
check(result.includes('TEST-DOC'), 'Includes document identity');
}

// Test 10: wrapHtmlForPrintRendering formats figure captions
{
const html = `
<figure data-snapshot-id="snap-001">
<figcaption>Diagram</figcaption>
</figure>
`;
const metadata = {
snapshots: {
'snap-001': { generated_at: '2026-09-02T14:45:00Z' }
}
};
const result = wrapHtmlForPrintRendering(html, metadata);
check(result.includes('Diagram (Sep 2, 2026)'), 'Figure caption formatted in wrapper');
}

// Test 11: wrapHtmlForPrintRendering escapes metadata
{
const html = '<p>Content</p>';
const metadata = {
issuer: '<script>alert("xss")</script>',
document_identity: 'DOC & MORE'
};
const result = wrapHtmlForPrintRendering(html, metadata);
check(!result.includes('<script>'), 'Script tags escaped in issuer');
check(result.includes('&amp;'), 'Ampersands escaped in identity');
}

// Test 12: formatFigureCaptions handles figures without figcaption
{
const html = `
<figure data-snapshot-id="snap-001">
<img src="diagram.svg" />
</figure>
`;
const snapshots = {
'snap-001': { generated_at: '2026-09-02T14:45:00Z' }
};
const result = formatFigureCaptions(html, snapshots);
check(result === html, 'Figures without figcaption are skipped');
}

// Test 13: formatFigureCaptions date formatting for various dates
{
const tests = [
{ generated_at: '2026-01-15T10:00:00Z', shouldContain: 'Jan' },
{ generated_at: '2026-12-31T23:59:00Z', shouldContain: '', isYearEdge: true }, // Timezone-dependent edge case
{ generated_at: '2025-06-01T00:00:00Z', shouldContain: 'Jun' },
];

for (const test of tests) {
const html = `<figure data-snapshot-id="snap"><figcaption>Test</figcaption></figure>`;
const snapshots = { 'snap': { generated_at: test.generated_at } };
const result = formatFigureCaptions(html, snapshots);
const hasDate = /Test \([A-Za-z]+ \d+, \d{4}\)/.test(result);
if (test.isYearEdge) {
check(hasDate, `Date formats correctly (edge case): ${test.generated_at}`);
} else {
check(result.includes('Test (') && result.includes(test.shouldContain), `Date formats correctly: ${test.generated_at}`);
}
}
}

// Test 14: formatFigureCaptions preserves figure attributes
{
const html = `
<figure data-snapshot-id="snap-001" class="diagram" id="fig-arch">
<figcaption>Architecture</figcaption>
</figure>
`;
const snapshots = {
'snap-001': { generated_at: '2026-09-02T14:45:00Z' }
};
const result = formatFigureCaptions(html, snapshots);
check(result.includes('class="diagram"'), 'CSS class preserved');
check(result.includes('id="fig-arch"'), 'ID preserved');
}

console.log(`\nExit: ${failures === 0 ? 0 : 1} (${failures === 0 ? 'all pass' : failures + ' failures'})`);
process.exit(failures === 0 ? 0 : 1);
Loading