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 => `| ${escapeHtml(c.label)} | `).join('')}
${rows.map(row => `${columns.map(c => `| ${escapeHtml(getPath(row, c.key))} | `).join('')}
`).join('')}
`
+ 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 => `| ${escapeHtml(c.label)} | `).join('')}
${rows.map(row => `${columns.map(c => `| ${escapeHtml(getPath(row, c.key))} | `).join('')}
`).join('')}
`
}
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)}