diff --git a/server/routes/ai.js b/server/routes/ai.js index 7db63fa..453b76c 100644 --- a/server/routes/ai.js +++ b/server/routes/ai.js @@ -95,9 +95,45 @@ function normalizeDesign(raw) { }); } } + + // De-overlap text lines (models stack them at the same y) and order shapes + // behind text so accent bands never hide the words. + const shapes = out.elements.filter((e) => e.type === 'shape'); + const texts = out.elements.filter((e) => e.type === 'text'); + deoverlapTexts(texts); + out.elements = [...shapes, ...texts]; return out; } +// Push text lines apart so they don't sit on top of each other. Only nudges a +// line down when it also overlaps horizontally (leaves side-by-side text alone), +// then shifts the whole stack up if it ran past the bottom margin. CW/CH match +// fitText's width/height estimates. +function deoverlapTexts(texts) { + const M = 4, GAP = 2.5, CW = 0.075, CH = 0.26; + const widthOf = (el) => Math.max(1, el.text.length) * el.fontSize * CW; + const heightOf = (el) => el.fontSize * CH; + const ordered = texts.map((el, i) => ({ el, i })).sort((a, b) => a.el.y - b.el.y || a.i - b.i); + const placed = []; + for (const cur of ordered) { + const cw = widthOf(cur.el); + let minY = M; + for (const p of placed) { + const hOverlap = cur.el.x < p.el.x + widthOf(p.el) && p.el.x < cur.el.x + cw; + if (hOverlap) minY = Math.max(minY, p.el.y + heightOf(p.el) + GAP); + } + if (cur.el.y < minY) cur.el.y = Math.round(minY * 10) / 10; + placed.push(cur); + } + let maxBottom = 0; + for (const p of placed) maxBottom = Math.max(maxBottom, p.el.y + heightOf(p.el)); + const overflow = maxBottom - (100 - M); + if (overflow > 0 && placed.length) { + const shift = Math.min(overflow, Math.min(...placed.map((p) => p.el.y)) - M); + if (shift > 0) for (const p of placed) p.el.y = Math.round((p.el.y - shift) * 10) / 10; + } +} + // GET /api/ai/settings — workspace members (never returns the key) router.get('/settings', (req, res) => { const row = db.prepare('SELECT base_url, model, image_base_url, image_model, api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId); diff --git a/server/test/ai-design.test.js b/server/test/ai-design.test.js index cc30274..e645534 100644 --- a/server/test/ai-design.test.js +++ b/server/test/ai-design.test.js @@ -26,8 +26,9 @@ test('normalizeDesign: keeps valid text+shape, sets background', () => { ]}); assert.equal(d.background, '#102030'); assert.equal(d.elements.length, 2); - assert.equal(d.elements[0].text, 'HELLO'); - assert.equal(d.elements[0].fontFamily, 'Arial'); + const txt = d.elements.find((e) => e.type === 'text'); + assert.equal(txt.text, 'HELLO'); + assert.equal(txt.fontFamily, 'Arial'); }); test('normalizeDesign: converts pixel shape dims to %, clamps ranges', () => { @@ -86,3 +87,16 @@ test('normalizeDesign: long/large text is shrunk + repositioned to fit canvas', assert.ok(e.fontSize < 160, 'fontSize was shrunk'); assert.ok(e.x >= 4 && e.y >= 4, 'within margins'); }); + +test('normalizeDesign: separates overlapping text + orders shapes behind text', () => { + const d = normalizeDesign({ elements: [ + { type: 'text', x: 5, y: 40, text: 'HEADLINE TEXT HERE', fontSize: 60, color: '#fff' }, + { type: 'text', x: 5, y: 41, text: 'SUBTEXT OVERLAPPING IT', fontSize: 40, color: '#fff' }, + { type: 'shape', x: 0, y: 0, width: 100, height: 100, color: '#000', opacity: 0.5 }, + ]}); + assert.equal(d.elements[0].type, 'shape', 'shape rendered behind (first in array)'); + const texts = d.elements.filter((e) => e.type === 'text'); + const hi = texts[0].y <= texts[1].y ? texts[0] : texts[1]; + const lo = texts[0].y <= texts[1].y ? texts[1] : texts[0]; + assert.ok(lo.y >= hi.y + hi.fontSize * 0.22, 'text lines no longer overlap vertically'); +});