pptx-glimpse 1.0.1 → 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
@@ -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) {
@@ -8062,7 +8205,19 @@ function renderTextBody(textBody, transform) {
8062
8205
  getLineSpacing(para, lnSpcReduction),
8063
8206
  paragraphGapPx
8064
8207
  );
8065
- 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);
8066
8221
  tspans.push(
8067
8222
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8068
8223
  );
@@ -8107,7 +8262,18 @@ function renderTextBody(textBody, transform) {
8107
8262
  getLineSpacing(para, lnSpcReduction),
8108
8263
  paragraphGapPx
8109
8264
  );
8110
- 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);
8111
8277
  tspans.push(
8112
8278
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8113
8279
  );
@@ -8242,14 +8408,18 @@ function toAlpha(num) {
8242
8408
  }
8243
8409
  return result;
8244
8410
  }
8245
- 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) {
8246
8415
  const styles = [];
8247
8416
  if (props.bulletSizePct !== null) {
8248
8417
  const size = textFontSizePt * (props.bulletSizePct / 1e5);
8249
8418
  styles.push(`font-size="${size}pt"`);
8250
8419
  }
8251
- if (props.bulletFont) {
8252
- styles.push(`font-family="${escapeXml2(props.bulletFont)}"`);
8420
+ const fontFamilyValue = buildFontFamilyValue(bulletFontChain);
8421
+ if (fontFamilyValue) {
8422
+ styles.push(`font-family="${fontFamilyValue}"`);
8253
8423
  }
8254
8424
  if (props.bulletColor) {
8255
8425
  styles.push(`fill="${props.bulletColor.hex}"`);
@@ -8433,6 +8603,7 @@ function renderSegment(text, props, fontScale, prefix) {
8433
8603
  let tspanContent;
8434
8604
  if (!needsScriptSplit(props)) {
8435
8605
  const styles = buildStyleAttrs(props, fontScale);
8606
+ getFontUsageCollector()?.record([props.fontFamily, props.fontFamilyEa], text);
8436
8607
  tspanContent = `<tspan ${prefix}${styles}>${escapeXml2(text)}</tspan>`;
8437
8608
  } else {
8438
8609
  const parts = splitByScript(text);
@@ -8441,6 +8612,7 @@ function renderSegment(text, props, fontScale, prefix) {
8441
8612
  const part = parts[i];
8442
8613
  const fonts = part.isEa ? [props.fontFamilyEa, getJpanFallbackFont(), props.fontFamily] : [props.fontFamily, props.fontFamilyEa];
8443
8614
  const styles = buildStyleAttrs(props, fontScale, fonts);
8615
+ getFontUsageCollector()?.record(fonts, part.text);
8444
8616
  if (i === 0) {
8445
8617
  result.push(`<tspan ${prefix}${styles}>${escapeXml2(part.text)}</tspan>`);
8446
8618
  } else {
@@ -9221,6 +9393,7 @@ function escapeXmlAttr(str) {
9221
9393
 
9222
9394
  // src/converter.ts
9223
9395
  async function convertPptxToSvg(input, options) {
9396
+ const textOutput = options?.textOutput ?? "path";
9224
9397
  const setup = await createOpentypeSetupFromSystem(
9225
9398
  options?.fontDirs,
9226
9399
  options?.fontMapping,
@@ -9228,7 +9401,13 @@ async function convertPptxToSvg(input, options) {
9228
9401
  );
9229
9402
  if (setup) {
9230
9403
  setTextMeasurer(setup.measurer);
9231
- 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);
9232
9411
  }
9233
9412
  setFontMapping(createFontMapping(options?.fontMapping));
9234
9413
  enableXmlCache();
@@ -9247,7 +9426,14 @@ async function convertPptxToSvg(input, options) {
9247
9426
  const { slide, layoutElements, layoutShowMasterSp, masterElements } = parsed;
9248
9427
  const effectiveMasterElements = slide.showMasterSp && layoutShowMasterSp ? masterElements : [];
9249
9428
  slide.elements = mergeElements(effectiveMasterElements, layoutElements, slide.elements);
9250
- 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
+ }
9251
9437
  results.push({ slideNumber, svg });
9252
9438
  }
9253
9439
  flushWarnings();
@@ -9256,10 +9442,16 @@ async function convertPptxToSvg(input, options) {
9256
9442
  clearXmlCache();
9257
9443
  resetTextMeasurer();
9258
9444
  resetTextPathFontResolver();
9445
+ resetFontUsageCollector();
9259
9446
  resetFontMapping();
9260
9447
  resetScriptFonts();
9261
9448
  }
9262
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
+ }
9263
9455
  var cachedFontBuffers = null;
9264
9456
  var cachedFontBuffersKey = null;
9265
9457
  var MAX_TOTAL_FONT_BUFFER_BYTES = 100 * 1024 * 1024;
@@ -9297,7 +9489,7 @@ ${skipSystemFonts ?? false}`;
9297
9489
  return buffers;
9298
9490
  }
9299
9491
  async function convertPptxToPng(input, options) {
9300
- const svgResults = await convertPptxToSvg(input, options);
9492
+ const svgResults = await convertPptxToSvg(input, { ...options, textOutput: "path" });
9301
9493
  const width = options?.width ?? DEFAULT_OUTPUT_WIDTH;
9302
9494
  const height = options?.height;
9303
9495
  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) {
@@ -8024,7 +8167,19 @@ function renderTextBody(textBody, transform) {
8024
8167
  getLineSpacing(para, lnSpcReduction),
8025
8168
  paragraphGapPx
8026
8169
  );
8027
- 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);
8028
8183
  tspans.push(
8029
8184
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8030
8185
  );
@@ -8069,7 +8224,18 @@ function renderTextBody(textBody, transform) {
8069
8224
  getLineSpacing(para, lnSpcReduction),
8070
8225
  paragraphGapPx
8071
8226
  );
8072
- 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);
8073
8239
  tspans.push(
8074
8240
  `<tspan x="${bulletX}" dy="${dy}" text-anchor="start" ${bulletStyles}>${escapeXml2(bulletText)}</tspan>`
8075
8241
  );
@@ -8204,14 +8370,18 @@ function toAlpha(num) {
8204
8370
  }
8205
8371
  return result;
8206
8372
  }
8207
- 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) {
8208
8377
  const styles = [];
8209
8378
  if (props.bulletSizePct !== null) {
8210
8379
  const size = textFontSizePt * (props.bulletSizePct / 1e5);
8211
8380
  styles.push(`font-size="${size}pt"`);
8212
8381
  }
8213
- if (props.bulletFont) {
8214
- styles.push(`font-family="${escapeXml2(props.bulletFont)}"`);
8382
+ const fontFamilyValue = buildFontFamilyValue(bulletFontChain);
8383
+ if (fontFamilyValue) {
8384
+ styles.push(`font-family="${fontFamilyValue}"`);
8215
8385
  }
8216
8386
  if (props.bulletColor) {
8217
8387
  styles.push(`fill="${props.bulletColor.hex}"`);
@@ -8395,6 +8565,7 @@ function renderSegment(text, props, fontScale, prefix) {
8395
8565
  let tspanContent;
8396
8566
  if (!needsScriptSplit(props)) {
8397
8567
  const styles = buildStyleAttrs(props, fontScale);
8568
+ getFontUsageCollector()?.record([props.fontFamily, props.fontFamilyEa], text);
8398
8569
  tspanContent = `<tspan ${prefix}${styles}>${escapeXml2(text)}</tspan>`;
8399
8570
  } else {
8400
8571
  const parts = splitByScript(text);
@@ -8403,6 +8574,7 @@ function renderSegment(text, props, fontScale, prefix) {
8403
8574
  const part = parts[i];
8404
8575
  const fonts = part.isEa ? [props.fontFamilyEa, getJpanFallbackFont(), props.fontFamily] : [props.fontFamily, props.fontFamilyEa];
8405
8576
  const styles = buildStyleAttrs(props, fontScale, fonts);
8577
+ getFontUsageCollector()?.record(fonts, part.text);
8406
8578
  if (i === 0) {
8407
8579
  result.push(`<tspan ${prefix}${styles}>${escapeXml2(part.text)}</tspan>`);
8408
8580
  } else {
@@ -9183,6 +9355,7 @@ function escapeXmlAttr(str) {
9183
9355
 
9184
9356
  // src/converter.ts
9185
9357
  async function convertPptxToSvg(input, options) {
9358
+ const textOutput = options?.textOutput ?? "path";
9186
9359
  const setup = await createOpentypeSetupFromSystem(
9187
9360
  options?.fontDirs,
9188
9361
  options?.fontMapping,
@@ -9190,7 +9363,13 @@ async function convertPptxToSvg(input, options) {
9190
9363
  );
9191
9364
  if (setup) {
9192
9365
  setTextMeasurer(setup.measurer);
9193
- 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);
9194
9373
  }
9195
9374
  setFontMapping(createFontMapping(options?.fontMapping));
9196
9375
  enableXmlCache();
@@ -9209,7 +9388,14 @@ async function convertPptxToSvg(input, options) {
9209
9388
  const { slide, layoutElements, layoutShowMasterSp, masterElements } = parsed;
9210
9389
  const effectiveMasterElements = slide.showMasterSp && layoutShowMasterSp ? masterElements : [];
9211
9390
  slide.elements = mergeElements(effectiveMasterElements, layoutElements, slide.elements);
9212
- 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
+ }
9213
9399
  results.push({ slideNumber, svg });
9214
9400
  }
9215
9401
  flushWarnings();
@@ -9218,10 +9404,16 @@ async function convertPptxToSvg(input, options) {
9218
9404
  clearXmlCache();
9219
9405
  resetTextMeasurer();
9220
9406
  resetTextPathFontResolver();
9407
+ resetFontUsageCollector();
9221
9408
  resetFontMapping();
9222
9409
  resetScriptFonts();
9223
9410
  }
9224
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
+ }
9225
9417
  var cachedFontBuffers = null;
9226
9418
  var cachedFontBuffersKey = null;
9227
9419
  var MAX_TOTAL_FONT_BUFFER_BYTES = 100 * 1024 * 1024;
@@ -9259,7 +9451,7 @@ ${skipSystemFonts ?? false}`;
9259
9451
  return buffers;
9260
9452
  }
9261
9453
  async function convertPptxToPng(input, options) {
9262
- const svgResults = await convertPptxToSvg(input, options);
9454
+ const svgResults = await convertPptxToSvg(input, { ...options, textOutput: "path" });
9263
9455
  const width = options?.width ?? DEFAULT_OUTPUT_WIDTH;
9264
9456
  const height = options?.height;
9265
9457
  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.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",