|
| 1 | +import { BASE_PATH } from '@ranuts/shared/document-utils'; |
| 2 | +import type { EmscriptenModule } from '@ranuts/shared/document-types'; |
| 3 | + |
| 4 | +/** |
| 5 | + * The fonts an exported PDF needs, and where they come from. |
| 6 | + * |
| 7 | + * Without them x2t writes a PDF whose text is invisible. They are read from |
| 8 | + * the indexed catalog (public/fonts/{index}) -- the same files the editor |
| 9 | + * loads, so they are usually already in the HTTP cache -- and de-obfuscated |
| 10 | + * before being written under the alias names x2t matches against. |
| 11 | + * |
| 12 | + * The slot numbers here are one of three places in the repository that hard-code |
| 13 | + * them (the others are public/landing-prefetch.js and |
| 14 | + * test/e2e/landing-prefetch.spec.ts). Changing the catalog means changing all |
| 15 | + * three; test/unit/font-catalog-licensing.test.ts checks this one against what |
| 16 | + * is actually on disk. |
| 17 | + */ |
| 18 | + |
| 19 | +/** Same 16-byte key bin/font-catalog.mjs and the vendor's fetchFonts use. */ |
| 20 | +const CATALOG_FONT_XOR_KEY = [160, 102, 214, 32, 20, 150, 71, 250, 149, 105, 184, 80, 176, 65, 73, 72]; |
| 21 | + |
| 22 | +/** |
| 23 | + * PDF-export font manifest: catalog file index -> alias file names x2t |
| 24 | + * matches against inside m_sFontDir. One decoded byte set is written once |
| 25 | + * per alias. Indexes come from __fonts_infos in public/sdkjs/common/ |
| 26 | + * AllFonts.js (file position, then __fonts_files lookup). Keep Arial and |
| 27 | + * other western families on their own files -- aliasing them to the CJK |
| 28 | + * fallback garbles latin text and digits. The CJK alias entries carry the |
| 29 | + * literal zh font names documents reference; they are data, not UI copy. |
| 30 | + */ |
| 31 | +export const PDF_FONT_MANIFEST: ReadonlyArray<{ file: string; aliases: string[] }> = [ |
| 32 | + // The aliases are the names x2t looks for; the slot behind each one is an |
| 33 | + // open-licensed face after bin/font-license-sweep.mjs (the proprietary |
| 34 | + // originals are no longer in the catalog). Liberation and Carlito are |
| 35 | + // metric-compatible with the names they answer to, so an exported PDF |
| 36 | + // keeps the same line and page breaks. |
| 37 | + { file: '062', aliases: ['Arial.ttf', 'LiberationSans-Regular.ttf'] }, |
| 38 | + { file: '059', aliases: ['Arial_Bold.ttf'] }, |
| 39 | + { file: '061', aliases: ['Arial_Italic.ttf'] }, |
| 40 | + { file: '060', aliases: ['Arial_Bold_Italic.ttf'] }, |
| 41 | + { file: '112', aliases: ['Calibri.ttf', 'Carlito.ttf'] }, |
| 42 | + { file: '109', aliases: ['Calibri_Bold.ttf', 'Carlito_Bold.ttf'] }, |
| 43 | + { file: '111', aliases: ['Calibri_Italic.ttf', 'Carlito_Italic.ttf'] }, |
| 44 | + { file: '110', aliases: ['Calibri_Bold_Italic.ttf', 'Carlito_Bold_Italic.ttf'] }, |
| 45 | + { file: '070', aliases: ['Times_New_Roman.ttf', 'Times New Roman.ttf'] }, |
| 46 | + { file: '067', aliases: ['Times_New_Roman_Bold.ttf'] }, |
| 47 | + { file: '069', aliases: ['Times_New_Roman_Italic.ttf'] }, |
| 48 | + { file: '068', aliases: ['Times_New_Roman_Bold_Italic.ttf'] }, |
| 49 | + { file: '058', aliases: ['Courier_New.ttf', 'Courier New.ttf'] }, |
| 50 | + // Names the previous implementation fetched directly (kept for the same |
| 51 | + // default-latin coverage). |
| 52 | + { file: '117', aliases: ['DejaVuSans.ttf'] }, |
| 53 | + { file: '050', aliases: ['DejaVuSans-Bold.ttf'] }, |
| 54 | + // CJK. Serif answers to the Song/Ming names a document's body text uses, |
| 55 | + // sans to the Hei/YaHei names; PingFang maps to the sans as the closest |
| 56 | + // match. Both are TrueType on purpose: x2t embeds no glyphs at all for |
| 57 | + // CFF-flavoured faces, so a pan-CJK OTF here exports a PDF whose Chinese |
| 58 | + // is blank while its Latin survives (measured both ways round). |
| 59 | + { file: '269', aliases: ['SimSun.ttf', 'NSimSun.ttf', '宋体.ttf', 'NotoSerifSC-Regular.ttf'] }, |
| 60 | + { file: '270', aliases: ['SimSun_Bold.ttf'] }, |
| 61 | + { |
| 62 | + file: '267', |
| 63 | + aliases: [ |
| 64 | + 'Microsoft YaHei.ttf', |
| 65 | + '微软雅黑.ttf', |
| 66 | + 'PingFang SC.ttf', |
| 67 | + 'SimHei.ttf', |
| 68 | + '黑体.ttf', |
| 69 | + 'DroidSansFallback.ttf', |
| 70 | + 'Droid Sans Fallback.ttf', |
| 71 | + 'NotoSansSC-Regular.ttf', |
| 72 | + ], |
| 73 | + }, |
| 74 | + { file: '268', aliases: ['Microsoft YaHei_Bold.ttf', 'SimHei_Bold.ttf'] }, |
| 75 | +]; |
| 76 | + |
| 77 | +/** Undo the catalog XOR obfuscation, returning a plain TTF byte copy. */ |
| 78 | +export function decodeCatalogFont(bytes: Uint8Array): Uint8Array { |
| 79 | + const out = new Uint8Array(bytes); |
| 80 | + const n = Math.min(32, out.length); |
| 81 | + for (let i = 0; i < n; i++) { |
| 82 | + out[i] ^= CATALOG_FONT_XOR_KEY[i % CATALOG_FONT_XOR_KEY.length]!; |
| 83 | + } |
| 84 | + return out; |
| 85 | +} |
| 86 | + |
| 87 | +/** |
| 88 | + * Write every manifest face into the module's FS, once per session. A face |
| 89 | + * that cannot be fetched is skipped rather than failing the export: the PDF |
| 90 | + * still renders with the ones that arrived. |
| 91 | + */ |
| 92 | +export async function loadFontsForPdf(module: EmscriptenModule): Promise<void> { |
| 93 | + await Promise.all( |
| 94 | + PDF_FONT_MANIFEST.map(async ({ file, aliases }) => { |
| 95 | + try { |
| 96 | + const res = await fetch(`${BASE_PATH}fonts/${file}`); |
| 97 | + if (!res.ok) return; |
| 98 | + const bytes = decodeCatalogFont(new Uint8Array(await res.arrayBuffer())); |
| 99 | + for (const alias of aliases) { |
| 100 | + module.FS.writeFile(`/working/fonts/${alias}`, bytes); |
| 101 | + } |
| 102 | + } catch { |
| 103 | + // Non-fatal -- the PDF may still render with the remaining fonts. |
| 104 | + } |
| 105 | + }), |
| 106 | + ); |
| 107 | +} |
0 commit comments