| 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 |