feat: add configurable A4 page margins

This commit is contained in:
Admin 2026-09-23 04:44:05 +00:00
parent 594b6b7221
commit acb20be569
6 changed files with 180 additions and 2 deletions

View File

@ -31,6 +31,11 @@ my-pack.zip
"accentColor": "#8b5e34", "accentColor": "#8b5e34",
"borderColor": "#d8c7b3", "borderColor": "#d8c7b3",
"borderRadius": 14, "borderRadius": 14,
"pageMarginTop": 12,
"pageMarginRight": 12,
"pageMarginBottom": 12,
"pageMarginLeft": 12,
"pagePadding": 18,
"pageBackgroundImage": "asset://paper-bg", "pageBackgroundImage": "asset://paper-bg",
"borderImage": "asset://page-border" "borderImage": "asset://page-border"
}, },
@ -107,6 +112,12 @@ Use `variant: "logo"` for compact brand marks and `variant: "hero"` for banners/
For report pagination, blocks avoid splitting across pages by default. Optional block style flags are `keepWithNext`, `pageBreakBefore`, `pageBreakAfter`, and `allowPageBreak`. For report pagination, blocks avoid splitting across pages by default. Optional block style flags are `keepWithNext`, `pageBreakBefore`, `pageBreakAfter`, and `allowPageBreak`.
### Page setup
Report themes can set A4 print margins independently in millimetres with `pageMarginTop`, `pageMarginRight`, `pageMarginBottom`, and `pageMarginLeft`. Each accepts 0–40 mm and defaults to 12 mm.
`pagePadding` is separate: it controls the internal spacing inside the rendered document/frame in pixels and defaults to 18 px.
### Styling ### Styling
The constrained theme supports colors, border radius, background imagery and report border imagery. Blocks may also include a `style` object with background/text/border colors, border width/radius, padding and alignment. Arbitrary CSS/HTML/JavaScript is intentionally not supported. The constrained theme supports colors, border radius, background imagery and report border imagery. Blocks may also include a `style` object with background/text/border colors, border width/radius, padding and alignment. Arbitrary CSS/HTML/JavaScript is intentionally not supported.

View File

