pptx-glimpse 1.0.0 → 1.1.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.
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
@@ -35,6 +35,192 @@ __export(index_exports, {
35
35
  });
36
36
  module.exports = __toCommonJS(index_exports);
37
37
 
38
+ // src/converter.ts
39
+ var import_node_fs2 = require("fs");
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
+
38
224
  // src/font/font-mapping.ts
39
225
  var DEFAULT_FONT_MAPPING = {
40
226
  // ラテン文字フォント
@@ -101,6 +287,40 @@ function getCurrentMappedFont(fontFamily) {
101
287
  return getMappedFont(fontFamily, currentMapping);
102
288
  }
103
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
+
104
324
  // src/font/opentype-helpers.ts
105
325
  var import_promises = require("fs/promises");
106
326
 
@@ -1044,65 +1264,6 @@ function measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
1044
1264
  return totalWidth;
1045
1265
  }
1046
1266
 
1047
- // src/warning-logger.ts
1048
- var PREFIX = "[pptx-glimpse]";
1049
- var currentLevel = "off";
1050
- var entries = [];
1051
- var featureCounts = /* @__PURE__ */ new Map();
1052
- function initWarningLogger(level) {
1053
- currentLevel = level;
1054
- entries = [];
1055
- featureCounts.clear();
1056
- }
1057
- function warn(feature, message, context) {
1058
- if (currentLevel === "off") return;
1059
- entries.push({ feature, message, ...context !== void 0 && { context } });
1060
- const existing = featureCounts.get(feature);
1061
- if (existing) {
1062
- existing.count++;
1063
- } else {
1064
- featureCounts.set(feature, { message, count: 1 });
1065
- }
1066
- if (currentLevel === "debug") {
1067
- const ctx = context ? ` (${context})` : "";
1068
- console.warn(`${PREFIX} SKIP: ${feature} - ${message}${ctx}`);
1069
- }
1070
- }
1071
- function debug(feature, message, context) {
1072
- if (currentLevel !== "debug") return;
1073
- entries.push({ feature, message, ...context !== void 0 && { context } });
1074
- const existing = featureCounts.get(feature);
1075
- if (existing) {
1076
- existing.count++;
1077
- } else {
1078
- featureCounts.set(feature, { message, count: 1 });
1079
- }
1080
- const ctx = context ? ` (${context})` : "";
1081
- console.warn(`${PREFIX} DEBUG: ${feature} - ${message}${ctx}`);
1082
- }
1083
- function getWarningSummary() {
1084
- const features = [];
1085
- for (const [feature, { message, count }] of featureCounts) {
1086
- features.push({ feature, message, count });
1087
- }
1088
- return { totalCount: entries.length, features };
1089
- }
1090
- function flushWarnings() {
1091
- const summary = getWarningSummary();
1092
- if (currentLevel !== "off" && summary.features.length > 0) {
1093
- console.warn(`${PREFIX} Summary: ${summary.features.length} unsupported feature(s) detected`);
1094
- for (const { feature, count } of summary.features) {
1095
- console.warn(` - ${feature}: ${count} occurrence(s)`);
1096
- }
1097
- }
1098
- entries = [];
1099
- featureCounts.clear();
1100
- return summary;
1101
- }
1102
- function getWarningEntries() {
1103
- return entries;
1104
- }
1105
-
1106
1267
  // src/font/cjk-font-fallback.ts
1107
1268
  var import_node_os = require("os");
