fix(cv): align preview pagination with native PDF

This commit is contained in:
cesnimda
2026-10-07 08:46:43 +02:00
parent 046dc304aa
commit efc9e7bdd4
182 changed files with 16092 additions and 47 deletions
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "@playwright/test";
import fs from "node:fs/promises";
import path from "node:path";
import { previewDocumentHtml } from "../src/cvPreview";
const original = path.resolve("../docs/redesign/phase-5/p5-09-readiness/evidence/fidelity");
test("[PRES-C09 C12] original flowing/boundary fixtures retain native PDF page membership", async ({ page }) => {
const baseline = JSON.parse(await fs.readFile(path.join(original, "fidelity-results.json"), "utf8"));
for (const fixture of baseline.fixtures.filter((fixture: { pass: boolean }) => !fixture.pass)) {
await page.setContent(previewDocumentHtml(await fs.readFile(path.join(original, fixture.fixture, "render.html"), "utf8"), 1122.52));
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe("true");
expect(await page.evaluate(() => Number(document.documentElement.dataset.cvPreviewPages)), fixture.fixture).toBe(fixture.pdfPages);
const membership = await page.evaluate(() => {
const width = Number(document.documentElement.dataset.cvPreviewWidth);
const walker = document.createTreeWalker(document.querySelector(".page")!, NodeFilter.SHOW_TEXT);
const markers: { token: string; page: number }[] = [];
let node;
while ((node = walker.nextNode())) for (const match of (node.textContent || "").matchAll(/\b[SEBPR]\d{2}\b/g)) {
const range = document.createRange();
range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
markers.push({ token: match[0], page: Math.floor(range.getClientRects()[0].x / width) + 1 });
}
return markers;
});
expect(membership, fixture.fixture).toEqual(fixture.comparisons.map((marker: { token: string; pdfPage: number }) => ({ token: marker.token, page: marker.pdfPage })));
expect(await page.evaluate(() => document.body.innerText)).not.toContain("HIDDEN-MARKER");
}
});
test("[PRES-C06 C09] pagination waits for image layout and publishes one settled measurement", async ({ page }) => {
let releaseImage!: () => void;
const gate = new Promise<void>(resolve => { releaseImage = resolve; });
await page.route("**/pagination-photo.svg", async route => {
await gate;
await route.fulfill({ contentType: "image/svg+xml", body: '<svg xmlns="http://www.w3.org/2000/svg" width="200" height="100"><rect width="200" height="100" fill="navy"/></svg>' });
});
const html = '<html><head><style>:root{--cv-page-margin:16mm}body{margin:0}.page{width:210mm;min-height:297mm}@media print{.page{width:auto;min-height:0}}</style></head><body><div class="page"><img alt="Synthetic fixture" src="http://localhost:3300/pagination-photo.svg" style="width:20mm"></div></body></html>';
await page.setContent(previewDocumentHtml(html, 1122.52), { waitUntil: "domcontentloaded" });
expect(await page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBeUndefined();
await page.evaluate(() => {
document.documentElement.dataset.readyEvents = "0";
window.addEventListener("cv-preview-ready", () => { document.documentElement.dataset.readyEvents = String(Number(document.documentElement.dataset.readyEvents) + 1); });
});
releaseImage();
await expect.poll(() => page.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe("true");
expect(await page.evaluate(() => ({ events: document.documentElement.dataset.readyEvents, pages: document.documentElement.dataset.cvPreviewPages }))).toEqual({ events: "1", pages: "1" });
expect(await page.evaluate(() => document.querySelector("img")!.getBoundingClientRect().height)).toBeCloseTo(10 * 96 / 25.4, 1);
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
expect(await page.evaluate(() => document.documentElement.dataset.readyEvents)).toBe("1");
});
+11 -5
View File
@@ -71,13 +71,16 @@ test("[PRES-C06 C08 C09 C12] native preview/PDF fidelity corpus records unchange
await page.goto(`/career/builder/${variant.id}`);
await expect(page.getByTitle("CV preview page 1", { exact: true })).toBeVisible();
const iframe = page.getByTitle("CV preview", { exact: true });
await expect.poll(async () => iframe.evaluate(element => !!(element as HTMLIFrameElement).contentDocument?.querySelector(".name"))).toBe(true);
await expect.poll(async () => iframe.evaluate(element => (element as HTMLIFrameElement).contentDocument?.documentElement?.dataset.cvPreviewReady)).toBe("true");
const measured = await iframe.evaluate(async element => {
const frame = element as HTMLIFrameElement;
const doc = frame.contentDocument!;
await doc.fonts.ready;
await new Promise<void>(resolve => frame.contentWindow!.requestAnimationFrame(() => frame.contentWindow!.requestAnimationFrame(() => resolve())));
const win = frame.contentWindow!;
const geometry = doc.documentElement.dataset;
const physicalWidth = Number(geometry.cvPreviewWidth);
const physicalHeight = Number(geometry.cvPreviewHeight);
const css = (selector: string) => {
const node = doc.querySelector(selector);
if (!node) return null;
@@ -93,12 +96,15 @@ test("[PRES-C06 C08 C09 C12] native preview/PDF fidelity corpus records unchange
for (const match of (node.textContent || "").matchAll(/\b[SEBPR]\d{2}\b/g)) {
const range = doc.createRange();
range.setStart(node, match.index!); range.setEnd(node, match.index! + match[0].length);
const rect = range.getBoundingClientRect();
const rect = range.getClientRects()[0];
const style = win.getComputedStyle(node.parentElement!);
tokens.push({ token: match[0], x: rect.x, y: rect.y, width: rect.width, height: rect.height, fontSize: style.fontSize, fontFamily: style.fontFamily, color: style.color });
// Native screen columns are horizontal. Normalize to the same physical-page
// coordinate system used by the unchanged PDF oracle, retaining raw evidence.
const pageIndex = Math.floor(rect.x / physicalWidth);
tokens.push({ token: match[0], x: rect.x - pageIndex * physicalWidth, y: rect.y + pageIndex * physicalHeight, rawX: rect.x, rawY: rect.y, page: pageIndex + 1, width: rect.width, height: rect.height, fontSize: style.fontSize, fontFamily: style.fontFamily, color: style.color });
}
}
return { width: frame.clientWidth, contentHeight: Math.max(doc.body.scrollHeight, doc.documentElement.scrollHeight), text: doc.body.innerText, tokens, body: css("body"), page: css(".page"), header: css(".header,.hero"), main: css(".main"), sidebar: css(".sidebar"), parsedRules: Array.from(doc.styleSheets).flatMap(sheet => Array.from(sheet.cssRules).map(rule => rule.cssText)), sections: Array.from(doc.querySelectorAll(".section")).map(node => ({ key: node.className, title: node.querySelector("h2")?.textContent, y: node.getBoundingClientRect().y })), effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue("--cv-page-margin"), userAgent: win.navigator.userAgent };
return { width: physicalWidth, contentHeight: Number(geometry.cvPreviewPages) * physicalHeight, usableHeight: Number(geometry.cvPreviewUsableHeight), layoutDurationMs: Number(geometry.cvPreviewDurationMs), text: doc.body.innerText, tokens, body: css("body"), page: css(".page"), header: css(".header,.hero"), main: css(".main"), sidebar: css(".sidebar"), parsedRules: Array.from(doc.styleSheets).flatMap(sheet => Array.from(sheet.cssRules).map(rule => rule.cssText)), sections: Array.from(doc.querySelectorAll(".section")).map(node => ({ key: node.className, title: node.querySelector("h2")?.textContent, fragments: Array.from(node.getClientRects()).map(rect => ({ x: rect.x, y: rect.y, width: rect.width, height: rect.height })) })), effectiveMargin: win.getComputedStyle(doc.documentElement).getPropertyValue("--cv-page-margin"), userAgent: win.navigator.userAgent };
});
expect(measured.text, fixture.key).not.toContain("HIDDEN-MARKER");
const pdf = await page.request.post(`${apiUrl}/cv/variants/${variant.id}/export-pdf`, { headers });
@@ -117,7 +123,7 @@ test("[PRES-C06 C08 C09 C12] native preview/PDF fidelity corpus records unchange
const capture = await browser.newPage({ viewport: { width: Math.ceil(measured.width), height: Math.ceil(height) } });
try {
await capture.setContent(srcDoc!);
await capture.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
await expect.poll(() => capture.evaluate(() => document.documentElement.dataset.cvPreviewReady)).toBe("true");
await capture.screenshot({ path: path.join(directory, `preview-${index}.png`) });
} finally { await capture.close(); }
}
+43
View File
@@ -0,0 +1,43 @@
// Use browser fragmentation, rather than estimating breaks in a tall screen document.
// The renderer's print rules and physical geometry remain authoritative.
export function previewDocumentHtml(html: string, fallbackHeightPx: number, pageIndex = 0): string {
const script = `<script data-cv-preview-pagination>(async()=>{
const started=performance.now();
const page=document.querySelector('.page');if(!page)return;
await document.fonts.ready;
await Promise.all(Array.from(document.images).map(image=>image.decode().catch(()=>{})));
const width=page.getBoundingClientRect().width;
const height=parseFloat(getComputedStyle(page).minHeight)||${fallbackHeightPx};
const margin=parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--cv-page-margin'))*96/25.4;
const printRules=Array.from(document.styleSheets).flatMap(sheet=>Array.from(sheet.cssRules))
.filter(rule=>rule instanceof CSSMediaRule&&rule.conditionText==='print')
.flatMap(rule=>Array.from(rule.cssRules).map(rule=>rule.cssText)).join(' ');
const style=document.createElement('style');
// Chromium's print fragmentainer rounds the usable CSS height up to a pixel.
// A native 1px ruler verifies this for A4, Letter and density-adjusted margins.
const usableHeight=Math.ceil(height-2*margin);
style.textContent=printRules+'html,body{overflow:hidden!important;background:transparent!important;}body{padding-top:'+margin+'px;}'+
'[data-cv-preview-flow]{width:'+width+'px;height:'+usableHeight+'px;column-width:'+width+'px;column-gap:0;column-fill:auto;transform-origin:top left;}';
document.head.append(style);
const flow=document.createElement('div');flow.setAttribute('data-cv-preview-flow','');page.before(flow);flow.append(page);
// Keep the existing print decisions, substituting the screen fragment type.
const breaks=[];
page.querySelectorAll('*').forEach(element=>{
const css=getComputedStyle(element);
['break-inside','break-before','break-after'].forEach(property=>{
if(css.getPropertyValue(property)==='avoid-page')breaks.push([element,property]);
});
});
breaks.forEach(([element,property])=>element.style.setProperty(property,'avoid-column'));
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
const pages=Math.max(1,Math.round(flow.scrollWidth/width));
const overflow=Array.from(page.querySelectorAll('*')).some(element=>
Array.from(element.getClientRects()).some(rect=>rect.width>width+2));
flow.style.transform='translateX(-'+(${Math.max(0, pageIndex)}*width)+'px)';
Object.assign(document.documentElement.dataset,{cvPreviewPages:String(pages),cvPreviewWidth:String(width),
cvPreviewHeight:String(height),cvPreviewUsableHeight:String(usableHeight),cvPreviewOverflow:String(overflow),
cvPreviewDurationMs:String(performance.now()-started),cvPreviewReady:'true'});
window.dispatchEvent(new Event('cv-preview-ready'));
})();</script>`;
return html.includes('</body>') ? html.replace('</body>', `${script}</body>`) : `${html}${script}`;
}
+12 -34
View File
@@ -35,26 +35,15 @@ import { useDragReorder } from "../hooks/useDragReorder";
import {
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
cvBuilderApi, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
} from "../cvBuilder";
import { previewDocumentHtml } from "../cvPreview";
import { useDialogActions } from "../dialogs";
import { useI18n } from "../i18n/I18nProvider";
const MIN_PREVIEW_ZOOM = 0.2;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error" | "conflict";
function previewDocumentHtml(html: string, pageHeightPx: number, pageMarginMm: number, pageIndex = 0): string {
const height = Math.max(1, Math.round(pageHeightPx * 100) / 100);
const margin = Math.max(0, Math.round(pageMarginMm * (96 / 25.4) * 100) / 100);
const offset = Math.max(0, pageIndex) * height;
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:transparent!important;}.page{position:relative;transform-origin:top left;padding-top:${margin}px;padding-bottom:${margin}px;}.header{padding-top:0!important;}.main,.sidebar{padding-bottom:0!important;}.cols{min-height:calc(${height}px - ${margin * 2}px)!important;}.cols>.main,.cols>.sidebar{padding-top:0!important;}</style>`;
// Screen media does not apply Chromium's paged-media fragmentation. Reproduce the important
// keep-together decisions before each preview frame crops the selected physical page.
const previewScript = `<script data-cv-preview-pagination>(()=>{const H=${height};const M=${margin};const OFFSET=${offset};const page=document.querySelector('.page');if(!page)return;const topOf=(el)=>{let top=0,node=el;while(node&&node!==document.body){top+=node.offsetTop||0;node=node.offsetParent;}return top;};const paginate=()=>{document.querySelectorAll('[data-cv-preview-pad]').forEach((el)=>{el.style.paddingTop='';el.removeAttribute('data-cv-preview-pad');});const pushToNextPage=(el,heightOverride)=>{const current=parseFloat(getComputedStyle(el).paddingTop)||0;let top=topOf(el)+current;const blockHeight=heightOverride||Math.max(0,el.offsetHeight-current);const usable=H-(M*2);if(blockHeight<=0||blockHeight>usable-2)return;let pageTop=Math.floor(top/H)*H;let contentTop=pageTop+M;let contentBottom=pageTop+H-M;let extra=0;if(top<contentTop){extra=contentTop-top;top=contentTop;}if(top+blockHeight>contentBottom+1){extra+=(pageTop+H+M)-top;}if(extra>0){el.style.paddingTop=(current+extra)+'px';el.setAttribute('data-cv-preview-pad','true');}};document.querySelectorAll('.section').forEach((section)=>{if(!section.classList.contains('section-flow')){pushToNextPage(section);return;}const title=section.querySelector(':scope > .section-title');const first=title&&title.nextElementSibling;if(title&&first){const groupedBottom=topOf(first)+first.offsetHeight;pushToNextPage(section,groupedBottom-topOf(section));}section.querySelectorAll(':scope > .entry:not(.entry-flow),:scope > .skill-groups > .skill-group,:scope > .bullets > li:not(.item-flow),:scope > .entry-flow .bullets > li:not(.item-flow),:scope > .paragraphs > p').forEach((block)=>pushToNextPage(block));});page.style.transform='translateY(-'+OFFSET+'px)';};paginate();requestAnimationFrame(()=>requestAnimationFrame(paginate));})();</script>`;
const withCss = html.includes("</head>") ? html.replace("</head>", `${previewCss}</head>`) : `${previewCss}${html}`;
return withCss.includes("</body>") ? withCss.replace("</body>", `${previewScript}</body>`) : `${withCss}${previewScript}`;
}
export default function CvBuilderEditor() {
const { id } = useParams();
const variantId = Number(id);
@@ -102,11 +91,6 @@ export default function CvBuilderEditor() {
const undoStack = useRef<CvVariantSettings[]>([]);
const redoStack = useRef<CvVariantSettings[]>([]);
const pageMetrics = useMemo(() => getCvPageMetrics(settings?.pageSize), [settings?.pageSize]);
const pageMarginMm = useMemo(() => {
const themeMargin = themes.find((theme) => theme.id === settings?.themeId)?.pageMarginMm;
const density = settings?.density === "compact" ? 0.82 : settings?.density === "roomy" ? 1.18 : 1;
return (settings?.pageMarginMm ?? themeMargin ?? 16) * density;
}, [settings?.pageMarginMm, settings?.themeId, settings?.density, themes]);
useEffect(() => {
let alive = true;
@@ -415,27 +399,21 @@ export default function CvBuilderEditor() {
// Measure rendered content to drive page count + break indicators.
const onIframeLoad = () => {
const win = iframeRef.current?.contentWindow;
const frame = iframeRef.current;
const win = frame?.contentWindow;
const doc = frame?.contentDocument;
const measure = () => {
if (frame !== iframeRef.current || doc !== frame?.contentDocument) return;
try {
const doc = iframeRef.current?.contentDocument;
const h = Math.max(
pageMetrics.heightPx,
doc?.body?.scrollHeight ?? 0,
doc?.documentElement?.scrollHeight ?? 0,
);
const pageCount = getCvPageCount(h, pageMetrics.heightPx);
setPages(pageCount);
const viewportWidth = doc?.documentElement?.clientWidth ?? pageMetrics.widthPx;
const contentWidth = Math.max(doc?.body?.scrollWidth ?? 0, doc?.documentElement?.scrollWidth ?? 0);
setPreviewOverflow(contentWidth > viewportWidth + 2);
setPages(Number(doc?.documentElement.dataset.cvPreviewPages) || 1);
setPreviewOverflow(doc?.documentElement.dataset.cvPreviewOverflow === "true");
} catch {
setPages(1);
setPreviewOverflow(false);
}
};
if (win) win.requestAnimationFrame(() => win.requestAnimationFrame(measure));
else measure();
if (doc?.documentElement.dataset.cvPreviewReady === "true" || !doc?.querySelector(".page")) measure();
else win?.addEventListener("cv-preview-ready", measure, { once: true });
};
const fitPreview = useCallback(() => {
@@ -532,7 +510,7 @@ export default function CvBuilderEditor() {
</Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, visibility: "hidden", pointerEvents: "none" }} />
<iframe ref={iframeRef} title={t("cvEditorPreviewTitle")} srcDoc={previewDocumentHtml(html, pageMetrics.heightPx)} sandbox="allow-same-origin allow-scripts" onLoad={onIframeLoad} aria-hidden tabIndex={-1} style={{ position: "absolute", left: "-10000px", top: 0, width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", visibility: "hidden", pointerEvents: "none" }} />
<Box ref={scrollRef} role="region" aria-label={t("cvEditorLivePreview")} tabIndex={0} sx={{ overflow: "auto", maxHeight: "86vh", pr: 0.5, scrollbarWidth: "none", "&::-webkit-scrollbar": { display: "none" } }}>
<Stack spacing={2} alignItems="center">
{Array.from({ length: pages }).map((_, index) => (
@@ -540,7 +518,7 @@ export default function CvBuilderEditor() {
<Box sx={{ position: "relative", width: `calc(${pageMetrics.widthMm}mm * ${zoom})`, height: `calc(${pageMetrics.heightMm}mm * ${zoom})`, bgcolor: "#fff", boxShadow: "0 8px 24px rgba(15,23,42,.16)", overflow: "hidden" }}>
<iframe
title={t("cvEditorPreviewPage", { page: index + 1 })}
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm, index)}
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, index)}
sandbox="allow-same-origin allow-scripts"
style={{ width: `${pageMetrics.widthMm}mm`, height: `${pageMetrics.heightMm}mm`, border: "none", transform: `scale(${zoom})`, transformOrigin: "top left", background: "#fff", display: "block" }}
/>