@ -58,7 +58,11 @@ function themeOf(template) {
buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'), buttonTextColor: safeHex(theme.buttonTextColor, '#ffffff'),
borderColor: safeHex(theme.borderColor, '#dbe2ea'), borderColor: safeHex(theme.borderColor, '#dbe2ea'),
borderRadius: safeNumber(theme.borderRadius, 12, 0, 32), borderRadius: safeNumber(theme.borderRadius, 12, 0, 32),
pagePadding: safeNumber(theme.pagePadding, 18, 0, 40), pagePadding: safeNumber(theme.pagePadding, 18, 0, 80),
pageMarginTop: safeNumber(theme.pageMarginTop, 12, 0, 40),
pageMarginRight: safeNumber(theme.pageMarginRight, 12, 0, 40),
pageMarginBottom: safeNumber(theme.pageMarginBottom, 12, 0, 40),
pageMarginLeft: safeNumber(theme.pageMarginLeft, 12, 0, 40),
fontFamily: safeFont(theme.fontFamily), fontFamily: safeFont(theme.fontFamily),
pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}), pageBackgroundImage: safeUrl(theme.pageBackgroundImage, {}),
borderImage: safeUrl(theme.borderImage, {}) borderImage: safeUrl(theme.borderImage, {})
@ -178,7 +182,7 @@ export function renderReportHtml(template, data = {}) {
return `<!doctype html> return `<!doctype html>
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style> <style>
@page{size:A4;margin:12mm}*{box-sizing:border-box}html,body{min-height:100%}body{font:${15}px/1.45 ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:repeat-y;-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{min-height:calc(297mm - 24mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 10}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.block{margin:0 0 10px;break-inside:avoid-page;page-break-inside:avoid}.block:last-child{margin-bottom:0}.image-logo{margin-bottom:6px}.image-hero{margin-top:2px;margin-bottom:10px}h2{font-size:23px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:12px 14px;display:flex;justify-content:space-between;gap:16px;background:${theme.surfaceColor}}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:18px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{height:auto;vertical-align:middle}hr{border:0;border-top:1px solid ${theme.borderColor};margin:10px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}table{border-collapse:collapse;width:100%;font-size:13px}thead{display:table-header-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:8px 10px;text-align:left;border-bottom:1px solid ${theme.borderColor}}th{background:${theme.backgroundColor}} @page{size:A4;margin:${theme.pageMarginTop}mm ${theme.pageMarginRight}mm ${theme.pageMarginBottom}mm ${theme.pageMarginLeft}mm}*{box-sizing:border-box}html,body{min-height:100%}body{font:${15}px/1.45 ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:repeat-y;-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{min-height:calc(297mm - ${theme.pageMarginTop + theme.pageMarginBottom}mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 10}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.block{margin:0 0 10px;break-inside:avoid-page;page-break-inside:avoid}.block:last-child{margin-bottom:0}.image-logo{margin-bottom:6px}.image-hero{margin-top:2px;margin-bottom:10px}h2{font-size:23px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:12px 14px;display:flex;justify-content:space-between;gap:16px;background:${theme.surfaceColor}}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:18px}.button{display:inline-block;background:${theme.accentColor};color:${theme.buttonTextColor};text-decoration:none;padding:10px 16px;border-radius:${theme.borderRadius}px}.image{height:auto;vertical-align:middle}hr{border:0;border-top:1px solid ${theme.borderColor};margin:10px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}table{border-collapse:collapse;width:100%;font-size:13px}thead{display:table-header-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:8px 10px;text-align:left;border-bottom:1px solid ${theme.borderColor}}th{background:${theme.backgroundColor}}
</style></head><body><main class="document">${blocks.map(block => blockHtml(block, data, theme)).join('\n')}</main></body></html>` </style></head><body><main class="document">${blocks.map(block => blockHtml(block, data, theme)).join('\n')}</main></body></html>`
} }

View File

@ -64,3 +64,19 @@ test('report blocks avoid page breaks by default and support explicit page contr
assert.match(html, /break-after:avoid-page/) assert.match(html, /break-after:avoid-page/)
assert.match(html, /page-break-inside:avoid/) assert.match(html, /page-break-inside:avoid/)
}) })
test('report renderer applies independent page margins in millimetres', () => {
const html = renderReportHtml({
theme: {
pageMarginTop: 8,
pageMarginRight: 10,
pageMarginBottom: 14,
pageMarginLeft: 16,
pagePadding: 22
},
blocks: [{ type: 'text', props: { text: 'Margins' } }]
}, {})
assert.match(html, /margin:8mm 10mm 14mm 16mm/)
assert.match(html, /min-height:calc\(297mm - 22mm\)/)
assert.match(html, /padding:22px/)
})

View File

@ -230,6 +230,11 @@ export const templatePackSpec = {
accentColor: '#8b5e34', accentColor: '#8b5e34',
borderColor: '#d8c7b3', borderColor: '#d8c7b3',
borderRadius: 14, borderRadius: 14,
pageMarginTop: 12,
pageMarginRight: 12,
pageMarginBottom: 12,
pageMarginLeft: 12,
pagePadding: 18,
pageBackgroundImage: 'asset://paper-bg', pageBackgroundImage: 'asset://paper-bg',
borderImage: 'asset://page-border' borderImage: 'asset://page-border'
}, },

View File

