1/**2 * Fit authored scene titles without changing their copy. The returned lines3 * are always complete words; an overlong second line is reported instead of4 * being ellipsized or silently shortened.5 */6export function fitSceneTitle(ctx, value, options = {}) {7 const text = String(value ?? '').trim();8 const maxWidth = Math.max(1, Number(options.maxWidth) || 1);9 const maxSize = Math.max(1, Number(options.maxSize) || 22);10 const minSize = Math.min(maxSize, Math.max(1, Number(options.minSize) || 16));11 const maxLines = Math.max(1, Number(options.maxLines) || 2);12 const weight = options.weight || 800;13 const family = options.family || 'Recursive, monospace';14 const measure = (line, size) => {15 if (ctx && typeof ctx.measureText === 'function') {16 ctx.font = `${weight} ${size}px ${family}`;17 return ctx.measureText(line).width;18 }19 return String(line).length * size * 0.6;20 };21 const words = text ? text.split(/\s+/) : [''];22 const wrap = (size) => {23 const lines = [];24 let line = '';25 for (const word of words) {26 const candidate = line ? `${line} ${word}` : word;27 if (line && measure(candidate, size) > maxWidth) {28 lines.push(line);29 line = word;30 } else line = candidate;31 }32 if (line || !lines.length) lines.push(line);33 if (lines.length <= maxLines) return lines;34 return [lines[0], lines.slice(1).join(' ')];35 };36 for (let size = maxSize; size >= minSize; size -= 1) {37 if (measure(text, size) <= maxWidth)38 return { text, lines: [text], size, lineHeight: Math.ceil(size * 1.16), overflow: false };39 }40 const lines = wrap(minSize);41 return {42 text,43 lines,44 size: minSize,45 lineHeight: Math.ceil(minSize * 1.16),46 overflow: lines.some(line => measure(line, minSize) > maxWidth),47 };48}49