feat: add dense report composition

This commit is contained in:
Admin 2026-09-23 06:39:39 +00:00
parent 8edad1e9e4
commit 444990a3af
5 changed files with 183 additions and 9 deletions

View File

@ -36,6 +36,7 @@ my-pack.zip
"pageMarginBottom": 12,
"pageMarginLeft": 12,
"pagePadding": 18,
"reportDensity": "auto",
"pageBackgroundImage": "asset://paper-bg",
"pageBackgroundMode": "none",
"pageBackgroundOpacity": 0.08,
@ -121,6 +122,19 @@ 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.
### Report density
Set `reportDensity` to `"auto"`, `"comfortable"`, or `"compact"`. Auto is the default. Reports with at least 20 blocks or 4 table blocks automatically use compact density.
Compact density:
- groups consecutive KPI blocks into a three-column grid;
- tightens heading, block, KPI and table spacing;
- treats normal `pageBreakBefore` hints as soft so sections can flow into unused page space;
- still honours `forcePageBreak: true`;
- keeps headings with the following content where possible.
For long tables set `style.allowPageBreak: true`. The renderer then permits rows to continue onto the next page and repeats the table header. Use `style.compact: true` to request tight table spacing even in comfortable mode.
### 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.

View File

@ -51,8 +51,20 @@ function safeBackgroundMode(value) {
return ['none', 'watermark', 'full'].includes(value) ? value : 'none'
}
function safeDensityMode(value) {
return ['auto', 'comfortable', 'compact'].includes(value) ? value : 'auto'
}
function resolveDensity(template, mode) {
if (mode !== 'auto') return mode
const blocks = Array.isArray(template?.blocks) ? template.blocks : []
const tableCount = blocks.filter(block => block?.type === 'table').length
return blocks.length >= 20 || tableCount >= 4 ? 'compact' : 'comfortable'
}
function themeOf(template) {
const theme = template?.theme || {}
const reportDensityMode = safeDensityMode(theme.reportDensity)
return {
backgroundColor: safeHex(theme.backgroundColor, '#f8fafc'),
surfaceColor: safeHex(theme.surfaceColor, '#ffffff'),
@ -72,7 +84,9 @@ function themeOf(template) {
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, {}),
reportDensityMode,
reportDensity: resolveDensity(template, reportDensityMode)
}
}
@ -91,8 +105,11 @@ function safeVariant(block) {
function reportBlockStyle(block, theme) {
const style = block?.style || {}
const breakBefore = style.pageBreakBefore === true ? 'page' : 'auto'
const breakAfter = style.pageBreakAfter === true ? 'page' : (style.keepWithNext === true ? 'avoid-page' : 'auto')
const compact = theme.reportDensity === 'compact'
const forceBreakBefore = style.forcePageBreak === true
const breakBefore = (style.pageBreakBefore === true && (!compact || forceBreakBefore)) ? 'page' : 'auto'
const keepWithNext = style.keepWithNext === true || (compact && block?.type === 'heading')
const breakAfter = style.pageBreakAfter === true ? 'page' : (keepWithNext ? 'avoid-page' : 'auto')
const breakInside = style.allowPageBreak === true ? 'auto' : 'avoid-page'
const rules = [
`background:${safeHex(style.backgroundColor, 'transparent')}`,
@ -142,11 +159,14 @@ function wrapReportBlock(block, theme, html) {
return `<div class="block${variant}" style="${reportBlockStyle(block, theme)}">${html}</div>`
}
function renderTable(block, data) {
function renderTable(block, data, theme) {
const rows = getPath(data, block.props?.rowsPath) || []
const columns = Array.isArray(block.props?.columns) ? block.props.columns : []
if (!Array.isArray(rows)) return ''
return `<div class="table-wrap"><table><thead><tr>${columns.map(c => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(c => `<td>${escapeHtml(getPath(row, c.key))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
const allowBreak = block?.style?.allowPageBreak === true
const compact = theme.reportDensity === 'compact' || block?.style?.compact === true
const classes = ['table-wrap', allowBreak ? 'allow-break' : '', compact ? 'compact-table' : ''].filter(Boolean).join(' ')
return `<div class="${classes}"><table><thead><tr>${columns.map(c => `<th>${escapeHtml(c.label)}</th>`).join('')}</tr></thead><tbody>${rows.map(row => `<tr>${columns.map(c => `<td>${escapeHtml(getPath(row, c.key))}</td>`).join('')}</tr>`).join('')}</tbody></table></div>`
}
function blockHtml(block, data, theme) {
@ -172,7 +192,7 @@ function blockHtml(block, data, theme) {
html = '<hr />'
break
case 'table':
html = renderTable(block, data)
html = renderTable(block, data, theme)
break
default:
return ''
@ -180,6 +200,26 @@ function blockHtml(block, data, theme) {
return wrapReportBlock(block, theme, html)
}
function renderReportBlocks(blocks, data, theme) {
const output = []
for (let index = 0; index < blocks.length; index += 1) {
const block = blocks[index]
if (theme.reportDensity === 'compact' && block?.type === 'kpi') {
const group = [block]
while (index + 1 < blocks.length && blocks[index + 1]?.type === 'kpi') {
group.push(blocks[index + 1])
index += 1
}
if (group.length > 1) {
output.push(`<div class="kpi-grid">${group.map(item => blockHtml(item, data, theme)).join('')}</div>`)
continue
}
}
output.push(blockHtml(block, data, theme))
}
return output.join('\n')
}
export function renderReportHtml(template, data = {}) {
const blocks = template?.blocks || []
const theme = themeOf(template)
@ -192,8 +232,8 @@ export function renderReportHtml(template, data = {}) {
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};-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>`
@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:${theme.reportDensity === 'compact' ? 13 : 15}px/${theme.reportDensity === 'compact' ? 1.35 : 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 ${theme.reportDensity === 'compact' ? 6 : 10}px;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:${theme.reportDensity === 'compact' ? 19 : 23}px;line-height:1.2;margin:0;color:inherit}p{margin:0}.kpi-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px;margin:0 0 6px;break-inside:avoid-page;page-break-inside:avoid}.kpi-grid>.block{margin:0;min-width:0}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding:${theme.reportDensity === 'compact' ? '8px 10px' : '12px 14px'};display:flex;justify-content:space-between;gap:10px;background:${theme.surfaceColor}}.kpi-grid .kpi{height:100%;display:grid;gap:3px;align-content:center}.kpi span{color:${theme.mutedColor}}.kpi strong{font-size:${theme.reportDensity === 'compact' ? 16 : 18}px}.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:${theme.reportDensity === 'compact' ? 6 : 10}px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;break-inside:avoid-page;page-break-inside:avoid}.table-wrap.allow-break{overflow:visible;break-inside:auto;page-break-inside:auto}.table-wrap.allow-break table{border-collapse:collapse}.compact-table{border-radius:${Math.min(theme.borderRadius, 6)}px}table{border-collapse:collapse;width:100%;font-size:${theme.reportDensity === 'compact' ? 11.5 : 13}px}thead{display:table-header-group}tfoot{display:table-footer-group}tr{break-inside:avoid-page;page-break-inside:avoid}th,td{padding:${theme.reportDensity === 'compact' ? '5px 6px' : '8px 10px'};text-align:left;border-bottom:1px solid ${theme.borderColor};vertical-align:top}th{background:${theme.backgroundColor};font-weight:700}.compact-table th,.compact-table td{padding:5px 6px;font-size:11.5px}
</style></head><body><main class="document" data-density="${theme.reportDensity}">${renderReportBlocks(blocks, data, theme)}</main></body></html>`
}
function emailBlock(block, data, theme) {

View File

@ -109,3 +109,55 @@ test('report page chrome can opt into subtle background and decorative border',
assert.match(html, /opacity:0\.1/)
assert.match(html, /border:10px solid transparent/)
})
test('auto density compacts data-heavy reports and groups consecutive KPIs', () => {
const blocks = [
{ type: 'heading', props: { text: 'Dense' } },
{ type: 'kpi', props: { label: 'A', value: '1' } },
{ type: 'kpi', props: { label: 'B', value: '2' } },
{ type: 'kpi', props: { label: 'C', value: '3' } },
...Array.from({ length: 16 }, (_, index) => ({ type: 'text', props: { text: `Row ${index}` } }))
]
const html = renderReportHtml({ blocks }, {})
assert.match(html, /data-density="compact"/)
assert.match(html, /class="kpi-grid"/)
assert.match(html, /grid-template-columns:repeat\(3,minmax\(0,1fr\)\)/)
})
test('compact density flows past ordinary page breaks but preserves forced breaks', () => {
const html = renderReportHtml({
theme: { reportDensity: 'compact' },
blocks: [
{ type: 'heading', props: { text: 'Auto flow' }, style: { pageBreakBefore: true } },
{ type: 'heading', props: { text: 'Forced' }, style: { pageBreakBefore: true, forcePageBreak: true } }
]
}, {})
assert.match(html, /Auto flow/)
assert.match(html, /break-before:auto/)
assert.match(html, /Forced/)
assert.match(html, /break-before:page/)
})
test('splittable tables repeat headers and use compact table styling', () => {
const html = renderReportHtml({
theme: { reportDensity: 'compact' },
blocks: [{
type: 'table',
props: { rowsPath: 'rows', columns: [{ label: 'Name', key: 'name' }] },
style: { allowPageBreak: true }
}]
}, { rows: [{ name: 'A' }, { name: 'B' }] })
assert.match(html, /class="table-wrap allow-break compact-table"/)
assert.match(html, /thead\{display:table-header-group\}/)
assert.match(html, /page-break-inside:auto/)
})
test('comfortable density preserves explicit section page breaks', () => {
const html = renderReportHtml({
theme: { reportDensity: 'comfortable' },
blocks: [{ type: 'heading', props: { text: 'Section' }, style: { pageBreakBefore: true } }]
}, {})
assert.match(html, /data-density="comfortable"/)
assert.match(html, /break-before:page/)
})

View File

@ -203,6 +203,15 @@ export function App({ user }: Props) {
}))
}
function updateBlockStyle(id: string, patch: Record<string, unknown>) {
setTemplateDoc(current => ({
...current,
blocks: current.blocks.map(block =>
block.id === id ? { ...block, style: { ...(block.style || {}), ...patch } } : block
)
}))
}
function updateTheme(patch: Record<string, unknown>) {
setTemplateDoc(current => ({
...current,
@ -544,6 +553,25 @@ export function App({ user }: Props) {
</div>
<span>mm</span>
</div>
<div className="density-control">
<span className="control-label">Layout density</span>
<div className="segmented compact">
{[
['auto', 'Auto'],
['comfortable', 'Comfortable'],
['compact', 'Compact']
].map(([value, label]) => (
<button
key={value}
className={themeString('reportDensity', 'auto') === value ? 'active' : ''}
onClick={() => updateTheme({ reportDensity: value })}
>
{label}
</button>
))}
</div>
<small>Auto switches data-heavy reports to compact flow.</small>
</div>
<div className="page-margin-grid">
{[
['Top', 'pageMarginTop', 12],
@ -630,6 +658,7 @@ export function App({ user }: Props) {
pageMarginBottom: 12,
pageMarginLeft: 12,
pagePadding: 18,
reportDensity: 'auto',
pageBackgroundMode: 'none',
pageBackgroundOpacity: 0.08,
decorativeBorder: false
@ -864,7 +893,31 @@ export function App({ user }: Props) {
)}
{selected.type === 'table' && (
<label>Rows data path<input value={String(selected.props.rowsPath || '')} onChange={event => updateBlock(selected.id, { rowsPath: event.target.value })} placeholder="items" /></label>
<>
<label>Rows data path<input value={String(selected.props.rowsPath || '')} onChange={event => updateBlock(selected.id, { rowsPath: event.target.value })} placeholder="items" /></label>
<label className="toggle-row inline-toggle">
<span>
<strong>Allow page flow</strong>
<small>Split long tables across pages and repeat the header row.</small>
</span>
<input
type="checkbox"
checked={selected.style?.allowPageBreak === true}
onChange={event => updateBlockStyle(selected.id, { allowPageBreak: event.target.checked })}
/>
</label>
<label className="toggle-row inline-toggle">
<span>
<strong>Compact table</strong>
<small>Use tighter row spacing for high-column datasets.</small>
</span>
<input
type="checkbox"
checked={selected.style?.compact === true}
onChange={event => updateBlockStyle(selected.id, { compact: event.target.checked })}
/>
</label>
</>
)}
{bindingField(selected) && !!paths.length && (

View File

@ -833,3 +833,18 @@ body{
height:18px;
flex:0 0 auto;
}
.density-control{
display:grid;
gap:7px;
margin-bottom:12px;
}
.density-control small{
color:#8994a4;
font-size:9px;
line-height:1.4;
}
.inline-toggle{
margin-top:4px;
padding:10px 0;
}