feat: choose separate local fonts for CJK characters in original and … · PDFMathTranslate/PDFMathReader@1c13247 · GitHub
Skip to content

Commit 1c13247

Browse files
committed
feat: choose separate local fonts for CJK characters in original and translated documents
1 parent 1ab0392 commit 1c13247

18 files changed

Lines changed: 365 additions & 99 deletions

‎electron/main/ipc/preferences-ipc.mjs‎

Lines changed: 6 additions & 0 deletions

‎electron/main/services/preferences.mjs‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@ const DEFAULT_PREFERENCES = Object.freeze({
5151
translatedSansFont: '',
5252
translatedSerifFont: '',
5353
translatedMonoFont: '',
54+
originalCjkSansFont: '',
55+
originalCjkSerifFont: '',
56+
originalCjkMonoFont: '',
57+
translatedCjkSansFont: '',
58+
translatedCjkSerifFont: '',
59+
translatedCjkMonoFont: '',
5460
recentDocumentFonts: [],
5561
translationErrorDismissals: [],
5662
reduceResourceUsage: true,
@@ -286,6 +292,12 @@ const VALIDATORS = {
286292
translatedSansFont: isValidTranslationFont,
287293
translatedSerifFont: isValidTranslationFont,
288294
translatedMonoFont: isValidTranslationFont,
295+
originalCjkSansFont: isValidTranslationFont,
296+
originalCjkSerifFont: isValidTranslationFont,
297+
originalCjkMonoFont: isValidTranslationFont,
298+
translatedCjkSansFont: isValidTranslationFont,
299+
translatedCjkSerifFont: isValidTranslationFont,
300+
translatedCjkMonoFont: isValidTranslationFont,
289301
recentDocumentFonts: (value) =>
290302
Array.isArray(value) &&
291303
value.length <= MAX_RECENT_DOCUMENT_FONTS &&
@@ -361,6 +373,12 @@ const VALIDATION_MESSAGES = {
361373
translatedSansFont: 'Invalid translated sans-serif document font preference',
362374
translatedSerifFont: 'Invalid translated serif document font preference',
363375
translatedMonoFont: 'Invalid translated monospace document font preference',
376+
originalCjkSansFont: 'Invalid original CJK sans-serif document font preference',
377+
originalCjkSerifFont: 'Invalid original CJK serif document font preference',
378+
originalCjkMonoFont: 'Invalid original CJK monospace document font preference',
379+
translatedCjkSansFont: 'Invalid translated CJK sans-serif document font preference',
380+
translatedCjkSerifFont: 'Invalid translated CJK serif document font preference',
381+
translatedCjkMonoFont: 'Invalid translated CJK monospace document font preference',
364382
recentDocumentFonts: 'Invalid recent document fonts',
365383
translationMode: 'Invalid translation mode',
366384
interactionMode: 'Invalid interaction mode',

‎scripts/pdf-bionic-runtime.mjs‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,22 +55,23 @@ export function extendPDFRuntime(source) {
5555
// glyph's position, fitted to its width the way pdf.js fits missing fonts.
5656
replace(
5757
' this.current.font = fontObj;\n this.current.fontSize = size;',
58-
' this.current.font = fontObj;\n this.current.fontSize = size;\n this.current.substituteFont = null;',
58+
' this.current.font = fontObj;\n this.current.fontSize = size;\n this.current.substituteFonts = null;',
5959
);
6060
replace(
6161
' this.ctx.font = `${italic} ${bold} ${browserFontSize}px ${typeface}`;\n }',
6262
` this.ctx.font = \`\${italic} \${bold} \${browserFontSize}px \${typeface}\`;
6363
const substitute = this.fontSubstitution?.face(fontObj);
6464
if (substitute) {
65-
this.current.substituteFont = \`\${substitute.style} \${substitute.weight} \${browserFontSize}px \${substitute.family}\`;
65+
const css = (family) => family ? \`\${substitute.style} \${substitute.weight} \${browserFontSize}px \${family}\` : null;
66+
this.current.substituteFonts = { text: css(substitute.text), cjk: css(substitute.cjk) };
6667
}
6768
}`,
6869
);
6970
replace(
7071
' let x = 0,\n i;\n for (i = 0; i < glyphsLength; ++i) {',
71-
` const substituteFont = !vertical && !font.disableFontFace && !current.patternFill && !current.patternStroke &&
72+
` const substituteFonts = !vertical && !font.disableFontFace && !current.patternFill && !current.patternStroke &&
7273
!(current.textRenderingMode & TextRenderingMode.ADD_TO_PATH_FLAG) &&
73-
fillStrokeMode !== TextRenderingMode.INVISIBLE ? current.substituteFont : null;
74+
fillStrokeMode !== TextRenderingMode.INVISIBLE ? current.substituteFonts : null;
7475
const originalFont = ctx.font;
7576
let activeFont = originalFont;
7677
let x = 0,
@@ -80,7 +81,8 @@ export function extendPDFRuntime(source) {
8081
replace(
8182
' const character = glyph.fontChar;\n const accent = glyph.accent;',
8283
` const accent = glyph.accent;
83-
const substituted = !!substituteFont && !accent && !!glyph.unicode && this.fontSubstitution.accepts(glyph.unicode);
84+
const substituteFont = substituteFonts && !accent && glyph.unicode ? substituteFonts[this.fontSubstitution.kind(glyph.unicode)] : null;
85+
const substituted = !!substituteFont;
8486
const character = substituted ? glyph.unicode : glyph.fontChar;
8587
const glyphFont = substituted ? substituteFont : originalFont;
8688
if (glyphFont !== activeFont) {

‎server/fonts/installed-fonts.mjs‎

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,21 @@ export function classifyFace({ family = '', familyClass = 0, panose = [], fixedP
121121
return SERIF_NAME.test(family) ? 'serif' : 'sans';
122122
}
123123

124-
/** Faces in one sfnt file or collection: [{ family, regular, style }]. */
124+
// OS/2 Unicode ranges for Hiragana, Hangul syllables and CJK ideographs, and the
125+
// Japanese, Chinese and Korean code pages.
126+
const CJK_UNICODE_RANGE2 = (1 << 17) | (1 << 24) | (1 << 27);
127+
const CJK_CODE_PAGES = 0x1f << 17;
128+
129+
/** Whether an OS/2 table says the face covers Chinese, Japanese or Korean text. */
130+
export function coversCjk(os2) {
131+
if (!os2 || os2.length < 50) return false;
132+
if (os2.readUInt32BE(46) & CJK_UNICODE_RANGE2) return true;
133+
return (
134+
os2.length >= 82 && os2.readUInt16BE(0) >= 1 && (os2.readUInt32BE(78) & CJK_CODE_PAGES) !== 0
135+
);
136+
}
137+
138+
/** Faces in one sfnt file or collection: [{ family, regular, style, cjk }]. */
125139
export async function readFontFaces(path) {
126140
const file = await open(path, 'r');
127141
try {
@@ -156,7 +170,7 @@ export async function readFontFaces(path) {
156170
tables[name] ? read(tables[name].offset, Math.min(tables[name].length, max)) : null;
157171
const [names, os2, post] = await Promise.all([
158172
table('name', 1 << 20),
159-
table('OS/2', 64),
173+
table('OS/2', 86),
160174
table('post', 16),
161175
]);
162176
const family = nameOf(names, [16, 1]);
@@ -171,6 +185,7 @@ export async function readFontFaces(path) {
171185
panose: os2?.length >= 42 ? [...os2.subarray(32, 42)] : [],
172186
fixedPitch: post?.length >= 16 ? post.readUInt32BE(12) !== 0 : false,
173187
}),
188+
cjk: coversCjk(os2),
174189
});
175190
}
176191
return faces;
@@ -183,10 +198,11 @@ export async function readFontFaces(path) {
183198
const HIDDEN = (family) => family.startsWith('.') || /^(lastresort|system font)/i.test(family);
184199
const byName = (a, b) => a.localeCompare(b, 'en', { sensitivity: 'base', numeric: true });
185200

186-
/** Installed families by style, each list sorted by name. */
201+
/** Installed families by style, plus those covering CJK text, each list sorted by name. */
187202
export async function scanInstalledFonts(directories = fontDirectories()) {
188203
const files = await fontFiles(directories),
189-
families = new Map();
204+
families = new Map(),
205+
cjk = new Set();
190206
let next = 0;
191207
const worker = async () => {
192208
while (next < files.length) {
@@ -199,15 +215,20 @@ export async function scanInstalledFonts(directories = fontDirectories()) {
199215
}
200216
for (const face of faces) {
201217
if (HIDDEN(face.family)) continue;
218+
if (face.cjk) cjk.add(face.family);
202219
const known = families.get(face.family);
203220
// A family's regular face speaks for it; otherwise its first face does.
204221
if (!known || (face.regular && !known.regular)) families.set(face.family, face);
205222
}
206223
}
207224
};
208225
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
209-
const fonts = { sans: [], serif: [], mono: [] };
210-
for (const { family, style } of families.values()) fonts[style]?.push(family);
226+
const fonts = { sans: [], serif: [], mono: [], cjk: [] };
227+
for (const { family, style } of families.values()) {
228+
if (!fonts[style]) continue;
229+
fonts[style].push(family);
230+
if (cjk.has(family)) fonts.cjk.push(family);
231+
}
211232
for (const list of Object.values(fonts)) list.sort(byName);
212233
return fonts;
213234
}

‎shared/reader/translation-font.mjs‎

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@ export const DOCUMENT_FONT_KEYS = [
3131
'translatedSansFont',
3232
'translatedSerifFont',
3333
'translatedMonoFont',
34+
'originalCjkSansFont',
35+
'originalCjkSerifFont',
36+
'originalCjkMonoFont',
37+
'translatedCjkSansFont',
38+
'translatedCjkSerifFont',
39+
'translatedCjkMonoFont',
3440
];
3541

3642
// Quotes, backslashes and control characters cannot form a valid family name.
@@ -45,11 +51,17 @@ export function isValidTranslationFont(value) {
4551
return typeof value === 'string' && stripTranslationFont(value) === value;
4652
}
4753

48-
// Returns '' when the reader should keep its default font.
49-
export function translationFontFamily({ serif = false, font = '', language = '' } = {}) {
50-
const name = stripTranslationFont(font).trim();
51-
if (!serif && !name) return '';
52-
return [...(name ? [name] : []), ...(serif ? SERIF(language) : SANS)]
54+
// Returns '' when the reader should keep its default font. A CJK font follows the
55+
// Latin one, so it draws only the characters the Latin font lacks.
56+
export function translationFontFamily({
57+
serif = false,
58+
font = '',
59+
cjkFont = '',
60+
language = '',
61+
} = {}) {
62+
const names = [font, cjkFont].map((name) => stripTranslationFont(name).trim()).filter(Boolean);
63+
if (!serif && !names.length) return '';
64+
return [...new Set([...names, ...(serif ? SERIF(language) : SANS)])]
5365
.map((family) => (GENERIC.has(family) ? family : `"${family}"`))
5466
.join(', ');
5567
}

‎src/app/useReaderWindow.mjs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ export function useReaderWindow() {
110110
(preferences.ultraFastSerif.value
111111
? preferences.translatedSerifFont.value
112112
: preferences.translatedSansFont.value),
113+
cjkFont: preferences.ultraFastSerif.value
114+
? preferences.translatedCjkSerifFont.value
115+
: preferences.translatedCjkSansFont.value,
113116
language: preferences.language.value,
114117
}),
115118
),

‎src/features/reader/font-substitution.mjs‎

Lines changed: 42 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,19 @@ const SYMBOL =
88
/^(cm(mi|sy|ex|bsy|mib)|msam|msbm|eu(fm|fb|rm|sm|ex)|rsfs|stmary|wasy|lasy|esint|bbm|dsrom|txsy|txmi|txex|pxsy|pxmi|pxex|mtsy|mtmi|mtex)|math|symbol|dingbat|wingding|webding|zapf|marvosym|fontawesome|esstix|euclid/;
99
const MONOSPACE = /mono|courier|consol|menlo|^cmtt|^sftt|^ectt|inconsolata|typewriter|code/;
1010
const SANS =
11-
/sans|grotesk|grotesque|gothic|arial|helvetica|verdana|calibri|segoe|tahoma|trebuchet|myriad|frutiger|futura|gillsans|univers|avenir|roboto|lato|ubuntu|^cmss|^sfss|^lmss|nimbussan|yahei|simhei|heiti|pingfang|hiraginokaku|meiryo|gulim|dotum|malgun|franklin|optima|candara|corbel|lucidagrande|sfpro|sfns|aptos/;
11+
/sans|grotesk|grotesque|gothic|arial|helvetica|verdana|calibri|segoe|tahoma|trebuchet|myriad|frutiger|futura|gillsans|univers|avenir|roboto|lato|ubuntu|^cmss|^sfss|^lmss|nimbussan|yahei|simhei|heiti|hei$|dengxian|pingfang|hiraginokaku|meiryo|gulim|dotum|malgun|franklin|optima|candara|corbel|lucidagrande|sfpro|sfns|aptos/;
1212
const SERIF =
13-
/serif|times|roman|georgia|garamond|minion|palatino|antiqua|baskerville|caslon|cambria|charter|century|libertin|bodoni|didot|utopia|mercury|warnock|sabon|bembo|janson|cochin|hoefler|constantia|merriweather|lora|tiempos|literata|spectral|crimson|stix|nimbusrom|^cm(r|bx|ti|sl|csc|b|u|bxti|bxsl)\d|^sf(rm|bx|ti|sl|cc)\d|^ec(rm|bx|ti|sl|cc|bi)|^lmr|^txr|^pxr|mincho|song|ming|batang|myungjo|charis|gentium|joanna|perpetua|plantin|electra|newsreader|iowan|sitka/;
13+
/serif|times|roman|georgia|garamond|minion|palatino|antiqua|baskerville|caslon|cambria|charter|century|libertin|bodoni|didot|utopia|mercury|warnock|sabon|bembo|janson|cochin|hoefler|constantia|merriweather|lora|tiempos|literata|spectral|crimson|stix|nimbusrom|^cm(r|bx|ti|sl|csc|b|u|bxti|bxsl)\d|^sf(rm|bx|ti|sl|cc)\d|^ec(rm|bx|ti|sl|cc|bi)|^lmr|^txr|^pxr|mincho|song|simsun|ming|kai|batang|myungjo|charis|gentium|joanna|perpetua|plantin|electra|newsreader|iowan|sitka/;
1414
const BOLD = /bold|black|heavy|demi|[-,]medi(ital)?$|^cmbx|^cmb\d|^sfbx|^ecbx|^cmssbx/;
1515
const ITALIC = /ital|obliq|[-,][a-z]*it$|^cm(ti|sl|bxti|bxsl|ssi)|^sf(ti|sl)|^ec(ti|sl|bi)/;
1616
// Text that a local face can stand in for: letters, digits, punctuation and
1717
// spaces. Math symbols and mathematical alphanumerics stay with the PDF font.
1818
const TEXT = /^[\p{L}\p{M}\p{N}\p{P}\p{Zs}]+$/u;
1919
const MATH_ALPHANUMERIC = /[\u{1D400}-\u{1D7FF}]/u;
20+
// Chinese, Japanese and Korean characters, their punctuation and full-width forms
21+
// take the CJK face; everything else takes the Latin one.
22+
const CJK =
23+
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Script=Bopomofo}\u3000-\u303f\u30fc\uff00-\uffef]/u;
2024

2125
// Repainting every page per keystroke would be wasted while a name is typed.
2226
const SETTLE_MS = 400;
@@ -39,55 +43,69 @@ export function pdfFontRole(font) {
3943
export const substitutableText = (text) =>
4044
typeof text === 'string' && TEXT.test(text) && !MATH_ALPHANUMERIC.test(text);
4145

46+
/** 'cjk' or 'text' for the face that draws a glyph's text, '' when the PDF font keeps it. */
47+
export const substituteKind = (text) =>
48+
substitutableText(text) ? (CJK.test(text) ? 'cjk' : 'text') : '';
49+
4250
const GENERIC = { serif: 'serif', sans: 'sans-serif', mono: 'monospace' };
4351
const cssFamily = (name, generic) => `"${name}", ${generic}`;
4452

45-
/** pdf.js render options that draw serif, sans and mono text with local fonts, or null. */
46-
export function fontSubstitutionOptions({ serif = '', sans = '', mono = '' } = {}) {
47-
const families = {
48-
serif: stripTranslationFont(serif).trim(),
49-
sans: stripTranslationFont(sans).trim(),
50-
mono: stripTranslationFont(mono).trim(),
51-
};
52-
if (!families.serif && !families.sans && !families.mono) return null;
53+
const ROLES = ['serif', 'sans', 'mono'];
54+
const cleanFamilies = (names = {}) =>
55+
Object.fromEntries(ROLES.map((role) => [role, stripTranslationFont(names[role] || '').trim()]));
56+
57+
/**
58+
* pdf.js render options that draw serif, sans and mono text with local fonts, or
59+
* null. `cjk` names the faces for Chinese, Japanese and Korean characters.
60+
*/
61+
export function fontSubstitutionOptions({ serif = '', sans = '', mono = '', cjk = {} } = {}) {
62+
const families = { text: cleanFamilies({ serif, sans, mono }), cjk: cleanFamilies(cjk) };
63+
const names = [
64+
...ROLES.map((role) => families.text[role]),
65+
...ROLES.map((role) => families.cjk[role]),
66+
];
67+
if (!names.some(Boolean)) return null;
5368
return {
54-
key: `font:${families.serif}|${families.sans}|${families.mono}`,
69+
key: `font:${names.join('|')}`,
5570
render: {
5671
fontSubstitution: {
5772
face(font) {
5873
const role = pdfFontRole(font),
59-
family = families[role];
60-
if (!family) return null;
74+
text = families.text[role],
75+
cjkFamily = families.cjk[role];
76+
if (!text && !cjkFamily) return null;
6177
const name = baseName(font.name);
6278
return {
63-
family: cssFamily(family, GENERIC[role]),
79+
text: text && cssFamily(text, GENERIC[role]),
80+
cjk: cjkFamily && cssFamily(cjkFamily, GENERIC[role]),
6481
style: font.italic || ITALIC.test(name) ? 'italic' : 'normal',
6582
weight: font.black ? '900' : font.bold || BOLD.test(name) ? 'bold' : 'normal',
6683
};
6784
},
68-
accepts: substitutableText,
85+
kind: substituteKind,
6986
},
7087
},
7188
};
7289
}
7390

7491
const fontSet = (preferences, prefix) =>
75-
['serif', 'sans', 'mono'].map((role) => {
76-
const key = `${prefix}${role[0].toUpperCase()}${role.slice(1)}Font`;
77-
return preferences[key]?.value || '';
78-
});
92+
Object.fromEntries(
93+
ROLES.map((role) => [
94+
role,
95+
preferences[`${prefix}${role[0].toUpperCase()}${role.slice(1)}Font`]?.value || '',
96+
]),
97+
);
7998

8099
// Local fonts for the original and the translated document, each with its own
81-
// serif, sans-serif and monospace families.
100+
// serif, sans-serif and monospace families for Latin and for CJK text.
82101
export function createFontSubstitution({ preferences, onToggle }) {
83102
const cache = {},
84103
timer = { id: 0 };
85104
const renderOptions = (translated = false) => {
86105
const prefix = translated ? 'translated' : 'original',
87-
[serif, sans, mono] = fontSet(preferences, prefix),
88-
id = `${serif}|${sans}|${mono}`;
89-
if (cache[prefix]?.id !== id)
90-
cache[prefix] = { id, value: fontSubstitutionOptions({ serif, sans, mono }) };
106+
fonts = { ...fontSet(preferences, prefix), cjk: fontSet(preferences, `${prefix}Cjk`) },
107+
id = JSON.stringify(fonts);
108+
if (cache[prefix]?.id !== id) cache[prefix] = { id, value: fontSubstitutionOptions(fonts) };
91109
return cache[prefix].value;
92110
};
93111
const key = () => `${renderOptions(false)?.key || ''}/${renderOptions(true)?.key || ''}`;

‎src/features/settings/AppearanceSettings.vue‎

Lines changed: 1 addition & 0 deletions

0 commit comments

Comments
 (0)