@office-kit/pptx-preview 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -484,6 +484,21 @@ const renderEmfToSvg = (bytes) => {
484
484
  return null;
485
485
  };
486
486
  //#endregion
487
+ //#region src/link-href.ts
488
+ const ALLOWED_PROTOCOLS = new Set([
489
+ "http:",
490
+ "https:",
491
+ "mailto:"
492
+ ]);
493
+ const safeLinkHref = (href) => {
494
+ if (href.startsWith("#")) return href;
495
+ try {
496
+ return ALLOWED_PROTOCOLS.has(new URL(href).protocol) ? href : void 0;
497
+ } catch {
498
+ return;
499
+ }
500
+ };
501
+ //#endregion
487
502
  //#region src/text-layout.ts
488
503
  const SANS = "Carlito";
489
504
  const SERIF = "Caladea";
@@ -564,6 +579,8 @@ const escapeXml$1 = (s) => s.replace(/[&<>"]/g, (c) => ({
564
579
  ">": "&gt;",
565
580
  "\"": "&quot;"
566
581
  })[c] ?? c);
582
+ const CSS_IDENT = /^-?[A-Za-z_\u0080-\u{10FFFF}][\w\u0080-\u{10FFFF}-]*$/u;
583
+ const cssFamilyName = (family) => family.split(" ").every((word) => CSS_IDENT.test(word)) ? family : `"${family.replace(/["\\]/g, "\\$&")}"`;
567
584
  const fmt = (n) => {
568
585
  const r = Math.round(n * 100) / 100;
569
586
  return Object.is(r, -0) ? "0" : String(r);
@@ -649,8 +666,27 @@ const layoutCore = (input, measure) => {
649
666
  }
650
667
  }
651
668
  const canBreak = EAST_ASIAN_CHAR.test(segment) ? width > avail - bulletLead : para.latinLineBreak === true || width > avail - bulletLead;
652
- if (input.wrap && canBreak && [...segment].length > 1) for (const fragment of fragments) for (const ch of fragment.text) tokens.push(makeToken(ch, fragment.piece));
653
- else for (const fragment of fragments) tokens.push(fragment);
669
+ if (input.wrap && canBreak && [...segment].length > 1) {
670
+ const breakAnywhere = para.latinLineBreak === true || EAST_ASIAN_CHAR.test(segment);
671
+ let chunk = [];
672
+ let chunkWidth = 0;
673
+ const flushChunk = () => {
674
+ const keepWhole = chunk.length > 1 && chunkWidth <= avail - bulletLead;
675
+ chunk.forEach((token, ci) => tokens.push(keepWhole && ci > 0 ? {
676
+ ...token,
677
+ continuesSegment: true
678
+ } : token));
679
+ chunk = [];
680
+ chunkWidth = 0;
681
+ };
682
+ for (const fragment of fragments) for (const ch of fragment.text) {
683
+ const token = makeToken(ch, fragment.piece);
684
+ chunk.push(token);
685
+ chunkWidth += token.width;
686
+ if (breakAnywhere || ch === "-" || ch === "/") flushChunk();
687
+ }
688
+ flushChunk();
689
+ } else for (const fragment of fragments) tokens.push(fragment);
654
690
  }
655
691
  wordParts = [];
656
692
  };
@@ -1015,7 +1051,7 @@ const emitPlacements = (placements, effectsOnly = false, effectKind = "all") =>
1015
1051
  if (line.bullet && !effectsOnly) {
1016
1052
  const b = line.bullet.b;
1017
1053
  if (b.imageHref) parts.push(`<image x="${fmt(line.bullet.x + dx + GRID_NUDGE_X)}" y="${fmt(baselineY - b.sizePx)}" width="${fmt(b.sizePx)}" height="${fmt(b.sizePx)}" href="${b.imageHref}" xlink:href="${b.imageHref}" preserveAspectRatio="xMidYMid meet"/>`);
1018
- else parts.push(`<text x="${fmt(line.bullet.x + dx + GRID_NUDGE_X)}" y="${fmt(baselineY)}" font-family="${escapeXml$1(b.family)}" font-size="${fmt(b.sizePx)}" fill="${b.fillHex}" xml:space="preserve">${escapeXml$1(b.text)}</text>`);
1054
+ else parts.push(`<text x="${fmt(line.bullet.x + dx + GRID_NUDGE_X)}" y="${fmt(baselineY)}" font-family="${escapeXml$1(b.cssFamily ?? b.family)}" font-size="${fmt(b.sizePx)}" fill="${b.fillHex}" xml:space="preserve">${escapeXml$1(b.text)}</text>`);
1019
1055
  }
1020
1056
  parts.push(emitLine(line, baselineY, dx, line.descent, (kind) => nextEffectId(kind), effectsOnly, effectKind));
1021
1057
  }
