mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-11 17:23:37 +01:00
feat: add fallback font loading and handling for various languages
This commit is contained in:
1 parent
9ae5c93886
commit
5a7927572f
28 files changed
+375
-4
No files matched your search
@@ -10260,4 +10260,6 @@ export {
|
||||
getZoom,
|
||||
getDocDescription,
|
||||
setOnSaved,
|
||||
endEdit,
|
||||
saveFile,
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user