fix: make report page artwork opt-in
This commit is contained in:
parent
acb20be569
commit
59d1269366
@ -37,6 +37,9 @@ my-pack.zip
|
||||
"pageMarginLeft": 12,
|
||||
"pagePadding": 18,
|
||||
"pageBackgroundImage": "asset://paper-bg",
|
||||
"pageBackgroundMode": "none",
|
||||
"pageBackgroundOpacity": 0.08,
|
||||
"decorativeBorder": false,
|
||||
"borderImage": "asset://page-border"
|
||||
},
|
||||
"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.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@ -47,6 +47,10 @@ function safeFont(value) {
|
||||
return allowed.includes(font) ? font : 'Arial'
|
||||
}
|
||||
|
||||
function safeBackgroundMode(value) {
|
||||
return ['none', 'watermark', 'full'].includes(value) ? value : 'none'
|
||||
}
|
||||
|
||||
function themeOf(template) {
|
||||
const theme = template?.theme || {}
|
||||
return {
|
||||
@ -65,6 +69,9 @@ function themeOf(template) {
|
||||
pageMarginLeft: safeNumber(theme.pageMarginLeft, 12, 0, 40),
|
||||
fontFamily: safeFont(theme.fontFamily),
|
||||
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, {})
|
||||
}
|
||||
}
|
||||
@ -176,13 +183,16 @@ function blockHtml(block, data, theme) {
|
||||
export function renderReportHtml(template, data = {}) {
|
||||
const blocks = template?.blocks || []
|
||||
const theme = themeOf(template)
|
||||
const backgroundImage = theme.pageBackgroundImage === '#' ? 'none' : `url("${escapeHtml(theme.pageBackgroundImage)}")`
|
||||
const borderImage = theme.borderImage === '#' ? 'none' : `url("${escapeHtml(theme.borderImage)}") 30 stretch`
|
||||
const hasBackground = theme.pageBackgroundImage !== '#' && theme.pageBackgroundMode !== 'none'
|
||||
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>
|
||||
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<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>`
|
||||
}
|
||||
|
||||
|
||||
@ -80,3 +80,31 @@ test('report renderer applies independent page margins in millimetres', () => {
|
||||
assert.match(html, /min-height:calc\(297mm - 22mm\)/)
|
||||
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/)
|
||||
})
|
||||
|
||||
@ -236,6 +236,9 @@ export const templatePackSpec = {
|
||||
pageMarginLeft: 12,
|
||||
pagePadding: 18,
|
||||
pageBackgroundImage: 'asset://paper-bg',
|
||||
pageBackgroundMode: 'none',
|
||||
pageBackgroundOpacity: 0.08,
|
||||
decorativeBorder: false,
|
||||
borderImage: 'asset://page-border'
|
||||
},
|
||||
blocks: [
|
||||
|
||||
62
src/App.tsx
62
src/App.tsx
@ -215,6 +215,16 @@ export function App({ user }: Props) {
|
||||
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) {
|
||||
setTemplateDoc(current => {
|
||||
const blocks = [...current.blocks]
|
||||
@ -567,12 +577,62 @@ export function App({ user }: Props) {
|
||||
/>
|
||||
<strong>{themeNumber('pagePadding', 18)} px</strong>
|
||||
</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({
|
||||
pageMarginTop: 12,
|
||||
pageMarginRight: 12,
|
||||
pageMarginBottom: 12,
|
||||
pageMarginLeft: 12,
|
||||
pagePadding: 18
|
||||
pagePadding: 18,
|
||||
pageBackgroundMode: 'none',
|
||||
pageBackgroundOpacity: 0.08,
|
||||
decorativeBorder: false
|
||||
})}>Reset page setup</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@ -788,3 +788,48 @@ body{
|
||||
padding:0;
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user