Keep poster graphics and text inside the poster

The diagonal stripe graphic started left of the paper and long titles
or unbreakable words (repository and file names) overflowed the sides
and bottom. Posters are now clipped to the paper, and text is fitted:
the largest size at which it wraps within the width, line limit and
remaining height; words too wide even at the smallest size are
shortened individually with an ellipsis so the rest of the text stays.
This commit is contained in:
Andy Dixon
2026-07-12 15:30:40 +01:00
parent 8f3a3337b5
commit c38a65556c
2 changed files with 81 additions and 24 deletions
+64 -24
View File
@@ -109,38 +109,78 @@ export class PosterAtlas {
} }
} }
function wrap(ctx, text, maxWidth) {
const lines = []; let line = ''; // Largest font size (down to `min`) at which `text` wraps into at most `maxLines` lines that each
for (const word of text.split(/\s+/)) { // fit `width`, with the block no taller than `height`. Returns { size, lines }.
export function fitText(measure, text, width, height, max, min, maxLines, lineHeight = 1.08) {
for (let size = max; size >= min; size -= 1) {
const lines = [], words = text.split(/\s+/).filter(Boolean);
let line = '', fits = true;
for (const word of words) {
const next = line ? `${line} ${word}` : word; const next = line ? `${line} ${word}` : word;
if (line && ctx.measureText(next).width > maxWidth) { lines.push(line); line = word; } else line = next; if (measure(next, size) <= width) { line = next; continue; }
if (!line || measure(word, size) > width) { fits = false; break; } // a single word too wide
lines.push(line); line = word;
} }
if (line) lines.push(line); if (line) lines.push(line);
return lines; if (fits && lines.length <= maxLines && lines.length * size * lineHeight <= height) return { size, lines };
}
// Nothing fits at the smallest size: shorten only the words that are too wide on their own, then
// keep the lines that fit, ending with an ellipsis if some had to go.
const truncate = word => {
if (measure(word, min) <= width) return word;
while (word.length > 1 && measure(`${word}…`, min) > width) word = word.slice(0, -1);
return `${word}…`;
};
const shortened = text.split(/\s+/).filter(Boolean).map(truncate).join(' ');
if (shortened !== text) return fitText(measure, shortened, width, height, min, min, maxLines, lineHeight);
const lines = [];
let line = '';
for (const word of text.split(/\s+/)) {
const next = line ? `${line} ${word}` : word;
if (measure(next, min) <= width) line = next; else { lines.push(line); line = word; }
}
lines.push(line);
const room = Math.max(1, Math.min(maxLines, Math.floor(height / (min * lineHeight))));
if (lines.length > room) {
lines.length = room;
let last = lines[room - 1];
while (last.length > 1 && measure(`${last}…`, min) > width) last = last.slice(0, -1);
lines[room - 1] = `${last}…`;
}
return { size: min, lines };
} }
function paintPoster(ctx, { title, body }, w, h, theme, variant) { function paintPoster(ctx, { title, body }, w, h, theme, variant) {
const inset = 6; const inset = 6, pw = w - inset * 2, ph = h - inset * 2, pad = 16;
ctx.fillStyle = theme.paper; ctx.fillRect(inset, inset, w - inset * 2, h - inset * 2); ctx.save();
// Everything below is clipped to the paper so no graphic or glyph can escape the poster.
ctx.beginPath(); ctx.rect(inset, inset, pw, ph); ctx.clip();
ctx.fillStyle = theme.paper; ctx.fillRect(inset, inset, pw, ph);
// Weathering: faint speckles and a darker fold line. // Weathering: faint speckles and a darker fold line.
ctx.fillStyle = 'rgba(0,0,0,.06)'; ctx.fillStyle = 'rgba(0,0,0,.06)';
for (let i = 0; i < 70; i++) ctx.fillRect(inset + ((i * 73 + variant * 31) % (w - inset * 2)), inset + ((i * 137 + variant * 17) % (h - inset * 2)), 2, 2); for (let i = 0; i < 70; i++) ctx.fillRect(inset + ((i * 73 + variant * 31) % pw), inset + ((i * 137 + variant * 17) % ph), 2, 2);
ctx.fillRect(inset, h * .52, w - inset * 2, 1.5); ctx.fillRect(inset, h * .52, pw, 1.5);
// A bold graphic per variant. // A bold graphic per variant, within the top band.
ctx.fillStyle = theme.accent; ctx.fillStyle = theme.accent;
const shape = variant % 4; const shape = variant % 4;
if (shape === 0) { ctx.beginPath(); ctx.arc(w / 2, 104, 52, 0, Math.PI * 2); ctx.fill(); } if (shape === 0) { ctx.beginPath(); ctx.arc(w / 2, 100, 50, 0, Math.PI * 2); ctx.fill(); }
else if (shape === 1) { for (let i = 0; i < 6; i++) { ctx.beginPath(); ctx.moveTo(inset + i * 44, inset); ctx.lineTo(inset + i * 44 + 22, inset); ctx.lineTo(inset + i * 44 - 30, 150); ctx.lineTo(inset + i * 44 - 52, 150); ctx.fill(); } } else if (shape === 1) { for (let i = 0; i < 8; i++) { const x = inset + i * 44; ctx.beginPath(); ctx.moveTo(x, inset); ctx.lineTo(x + 22, inset); ctx.lineTo(x - 30, 150); ctx.lineTo(x - 52, 150); ctx.fill(); } }
else if (shape === 2) { ctx.beginPath(); ctx.moveTo(w / 2, 40); ctx.lineTo(w / 2 + 64, 160); ctx.lineTo(w / 2 - 64, 160); ctx.closePath(); ctx.fill(); ctx.fillStyle = theme.paper; ctx.font = '900 64px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillText('!', w / 2, 150); } else if (shape === 2) { ctx.beginPath(); ctx.moveTo(w / 2, 40); ctx.lineTo(w / 2 + 64, 160); ctx.lineTo(w / 2 - 64, 160); ctx.closePath(); ctx.fill(); ctx.fillStyle = theme.paper; ctx.font = '900 64px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillText('!', w / 2, 150); }
else { ctx.fillRect(inset, 40, w - inset * 2, 22); ctx.fillRect(inset, 84, w - inset * 2, 22); ctx.fillRect(inset, 128, w - inset * 2, 22); } else { for (const y of [40, 84, 128]) ctx.fillRect(inset, y, pw, 22); }
ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic'; ctx.fillStyle = theme.ink; const font = (weight, size) => `${weight} ${size}px Inter, system-ui, sans-serif`;
ctx.font = '900 34px Inter, system-ui, sans-serif'; const measure = weight => (text, size) => { ctx.font = font(weight, size); return ctx.measureText(text).width; };
let y = 206; const footer = 30, textTop = 176, textWidth = pw - pad * 2, textBottom = h - inset - footer;
for (const line of wrap(ctx, title.toUpperCase(), w - 36).slice(0, 3)) { ctx.fillText(line, w / 2, y, w - 30); y += 36; } const titleFit = fitText(measure(900), title.toUpperCase(), textWidth, (textBottom - textTop) * .55, 34, 14, 3);
ctx.font = '600 17px Inter, system-ui, sans-serif'; const bodyFit = fitText(measure(600), body, textWidth, textBottom - textTop - titleFit.lines.length * titleFit.size * 1.08 - 10, 17, 10, 4, 1.25);
y += 6; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = theme.ink;
for (const line of wrap(ctx, body, w - 44).slice(0, 4)) { ctx.fillText(line, w / 2, y, w - 30); y += 21; } let y = textTop;
ctx.font = '700 10px Inter, system-ui, sans-serif'; ctx.globalAlpha = .7; ctx.font = font(900, titleFit.size);
ctx.fillText('CODENAVIGATOR CIVIC AUTHORITY', w / 2, h - 18); for (const line of titleFit.lines) { ctx.fillText(line, w / 2, y); y += titleFit.size * 1.08; }
ctx.globalAlpha = 1; y += 10;
ctx.font = font(600, bodyFit.size);
for (const line of bodyFit.lines) { ctx.fillText(line, w / 2, y); y += bodyFit.size * 1.25; }
ctx.font = font(700, 10); ctx.globalAlpha = .7; ctx.textBaseline = 'alphabetic';
ctx.fillText('CODENAVIGATOR CIVIC AUTHORITY', w / 2, h - inset - 12, textWidth);
ctx.restore();
} }
+17
View File
@@ -109,3 +109,20 @@ test('hazard tape crosses each wall corner to corner, readable from outside', ()
} }
assert.equal(tapeQuads({ x: 0, y: 0, w: 2, h: 2, height: 30 }, 6).length, 0, 'too narrow for tape'); assert.equal(tapeQuads({ x: 0, y: 0, w: 2, h: 2, height: 30 }, 6).length, 0, 'too narrow for tape');
}); });
import { fitText } from '../web/labels.js';
test('poster text shrinks and wraps until it fits inside the box', () => {
const measure = (text, size) => text.length * size * .6; // monospace-ish stand-in for canvas
const short = fitText(measure, 'SHIP SMALL', 200, 120, 34, 12, 3);
assert.equal(short.size, 34);
const long = fitText(measure, 'WELCOME TO AN EXTREMELY LONG REPOSITORY NAME THAT GOES ON', 200, 120, 34, 12, 3);
assert.ok(long.lines.length <= 3 && long.lines.length * long.size * 1.08 <= 120);
for (const fit of [short, long]) for (const line of fit.lines) assert.ok(measure(line, fit.size) <= 200, line);
const word = fitText(measure, 'supercalifragilistic_expialidocious_really_long_filename.go', 200, 120, 34, 12, 3);
assert.ok(word.lines.every(line => measure(line, word.size) <= 200) && word.lines[0].endsWith('…'), 'unbreakable words are truncated');
const mixed = fitText(measure, 'WELCOME TO PROMETHEUS-OPERATOR-HELM-CHARTS-MONOREPO', 200, 120, 34, 12, 3);
assert.ok(mixed.lines[0].startsWith('WELCOME TO') && mixed.lines.every(line => measure(line, mixed.size) <= 200), `keeps the short words: ${mixed.lines}`);
const crowded = fitText(measure, 'one two three four five six seven eight nine ten eleven twelve thirteen fourteen fifteen', 60, 30, 20, 12, 3);
assert.ok(crowded.lines.length * 12 * 1.08 <= 30 && crowded.lines.every(line => measure(line, 12) <= 60 + 1e-9), `crowded: ${crowded.lines}`);
});