diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c984cfc..fe3b8b1 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -8,6 +8,7 @@ they cover, and Electron's and Chromium's licenses are in the app's installation | Component | License | Source | | --- | --- | --- | | [pdf.js](https://github.com/mozilla/pdf.js) (`pdfjs-dist`) | Apache-2.0 | Mozilla Foundation | +| [DejaVu Sans](https://dejavu-fonts.github.io/), [fontkit](https://github.com/Hopding/fontkit) and [bidi-js](https://github.com/lojjic/bidi-js) (Arabic editing) | Bitstream Vera and Arev font licenses, MIT libraries | DejaVu, fontkit and bidi-js contributors | | [EmbedPDF PDFium](https://github.com/embedpdf/embed-pdf-viewer/tree/main/packages/pdfium) and [pdf-lib](https://github.com/Hopding/pdf-lib) (content editing) | MIT wrappers, PDFium and bundled dependency notices in `pdfjs/editor-licenses/` | EmbedPDF, PDFium and pdf-lib contributors | | [Tesseract.js](https://github.com/naptha/tesseract.js), its WebAssembly core and [recognition models](https://github.com/naptha/tessdata) | Apache-2.0, worker dependency notices in `ocr/worker.min.js.LICENSE.txt` | Tesseract.js and Tesseract OCR contributors | | pdf.js JBIG2 decoder (from PDFium) | BSD-3-Clause | The PDFium Authors | @@ -53,3 +54,27 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +## fontkit + +MIT License + +Copyright (c) 2014-present Devon Govett + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package-lock.json b/package-lock.json index 42cec42..7982d73 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ }, "devDependencies": { "@embedpdf/pdfium": "2.15.1", + "@pdf-lib/fontkit": "1.1.1", "@tesseract.js-data/deu": "^1.0.0", "@tesseract.js-data/eng": "^1.0.0", "@tesseract.js-data/spa": "^1.0.0", @@ -22,6 +23,8 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", + "bidi-js": "1.1.0", + "dejavu-fonts-ttf": "2.37.3", "electron": "^45.2.1", "electron-builder": "^26.18.0", "fake-indexeddb": "^6.2.7", @@ -952,6 +955,16 @@ "url": "https://github.com/sponsors/oxc-project" } }, + "node_modules/@pdf-lib/fontkit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", + "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, "node_modules/@pdf-lib/standard-fonts": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", @@ -2979,6 +2992,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dejavu-fonts-ttf": { + "version": "2.37.3", + "resolved": "https://registry.npmjs.org/dejavu-fonts-ttf/-/dejavu-fonts-ttf-2.37.3.tgz", + "integrity": "sha512-f1hd7jJbeQa1VWcw+K2KrTXS50zTMaHpVC4XIKJpNcDeYR5ajMtj/iLlQDYNvLOKamUB3ARVVCf79lNwNVztSQ==", + "dev": true, + "license": "SEE LICENSE IN README.md AND LICENSE" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", diff --git a/package.json b/package.json index 6c4c977..2bfc62d 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@embedpdf/pdfium": "2.15.1", + "@pdf-lib/fontkit": "1.1.1", "@tesseract.js-data/deu": "^1.0.0", "@tesseract.js-data/eng": "^1.0.0", "@tesseract.js-data/spa": "^1.0.0", @@ -35,6 +36,8 @@ "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", + "bidi-js": "1.1.0", + "dejavu-fonts-ttf": "2.37.3", "electron": "^45.2.1", "electron-builder": "^26.18.0", "fake-indexeddb": "^6.2.7", diff --git a/scripts/copy-pdfjs-assets.mjs b/scripts/copy-pdfjs-assets.mjs index f2cdf1b..b700041 100644 --- a/scripts/copy-pdfjs-assets.mjs +++ b/scripts/copy-pdfjs-assets.mjs @@ -16,5 +16,6 @@ for (const dir of ["cmaps", "standard_fonts", "wasm", "iccs"]) { // Worker-only packages aren't included in Vite's renderer license report. for (const [pkg, license] of [ ["@embedpdf/pdfium", "LICENSE"], ["@embedpdf/pdfium", "LICENSE.pdfium"], ["pdf-lib", "LICENSE.md"], + ["dejavu-fonts-ttf", "LICENSE"], ["bidi-js", "LICENSE.txt"], ["@pdf-lib/standard-fonts", "LICENSE.md"], ["@pdf-lib/upng", "LICENSE"], ["pako", "LICENSE"], ["tslib", "LICENSE.txt"], ]) cpSync(resolve(root, "node_modules", pkg, license), resolve(dest, "editor-licenses", pkg, license)); diff --git a/scripts/fixtures/arabic-editing.pdf b/scripts/fixtures/arabic-editing.pdf new file mode 100644 index 0000000..f003ed0 Binary files /dev/null and b/scripts/fixtures/arabic-editing.pdf differ diff --git a/scripts/smoke-reader-tools.mjs b/scripts/smoke-reader-tools.mjs index 09f1d38..bb1abea 100644 --- a/scripts/smoke-reader-tools.mjs +++ b/scripts/smoke-reader-tools.mjs @@ -541,7 +541,61 @@ async function checkRightToLeftEditing(app, page, shots) { await page.getByRole("button", { name: "Exit edit mode" }).click(); await page.getByRole("button", { name: "Edit mode", exact: true }).waitFor(); assert.equal(await page.getByRole("dialog").count(), 0); - console.log("ok right-to-left text is read-only in Edit mode and can't be typed into other text"); + console.log("ok Hebrew text remains protected in Edit mode"); +} + +async function checkArabicEditing(app, page, folder, shots) { + // This synthetic PDF has subset fonts, Arabic ligatures, and separately positioned glyphs. + const source = readFileSync(path.join(import.meta.dirname, "fixtures", "arabic-editing.pdf")); + const open = (name, bytes) => app.evaluate(({ BrowserWindow }, { name, bytes }) => + BrowserWindow.getAllWindows()[0].webContents.send("folio:open-file", { name, data: Buffer.from(bytes) }), + { name, bytes: Array.from(bytes) }); + await open("Arabic editing.pdf", source); + await page.window.waitForFunction(() => document.title.startsWith("Arabic editing.pdf")); + const edit = page.getByRole("button", { name: "Edit mode", exact: true }); + await edit.click(); + const sheet = page.locator('.page[data-page-number="1"]'); + const text = label => sheet.getByRole("button", { name: label, exact: true }); + const input = sheet.getByRole("textbox", { name: "Edit text on page" }); + await text("مراد ناصر").click(); + assert.equal(await input.inputValue(), "مراد ناصر"); + assert.equal(await input.evaluate(el => getComputedStyle(el).direction), "rtl"); + const greeting = "مَرْحَبًا 2026"; + await input.fill(greeting); + await page.screenshot({ path: path.join(shots, "arabic-edit-after.png") }); + // Switching directly to another run commits the first edit and selects the second. + await text("شركة الخطوط يويو").click(); + await page.waitForFunction(() => document.querySelector(".folio-inline-text")?.value === "شركة الخطوط يويو"); + await input.fill("شركة الخطوط"); + await sheet.click({ position: { x: 5, y: 5 } }); + await text("شركة الخطوط").waitFor(); + await text(greeting).waitFor(); + const file = path.join(folder, "arabic-copy.pdf"); + await captureDownload(app, file); + await page.getByRole("button", { name: "Save copy", exact: true }).click(); + assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed"); + const bytes = readFileSync(file); + const extracted = await page.evaluate(async bytes => { + const task = pdfjsLib.getDocument({ data: Uint8Array.from(bytes) }); + try { + const content = await (await (await task.promise).getPage(1)).getTextContent(); + return content.items.map(item => item.str || "").join(" ").normalize("NFKC"); + } finally { await task.destroy(); } + }, Array.from(bytes)); + assert.ok(extracted.includes("شركة الخطوط"), extracted); + assert.ok(extracted.includes(greeting) && !extracted.includes("يويو"), extracted); + await open("Reopened Arabic.pdf", bytes); + await page.window.waitForFunction(() => document.title.startsWith("Reopened Arabic.pdf")); + await edit.click(); + await text(greeting).click(); + assert.equal(await input.inputValue(), greeting); + await input.fill("أهلا"); + await input.press("Enter"); + await text("أهلا").waitFor(); + await captureDownload(app, path.join(folder, "arabic-edited-again.pdf")); + await page.getByRole("button", { name: "Save copy", exact: true }).click(); + assert.equal(await app.evaluate(() => globalThis.folioReordered), "completed"); + console.log("ok Arabic ligatures, diacritics, and numbers survive editing, saving, and reopening"); } export function linkHistoryPdf() { @@ -1004,6 +1058,7 @@ export async function checkReaderTools(app, page, folder, shots) { await checkScanSelection(app, page); await checkContentEditing(app, page, folder, shots); await checkRightToLeftEditing(app, page, shots); + await checkArabicEditing(app, page, folder, shots); await checkLinkHistory(app, page, shots); await checkPageExport(app, page, folder, shots); await checkPenDrawing(app, page, folder, shots); diff --git a/scripts/test-text-direction.mjs b/scripts/test-text-direction.mjs index b098d34..ed39613 100644 --- a/scripts/test-text-direction.mjs +++ b/scripts/test-text-direction.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { test } from "node:test"; -import { hasRightToLeft, RIGHT_TO_LEFT_MESSAGE } from "../src/textDirection.ts"; +import { hasRightToLeft, unsupportedDirection, RIGHT_TO_LEFT_MESSAGE } from "../src/textDirection.ts"; const at = (...points) => String.fromCodePoint(...points); @@ -32,3 +32,8 @@ test("left-to-right text is left alone", () => { test("the message says what can't be edited", () => { assert.match(RIGHT_TO_LEFT_MESSAGE, /right-to-left/); }); + +test("Arabic editing accepts Arabic and mixed Latin lines and preserves the protection for other RTL scripts", () => { + for (const text of ["مَرْحَبًا 2026", "PDF (العربية)", "Original"]) assert.equal(unsupportedDirection(text), false); + for (const text of ["שלום", "العربية שלום", "ܫܠܡܐ"]) assert.equal(unsupportedDirection(text), true); +}); diff --git a/src/ContentEditor.tsx b/src/ContentEditor.tsx index e6f5ddb..e2d2840 100644 --- a/src/ContentEditor.tsx +++ b/src/ContentEditor.tsx @@ -5,7 +5,7 @@ import type { PDFDocumentProxy } from "./pdf"; import type { EventBus, PDFViewer } from "./viewer"; import type { Change, EditorReply, EditorRequest, EditTarget } from "./contentEditor.worker"; import type { PdfChange } from "./versionHistory"; -import { hasRightToLeft, RIGHT_TO_LEFT_MESSAGE } from "./textDirection"; +import { isArabicText, unsupportedDirection, RIGHT_TO_LEFT_MESSAGE } from "./textDirection"; export interface ContentEditorHandle { finish: () => Promise; deselect: () => boolean } interface Surface { page: number; host: HTMLDivElement; viewport: PageViewport } @@ -37,10 +37,11 @@ function inputStyle(t: EditTarget, v: PageViewport, paper: string): CSSPropertie const size = Math.max(1, Math.hypot(ux-ox,uy-oy)); const angle = Math.atan2(ey-oy,ex-ox), box = rectangle(t,v); const width = Math.abs(Math.cos(angle))*box.width + Math.abs(Math.sin(angle))*box.height; - return { left: ox, top: oy, minWidth: Math.max(width+4, size), fontSize: size, + const anchor = s.anchor ? v.convertToViewportPoint(s.anchor[0], s.anchor[1]) : [ox,oy]; + return { left: anchor[0], top: anchor[1], minWidth: Math.max(width+4, size), fontSize: size, fontFamily: s.flags & 1 ? "monospace" : s.flags & 2 ? "serif" : "sans-serif", fontWeight: s.bold ? 700 : 400, fontStyle: s.flags & 64 ? "italic" : "normal", color: s.color, background: paper, - transform: 'rotate(' + angle + 'rad) translateY(-0.88em)' }; + transform: 'rotate(' + angle + 'rad) ' + (s.anchor ? 'translate(-100%, -0.88em)' : 'translateY(-0.88em)') }; } export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, apply, onDirty }: { @@ -159,13 +160,12 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, const pick = (s: Surface, target: EditTarget) => { if (pending.current) return; - // Opening right-to-left text in the editor would show it reversed and then refuse every change. - if (target.kind === "text" && hasRightToLeft(target.label)) { + if (target.kind === "text" && unsupportedDirection(target.label)) { selectedRef.current = null; setSelected(null); setError(RIGHT_TO_LEFT_MESSAGE); return; } setError(""); setValue(target.label); setSelected({ page: s.page, target, paper: target.style ? paperColor(s,target) : "" }); - if (!target.style) return; + if (!target.style || target.fitted || isArabicText(target.label)) return; // Use the same embedded or system font as the page renderer while the caret is visible. const origin = target.style.origin; void pdf.getPage(s.page).then(async page => { @@ -211,7 +211,7 @@ export function ContentEditor({ ref, nextSelection, pdf, viewer, bus, snapshot, return <> {surfaces.map(s => createPortal(<> {(targets.get(s.page) ?? []).map(t =>