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,
|
"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.
|
||||||
|
|||||||
@ -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>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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/)
|
||||||
|
})
|
||||||
|
|||||||
@ -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: [
|
||||||
|
|||||||
62
src/App.tsx
62
src/App.tsx
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user