diff --git a/docs/template-pack.md b/docs/template-pack.md index 11a2899..e39b154 100644 --- a/docs/template-pack.md +++ b/docs/template-pack.md @@ -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. diff --git a/server/templateEngine.js b/server/templateEngine.js index 033f63c..02b2044 100644 --- a/server/templateEngine.js +++ b/server/templateEngine.js @@ -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 `
${html}
` } -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 `
${columns.map(c => ``).join('')}${rows.map(row => `${columns.map(c => ``).join('')}`).join('')}
${escapeHtml(c.label)}
${escapeHtml(getPath(row, c.key))}
` + 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 `
${columns.map(c => ``).join('')}${rows.map(row => `${columns.map(c => ``).join('')}`).join('')}
${escapeHtml(c.label)}
${escapeHtml(getPath(row, c.key))}
` } function blockHtml(block, data, theme) { @@ -172,7 +192,7 @@ function blockHtml(block, data, theme) { html = '
' 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(`
${group.map(item => blockHtml(item, data, theme)).join('')}
`) + 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 `
${blocks.map(block => blockHtml(block, data, theme)).join('\n')}
` +@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} +
${renderReportBlocks(blocks, data, theme)}
` } function emailBlock(block, data, theme) { diff --git a/server/templateEngine.test.js b/server/templateEngine.test.js index 2e8d476..3421aae 100644 --- a/server/templateEngine.test.js +++ b/server/templateEngine.test.js @@ -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/) +}) diff --git a/src/App.tsx b/src/App.tsx index 278f428..11b541a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -203,6 +203,15 @@ export function App({ user }: Props) { })) } + function updateBlockStyle(id: string, patch: Record) { + setTemplateDoc(current => ({ + ...current, + blocks: current.blocks.map(block => + block.id === id ? { ...block, style: { ...(block.style || {}), ...patch } } : block + ) + })) + } + function updateTheme(patch: Record) { setTemplateDoc(current => ({ ...current, @@ -544,6 +553,25 @@ export function App({ user }: Props) { mm +
+ Layout density +
+ {[ + ['auto', 'Auto'], + ['comfortable', 'Comfortable'], + ['compact', 'Compact'] + ].map(([value, label]) => ( + + ))} +
+ Auto switches data-heavy reports to compact flow. +
{[ ['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' && ( - + <> + + + + )} {bindingField(selected) && !!paths.length && ( diff --git a/src/refine.css b/src/refine.css index 5c457ce..e63b189 100644 --- a/src/refine.css +++ b/src/refine.css @@ -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; +}