fix: make report page artwork opt-in

This commit is contained in:
Admin 2026-09-23 04:53:49 +00:00
parent acb20be569
commit 59d1269366
6 changed files with 159 additions and 4 deletions

View File

@ -37,6 +37,9 @@ my-pack.zip
"pageMarginLeft": 12, "pageMarginLeft": 12,
"pagePadding": 18, "pagePadding": 18,
"pageBackgroundImage": "asset://paper-bg", "pageBackgroundImage": "asset://paper-bg",
"pageBackgroundMode": "none",
"pageBackgroundOpacity": 0.08,
"decorativeBorder": false,
"borderImage": "asset://page-border" "borderImage": "asset://page-border"
}, },
"blocks": [ "blocks": [
@ -118,6 +121,12 @@ Report themes can set A4 print margins independently in millimetres with `pageMa
`pagePadding` is separate: it controls the internal spacing inside the rendered document/frame in pixels and defaults to 18 px. `pagePadding` is separate: it controls the internal spacing inside the rendered document/frame in pixels and defaults to 18 px.
### Page artwork
Reports default to a clean page even if the ZIP includes background or border assets. Set `pageBackgroundMode` to `"watermark"` or `"full"` to opt in to the background image. `pageBackgroundOpacity` controls artwork strength from 0 to 0.35.
Decorative border artwork is also opt-in with `decorativeBorder: true`. This prevents stationery-style page chrome from appearing automatically on operational reports.
### 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

@ -47,6 +47,10 @@ function safeFont(value) {
return allowed.includes(font) ? font : 'Arial' return allowed.includes(font) ? font : 'Arial'
} }
function safeBackgroundMode(value) {
return ['none', 'watermark', 'full'].includes(value) ? value : 'none'
}
function themeOf(template) { function themeOf(template) {
const theme = template?.theme || {} const theme = template?.theme || {}
return { return {
@ -65,6 +69,9 @@ function themeOf(template) {
pageMarginLeft: safeNumber(theme.pageMarginLeft, 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, {}),
pageBackgroundMode: safeBackgroundMode(theme.pageBackgroundMode),
pageBackgroundOpacity: safeNumber(theme.pageBackgroundOpacity, 0.08, 0, 0.35),
decorativeBorder: theme.decorativeBorder === true,
borderImage: safeUrl(theme.borderImage, {}) borderImage: safeUrl(theme.borderImage, {})
} }
} }
@ -176,13 +183,16 @@ function blockHtml(block, data, theme) {
export function renderReportHtml(template, data = {}) { export function renderReportHtml(template, data = {}) {
const blocks = template?.blocks || [] const blocks = template?.blocks || []
const theme = themeOf(template) const theme = themeOf(template)
const backgroundImage = theme.pageBackgroundImage === '#' ? 'none' : `url("${escapeHtml(theme.pageBackgroundImage)}")` const hasBackground = theme.pageBackgroundImage !== '#' && theme.pageBackgroundMode !== 'none'
const borderImage = theme.borderImage === '#' ? 'none' : `url("${escapeHtml(theme.borderImage)}") 30 stretch` const backgroundImage = hasBackground ? `url("${escapeHtml(theme.pageBackgroundImage)}")` : 'none'
const borderEnabled = theme.decorativeBorder && theme.borderImage !== '#'
const borderImage = borderEnabled ? `url("${escapeHtml(theme.borderImage)}") 30 stretch` : 'none'
const backgroundSize = theme.pageBackgroundMode === 'full' ? 'cover' : '100% auto'
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:${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}} @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};-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{position:relative;isolation:isolate;overflow:hidden;min-height:calc(297mm - ${theme.pageMarginTop + theme.pageMarginBottom}mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${borderEnabled ? 10 : 0}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.document::before{content:"";position:absolute;z-index:-1;inset:0;background-image:${backgroundImage};background-size:${backgroundSize};background-position:center top;background-repeat:no-repeat;opacity:${hasBackground ? theme.pageBackgroundOpacity : 0};pointer-events:none}.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

@ -80,3 +80,31 @@ test('report renderer applies independent page margins in millimetres', () => {
assert.match(html, /min-height:calc\(297mm - 22mm\)/) assert.match(html, /min-height:calc\(297mm - 22mm\)/)
assert.match(html, /padding:22px/) assert.match(html, /padding:22px/)
}) })
test('report page chrome defaults to clean without stacked artwork', () => {
const html = renderReportHtml({
theme: {
pageBackgroundImage: 'https://example.com/paper.webp',
borderImage: 'https://example.com/border.png'
},
blocks: [{ type: 'text', props: { text: 'Clean report' } }]
}, {})
assert.match(html, /background-image:none/)
assert.match(html, /border:0px solid transparent/)
})
test('report page chrome can opt into subtle background and decorative border', () => {
const html = renderReportHtml({
theme: {
pageBackgroundImage: 'https://example.com/paper.webp',
pageBackgroundMode: 'watermark',
pageBackgroundOpacity: 0.1,
borderImage: 'https://example.com/border.png',
decorativeBorder: true
},
blocks: [{ type: 'text', props: { text: 'Styled report' } }]
}, {})
assert.match(html, /paper\.webp/)
assert.match(html, /opacity:0\.1/)
assert.match(html, /border:10px solid transparent/)
})

View File

@ -236,6 +236,9 @@ export const templatePackSpec = {
pageMarginLeft: 12, pageMarginLeft: 12,
pagePadding: 18, pagePadding: 18,
pageBackgroundImage: 'asset://paper-bg', pageBackgroundImage: 'asset://paper-bg',
pageBackgroundMode: 'none',
pageBackgroundOpacity: 0.08,
decorativeBorder: false,
borderImage: 'asset://page-border' borderImage: 'asset://page-border'
}, },
blocks: [ blocks: [

View File

@ -215,6 +215,16 @@ export function App({ user }: Props) {
return Number.isFinite(value) ? value : fallback return Number.isFinite(value) ? value : fallback
} }
function themeString(key: string, fallback: string) {
const value = templateDoc.theme?.[key]
return typeof value === 'string' ? value : fallback
}
function themeBoolean(key: string, fallback = false) {
const value = templateDoc.theme?.[key]
return typeof value === 'boolean' ? 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]
@ -567,12 +577,62 @@ export function App({ user }: Props) {
/> />
<strong>{themeNumber('pagePadding', 18)} px</strong> <strong>{themeNumber('pagePadding', 18)} px</strong>
</label> </label>
<div className="page-decoration-control">
<span className="control-label">Page artwork</span>
<div className="segmented compact">
{[
['none', 'Clean'],
['watermark', 'Subtle'],
['full', 'Full']
].map(([value, label]) => (
<button
key={value}
className={themeString('pageBackgroundMode', 'none') === value ? 'active' : ''}
onClick={() => updateTheme({ pageBackgroundMode: value })}
>
{label}
</button>
))}
</div>
</div>
{themeString('pageBackgroundMode', 'none') !== 'none' && (
<label className="content-padding-control">
Artwork strength <span>%</span>
<input
type="range"
min="0"
max="35"
step="1"
value={Math.round(themeNumber('pageBackgroundOpacity', 0.08) * 100)}
onChange={event => updateTheme({ pageBackgroundOpacity: Number(event.target.value) / 100 })}
/>
<strong>{Math.round(themeNumber('pageBackgroundOpacity', 0.08) * 100)}%</strong>
</label>
)}
<label className="toggle-row">
<span>
<strong>Decorative border</strong>
<small>Use the pack's border artwork around the report.</small>
</span>
<input
type="checkbox"
checked={themeBoolean('decorativeBorder')}
onChange={event => updateTheme({ decorativeBorder: event.target.checked })}
/>
</label>
<button className="text-button page-reset" onClick={() => updateTheme({ <button className="text-button page-reset" onClick={() => updateTheme({
pageMarginTop: 12, pageMarginTop: 12,
pageMarginRight: 12, pageMarginRight: 12,
pageMarginBottom: 12, pageMarginBottom: 12,
pageMarginLeft: 12, pageMarginLeft: 12,
pagePadding: 18 pagePadding: 18,
pageBackgroundMode: 'none',
pageBackgroundOpacity: 0.08,
decorativeBorder: false
})}>Reset page setup</button> })}>Reset page setup</button>
</section> </section>
)} )}

View File

@ -788,3 +788,48 @@ body{
padding:0; padding:0;
font-size:10px; font-size:10px;
} }
.page-decoration-control{
display:grid;
gap:7px;
margin-top:12px;
}
.control-label{
color:#59677a;
font-size:10px;
font-weight:800;
}
.segmented.compact{
margin:0;
}
.segmented.compact button{
padding:8px 9px;
font-size:10px;
}
.toggle-row{
display:flex!important;
align-items:center;
justify-content:space-between;
gap:14px;
margin-top:12px;
padding:10px 0 0;
border-top:1px solid #edf1f5;
}
.toggle-row>span{
display:grid;
gap:2px;
}
.toggle-row strong{
color:#263449;
font-size:11px;
}
.toggle-row small{
color:#8994a4;
font-size:9px;
font-weight:500;
}
.toggle-row input{
width:18px;
height:18px;
flex:0 0 auto;
}