Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/cjk-wrap-and-legend-packing.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 43 additions & 27 deletions packages/preview/src/render-slide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3785,6 +3785,19 @@ const renderChartTitle = (f: ChartFrame, title: string, style?: ChartTextStyle):
return `<text x="${px(f.x + f.w / 2)}" y="${px(f.titleY)}" text-anchor="middle" dominant-baseline="middle" font-family="sans-serif" font-size="${chartFontPx(sz)}" fill="${fill}" font-weight="${weight}"${fontStyleAttr}>${escapeXml(title)}</text>`;
};

// 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<string>,
Expand Down Expand Up @@ -3817,44 +3830,47 @@ const renderChartLegend = (
return `<rect x="${px(swatchX)}" y="${px(swatchY)}" width="9" height="9" fill="${color}"/>`;
};
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),
`<text x="${px(labelX)}" y="${px(f.legendY)}" dominant-baseline="middle" ${textAttrs}>${escapeXml(names[i] ?? `Series ${i + 1}`)}</text>`,
);
}
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.
Comment on lines +3833 to +3837
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),
`<text x="${px(cx + 18)}" y="${px(yTop + 8)}" dominant-baseline="middle" ${textAttrs}>${escapeXml(names[i] ?? `Series ${i + 1}`)}</text>`,
swatch(i, cursor, rowY - 4),
`<text x="${px(cursor + swatchGapPx * scale)}" y="${px(rowY)}" dominant-baseline="middle" ${effAttrs}>${escapeXml(names[i] ?? `Series ${i + 1}`)}</text>`,
);
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(
Expand Down
70 changes: 56 additions & 14 deletions packages/preview/src/text-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,46 @@ 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
Comment on lines +255 to +259
// 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,
Expand Down Expand Up @@ -354,21 +394,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 });
}
}
}
Expand Down
Loading