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