pptx-glimpse 1.0.1 → 1.1.1

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.
package/README.md CHANGED
@@ -88,6 +88,19 @@ const results = await convertPptxToPng(pptx, {
88
88
  });
89
89
  ```
90
90
 
91
+ #### Text output mode (`textOutput`)
92
+
93
+ By default, text is converted to `<path>` outlines, which renders consistently in any environment but bypasses the browser's native text rasterization (hinting, text-specific anti-aliasing), so glyph edges can look jagged when SVGs are displayed inline in a browser.
94
+
95
+ With `textOutput: "text"`, `convertPptxToSvg` emits native `<text>` elements along with `@font-face` rules that embed subsetted fonts as data URIs. This produces smoother text rendering in browsers, smaller SVGs for CJK-heavy slides, and selectable/copyable text. Characters not covered by the resolved font fall back to the viewer's fonts via the `font-family` chain.
96
+
97
+ ```typescript
98
+ // SVG only — convertPptxToPng always uses path output
99
+ const svgResults = await convertPptxToSvg(pptx, { textOutput: "text" });
100
+ ```
101
+
102
+ Note: embedded fonts and `<text>` may not render as expected when the SVG is referenced via `<img src="...svg">` or sanitized. `convertPptxToPng` always uses path output regardless of this option.
103
+
91
104
  ### Advanced Usage
92
105
 
93
106
  <details>
package/dist/index.cjs CHANGED
@@ -38,6 +38,189 @@ module.exports = __toCommonJS(index_exports);
38
38
  // src/converter.ts
39
39
  var import_node_fs2 = require("fs");
40
40
 
41
+ // src/font/font-embedder.ts
42
+ var import_node_buffer = require("buffer");
43
+
44
+ // src/warning-logger.ts
45
+ var PREFIX = "[pptx-glimpse]";
46
+ var currentLevel = "off";
47
+ var entries = [];
48
+ var featureCounts = /* @__PURE__ */ new Map();
49
+ function initWarningLogger(level) {
50
+ currentLevel = level;
51
+ entries = [];
52
+ featureCounts.clear();
53
+ }
54
+ function warn(feature, message, context) {
55
+ if (currentLevel === "off") return;
56
+ entries.push({ feature, message, ...context !== void 0 && { context } });
57
+ const existing = featureCounts.get(feature);
58
+ if (existing) {
59
+ existing.count++;
60
+ } else {
61
+ featureCounts.set(feature, { message, count: 1 });
62
+ }
63
+ if (currentLevel === "debug") {
64
+ const ctx = context ? ` (${context})` : "";
65
+ console.warn(`${PREFIX} SKIP: ${feature} - ${message}${ctx}`);
66
+ }
67
+ }
68
+ function debug(feature, message, context) {
69
+ if (currentLevel !== "debug") return;
70
+ entries.push({ feature, message, ...context !== void 0 && { context } });
71
+ const existing = featureCounts.get(feature);
72
+ if (existing) {
73
+ existing.count++;
74
+ } else {
75
+ featureCounts.set(feature, { message, count: 1 });
76
+ }
77
+ const ctx = context ? ` (${context})` : "";
78
+ console.warn(`${PREFIX} DEBUG: ${feature} - ${message}${ctx}`);
79
+ }
80
+ function getWarningSummary() {
81
+ const features = [];
82
+ for (const [feature, { message, count }] of featureCounts) {
83
+ features.push({ feature, message, count });
84
+ }
85
+ return { totalCount: entries.length, features };
86
+ }
87
+ function flushWarnings() {
88
+ const summary = getWarningSummary();
89
+ if (currentLevel !== "off" && summary.features.length > 0) {
90
+ console.warn(`${PREFIX} Summary: ${summary.features.length} unsupported feature(s) detected`);
91
+ for (const { feature, count } of summary.features) {
92
+ console.warn(` - ${feature}: ${count} occurrence(s)`);
93
+ }
94
+ }
95
+ entries = [];
96
+ featureCounts.clear();
97
+ return summary;
98
+ }
99
+ function getWarningEntries() {
100
+ return entries;
101
+ }
102
+
103
+ // src/font/font-subsetter.ts
104
+ async function tryLoadOpentypeCtors() {
105
+ try {
106
+ const specifier = "opentype.js";
107
+ const mod = await import(
108
+ /* @vite-ignore */
109
+ specifier
110
+ );
111
+ return { Font: mod.Font, Glyph: mod.Glyph };
112
+ } catch {
113
+ return null;
114
+ }
115
+ }
116
+ function glyphName(glyph, firstUnicode) {
117
+ if (glyph.name) return glyph.name;
118
+ return `uni${firstUnicode.toString(16).toUpperCase().padStart(4, "0")}`;
119
+ }
120
+ async function subsetFont(font, chars, familyName) {
121
+ const opentype = await tryLoadOpentypeCtors();
122
+ if (!opentype) return null;
123
+ const source = font;
124
+ if (typeof source.charToGlyph !== "function" || !source.glyphs) return null;
125
+ const glyphMap = /* @__PURE__ */ new Map();
126
+ for (const char of chars) {
127
+ const codePoint = char.codePointAt(0);
128
+ if (codePoint === void 0) continue;
129
+ let glyph;
130
+ try {
131
+ glyph = source.charToGlyph(char);
132
+ } catch {
133
+ continue;
134
+ }
135
+ if (!glyph || !glyph.index) continue;
136
+ const entry = glyphMap.get(glyph.index);
137
+ if (entry) {
138
+ entry.unicodes.add(codePoint);
139
+ } else {
140
+ glyphMap.set(glyph.index, { glyph, unicodes: /* @__PURE__ */ new Set([codePoint]) });
141
+ }
142
+ }
143
+ if (glyphMap.size === 0) return null;
144
+ try {
145
+ const notdefSource = source.glyphs.get(0);
146
+ const glyphs = [
147
+ new opentype.Glyph({
148
+ name: ".notdef",
149
+ advanceWidth: notdefSource?.advanceWidth ?? source.unitsPerEm / 2,
150
+ path: notdefSource?.path
151
+ })
152
+ ];
153
+ for (const { glyph, unicodes } of glyphMap.values()) {
154
+ const unicodeList = [...unicodes].sort((a, b) => a - b);
155
+ glyphs.push(
156
+ new opentype.Glyph({
157
+ name: glyphName(glyph, unicodeList[0]),
158
+ unicode: unicodeList[0],
159
+ unicodes: unicodeList,
160
+ advanceWidth: glyph.advanceWidth ?? 0,
161
+ path: glyph.path
162
+ })
163
+ );
164
+ }
165
+ const subset = new opentype.Font({
166
+ familyName: familyName || "EmbeddedFont",
167
+ styleName: "Regular",
168
+ unitsPerEm: source.unitsPerEm,
169
+ ascender: source.ascender,
170
+ // opentype.js は descender に負値を要求する
171
+ descender: source.descender < 0 ? source.descender : -1,
172
+ glyphs
173
+ });
174
+ return new Uint8Array(subset.toArrayBuffer());
175
+ } catch (e) {
176
+ warn(
177
+ "font.subsetFailed",
178
+ `Failed to subset font "${familyName}": ${e instanceof Error ? e.message : String(e)}`
179
+ );
180
+ return null;
181
+ }
182
+ }
183
+
184
+ // src/font/script-font-context.ts
185
+ var jpanMajorFont = null;
186
+ var jpanMinorFont = null;
187
+ function setScriptFonts(majorJpan, minorJpan) {
188
+ jpanMajorFont = majorJpan;
189
+ jpanMinorFont = minorJpan;
190
+ }
191
+ function resetScriptFonts() {
192
+ jpanMajorFont = null;
193
+ jpanMinorFont = null;
194
+ }
195
+ function getJpanFallbackFont() {
196
+ return jpanMajorFont ?? jpanMinorFont;
197
+ }
198
+
199
+ // src/font/font-embedder.ts
200
+ function escapeCssFamilyName(name) {
201
+ return name.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[<>&]/g, (c) => `\\${c.codePointAt(0).toString(16)} `);
202
+ }
203
+ async function buildFontFaceStyle(usages, fontResolver) {
204
+ const faces = [];
205
+ const jpanFallback = getJpanFallbackFont();
206
+ for (const [familyName, usage] of usages) {
207
+ const font = fontResolver.resolveFont(
208
+ usage.fonts[0],
209
+ usage.fonts[1] ?? null,
210
+ usage.fonts[2] ?? jpanFallback
211
+ );
212
+ if (!font) continue;
213
+ const buffer = await subsetFont(font, usage.chars, familyName);
214
+ if (!buffer) continue;
215
+ const base64 = import_node_buffer.Buffer.from(buffer).toString("base64");
216
+ faces.push(
217
+ `@font-face{font-family:"${escapeCssFamilyName(familyName)}";src:url(data:font/otf;base64,${base64}) format("opentype");}`
218
+ );
219
+ }
220
+ if (faces.length === 0) return "";
221
+ return `<style type="text/css">${faces.join("")}</style>`;
222
+ }
223
+
41
224
  // src/font/font-mapping.ts
42
225
  var DEFAULT_FONT_MAPPING = {
43
226
  // ラテン文字フォント
@@ -104,6 +287,40 @@ function getCurrentMappedFont(fontFamily) {
104
287
  return getMappedFont(fontFamily, currentMapping);
105
288
  }
106
289
 
290
+ // src/font/font-usage-collector.ts
291
+ var FontUsageCollector = class {
292
+ /** key: tspan の font-family リスト先頭のフォント名 (= @font-face で宣言する名前) */
293
+ usages = /* @__PURE__ */ new Map();
294
+ record(fonts, text) {
295
+ const primary = fonts.find((f) => f !== null && f !== void 0);
296
+ if (!primary || text.length === 0) return;
297
+ let usage = this.usages.get(primary);
298
+ if (!usage) {
299
+ usage = { fonts: [...fonts], chars: /* @__PURE__ */ new Set() };
300
+ this.usages.set(primary, usage);
301
+ }
302
+ for (const char of text) {
303
+ usage.chars.add(char);
304
+ }
305
+ }
306
+ getUsages() {
307
+ return this.usages;
308
+ }
309
+ reset() {
310
+ this.usages.clear();
311
+ }
312
+ };
313
+ var currentCollector = null;
314
+ function setFontUsageCollector(collector) {
315
+ currentCollector = collector;
316
+ }
317
+ function getFontUsageCollector() {
318
+ return currentCollector;
319
+ }
320
+ function resetFontUsageCollector() {
321
+ currentCollector = null;
322
+ }
323
+
107
324
  // src/font/opentype-helpers.ts
108
325
  var import_promises = require("fs/promises");
109
326
 
@@ -1047,65 +1264,6 @@ function measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
1047
1264
  return totalWidth;
1048
1265
  }
1049
1266
 
1050
- // src/warning-logger.ts
1051
- var PREFIX = "[pptx-glimpse]";
1052
- var currentLevel = "off";
1053
- var entries = [];
1054
- var featureCounts = /* @__PURE__ */ new Map();
1055
- function initWarningLogger(level) {
1056
- currentLevel = level;
1057
- entries = [];
1058
- featureCounts.clear();
1059
- }
1060
- function warn(feature, message, context) {
1061
- if (currentLevel === "off") return;
1062
- entries.push({ feature, message, ...context !== void 0 && { context } });
1063
- const existing = featureCounts.get(feature);
1064
- if (existing) {
1065
- existing.count++;
1066
- } else {
1067
- featureCounts.set(feature, { message, count: 1 });
1068
- }
1069
- if (currentLevel === "debug") {
1070
- const ctx = context ? ` (${context})` : "";
1071
- console.warn(`${PREFIX} SKIP: ${feature} - ${message}${ctx}`);
1072
- }
1073
- }
1074
- function debug(feature, message, context) {
1075
- if (currentLevel !== "debug") return;
1076
- entries.push({ feature, message, ...context !== void 0 && { context } });
1077
- const existing = featureCounts.get(feature);
1078
- if (existing) {
1079
- existing.count++;
1080
- } else {
1081
- featureCounts.set(feature, { message, count: 1 });
1082
- }
1083
- const ctx = context ? ` (${context})` : "";
1084
- console.warn(`${PREFIX} DEBUG: ${feature} - ${message}${ctx}`);
1085
- }
1086
- function getWarningSummary() {
1087
- const features = [];
1088
- for (const [feature, { message, count }] of featureCounts) {
1089
- features.push({ feature, message, count });
1090
- }
1091
- return { totalCount: entries.length, features };
1092
- }
1093
- function flushWarnings() {
1094
- const summary = getWarningSummary();
1095
- if (currentLevel !== "off" && summary.features.length > 0) {
1096
- console.warn(`${PREFIX} Summary: ${summary.features.length} unsupported feature(s) detected`);
1097
- for (const { feature, count } of summary.features) {
1098
- console.warn(` - ${feature}: ${count} occurrence(s)`);
1099
- }
1100
- }
1101
- entries = [];
1102
- featureCounts.clear();
1103
- return summary;
1104
- }
1105
- function getWarningEntries() {
1106
- return entries;
1107
- }
1108
-
1109
1267
  // src/font/cjk-font-fallback.ts
1110
1268
  var import_node_os = require("os");
1111
1269
  var MACOS_FALLBACKS = {
@@ -1642,21 +1800,6 @@ async function createOpentypeSetupFromSystem(additionalFontDirs, fontMapping, sk
1642
1800
  return setup;
1643
1801
  }
1644
1802
 
1645
- // src/font/script-font-context.ts
1646
- var jpanMajorFont = null;
1647
- var jpanMinorFont = null;
1648
- function setScriptFonts(majorJpan, minorJpan) {
1649
- jpanMajorFont = majorJpan;
1650
- jpanMinorFont = minorJpan;
1651
- }
1652
- function resetScriptFonts() {
1653
- jpanMajorFont = null;
1654
- jpanMinorFont = null;
1655
- }
1656
- function getJpanFallbackFont() {
1657
- return jpanMajorFont ?? jpanMinorFont;
1658
- }
1659
-
1660
1803
  // src/font/text-measurer.ts
1661
1804
  var DefaultTextMeasurer = class {
1662
1805
  measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
@@ -4256,11 +4399,10 @@ function parseParagraph(p, colorResolver, rels, fontScheme, lstStyle, orderedPCh
4256
4399
  const lstLevelProps = lstStyle?.levels[level];
4257
4400
  const { bullet, bulletFont, bulletColor, bulletSizePct } = parseBullet(pPr, colorResolver);
4258
4401
  const lnSpc = pPr?.lnSpc;
4259
- const lnSpcSpcPct = lnSpc?.spcPct;
4260
4402
  const tabStops = parseTabStops(pPr);
4261
4403
  const properties = {
4262
4404
  alignment: pPr?.["@_algn"] ?? lstLevelProps?.alignment ?? null,
4263
- lineSpacing: lnSpcSpcPct ? Number(lnSpcSpcPct["@_val"]) : null,
4405
+ lineSpacing: lnSpc?.spcPts || lnSpc?.spcPct ? parseSpacing(lnSpc) : null,
4264
4406
  spaceBefore: parseSpacing(pPr?.spcBef),
4265
4407
  spaceAfter: parseSpacing(pPr?.spcAft),
4266
4408
  level,
@@ -8022,7 +8164,11 @@ function renderTextBody(textBody, transform) {
8022
8164
  const paragraphGapPx = Math.max(prevSpaceAfterPx, spaceBeforePx);
8023
8165
  if (para.runs.length === 0 || !para.runs.some((r) => r.text.length > 0)) {
8024
8166
  const emptyParaHeightPt = paraFontSizePt > 0 ? paraFontSizePt : defaultNaturalHeightPt;
8025
- const dy = computeDy(isFirstLine, emptyParaHeightPt, DEFAULT_LINE_SPACING, paragraphGapPx);
8167
+ const dy = computeDy(
8168
+ isFirstLine,
8169
+ getLineHeightPx(para, emptyParaHeightPt, lnSpcReduction),
8170
+ paragraphGapPx
8171
+ );
8026
8172
  tspans.push(`<tspan x="${xPos}" dy="${dy}" text-anchor="${anchorValue}"> </tspan>`);
8027
8173
  isFirstLine = false;
8028
8174
  prevSpaceAfterPx = resolveSpacingPx(para.properties.spaceAfter, paraFontSizePt);
@@ -8041,8 +8187,7 @@ function renderTextBody(textBody, transform) {
8041
8187
  if (line.segments.length === 0) {
8042
8188
  const dy = computeDy(
8043
8189
  isFirstLine,
8044
- defaultNaturalHeightPt,
8045
- getLineSpacing(para, lnSpcReduction),
8190
+ getLineHeightPx(para, defaultNaturalHeightPt, lnSpcReduction),
8046
8191
  lineGapPx
8047
8192
  );
8048
8193
  tspans.push(`<tspan x="${xPos}" dy="${dy}" text-anchor="${anchorValue}"> </tspan>`);
@@ -8058,11 +8203,22 @@ function renderTextBody(textBody, transform) {
8058
8203
  );
8059
8204
  const dy = computeDy(
8060
8205
  isFirstLine,
8061
- lineNaturalHeightPt,
8062
- getLineSpacing(para, lnSpcReduction),
8206
+ getLineHeightPx(para, lineNaturalHeightPt, lnSpcReduction),
8063
8207
  paragraphGapPx
8064
8208
  );
8065
- const bulletStyles = buildBulletStyleAttrs(para.properties, lineFontSize, fontScale);
8209
+ const firstSeg = line.segments[0];
8210
+ const bulletFontChain = buildBulletFontChain(
8211
+ para.properties,
8212
+ firstSeg?.properties.fontFamily,
8213
+ firstSeg?.properties.fontFamilyEa
8214
+ );
8215
+ const bulletStyles = buildBulletStyleAttrs(
8216
+ para.properties,
8217
+ lineFontSize,
8218
+ fontScale,
8219
+ bulletFontChain
8220
+ );
8221
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8066
8222
  tspans.push(
8067
8223
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8068
8224
  );
@@ -8082,8 +8238,7 @@ function renderTextBody(textBody, transform) {
8082
8238
  );
8083
8239
  const dy = computeDy(
8084
8240
  isFirstLine,
8085
- lineNaturalHeightPt,
8086
- getLineSpacing(para, lnSpcReduction),
8241
+ getLineHeightPx(para, lineNaturalHeightPt, lnSpcReduction),
8087
8242
  lineGapPx
8088
8243
  );
8089
8244
  const prefix = `x="${xPos}" dy="${dy}" text-anchor="${anchorValue}" `;
@@ -8103,11 +8258,21 @@ function renderTextBody(textBody, transform) {
8103
8258
  const naturalHeightPt = computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8104
8259
  const dy = computeDy(
8105
8260
  isFirstLine,
8106
- naturalHeightPt,
8107
- getLineSpacing(para, lnSpcReduction),
8261
+ getLineHeightPx(para, naturalHeightPt, lnSpcReduction),
8108
8262
  paragraphGapPx
8109
8263
  );
8110
- const bulletStyles = buildBulletStyleAttrs(para.properties, fontSize, fontScale);
8264
+ const bulletFontChain = buildBulletFontChain(
8265
+ para.properties,
8266
+ firstRun?.properties.fontFamily,
8267
+ firstRun?.properties.fontFamilyEa
8268
+ );
8269
+ const bulletStyles = buildBulletStyleAttrs(
8270
+ para.properties,
8271
+ fontSize,
8272
+ fontScale,
8273
+ bulletFontChain
8274
+ );
8275
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8111
8276
  tspans.push(
8112
8277
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8113
8278
  );
@@ -8123,8 +8288,7 @@ function renderTextBody(textBody, transform) {
8123
8288
  const naturalHeightPt = computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8124
8289
  const dy = computeDy(
8125
8290
  isFirstLine,
8126
- naturalHeightPt,
8127
- getLineSpacing(para, lnSpcReduction),
8291
+ getLineHeightPx(para, naturalHeightPt, lnSpcReduction),
8128
8292
  paragraphGapPx
8129
8293
  );
8130
8294
  const prefix = `x="${xPos}" dy="${dy}" text-anchor="${anchorValue}" `;
@@ -8242,14 +8406,18 @@ function toAlpha(num) {
8242
8406
  }
8243
8407
  return result;
8244
8408
  }
8245
- function buildBulletStyleAttrs(props, textFontSizePt, fontScale) {
8409
+ function buildBulletFontChain(props, runFontFamily, runFontFamilyEa) {
8410
+ return [props.bulletFont, runFontFamily ?? null, runFontFamilyEa ?? null];
8411
+ }
8412
+ function buildBulletStyleAttrs(props, textFontSizePt, fontScale, bulletFontChain) {
8246
8413
  const styles = [];
8247
8414
  if (props.bulletSizePct !== null) {
8248
8415
  const size = textFontSizePt * (props.bulletSizePct / 1e5);
8249
8416
  styles.push(`font-size="${size}pt"`);
8250
8417
  }
8251
- if (props.bulletFont) {
8252
- styles.push(`font-family="${escapeXml2(props.bulletFont)}"`);
8418
+ const fontFamilyValue = buildFontFamilyValue(bulletFontChain);
8419
+ if (fontFamilyValue) {
8420
+ styles.push(`font-family="${fontFamilyValue}"`);
8253
8421
  }
8254
8422
  if (props.bulletColor) {
8255
8423
  styles.push(`fill="${props.bulletColor.hex}"`);
@@ -8270,15 +8438,13 @@ function getAlignmentInfo(alignment, marginLeftPx, textWidth, width, marginRight
8270
8438
  return { xPos: marginLeftPx, anchorValue: "start" };
8271
8439
  }
8272
8440
  }
8273
- function getLineSpacing(para, lnSpcReduction = 0) {
8274
- let spacing;
8275
- if (para.properties.lineSpacing !== null) {
8276
- const factor = para.properties.lineSpacing / 1e5;
8277
- spacing = Math.max(0.5, factor);
8278
- } else {
8279
- spacing = DEFAULT_LINE_SPACING;
8441
+ function getLineHeightPx(para, naturalHeightPt, lnSpcReduction = 0) {
8442
+ const lineSpacing = para.properties.lineSpacing;
8443
+ if (lineSpacing?.type === "pts") {
8444
+ return lineSpacing.value / 100 * PX_PER_PT3 * (1 - lnSpcReduction);
8280
8445
  }
8281
- return spacing * (1 - lnSpcReduction);
8446
+ const factor = lineSpacing !== null ? Math.max(0.5, lineSpacing.value / 1e5) : DEFAULT_LINE_SPACING;
8447
+ return naturalHeightPt * PX_PER_PT3 * factor * (1 - lnSpcReduction);
8282
8448
  }
8283
8449
  function resolveSpacingPx(spacing, fontSizePt) {
8284
8450
  if (spacing.type === "pts") {
@@ -8297,11 +8463,9 @@ function getParagraphFontSize(para, defaultFontSize) {
8297
8463
  }
8298
8464
  return defaultFontSize;
8299
8465
  }
8300
- function computeDy(isFirstLine, fontSizePt, lineSpacingFactor, paragraphGapPx) {
8466
+ function computeDy(isFirstLine, lineHeightPx, paragraphGapPx) {
8301
8467
  if (isFirstLine) return "0";
8302
- const lineHeight = fontSizePt * PX_PER_PT3 * lineSpacingFactor;
8303
- const dy = lineHeight + paragraphGapPx;
8304
- return dy.toFixed(2);
8468
+ return (lineHeightPx + paragraphGapPx).toFixed(2);
8305
8469
  }
8306
8470
  function getLineFontSize(segments, defaultFontSize) {
8307
8471
  for (const seg of segments) {
@@ -8433,6 +8597,7 @@ function renderSegment(text, props, fontScale, prefix) {
8433
8597
  let tspanContent;
8434
8598
  if (!needsScriptSplit(props)) {
8435
8599
  const styles = buildStyleAttrs(props, fontScale);
8600
+ getFontUsageCollector()?.record([props.fontFamily, props.fontFamilyEa], text);
8436
8601
  tspanContent = `<tspan ${prefix}${styles}>${escapeXml2(text)}</tspan>`;
8437
8602
  } else {
8438
8603
  const parts = splitByScript(text);
@@ -8441,6 +8606,7 @@ function renderSegment(text, props, fontScale, prefix) {
8441
8606
  const part = parts[i];
8442
8607
  const fonts = part.isEa ? [props.fontFamilyEa, getJpanFallbackFont(), props.fontFamily] : [props.fontFamily, props.fontFamilyEa];
8443
8608
  const styles = buildStyleAttrs(props, fontScale, fonts);
8609
+ getFontUsageCollector()?.record(fonts, part.text);
8444
8610
  if (i === 0) {
8445
8611
  result.push(`<tspan ${prefix}${styles}>${escapeXml2(part.text)}</tspan>`);
8446
8612
  } else {
@@ -8535,10 +8701,13 @@ function estimateTextHeight(paragraphs, defaultFontSize, shouldWrap, textWidth,
8535
8701
  const scaledDefaultForWrap = defaultFontSize * fontScale;
8536
8702
  for (let pIdx = 0; pIdx < paragraphs.length; pIdx++) {
8537
8703
  const para = paragraphs[pIdx];
8538
- const lineSpacing = getLineSpacing(para, lnSpcReduction);
8539
8704
  const isEmpty = !para.runs.some((r) => r.text.length > 0);
8540
8705
  const naturalHeightPt = isEmpty && para.endParaRunProperties?.fontSize ? para.endParaRunProperties.fontSize * fontScale * defaultRatio : computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8541
- const lineHeight = (naturalHeightPt > 0 ? naturalHeightPt : defaultFontSize * fontScale * defaultRatio) * PX_PER_PT3 * lineSpacing;
8706
+ const lineHeight = getLineHeightPx(
8707
+ para,
8708
+ naturalHeightPt > 0 ? naturalHeightPt : defaultFontSize * fontScale * defaultRatio,
8709
+ lnSpcReduction
8710
+ );
8542
8711
  let lineCount;
8543
8712
  if (shouldWrap && para.runs.length > 0 && para.runs.some((r) => r.text.length > 0)) {
8544
8713
  const wrappedLines = wrapParagraph(para, textWidth, scaledDefaultForWrap, fontScale);
@@ -8805,7 +8974,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8805
8974
  if (para.runs.length === 0 || !para.runs.some((r) => r.text.length > 0)) {
8806
8975
  if (!isFirstLine) {
8807
8976
  const emptyParaHeightPt = paraFontSizePt > 0 ? paraFontSizePt : defaultNaturalHeightPt;
8808
- currentY += emptyParaHeightPt * PX_PER_PT3 * DEFAULT_LINE_SPACING + paragraphGapPx;
8977
+ currentY += getLineHeightPx(para, emptyParaHeightPt, lnSpcReduction) + paragraphGapPx;
8809
8978
  }
8810
8979
  isFirstLine = false;
8811
8980
  prevSpaceAfterPx = resolveSpacingPx(para.properties.spaceAfter, paraFontSizePt);
@@ -8823,7 +8992,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8823
8992
  const lineGapPx = lineIdx === 0 ? paragraphGapPx : 0;
8824
8993
  if (line.segments.length === 0) {
8825
8994
  if (!isFirstLine) {
8826
- currentY += defaultNaturalHeightPt * PX_PER_PT3 * getLineSpacing(para, lnSpcReduction) + lineGapPx;
8995
+ currentY += getLineHeightPx(para, defaultNaturalHeightPt, lnSpcReduction) + lineGapPx;
8827
8996
  }
8828
8997
  isFirstLine = false;
8829
8998
  continue;
@@ -8834,7 +9003,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8834
9003
  fontScale
8835
9004
  );
8836
9005
  if (!isFirstLine) {
8837
- currentY += lineNaturalHeightPt * PX_PER_PT3 * getLineSpacing(para, lnSpcReduction) + lineGapPx;
9006
+ currentY += getLineHeightPx(para, lineNaturalHeightPt, lnSpcReduction) + lineGapPx;
8838
9007
  }
8839
9008
  const lineWidth = measureLineWidth(line.segments, defaultFontSize, fontScale, fontResolver);
8840
9009
  const lineStartX = computePathLineX(
@@ -8882,7 +9051,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8882
9051
  } else {
8883
9052
  const naturalHeightPt = computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8884
9053
  if (!isFirstLine) {
8885
- currentY += naturalHeightPt * PX_PER_PT3 * getLineSpacing(para, lnSpcReduction) + paragraphGapPx;
9054
+ currentY += getLineHeightPx(para, naturalHeightPt, lnSpcReduction) + paragraphGapPx;
8886
9055
  }
8887
9056
  const runsAsSegments = para.runs.filter((r) => r.text.length > 0).map((r) => ({ text: r.text, properties: r.properties }));
8888
9057
  const lineWidth = measureLineWidth(runsAsSegments, defaultFontSize, fontScale, fontResolver);
@@ -9221,6 +9390,7 @@ function escapeXmlAttr(str) {
9221
9390
 
9222
9391
  // src/converter.ts
9223
9392
  async function convertPptxToSvg(input, options) {
9393
+ const textOutput = options?.textOutput ?? "path";
9224
9394
  const setup = await createOpentypeSetupFromSystem(
9225
9395
  options?.fontDirs,
9226
9396
  options?.fontMapping,
@@ -9228,7 +9398,13 @@ async function convertPptxToSvg(input, options) {
9228
9398
  );
9229
9399
  if (setup) {
9230
9400
  setTextMeasurer(setup.measurer);
9231
- setTextPathFontResolver(setup.fontResolver);
9401
+ if (textOutput !== "text") {
9402
+ setTextPathFontResolver(setup.fontResolver);
9403
+ }
9404
+ }
9405
+ const fontUsageCollector = textOutput === "text" ? new FontUsageCollector() : null;
9406
+ if (fontUsageCollector) {
9407
+ setFontUsageCollector(fontUsageCollector);
9232
9408
  }
9233
9409
  setFontMapping(createFontMapping(options?.fontMapping));
9234
9410
  enableXmlCache();
@@ -9247,7 +9423,14 @@ async function convertPptxToSvg(input, options) {
9247
9423
  const { slide, layoutElements, layoutShowMasterSp, masterElements } = parsed;
9248
9424
  const effectiveMasterElements = slide.showMasterSp && layoutShowMasterSp ? masterElements : [];
9249
9425
  slide.elements = mergeElements(effectiveMasterElements, layoutElements, slide.elements);
9250
- const svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9426
+ fontUsageCollector?.reset();
9427
+ let svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9428
+ if (fontUsageCollector && setup) {
9429
+ const style = await buildFontFaceStyle(fontUsageCollector.getUsages(), setup.fontResolver);
9430
+ if (style) {
9431
+ svg = injectIntoSvgDefs(svg, style);
9432
+ }
9433
+ }
9251
9434
  results.push({ slideNumber, svg });
9252
9435
  }
9253
9436
  flushWarnings();
@@ -9256,10 +9439,16 @@ async function convertPptxToSvg(input, options) {
9256
9439
  clearXmlCache();
9257
9440
  resetTextMeasurer();
9258
9441
  resetTextPathFontResolver();
9442
+ resetFontUsageCollector();
9259
9443
  resetFontMapping();
9260
9444
  resetScriptFonts();
9261
9445
  }
9262
9446
  }
9447
+ function injectIntoSvgDefs(svg, content) {
9448
+ const openTagEnd = svg.indexOf(">");
9449
+ if (openTagEnd === -1) return svg;
9450
+ return `${svg.slice(0, openTagEnd + 1)}<defs>${content}</defs>${svg.slice(openTagEnd + 1)}`;
9451
+ }
9263
9452
  var cachedFontBuffers = null;
9264
9453
  var cachedFontBuffersKey = null;
9265
9454
  var MAX_TOTAL_FONT_BUFFER_BYTES = 100 * 1024 * 1024;
@@ -9297,7 +9486,7 @@ ${skipSystemFonts ?? false}`;
9297
9486
  return buffers;