@ -203,6 +203,18 @@ export function App({ user }: Props) {
})) }))
} }
function updateTheme(patch: Record<string, unknown>) {
setTemplateDoc(current => ({
...current,
theme: { ...(current.theme || {}), ...patch }
}))
}
function themeNumber(key: string, fallback: number) {
const value = Number(templateDoc.theme?.[key])
return Number.isFinite(value) ? value : fallback
}
function moveBlock(id: string, direction: -1 | 1) { function moveBlock(id: string, direction: -1 | 1) {
setTemplateDoc(current => { setTemplateDoc(current => {
const blocks = [...current.blocks] const blocks = [...current.blocks]
@ -513,6 +525,57 @@ export function App({ user }: Props) {
</div> </div>
<button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button> <button className="button" onClick={() => void refreshPreview()}>↻ Refresh</button>
</div> </div>
{active?.type === 'report' && (
<section className="page-setup-card">
<div className="page-setup-heading">
<div>
<span className="eyebrow">PAGE SETUP</span>
<h3>A4 margins</h3>
</div>
<span>mm</span>
</div>
<div className="page-margin-grid">
{[
['Top', 'pageMarginTop', 12],
['Right', 'pageMarginRight', 12],
['Bottom', 'pageMarginBottom', 12],
['Left', 'pageMarginLeft', 12]
].map(([label, key, fallback]) => (
<label key={String(key)}>
{label}
<input
type="number"
inputMode="decimal"
min="0"
max="40"
step="1"
value={themeNumber(String(key), Number(fallback))}
onChange={event => updateTheme({ [String(key)]: Number(event.target.value) })}
/>
</label>
))}
</div>
<label className="content-padding-control">
Content padding <span>px</span>
<input
type="range"
min="0"
max="80"
step="1"
value={themeNumber('pagePadding', 18)}
onChange={event => updateTheme({ pagePadding: Number(event.target.value) })}
/>
<strong>{themeNumber('pagePadding', 18)} px</strong>
</label>
<button className="text-button page-reset" onClick={() => updateTheme({
pageMarginTop: 12,
pageMarginRight: 12,
pageMarginBottom: 12,
pageMarginLeft: 12,
pagePadding: 18
})}>Reset page setup</button>
</section>
)}
<div className="segmented preview-segment"> <div className="segmented preview-segment">
<button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Phone</button> <button className={previewDevice === 'mobile' ? 'active' : ''} onClick={() => setPreviewDevice('mobile')}>Phone</button>
<button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button> <button className={previewDevice === 'desktop' ? 'active' : ''} onClick={() => setPreviewDevice('desktop')}>Desktop</button>

View File

@ -709,3 +709,82 @@ body{
margin-left:auto; margin-left:auto;
} }
} }
.page-setup-card{
margin:0 0 14px;
padding:14px;
border:1px solid var(--line);
border-radius:16px;
background:white;
box-shadow:0 3px 12px rgba(15,23,42,.025);
}
.page-setup-heading{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:12px;
margin-bottom:12px;
}
.page-setup-heading h3{
margin:3px 0 0;
font-size:15px;
}
.page-setup-heading>span{
color:#8994a4;
font-size:10px;
font-weight:800;
}
.page-margin-grid{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:7px;
}
.page-margin-grid label{
display:grid;
gap:5px;
min-width:0;
color:#7b8797;
font-size:9px;
font-weight:800;
}
.page-margin-grid input{
width:100%;
min-width:0;
border:1px solid #d6dde6;
border-radius:9px;
padding:9px 6px;
text-align:center;
font-size:13px;
font-weight:800;
color:#263449;
background:#f9fafb;
}
.content-padding-control{
display:grid;
grid-template-columns:auto auto 1fr auto;
align-items:center;
gap:7px;
margin-top:12px;
color:#59677a;
font-size:10px;
font-weight:800;
}
.content-padding-control>span{
color:#9aa4b2;
font-size:9px;
}
.content-padding-control input{
width:100%;
min-width:0;
}
.content-padding-control strong{
min-width:38px;
text-align:right;
color:#263449;
font-size:10px;
}
.page-reset{
margin-top:10px;
padding:0;
font-size:10px;
}