返回 oh-my-ppt
font-collect.ts
根目录 / src / main / io / html-pptx / font-collect.ts
1 import { readFileSync, statSync } from 'fs'
2 import path from 'path'
3 import log from 'electron-log/main.js'
4 import { decompress } from 'woff2-encoder'
5 import fonteditorCore, { createFont } from 'fonteditor-core'
6 import type { HtmlToPptxEmbeddedFont, HtmlToPptxSlide } from '@arcsin1/html2pptx'
7
8 type EmbeddedFontStyle = HtmlToPptxEmbeddedFont['style']
9
10 type FontUsage = {
11 fontFace: string
12 style: EmbeddedFontStyle
13 characters: Set<string>
14 }
15
16 type ProjectFontFace = {
17 fontFace: string
18 weight: number
19 style: 'normal' | 'italic'
20 fontPath: string
21 unicodeRange?: string
22 }
23
24 const EOT_HEADER_SIZE = 82
25 const RESTRICTED_EMBEDDING = 0x0002
26 const BITMAP_ONLY_EMBEDDING = 0x0200
27
28 const normalizeFontFace = (value: string): string => value.replace(/\s+/g, ' ').trim()
29
30 const fontStyleFor = (bold?: boolean, italic?: boolean): EmbeddedFontStyle => {
31 if (bold && italic) return 'boldItalic'
32 if (bold) return 'bold'
33 if (italic) return 'italic'
34 return 'regular'
35 }
36
37 const usageKey = (fontFace: string, style: EmbeddedFontStyle): string =>
38 `${normalizeFontFace(fontFace).toLocaleLowerCase()}::${style}`
39
40 const addUsage = (
41 usages: Map<string, FontUsage>,
42 fontFace: string | undefined,
43 text: string,
44 bold?: boolean,
45 italic?: boolean
46 ): void => {
47 const normalizedFace = normalizeFontFace(fontFace || '')
48 if (!normalizedFace) return
49 const style = fontStyleFor(bold, italic)
50 const key = usageKey(normalizedFace, style)
51 const usage = usages.get(key) || {
52 fontFace: normalizedFace,
53 style,
54 characters: new Set<string>()
55 }
56 for (const character of text) usage.characters.add(character)
57 usages.set(key, usage)
58 }
59
60 // Text extraction is the authority for what becomes editable in the PPTX.
61 // Scanning every font on disk would embed unused families and miss the actual
62 // style used by rich-text runs.
63 const collectUsedFontUsages = (slides: HtmlToPptxSlide[]): FontUsage[] => {
64 const usages = new Map<string, FontUsage>()
65 for (const slide of slides) {
66 for (const text of slide.texts) {
67 if (text.runs?.length) {
68 for (const run of text.runs) {
69 addUsage(
70 usages,
71 run.fontFace || text.fontFace,
72 run.text,
73 run.bold ?? text.bold,
74 run.italic ?? text.italic
75 )
76 }
77 } else {
78 addUsage(usages, text.fontFace, text.text, text.bold, text.italic)
79 }
80 }
81 for (const table of slide.tables || []) {
82 for (const row of table.rows) {
83 for (const cell of row) {
84 addUsage(usages, cell.fontFace, cell.text, cell.bold, cell.italic)
85 }
86 }
87 }
88 }
89 return [...usages.values()]
90 }
91
92 const parseCssValue = (css: string, property: string): string => {
93 const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
94 return css.match(new RegExp(`(?:^|;)\\s*${escapedProperty}\\s*:\\s*([^;]+)`, 'i'))?.[1]?.trim() || ''
95 }
96
97 const parseCssFontFamily = (value: string): string =>
98 normalizeFontFace(value.split(',')[0]?.trim().replace(/^['"]|['"]$/g, '') || '')
99
100 const parseCssFontWeight = (value: string): number => {
101 const normalized = value.trim().toLowerCase()
102 if (normalized === 'normal') return 400
103 if (normalized === 'bold') return 700
104 const match = normalized.match(/\d{1,4}/)
105 return match ? Math.max(1, Math.min(1000, Number.parseInt(match[0], 10))) : 400
106 }
107
108 const parseCssFontStyle = (value: string): 'normal' | 'italic' =>
109 /italic|oblique/i.test(value) ? 'italic' : 'normal'
110
111 const isProjectFontPath = (candidatePath: string, projectDir: string): boolean => {
112 const fontRoot = path.resolve(projectDir, 'assets', 'fonts')
113 const relative = path.relative(fontRoot, candidatePath)
114 return relative !== '' && !relative.startsWith('..') && !path.isAbsolute(relative)
115 }
116
117 const resolveFontUrl = (url: string, htmlPath: string, projectDir: string): string | null => {
118 const source = url.trim().replace(/^['"]|['"]$/g, '')
119 if (!source || /^(?:data:|https?:|local-asset:)/i.test(source)) return null
120 const pathname = source.split(/[?#]/, 1)[0]
121 const candidatePath = path.resolve(path.dirname(htmlPath), decodeURIComponent(pathname))
122 if (!isProjectFontPath(candidatePath, projectDir)) return null
123 if (path.extname(candidatePath).toLowerCase() !== '.woff2') return null
124 try {
125 return statSync(candidatePath).isFile() ? candidatePath : null
126 } catch {
127 return null
128 }
129 }
130
131 // Read the actual @font-face declarations injected into exported HTML. This
132 // covers both bundled Google files and user uploads without relying on either
133 // storage directory's name or global user-font registry state.
134 const collectProjectFontFaces = (projectDir: string, htmlPaths: string[]): ProjectFontFace[] => {
135 const faces = new Map<string, ProjectFontFace>()
136 for (const htmlPath of new Set(htmlPaths)) {
137 let html: string
138 try {
139 html = readFileSync(htmlPath, 'utf-8')
140 } catch {
141 continue
142 }
143 for (const match of html.matchAll(/@font-face\s*\{([\s\S]*?)\}/gi)) {
144 const block = match[1] || ''
145 const fontFace = parseCssFontFamily(parseCssValue(block, 'font-family'))
146 const src = parseCssValue(block, 'src')
147 if (!fontFace || !src) continue
148 const weight = parseCssFontWeight(parseCssValue(block, 'font-weight'))
149 const style = parseCssFontStyle(parseCssValue(block, 'font-style'))
150 const unicodeRange = parseCssValue(block, 'unicode-range') || undefined
151 for (const urlMatch of src.matchAll(/url\(\s*([^)]*?)\s*\)/gi)) {
152 const fontPath = resolveFontUrl(urlMatch[1] || '', htmlPath, projectDir)
153 if (!fontPath) continue
154 const key = `${normalizeFontFace(fontFace).toLocaleLowerCase()}::${weight}::${style}::${fontPath}::${unicodeRange || ''}`
155 faces.set(key, { fontFace, weight, style, fontPath, unicodeRange })
156 }
157 }
158 }
159 return [...faces.values()]
160 }
161
162 const unicodeRangeContains = (unicodeRange: string, codePoint: number): boolean => {
163 for (const token of unicodeRange.split(',')) {
164 const value = token.trim().replace(/^U\+/i, '')
165 if (!value) continue
166 const rangeMatch = value.match(/^([0-9a-f?]+)(?:-([0-9a-f]+))?$/i)
167 if (!rangeMatch) continue
168 const start = rangeMatch[1].replace(/\?/g, '0')
169 const end = rangeMatch[2] || rangeMatch[1].replace(/\?/g, 'f')
170 const lower = Number.parseInt(start, 16)
171 const upper = Number.parseInt(end, 16)
172 if (Number.isFinite(lower) && Number.isFinite(upper) && codePoint >= lower && codePoint <= upper) {
173 return true
174 }
175 }
176 return false
177 }
178
179 const faceContainsUsage = (face: ProjectFontFace, usage: FontUsage): boolean => {
180 if (!face.unicodeRange || usage.characters.size === 0) return true
181 for (const character of usage.characters) {
182 if (unicodeRangeContains(face.unicodeRange, character.codePointAt(0) || 0)) return true
183 }
184 return false
185 }
186
187 const expectedWeightFor = (style: EmbeddedFontStyle): number =>
188 style === 'bold' || style === 'boldItalic' ? 700 : 400
189
190 const sourceStyleFor = (style: EmbeddedFontStyle): 'normal' | 'italic' =>
191 style === 'italic' || style === 'boldItalic' ? 'italic' : 'normal'
192
193 const resolveFontSources = (
194 usage: FontUsage,
195 faces: ProjectFontFace[]
196 ): { weight: number; paths: string[] } | null => {
197 const normalizedFace = normalizeFontFace(usage.fontFace).toLocaleLowerCase()
198 const style = sourceStyleFor(usage.style)
199 const candidates = faces.filter(
200 (face) =>
201 normalizeFontFace(face.fontFace).toLocaleLowerCase() === normalizedFace &&
202 face.style === style
203 )
204 if (candidates.length === 0) return null
205
206 const expectedWeight = expectedWeightFor(usage.style)
207 const closestWeight = candidates.reduce(
208 (closest, face) =>
209 Math.abs(face.weight - expectedWeight) < Math.abs(closest - expectedWeight)
210 ? face.weight
211 : closest,
212 candidates[0].weight
213 )
214 const matchingFaces = candidates.filter((face) => face.weight === closestWeight)
215 const paths = matchingFaces
216 .filter((face) => faceContainsUsage(face, usage))
217 .map((face) => face.fontPath)
218
219 return paths.length > 0 ? { weight: closestWeight, paths: [...new Set(paths)].sort() } : null
220 }
221
222 // ─── TTF merge ──────────────────────────────────────────────────────
223
224 const uint8ToArrayBuffer = (buffer: Uint8Array): ArrayBuffer => {
225 const arrayBuffer = new ArrayBuffer(buffer.byteLength)
226 new Uint8Array(arrayBuffer).set(buffer)
227 return arrayBuffer
228 }
229
230 const detectSfntType = (buffer: Uint8Array): 'ttf' | 'otf' =>
231 buffer[0] === 0x4f && buffer[1] === 0x54 && buffer[2] === 0x54 && buffer[3] === 0x4f
232 ? 'otf'
233 : 'ttf'
234
235 const readWoff2SubsetAsTtfObject = async (woff2Path: string): Promise<any> => {
236 const woff2Data = new Uint8Array(readFileSync(woff2Path))
237 const sfntData = await decompress(woff2Data)
238 const font = createFont(uint8ToArrayBuffer(sfntData), {
239 type: detectSfntType(sfntData),
240 subset: [],
241 hinting: false,
242 compound2simple: true
243 })
244 return font.get()
245 }
246
247 const glyphUnicodeCodes = (glyph: any): number[] =>
248 Array.isArray(glyph?.unicode)
249 ? Array.from(
250 new Set<number>(
251 glyph.unicode.filter((code: unknown): code is number =>
252 typeof code === 'number' && Number.isFinite(code)
253 )
254 )
255 )
256 : []
257
258 const glyphSortKey = (glyph: any): number => {
259 const codes = glyphUnicodeCodes(glyph)
260 return codes.length > 0 ? Math.min(...codes) : Number.MAX_SAFE_INTEGER
261 }
262
263 const isEmbeddable = (ttf: any): boolean => {
264 const flags = Number(ttf?.['OS/2']?.fsType || 0)
265 return (flags & (RESTRICTED_EMBEDDING | BITMAP_ONLY_EMBEDDING)) === 0
266 }
267
268 const normalizeMergedFontMetadata = (
269 ttf: any,
270 familyName: string,
271 styleName: string,
272 weight: number,
273 italic: boolean
274 ): void => {
275 const postScriptStyle = styleName.replace(/\s+/g, '')
276 ttf.name = {
277 ...(ttf.name || {}),
278 fontFamily: familyName,
279 fontSubFamily: styleName,
280 preferredFamily: familyName,
281 preferredSubFamily: styleName,
282 compatibleFull: `${familyName} ${styleName}`,
283 uniqueSubFamily: `${familyName}-${postScriptStyle}`,
284 fullName: `${familyName} ${styleName}`,
285 postScriptName: `${familyName.replace(/\s+/g, '')}-${postScriptStyle}`
286 }
287 if (ttf['OS/2']) {
288 ttf['OS/2'].usWeightClass = weight
289 const currentSelection = Number(ttf['OS/2'].fsSelection || 0)
290 ttf['OS/2'].fsSelection =
291 (currentSelection & ~(0x0001 | 0x0020)) | (italic ? 0x0001 : 0) | (weight >= 700 ? 0x0020 : 0)
292 const unicodes = ttf.glyf
293 .flatMap((glyph: any) => (Array.isArray(glyph.unicode) ? glyph.unicode : []))
294 .filter((code: number) => Number.isFinite(code))
295 if (unicodes.length > 0) {
296 ttf['OS/2'].usFirstCharIndex = Math.min(...unicodes)
297 ttf['OS/2'].usLastCharIndex = Math.max(...unicodes)
298 }
299 }
300 if (ttf.head) {
301 ttf.head.macStyle = (weight >= 700 ? 1 : 0) | (italic ? 2 : 0)
302 }
303 }
304
305 const isCjkFontFace = (fontFace: string): boolean =>
306 /(?:Noto Sans SC|Noto Serif SC|Ma Shan Zheng|Source Han|PingFang|Microsoft YaHei|SimHei|SimSun)/i.test(fontFace)
307
308 const swapUtf16ByteOrder = (buffer: Uint8Array, start: number, byteLength: number): void => {
309 for (let index = start; index < start + byteLength; index += 2) {
310 const first = buffer[index]
311 buffer[index] = buffer[index + 1]
312 buffer[index + 1] = first
313 }
314 }
315
316 const readEotNames = (buffer: Uint8Array): string[] | null => {
317 if (buffer.byteLength < EOT_HEADER_SIZE) return null
318 const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
319 if (view.getUint32(0, true) !== buffer.byteLength || view.getUint16(34, true) !== 0x504c) return null
320 let offset = EOT_HEADER_SIZE
321 const names: string[] = []
322 for (let index = 0; index < 4; index += 1) {
323 if (offset + 4 > buffer.byteLength) return null
324 const byteLength = view.getUint16(offset, true)
325 const textStart = offset + 2
326 const textEnd = textStart + byteLength
327 if (byteLength % 2 !== 0 || textEnd + 2 > buffer.byteLength) return null
328 names.push(new TextDecoder('utf-16le').decode(buffer.slice(textStart, textEnd)))
329 offset = textEnd + 2
330 }
331 if (offset + 2 > buffer.byteLength) return null
332 const rootStringSize = view.getUint16(offset, true)
333 const fontOffset = offset + 2 + rootStringSize
334 const fontDataSize = view.getUint32(4, true)
335 if (fontOffset + fontDataSize !== buffer.byteLength) return null
336 return names
337 }
338
339 const encodeUtf16Le = (value: string): Uint8Array => {
340 const output = new Uint8Array(value.length * 2)
341 for (let index = 0; index < value.length; index += 1) {
342 const codeUnit = value.charCodeAt(index)
343 output[index * 2] = codeUnit & 0xff
344 output[index * 2 + 1] = codeUnit >>> 8
345 }
346 return output
347 }
348
349 const readEotNameRanges = (buffer: Uint8Array): Array<{ start: number; byteLength: number }> | null => {
350 if (buffer.byteLength < EOT_HEADER_SIZE) return null
351 const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength)
352 if (view.getUint32(0, true) !== buffer.byteLength || view.getUint16(34, true) !== 0x504c) return null
353 let offset = EOT_HEADER_SIZE
354 const ranges: Array<{ start: number; byteLength: number }> = []
355 for (let index = 0; index < 4; index += 1) {
356 if (offset + 4 > buffer.byteLength) return null
357 const byteLength = view.getUint16(offset, true)
358 const start = offset + 2
359 const end = start + byteLength
360 if (byteLength % 2 !== 0 || end + 2 > buffer.byteLength) return null
361 ranges.push({ start, byteLength })
362 offset = end + 2
363 }
364 return ranges
365 }
366
367 const replaceEotStyleName = (eotBuffer: Uint8Array, styleName: string): Uint8Array | null => {
368 const nameRanges = readEotNameRanges(eotBuffer)
369 if (!nameRanges) return null
370 const styleRange = nameRanges[1]
371 const styleBytes = encodeUtf16Le(styleName)
372 if (styleRange.byteLength === styleBytes.byteLength) {
373 const normalized = new Uint8Array(eotBuffer)
374 normalized.set(styleBytes, styleRange.start)
375 return normalized
376 }
377
378 // fonteditor-core serializes name ID 2 as fontSubFamily, but its EOT writer
379 // reads the non-existent fontStyle alias. Rebuild only that EOT field so the
380 // header's StyleName remains consistent with the embedded OpenType font.
381 const styleSizeOffset = styleRange.start - 2
382 const sourceAfterStyle = styleRange.start + styleRange.byteLength
383 const resized = new Uint8Array(eotBuffer.byteLength - styleRange.byteLength + styleBytes.byteLength)
384 resized.set(eotBuffer.slice(0, styleSizeOffset), 0)
385 const view = new DataView(resized.buffer)
386 view.setUint16(styleSizeOffset, styleBytes.byteLength, true)
387 resized.set(styleBytes, styleSizeOffset + 2)
388 resized.set(eotBuffer.slice(sourceAfterStyle), styleSizeOffset + 2 + styleBytes.byteLength)
389 view.setUint32(0, resized.byteLength, true)
390 return resized
391 }
392
393 const normalizeEotPayload = (
394 eotBuffer: Uint8Array,
395 familyName: string,
396 styleName: string,
397 italic: boolean
398 ): Uint8Array | null => {
399 const normalized = new Uint8Array(eotBuffer)
400 if (normalized.byteLength < EOT_HEADER_SIZE) return null
401 const view = new DataView(normalized.buffer, normalized.byteOffset, normalized.byteLength)
402 if (view.getUint16(34, true) !== 0x504c) return null
403
404 const nameRanges = readEotNameRanges(normalized)
405 if (!nameRanges) return null
406 for (const { start: textStart, byteLength } of nameRanges) {
407 // fonteditor-core writes EOT name strings as UTF-16BE. EOT requires
408 // UTF-16LE, which Office uses when matching the font reference.
409 swapUtf16ByteOrder(normalized, textStart, byteLength)
410 }
411 const withStyleName = replaceEotStyleName(normalized, styleName)
412 if (!withStyleName) return null
413 withStyleName[26] = isCjkFontFace(familyName) ? 0x86 : 0x01
414 withStyleName[27] = italic ? 1 : 0
415 const names = readEotNames(withStyleName)
416 return names && normalizeFontFace(names[0]) === normalizeFontFace(familyName) && names[1] === styleName
417 ? withStyleName
418 : null
419 }
420
421 const mergeTtfObjects = (
422 ttfObjects: any[],
423 familyName: string,
424 styleName: string,
425 weight: number,
426 italic: boolean
427 ): Uint8Array | null => {
428 const base = ttfObjects[0]
429 const notdef = base.glyf?.[0] || { name: '.notdef', unicode: [] }
430 const glyphs: any[] = [notdef]
431 const seenCodes = new Set<number>()
432 const seenNames = new Set<string>()
433
434 for (const ttf of ttfObjects) {
435 for (const glyph of ttf.glyf || []) {
436 if (glyph.name === '.notdef' || glyph.name === '.null' || glyph.name === 'nonmarkingreturn') {
437 continue
438 }
439 const codes = glyphUnicodeCodes(glyph)
440 const name = String(glyph.name || '')
441 if (codes.length > 0) {
442 if (codes.some((code) => seenCodes.has(code))) continue
443 glyph.unicode = codes.sort((a, b) => a - b)
444 codes.forEach((code) => seenCodes.add(code))
445 } else if (name) {
446 if (seenNames.has(name)) continue
447 seenNames.add(name)
448 } else {
449 continue
450 }
451 glyphs.push(glyph)
452 }
453 }
454
455 base.glyf = [glyphs[0], ...glyphs.slice(1).sort((a, b) => glyphSortKey(a) - glyphSortKey(b))]
456 normalizeMergedFontMetadata(base, familyName, styleName, weight, italic)
457
458 const writer = new fonteditorCore.TTFWriter()
459 try {
460 const ttfBuffer = new Uint8Array(writer.write(base))
461 const eotBuffer = new Uint8Array(fonteditorCore.ttf2eot(uint8ToArrayBuffer(ttfBuffer)))
462 const normalizedEot = normalizeEotPayload(eotBuffer, familyName, styleName, italic)
463 const eotNames = normalizedEot ? readEotNames(normalizedEot) : null
464 if (!normalizedEot || !eotNames || normalizeFontFace(eotNames[0]) !== normalizeFontFace(familyName)) {
465 return null
466 }
467 return normalizedEot
468 } finally {
469 writer.dispose()
470 }
471 }
472
473 export const collectEmbeddedFonts = async (
474 projectDir: string,
475 slides: HtmlToPptxSlide[],
476 options: {
477 mode?: 'auto' | 'always' | 'never'
478 maxTotalBytes?: number
479 pageHtmlPaths?: string[]
480 } = {}
481 ): Promise<HtmlToPptxEmbeddedFont[]> => {
482 const mode = options.mode || 'auto'
483 if (mode === 'never') {
484 log.info('[font-embed] disabled by export option')
485 return []
486 }
487 if (slides.length === 0) return []
488
489 const usages = collectUsedFontUsages(slides)
490 log.info('[font-embed] actual text font usages', {
491 usages: usages.map((usage) => ({
492 fontFace: usage.fontFace,
493 style: usage.style,
494 characterCount: usage.characters.size
495 }))
496 })
497 if (usages.length === 0) return []
498
499 const faces = collectProjectFontFaces(projectDir, options.pageHtmlPaths || [])
500 log.info('[font-embed] local @font-face declarations', {
501 count: faces.length,
502 families: [...new Set(faces.map((face) => face.fontFace))]
503 })
504 if (faces.length === 0) return []
505
506 const embeddedFonts: HtmlToPptxEmbeddedFont[] = []
507 for (const usage of usages) {
508 const source = resolveFontSources(usage, faces)
509 if (!source) {
510 log.info('[font-embed] skip (no matching local font face)', {
511 fontFace: usage.fontFace,
512 style: usage.style
513 })
514 continue
515 }
516
517 const ttfObjects: any[] = []
518 for (const woff2Path of source.paths) {
519 try {
520 const ttf = await readWoff2SubsetAsTtfObject(woff2Path)
521 if (!isEmbeddable(ttf)) {
522 log.warn('[font-embed] skip font restricted by fsType', {
523 fontFace: usage.fontFace,
524 style: usage.style,
525 path: woff2Path
526 })
527 continue
528 }
529 ttfObjects.push(ttf)
530 } catch (error) {
531 log.warn('[font-embed] failed to read woff2 source', {
532 path: woff2Path,
533 error: String(error)
534 })
535 }
536 }
537 if (ttfObjects.length === 0) continue
538
539 try {
540 const isBold = usage.style === 'bold' || usage.style === 'boldItalic'
541 const isItalic = usage.style === 'italic' || usage.style === 'boldItalic'
542 const eotPayload = mergeTtfObjects(
543 ttfObjects,
544 usage.fontFace,
545 isBold ? (isItalic ? 'Bold Italic' : 'Bold') : isItalic ? 'Italic' : 'Regular',
546 source.weight,
547 isItalic
548 )
549 if (!eotPayload) {
550 log.warn('[font-embed] generated EOT failed structural validation', {
551 fontFace: usage.fontFace,
552 style: usage.style
553 })
554 continue
555 }
556 embeddedFonts.push({ fontFace: usage.fontFace, style: usage.style, ttfBuffer: eotPayload })
557 log.info('[font-embed] embedded actual font usage', {
558 fontFace: usage.fontFace,
559 style: usage.style,
560 files: source.paths.length,
561 sizeKb: Math.round(eotPayload.byteLength / 1024)
562 })
563 } catch (error) {
564 log.warn('[font-embed] failed to merge font', {
565 fontFace: usage.fontFace,
566 style: usage.style,
567 error: String(error)
568 })
569 }
570 }
571
572 if (mode === 'auto') {
573 const maxTotalBytes = options.maxTotalBytes ?? 20 * 1024 * 1024
574 const totalBytes = embeddedFonts.reduce((sum, item) => sum + item.ttfBuffer.byteLength, 0)
575 if (totalBytes > maxTotalBytes) {
576 log.warn('[font-embed] skipped embedded fonts in auto mode because payload is too large', {
577 totalBytes,
578 maxTotalBytes,
579 count: embeddedFonts.length
580 })
581 return []
582 }
583 }
584
585 return embeddedFonts
586 }
587
587 lines TYPESCRIPT