Merge pull request #60 from screentinker/fix/ai-deoverlap

fix(ai): de-overlap text + layer shapes behind text (#41)
This commit is contained in:
screentinker 2026-06-09 12:57:45 -05:00 committed by GitHub
commit df4110d9ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 52 additions and 2 deletions

View file

@ -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);

View file

@ -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');
});