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 `
${escapeHtml(interpolate(p.text, data))}
` break case 'image': - html = `