feat: add fallback font loading and handling for various languages

This commit is contained in:
alam00000 committed 2026-09-17 13:56:16 +05:30
1 parent 9ae5c93886
commit 5a7927572f
28 files changed
+375 -4

No files matched your search

+2
View File
@@ -10260,4 +10260,6 @@ export {
getZoom,
getDocDescription,
setOnSaved,
endEdit,
saveFile,
};
+13
View File
@@ -6,6 +6,7 @@ const devBust = '';
import { applyType3LeftoverSurgery } from './type3surgery.js';
import { probePdfBytes } from './shadingsurgery.js';
import { needsFontEmbedding } from '../utils/freetext-script';
const RUN_SIZE = 60;
const FMT_SIZE = 36;
@@ -338,6 +339,7 @@ const PAGE_CACHE_MAX = 6;
export class PdfEngine {
static localFonts = new Map();
static fallbackFonts = new Map();
constructor(module) {
this.M = module;
@@ -396,6 +398,17 @@ export class PdfEngine {
if (bytes) break;
}
if (!bytes) bytes = normLookup(fam, styleKey, want);
if (
!bytes &&
want.some((cp) => needsFontEmbedding(String.fromCodePoint(cp)))
) {
for (const b of PdfEngine.fallbackFonts.values()) {
if (b && b.length && sfntCovers(b, want)) {
bytes = b;
break;
}
}
}
if (!bytes || !bytes.length) return 0;
const buf = M._ec_buffer_alloc(bytes.length);
if (!buf) return 0;
+161
View File
@@ -3,6 +3,11 @@ import { createIcons, icons } from 'lucide';
import { showAlert, showLoader, hideLoader } from '../ui.js';
import { t } from '../i18n/i18n';
import { batchDecryptIfNeeded } from '../utils/password-prompt.js';
import {
fallbackFontLanguages,
hasFallbackFonts,
loadFallbackFonts,
} from '../utils/font-loader.js';
import { setupFormatDock, setupFindSheet } from './edit-pdf-text-dock';
interface DocDescription {
@@ -276,6 +281,8 @@ interface EditorAppModule {
getZoom: () => number;
getDocDescription: () => DocDescription;
setOnSaved: (fn: ((kb: number, fileName: string) => void) | null) => void;
endEdit: (commit: boolean) => void;
saveFile: () => Promise<void>;
}
function relocateAddText() {
@@ -422,6 +429,155 @@ function fitMobileWidth() {
mod.setZoom(target);
}
function setupFallbackFonts(
app: EditorAppModule,
fonts: Map<string, Uint8Array>
) {
const pending = new Set<Promise<boolean>>();
let failed = false;
let warned = false;
let loaderShown = false;
let swallowGesture = false;
let resume: (() => void) | null = null;
const saveIfEnabled = () => {
const save = document.getElementById('save');
if (save instanceof HTMLButtonElement && !save.disabled) {
void app.saveFile();
}
};
const commitEdit = () => app.endEdit(true);
const track = (text: string | null | undefined) => {
const langs = fallbackFontLanguages(text ?? '');
if (langs.size === 0 || hasFallbackFonts(fonts, langs)) return;
const load = loadFallbackFonts(fonts, langs);
if (warned) return;
pending.add(load);
void load.then((ok) => {
pending.delete(load);
if (!ok) failed = true;
if (pending.size > 0) return;
const next = resume;
const actionHeld = loaderShown;
resume = null;
if (loaderShown) {
loaderShown = false;
hideLoader();
}
if (failed) {
failed = false;
if (actionHeld || !warned) {
warned = true;
showAlert(
t('warning.title'),
t('tools:editPdfText.fontsUnavailable'),
'warning'
);
}
return;
}
next?.();
});
};
const hold = (e: Event, next: (() => void) | null) => {
e.preventDefault();
e.stopImmediatePropagation();
if (next) resume = next;
if (!loaderShown) {
loaderShown = true;
showLoader(t('tools:editPdfText.loadingFonts'));
}
};
void loadFallbackFonts(fonts, ['eng']);
document.addEventListener(
'beforeinput',
(e) => track(e.data ?? e.dataTransfer?.getData('text/plain')),
true
);
document.addEventListener(
'paste',
(e) => {
const data = e.clipboardData;
track(data?.getData('text/plain') || data?.getData('text/html'));
const target = e.target;
const intoField =
target instanceof HTMLElement &&
(target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement);
if (pending.size > 0 && !intoField) hold(e, null);
},
true
);
window.addEventListener(
'pointerdown',
(e) => {
const target = e.target instanceof Element ? e.target : null;
swallowGesture =
pending.size > 0 && !target?.closest('.editor, .edit-chrome');
if (!swallowGesture) return;
hold(
e,
target?.closest('#save')
? saveIfEnabled
: target?.closest('#stage')
? commitEdit
: null
);
},
true
);
for (const type of [
'mousedown',
'mouseup',
'pointerup',
'click',
'dblclick',
'contextmenu',
'touchstart',
'touchend',
]) {
window.addEventListener(
type,
(e) => {
if (!swallowGesture) return;
e.preventDefault();
e.stopImmediatePropagation();
if (type === 'click') swallowGesture = false;
},
{ capture: true, passive: false }
);
}
window.addEventListener(
'keydown',
(e) => {
swallowGesture = false;
if (pending.size === 0) return;
const key = e.key.toLowerCase();
const meta = e.metaKey || e.ctrlKey;
if (meta && key === 's') hold(e, saveIfEnabled);
else if (e.key === 'Escape') hold(e, commitEdit);
else if (meta && (key === 'z' || key === 'y' || key === 'f')) {
hold(e, null);
} else if (
(e.key === 'Enter' || e.key === ' ') &&
e.target instanceof HTMLButtonElement
) {
hold(e, e.target.id === 'save' ? saveIfEnabled : null);
}
},
true
);
}
async function launchEditor(file: File) {
if (launching) return;
launching = true;
@@ -436,6 +592,11 @@ async function launchEditor(file: File) {
document.getElementById('text-editor-app')?.removeAttribute('hidden');
if (!appModule) {
appModule = (await import('../editcore/app.js')) as EditorAppModule;
const { PdfEngine } =
(await import('../editcore/core.js')) as unknown as {
PdfEngine: { fallbackFonts: Map<string, Uint8Array> };
};
setupFallbackFonts(appModule, PdfEngine.fallbackFonts);
appModule.setOnSaved((kb) => {
showAlert(
t('common.success'),
+51
View File
@@ -3,6 +3,7 @@ import {
getFontUrlForFamily,
languageToFontFamily,
} from '../config/font-mappings.js';
import { needsFontEmbedding } from './freetext-script.js';
const fontCache: Map<string, ArrayBuffer> = new Map();
@@ -134,6 +135,56 @@ export async function getFontForLanguage(lang: string): Promise<ArrayBuffer> {
}
}
const fallbackFontLoads = new Map<string, Promise<boolean>>();
const fallbackFontFamily = (lang: string): string =>
languageToFontFamily[lang] || 'Noto Sans';
export function fallbackFontLanguages(text: string): Set<string> {
const langs = new Set<string>();
for (const ch of text) {
if (needsFontEmbedding(ch)) langs.add(getLanguageForChar(ch));
}
return langs;
}
export function hasFallbackFonts(
loaded: Map<string, Uint8Array>,
langs: Iterable<string>
): boolean {
return [...langs].every((lang) => loaded.has(fallbackFontFamily(lang)));
}
export async function loadFallbackFonts(
into: Map<string, Uint8Array>,
langs: Iterable<string>
): Promise<boolean> {
const results = await Promise.all(
[...langs].map((lang) => {
const family = fallbackFontFamily(lang);
let load = fallbackFontLoads.get(family);
if (!load) {
load = getFontForLanguage(lang)
.then((buffer) => {
if (!fontCache.has(family)) {
throw new Error('Only a substitute font was available');
}
into.set(family, new Uint8Array(buffer));
return true;
})
.catch((error: unknown) => {
fallbackFontLoads.delete(family);
console.warn(`Fallback font unavailable for ${family}`, error);
return false;
});
fallbackFontLoads.set(family, load);
}
return load;
})
);
return results.every(Boolean);
}
export function detectScripts(text: string): string[] {
const scripts = new Set<string>();
+7 -1
View File
@@ -1,5 +1,6 @@
import type { FreeTextSystemFontAnnotation } from '@/types';
import { needsFontEmbedding } from './freetext-script.js';
import { fallbackFontLanguages, loadFallbackFonts } from './font-loader.js';
export const FPDF_ANNOT_FREETEXT = 3;
@@ -153,7 +154,7 @@ function buildRuns(
return [
{
text: contents,
family: 'Helvetica',
family: needsFontEmbedding(contents) ? 'Noto Sans' : 'Helvetica',
size,
rgba: hexToRgba(style?.fontColor ?? '#000000'),
bold: false,
@@ -196,6 +197,7 @@ export async function flattenFreeTextToPageText(
PdfEngine: {
create: () => Promise<EngineLike>;
localFonts: Map<string, Uint8Array>;
fallbackFonts: Map<string, Uint8Array>;
};
scriptFallbackFamilies: (cps: number[]) => string[];
};
@@ -245,6 +247,10 @@ export async function flattenFreeTextToPageText(
return { bytes, flattened: 0 };
}
await primeScriptFonts(mod, found);
await loadFallbackFonts(
mod.PdfEngine.fallbackFonts,
fallbackFontLanguages(found.join('\n'))
);
for (const pageIndex of [...pages].sort((x, y) => x - y)) {
if (pageIndex < 0 || pageIndex >= eng.pageCount) continue;
+52 -2
View File
@@ -1,7 +1,12 @@
import { describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getFontAssetFileName } from '../js/config/font-mappings';
import { resolveFontUrl } from '../js/utils/font-loader';
import {
fallbackFontLanguages,
hasFallbackFonts,
loadFallbackFonts,
resolveFontUrl,
} from '../js/utils/font-loader';
describe('font-loader', () => {
it('uses the default public font URL when no offline font base URL is configured', () => {
@@ -25,4 +30,49 @@ describe('font-loader', () => {
'NotoSansCJKsc-Regular.otf'
);
});
describe('fallback fonts', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('needs no font for text the standard fonts can encode', () => {
expect(fallbackFontLanguages('Plain café š € “quotes”').size).toBe(0);
expect([...fallbackFontLanguages('Příliš ŠĐČĆŽ')]).toEqual(['eng']);
});
it('downloads a missing font once and reports success', async () => {
const fetchFont = vi.fn(async () => new Response(new Uint8Array([1])));
vi.stubGlobal('fetch', fetchFont);
const fonts = new Map<string, Uint8Array>();
const results = await Promise.all([
loadFallbackFonts(fonts, ['eng']),
loadFallbackFonts(fonts, ['eng']),
]);
expect(results).toEqual([true, true]);
expect(fetchFont).toHaveBeenCalledTimes(1);
expect(hasFallbackFonts(fonts, ['eng'])).toBe(true);
});
it('reports failure when only a substitute font arrives, then retries', async () => {
vi.stubGlobal(
'fetch',
vi.fn(async (url: string) =>
url.includes('Arabic')
? Promise.reject(new Error('offline'))
: new Response(new Uint8Array([1]))
)
);
const fonts = new Map<string, Uint8Array>();
expect(await loadFallbackFonts(fonts, ['ara'])).toBe(false);
expect(hasFallbackFonts(fonts, ['ara'])).toBe(false);
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(new Uint8Array([1])))
);
expect(await loadFallbackFonts(fonts, ['ara'])).toBe(true);
expect(hasFallbackFonts(fonts, ['ara'])).toBe(true);
});
});
});
+47 -1
View File
@@ -1,6 +1,9 @@
// @vitest-environment node
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { flattenFreeTextToPageText } from '@/js/utils/freetext-flatten';
import type { FreeTextSystemFontAnnotation } from '@/types';
import {
createEngine,
modelOf,
@@ -113,6 +116,49 @@ describe('flattening FreeText annotations into page text', () => {
expect(target?.lockReason).toBe(0);
});
it('keeps Czech and Croatian letters when no system font is available', async () => {
const font = readFileSync(
resolve(
'node_modules/pdfjs-dist/standard_fonts/LiberationSans-Regular.ttf'
)
);
const fetchFont = vi.fn(async () => new Response(font));
vi.stubGlobal('fetch', fetchFont);
const czech = 'Příliš žluťoučký kůň, úpěl ďábelské ódy.!?';
const croatian = 'ŠĐČĆŽ šđčćž';
const style = (id: string, contents: string) =>
({
id,
pageIndex: 0,
contents,
fontSize: 12,
fontColor: '#000000',
textAlign: 0,
verticalAlign: 0,
opacity: 1,
rect: { origin: { x: 0, y: 0 }, size: { width: 0, height: 0 } },
fontPostScriptName: '',
}) satisfies FreeTextSystemFontAnnotation;
try {
const source = freeTextAnnotationPdf([
{ rect: [72, 600, 540, 640], contents: 'x', name: 'cs' },
{ rect: [72, 500, 540, 540], contents: 'x', name: 'hr' },
]);
const { bytes, flattened } = await flattenFreeTextToPageText(
source,
[style('cs', czech), style('hr', croatian)],
1
);
expect(flattened).toBe(2);
expect(fetchFont).toHaveBeenCalledTimes(1);
const text = allText(modelOf(engine, bytes));
expect(text).toContain(czech);
expect(text).toContain(croatian);
} finally {
vi.unstubAllGlobals();
}
});
it('returns the original bytes when the document cannot be opened', async () => {
const junk = new Uint8Array([1, 2, 3, 4, 5]);
const result = await flattenFreeTextToPageText(junk, [], 1);