fix(cv): align preview pagination with native PDF
This commit is contained in:
@@ -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");
|
||||
});
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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" }}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user