9298
9487
  }
9299
9488
  async function convertPptxToPng(input, options) {
9300
- const svgResults = await convertPptxToSvg(input, options);
9489
+ const svgResults = await convertPptxToSvg(input, { ...options, textOutput: "path" });
9301
9490
  const width = options?.width ?? DEFAULT_OUTPUT_WIDTH;
9302
9491
  const height = options?.height;
9303
9492
  const fontBuffers = loadFontBuffers(options?.fontDirs, options?.skipSystemFonts);
package/dist/index.d.cts CHANGED
@@ -48,6 +48,15 @@ interface ConvertOptions {
48
48
  fontMapping?: FontMapping;
49
49
  /** true のとき OS のシステムフォントをスキャンせず fontDirs のみを使用する */
50
50
  skipSystemFonts?: boolean;
51
+ /**
52
+ * SVG でのテキスト出力方式。デフォルト: "path"
53
+ * - "path": グリフをアウトライン化した <path> として出力する。フォント環境に依存しない
54
+ * - "text": ネイティブ <text> 要素 + サブセット化フォントの @font-face (data URI) 埋め込みで出力する。
55
+ * ブラウザでのインライン表示時にネイティブテキスト描画 (ヒンティング等) が効き、テキスト選択も可能になる。
56
+ * <img src="...svg"> 参照やサニタイズ環境では期待どおり描画されないことがある。
57
+ * convertPptxToPng では無視され、常に "path" で変換される (resvg は @font-face を解釈しないため)
58
+ */
59
+ textOutput?: "path" | "text";
51
60
  }
52
61
  interface SlideSvg {
53
62
  slideNumber: number;
package/dist/index.d.ts CHANGED
@@ -48,6 +48,15 @@ interface ConvertOptions {
48
48
  fontMapping?: FontMapping;
49
49
  /** true のとき OS のシステムフォントをスキャンせず fontDirs のみを使用する */
50
50
  skipSystemFonts?: boolean;
51
+ /**
52
+ * SVG でのテキスト出力方式。デフォルト: "path"
53
+ * - "path": グリフをアウトライン化した <path> として出力する。フォント環境に依存しない
54
+ * - "text": ネイティブ <text> 要素 + サブセット化フォントの @font-face (data URI) 埋め込みで出力する。
55
+ * ブラウザでのインライン表示時にネイティブテキスト描画 (ヒンティング等) が効き、テキスト選択も可能になる。
56
+ * <img src="...svg"> 参照やサニタイズ環境では期待どおり描画されないことがある。
57
+ * convertPptxToPng では無視され、常に "path" で変換される (resvg は @font-face を解釈しないため)
58
+ */
59
+ textOutput?: "path" | "text";
51
60
  }
52
61
  interface SlideSvg {
53
62
  slideNumber: number;
package/dist/index.js CHANGED
@@ -1,6 +1,189 @@
1
1
  // src/converter.ts
2
2
  import { readFileSync, statSync } from "fs";
3
3
 
4
+ // src/font/font-embedder.ts
5
+ import { Buffer as Buffer2 } from "buffer";
6
+
7
+ // src/warning-logger.ts
8
+ var PREFIX = "[pptx-glimpse]";
9
+ var currentLevel = "off";
10
+ var entries = [];
11
+ var featureCounts = /* @__PURE__ */ new Map();
12
+ function initWarningLogger(level) {
13
+ currentLevel = level;
14
+ entries = [];
15
+ featureCounts.clear();
16
+ }
17
+ function warn(feature, message, context) {
18
+ if (currentLevel === "off") return;
19
+ entries.push({ feature, message, ...context !== void 0 && { context } });
20
+ const existing = featureCounts.get(feature);
21
+ if (existing) {
22
+ existing.count++;
23
+ } else {
24
+ featureCounts.set(feature, { message, count: 1 });
25
+ }
26
+ if (currentLevel === "debug") {
27
+ const ctx = context ? ` (${context})` : "";
28
+ console.warn(`${PREFIX} SKIP: ${feature} - ${message}${ctx}`);
29
+ }
30
+ }
31
+ function debug(feature, message, context) {
32
+ if (currentLevel !== "debug") return;
33
+ entries.push({ feature, message, ...context !== void 0 && { context } });
34
+ const existing = featureCounts.get(feature);
35
+ if (existing) {
36
+ existing.count++;
37
+ } else {
38
+ featureCounts.set(feature, { message, count: 1 });
39
+ }
40
+ const ctx = context ? ` (${context})` : "";
41
+ console.warn(`${PREFIX} DEBUG: ${feature} - ${message}${ctx}`);
42
+ }
43
+ function getWarningSummary() {
44
+ const features = [];
45
+ for (const [feature, { message, count }] of featureCounts) {
46
+ features.push({ feature, message, count });
47
+ }
48
+ return { totalCount: entries.length, features };
49
+ }
50
+ function flushWarnings() {
51
+ const summary = getWarningSummary();
52
+ if (currentLevel !== "off" && summary.features.length > 0) {
53
+ console.warn(`${PREFIX} Summary: ${summary.features.length} unsupported feature(s) detected`);
54
+ for (const { feature, count } of summary.features) {
55
+ console.warn(` - ${feature}: ${count} occurrence(s)`);
56
+ }
57
+ }
58
+ entries = [];
59
+ featureCounts.clear();
60
+ return summary;
61
+ }
62
+ function getWarningEntries() {
63
+ return entries;
64
+ }
65
+
66
+ // src/font/font-subsetter.ts
67
+ async function tryLoadOpentypeCtors() {
68
+ try {
69
+ const specifier = "opentype.js";
70
+ const mod = await import(
71
+ /* @vite-ignore */
72
+ specifier
73
+ );
74
+ return { Font: mod.Font, Glyph: mod.Glyph };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+ function glyphName(glyph, firstUnicode) {
80
+ if (glyph.name) return glyph.name;
81
+ return `uni${firstUnicode.toString(16).toUpperCase().padStart(4, "0")}`;
82
+ }
83
+ async function subsetFont(font, chars, familyName) {
84
+ const opentype = await tryLoadOpentypeCtors();
85
+ if (!opentype) return null;
86
+ const source = font;
87
+ if (typeof source.charToGlyph !== "function" || !source.glyphs) return null;
88
+ const glyphMap = /* @__PURE__ */ new Map();
89
+ for (const char of chars) {
90
+ const codePoint = char.codePointAt(0);
91
+ if (codePoint === void 0) continue;
92
+ let glyph;
93
+ try {
94
+ glyph = source.charToGlyph(char);
95
+ } catch {
96
+ continue;
97
+ }
98
+ if (!glyph || !glyph.index) continue;
99
+ const entry = glyphMap.get(glyph.index);
100
+ if (entry) {
101
+ entry.unicodes.add(codePoint);
102
+ } else {
103
+ glyphMap.set(glyph.index, { glyph, unicodes: /* @__PURE__ */ new Set([codePoint]) });
104
+ }
105
+ }
106
+ if (glyphMap.size === 0) return null;
107
+ try {
108
+ const notdefSource = source.glyphs.get(0);
109
+ const glyphs = [
110
+ new opentype.Glyph({
111
+ name: ".notdef",
112
+ advanceWidth: notdefSource?.advanceWidth ?? source.unitsPerEm / 2,
113
+ path: notdefSource?.path
114
+ })
115
+ ];
116
+ for (const { glyph, unicodes } of glyphMap.values()) {
117
+ const unicodeList = [...unicodes].sort((a, b) => a - b);
118
+ glyphs.push(
119
+ new opentype.Glyph({
120
+ name: glyphName(glyph, unicodeList[0]),
121
+ unicode: unicodeList[0],
122
+ unicodes: unicodeList,
123
+ advanceWidth: glyph.advanceWidth ?? 0,
124
+ path: glyph.path
125
+ })
126
+ );
127
+ }
128
+ const subset = new opentype.Font({
129
+ familyName: familyName || "EmbeddedFont",
130
+ styleName: "Regular",
131
+ unitsPerEm: source.unitsPerEm,
132
+ ascender: source.ascender,
133
+ // opentype.js は descender に負値を要求する
134
+ descender: source.descender < 0 ? source.descender : -1,
135
+ glyphs
136
+ });
137
+ return new Uint8Array(subset.toArrayBuffer());
138
+ } catch (e) {
139
+ warn(
140
+ "font.subsetFailed",
141
+ `Failed to subset font "${familyName}": ${e instanceof Error ? e.message : String(e)}`
142
+ );
143
+ return null;
144
+ }
145
+ }
146
+
147
+ // src/font/script-font-context.ts
148
+ var jpanMajorFont = null;
149
+ var jpanMinorFont = null;
150
+ function setScriptFonts(majorJpan, minorJpan) {
151
+ jpanMajorFont = majorJpan;
152
+ jpanMinorFont = minorJpan;
153
+ }
154
+ function resetScriptFonts() {
155
+ jpanMajorFont = null;
156
+ jpanMinorFont = null;
157
+ }
158
+ function getJpanFallbackFont() {
159
+ return jpanMajorFont ?? jpanMinorFont;
160
+ }
161
+
162
+ // src/font/font-embedder.ts
163
+ function escapeCssFamilyName(name) {
164
+ return name.replace(/[\\"]/g, (c) => `\\${c}`).replace(/[<>&]/g, (c) => `\\${c.codePointAt(0).toString(16)} `);
165
+ }
166
+ async function buildFontFaceStyle(usages, fontResolver) {
167
+ const faces = [];
168
+ const jpanFallback = getJpanFallbackFont();
169
+ for (const [familyName, usage] of usages) {
170
+ const font = fontResolver.resolveFont(
171
+ usage.fonts[0],
172
+ usage.fonts[1] ?? null,
173
+ usage.fonts[2] ?? jpanFallback
174
+ );
175
+ if (!font) continue;
176
+ const buffer = await subsetFont(font, usage.chars, familyName);
177
+ if (!buffer) continue;
178
+ const base64 = Buffer2.from(buffer).toString("base64");
179
+ faces.push(
180
+ `@font-face{font-family:"${escapeCssFamilyName(familyName)}";src:url(data:font/otf;base64,${base64}) format("opentype");}`
181
+ );
182
+ }
183
+ if (faces.length === 0) return "";
184
+ return `<style type="text/css">${faces.join("")}</style>`;
185
+ }
186
+
4
187
  // src/font/font-mapping.ts
5
188
  var DEFAULT_FONT_MAPPING = {
6
189
  // ラテン文字フォント
@@ -67,6 +250,40 @@ function getCurrentMappedFont(fontFamily) {
67
250
  return getMappedFont(fontFamily, currentMapping);
68
251
  }
69
252
 
253
+ // src/font/font-usage-collector.ts
254
+ var FontUsageCollector = class {
255
+ /** key: tspan の font-family リスト先頭のフォント名 (= @font-face で宣言する名前) */
256
+ usages = /* @__PURE__ */ new Map();
257
+ record(fonts, text) {
258
+ const primary = fonts.find((f) => f !== null && f !== void 0);
259
+ if (!primary || text.length === 0) return;
260
+ let usage = this.usages.get(primary);
261
+ if (!usage) {
262
+ usage = { fonts: [...fonts], chars: /* @__PURE__ */ new Set() };
263
+ this.usages.set(primary, usage);
264
+ }
265
+ for (const char of text) {
266
+ usage.chars.add(char);
267
+ }
268
+ }
269
+ getUsages() {
270
+ return this.usages;
271
+ }
272
+ reset() {
273
+ this.usages.clear();
274
+ }
275
+ };
276
+ var currentCollector = null;
277
+ function setFontUsageCollector(collector) {
278
+ currentCollector = collector;
279
+ }
280
+ function getFontUsageCollector() {
281
+ return currentCollector;
282
+ }
283
+ function resetFontUsageCollector() {
284
+ currentCollector = null;
285
+ }
286
+
70
287
  // src/font/opentype-helpers.ts
71
288
  import { readFile } from "fs/promises";
72
289
 
@@ -1010,65 +1227,6 @@ function measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
1010
1227
  return totalWidth;
1011
1228
  }
1012
1229
 
1013
- // src/warning-logger.ts
1014
- var PREFIX = "[pptx-glimpse]";
1015
- var currentLevel = "off";
1016
- var entries = [];
1017
- var featureCounts = /* @__PURE__ */ new Map();
1018
- function initWarningLogger(level) {
1019
- currentLevel = level;
1020
- entries = [];
1021
- featureCounts.clear();
1022
- }
1023
- function warn(feature, message, context) {
1024
- if (currentLevel === "off") return;
1025
- entries.push({ feature, message, ...context !== void 0 && { context } });
1026
- const existing = featureCounts.get(feature);
1027
- if (existing) {
1028
- existing.count++;
1029
- } else {
1030
- featureCounts.set(feature, { message, count: 1 });
1031
- }
1032
- if (currentLevel === "debug") {
1033
- const ctx = context ? ` (${context})` : "";
1034
- console.warn(`${PREFIX} SKIP: ${feature} - ${message}${ctx}`);
1035
- }
1036
- }
1037
- function debug(feature, message, context) {
1038
- if (currentLevel !== "debug") return;
1039
- entries.push({ feature, message, ...context !== void 0 && { context } });
1040
- const existing = featureCounts.get(feature);
1041
- if (existing) {
1042
- existing.count++;
1043
- } else {
1044
- featureCounts.set(feature, { message, count: 1 });
1045
- }
1046
- const ctx = context ? ` (${context})` : "";
1047
- console.warn(`${PREFIX} DEBUG: ${feature} - ${message}${ctx}`);
1048
- }
1049
- function getWarningSummary() {
1050
- const features = [];
1051
- for (const [feature, { message, count }] of featureCounts) {
1052
- features.push({ feature, message, count });
1053
- }
1054
- return { totalCount: entries.length, features };
1055
- }
1056
- function flushWarnings() {
1057
- const summary = getWarningSummary();
1058
- if (currentLevel !== "off" && summary.features.length > 0) {
1059
- console.warn(`${PREFIX} Summary: ${summary.features.length} unsupported feature(s) detected`);
1060
- for (const { feature, count } of summary.features) {
1061
- console.warn(` - ${feature}: ${count} occurrence(s)`);
1062
- }
1063
- }
1064
- entries = [];
1065
- featureCounts.clear();
1066
- return summary;
1067
- }
1068
- function getWarningEntries() {
1069
- return entries;
1070
- }
1071
-
1072
1230
  // src/font/cjk-font-fallback.ts
1073
1231
  import { platform } from "os";
1074
1232
  var MACOS_FALLBACKS = {
@@ -1605,21 +1763,6 @@ async function createOpentypeSetupFromSystem(additionalFontDirs, fontMapping, sk
1605
1763
  return setup;
1606
1764
  }
1607
1765
 
1608
- // src/font/script-font-context.ts
1609
- var jpanMajorFont = null;
1610
- var jpanMinorFont = null;
1611
- function setScriptFonts(majorJpan, minorJpan) {
1612
- jpanMajorFont = majorJpan;
1613
- jpanMinorFont = minorJpan;
1614
- }
1615
- function resetScriptFonts() {
1616
- jpanMajorFont = null;
1617
- jpanMinorFont = null;
1618
- }
1619
- function getJpanFallbackFont() {
1620
- return jpanMajorFont ?? jpanMinorFont;
1621
- }
1622
-
1623
1766
  // src/font/text-measurer.ts
1624
1767
  var DefaultTextMeasurer = class {
1625
1768
  measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
@@ -4218,11 +4361,10 @@ function parseParagraph(p, colorResolver, rels, fontScheme, lstStyle, orderedPCh
4218
4361
  const lstLevelProps = lstStyle?.levels[level];
4219
4362
  const { bullet, bulletFont, bulletColor, bulletSizePct } = parseBullet(pPr, colorResolver);
4220
4363
  const lnSpc = pPr?.lnSpc;
4221
- const lnSpcSpcPct = lnSpc?.spcPct;
4222
4364
  const tabStops = parseTabStops(pPr);
4223
4365
  const properties = {
4224
4366
  alignment: pPr?.["@_algn"] ?? lstLevelProps?.alignment ?? null,
4225
- lineSpacing: lnSpcSpcPct ? Number(lnSpcSpcPct["@_val"]) : null,
4367
+ lineSpacing: lnSpc?.spcPts || lnSpc?.spcPct ? parseSpacing(lnSpc) : null,
4226
4368
  spaceBefore: parseSpacing(pPr?.spcBef),
4227
4369
  spaceAfter: parseSpacing(pPr?.spcAft),
4228
4370
  level,
@@ -7984,7 +8126,11 @@ function renderTextBody(textBody, transform) {
7984
8126
  const paragraphGapPx = Math.max(prevSpaceAfterPx, spaceBeforePx);
7985
8127
  if (para.runs.length === 0 || !para.runs.some((r) => r.text.length > 0)) {
7986
8128
  const emptyParaHeightPt = paraFontSizePt > 0 ? paraFontSizePt : defaultNaturalHeightPt;
7987
- const dy = computeDy(isFirstLine, emptyParaHeightPt, DEFAULT_LINE_SPACING, paragraphGapPx);
8129
+ const dy = computeDy(
8130
+ isFirstLine,
8131
+ getLineHeightPx(para, emptyParaHeightPt, lnSpcReduction),
8132
+ paragraphGapPx
8133
+ );
7988
8134
  tspans.push(`<tspan x="${xPos}" dy="${dy}" text-anchor="${anchorValue}"> </tspan>`);
7989
8135
  isFirstLine = false;
7990
8136
  prevSpaceAfterPx = resolveSpacingPx(para.properties.spaceAfter, paraFontSizePt);
@@ -8003,8 +8149,7 @@ function renderTextBody(textBody, transform) {
8003
8149
  if (line.segments.length === 0) {
8004
8150
  const dy = computeDy(
8005
8151
  isFirstLine,
8006
- defaultNaturalHeightPt,
8007
- getLineSpacing(para, lnSpcReduction),
8152
+ getLineHeightPx(para, defaultNaturalHeightPt, lnSpcReduction),
8008
8153
  lineGapPx
8009
8154
  );
8010
8155
  tspans.push(`<tspan x="${xPos}" dy="${dy}" text-anchor="${anchorValue}"> </tspan>`);
@@ -8020,11 +8165,22 @@ function renderTextBody(textBody, transform) {
8020
8165
  );
8021
8166
  const dy = computeDy(
8022
8167
  isFirstLine,
8023
- lineNaturalHeightPt,
8024
- getLineSpacing(para, lnSpcReduction),
8168
+ getLineHeightPx(para, lineNaturalHeightPt, lnSpcReduction),
8025
8169
  paragraphGapPx
8026
8170
  );
8027
- const bulletStyles = buildBulletStyleAttrs(para.properties, lineFontSize, fontScale);
8171
+ const firstSeg = line.segments[0];
8172
+ const bulletFontChain = buildBulletFontChain(
8173
+ para.properties,
8174
+ firstSeg?.properties.fontFamily,
8175
+ firstSeg?.properties.fontFamilyEa
8176
+ );
8177
+ const bulletStyles = buildBulletStyleAttrs(
8178
+ para.properties,
8179
+ lineFontSize,
8180
+ fontScale,
8181
+ bulletFontChain
8182
+ );
8183
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8028
8184
  tspans.push(
8029
8185
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8030
8186
  );
@@ -8044,8 +8200,7 @@ function renderTextBody(textBody, transform) {
8044
8200
  );
8045
8201
  const dy = computeDy(
8046
8202
  isFirstLine,
8047
- lineNaturalHeightPt,
8048
- getLineSpacing(para, lnSpcReduction),
8203
+ getLineHeightPx(para, lineNaturalHeightPt, lnSpcReduction),
8049
8204
  lineGapPx
8050
8205
  );
8051
8206
  const prefix = `x="${xPos}" dy="${dy}" text-anchor="${anchorValue}" `;
@@ -8065,11 +8220,21 @@ function renderTextBody(textBody, transform) {
8065
8220
  const naturalHeightPt = computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8066
8221
  const dy = computeDy(
8067
8222
  isFirstLine,
8068
- naturalHeightPt,
8069
- getLineSpacing(para, lnSpcReduction),
8223
+ getLineHeightPx(para, naturalHeightPt, lnSpcReduction),
8070
8224
  paragraphGapPx
8071
8225
  );
8072
- const bulletStyles = buildBulletStyleAttrs(para.properties, fontSize, fontScale);
8226
+ const bulletFontChain = buildBulletFontChain(
8227
+ para.properties,
8228
+ firstRun?.properties.fontFamily,
8229
+ firstRun?.properties.fontFamilyEa
8230
+ );
8231
+ const bulletStyles = buildBulletStyleAttrs(
8232
+ para.properties,
8233
+ fontSize,
8234
+ fontScale,
8235
+ bulletFontChain
8236
+ );
8237
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8073
8238
  tspans.push(
8074
8239
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8075
8240
  );
@@ -8085,8 +8250,7 @@ function renderTextBody(textBody, transform) {
8085
8250
  const naturalHeightPt = computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8086
8251
  const dy = computeDy(
8087
8252
  isFirstLine,
8088
- naturalHeightPt,
8089
- getLineSpacing(para, lnSpcReduction),
8253
+ getLineHeightPx(para, naturalHeightPt, lnSpcReduction),
8090
8254
  paragraphGapPx
8091
8255
  );
8092
8256
  const prefix = `x="${xPos}" dy="${dy}" text-anchor="${anchorValue}" `;
@@ -8204,14 +8368,18 @@ function toAlpha(num) {
8204
8368
  }
8205
8369
  return result;
8206
8370
  }
8207
- function buildBulletStyleAttrs(props, textFontSizePt, fontScale) {
8371
+ function buildBulletFontChain(props, runFontFamily, runFontFamilyEa) {
8372
+ return [props.bulletFont, runFontFamily ?? null, runFontFamilyEa ?? null];
8373
+ }
8374
+ function buildBulletStyleAttrs(props, textFontSizePt, fontScale, bulletFontChain) {
8208
8375
  const styles = [];
8209
8376
  if (props.bulletSizePct !== null) {
8210
8377
  const size = textFontSizePt * (props.bulletSizePct / 1e5);
8211
8378
  styles.push(`font-size="${size}pt"`);
8212
8379
  }
8213
- if (props.bulletFont) {
8214
- styles.push(`font-family="${escapeXml2(props.bulletFont)}"`);
8380
+ const fontFamilyValue = buildFontFamilyValue(bulletFontChain);
8381
+ if (fontFamilyValue) {
8382
+ styles.push(`font-family="${fontFamilyValue}"`);
8215
8383
  }
8216
8384
  if (props.bulletColor) {
8217
8385
  styles.push(`fill="${props.bulletColor.hex}"`);
@@ -8232,15 +8400,13 @@ function getAlignmentInfo(alignment, marginLeftPx, textWidth, width, marginRight
8232
8400
  return { xPos: marginLeftPx, anchorValue: "start" };
8233
8401
  }
8234
8402
  }
8235
- function getLineSpacing(para, lnSpcReduction = 0) {
8236
- let spacing;
8237
- if (para.properties.lineSpacing !== null) {
8238
- const factor = para.properties.lineSpacing / 1e5;
8239
- spacing = Math.max(0.5, factor);
8240
- } else {
8241
- spacing = DEFAULT_LINE_SPACING;
8403
+ function getLineHeightPx(para, naturalHeightPt, lnSpcReduction = 0) {
8404
+ const lineSpacing = para.properties.lineSpacing;
8405
+ if (lineSpacing?.type === "pts") {
8406
+ return lineSpacing.value / 100 * PX_PER_PT3 * (1 - lnSpcReduction);
8242
8407
  }
8243
- return spacing * (1 - lnSpcReduction);
8408
+ const factor = lineSpacing !== null ? Math.max(0.5, lineSpacing.value / 1e5) : DEFAULT_LINE_SPACING;
8409
+ return naturalHeightPt * PX_PER_PT3 * factor * (1 - lnSpcReduction);
8244
8410
  }
8245
8411
  function resolveSpacingPx(spacing, fontSizePt) {
8246
8412
  if (spacing.type === "pts") {
@@ -8259,11 +8425,9 @@ function getParagraphFontSize(para, defaultFontSize) {
8259
8425
  }
8260
8426
  return defaultFontSize;
8261
8427
  }
8262
- function computeDy(isFirstLine, fontSizePt, lineSpacingFactor, paragraphGapPx) {
8428
+ function computeDy(isFirstLine, lineHeightPx, paragraphGapPx) {
8263
8429
  if (isFirstLine) return "0";
8264
- const lineHeight = fontSizePt * PX_PER_PT3 * lineSpacingFactor;
8265
- const dy = lineHeight + paragraphGapPx;
8266
- return dy.toFixed(2);
8430
+ return (lineHeightPx + paragraphGapPx).toFixed(2);
8267
8431
  }
8268
8432
  function getLineFontSize(segments, defaultFontSize) {
8269
8433
  for (const seg of segments) {
@@ -8395,6 +8559,7 @@ function renderSegment(text, props, fontScale, prefix) {
8395
8559
  let tspanContent;
8396
8560
  if (!needsScriptSplit(props)) {
8397
8561
  const styles = buildStyleAttrs(props, fontScale);
8562
+ getFontUsageCollector()?.record([props.fontFamily, props.fontFamilyEa], text);
8398
8563
  tspanContent = `<tspan ${prefix}${styles}>${escapeXml2(text)}</tspan>`;
8399
8564
  } else {
8400
8565
  const parts = splitByScript(text);
@@ -8403,6 +8568,7 @@ function renderSegment(text, props, fontScale, prefix) {
8403
8568
  const part = parts[i];
8404
8569
  const fonts = part.isEa ? [props.fontFamilyEa, getJpanFallbackFont(), props.fontFamily] : [props.fontFamily, props.fontFamilyEa];
8405
8570
  const styles = buildStyleAttrs(props, fontScale, fonts);
8571
+ getFontUsageCollector()?.record(fonts, part.text);
8406
8572
  if (i === 0) {
8407
8573
  result.push(`<tspan ${prefix}${styles}>${escapeXml2(part.text)}</tspan>`);
8408
8574
  } else {
@@ -8497,10 +8663,13 @@ function estimateTextHeight(paragraphs, defaultFontSize, shouldWrap, textWidth,
8497
8663
  const scaledDefaultForWrap = defaultFontSize * fontScale;
8498
8664
  for (let pIdx = 0; pIdx < paragraphs.length; pIdx++) {
8499
8665
  const para = paragraphs[pIdx];
8500
- const lineSpacing = getLineSpacing(para, lnSpcReduction);
8501
8666
  const isEmpty = !para.runs.some((r) => r.text.length > 0);
8502
8667
  const naturalHeightPt = isEmpty && para.endParaRunProperties?.fontSize ? para.endParaRunProperties.fontSize * fontScale * defaultRatio : computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8503
- const lineHeight = (naturalHeightPt > 0 ? naturalHeightPt : defaultFontSize * fontScale * defaultRatio) * PX_PER_PT3 * lineSpacing;
8668
+ const lineHeight = getLineHeightPx(
8669
+ para,
8670
+ naturalHeightPt > 0 ? naturalHeightPt : defaultFontSize * fontScale * defaultRatio,
8671
+ lnSpcReduction
8672
+ );
8504
8673
  let lineCount;
8505
8674
  if (shouldWrap && para.runs.length > 0 && para.runs.some((r) => r.text.length > 0)) {
8506
8675
  const wrappedLines = wrapParagraph(para, textWidth, scaledDefaultForWrap, fontScale);
@@ -8767,7 +8936,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8767
8936
  if (para.runs.length === 0 || !para.runs.some((r) => r.text.length > 0)) {
8768
8937
  if (!isFirstLine) {
8769
8938
  const emptyParaHeightPt = paraFontSizePt > 0 ? paraFontSizePt : defaultNaturalHeightPt;
8770
- currentY += emptyParaHeightPt * PX_PER_PT3 * DEFAULT_LINE_SPACING + paragraphGapPx;
8939
+ currentY += getLineHeightPx(para, emptyParaHeightPt, lnSpcReduction) + paragraphGapPx;
8771
8940
  }
8772
8941
  isFirstLine = false;
8773
8942
  prevSpaceAfterPx = resolveSpacingPx(para.properties.spaceAfter, paraFontSizePt);
@@ -8785,7 +8954,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8785
8954
  const lineGapPx = lineIdx === 0 ? paragraphGapPx : 0;
8786
8955
  if (line.segments.length === 0) {
8787
8956
  if (!isFirstLine) {
8788
- currentY += defaultNaturalHeightPt * PX_PER_PT3 * getLineSpacing(para, lnSpcReduction) + lineGapPx;
8957
+ currentY += getLineHeightPx(para, defaultNaturalHeightPt, lnSpcReduction) + lineGapPx;
8789
8958
  }
8790
8959
  isFirstLine = false;
8791
8960
  continue;
@@ -8796,7 +8965,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8796
8965
  fontScale
8797
8966
  );
8798
8967
  if (!isFirstLine) {
8799
- currentY += lineNaturalHeightPt * PX_PER_PT3 * getLineSpacing(para, lnSpcReduction) + lineGapPx;
8968
+ currentY += getLineHeightPx(para, lineNaturalHeightPt, lnSpcReduction) + lineGapPx;
8800
8969
  }
8801
8970
  const lineWidth = measureLineWidth(line.segments, defaultFontSize, fontScale, fontResolver);
8802
8971
  const lineStartX = computePathLineX(
@@ -8844,7 +9013,7 @@ function renderTextBodyAsPath(textBody, transform, fontResolver) {
8844
9013
  } else {
8845
9014
  const naturalHeightPt = computeLineNaturalHeight(para.runs, defaultFontSize, fontScale);
8846
9015
  if (!isFirstLine) {
8847
- currentY += naturalHeightPt * PX_PER_PT3 * getLineSpacing(para, lnSpcReduction) + paragraphGapPx;
9016
+ currentY += getLineHeightPx(para, naturalHeightPt, lnSpcReduction) + paragraphGapPx;
8848
9017
  }
8849
9018
  const runsAsSegments = para.runs.filter((r) => r.text.length > 0).map((r) => ({ text: r.text, properties: r.properties }));
8850
9019
  const lineWidth = measureLineWidth(runsAsSegments, defaultFontSize, fontScale, fontResolver);
@@ -9183,6 +9352,7 @@ function escapeXmlAttr(str) {
9183
9352
 
9184
9353
  // src/converter.ts
9185
9354
  async function convertPptxToSvg(input, options) {
9355
+ const textOutput = options?.textOutput ?? "path";
9186
9356
  const setup = await createOpentypeSetupFromSystem(
9187
9357
  options?.fontDirs,
9188
9358
  options?.fontMapping,
@@ -9190,7 +9360,13 @@ async function convertPptxToSvg(input, options) {
9190
9360
  );
9191
9361
  if (setup) {
9192
9362
  setTextMeasurer(setup.measurer);
9193
- setTextPathFontResolver(setup.fontResolver);
9363
+ if (textOutput !== "text") {
9364
+ setTextPathFontResolver(setup.fontResolver);
9365
+ }
9366
+ }
9367
+ const fontUsageCollector = textOutput === "text" ? new FontUsageCollector() : null;
9368
+ if (fontUsageCollector) {
9369
+ setFontUsageCollector(fontUsageCollector);
9194
9370
  }
9195
9371
  setFontMapping(createFontMapping(options?.fontMapping));
9196
9372
  enableXmlCache();
@@ -9209,7 +9385,14 @@ async function convertPptxToSvg(input, options) {
9209
9385
  const { slide, layoutElements, layoutShowMasterSp, masterElements } = parsed;
9210
9386
  const effectiveMasterElements = slide.showMasterSp && layoutShowMasterSp ? masterElements : [];
9211
9387
  slide.elements = mergeElements(effectiveMasterElements, layoutElements, slide.elements);
9212
- const svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9388
+ fontUsageCollector?.reset();
9389
+ let svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9390
+ if (fontUsageCollector && setup) {
9391
+ const style = await buildFontFaceStyle(fontUsageCollector.getUsages(), setup.fontResolver);
9392
+ if (style) {
9393
+ svg = injectIntoSvgDefs(svg, style);
9394
+ }
9395
+ }
9213
9396
  results.push({ slideNumber, svg });
9214
9397
  }
9215
9398
  flushWarnings();
@@ -9218,10 +9401,16 @@ async function convertPptxToSvg(input, options) {
9218
9401
  clearXmlCache();
9219
9402
  resetTextMeasurer();
9220
9403
  resetTextPathFontResolver();
9404
+ resetFontUsageCollector();
9221
9405
  resetFontMapping();
9222
9406
  resetScriptFonts();
9223
9407
  }
9224
9408
  }
9409
+ function injectIntoSvgDefs(svg, content) {
9410
+ const openTagEnd = svg.indexOf(">");
9411
+ if (openTagEnd === -1) return svg;
9412
+ return `${svg.slice(0, openTagEnd + 1)}<defs>${content}</defs>${svg.slice(openTagEnd + 1)}`;
9413
+ }
9225
9414
  var cachedFontBuffers = null;
9226
9415
  var cachedFontBuffersKey = null;
9227
9416
  var MAX_TOTAL_FONT_BUFFER_BYTES = 100 * 1024 * 1024;
@@ -9259,7 +9448,7 @@ ${skipSystemFonts ?? false}`;
9259
9448
  return buffers;
9260
9449
  }
9261
9450
  async function convertPptxToPng(input, options) {
9262
- const svgResults = await convertPptxToSvg(input, options);
9451
+ const svgResults = await convertPptxToSvg(input, { ...options, textOutput: "path" });
9263
9452
  const width = options?.width ?? DEFAULT_OUTPUT_WIDTH;
9264
9453
  const height = options?.height;
9265
9454
  const fontBuffers = loadFontBuffers(options?.fontDirs, options?.skipSystemFonts);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pptx-glimpse",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "A lightweight JavaScript library for rendering PowerPoint (.pptx) files as SVG or PNG in Node.js. No LibreOffice required.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",