fix(cv): repeat printable page margins

This commit is contained in:
cesnimda
2026-08-28 12:20:44 +02:00
parent 6a31fb296f
commit 4da673ff00
5 changed files with 27 additions and 8 deletions
+3
View File
@@ -291,6 +291,9 @@ public sealed class CvBuilderTests
Assert.Contains("line-height:1.55", html);
Assert.Contains("font-size:13pt", html);
Assert.Contains("class=\"skills-text\">C# · SQL</p>", html);
Assert.Contains("--cv-page-margin:20mm", html);
Assert.Contains("@page{size:210mm 297mm;margin:20mm 0;}", html);
Assert.Contains(".page{width:auto;min-height:0;margin:0;}", html);
}
[Fact]
@@ -60,6 +60,7 @@ public sealed class CvVariantController : ControllerBase
available = proThemes || !t.Premium,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
supportedSettings = t.SupportedSettings,
pageMarginMm = t.PageMarginMm,
});
return Ok(themes);
}
+12 -3
View File
@@ -256,7 +256,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return $@"
*{{box-sizing:border-box;}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;}}
html,body{{min-width:0;}}
body{{margin:0;background:#e9edf2;color:var(--cv-ink);font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:var(--cv-paper);overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
@@ -315,8 +315,17 @@ h1,h2{{font-family:{headingFont};}}
.tag,.contact-item{{break-inside:avoid;}}
.bullets li{{break-inside:avoid-page;page-break-inside:avoid;orphans:2;widows:2;overflow-wrap:anywhere;}}
.bullets li.item-flow{{break-inside:auto;page-break-inside:auto;}}
@media print{{body{{background:transparent;}}}}
@page{{size:{page.w} {page.h};margin:0;}}
/* Physical-page margins belong to paged media rather than an individual template box. This makes
the same top/bottom safe area repeat on every page while themes continue to own horizontal
layout and density. Screen preview mirrors these bounds in CvBuilderEditor. */
@media print{{
body{{background:transparent;}}
.page{{width:auto;min-height:0;margin:0;}}
.cols{{min-height:0;}}
.header{{padding-top:0;}}
.main,.sidebar{{padding-top:0;padding-bottom:0;}}
}}
@page{{size:{page.w} {page.h};margin:{margin}mm 0;}}
";
}
+1
View File
@@ -62,6 +62,7 @@ export type CvTheme = {
available: boolean;
swatches: string[];
supportedSettings?: string[];
pageMarginMm?: number;
};
// Master profile resolved to sections+entries (GET /api/cv/outline) — what the Content tab edits.
+10 -5
View File
@@ -48,13 +48,14 @@ const FONT_LABELS = ["Segoe UI", "Arial", "Georgia (serif)", "Helvetica Neue", "
const MIN_PREVIEW_ZOOM = 0.32;
type SaveState = "idle" | "unsaved" | "saving" | "saved" | "error";
function previewDocumentHtml(html: string, pageHeightPx: number, pageIndex = 0): string {
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:#fff!important;}.page{transform-origin:top left;}</style>`;
const previewCss = `<style data-cv-preview-page>html,body{overflow:hidden!important;background:#fff!important;}.page{position:relative;transform-origin:top left;}.page::after{content:"";position:absolute;z-index:20;pointer-events:none;inset:0;background:repeating-linear-gradient(to bottom,var(--cv-paper,#fff) 0 ${margin}px,transparent ${margin}px calc(${height}px - ${margin}px),var(--cv-paper,#fff) calc(${height}px - ${margin}px) ${height}px);}</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 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;const top=topOf(el)+current;const blockHeight=heightOverride||Math.max(0,el.offsetHeight-current);if(blockHeight<=0||blockHeight>H-2)return;const remaining=H-(top%H);if(blockHeight>remaining+1){el.style.paddingTop=(current+remaining)+'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)').forEach((block)=>pushToNextPage(block));});page.style.transform='translateY(-'+OFFSET+'px)';};paginate();requestAnimationFrame(()=>requestAnimationFrame(paginate));})();</script>`;
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}`;
}
@@ -103,6 +104,10 @@ 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;
return settings?.pageMarginMm ?? themeMargin ?? 16;
}, [settings?.pageMarginMm, settings?.themeId, themes]);
useEffect(() => {
let alive = true;
@@ -482,7 +487,7 @@ export default function CvBuilderEditor() {
</Stack>
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>The preview reported horizontal overflow. Shorten an unbroken value or retry after the latest render.</Alert>}
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>This CV is {pages} pages. Content remains readable, but consider hiding less relevant entries for a more focused application.</Alert>}
<iframe ref={iframeRef} title="CV preview" 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`, visibility: "hidden", pointerEvents: "none" }} />
<iframe ref={iframeRef} title="CV preview" 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" }} />
<Box ref={scrollRef} sx={{ overflow: "auto", maxHeight: "82vh", p: { xs: 1, sm: 2 }, bgcolor: "#dfe4ea", borderRadius: 2 }}>
<Stack spacing={3} alignItems="center">
{Array.from({ length: pages }).map((_, index) => (
@@ -491,7 +496,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 10px 28px rgba(15,23,42,.18)", overflow: "hidden" }}>
<iframe
title={`CV preview page ${index + 1}`}
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, index)}
srcDoc={previewDocumentHtml(html, pageMetrics.heightPx, pageMarginMm, 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" }}
/>