From 4cc8ccb67e41edc67322873434444b9163b285b1 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Tue, 9 Jun 2026 12:51:23 -0500 Subject: [PATCH] fix(ai): keep generated designs inside the canvas (#41) Text could run off the edge (long/large headlines, nowrap) and shapes placed at the far edge (e.g. a bottom band at y=100) spilled over. - Server-side fit pass on every generated element: shrink text fontSize so it fits the canvas width (chars*fontSize*0.075, tuned for bold/uppercase headlines) and height (incl. line-height), then nudge x/y within 4% margins; clamp shapes so x+width<=100 and y+height<=100. Deterministic - doesn't rely on the model getting layout right. - Designer preview: vw -> cqw (+ container-type on the canvas) so text scales to the canvas, not the browser window. The preview was overstating size vs what actually publishes; now it matches. Published widget keeps vw (scales on the player). Verified: Playwright DOM check shows zero elements overflowing the canvas after generation; unit test asserts long text is shrunk + repositioned in-bounds. 62/62. --- frontend/js/views/designer.js | 16 ++++++++-------- server/routes/ai.js | 36 ++++++++++++++++++++++++++++++----- server/test/ai-design.test.js | 16 ++++++++++++++-- 3 files changed, 53 insertions(+), 15 deletions(-) diff --git a/frontend/js/views/designer.js b/frontend/js/views/designer.js index 4182002..5d018ef 100644 --- a/frontend/js/views/designer.js +++ b/frontend/js/views/designer.js @@ -45,7 +45,7 @@ export function render(container) {
-
+

${t('designer.preview_hint')}

@@ -421,13 +421,13 @@ function redraw() { switch (el.type) { case 'text': - html += `
${el.text}
`; + html += `
${el.text}
`; break; case 'clock': - html += `
`; + html += `
`; break; case 'date': - html += `
`; + html += `
`; break; case 'image': html += ``; @@ -439,11 +439,11 @@ function redraw() { html += `
`; break; case 'weather': - html += `
⛅ ${t('common.loading')}
`; + html += `
⛅ ${t('common.loading')}
`; break; case 'ticker': html += `
-
${t('designer.loading_news')}
+
${t('designer.loading_news')}
`; break; case 'qr': @@ -454,8 +454,8 @@ function redraw() { break; case 'countdown': html += `
-
${el.label || ''}
-
+
${el.label || ''}
+
`; break; case 'webpage': diff --git a/server/routes/ai.js b/server/routes/ai.js index f8b325a..7db63fa 100644 --- a/server/routes/ai.js +++ b/server/routes/ai.js @@ -42,8 +42,28 @@ const clampN = (n, lo, hi, d) => { n = Number(n); return Number.isFinite(n) ? Ma const hex = (c, d) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c.trim())) ? c.trim() : d; const cleanText = (s) => String(s == null ? '' : s).replace(/<[^>]*>/g, '').trim().slice(0, 200); +// Keep generated text on the canvas. The Designer renders text nowrap at +// ~fontSize/10 % of the canvas width per em, so long/large text runs off the +// edge. Estimate width = chars * fontSize * 0.06 (% of canvas width) and height +// = fontSize * 0.18 (% of canvas height); shrink fontSize to fit within 4% +// margins, then nudge x/y in-bounds. Deterministic, so it doesn't depend on the +// model getting layout right. +function fitText(el) { + // CW: width-% per (char * fontSize). 0.075 ~ bold/uppercase headlines (wider + // than mixed-case). CH: height-% per fontSize incl. line-height. + const M = 4, CW = 0.075, CH = 0.22; + const len = Math.max(1, el.text.length); + const maxByW = (100 - 2 * M) / (len * CW); + const maxByH = (100 - 2 * M) / CH; + el.fontSize = Math.floor(Math.max(8, Math.min(el.fontSize, maxByW, maxByH))); + const w = len * el.fontSize * CW; + const h = el.fontSize * CH; + el.x = Math.round(Math.min(Math.max(el.x, M), Math.max(M, 100 - M - w)) * 10) / 10; + el.y = Math.round(Math.min(Math.max(el.y, M), Math.max(M, 100 - M - h)) * 10) / 10; +} + // Never trust raw model output: cap count, clamp ranges, fix px-vs-% (models -// often emit pixels), strip any HTML from text, validate colors. +// often emit pixels), strip any HTML from text, validate colors, fit to canvas. function normalizeDesign(raw) { const out = { background: hex(raw && raw.background, '#111827'), elements: [] }; const els = Array.isArray(raw && raw.elements) ? raw.elements.slice(0, 20) : []; @@ -52,19 +72,25 @@ function normalizeDesign(raw) { if (e.type === 'text') { const text = cleanText(e.text); if (!text) continue; - out.elements.push({ + const el = { type: 'text', x: clampN(e.x, 0, 95, 5), y: clampN(e.y, 0, 95, 5), text, fontSize: clampN(e.fontSize, 12, 200, 48), fontFamily: 'Arial', color: hex(e.color, '#FFFFFF'), bold: !!e.bold, shadow: !!e.shadow, - }); + }; + fitText(el); + out.elements.push(el); } else if (e.type === 'shape') { let w = Number(e.width), h = Number(e.height); if (w > 100) w = w / 19.2; // px of 1920 -> % if (h > 100) h = h / 10.8; // px of 1080 -> % + w = clampN(w, 1, 100, 30); + h = clampN(h, 1, 100, 20); out.elements.push({ type: 'shape', shape: 'rect', - x: clampN(e.x, 0, 100, 0), y: clampN(e.y, 0, 100, 0), - width: clampN(w, 1, 100, 30), height: clampN(h, 1, 100, 20), + // keep the shape on-canvas: x+width <= 100, y+height <= 100 + x: Math.min(clampN(e.x, 0, 100, 0), 100 - w), + y: Math.min(clampN(e.y, 0, 100, 0), 100 - h), + width: w, height: h, color: hex(e.color, '#3b82f6'), opacity: clampN(e.opacity, 0, 1, 0.85), radius: 0, }); } diff --git a/server/test/ai-design.test.js b/server/test/ai-design.test.js index 9685147..cc30274 100644 --- a/server/test/ai-design.test.js +++ b/server/test/ai-design.test.js @@ -35,8 +35,8 @@ test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => { { type: 'shape', x: -10, y: 200, width: 1920, height: 1080, color: 'red', opacity: 5 }, ]}); const s = d.elements[0]; - assert.equal(s.x, 0, 'x clamped to 0'); - assert.equal(s.y, 100, 'y clamped to 100'); + assert.equal(s.x, 0, 'x clamped so full-width shape fits'); + assert.equal(s.y, 0, 'y clamped so full-height shape fits (y+height<=100)'); assert.ok(Math.abs(s.width - 100) < 0.01, '1920px -> 100%'); assert.ok(Math.abs(s.height - 100) < 0.01, '1080px -> 100%'); assert.equal(s.color, '#3b82f6', 'non-hex color -> default'); @@ -74,3 +74,15 @@ test('endpointAllowed: blocks private/internal when hosted, allows public https' assert.equal(endpointAllowed('ftp://example.com'), false, 'non-http blocked'); assert.equal(endpointAllowed('not a url'), false); }); + +test('normalizeDesign: long/large text is shrunk + repositioned to fit canvas', () => { + const d = normalizeDesign({ elements: [ + { type: 'text', x: 30, y: 95, text: 'GRAND OPENING THIS FRIDAY EVERYONE WELCOME', fontSize: 160, color: '#fff' }, + ]}); + const e = d.elements[0]; + const w = e.text.length * e.fontSize * 0.075; + assert.ok(e.x + w <= 96.5, `fits horizontally (x=${e.x} w=${w.toFixed(1)})`); + assert.ok(e.y + e.fontSize * 0.22 <= 96.5, 'fits vertically'); + assert.ok(e.fontSize < 160, 'fontSize was shrunk'); + assert.ok(e.x >= 4 && e.y >= 4, 'within margins'); +});