fix: improve report image sizing and pagination
This commit is contained in:
parent
7da99c16a2
commit
594b6b7221
@ -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.
|
||||
|
||||
@ -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 `<div class="block" style="${reportBlockStyle(block, theme)}">${html}</div>`
|
||||
const variant = block?.type === 'image' ? ` image-${safeVariant(block)}` : ''
|
||||
return `<div class="block${variant}" style="${reportBlockStyle(block, theme)}">${html}</div>`
|
||||
}
|
||||
|
||||
function renderTable(block, data) {
|
||||
@ -108,7 +155,7 @@ function blockHtml(block, data, theme) {
|
||||
html = `<p><a class="button" href="${escapeHtml(safeUrl(p.url, data))}">${escapeHtml(interpolate(p.text, data))}</a></p>`
|
||||
break
|
||||
case 'image':
|
||||
html = `<img class="image" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
html = `<img class="image" style="${imageStyle(block, theme)}" src="${escapeHtml(safeUrl(p.url, data))}" alt="${escapeHtml(interpolate(p.alt, data))}" />`
|
||||
break
|
||||
case 'divider':
|
||||
html = '<hr />'
|
||||
@ -131,7 +178,7 @@ 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:14mm}*{box-sizing:border-box}body{font:${15}px/1.5 ${theme.fontFamily},sans-serif;color:${theme.textColor};margin:0;background:${theme.backgroundColor};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:no-repeat}.document{min-height:100%;background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 12}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.block{margin:0 0 12px}.block:last-child{margin-bottom:0}h2{font-size:24px;margin:0 0 16px;color:inherit}p{margin:0 0 14px}.kpi{border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px;padding: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{max-width:100%;height:auto;border-radius:${theme.borderRadius}px}hr{border:0;border-top:1px solid ${theme.borderColor};margin:18px 0}.table-wrap{overflow:hidden;border:1px solid ${theme.borderColor};border-radius:${theme.borderRadius}px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{padding:9px 10px;text-align:left;border-bottom:1px solid ${theme.borderColor}}th{background:${theme.backgroundColor}}
|
||||
@page{size:A4;margin:12mm}*{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};background-image:${backgroundImage};background-size:cover;background-position:center;background-repeat:repeat-y;-webkit-print-color-adjust:exact;print-color-adjust:exact}.document{min-height:calc(297mm - 24mm);background:${theme.surfaceColor};padding:${theme.pagePadding}px;border:${theme.borderImage === '#' ? 0 : 10}px solid transparent;border-image:${borderImage};border-radius:${theme.borderRadius}px}.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>`
|
||||
}
|
||||
|
||||
|
||||
@ -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/)
|
||||
})
|
||||
|
||||
@ -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'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
22
src/App.tsx
22
src/App.tsx
@ -715,6 +715,28 @@ export function App({ user }: Props) {
|
||||
<>
|
||||
<label>Image URL<input value={String(selected.props.url || '')} onChange={event => updateBlock(selected.id, { url: event.target.value })} /></label>
|
||||
<label>Alt text<input value={String(selected.props.alt || '')} onChange={event => updateBlock(selected.id, { alt: event.target.value })} /></label>
|
||||
<label>
|
||||
Image role
|
||||
<select value={String(selected.props.variant || '')} onChange={event => updateBlock(selected.id, { variant: event.target.value })}>
|
||||
<option value="">Auto</option>
|
||||
<option value="logo">Logo</option>
|
||||
<option value="hero">Hero / banner</option>
|
||||
<option value="image">Normal image</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="two-field-grid">
|
||||
<label>Max width (px)<input type="number" inputMode="numeric" value={String(selected.props.maxWidth || '')} onChange={event => updateBlock(selected.id, { maxWidth: event.target.value ? Number(event.target.value) : undefined })} placeholder="Auto" /></label>
|
||||
<label>Max height (px)<input type="number" inputMode="numeric" value={String(selected.props.maxHeight || '')} onChange={event => updateBlock(selected.id, { maxHeight: event.target.value ? Number(event.target.value) : undefined })} placeholder="Auto" /></label>
|
||||
</div>
|
||||
<label>
|
||||
Fit
|
||||
<select value={String(selected.props.fit || '')} onChange={event => updateBlock(selected.id, { fit: event.target.value })}>
|
||||
<option value="">Auto</option>
|
||||
<option value="contain">Contain</option>
|
||||
<option value="cover">Cover</option>
|
||||
<option value="fill">Fill</option>
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user