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
6 changes: 6 additions & 0 deletions .github/workflows/document-renderer-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,9 @@ jobs:

- name: Run full-run end-to-end test
run: node packages/document-renderer/tests/test_full_run.mjs

- name: Run paged-media CSS tests
run: node packages/document-renderer/tests/test_paged_media_css.mjs

- name: Run snapshot caption tests
run: node packages/document-renderer/tests/test_render_vivliostyle.mjs
21 changes: 20 additions & 1 deletion packages/document-renderer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,8 @@ The package has two PDF rendering paths:
- 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)
- Every-page identity for issued documents, including the first portrait or landscape page
- First-page suppression retained for non-issued content (no `issued_at`)

**Usage:**

Expand All @@ -514,6 +515,24 @@ const html = wrapHtmlForPrintRendering('<h1>Title</h1><p>Content...</p>', {
// Pass html to Vivliostyle or another CSS Paged Media renderer
```

Supplying `issued_at` selects the issued-document footer behavior; a one-page
issued document receives the same issuer, issue date, identity, optional commit
and page counters as subsequent pages. Callers supply the existing identity
fields; this helper does not create or validate an issue identity.

Issue dates (`issued_at`) and snapshot caption dates (`generated_at`) use the
`en-US` locale and **UTC**, independent of the host timezone. Supply ISO 8601
timestamps with `Z` or an explicit numeric offset (or a date-only `YYYY-MM-DD`);
timezone-less date-time strings are not stable inputs across hosts.
Snapshot captions use only captured-at `generated_at`, never valid-at
`renderDate`, the issue date, or the wall clock. `renderDate` remains the separate
pass-1 input that selects model validity. Missing snapshot dates leave captions
unchanged; invalid dates produce `Unknown date`. Callers remain responsible for
snapshot binding before issuing a document.

These helpers prepare HTML/CSS; their tests do not establish footer visibility,
landscape geometry, diagram embedding or reproducibility in a final PDF.

**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
Expand Down
12 changes: 8 additions & 4 deletions packages/document-renderer/src/render-vivliostyle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ function escapeHtml(text) {
*
* @param {object} metadata - Document metadata
* @param {string} metadata.issuer - Who created the document
* @param {string} metadata.issued_at - ISO 8601 timestamp
* @param {string} metadata.issued_at - Issue timestamp with explicit offset; formatted in UTC.
* When present, retains the footer on every page, including the first.
* @param {string} metadata.document_identity - Recipe ID or document identifier
* @param {string} [metadata.repository_commit] - Git commit hash
* @param {array} [metadata.views] - View specifications with width/height
Expand All @@ -52,6 +53,7 @@ export function generatePagedMediaCss(metadata = {}) {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
} catch {
// ignore invalid date
Expand Down Expand Up @@ -98,11 +100,11 @@ export function generatePagedMediaCss(metadata = {}) {
}
}

@page :first {
${metadata.issued_at ? '' : `@page :first {
@bottom-center {
content: "";
}
}
}`}

@page :left {
margin-right: 20mm;
Expand Down Expand Up @@ -238,7 +240,8 @@ ${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.
* the snapshot's captured-at generated_at timestamp to the caption, formatted in UTC.
* Supply timestamps with explicit offsets; renderDate (valid-at) is not a fallback.
*
* @param {string} html - HTML content with figure elements
* @param {object} snapshots - Map of snapshot IDs to snapshot objects with generated_at
Expand All @@ -265,6 +268,7 @@ export function formatFigureCaptions(html, snapshots = {}) {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC',
});
}
} catch {
Expand Down
42 changes: 39 additions & 3 deletions packages/document-renderer/tests/test_paged_media_css.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,47 @@ function check(condition, message) {
check(!hasUnescapedAmpersand, 'No unescaped ampersands in content string');
}

// Test 5: First page omits footer
// Test 5: Issued identity survives on every page, including a one-page document.
{
const metadata = {
issuer: 'Test Corp',
issued_at: '2026-09-02T00:30:00Z',
document_identity: 'SPEC-v2.0',
repository_commit: 'a1b2c3d4e5f6',
};
for (const content of ['<p>One page</p>', '<figure class="diagram-wide">Wide view</figure>']) {
const html = wrapHtmlForPrintRendering(content, metadata);
const css = html.match(/<style>([\s\S]*?)<\/style>/)[1];
check(!/@page[^{}]*:first/.test(css), 'Issued document has no first-page override');
check(!/content:\s*(?:""|none)/.test(css), 'Issued footer is never suppressed');
for (const selector of ['@page', '@page landscape']) {
const rule = css.slice(css.indexOf(`${selector} {`)).match(/^[\s\S]*?@bottom-center\s*\{([^}]+)\}/)?.[1] || '';
for (const text of ['Test Corp', 'Sep 2, 2026', 'SPEC-v2.0', 'a1b2c3d', 'counter(page)', 'counter(pages)']) {
check(rule.includes(text), `${selector} footer retains ${text}`);
}
}
}
const css = generatePagedMediaCss({ issuer: 'Test' });
check(css.includes('@page :first'), 'CSS includes :first pseudo-page');
check(css.includes('@page :first') && css.includes('content: ""'), 'First page footer is empty');
check(/@page :first\s*\{\s*@bottom-center\s*\{\s*content: "";/.test(css),
'Non-issued title page retains footer suppression');
}

// Issue dates must not change when the host timezone changes.
{
const originalTZ = process.env.TZ;
try {
for (const issued_at of ['2026-09-02T00:30:00Z', '2026-09-02', '2026-09-01T17:30:00-07:00']) {
const outputs = ['UTC', 'America/Los_Angeles'].map(timeZone => {
process.env.TZ = timeZone;
return generatePagedMediaCss({ issuer: 'Test', issued_at, document_identity: 'DOC' });
});
check(outputs[0] === outputs[1], `Issue date is host-independent: ${issued_at}`);
check(outputs[0].includes('Sep 2, 2026'), `Issue date uses UTC: ${issued_at}`);
}
} finally {
if (originalTZ === undefined) delete process.env.TZ;
else process.env.TZ = originalTZ;
}
}

// Test 6: Landscape pages included
Expand Down
57 changes: 41 additions & 16 deletions packages/document-renderer/tests/test_render_vivliostyle.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -182,24 +182,49 @@ function check(condition, message) {
check(result === html, 'Figures without figcaption are skipped');
}

// Test 13: formatFigureCaptions date formatting for various dates
// Test 13: Exact UTC dates, including midnight and year boundaries.
{
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}`);
const originalTZ = process.env.TZ;
const html = '<figure data-snapshot-id="snap"><figcaption>Test</figcaption></figure>';
try {
for (const [generated_at, expected] of [
['2026-01-15T10:00:00Z', 'Jan 15, 2026'],
['2026-12-31T23:59:00Z', 'Dec 31, 2026'],
['2025-06-01T00:00:00Z', 'Jun 1, 2025'],
['2026-09-02T00:30:00Z', 'Sep 2, 2026'],
['2026-09-01T17:30:00-07:00', 'Sep 2, 2026'],
['2026-09-02', 'Sep 2, 2026'],
]) {
const outputs = ['UTC', 'America/Los_Angeles'].map(timeZone => {
process.env.TZ = timeZone;
return formatFigureCaptions(html, { snap: { generated_at } });
});
check(outputs[0] === outputs[1], `Snapshot date is host-independent: ${generated_at}`);
check(outputs[0].includes(`Test (${expected})`), `Snapshot date uses UTC: ${generated_at}`);
}
} finally {
if (originalTZ === undefined) delete process.env.TZ;
else process.env.TZ = originalTZ;
}
}

// Captured-at is distinct from valid-at and issue time; no wall-clock fallback.
{
const html = '<figure data-snapshot-id="snap"><figcaption>Snapshot</figcaption></figure>';
for (const renderDate of ['2020-01-01', '2030-12-31']) {
const metadata = {
renderDate,
issued_at: '2026-10-01T12:00:00Z',
snapshots: { snap: { generated_at: '2026-09-02T00:30:00Z', renderDate } },
};
check(wrapHtmlForPrintRendering(html, metadata).includes('Snapshot (Sep 2, 2026)'),
'Wrapper uses captured-at regardless of valid-at or issue date');
delete metadata.snapshots.snap.generated_at;
check(wrapHtmlForPrintRendering(html, metadata).includes(html),
'Missing captured-at has no valid-at, issue-date or wall-clock fallback');
metadata.snapshots.snap.generated_at = 'invalid';
check(wrapHtmlForPrintRendering(html, metadata).includes('Snapshot (Unknown date)'),
'Invalid captured-at has no valid-at, issue-date or wall-clock fallback');
}
}

Expand Down
Loading