diff --git a/.changeset/cjk-wrap-and-legend-packing.md b/.changeset/cjk-wrap-and-legend-packing.md new file mode 100644 index 0000000..96d96fa --- /dev/null +++ b/.changeset/cjk-wrap-and-legend-packing.md @@ -0,0 +1,20 @@ +--- +'@office-kit/pptx-preview': patch +--- + +East Asian line breaking and measured legend packing. + +The text layout engine tokenized wrapped text by whitespace only, so a +space-free CJK clause travelled as one unbreakable "word" — the greedy +wrapper pushed the whole clause to the next line, leaving artifacts like a +lone bullet glyph on its own line. CJK runs now break between any two +characters with simple kinsoku (closing punctuation glued to its +predecessor, opening brackets to their successor), matching PowerPoint's +East Asian line breaking. + +Chart legends previously packed items into fixed-width slots +(`min(140px, frameW / n)`), so long CJK series names overflowed into the +neighbouring item. Horizontal legends ('b'/'t') now pack items by an +estimated per-label width and shrink the font when the row exceeds the +frame; vertical legends ('r'/'tr') size the right column to the widest +label instead of a fixed 100px. diff --git a/packages/preview/src/render-slide.ts b/packages/preview/src/render-slide.ts index 5f5b05e..5c1d923 100644 --- a/packages/preview/src/render-slide.ts +++ b/packages/preview/src/render-slide.ts @@ -3785,6 +3785,19 @@ const renderChartTitle = (f: ChartFrame, title: string, style?: ChartTextStyle): return `${escapeXml(title)}`; }; +// Legend text is drawn in `sans-serif` without a text measurer, so pack the +// items with a char-class width estimate (fullwidth/CJK ≈ 1em, everything else +// ≈ 0.55em). Fixed per-item slots overlap as soon as a CJK series name exceeds +// the slot (e.g. 「平均品質スコア(点)」), which this estimate avoids. +const FULLWIDTH_CHAR_PATTERN = + /[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6]/u; + +const approxLegendTextPx = (text: string, fontPx: number): number => { + let units = 0; + for (const ch of text) units += FULLWIDTH_CHAR_PATTERN.test(ch) ? 1 : 0.55; + return units * fontPx; +}; + const renderChartLegend = ( f: ChartFrame, names: ReadonlyArray, @@ -3817,44 +3830,47 @@ const renderChartLegend = ( return ``; }; const out: string[] = []; - if (position === 'b') { - // Default: horizontal row centered at the bottom. - const itemPx = Math.min(140, f.w / names.length); - const totalW = itemPx * names.length; - const startX = f.x + (f.w - totalW) / 2; - for (let i = 0; i < names.length; i++) { - const cx = startX + i * itemPx; - const swatchX = cx + 4; - const swatchY = f.legendY - 4; - const labelX = swatchX + 14; - out.push( - swatch(i, swatchX, swatchY), - `${escapeXml(names[i] ?? `Series ${i + 1}`)}`, - ); - } - return out.join(''); - } - if (position === 't') { - const itemPx = Math.min(140, f.w / names.length); - const totalW = itemPx * names.length; - const startX = f.x + (f.w - totalW) / 2; - const yTop = f.y + 4; + if (position === 'b' || position === 't') { + // Horizontal row centered along the chosen edge. Items are packed by the + // estimated label width — fixed per-item slots make long (especially CJK) + // series names spill into the neighbouring slot and overlap. When the row + // is wider than the frame, shrink the text instead of overlapping. + const swatchGapPx = 14; + const itemGapPx = 12; + const labelWidths = names.map((name, i) => + approxLegendTextPx(name ?? `Series ${i + 1}`, sz * PX_PER_PT), + ); + const naturalTotal = + labelWidths.reduce((sum, w) => sum + swatchGapPx + w, 0) + itemGapPx * (names.length - 1); + const scale = Math.min(1, f.w / Math.max(1, naturalTotal)); + const effAttrs = + scale < 1 + ? `font-family="sans-serif" font-size="${chartFontPx(sz * scale)}" fill="${fill}"${weight}${italic}` + : textAttrs; + const rowY = position === 'b' ? f.legendY : f.y + 12; + let cursor = f.x + Math.max(0, (f.w - naturalTotal * scale) / 2); for (let i = 0; i < names.length; i++) { - const cx = startX + i * itemPx; out.push( - swatch(i, cx + 4, yTop), - `${escapeXml(names[i] ?? `Series ${i + 1}`)}`, + swatch(i, cursor, rowY - 4), + `${escapeXml(names[i] ?? `Series ${i + 1}`)}`, ); + cursor += (swatchGapPx + (labelWidths[i] ?? 0) + itemGapPx) * scale; } return out.join(''); } // Right / Top-Right / Left — vertical stack along the chosen edge. // 'r' / 'l' center the stack vertically; 'tr' pins it to the top. + // The right-edge column is sized to the widest label (capped at 45% of the + // frame) so long CJK names don't run past the chart's right edge. const lineH = 14; const totalH = names.length * lineH; const yStart = position === 'tr' ? f.y + 12 : Math.max(f.y + 12, f.y + (f.h - totalH) / 2); - const xCol = - position === 'l' ? f.x + 6 : position === 'tr' ? f.x + f.w - 100 : /* 'r' */ f.x + f.w - 100; + const maxLabelPx = Math.max( + 0, + ...names.map((name, i) => approxLegendTextPx(name ?? `Series ${i + 1}`, sz * PX_PER_PT)), + ); + const rightColW = Math.min(f.w * 0.45, 14 + maxLabelPx + 4); + const xCol = position === 'l' ? f.x + 6 : /* 'r' / 'tr' */ f.x + f.w - rightColW; for (let i = 0; i < names.length; i++) { const yp = yStart + i * lineH; out.push( diff --git a/packages/preview/src/text-layout.ts b/packages/preview/src/text-layout.ts index 80f7313..0f3e5df 100644 --- a/packages/preview/src/text-layout.ts +++ b/packages/preview/src/text-layout.ts @@ -252,6 +252,45 @@ export interface Placement { // paragraph model and measurer caches inside layoutTextSvg. type LineBuilder = (contentLeft: number, contentRight: number) => { lines: Line[]; blockH: number }; +// East Asian text carries no spaces, so a whole CJK clause arrives as one +// `\S+` "word". Treating it as unbreakable pushes the entire clause to the +// next line (leaving e.g. a lone bullet glyph behind) — PowerPoint instead +// breaks East Asian runs between any two characters. Split CJK runs into +// per-character tokens, gluing closing punctuation to its predecessor and +// opening brackets to their successor (simple kinsoku). +const EAST_ASIAN_CHAR = /[ᄀ-ᅟ⺀-〾ぁ-㏿㐀-䶿一-鿿ꀀ-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]/; +const CLOSING_PUNCT = /^[、。,.・)」』】〉》”’!?:;ー〜…,.!?:;)\]}]+$/; +const OPENING_PUNCT = /^[(「『【〈《“‘([{]+$/; + +const splitEastAsianBreakables = (word: string): string[] => { + if (!EAST_ASIAN_CHAR.test(word)) return [word]; + const parts: string[] = []; + let latin = ''; + for (const ch of word) { + if (EAST_ASIAN_CHAR.test(ch)) { + if (latin !== '') { + parts.push(latin); + latin = ''; + } + parts.push(ch); + } else { + latin += ch; + } + } + if (latin !== '') parts.push(latin); + + const glued: string[] = []; + for (const part of parts) { + const prev = glued.at(-1); + if (prev !== undefined && (CLOSING_PUNCT.test(part) || OPENING_PUNCT.test(prev))) { + glued[glued.length - 1] = prev + part; + } else { + glued.push(part); + } + } + return glued; +}; + const specOf = (piece: PieceInput): FontSpec => ({ family: piece.family, sizePx: piece.sizePx, @@ -354,21 +393,23 @@ export const layoutCore = (input: TextBodyInput, measure: TextMeasurer): LayoutC tokens.push({ text: '', piece, isSpace: false, isBreak: true, width: 0 }); continue; } - for (const seg of piece.text.match(/\s+|\S+/g) ?? []) { - const isSpace = /^\s+$/.test(seg); - const w = mWidth(seg, specOf(piece)); - if (input.wrap && !isSpace && w > avail - bulletLead && [...seg].length > 1) { - for (const ch of seg) { - tokens.push({ - text: ch, - piece, - isSpace: false, - isBreak: false, - width: mWidth(ch, specOf(piece)), - }); + for (const word of piece.text.match(/\s+|\S+/g) ?? []) { + const isSpace = /^\s+$/.test(word); + for (const seg of isSpace ? [word] : splitEastAsianBreakables(word)) { + const w = mWidth(seg, specOf(piece)); + if (input.wrap && !isSpace && w > avail - bulletLead && [...seg].length > 1) { + for (const ch of seg) { + tokens.push({ + text: ch, + piece, + isSpace: false, + isBreak: false, + width: mWidth(ch, specOf(piece)), + }); + } + } else { + tokens.push({ text: seg, piece, isSpace, isBreak: false, width: w }); } - } else { - tokens.push({ text: seg, piece, isSpace, isBreak: false, width: w }); } } }