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