From 5a7927572fefe06b681c834a13532833202f971b Mon Sep 17 00:00:00 2001 From: alam00000 Date: Thu, 17 Sep 2026 13:55:04 +0530 Subject: [PATCH] feat: add fallback font loading and handling for various languages --- public/locales/ar/tools.json | 2 + public/locales/be/tools.json | 2 + public/locales/da/tools.json | 2 + public/locales/de/tools.json | 2 + public/locales/en/tools.json | 2 + public/locales/es/tools.json | 2 + public/locales/fr/tools.json | 2 + public/locales/id/tools.json | 2 + public/locales/it/tools.json | 2 + public/locales/ja/tools.json | 2 + public/locales/ko/tools.json | 2 + public/locales/nl/tools.json | 2 + public/locales/pt/tools.json | 2 + public/locales/ru/tools.json | 2 + public/locales/sk/tools.json | 2 + public/locales/sv/tools.json | 2 + public/locales/tr/tools.json | 2 + public/locales/uk/tools.json | 2 + public/locales/vi/tools.json | 2 + public/locales/zh-TW/tools.json | 2 + public/locales/zh/tools.json | 2 + src/js/editcore/app.js | 2 + src/js/editcore/core.js | 13 +++ src/js/logic/edit-pdf-text-page.ts | 161 +++++++++++++++++++++++++++++ src/js/utils/font-loader.ts | 51 +++++++++ src/js/utils/freetext-flatten.ts | 8 +- src/tests/font-loader.test.ts | 54 +++++++++- src/tests/freetext-flatten.test.ts | 48 ++++++++- 28 files changed, 375 insertions(+), 4 deletions(-) diff --git a/public/locales/ar/tools.json b/public/locales/ar/tools.json index c3f8e519..d9f78c38 100644 --- a/public/locales/ar/tools.json +++ b/public/locales/ar/tools.json @@ -60,6 +60,8 @@ "subtitle": "انقر على أي فقرة وحرّرها في مكانها مع إعادة تدفق فوري للنص والخطوط والأنماط.", "saved": "تم حفظ ملف PDF بنجاح! ({{size}} كيلوبايت)", "failedToLoad": "تعذّر تحميل محرّر نصوص PDF.", + "loadingFonts": "جارٍ تحميل الخطوط...", + "fontsUnavailable": "تعذّر تنزيل خط مطلوب لبعض الأحرف. تحقّق من اتصالك وحاول مرة أخرى، وإلا فقد لا تظهر هذه الأحرف بشكل صحيح في ملف PDF المحفوظ.", "howToUpload": "رفع ملف PDF", "howToClick": "انقر على فقرة", "howToRestyle": "غيّر تنسيق أي شيء", diff --git a/public/locales/be/tools.json b/public/locales/be/tools.json index 70d1ef2f..b8c31892 100644 --- a/public/locales/be/tools.json +++ b/public/locales/be/tools.json @@ -61,6 +61,8 @@ "subtitle": "Рэдагаваць любы абзац на месцы — з імгненнай перакампаноўкай тэксту, шрыфтамі і стылямі.", "saved": "PDF паспяхова захаваны! ({{size}} КБ)", "failedToLoad": "Не ўдалося загрузіць рэдактар тэксту PDF.", + "loadingFonts": "Загрузка шрыфтоў...", + "fontsUnavailable": "Не ўдалося спампаваць шрыфт, патрэбны для некаторых сімвалаў. Праверце злучэнне і паспрабуйце яшчэ раз, інакш гэтыя сімвалы могуць адлюстроўвацца няправільна ў захаваным PDF.", "howToUpload": "Запампуйце PDF", "howToClick": "Націсніце на абзац", "howToRestyle": "Змяніце стыль", diff --git a/public/locales/da/tools.json b/public/locales/da/tools.json index fffc2a54..52277ccc 100644 --- a/public/locales/da/tools.json +++ b/public/locales/da/tools.json @@ -60,6 +60,8 @@ "subtitle": "Klik på et hvilket som helst afsnit, og redigér det direkte med live-ombrydning, skrifttyper og formatering.", "saved": "PDF blev gemt! ({{size}} KB)", "failedToLoad": "PDF-teksteditoren kunne ikke indlæses.", + "loadingFonts": "Indlæser skrifttyper...", + "fontsUnavailable": "En skrifttype, der er nødvendig for nogle tegn, kunne ikke downloades. Tjek din forbindelse, og prøv igen, ellers vises disse tegn muligvis ikke korrekt i den gemte PDF.", "howToUpload": "Upload PDF", "howToClick": "Klik på et afsnit", "howToRestyle": "Giv alt ny stil", diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json index e90cebc5..5c6cfa46 100644 --- a/public/locales/de/tools.json +++ b/public/locales/de/tools.json @@ -60,6 +60,8 @@ "subtitle": "Auf einen beliebigen Absatz klicken und ihn direkt bearbeiten – mit Live-Umbruch, Schriftarten und Formatierung.", "saved": "PDF erfolgreich gespeichert! ({{size}} KB)", "failedToLoad": "Der PDF-Texteditor konnte nicht geladen werden.", + "loadingFonts": "Schriftarten werden geladen...", + "fontsUnavailable": "Eine für einige Zeichen benötigte Schriftart konnte nicht heruntergeladen werden. Prüfen Sie Ihre Verbindung und versuchen Sie es erneut, sonst werden diese Zeichen im gespeicherten PDF möglicherweise nicht korrekt angezeigt.", "howToUpload": "PDF hochladen", "howToClick": "Absatz anklicken", "howToRestyle": "Alles umgestalten", diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json index 5838c8a3..4976b13d 100644 --- a/public/locales/en/tools.json +++ b/public/locales/en/tools.json @@ -61,6 +61,8 @@ "subtitle": "Click any paragraph and edit it in place with live reflow, fonts, and styling.", "saved": "PDF saved successfully! ({{size}} KB)", "failedToLoad": "Failed to load the PDF Text Editor.", + "loadingFonts": "Loading fonts...", + "fontsUnavailable": "A font needed for some characters could not be downloaded. Check your connection and try again, or those characters may not appear correctly in the saved PDF.", "howToUpload": "Upload PDF", "howToClick": "Click a Paragraph", "howToRestyle": "Restyle Anything", diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json index 46f83148..ddbe8db9 100644 --- a/public/locales/es/tools.json +++ b/public/locales/es/tools.json @@ -60,6 +60,8 @@ "subtitle": "Haz clic en cualquier párrafo y edítalo directamente con reajuste de texto en vivo, fuentes y estilos.", "saved": "¡PDF guardado correctamente! ({{size}} KB)", "failedToLoad": "No se pudo cargar el editor de texto PDF.", + "loadingFonts": "Cargando fuentes...", + "fontsUnavailable": "No se pudo descargar una fuente necesaria para algunos caracteres. Comprueba tu conexión e inténtalo de nuevo; de lo contrario, es posible que esos caracteres no se vean correctamente en el PDF guardado.", "howToUpload": "Subir PDF", "howToClick": "Haz clic en un párrafo", "howToRestyle": "Cambia el estilo de todo", diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index 5bbcec34..86813f7b 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -60,6 +60,8 @@ "subtitle": "Cliquer sur un paragraphe et le modifier directement, avec remise en page instantanée, polices et styles.", "saved": "PDF enregistré avec succès ! ({{size}} Ko)", "failedToLoad": "Impossible de charger l'éditeur de texte PDF.", + "loadingFonts": "Chargement des polices...", + "fontsUnavailable": "Impossible de télécharger une police nécessaire à certains caractères. Vérifiez votre connexion et réessayez, sinon ces caractères risquent de ne pas s'afficher correctement dans le PDF enregistré.", "howToUpload": "Importer un PDF", "howToClick": "Cliquer sur un paragraphe", "howToRestyle": "Tout restyler", diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json index 00b3f0b9..0b719639 100644 --- a/public/locales/id/tools.json +++ b/public/locales/id/tools.json @@ -60,6 +60,8 @@ "subtitle": "Klik paragraf mana pun dan edit langsung di tempat dengan penyusunan ulang teks secara langsung, font, dan gaya.", "saved": "PDF berhasil disimpan! ({{size}} KB)", "failedToLoad": "Gagal memuat Editor Teks PDF.", + "loadingFonts": "Memuat font...", + "fontsUnavailable": "Font yang diperlukan untuk beberapa karakter tidak dapat diunduh. Periksa koneksi Anda dan coba lagi, atau karakter tersebut mungkin tidak tampil dengan benar di PDF yang disimpan.", "howToUpload": "Unggah PDF", "howToClick": "Klik sebuah paragraf", "howToRestyle": "Ubah gaya apa pun", diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json index a39cbf3f..29b9926b 100644 --- a/public/locales/it/tools.json +++ b/public/locales/it/tools.json @@ -60,6 +60,8 @@ "subtitle": "Clicca su un paragrafo qualsiasi e modificalo direttamente con ricomposizione del testo in tempo reale, caratteri e stili.", "saved": "PDF salvato correttamente! ({{size}} KB)", "failedToLoad": "Impossibile caricare l'editor di testo PDF.", + "loadingFonts": "Caricamento dei font...", + "fontsUnavailable": "Impossibile scaricare un font necessario per alcuni caratteri. Controlla la connessione e riprova, altrimenti questi caratteri potrebbero non essere visualizzati correttamente nel PDF salvato.", "howToUpload": "Carica PDF", "howToClick": "Fai clic su un paragrafo", "howToRestyle": "Ridefinisci lo stile", diff --git a/public/locales/ja/tools.json b/public/locales/ja/tools.json index 0bfa7c18..5bf2f075 100644 --- a/public/locales/ja/tools.json +++ b/public/locales/ja/tools.json @@ -60,6 +60,8 @@ "subtitle": "段落をクリックしてその場で編集。テキストは自動で再配置され、フォントやスタイルも変更できます。", "saved": "PDF を保存しました!({{size}} KB)", "failedToLoad": "PDF テキストエディタを読み込めませんでした。", + "loadingFonts": "フォントを読み込み中...", + "fontsUnavailable": "一部の文字に必要なフォントをダウンロードできませんでした。接続を確認してもう一度お試しください。このままでは、保存した PDF でそれらの文字が正しく表示されない可能性があります。", "howToUpload": "PDF をアップロード", "howToClick": "段落をクリック", "howToRestyle": "自由にスタイル変更", diff --git a/public/locales/ko/tools.json b/public/locales/ko/tools.json index ff9f95d4..5ce816ae 100644 --- a/public/locales/ko/tools.json +++ b/public/locales/ko/tools.json @@ -60,6 +60,8 @@ "subtitle": "아무 단락이나 클릭해 실시간 리플로우, 글꼴, 스타일과 함께 바로 편집하세요.", "saved": "PDF를 저장했습니다! ({{size}} KB)", "failedToLoad": "PDF 텍스트 편집기를 불러오지 못했습니다.", + "loadingFonts": "글꼴을 불러오는 중...", + "fontsUnavailable": "일부 문자에 필요한 글꼴을 다운로드하지 못했습니다. 연결을 확인하고 다시 시도하세요. 그렇지 않으면 저장된 PDF에서 해당 문자가 올바르게 표시되지 않을 수 있습니다.", "howToUpload": "PDF 업로드", "howToClick": "문단 클릭", "howToRestyle": "무엇이든 스타일 변경", diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json index ae8c1ed8..dcd7b213 100644 --- a/public/locales/nl/tools.json +++ b/public/locales/nl/tools.json @@ -60,6 +60,8 @@ "subtitle": "Klik op een alinea en bewerk deze direct, met live tekstomloop, lettertypen en opmaak.", "saved": "PDF is opgeslagen! ({{size}} KB)", "failedToLoad": "Kan de PDF-teksteditor niet laden.", + "loadingFonts": "Lettertypen laden…", + "fontsUnavailable": "Een lettertype dat nodig is voor sommige tekens kon niet worden gedownload. Controleer de verbinding en probeer het opnieuw, anders worden deze tekens mogelijk niet correct weergegeven in de opgeslagen PDF.", "howToUpload": "PDF uploaden", "howToClick": "Klik op een alinea", "howToRestyle": "Alles opnieuw vormgeven", diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json index 09c0e42a..b2b2a544 100644 --- a/public/locales/pt/tools.json +++ b/public/locales/pt/tools.json @@ -60,6 +60,8 @@ "subtitle": "Clique em qualquer parágrafo e edite-o no local com reorganização do texto em tempo real, fontes e estilos.", "saved": "PDF salvo com sucesso! ({{size}} KB)", "failedToLoad": "Não foi possível carregar o editor de texto PDF.", + "loadingFonts": "Carregando fontes...", + "fontsUnavailable": "Não foi possível baixar uma fonte necessária para alguns caracteres. Verifique sua conexão e tente novamente; caso contrário, esses caracteres podem não aparecer corretamente no PDF salvo.", "howToUpload": "Enviar PDF", "howToClick": "Clique em um parágrafo", "howToRestyle": "Reestilize tudo", diff --git a/public/locales/ru/tools.json b/public/locales/ru/tools.json index df75fec3..ae192f11 100644 --- a/public/locales/ru/tools.json +++ b/public/locales/ru/tools.json @@ -60,6 +60,8 @@ "subtitle": "Нажать на любой абзац и отредактировать его на месте — с мгновенной перекомпоновкой текста, шрифтами и стилями.", "saved": "PDF успешно сохранён! ({{size}} КБ)", "failedToLoad": "Не удалось загрузить редактор текста PDF.", + "loadingFonts": "Загрузка шрифтов...", + "fontsUnavailable": "Не удалось загрузить шрифт, необходимый для некоторых символов. Проверьте подключение и повторите попытку, иначе эти символы могут отображаться неправильно в сохранённом PDF.", "howToUpload": "Загрузить PDF", "howToClick": "Нажмите на абзац", "howToRestyle": "Измените стиль", diff --git a/public/locales/sk/tools.json b/public/locales/sk/tools.json index 70c15f9a..8b115a46 100644 --- a/public/locales/sk/tools.json +++ b/public/locales/sk/tools.json @@ -60,6 +60,8 @@ "subtitle": "Kliknite na ľubovoľný odsek a upravte ho priamo na mieste s okamžitým preformátovaním textu, písmami a štýlmi.", "saved": "PDF bolo úspešne uložené! ({{size}} KB)", "failedToLoad": "Nepodarilo sa načítať textový editor PDF.", + "loadingFonts": "Načítavajú sa písma...", + "fontsUnavailable": "Písmo potrebné pre niektoré znaky sa nepodarilo stiahnuť. Skontrolujte pripojenie a skúste to znova, inak sa tieto znaky v uloženom PDF nemusia zobraziť správne.", "howToUpload": "Nahrať PDF", "howToClick": "Kliknite na odsek", "howToRestyle": "Zmeňte štýl čohokoľvek", diff --git a/public/locales/sv/tools.json b/public/locales/sv/tools.json index 1ea80ce5..d58e574c 100644 --- a/public/locales/sv/tools.json +++ b/public/locales/sv/tools.json @@ -60,6 +60,8 @@ "subtitle": "Klicka på valfritt stycke och redigera det på plats med direkt textomflöde, typsnitt och stilar.", "saved": "PDF-filen sparades! ({{size}} KB)", "failedToLoad": "Det gick inte att läsa in PDF-textredigeraren.", + "loadingFonts": "Läser in typsnitt...", + "fontsUnavailable": "Ett typsnitt som behövs för vissa tecken kunde inte laddas ned. Kontrollera anslutningen och försök igen, annars kanske tecknen inte visas korrekt i den sparade PDF-filen.", "howToUpload": "Ladda upp PDF", "howToClick": "Klicka på ett stycke", "howToRestyle": "Ge allt ny stil", diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json index 8026935a..1c7a80df 100644 --- a/public/locales/tr/tools.json +++ b/public/locales/tr/tools.json @@ -60,6 +60,8 @@ "subtitle": "Herhangi bir paragrafa tıklayın ve canlı yeniden akış, yazı tipleri ve stillerle yerinde düzenleyin.", "saved": "PDF başarıyla kaydedildi! ({{size}} KB)", "failedToLoad": "PDF Metin Düzenleyici yüklenemedi.", + "loadingFonts": "Yazı tipleri yükleniyor...", + "fontsUnavailable": "Bazı karakterler için gereken bir yazı tipi indirilemedi. Bağlantınızı kontrol edip tekrar deneyin, aksi takdirde bu karakterler kaydedilen PDF'de doğru görünmeyebilir.", "howToUpload": "PDF yükle", "howToClick": "Bir paragrafa tıklayın", "howToRestyle": "Her şeyi yeniden biçimlendirin", diff --git a/public/locales/uk/tools.json b/public/locales/uk/tools.json index d2415591..51e2f310 100644 --- a/public/locales/uk/tools.json +++ b/public/locales/uk/tools.json @@ -60,6 +60,8 @@ "subtitle": "Натисніть на будь-який абзац і редагуйте його на місці з миттєвим перекомпонуванням тексту, шрифтами та стилями.", "saved": "PDF успішно збережено! ({{size}} КБ)", "failedToLoad": "Не вдалося завантажити редактор тексту PDF.", + "loadingFonts": "Завантаження шрифтів...", + "fontsUnavailable": "Не вдалося завантажити шрифт, потрібний для деяких символів. Перевірте з'єднання та спробуйте ще раз, інакше ці символи можуть відображатися неправильно у збереженому PDF.", "howToUpload": "Завантажити PDF", "howToClick": "Натисніть на абзац", "howToRestyle": "Змініть стиль", diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json index 97b34d62..c64f6cae 100644 --- a/public/locales/vi/tools.json +++ b/public/locales/vi/tools.json @@ -60,6 +60,8 @@ "subtitle": "Nhấp vào bất kỳ đoạn văn nào và chỉnh sửa ngay tại chỗ với tính năng tự động dàn lại văn bản, phông chữ và kiểu dáng.", "saved": "Đã lưu PDF thành công! ({{size}} KB)", "failedToLoad": "Không thể tải Trình chỉnh sửa văn bản PDF.", + "loadingFonts": "Đang tải phông chữ...", + "fontsUnavailable": "Không thể tải xuống phông chữ cần cho một số ký tự. Hãy kiểm tra kết nối và thử lại, nếu không các ký tự đó có thể không hiển thị đúng trong tệp PDF đã lưu.", "howToUpload": "Tải PDF lên", "howToClick": "Nhấp vào một đoạn văn", "howToRestyle": "Đổi kiểu mọi thứ", diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json index cf614d35..3d16dd70 100644 --- a/public/locales/zh-TW/tools.json +++ b/public/locales/zh-TW/tools.json @@ -60,6 +60,8 @@ "subtitle": "點擊任意段落即可就地編輯,支援即時重排、字型與樣式設定。", "saved": "PDF 儲存成功!({{size}} KB)", "failedToLoad": "無法載入 PDF 文字編輯器。", + "loadingFonts": "正在載入字型…", + "fontsUnavailable": "無法下載部分字元所需的字型。請檢查網路連線後再試一次,否則這些字元在儲存的 PDF 中可能無法正確顯示。", "howToUpload": "上傳 PDF", "howToClick": "點擊段落", "howToRestyle": "隨意變更樣式", diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json index c150c838..589b8076 100644 --- a/public/locales/zh/tools.json +++ b/public/locales/zh/tools.json @@ -60,6 +60,8 @@ "subtitle": "点击任意段落即可就地编辑,支持实时重排、字体和样式设置。", "saved": "PDF 保存成功!({{size}} KB)", "failedToLoad": "无法加载 PDF 文本编辑器。", + "loadingFonts": "正在加载字体...", + "fontsUnavailable": "无法下载部分字符所需的字体。请检查网络连接后重试,否则这些字符在保存的 PDF 中可能无法正确显示。", "howToUpload": "上传 PDF", "howToClick": "点击段落", "howToRestyle": "随意restyle", diff --git a/src/js/editcore/app.js b/src/js/editcore/app.js index a270cd21..e1d32d2c 100644 --- a/src/js/editcore/app.js +++ b/src/js/editcore/app.js @@ -10260,4 +10260,6 @@ export { getZoom, getDocDescription, setOnSaved, + endEdit, + saveFile, }; diff --git a/src/js/editcore/core.js b/src/js/editcore/core.js index e6e24d37..0e238dcb 100644 --- a/src/js/editcore/core.js +++ b/src/js/editcore/core.js @@ -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; diff --git a/src/js/logic/edit-pdf-text-page.ts b/src/js/logic/edit-pdf-text-page.ts index 02f75e7c..70cca668 100644 --- a/src/js/logic/edit-pdf-text-page.ts +++ b/src/js/logic/edit-pdf-text-page.ts @@ -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; } function relocateAddText() { @@ -422,6 +429,155 @@ function fitMobileWidth() { mod.setZoom(target); } +function setupFallbackFonts( + app: EditorAppModule, + fonts: Map +) { + const pending = new Set>(); + 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 }; + }; + setupFallbackFonts(appModule, PdfEngine.fallbackFonts); appModule.setOnSaved((kb) => { showAlert( t('common.success'), diff --git a/src/js/utils/font-loader.ts b/src/js/utils/font-loader.ts index b27c27e2..62e8b007 100644 --- a/src/js/utils/font-loader.ts +++ b/src/js/utils/font-loader.ts @@ -3,6 +3,7 @@ import { getFontUrlForFamily, languageToFontFamily, } from '../config/font-mappings.js'; +import { needsFontEmbedding } from './freetext-script.js'; const fontCache: Map = new Map(); @@ -134,6 +135,56 @@ export async function getFontForLanguage(lang: string): Promise { } } +const fallbackFontLoads = new Map>(); + +const fallbackFontFamily = (lang: string): string => + languageToFontFamily[lang] || 'Noto Sans'; + +export function fallbackFontLanguages(text: string): Set { + const langs = new Set(); + for (const ch of text) { + if (needsFontEmbedding(ch)) langs.add(getLanguageForChar(ch)); + } + return langs; +} + +export function hasFallbackFonts( + loaded: Map, + langs: Iterable +): boolean { + return [...langs].every((lang) => loaded.has(fallbackFontFamily(lang))); +} + +export async function loadFallbackFonts( + into: Map, + langs: Iterable +): Promise { + 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(); diff --git a/src/js/utils/freetext-flatten.ts b/src/js/utils/freetext-flatten.ts index a382f9d4..704e9c83 100644 --- a/src/js/utils/freetext-flatten.ts +++ b/src/js/utils/freetext-flatten.ts @@ -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; localFonts: Map; + fallbackFonts: Map; }; 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; diff --git a/src/tests/font-loader.test.ts b/src/tests/font-loader.test.ts index 585d6f23..6d47bc80 100644 --- a/src/tests/font-loader.test.ts +++ b/src/tests/font-loader.test.ts @@ -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(); + 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(); + 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); + }); + }); }); diff --git a/src/tests/freetext-flatten.test.ts b/src/tests/freetext-flatten.test.ts index f391ac82..fcff4876 100644 --- a/src/tests/freetext-flatten.test.ts +++ b/src/tests/freetext-flatten.test.ts @@ -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);