1108
1269
  var MACOS_FALLBACKS = {
@@ -1639,21 +1800,6 @@ async function createOpentypeSetupFromSystem(additionalFontDirs, fontMapping, sk
1639
1800
  return setup;
1640
1801
  }
1641
1802
 
1642
- // src/font/script-font-context.ts
1643
- var jpanMajorFont = null;
1644
- var jpanMinorFont = null;
1645
- function setScriptFonts(majorJpan, minorJpan) {
1646
- jpanMajorFont = majorJpan;
1647
- jpanMinorFont = minorJpan;
1648
- }
1649
- function resetScriptFonts() {
1650
- jpanMajorFont = null;
1651
- jpanMinorFont = null;
1652
- }
1653
- function getJpanFallbackFont() {
1654
- return jpanMajorFont ?? jpanMinorFont;
1655
- }
1656
-
1657
1803
  // src/font/text-measurer.ts
1658
1804
  var DefaultTextMeasurer = class {
1659
1805
  measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
@@ -1801,6 +1947,10 @@ async function svgToPng(svgString, options) {
1801
1947
  } else if (options?.height) {
1802
1948
  resvgOptions.fitTo = { mode: "height", value: options.height };
1803
1949
  }
1950
+ const fontBuffers = options?.fontBuffers;
1951
+ if (fontBuffers && fontBuffers.length > 0) {
1952
+ resvgOptions.font = { fontBuffers };
1953
+ }
1804
1954
  const resvg = new import_resvg_wasm.Resvg(svgString, resvgOptions);
1805
1955
  const rendered = resvg.render();
1806
1956
  return {
@@ -2760,7 +2910,7 @@ function parseChartTitle(titleNode) {
2760
2910
  function parseLegend(legendNode) {
2761
2911
  if (!legendNode) return null;
2762
2912
  const legendPos = legendNode.legendPos;
2763
- const pos = legendPos?.["@_val"] ?? "b";
2913
+ const pos = legendPos?.["@_val"] ?? "r";
2764
2914
  return { position: pos };
2765
2915
  }
2766
2916
 
@@ -5417,6 +5567,7 @@ var DEFAULT_SERIES_COLORS = [
5417
5567
  { hex: "#5B9BD5", alpha: 1 },
5418
5568
  { hex: "#70AD47", alpha: 1 }
5419
5569
  ];
5570
+ var LEGEND_SIDE_WIDTH = 100;
5420
5571
  function renderChart(element) {
5421
5572
  const { transform, chart } = element;
5422
5573
  const w = emuToPixels(transform.extentWidth);
@@ -5435,6 +5586,8 @@ function renderChart(element) {
5435
5586
  if (chart.legend) {
5436
5587
  if (chart.legend.position === "b") margin.bottom = 50;
5437
5588
  else if (chart.legend.position === "t") margin.top += 20;
5589
+ else if (chart.legend.position === "r") margin.right = LEGEND_SIDE_WIDTH;
5590
+ else if (chart.legend.position === "l") margin.left += LEGEND_SIDE_WIDTH;
5438
5591
  }
5439
5592
  const plotX = margin.left;
5440
5593
  const plotY = margin.top;
@@ -6189,18 +6342,34 @@ function renderLegend(chart, chartW, chartH, position) {
6189
6342
  color: s.color
6190
6343
  }));
6191
6344
  if (entries2.length === 0) return "";
6192
- const entryWidth = 80;
6193
- const totalWidth = entries2.length * entryWidth;
6194
- const startX = Math.max((chartW - totalWidth) / 2, 5);
6195
- const legendY = position === "t" ? 25 : chartH - 15;
6196
- for (let i = 0; i < entries2.length; i++) {
6197
- const ex = startX + i * entryWidth;
6198
- parts.push(
6199
- `<rect x="${round(ex)}" y="${round(legendY - 6)}" width="12" height="12" ${fillAttr(entries2[i].color)}/>`
6200
- );
6201
- parts.push(
6202
- `<text x="${round(ex + 16)}" y="${round(legendY + 4)}" font-size="10" fill="#595959">${escapeXml(entries2[i].label)}</text>`
6203
- );
6345
+ const ENTRY_HEIGHT = 20;
6346
+ if (position === "r" || position === "l") {
6347
+ const totalH = entries2.length * ENTRY_HEIGHT;
6348
+ const startY = Math.max((chartH - totalH) / 2, 5);
6349
+ const legendX = position === "r" ? chartW - LEGEND_SIDE_WIDTH + 5 : 5;
6350
+ for (let i = 0; i < entries2.length; i++) {
6351
+ const ey = startY + i * ENTRY_HEIGHT;
6352
+ parts.push(
6353
+ `<rect x="${round(legendX)}" y="${round(ey)}" width="12" height="12" ${fillAttr(entries2[i].color)}/>`
6354
+ );
6355
+ parts.push(
6356
+ `<text x="${round(legendX + 16)}" y="${round(ey + 10)}" font-size="10" fill="#595959">${escapeXml(entries2[i].label)}</text>`
6357
+ );
6358
+ }
6359
+ } else {
6360
+ const entryWidth = 80;
6361
+ const totalWidth = entries2.length * entryWidth;
6362
+ const startX = Math.max((chartW - totalWidth) / 2, 5);
6363
+ const legendY = position === "t" ? 25 : chartH - 15;
6364
+ for (let i = 0; i < entries2.length; i++) {
6365
+ const ex = startX + i * entryWidth;
6366
+ parts.push(
6367
+ `<rect x="${round(ex)}" y="${round(legendY - 6)}" width="12" height="12" ${fillAttr(entries2[i].color)}/>`
6368
+ );
6369
+ parts.push(
6370
+ `<text x="${round(ex + 16)}" y="${round(legendY + 4)}" font-size="10" fill="#595959">${escapeXml(entries2[i].label)}</text>`
6371
+ );
6372
+ }
6204
6373
  }
6205
6374
  return parts.join("");
6206
6375
  }
@@ -8036,7 +8205,19 @@ function renderTextBody(textBody, transform) {
8036
8205
  getLineSpacing(para, lnSpcReduction),
8037
8206
  paragraphGapPx
8038
8207
  );
8039
- const bulletStyles = buildBulletStyleAttrs(para.properties, lineFontSize, fontScale);
8208
+ const firstSeg = line.segments[0];
8209
+ const bulletFontChain = buildBulletFontChain(
8210
+ para.properties,
8211
+ firstSeg?.properties.fontFamily,
8212
+ firstSeg?.properties.fontFamilyEa
8213
+ );
8214
+ const bulletStyles = buildBulletStyleAttrs(
8215
+ para.properties,
8216
+ lineFontSize,
8217
+ fontScale,
8218
+ bulletFontChain
8219
+ );
8220
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8040
8221
  tspans.push(
8041
8222
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8042
8223
  );
@@ -8081,7 +8262,18 @@ function renderTextBody(textBody, transform) {
8081
8262
  getLineSpacing(para, lnSpcReduction),
8082
8263
  paragraphGapPx
8083
8264
  );
8084
- const bulletStyles = buildBulletStyleAttrs(para.properties, fontSize, fontScale);
8265
+ const bulletFontChain = buildBulletFontChain(
8266
+ para.properties,
8267
+ firstRun?.properties.fontFamily,
8268
+ firstRun?.properties.fontFamilyEa
8269
+ );
8270
+ const bulletStyles = buildBulletStyleAttrs(
8271
+ para.properties,
8272
+ fontSize,
8273
+ fontScale,
8274
+ bulletFontChain
8275
+ );
8276
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8085
8277
  tspans.push(
8086
8278
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8087
8279
  );
@@ -8216,14 +8408,18 @@ function toAlpha(num) {
8216
8408
  }
8217
8409
  return result;
8218
8410
  }
8219
- function buildBulletStyleAttrs(props, textFontSizePt, fontScale) {
8411
+ function buildBulletFontChain(props, runFontFamily, runFontFamilyEa) {
8412
+ return [props.bulletFont, runFontFamily ?? null, runFontFamilyEa ?? null];
8413
+ }
8414
+ function buildBulletStyleAttrs(props, textFontSizePt, fontScale, bulletFontChain) {
8220
8415
  const styles = [];
8221
8416
  if (props.bulletSizePct !== null) {
8222
8417
  const size = textFontSizePt * (props.bulletSizePct / 1e5);
8223
8418
  styles.push(`font-size="${size}pt"`);
8224
8419
  }
8225
- if (props.bulletFont) {
8226
- styles.push(`font-family="${escapeXml2(props.bulletFont)}"`);
8420
+ const fontFamilyValue = buildFontFamilyValue(bulletFontChain);
8421
+ if (fontFamilyValue) {
8422
+ styles.push(`font-family="${fontFamilyValue}"`);
8227
8423
  }
8228
8424
  if (props.bulletColor) {
8229
8425
  styles.push(`fill="${props.bulletColor.hex}"`);
@@ -8407,6 +8603,7 @@ function renderSegment(text, props, fontScale, prefix) {
8407
8603
  let tspanContent;
8408
8604
  if (!needsScriptSplit(props)) {
8409
8605
  const styles = buildStyleAttrs(props, fontScale);
8606
+ getFontUsageCollector()?.record([props.fontFamily, props.fontFamilyEa], text);
8410
8607
  tspanContent = `<tspan ${prefix}${styles}>${escapeXml2(text)}</tspan>`;
8411
8608
  } else {
8412
8609
  const parts = splitByScript(text);
@@ -8415,6 +8612,7 @@ function renderSegment(text, props, fontScale, prefix) {
8415
8612
  const part = parts[i];
8416
8613
  const fonts = part.isEa ? [props.fontFamilyEa, getJpanFallbackFont(), props.fontFamily] : [props.fontFamily, props.fontFamilyEa];
8417
8614
  const styles = buildStyleAttrs(props, fontScale, fonts);
8615
+ getFontUsageCollector()?.record(fonts, part.text);
8418
8616
  if (i === 0) {
8419
8617
  result.push(`<tspan ${prefix}${styles}>${escapeXml2(part.text)}</tspan>`);
8420
8618
  } else {
@@ -9195,6 +9393,7 @@ function escapeXmlAttr(str) {
9195
9393
 
9196
9394
  // src/converter.ts
9197
9395
  async function convertPptxToSvg(input, options) {
9396
+ const textOutput = options?.textOutput ?? "path";
9198
9397
  const setup = await createOpentypeSetupFromSystem(
9199
9398
  options?.fontDirs,
9200
9399
  options?.fontMapping,
@@ -9202,7 +9401,13 @@ async function convertPptxToSvg(input, options) {
9202
9401
  );
9203
9402
  if (setup) {
9204
9403
  setTextMeasurer(setup.measurer);
9205
- setTextPathFontResolver(setup.fontResolver);
9404
+ if (textOutput !== "text") {
9405
+ setTextPathFontResolver(setup.fontResolver);
9406
+ }
9407
+ }
9408
+ const fontUsageCollector = textOutput === "text" ? new FontUsageCollector() : null;
9409
+ if (fontUsageCollector) {
9410
+ setFontUsageCollector(fontUsageCollector);
9206
9411
  }
9207
9412
  setFontMapping(createFontMapping(options?.fontMapping));
9208
9413
  enableXmlCache();
@@ -9221,7 +9426,14 @@ async function convertPptxToSvg(input, options) {
9221
9426
  const { slide, layoutElements, layoutShowMasterSp, masterElements } = parsed;
9222
9427
  const effectiveMasterElements = slide.showMasterSp && layoutShowMasterSp ? masterElements : [];
9223
9428
  slide.elements = mergeElements(effectiveMasterElements, layoutElements, slide.elements);
9224
- const svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9429
+ fontUsageCollector?.reset();
9430
+ let svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9431
+ if (fontUsageCollector && setup) {
9432
+ const style = await buildFontFaceStyle(fontUsageCollector.getUsages(), setup.fontResolver);
9433
+ if (style) {
9434
+ svg = injectIntoSvgDefs(svg, style);
9435
+ }
9436
+ }
9225
9437
  results.push({ slideNumber, svg });
9226
9438
  }
9227
9439
  flushWarnings();
@@ -9230,17 +9442,60 @@ async function convertPptxToSvg(input, options) {
9230
9442
  clearXmlCache();
9231
9443
  resetTextMeasurer();
9232
9444
  resetTextPathFontResolver();
9445
+ resetFontUsageCollector();
9233
9446
  resetFontMapping();
9234
9447
  resetScriptFonts();
9235
9448
  }
9236
9449
  }
9450
+ function injectIntoSvgDefs(svg, content) {
9451
+ const openTagEnd = svg.indexOf(">");
9452
+ if (openTagEnd === -1) return svg;
9453
+ return `${svg.slice(0, openTagEnd + 1)}<defs>${content}</defs>${svg.slice(openTagEnd + 1)}`;
9454
+ }
9455
+ var cachedFontBuffers = null;
9456
+ var cachedFontBuffersKey = null;
9457
+ var MAX_TOTAL_FONT_BUFFER_BYTES = 100 * 1024 * 1024;
9458
+ function loadFontBuffers(fontDirs, skipSystemFonts) {
9459
+ const key = `${(fontDirs ?? []).join("\0")}
9460
+ ${skipSystemFonts ?? false}`;
9461
+ if (cachedFontBuffers !== null && cachedFontBuffersKey === key) {
9462
+ return cachedFontBuffers;
9463
+ }
9464
+ const allPaths = collectFontFilePaths(fontDirs, skipSystemFonts);
9465
+ const ttfOtfPaths = allPaths.filter((p) => {
9466
+ const lower = p.toLowerCase();
9467
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
9468
+ });
9469
+ const pathsWithSize = [];
9470
+ for (const p of ttfOtfPaths) {
9471
+ try {
9472
+ pathsWithSize.push({ path: p, size: (0, import_node_fs2.statSync)(p).size });
9473
+ } catch {
9474
+ }
9475
+ }
9476
+ pathsWithSize.sort((a, b) => a.size - b.size);
9477
+ const buffers = [];
9478
+ let totalSize = 0;
9479
+ for (const { path, size } of pathsWithSize) {
9480
+ if (totalSize + size > MAX_TOTAL_FONT_BUFFER_BYTES) break;
9481
+ try {
9482
+ buffers.push(new Uint8Array((0, import_node_fs2.readFileSync)(path)));
9483
+ totalSize += size;
9484
+ } catch {
9485
+ }
9486
+ }
9487
+ cachedFontBuffers = buffers;
9488
+ cachedFontBuffersKey = key;
9489
+ return buffers;
9490
+ }
9237
9491
  async function convertPptxToPng(input, options) {
9238
- const svgResults = await convertPptxToSvg(input, options);
9492
+ const svgResults = await convertPptxToSvg(input, { ...options, textOutput: "path" });
9239
9493
  const width = options?.width ?? DEFAULT_OUTPUT_WIDTH;
9240
9494
  const height = options?.height;
9495
+ const fontBuffers = loadFontBuffers(options?.fontDirs, options?.skipSystemFonts);
9241
9496
  const results = [];
9242
9497
  for (const { slideNumber, svg } of svgResults) {
9243
- const pngResult = await svgToPng(svg, { width, height });
9498
+ const pngResult = await svgToPng(svg, { width, height, fontBuffers });
9244
9499
  results.push({
9245
9500
  slideNumber,
9246
9501
  png: pngResult.png,
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,3 +1,189 @@
1
+ // src/converter.ts
2
+ import { readFileSync, statSync } from "fs";
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
+
1
187
  // src/font/font-mapping.ts
2
188
  var DEFAULT_FONT_MAPPING = {
3
189
  // ラテン文字フォント
@@ -64,6 +250,40 @@ function getCurrentMappedFont(fontFamily) {
64
250
  return getMappedFont(fontFamily, currentMapping);
65
251
  }
66
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
+
67
287
  // src/font/opentype-helpers.ts
68
288
  import { readFile } from "fs/promises";
69
289
 
@@ -1007,65 +1227,6 @@ function measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
1007
1227
  return totalWidth;
1008
1228
  }
1009
1229
 
1010
- // src/warning-logger.ts
1011
- var PREFIX = "[pptx-glimpse]";
1012
- var currentLevel = "off";
1013
- var entries = [];
1014
- var featureCounts = /* @__PURE__ */ new Map();
1015
- function initWarningLogger(level) {
1016
- currentLevel = level;
1017
- entries = [];
1018
- featureCounts.clear();
1019
- }
1020
- function warn(feature, message, context) {
1021
- if (currentLevel === "off") return;
1022
- entries.push({ feature, message, ...context !== void 0 && { context } });
1023
- const existing = featureCounts.get(feature);
1024
- if (existing) {
1025
- existing.count++;
1026
- } else {
1027
- featureCounts.set(feature, { message, count: 1 });
1028
- }
1029
- if (currentLevel === "debug") {
1030
- const ctx = context ? ` (${context})` : "";
1031
- console.warn(`${PREFIX} SKIP: ${feature} - ${message}${ctx}`);
1032
- }
1033
- }
1034
- function debug(feature, message, context) {
1035
- if (currentLevel !== "debug") return;
1036
- entries.push({ feature, message, ...context !== void 0 && { context } });
1037
- const existing = featureCounts.get(feature);
1038
- if (existing) {
1039
- existing.count++;
1040
- } else {
1041
- featureCounts.set(feature, { message, count: 1 });
1042
- }
1043
- const ctx = context ? ` (${context})` : "";
1044
- console.warn(`${PREFIX} DEBUG: ${feature} - ${message}${ctx}`);
1045
- }
1046
- function getWarningSummary() {
1047
- const features = [];
1048
- for (const [feature, { message, count }] of featureCounts) {
1049
- features.push({ feature, message, count });
1050
- }
1051
- return { totalCount: entries.length, features };
1052
- }
1053
- function flushWarnings() {
1054
- const summary = getWarningSummary();
1055
- if (currentLevel !== "off" && summary.features.length > 0) {
1056
- console.warn(`${PREFIX} Summary: ${summary.features.length} unsupported feature(s) detected`);
1057
- for (const { feature, count } of summary.features) {
1058
- console.warn(` - ${feature}: ${count} occurrence(s)`);
1059
- }
1060
- }
1061
- entries = [];
1062
- featureCounts.clear();
1063
- return summary;
1064
- }
1065
- function getWarningEntries() {
1066
- return entries;
1067
- }
1068
-
1069
1230
  // src/font/cjk-font-fallback.ts
1070
1231
  import { platform } from "os";
1071
1232
  var MACOS_FALLBACKS = {
@@ -1602,21 +1763,6 @@ async function createOpentypeSetupFromSystem(additionalFontDirs, fontMapping, sk
1602
1763
  return setup;
1603
1764
  }
1604
1765
 
1605
- // src/font/script-font-context.ts
1606
- var jpanMajorFont = null;
1607
- var jpanMinorFont = null;
1608
- function setScriptFonts(majorJpan, minorJpan) {
1609
- jpanMajorFont = majorJpan;
1610
- jpanMinorFont = minorJpan;
1611
- }
1612
- function resetScriptFonts() {
1613
- jpanMajorFont = null;
1614
- jpanMinorFont = null;
1615
- }
1616
- function getJpanFallbackFont() {
1617
- return jpanMajorFont ?? jpanMinorFont;
1618
- }
1619
-
1620
1766
  // src/font/text-measurer.ts
1621
1767
  var DefaultTextMeasurer = class {
1622
1768
  measureTextWidth(text, fontSizePt, bold, fontFamily, fontFamilyEa) {
@@ -1763,6 +1909,10 @@ async function svgToPng(svgString, options) {
1763
1909
  } else if (options?.height) {
1764
1910
  resvgOptions.fitTo = { mode: "height", value: options.height };
1765
1911
  }
1912
+ const fontBuffers = options?.fontBuffers;
1913
+ if (fontBuffers && fontBuffers.length > 0) {
1914
+ resvgOptions.font = { fontBuffers };
1915
+ }
1766
1916
  const resvg = new Resvg(svgString, resvgOptions);
1767
1917
  const rendered = resvg.render();
1768
1918
  return {
@@ -2722,7 +2872,7 @@ function parseChartTitle(titleNode) {
2722
2872
  function parseLegend(legendNode) {
2723
2873
  if (!legendNode) return null;
2724
2874
  const legendPos = legendNode.legendPos;
2725
- const pos = legendPos?.["@_val"] ?? "b";
2875
+ const pos = legendPos?.["@_val"] ?? "r";
2726
2876
  return { position: pos };
2727
2877
  }
2728
2878
 
@@ -5379,6 +5529,7 @@ var DEFAULT_SERIES_COLORS = [
5379
5529
  { hex: "#5B9BD5", alpha: 1 },
5380
5530
  { hex: "#70AD47", alpha: 1 }
5381
5531
  ];
5532
+ var LEGEND_SIDE_WIDTH = 100;
5382
5533
  function renderChart(element) {
5383
5534
  const { transform, chart } = element;
5384
5535
  const w = emuToPixels(transform.extentWidth);
@@ -5397,6 +5548,8 @@ function renderChart(element) {
5397
5548
  if (chart.legend) {
5398
5549
  if (chart.legend.position === "b") margin.bottom = 50;
5399
5550
  else if (chart.legend.position === "t") margin.top += 20;
5551
+ else if (chart.legend.position === "r") margin.right = LEGEND_SIDE_WIDTH;
5552
+ else if (chart.legend.position === "l") margin.left += LEGEND_SIDE_WIDTH;
5400
5553
  }
5401
5554
  const plotX = margin.left;
5402
5555
  const plotY = margin.top;
@@ -6151,18 +6304,34 @@ function renderLegend(chart, chartW, chartH, position) {
6151
6304
  color: s.color
6152
6305
  }));
6153
6306
  if (entries2.length === 0) return "";
6154
- const entryWidth = 80;
6155
- const totalWidth = entries2.length * entryWidth;
6156
- const startX = Math.max((chartW - totalWidth) / 2, 5);
6157
- const legendY = position === "t" ? 25 : chartH - 15;
6158
- for (let i = 0; i < entries2.length; i++) {
6159
- const ex = startX + i * entryWidth;
6160
- parts.push(
6161
- `<rect x="${round(ex)}" y="${round(legendY - 6)}" width="12" height="12" ${fillAttr(entries2[i].color)}/>`
6162
- );
6163
- parts.push(
6164
- `<text x="${round(ex + 16)}" y="${round(legendY + 4)}" font-size="10" fill="#595959">${escapeXml(entries2[i].label)}</text>`
6165
- );
6307
+ const ENTRY_HEIGHT = 20;
6308
+ if (position === "r" || position === "l") {
6309
+ const totalH = entries2.length * ENTRY_HEIGHT;
6310
+ const startY = Math.max((chartH - totalH) / 2, 5);
6311
+ const legendX = position === "r" ? chartW - LEGEND_SIDE_WIDTH + 5 : 5;
6312
+ for (let i = 0; i < entries2.length; i++) {
6313
+ const ey = startY + i * ENTRY_HEIGHT;
6314
+ parts.push(
6315
+ `<rect x="${round(legendX)}" y="${round(ey)}" width="12" height="12" ${fillAttr(entries2[i].color)}/>`
6316
+ );
6317
+ parts.push(
6318
+ `<text x="${round(legendX + 16)}" y="${round(ey + 10)}" font-size="10" fill="#595959">${escapeXml(entries2[i].label)}</text>`
6319
+ );
6320
+ }
6321
+ } else {
6322
+ const entryWidth = 80;
6323
+ const totalWidth = entries2.length * entryWidth;
6324
+ const startX = Math.max((chartW - totalWidth) / 2, 5);
6325
+ const legendY = position === "t" ? 25 : chartH - 15;
6326
+ for (let i = 0; i < entries2.length; i++) {
6327
+ const ex = startX + i * entryWidth;
6328
+ parts.push(
6329
+ `<rect x="${round(ex)}" y="${round(legendY - 6)}" width="12" height="12" ${fillAttr(entries2[i].color)}/>`
6330
+ );
6331
+ parts.push(
6332
+ `<text x="${round(ex + 16)}" y="${round(legendY + 4)}" font-size="10" fill="#595959">${escapeXml(entries2[i].label)}</text>`
6333
+ );
6334
+ }
6166
6335
  }
6167
6336
  return parts.join("");
6168
6337
  }
@@ -7998,7 +8167,19 @@ function renderTextBody(textBody, transform) {
7998
8167
  getLineSpacing(para, lnSpcReduction),
7999
8168
  paragraphGapPx
8000
8169
  );
8001
- const bulletStyles = buildBulletStyleAttrs(para.properties, lineFontSize, fontScale);
8170
+ const firstSeg = line.segments[0];
8171
+ const bulletFontChain = buildBulletFontChain(
8172
+ para.properties,
8173
+ firstSeg?.properties.fontFamily,
8174
+ firstSeg?.properties.fontFamilyEa
8175
+ );
8176
+ const bulletStyles = buildBulletStyleAttrs(
8177
+ para.properties,
8178
+ lineFontSize,
8179
+ fontScale,
8180
+ bulletFontChain
8181
+ );
8182
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8002
8183
  tspans.push(
8003
8184
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8004
8185
  );
@@ -8043,7 +8224,18 @@ function renderTextBody(textBody, transform) {
8043
8224
  getLineSpacing(para, lnSpcReduction),
8044
8225
  paragraphGapPx
8045
8226
  );
8046
- const bulletStyles = buildBulletStyleAttrs(para.properties, fontSize, fontScale);
8227
+ const bulletFontChain = buildBulletFontChain(
8228
+ para.properties,
8229
+ firstRun?.properties.fontFamily,
8230
+ firstRun?.properties.fontFamilyEa
8231
+ );
8232
+ const bulletStyles = buildBulletStyleAttrs(
8233
+ para.properties,
8234
+ fontSize,
8235
+ fontScale,
8236
+ bulletFontChain
8237
+ );
8238
+ getFontUsageCollector()?.record(bulletFontChain, bulletText);
8047
8239
  tspans.push(
8048
8240
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8049
8241
  );
@@ -8178,14 +8370,18 @@ function toAlpha(num) {
8178
8370
  }
8179
8371
  return result;
8180
8372
  }
8181
- function buildBulletStyleAttrs(props, textFontSizePt, fontScale) {
8373
+ function buildBulletFontChain(props, runFontFamily, runFontFamilyEa) {
8374
+ return [props.bulletFont, runFontFamily ?? null, runFontFamilyEa ?? null];
8375
+ }
8376
+ function buildBulletStyleAttrs(props, textFontSizePt, fontScale, bulletFontChain) {
8182
8377
  const styles = [];
8183
8378
  if (props.bulletSizePct !== null) {
8184
8379
  const size = textFontSizePt * (props.bulletSizePct / 1e5);
8185
8380
  styles.push(`font-size="${size}pt"`);
8186
8381
  }
8187
- if (props.bulletFont) {
8188
- styles.push(`font-family="${escapeXml2(props.bulletFont)}"`);
8382
+ const fontFamilyValue = buildFontFamilyValue(bulletFontChain);
8383
+ if (fontFamilyValue) {
8384
+ styles.push(`font-family="${fontFamilyValue}"`);
8189
8385
  }
8190
8386
  if (props.bulletColor) {
8191
8387
  styles.push(`fill="${props.bulletColor.hex}"`);
@@ -8369,6 +8565,7 @@ function renderSegment(text, props, fontScale, prefix) {
8369
8565
  let tspanContent;
8370
8566
  if (!needsScriptSplit(props)) {
8371
8567
  const styles = buildStyleAttrs(props, fontScale);
8568
+ getFontUsageCollector()?.record([props.fontFamily, props.fontFamilyEa], text);
8372
8569
  tspanContent = `<tspan ${prefix}${styles}>${escapeXml2(text)}</tspan>`;
8373
8570
  } else {
8374
8571
  const parts = splitByScript(text);
@@ -8377,6 +8574,7 @@ function renderSegment(text, props, fontScale, prefix) {
8377
8574
  const part = parts[i];
8378
8575
  const fonts = part.isEa ? [props.fontFamilyEa, getJpanFallbackFont(), props.fontFamily] : [props.fontFamily, props.fontFamilyEa];
8379
8576
  const styles = buildStyleAttrs(props, fontScale, fonts);
8577
+ getFontUsageCollector()?.record(fonts, part.text);
8380
8578
  if (i === 0) {
8381
8579
  result.push(`<tspan ${prefix}${styles}>${escapeXml2(part.text)}</tspan>`);
8382
8580
  } else {
@@ -9157,6 +9355,7 @@ function escapeXmlAttr(str) {
9157
9355
 
9158
9356
  // src/converter.ts
9159
9357
  async function convertPptxToSvg(input, options) {
9358
+ const textOutput = options?.textOutput ?? "path";
9160
9359
  const setup = await createOpentypeSetupFromSystem(
9161
9360
  options?.fontDirs,
9162
9361
  options?.fontMapping,
@@ -9164,7 +9363,13 @@ async function convertPptxToSvg(input, options) {
9164
9363
  );
9165
9364
  if (setup) {
9166
9365
  setTextMeasurer(setup.measurer);
9167
- setTextPathFontResolver(setup.fontResolver);
9366
+ if (textOutput !== "text") {
9367
+ setTextPathFontResolver(setup.fontResolver);
9368
+ }
9369
+ }
9370
+ const fontUsageCollector = textOutput === "text" ? new FontUsageCollector() : null;
9371
+ if (fontUsageCollector) {
9372
+ setFontUsageCollector(fontUsageCollector);
9168
9373
  }
9169
9374
  setFontMapping(createFontMapping(options?.fontMapping));
9170
9375
  enableXmlCache();
@@ -9183,7 +9388,14 @@ async function convertPptxToSvg(input, options) {
9183
9388
  const { slide, layoutElements, layoutShowMasterSp, masterElements } = parsed;
9184
9389
  const effectiveMasterElements = slide.showMasterSp && layoutShowMasterSp ? masterElements : [];
9185
9390
  slide.elements = mergeElements(effectiveMasterElements, layoutElements, slide.elements);
9186
- const svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9391
+ fontUsageCollector?.reset();
9392
+ let svg = renderSlideToSvg(slide, data.presInfo.slideSize);
9393
+ if (fontUsageCollector && setup) {
9394
+ const style = await buildFontFaceStyle(fontUsageCollector.getUsages(), setup.fontResolver);
9395
+ if (style) {
9396
+ svg = injectIntoSvgDefs(svg, style);
9397
+ }
9398
+ }
9187
9399
  results.push({ slideNumber, svg });
9188
9400
  }
9189
9401
  flushWarnings();
@@ -9192,17 +9404,60 @@ async function convertPptxToSvg(input, options) {
9192
9404
  clearXmlCache();
9193
9405
  resetTextMeasurer();
9194
9406
  resetTextPathFontResolver();
9407
+ resetFontUsageCollector();
9195
9408
  resetFontMapping();
9196
9409
  resetScriptFonts();
9197
9410
  }
9198
9411
  }
9412
+ function injectIntoSvgDefs(svg, content) {
9413
+ const openTagEnd = svg.indexOf(">");
9414
+ if (openTagEnd === -1) return svg;
9415
+ return `${svg.slice(0, openTagEnd + 1)}<defs>${content}</defs>${svg.slice(openTagEnd + 1)}`;
9416
+ }
9417
+ var cachedFontBuffers = null;
9418
+ var cachedFontBuffersKey = null;
9419
+ var MAX_TOTAL_FONT_BUFFER_BYTES = 100 * 1024 * 1024;
9420
+ function loadFontBuffers(fontDirs, skipSystemFonts) {
9421
+ const key = `${(fontDirs ?? []).join("\0")}
9422
+ ${skipSystemFonts ?? false}`;
9423
+ if (cachedFontBuffers !== null && cachedFontBuffersKey === key) {
9424
+ return cachedFontBuffers;
9425
+ }
9426
+ const allPaths = collectFontFilePaths(fontDirs, skipSystemFonts);
9427
+ const ttfOtfPaths = allPaths.filter((p) => {
9428
+ const lower = p.toLowerCase();
9429
+ return lower.endsWith(".ttf") || lower.endsWith(".otf");
9430
+ });
9431
+ const pathsWithSize = [];
9432
+ for (const p of ttfOtfPaths) {
9433
+ try {
9434
+ pathsWithSize.push({ path: p, size: statSync(p).size });
9435
+ } catch {
9436
+ }
9437
+ }
9438
+ pathsWithSize.sort((a, b) => a.size - b.size);
9439
+ const buffers = [];
9440
+ let totalSize = 0;
9441
+ for (const { path, size } of pathsWithSize) {
9442
+ if (totalSize + size > MAX_TOTAL_FONT_BUFFER_BYTES) break;
9443
+ try {
9444
+ buffers.push(new Uint8Array(readFileSync(path)));
9445
+ totalSize += size;
9446
+ } catch {
9447
+ }
9448
+ }
9449
+ cachedFontBuffers = buffers;
9450
+ cachedFontBuffersKey = key;
9451
+ return buffers;
9452
+ }
9199
9453
  async function convertPptxToPng(input, options) {
9200
- const svgResults = await convertPptxToSvg(input, options);
9454
+ const svgResults = await convertPptxToSvg(input, { ...options, textOutput: "path" });
9201
9455
  const width = options?.width ?? DEFAULT_OUTPUT_WIDTH;
9202
9456
  const height = options?.height;
9457
+ const fontBuffers = loadFontBuffers(options?.fontDirs, options?.skipSystemFonts);
9203
9458
  const results = [];
9204
9459
  for (const { slideNumber, svg } of svgResults) {
9205
- const pngResult = await svgToPng(svg, { width, height });
9460
+ const pngResult = await svgToPng(svg, { width, height, fontBuffers });
9206
9461
  results.push({
9207
9462
  slideNumber,
9208
9463
  png: pngResult.png,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pptx-glimpse",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
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",
@@ -46,7 +46,7 @@
46
46
  "@changesets/cli": "^2.29.8",
47
47
  "@types/node": "^25.2.2",
48
48
  "@types/ws": "^8.18.1",
49
- "@vitest/coverage-v8": "^3.2.4",
49
+ "@vitest/coverage-v8": "4.1.0",
50
50
  "eslint": "^9.0.0",
51
51
  "eslint-config-prettier": "^10.0.0",
52
52
  "eslint-plugin-simple-import-sort": "^12.1.1",
@@ -59,7 +59,7 @@
59
59
  "tsx": "^4.21.0",
60
60
  "typescript": "^5.9.3",
61
61
  "typescript-eslint": "^8.55.0",
62
- "vitest": "^3.0.0",
62
+ "vitest": "^4.1.0",
63
63
  "ws": "^8.19.0"
64
64
  },
65
65
  "scripts": {