From 594b6b722179a6d83be3202b1acd2c49c2572213 Mon Sep 17 00:00:00 2001 From: Admin Date: Wed, 23 Sep 2026 04:28:13 +0000 Subject: [PATCH] fix: improve report image sizing and pagination --- docs/template-pack.md | 23 +++++++++++++++ server/templateEngine.js | 55 ++++++++++++++++++++++++++++++++--- server/templateEngine.test.js | 28 ++++++++++++++++++ server/templatePack.js | 8 ++++- src/App.tsx | 22 ++++++++++++++ 5 files changed, 131 insertions(+), 5 deletions(-) diff --git a/docs/template-pack.md b/docs/template-pack.md index 422ed60..798a242 100644 --- a/docs/template-pack.md +++ b/docs/template-pack.md @@ -84,6 +84,29 @@ my-pack.zip Use `asset://KEY` anywhere in the document or sample data. Import replaces it with an opaque Template Studio media URL. +### Image layout + +Image blocks support explicit report layout controls: + +```json +{ + "id": "brand-logo", + "type": "image", + "props": { + "url": "asset://logo", + "alt": "Company logo", + "variant": "logo", + "maxWidth": 110, + "maxHeight": 72, + "fit": "contain" + } +} +``` + +Use `variant: "logo"` for compact brand marks and `variant: "hero"` for banners/feature imagery. Hero images default to a 260px maximum height with `cover`; normal images default to 340px with `contain`. The renderer also recognises IDs/alt text containing `logo`, `mark`, `hero`, or `banner` for older packs that do not specify a variant. + +For report pagination, blocks avoid splitting across pages by default. Optional block style flags are `keepWithNext`, `pageBreakBefore`, `pageBreakAfter`, and `allowPageBreak`. + ### 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. diff --git a/server/templateEngine.js b/server/templateEngine.js index a0b1fb8..79ae7f6 100644 --- a/server/templateEngine.js +++ b/server/templateEngine.js @@ -65,8 +65,24 @@ function themeOf(template) { } } +function safeFit(value) { + return ['contain', 'cover', 'fill'].includes(value) ? value : 'contain' +} + +function safeVariant(block) { + const explicit = String(block?.props?.variant || '').toLowerCase() + if (['logo', 'hero', 'image'].includes(explicit)) return explicit + const marker = `${block?.id || ''} ${block?.props?.alt || ''}`.toLowerCase() + if (marker.includes('logo') || marker.includes('mark')) return 'logo' + if (marker.includes('hero') || marker.includes('banner')) return 'hero' + return 'image' +} + 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 breakInside = style.allowPageBreak === true ? 'auto' : 'avoid-page' const rules = [ `background:${safeHex(style.backgroundColor, 'transparent')}`, `color:${safeHex(style.textColor, theme.textColor)}`, @@ -75,13 +91,44 @@ function reportBlockStyle(block, theme) { 'border-style:solid', `border-radius:${safeNumber(style.borderRadius, theme.borderRadius, 0, 32)}px`, `padding:${safeNumber(style.padding, 0, 0, 48)}px`, - `text-align:${safeAlign(style.align)}` + `text-align:${safeAlign(style.align)}`, + `break-before:${breakBefore}`, + `break-after:${breakAfter}`, + `break-inside:${breakInside}`, + `page-break-inside:${breakInside === 'avoid-page' ? 'avoid' : 'auto'}` ] return rules.join(';') } +function imageStyle(block, theme) { + const style = block?.style || {} + const variant = safeVariant(block) + const defaults = variant === 'logo' + ? { maxWidth: 110, maxHeight: 72, fit: 'contain' } + : variant === 'hero' + ? { maxWidth: 999, maxHeight: 260, fit: 'cover' } + : { maxWidth: 999, maxHeight: 340, fit: 'contain' } + + const maxWidth = safeNumber(style.maxWidth ?? block?.props?.maxWidth, defaults.maxWidth, 24, 999) + const maxHeight = safeNumber(style.maxHeight ?? block?.props?.maxHeight, defaults.maxHeight, 24, 900) + const width = style.width ?? block?.props?.width + const widthRule = width == null || width === '' ? 'width:auto' : `width:${safeNumber(width, maxWidth, 24, 999)}px` + const fit = safeFit(style.fit ?? block?.props?.fit ?? defaults.fit) + const radius = safeNumber(style.borderRadius, theme.borderRadius, 0, 32) + + return [ + widthRule, + `max-width:min(100%,${maxWidth}px)`, + `max-height:${maxHeight}px`, + `object-fit:${fit}`, + `border-radius:${radius}px`, + 'display:inline-block' + ].join(';') +} + function wrapReportBlock(block, theme, html) { - return `
${html}
` + const variant = block?.type === 'image' ? ` image-${safeVariant(block)}` : '' + return `
${html}
` } function renderTable(block, data) { @@ -108,7 +155,7 @@ function blockHtml(block, data, theme) { html = `

${escapeHtml(interpolate(p.text, data))}

` break case 'image': - html = `${escapeHtml(interpolate(p.alt, data))}` + html = `${escapeHtml(interpolate(p.alt, data))}` break case 'divider': html = '
' @@ -131,7 +178,7 @@ export function renderReportHtml(template, data = {}) { return `
${blocks.map(block => blockHtml(block, data, theme)).join('\n')}
` } diff --git a/server/templateEngine.test.js b/server/templateEngine.test.js index 7ea0e27..c630980 100644 --- a/server/templateEngine.test.js +++ b/server/templateEngine.test.js @@ -36,3 +36,31 @@ test('report renderer applies constrained pack theme styling', () => { assert.match(html, /bg\.webp/) assert.match(html, /padding:18px/) }) + +test('report renderer constrains logo and hero images for print layout', () => { + const html = renderReportHtml({ + blocks: [ + { id: 'logo', type: 'image', props: { url: 'https://example.com/logo.png', alt: 'Company logo' } }, + { id: 'hero', type: 'image', props: { url: 'https://example.com/hero.jpg', alt: 'Hero banner' } } + ] + }, {}) + assert.match(html, /image-logo/) + assert.match(html, /max-height:72px/) + assert.match(html, /image-hero/) + assert.match(html, /max-height:260px/) + assert.match(html, /object-fit:cover/) +}) + +test('report blocks avoid page breaks by default and support explicit page controls', () => { + const html = renderReportHtml({ + blocks: [{ + id: 'title', + type: 'heading', + props: { text: 'Title' }, + style: { keepWithNext: true, pageBreakBefore: true } + }] + }, {}) + assert.match(html, /break-before:page/) + assert.match(html, /break-after:avoid-page/) + assert.match(html, /page-break-inside:avoid/) +}) diff --git a/server/templatePack.js b/server/templatePack.js index b434d74..b6fcda6 100644 --- a/server/templatePack.js +++ b/server/templatePack.js @@ -249,7 +249,13 @@ export const templatePackSpec = { { id: 'hero-image', type: 'image', - props: { url: 'asset://hero', alt: 'Report hero' } + props: { + url: 'asset://hero', + alt: 'Report hero', + variant: 'hero', + maxHeight: 260, + fit: 'cover' + } } ] }, diff --git a/src/App.tsx b/src/App.tsx index 990c15a..c4bde48 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -715,6 +715,28 @@ export function App({ user }: Props) { <> + +
+ + +
+ )}