@@ -1042,10 +1078,11 @@ const emitLine = (line, baselineY, dx, descent, nextEffectId, effectsOnly, effec
1042
1078
  const groups = groupTokens(content);
1043
1079
  const tspans = groups.map((g) => {
1044
1080
  const span = tspan(g);
1045
- if (!g.piece.href) return span;
1046
- const target = g.piece.href.startsWith("#") ? "" : " target=\"_blank\" rel=\"noopener noreferrer\"";
1081
+ const href = g.piece.href ? safeLinkHref(g.piece.href) : void 0;
1082
+ if (!href) return span;
1083
+ const target = href.startsWith("#") ? "" : " target=\"_blank\" rel=\"noopener noreferrer\"";
1047
1084
  const title = g.piece.hrefTip ? `<title>${escapeXml$1(g.piece.hrefTip)}</title>` : "";
1048
- return `<a href="${escapeXml$1(g.piece.href)}"${target}>${title}${span}</a>`;
1085
+ return `<a href="${escapeXml$1(href)}"${target}>${title}${span}</a>`;
1049
1086
  }).join("");
1050
1087
  if (tspans === "") return "";
1051
1088
  const x0 = line.anchorX + dx + GRID_NUDGE_X;
@@ -1376,7 +1413,7 @@ const tspan = (g) => {
1376
1413
  const p = g.piece;
1377
1414
  const sizePx = renderedSizePxOf(p);
1378
1415
  const attrs = [
1379
- `font-family="${escapeXml$1(p.family)}"`,
1416
+ `font-family="${escapeXml$1(p.cssFamily ?? p.family)}"`,
1380
1417
  `font-size="${fmt(sizePx)}"`,
1381
1418
  `fill="${p.fillHex}"`
1382
1419
  ];
@@ -1689,7 +1726,7 @@ const bevelFilterPrimitives = (shape3d) => {
1689
1726
  //#region src/render-slide.ts
1690
1727
  const clickActionHref = (pres, action) => {
1691
1728
  if (!action) return void 0;
1692
- if (action.kind === "url") return action.url;
1729
+ if (action.kind === "url") return safeLinkHref(action.url);
1693
1730
  if (action.kind === "customShow") return `#pptx-custom-show?id=${action.id}&return=${action.returnToShow}`;
1694
1731
  if (action.kind === "endShow") return "#pptx-end-show";
1695
1732
  if (action.kind === "lastSlideViewed") return "#pptx-last-slide-viewed";
@@ -1714,6 +1751,11 @@ const DEFAULT_BODY_PT = 18;
1714
1751
  const DEFAULT_TITLE_PT = 44;
1715
1752
  const DEFAULT_FONT = "Calibri, 'Helvetica Neue', Arial, sans-serif";
1716
1753
  const browserFontFamily = (family) => family ? `${JSON.stringify(family)}, ${DEFAULT_FONT}` : DEFAULT_FONT;
1754
+ const fontFamilyStyles = (font) => font.startsWith("+") ? [] : [`font-family:${escapeXml(browserFontFamily(font))}`];
1755
+ const BROWSER_FAMILIES = {
1756
+ resolveFamily: browserFontFamily,
1757
+ familyIsCssList: true
1758
+ };
1717
1759
  const DEFAULT_BULLET_FONT = "Arial";
1718
1760
  const DEFAULT_INSET_X = 91440;
1719
1761
  const DEFAULT_INSET_Y = 45720;
@@ -2436,7 +2478,7 @@ const renderRun = (text, format, theme, effectivePt, defaultColor, _wasDefault =
2436
2478
  const styles = [];
2437
2479
  styles.push(`font-size:${(effectivePt * PX_PER_PT).toFixed(2)}px`);
2438
2480
  styles.push(suppressLineHeight ? "line-height:0;vertical-align:top" : lineHeightOverride !== void 0 ? `line-height:${lineHeightOverride}` : "line-height:1.05");
2439
- if (format?.font) styles.push(`font-family:${escapeXml(format.font)}, ${DEFAULT_FONT}`);
2481
+ if (format?.font) styles.push(...fontFamilyStyles(format.font));
2440
2482
  if (format?.bold) styles.push("font-weight:700");
2441
2483
  if (format?.italic) styles.push("font-style:italic");
2442
2484
  const underline = format?.underline;
@@ -2557,6 +2599,13 @@ const smallCapsParts = (text, base) => {
2557
2599
  flush();
2558
2600
  return parts;
2559
2601
  };
2602
+ const faceOf = (a, font) => {
2603
+ const family = (a.resolveFamily ?? substituteFamily)(font);
2604
+ return a.familyIsCssList ? { family } : {
2605
+ family,
2606
+ cssFamily: cssFamilyName(family)
2607
+ };
2608
+ };
2560
2609
  const buildSvgTextInput = (a) => {
2561
2610
  const scale = a.autoFitScale;
2562
2611
  const paragraphs = a.paraData.map((para, pi) => {
@@ -2565,7 +2614,7 @@ const buildSvgTextInput = (a) => {
2565
2614
  if (run.text === "\n") {
2566
2615
  pieces.push({
2567
2616
  ...breakPiece(),
2568
- family: (a.resolveFamily ?? substituteFamily)(run.fmt?.font ?? a.themeFace),
2617
+ ...faceOf(a, run.fmt?.font ?? a.themeFace),
2569
2618
  sizePx: run.sizePt * scale * PX_PER_PT,
2570
2619
  bold: run.fmt?.bold ?? false,
2571
2620
  italic: run.fmt?.italic ?? false
@@ -2581,7 +2630,7 @@ const buildSvgTextInput = (a) => {
2581
2630
  underline: fmt?.underline ?? true
2582
2631
  };
2583
2632
  }
2584
- const family = (a.resolveFamily ?? substituteFamily)(fmt?.font ?? a.themeFace);
2633
+ const face = faceOf(a, fmt?.font ?? a.themeFace);
2585
2634
  const sizePx = run.sizePt * scale * PX_PER_PT;
2586
2635
  const fillHex = fmt?.color !== void 0 && fmt.color !== null ? resolveColor(fmt.color, a.theme, "#000000") : a.defaultColor;
2587
2636
  const baseline = fmt?.baseline ?? 0;
@@ -2590,7 +2639,7 @@ const buildSvgTextInput = (a) => {
2590
2639
  const cap = fmt?.cap;
2591
2640
  const caps = cap === "all" || cap === "small";
2592
2641
  const base = {
2593
- family,
2642
+ ...face,
2594
2643
  sizePx,
2595
2644
  bold: fmt?.bold ?? false,
2596
2645
  italic: fmt?.italic ?? false,
@@ -2736,10 +2785,9 @@ const buildBullet = (a, para, pi) => {
2736
2785
  const baseSizePx = (para.runs.find((r) => r.text !== "\n" && r.text !== "")?.sizePt ?? a.defaultPt) * PX_PER_PT * a.autoFitScale;
2737
2786
  const sizePx = para.bulletDetail.sizePct !== null ? baseSizePx * para.bulletDetail.sizePct : para.bulletDetail.sizePts !== null ? para.bulletDetail.sizePts * PX_PER_PT * a.autoFitScale : baseSizePx;
2738
2787
  const fillHex = bulletFillOf(para, a.theme, a.defaultColor);
2739
- const bulletFont = para.bulletDetail.fontFollowText ? para.runs.find((r) => r.text !== "\n" && r.text !== "")?.fmt?.font ?? DEFAULT_BULLET_FONT : para.bulletDetail.font ?? DEFAULT_BULLET_FONT;
2740
2788
  return {
2741
2789
  text: char,
2742
- family: (a.resolveFamily ?? substituteFamily)(bulletFont),
2790
+ ...faceOf(a, para.bulletDetail.fontFollowText ? para.runs.find((r) => r.text !== "\n" && r.text !== "")?.fmt?.font ?? DEFAULT_BULLET_FONT : para.bulletDetail.font ?? DEFAULT_BULLET_FONT),
2743
2791
  sizePx,
2744
2792
  fillHex,
2745
2793
  ...para.bulletImageHref ? { imageHref: para.bulletImageHref } : {}
@@ -2750,7 +2798,7 @@ const bulletFillOf = (para, theme, defaultColor) => {
2750
2798
  const runColor = para.runs.find((r) => r.text !== "\n" && r.text !== "")?.fmt?.color;
2751
2799
  return runColor === void 0 || runColor === null ? defaultColor : resolveColor(runColor, theme, defaultColor);
2752
2800
  };
2753
- const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defaultColor, useSvgTextRect = false) => {
2801
+ const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defaultColor, useSvgTextRect = false, resolveFamily = substituteFamily) => {
2754
2802
  let paragraphCount;
2755
2803
  try {
2756
2804
  paragraphCount = getShapeParagraphCount(shape);
@@ -2761,7 +2809,7 @@ const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defau
2761
2809
  const defaultPt = placeholderDefaultPt(phType);
2762
2810
  const themeFonts = getPresentationFonts(pres);
2763
2811
  const themeFace = phType === "title" || phType === "ctrTitle" ? themeFonts?.majorLatin ?? null : themeFonts?.minorLatin ?? null;
2764
- const effectiveDefaultFont = themeFace ? `${escapeXml(themeFace)}, ${DEFAULT_FONT}` : DEFAULT_FONT;
2812
+ const effectiveDefaultFont = escapeXml(browserFontFamily(themeFace));
2765
2813
  let effectiveBody;
2766
2814
  try {
2767
2815
  effectiveBody = getShapeBodyPrEffective(pres, shape);
@@ -2969,7 +3017,8 @@ const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defau
2969
3017
  innerH: fitRect.h,
2970
3018
  measure,
2971
3019
  vert: fitVert,
2972
- columns: fitColumns
3020
+ columns: fitColumns,
3021
+ resolveFamily
2973
3022
  };
2974
3023
  let s = 1;
2975
3024
  while (s > AUTOFIT_FLOOR) {
@@ -3009,6 +3058,7 @@ const resolveTextBodyModel = (pres, shape, bounds, theme, phType, measure, defau
3009
3058
  measure,
3010
3059
  vert,
3011
3060
  autoFitScale,
3061
+ resolveFamily,
3012
3062
  columns: vert === "none" && cols && cols.count >= 2 ? {
3013
3063
  count: cols.count,
3014
3064
  gapPx: cols.gapEmu !== void 0 ? cols.gapEmu / EMU_PER_PX : 0
@@ -3066,10 +3116,11 @@ const renderHtmlParagraphs = (paraData, numberLabels, theme, autoFitScale, defau
3066
3116
  };
3067
3117
  }
3068
3118
  const span = renderRun(run.text, runFmt, theme, run.sizePt * autoFitScale, defaultColor, run.fmt?.size === void 0, run.text === "\n" && /[^\n]$/.test(para.runs[runIndex - 1]?.text ?? ""), para.lineSpacing?.kind === "pct" ? para.lineSpacing.value.toFixed(3) : para.lineSpacing?.kind === "pts" ? "0" : void 0);
3069
- if (!run.href) return span;
3070
- const targetAttrs = run.href.startsWith("#") ? "" : " target=\"_blank\" rel=\"noopener noreferrer\"";
3119
+ const href = run.href ? safeLinkHref(run.href) : void 0;
3120
+ if (!href) return span;
3121
+ const targetAttrs = href.startsWith("#") ? "" : " target=\"_blank\" rel=\"noopener noreferrer\"";
3071
3122
  const titleAttr = run.hrefTip ? ` title="${escapeXml(run.hrefTip)}"` : "";
3072
- return `<a href="${escapeXml(run.href)}"${targetAttrs}${titleAttr} style="color:inherit;text-decoration:inherit">${span}</a>`;
3123
+ return `<a href="${escapeXml(href)}"${targetAttrs}${titleAttr} style="color:inherit;text-decoration:inherit">${span}</a>`;
3073
3124
  });
3074
3125
  const firstRunPt = para.runs.find((run) => run.text !== "\n" && run.text !== "")?.sizePt ?? defaultPt;
3075
3126
  let lineHeightCss = para.emptySizePt !== void 0 ? `line-height:${LINE_HEIGHT}` : "";
@@ -3083,7 +3134,7 @@ const renderHtmlParagraphs = (paraData, numberLabels, theme, autoFitScale, defau
3083
3134
  const pStyles = [
3084
3135
  "margin:0",
3085
3136
  para.lineSpacing?.kind === "pts" ? `font-size:${(firstRunPt * PX_PER_PT * autoFitScale).toFixed(2)}px` : "font-size:0",
3086
- ...para.lineSpacing?.kind === "pts" && para.runs[0]?.fmt?.font ? [`font-family:${escapeXml(para.runs[0].fmt.font)}, ${DEFAULT_FONT}`] : [],
3137
+ ...para.lineSpacing?.kind === "pts" && para.runs[0]?.fmt?.font ? fontFamilyStyles(para.runs[0].fmt.font) : [],
3087
3138
  ...para.emptySizePt !== void 0 ? [`font-size:${(para.emptySizePt * PX_PER_PT * autoFitScale).toFixed(2)}px`] : [],
3088
3139
  ...para.runs.some((run) => run.text.includes(" ")) ? [`white-space:${wrap ? "pre-wrap" : "pre"}`, `tab-size:${((para.defaultTabSizeEmu ?? 914400) / EMU_PER_PX * autoFitScale).toFixed(2)}px`] : [],
3089
3140
  marginTopCss,
@@ -3118,7 +3169,7 @@ const renderHtmlParagraphs = (paraData, numberLabels, theme, autoFitScale, defau
3118
3169
  else if (para.bulletDetail.sizePts !== null) bulletStyles.push(`font-size:${(para.bulletDetail.sizePts * PX_PER_PT * autoFitScale).toFixed(2)}px`);
3119
3170
  else bulletStyles.push(`font-size:${baseBulletPx.toFixed(2)}px`);
3120
3171
  const bulletFont = para.bulletDetail.fontFollowText ? para.runs.find((r) => r.text !== "\n" && r.text !== "")?.fmt?.font ?? DEFAULT_BULLET_FONT : para.bulletDetail.font ?? DEFAULT_BULLET_FONT;
3121
- bulletStyles.push(`font-family:${escapeXml(bulletFont)}, ${DEFAULT_FONT}`);
3172
+ bulletStyles.push(...fontFamilyStyles(bulletFont));
3122
3173
  prefix = `<span style="${bulletStyles.join(";")}">${escapeXml(char)}</span>`;
3123
3174
  }
3124
3175
  paragraphs.push(`<p data-pptx-paragraph="${pi}" style="${pStyles.join(";")}">${prefix}${runHtmls.join("") || "&#8203;"}</p>`);
@@ -3126,7 +3177,7 @@ const renderHtmlParagraphs = (paraData, numberLabels, theme, autoFitScale, defau
3126
3177
  return paragraphs;
3127
3178
  };
3128
3179
  const renderTextBody = (pres, shape, bounds, theme, phType, ctx, output = "body") => {
3129
- const model = resolveTextBodyModel(pres, shape, bounds, theme, phType, ctx.measure, activeDeckTextColor, ctx.mode === "svg" && output === "body");
3180
+ const model = resolveTextBodyModel(pres, shape, bounds, theme, phType, ctx.measure, activeDeckTextColor, ctx.mode === "svg" && output === "body", ctx.resolveFamily);
3130
3181
  if (model === null) return "";
3131
3182
  const bevel = textBevelOf(getShapeText3D(shape));
3132
3183
  const { paraData, numberLabels, authoredAutofit, autoFitScale, defaultPt, themeFace, effectiveDefaultFont, effectiveBody, anchor, innerX, innerY, innerW, innerH, svgTextRect } = model;
@@ -3162,7 +3213,7 @@ const renderTextBody = (pres, shape, bounds, theme, phType, ctx, output = "body"
3162
3213
  innerW: vInnerW,
3163
3214
  innerH: vInnerH,
3164
3215
  measure: ctx.mode === "svg" ? ctx.measure : browserTextMeasurer() ?? ctx.measure,
3165
- ...ctx.mode === "foreignObject" ? { resolveFamily: browserFontFamily } : {},
3216
+ ...ctx.mode === "foreignObject" ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily },
3166
3217
  vert: svgVert,
3167
3218
  columns: svgColumns,
3168
3219
  bevel
@@ -3172,7 +3223,7 @@ const renderTextBody = (pres, shape, bounds, theme, phType, ctx, output = "body"
3172
3223
  ...svgArgsBase,
3173
3224
  autoFitScale: authoredAutofit ? autoFitScale : 1,
3174
3225
  effectsOnly: true,
3175
- resolveFamily: browserFontFamily
3226
+ ...BROWSER_FAMILIES
3176
3227
  };
3177
3228
  const effects = [
3178
3229
  paraData.some((para) => para.runs.some((run) => run.fmt?.reflection != null)) ? buildAndLayoutSvgText({
@@ -3253,7 +3304,7 @@ const renderTextBody = (pres, shape, bounds, theme, phType, ctx, output = "body"
3253
3304
  vert: svgVert,
3254
3305
  columns: null,
3255
3306
  effectsOnly: true,
3256
- resolveFamily: browserFontFamily,
3307
+ ...BROWSER_FAMILIES,
3257
3308
  bevel
3258
3309
  };
3259
3310
  const hasCharacterReflection = paraData.some((para) => para.runs.some((run) => run.fmt?.reflection !== void 0 && run.fmt.reflection !== null));
@@ -3310,6 +3361,7 @@ const renderTextEffectsSvg = (pres, slide, shape, bounds, opts = {}) => {
3310
3361
  groupReflected: false,
3311
3362
  mode: "foreignObject",
3312
3363
  measure: opts.measureText ?? browserTextMeasurer() ?? defaultMeasurer,
3364
+ resolveFamily: substituteFamily,
3313
3365
  ownShapes: true,
3314
3366
  background: {
3315
3367
  id: "",
@@ -3346,6 +3398,7 @@ const renderTableCellEffectsSvgInternal = (pres, slide, shape, cellPosition, bou
3346
3398
  groupReflected: false,
3347
3399
  mode: "foreignObject",
3348
3400
  measure: opts.measureText ?? browserTextMeasurer() ?? defaultMeasurer,
3401
+ resolveFamily: substituteFamily,
3349
3402
  ownShapes: true,
3350
3403
  background: {
3351
3404
  id: "",
@@ -4841,7 +4894,7 @@ const renderTableCellText = (cell, paragraphs, cx, cy, cw, ch, color, pres, shap
4841
4894
  innerW: innerW * EMU_PER_PX,
4842
4895
  innerH: innerH * EMU_PER_PX,
4843
4896
  measure: ctx.mode === "svg" ? ctx.measure : browserTextMeasurer() ?? ctx.measure,
4844
- ...ctx.mode === "foreignObject" ? { resolveFamily: browserFontFamily } : {},
4897
+ ...ctx.mode === "foreignObject" ? BROWSER_FAMILIES : { resolveFamily: ctx.resolveFamily },
4845
4898
  vert: verticalLayoutOf(getTableCellTextDirection(cell)),
4846
4899
  columns: null,
4847
4900
  ...effectsOnly ? { effectsOnly: true } : {},
@@ -4874,7 +4927,7 @@ const renderTableCellText = (cell, paragraphs, cx, cy, cw, ch, color, pres, shap
4874
4927
  const vertical = verticalTextStyle(getTableCellTextDirection(cell));
4875
4928
  const directionStyle = `${vertical.declarations}${vertical.transform ? `;transform:${vertical.transform}` : ""}`;
4876
4929
  const justify = vAnchor === "top" ? "flex-start" : vAnchor === "bottom" ? "flex-end" : "center";
4877
- const familyFont = themeFace ? `${escapeXml(themeFace)}, ${DEFAULT_FONT}` : DEFAULT_FONT;
4930
+ const familyFont = escapeXml(browserFontFamily(themeFace));
4878
4931
  const body = renderHtmlParagraphs(paraData, numberLabels, theme, 1, DEFAULT_BODY_PT, color).join("");
4879
4932
  return `<foreignObject x="${px(innerX)}" y="${px(innerY)}" width="${px(innerW)}" height="${px(innerH)}"><div xmlns="http://www.w3.org/1999/xhtml" style="display:flex;flex-direction:column;justify-content:${justify};width:100%;height:100%;box-sizing:border-box;overflow:hidden;line-height:${LINE_HEIGHT};font-family:${familyFont};color:${color};word-break:break-word;${directionStyle}">${body}</div></foreignObject>${fillOverlay()}${bevelOverlay()}`;
4880
4933
  };
@@ -5720,6 +5773,7 @@ const renderSurface = (pres, surface, opts) => {
5720
5773
  groupReflected: false,
5721
5774
  mode: opts.textLayout ?? "foreignObject",
5722
5775
  measure: opts.measureText ?? defaultMeasurer,
5776
+ resolveFamily: opts.textLayout === "svg" ? opts.resolveFamily ?? substituteFamily : substituteFamily,
5723
5777
  ownShapes: true,
5724
5778
  background: {
5725
5779
  id: "",
@@ -5850,12 +5904,23 @@ const detectImageFormatLocal = (bytes) => {
5850
5904
  //#endregion
5851
5905
  //#region src/audit.ts
5852
5906
  const DEFAULT_TOLERANCE_PX = 1;
5907
+ const PT_PER_PX = 72 / 96;
5908
+ const SHORT_LAST_LINE_MAX_CHARS = 2;
5909
+ const LETTER_OR_DIGIT = /[\p{Script=Latin}\p{Script=Greek}\p{Script=Cyrillic}\p{Nd}]/u;
5910
+ const KATAKANA = /[\p{Script=Katakana}\u30FC]/u;
5911
+ const isWordChar = (ch) => LETTER_OR_DIGIT.test(ch) || KATAKANA.test(ch);
5912
+ const splitsWord = (before, after) => LETTER_OR_DIGIT.test(before) && LETTER_OR_DIGIT.test(after) || KATAKANA.test(before) && KATAKANA.test(after);
5913
+ const charBefore = (s, i) => {
5914
+ return (i >= 2 ? s.codePointAt(i - 2) : 0) > 65535 ? s.slice(i - 2, i) : s.slice(i - 1, i);
5915
+ };
5916
+ const charAt = (s, i) => i < s.length ? String.fromCodePoint(s.codePointAt(i)) : "";
5853
5917
  const round2 = (n) => Math.round(n * 100) / 100;
5854
5918
  function* walkShapes(shapes) {
5855
5919
  for (const shape of shapes) if (getShapeKind(shape) !== "group") yield shape;
5856
5920
  }
5857
5921
  const passthroughFamily = (family) => family ?? "Carlito";
5858
- const auditShape = (pres, theme, shape, slideIndex, measure, tolerancePx, reportSoftWraps, issues) => {
5922
+ const auditShape = (pres, theme, shape, slideIndex, measure, options, issues) => {
5923
+ const { tolerancePx, reportSoftWraps, reportBadWraps, minFontSizePt } = options;
5859
5924
  const kind = getShapeKind(shape);
5860
5925
  if (kind !== "shape" && kind !== "graphicFrame") return;
5861
5926
  const bounds = getShapeBoundsResolved(pres, shape);
@@ -5865,7 +5930,7 @@ const auditShape = (pres, theme, shape, slideIndex, measure, tolerancePx, report
5865
5930
  y: bounds.y,
5866
5931
  w: bounds.w,
5867
5932
  h: bounds.h
5868
- }, theme, getShapePlaceholderType(shape), measure, "#000000");
5933
+ }, theme, getShapePlaceholderType(shape), measure, "#000000", false, passthroughFamily);
5869
5934
  if (model === null) return;
5870
5935
  const vert = verticalLayoutOf(model.effectiveBody.vert ?? getShapeTextDirection(shape));
5871
5936
  const cols = model.effectiveBody.columns;
@@ -5900,51 +5965,65 @@ const auditShape = (pres, theme, shape, slideIndex, measure, tolerancePx, report
5900
5965
  resolveFamily: passthroughFamily
5901
5966
  });
5902
5967
  const core = layoutCore(input, measure);
5903
- const frame = vert === "cw90" || vert === "cw270" ? {
5904
- x: input.boxXpx + input.boxWpx / 2 - input.boxHpx / 2,
5905
- y: input.boxYpx + input.boxHpx / 2 - input.boxWpx / 2,
5906
- w: input.boxHpx,
5907
- h: input.boxWpx
5968
+ const rotated = vert === "cw90" || vert === "cw270";
5969
+ const frameOf = (x, y, w, h) => rotated ? {
5970
+ x: x + w / 2 - h / 2,
5971
+ y: y + h / 2 - w / 2,
5972
+ w: h,
5973
+ h: w
5908
5974
  } : {
5909
- x: input.boxXpx,
5910
- y: input.boxYpx,
5911
- w: input.boxWpx,
5912
- h: input.boxHpx
5975
+ x,
5976
+ y,
5977
+ w,
5978
+ h
5913
5979
  };
5980
+ const frame = frameOf(input.boxXpx, input.boxYpx, input.boxWpx, input.boxHpx);
5981
+ const shapeFrame = frameOf(bounds.x / EMU_PER_PX, bounds.y / EMU_PER_PX, bounds.w / EMU_PER_PX, bounds.h / EMU_PER_PX);
5982
+ let inkLeft = Infinity;
5983
+ let inkRight = -Infinity;
5914
5984
  let inkTop = Infinity;
5915
5985
  let inkBottom = -Infinity;
5916
- let overflowX = 0;
5917
- let anyInk = false;
5986
+ let lineHeight = 0;
5918
5987
  for (const p of core.placements) {
5919
5988
  const toks = [...p.line.tokens];
5920
5989
  while (toks.length > 0 && (toks[toks.length - 1].isSpace || toks[toks.length - 1].isBreak)) toks.pop();
5921
5990
  if (!toks.some((t) => !t.isSpace && !t.isBreak)) continue;
5922
- anyInk = true;
5923
5991
  let lineW = 0;
5924
5992
  for (const t of toks) if (!t.isBreak) lineW += t.width;
5925
- const left = p.line.textAnchor === "middle" ? p.line.anchorX - lineW / 2 : p.line.textAnchor === "end" ? p.line.anchorX - lineW : p.line.anchorX;
5926
- const over = Math.max(frame.x - (left + p.dx), left + p.dx + lineW - (frame.x + frame.w));
5927
- if (over > overflowX) overflowX = over;
5993
+ const left = (p.line.textAnchor === "middle" ? p.line.anchorX - lineW / 2 : p.line.textAnchor === "end" ? p.line.anchorX - lineW : p.line.anchorX) + p.dx;
5994
+ inkLeft = Math.min(inkLeft, left);
5995
+ inkRight = Math.max(inkRight, left + lineW);
5928
5996
  inkTop = Math.min(inkTop, p.baselineY - p.line.ascent);
5929
5997
  inkBottom = Math.max(inkBottom, p.baselineY + p.line.descent);
5998
+ lineHeight = Math.max(lineHeight, p.line.ascent + p.line.descent);
5930
5999
  }
5931
- if (!anyInk) return;
6000
+ if (inkLeft === Infinity) return;
5932
6001
  const approximate = detectApproximate(input.paragraphs, measure);
5933
6002
  const slideRef = {
5934
6003
  slideIndex,
5935
6004
  shapeName: getShapeName(shape),
5936
6005
  approximate
5937
6006
  };
6007
+ const overflowXOf = (f) => Math.max(0, f.x - inkLeft, inkRight - (f.x + f.w));
6008
+ const overflowYOf = (f) => Math.max(0, f.y - inkTop, inkBottom - (f.y + f.h));
6009
+ const shapeOverflowXOf = (f) => Math.max(0, f.x - inkLeft) + Math.max(0, inkRight - (f.x + f.w));
6010
+ const shapeOverflowYOf = (f) => Math.max(0, f.y - inkTop) + Math.max(0, inkBottom - (f.y + f.h));
6011
+ const lineHeightPx = round2(lineHeight);
6012
+ const overflowX = overflowXOf(frame);
5938
6013
  if (overflowX > tolerancePx) issues.push({
5939
6014
  ...slideRef,
5940
6015
  kind: "overflow-x",
5941
- overflowPx: round2(overflowX)
6016
+ overflowPx: round2(overflowX),
6017
+ shapeOverflowPx: round2(shapeOverflowXOf(shapeFrame)),
6018
+ lineHeightPx
5942
6019
  });
5943
- const overflowY = Math.max(frame.y - inkTop, inkBottom - (frame.y + frame.h));
6020
+ const overflowY = overflowYOf(frame);
5944
6021
  if (overflowY > tolerancePx) issues.push({
5945
6022
  ...slideRef,
5946
6023
  kind: "overflow-y",
5947
- overflowPx: round2(overflowY)
6024
+ overflowPx: round2(overflowY),
6025
+ shapeOverflowPx: round2(shapeOverflowYOf(shapeFrame)),
6026
+ lineHeightPx
5948
6027
  });
5949
6028
  if (reportSoftWraps && vert === "none") {
5950
6029
  const lineCounts = /* @__PURE__ */ new Map();
@@ -5960,6 +6039,94 @@ const auditShape = (pres, theme, shape, slideIndex, measure, tolerancePx, report
5960
6039
  });
5961
6040
  });
5962
6041
  }
6042
+ if (reportBadWraps && vert === "none") auditLineBreaks(input.paragraphs, core.placements, slideRef, issues);
6043
+ if (minFontSizePt !== void 0) {
6044
+ let smallestPx = Infinity;
6045
+ for (const para of input.paragraphs) for (const piece of para.pieces) if (!piece.isBreak && piece.text.trim() !== "" && piece.sizePx < smallestPx) smallestPx = piece.sizePx;
6046
+ const sizePt = Math.round(smallestPx * PT_PER_PX * 10) / 10;
6047
+ if (sizePt < minFontSizePt) issues.push({
6048
+ ...slideRef,
6049
+ kind: "small-font",
6050
+ sizePt
6051
+ });
6052
+ }
6053
+ };
6054
+ const auditLineBreaks = (paragraphs, placements, slideRef, issues) => {
6055
+ const linesByParagraph = /* @__PURE__ */ new Map();
6056
+ for (const { line } of placements) {
6057
+ const text = line.tokens.map((t) => t.isTab ? " " : t.text).join("");
6058
+ if (text.trim() === "") continue;
6059
+ const lines = linesByParagraph.get(line.paraIndex);
6060
+ if (lines) lines.push(text);
6061
+ else linesByParagraph.set(line.paraIndex, [text]);
6062
+ }
6063
+ for (const [paragraphIndex, lines] of linesByParagraph) auditParagraphBreaks(paragraphs[paragraphIndex].pieces.map((p) => p.isBreak ? "\n" : p.text).join(""), lines, {
6064
+ ...slideRef,
6065
+ paragraphIndex
6066
+ }, issues);
6067
+ };
6068
+ const auditParagraphBreaks = (source, lines, ref, issues) => {
6069
+ const found = [];
6070
+ const wordsByStart = /* @__PURE__ */ new Map();
6071
+ let previousEnd = -1;
6072
+ let wrappedLines = 0;
6073
+ const closeSegment = (lastLine) => {
6074
+ const visible = [...lastLine.replace(/\s/gu, "")];
6075
+ if (wrappedLines > 0 && visible.length <= SHORT_LAST_LINE_MAX_CHARS) found.push({
6076
+ ...ref,
6077
+ kind: "short-last-line",
6078
+ text: lastLine.trim()
6079
+ });
6080
+ };
6081
+ for (let i = 0; i < lines.length; i++) {
6082
+ const start = source.indexOf(lines[i], Math.max(previousEnd, 0));
6083
+ if (start < 0) return;
6084
+ if (previousEnd >= 0) {
6085
+ const gap = source.slice(previousEnd, start);
6086
+ if (gap.includes("\n")) {
6087
+ closeSegment(lines[i - 1]);
6088
+ wrappedLines = 0;
6089
+ } else {
6090
+ wrappedLines++;
6091
+ if (gap === "" && splitsWord(charBefore(source, start), charAt(source, start))) {
6092
+ let wordStart = start;
6093
+ while (wordStart > 0 && isWordChar(charBefore(source, wordStart))) wordStart -= charBefore(source, wordStart).length;
6094
+ let word = wordsByStart.get(wordStart);
6095
+ if (!word) {
6096
+ let end = start;
6097
+ while (end < source.length && isWordChar(charAt(source, end))) end += charAt(source, end).length;
6098
+ word = {
6099
+ start: wordStart,
6100
+ end,
6101
+ breaks: []
6102
+ };
6103
+ wordsByStart.set(wordStart, word);
6104
+ found.push(word);
6105
+ }
6106
+ word.breaks.push(start);
6107
+ }
6108
+ }
6109
+ }
6110
+ previousEnd = start + lines[i].length;
6111
+ }
6112
+ closeSegment(lines[lines.length - 1]);
6113
+ for (const entry of found) {
6114
+ if ("kind" in entry) {
6115
+ issues.push(entry);
6116
+ continue;
6117
+ }
6118
+ let text = "";
6119
+ let from = entry.start;
6120
+ for (const at of entry.breaks) {
6121
+ text += `${source.slice(from, at)}|`;
6122
+ from = at;
6123
+ }
6124
+ issues.push({
6125
+ ...ref,
6126
+ kind: "word-break",
6127
+ text: text + source.slice(from, entry.end)
6128
+ });
6129
+ }
5963
6130
  };
5964
6131
  const detectApproximate = (paragraphs, measure) => {
5965
6132
  const seen = /* @__PURE__ */ new Set();
@@ -5984,19 +6151,25 @@ const detectApproximate = (paragraphs, measure) => {
5984
6151
  /**
5985
6152
  * Measures every text body in the deck and reports text that escapes its box
5986
6153
  * (`overflow-x` / `overflow-y`) and, opt-in, paragraphs that soft-wrap
5987
- * (`soft-wrap`, 段落ち).
6154
+ * (`soft-wrap`, 段落ち), wraps that split a word or strand a line of one or
6155
+ * two characters (`word-break`, `short-last-line`), and text below a minimum
6156
+ * size (`small-font`).
5988
6157
  *
5989
6158
  * Table cell text is not audited (v1 covers shape text bodies, including
5990
6159
  * placeholders and shapes inside groups).
5991
6160
  */
5992
6161
  const auditTextLayout = (pres, options = {}) => {
5993
6162
  const measure = options.measureText ?? defaultMeasurer;
5994
- const tolerancePx = options.tolerancePx ?? DEFAULT_TOLERANCE_PX;
5995
- const reportSoftWraps = options.reportSoftWraps ?? false;
6163
+ const resolved = {
6164
+ tolerancePx: options.tolerancePx ?? DEFAULT_TOLERANCE_PX,
6165
+ reportSoftWraps: options.reportSoftWraps ?? false,
6166
+ reportBadWraps: options.reportBadWraps ?? false,
6167
+ minFontSizePt: options.minFontSizePt
6168
+ };
5996
6169
  const theme = getPresentationTheme(pres);
5997
6170
  const issues = [];
5998
6171
  const slides = getSlides(pres);
5999
- for (let slideIndex = 0; slideIndex < slides.length; slideIndex++) for (const shape of walkShapes(getSlideShapes(slides[slideIndex]))) auditShape(pres, theme, shape, slideIndex, measure, tolerancePx, reportSoftWraps, issues);
6172
+ for (let slideIndex = 0; slideIndex < slides.length; slideIndex++) for (const shape of walkShapes(getSlideShapes(slides[slideIndex]))) auditShape(pres, theme, shape, slideIndex, measure, resolved, issues);
6000
6173
  return issues;
6001
6174
  };
6002
6175
  //#endregion
@@ -6043,4 +6216,4 @@ function shapeTextAnchorOffset(pres, shape, options = {}) {
6043
6216
  //#endregion
6044
6217
  export { shapeTextRect as S, paragraphNumberLabels as _, renderSlideSvg as a, verticalTextStyle as b, AVG_GLYPH_W_RATIO as c, SERIF as d, TIMES as f, decimalSeparatorOf as g, substituteFamily as h, renderSlideLayoutSvg as i, MONO as l, isCjk as m, shapeTextAnchorOffset as n, renderTextEffectsSvg as o, defaultMeasurer as p, auditTextLayout as r, ARIAL as s, shapeAutoFitScale as t, SANS as u, textUnderlineStyle as v, resolveTextBodyRect as x, textColumnsStyle as y };
6045
6218
 
6046
- //# sourceMappingURL=src-pb3C61GD.js.map
6219
+ //# sourceMappingURL=src-ayE1F9lH.js.map