From 6ff4d49c73d5f03b3cabcd0e89119be01dd9d207 Mon Sep 17 00:00:00 2001 From: oscar981-op Date: Fri, 30 Oct 2026 09:43:01 +0100 Subject: [PATCH] fix: preserve visible text after zero-size PDF fonts (#878) --- .github/workflows/release.yml | 2 +- Cargo.lock | 12 +-- mcp-stdio/package.json | 2 +- mcpb/manifest.json | 2 +- open-pdf-cad/Cargo.toml | 2 +- open-pdf-cmyk/Cargo.toml | 2 +- open-pdf-render/Cargo.lock | 2 +- open-pdf-render/Cargo.toml | 2 +- .../js/pdf/office-document.test.mjs | 46 +++++++++- open-pdf-studio/js/pdf/office-export.js | 53 ++++++++++- open-pdf-studio/package-lock.json | 4 +- open-pdf-studio/package.json | 2 +- .../scripts/pdfjs-postscript-progress.mjs | 21 ++++- .../scripts/pdfjs-zero-font.test.mjs | 30 +++++++ open-pdf-studio/scripts/unit-testlijst.txt | 2 + open-pdf-studio/scripts/zero-font-fixture.mjs | 20 +++++ .../scripts/zero-font-office-e2e.mjs | 88 +++++++++++++++++++ open-pdf-studio/scripts/zero-font-probe.mjs | 51 +++++++++++ open-pdf-studio/src-tauri/Cargo.lock | 4 +- open-pdf-studio/src-tauri/Cargo.toml | 2 +- open-pdf-studio/src-tauri/tauri.conf.json | 2 +- open-pdf-thumbnails/Cargo.toml | 2 +- pdfium-worker/Cargo.toml | 2 +- snap/snapcraft.yaml | 2 +- 24 files changed, 327 insertions(+), 30 deletions(-) create mode 100644 open-pdf-studio/scripts/pdfjs-zero-font.test.mjs create mode 100644 open-pdf-studio/scripts/zero-font-fixture.mjs create mode 100644 open-pdf-studio/scripts/zero-font-office-e2e.mjs create mode 100644 open-pdf-studio/scripts/zero-font-probe.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 683dd9bbe..840fee516 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,7 +9,7 @@ on: version: description: 'Version tag vYYYY.W.N (year, ISO week, sequence); must match the version fields' required: true - default: 'v2026.44.4' + default: 'v2026.44.24' jobs: create-release: diff --git a/Cargo.lock b/Cargo.lock index ef28a018f..5864235ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3660,7 +3660,7 @@ dependencies = [ [[package]] name = "open-pdf-cad" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "acadrust", "flate2", @@ -3672,7 +3672,7 @@ dependencies = [ [[package]] name = "open-pdf-cmyk" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "flate2", "image", @@ -3685,7 +3685,7 @@ dependencies = [ [[package]] name = "open-pdf-render" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "flate2", "hayro-font", @@ -3700,7 +3700,7 @@ dependencies = [ [[package]] name = "open-pdf-studio" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "aes", "anyhow", @@ -3773,7 +3773,7 @@ dependencies = [ [[package]] name = "open-pdf-thumbnails" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "libloading 0.9.0", "windows 0.61.3", @@ -3974,7 +3974,7 @@ dependencies = [ [[package]] name = "pdfium-worker" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "anyhow", "memmap2", diff --git a/mcp-stdio/package.json b/mcp-stdio/package.json index 3f5904313..68c7e08dd 100644 --- a/mcp-stdio/package.json +++ b/mcp-stdio/package.json @@ -1,6 +1,6 @@ { "name": "open-pdf-studio-mcp-stdio", - "version": "2026.44.4", + "version": "2026.44.24", "private": true, "type": "module", "description": "Thin stdio↔HTTP bridge that exposes Open PDF Studio's in-app MCP server (assistant + PDF tools) to Claude Desktop / Claude Code.", diff --git a/mcpb/manifest.json b/mcpb/manifest.json index 23c469ab5..805d22fa8 100644 --- a/mcpb/manifest.json +++ b/mcpb/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "open-pdf-studio", "display_name": "Open PDF Studio", - "version": "2026.44.4", + "version": "2026.44.24", "description": "Open, measure, annotate and save PDF drawings in Open PDF Studio, the free open-source PDF editor for the construction industry.", "long_description": "Connects Claude to Open PDF Studio running on your computer. Claude can open PDF drawings, read and navigate pages, place scale regions, measure areas and lengths, create and edit annotations, cut vector snippets from one drawing into another, fill title blocks, place a quantity schedule on the sheet and save the result.\n\nEverything runs locally: the extension talks to the app on 127.0.0.1 and the PDF file itself is never sent to an external service. Claude only sees what it asks for, such as a picture of the current page view.\n\n**Before you start:** install Open PDF Studio (free, from open-aec.com). The AI link is on by default; check it under **Settings > General > AI link (MCP)**. Tools that change a document always ask for your permission.", "author": { diff --git a/open-pdf-cad/Cargo.toml b/open-pdf-cad/Cargo.toml index 6541361a5..0975cbd12 100644 --- a/open-pdf-cad/Cargo.toml +++ b/open-pdf-cad/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open-pdf-cad" -version = "2026.44.4" +version = "2026.44.24" edition = "2021" license = "LGPL-3.0-or-later" description = "CAD exchange for PDF pages: exports vector content to DXF and DWG through one shared geometry model" diff --git a/open-pdf-cmyk/Cargo.toml b/open-pdf-cmyk/Cargo.toml index a2c6606c9..6718001ad 100644 --- a/open-pdf-cmyk/Cargo.toml +++ b/open-pdf-cmyk/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open-pdf-cmyk" -version = "2026.44.4" +version = "2026.44.24" edition = "2021" license = "LGPL-3.0-or-later" description = "Converts the RGB colour of a PDF to CMYK through an ICC printing profile (PDF/X export)" diff --git a/open-pdf-render/Cargo.lock b/open-pdf-render/Cargo.lock index abccd6d08..8c936a060 100644 --- a/open-pdf-render/Cargo.lock +++ b/open-pdf-render/Cargo.lock @@ -441,7 +441,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "open-pdf-render" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "flate2", "hayro-font", diff --git a/open-pdf-render/Cargo.toml b/open-pdf-render/Cargo.toml index efd9450db..bbba887ab 100644 --- a/open-pdf-render/Cargo.toml +++ b/open-pdf-render/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open-pdf-render" -version = "2026.44.4" +version = "2026.44.24" edition = "2021" license = "MIT" description = "Pure Rust PDF page renderer — renders PDF pages to RGBA bitmaps" diff --git a/open-pdf-studio/js/pdf/office-document.test.mjs b/open-pdf-studio/js/pdf/office-document.test.mjs index e2bc9265b..deeebd947 100644 --- a/open-pdf-studio/js/pdf/office-document.test.mjs +++ b/open-pdf-studio/js/pdf/office-document.test.mjs @@ -1,15 +1,18 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; +import { isWachtwoordFout, vraagEnOntsleutel } from './wachtwoord-pdf.js'; const source = readFileSync(new URL('./office-export.js', import.meta.url), 'utf8'); const exportSource = source.slice(source.indexOf('export async function exportOffice')) .replace('export async function', 'async function'); const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; }; -function harness({ saveFileDialog = async name => `/tmp/${name}` } = {}) { +function harness({ saveFileDialog = async name => `/tmp/${name}`, parseError } = {}) { const content = deferred(); const extracting = deferred(); + const source = { bytes: new Uint8Array([1, 2, 3]) }; + let destroyed = 0; const page = { rotate: 0, getViewport: () => ({ width: 595, height: 842 }), @@ -23,6 +26,14 @@ function harness({ saveFileDialog = async name => `/tmp/${name}` } = {}) { const state = { documents: [doc] }; const writes = [], messages = [], built = []; const deps = { + isWachtwoordFout, vraagEnOntsleutel, + getCachedPdfBytes: () => source.bytes, + pdfjsBronnen: () => ({}), + pdfjsLib: { getDocument: options => { + assert.equal(options.stopAtErrors, true); + assert.notEqual(options.data, source.bytes); + return { promise: parseError ? Promise.reject(parseError) : Promise.resolve(pdf), destroy: async () => { destroyed++; } }; + } }, state, getActiveDocument: () => state.documents[0], saveFileDialog, writeBinaryFileAtomic: async (path, bytes) => writes.push({ path, bytes }), @@ -34,7 +45,7 @@ function harness({ saveFileDialog = async name => `/tmp/${name}` } = {}) { pageImage: async () => new Uint8Array(), }; const exportOffice = new Function(...Object.keys(deps), `${exportSource}\nreturn exportOffice;`)(...Object.values(deps)); - return { content, extracting, doc, state, writes, messages, built, exportOffice }; + return { content, extracting, doc, state, writes, messages, built, exportOffice, source, destroyed: () => destroyed }; } test('closing the source document during Office extraction prevents an output file', async () => { @@ -108,3 +119,34 @@ test('already saved edits do not block Office export', async () => { assert.equal(await exporting, true); assert.equal(h.writes.length, 1); }); + + +test('replacing cached source bytes during extraction prevents stale Office output and destroys the private parser', async () => { + const h = harness(); + const exporting = h.exportOffice({ format: 'docx', pages: [1] }); + await h.extracting.promise; + h.source.bytes = new Uint8Array([4, 5, 6]); + h.content.resolve({ items: [] }); + assert.equal(await exporting, false); + assert.deepEqual(h.writes, []); + assert.match(h.messages[0], /documentChangedDuringExport/); + assert.equal(h.destroyed(), 1); +}); + +test('successful Office export destroys the private parser without changing source bytes', async () => { + const h = harness(); + const exporting = h.exportOffice({ format: 'docx', pages: [1] }); + h.content.resolve({ items: [] }); + assert.equal(await exporting, true); + assert.deepEqual([...h.source.bytes], [1, 2, 3]); + assert.equal(h.destroyed(), 1); +}); + + +test('strict parser failure is reported without writing a file and cleans up', async () => { + const h = harness({ parseError: new Error('Invalid XObject') }); + assert.equal(await h.exportOffice({ format: 'docx', pages: [1] }), false); + assert.deepEqual(h.writes, []); + assert.match(h.messages[0], /Invalid XObject/); + assert.equal(h.destroyed(), 1); +}); diff --git a/open-pdf-studio/js/pdf/office-export.js b/open-pdf-studio/js/pdf/office-export.js index 94403fb76..5d80ab1ae 100644 --- a/open-pdf-studio/js/pdf/office-export.js +++ b/open-pdf-studio/js/pdf/office-export.js @@ -1,3 +1,7 @@ +import * as pdfjsLib from 'pdfjs-dist'; +import { getCachedPdfBytes } from './loader.js'; +import { pdfjsBronnen } from '../core/basis-url.js'; +import { isWachtwoordFout, vraagEnOntsleutel } from './wachtwoord-pdf.js'; import { state, getActiveDocument } from '../core/state.js'; import { saveFileDialog, writeBinaryFileAtomic } from '../core/platform.js'; import { showLoading, hideLoading } from '../ui/chrome/dialogs.js'; @@ -36,8 +40,9 @@ export async function exportOffice({ format, pages, includeImages = false, decim } const pdf = doc.pdfDoc; const sourcePath = doc.filePath; + const sourceBytes = getCachedPdfBytes(sourcePath); const ensureSource = () => { - if (!state.documents.includes(doc) || doc.pdfDoc !== pdf || doc.filePath !== sourcePath) { + if (!state.documents.includes(doc) || doc.pdfDoc !== pdf || doc.filePath !== sourcePath || getCachedPdfBytes(sourcePath) !== sourceBytes) { throw new Error(i18next.t('documentChangedDuringExport', { defaultValue: 'The document changed during export. Start again.', })); @@ -54,8 +59,46 @@ export async function exportOffice({ format, pages, includeImages = false, decim const path = await saveFileDialog(`${base}.${format}`, [{ name: OFFICE_FORMATS[format].filter, extensions: [format] }]); if (!path) return false; const outputPath = path.toLowerCase().endsWith(`.${format}`) ? path : `${path}.${format}`; + let extractionTask; showLoading(t('officeWorking')); try { + ensureSource(); + // Display stays tolerant of damaged PDFs, but export must not turn a + // failed extraction into an apparently successful empty document (#878). + if (!sourceBytes?.length) throw new Error('PDF source bytes are unavailable. Reopen the document before exporting.'); + const openExtraction = async password => { + ensureSource(); + extractionTask = pdfjsLib.getDocument({ + data: sourceBytes.slice(), ...pdfjsBronnen(), password, + isEvalSupported: false, stopAtErrors: true, + }); + try { + return await extractionTask.promise; + } catch (error) { + await extractionTask.destroy(); + extractionTask = null; + throw error; + } + }; + let extractionPdf; + try { + extractionPdf = await openExtraction(); + } catch (error) { + if (!isWachtwoordFout(error)) throw error; + // Desktop uses decrypted working bytes. The browser keeps encrypted + // bytes and deliberately does not retain the opening password. + const { metWachtwoordDialoog, vraagWachtwoord } = await import('../solid/stores/wachtwoordDialoogStore.js'); + hideLoading(); + extractionPdf = await metWachtwoordDialoog(() => vraagEnOntsleutel({ + vraag: ({ verkeerd }) => { + ensureSource(); + return vraagWachtwoord({ fileName: doc.fileName, verkeerd }); + }, + ontsleutel: openExtraction, + })); + if (!extractionPdf) return false; + showLoading(t('officeWorking')); + } ensureSource(); const output = []; for (const number of selected) { @@ -63,7 +106,9 @@ export async function exportOffice({ format, pages, includeImages = false, decim ensureSource(); const rotation = ((page.rotate + (doc.pageRotations?.[number] || 0)) % 360 + 360) % 360; const viewport = page.getViewport({ scale: 1, rotation }); - const content = await page.getTextContent(); + const textPage = await extractionPdf.getPage(number); + ensureSource(); + const content = await textPage.getTextContent(); ensureSource(); let boxes = textItemsToBoxes(content.items, viewport); if (!boxes.length && doc.ocrResults?.[number]?.length) { @@ -88,5 +133,7 @@ export async function exportOffice({ format, pages, includeImages = false, decim console.error('[office-export]', error); showMessage(`${t('officeFailed')}\n${error?.message || String(error)}`); return false; - } finally { hideLoading(); } + } finally { + try { await extractionTask?.destroy(); } finally { hideLoading(); } + } } diff --git a/open-pdf-studio/package-lock.json b/open-pdf-studio/package-lock.json index 826abe623..c8efccde6 100644 --- a/open-pdf-studio/package-lock.json +++ b/open-pdf-studio/package-lock.json @@ -1,12 +1,12 @@ { "name": "open-pdf-studio", - "version": "2026.44.4", + "version": "2026.44.24", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "open-pdf-studio", - "version": "2026.44.4", + "version": "2026.44.24", "license": "LGPL-3.0-or-later", "dependencies": { "@crabnebula/tauri-plugin-drag": "^2.1.0", diff --git a/open-pdf-studio/package.json b/open-pdf-studio/package.json index edfb6805f..4525aa3e6 100644 --- a/open-pdf-studio/package.json +++ b/open-pdf-studio/package.json @@ -1,6 +1,6 @@ { "name": "open-pdf-studio", - "version": "2026.44.4", + "version": "2026.44.24", "description": "A free, open-source PDF annotation editor built with Tauri", "scripts": { "prepare:native-runtime": "node scripts/native-runtime.mjs", diff --git a/open-pdf-studio/scripts/pdfjs-postscript-progress.mjs b/open-pdf-studio/scripts/pdfjs-postscript-progress.mjs index dc1c9c356..37cd017d0 100644 --- a/open-pdf-studio/scripts/pdfjs-postscript-progress.mjs +++ b/open-pdf-studio/scripts/pdfjs-postscript-progress.mjs @@ -65,6 +65,23 @@ export function patchPostscriptProgress(source) { return source.replace(BEFORE, AFTER); } +// Mozilla PDF.js #22384, d7fd526d5e5176ccd6f066fc6b48d45ee1560af7. +// Apache-2.0, Copyright Mozilla Foundation. The initial font can be null +// after zero-size text; preserve all subsequent visible text (#878). +const ZERO_FONT_BEFORE = 'currentTextState.font.name !== textState.font.name'; +const ZERO_FONT_AFTER = 'currentTextState.font?.name !== textState.font.name'; +export function patchZeroInitialFont(source) { + if (source.split(ZERO_FONT_AFTER).length === 2 && !source.includes(ZERO_FONT_BEFORE)) return source; + if (source.split(ZERO_FONT_BEFORE).length !== 2 || source.includes(ZERO_FONT_AFTER)) { + throw new Error('PDF.js zero-font backport no longer matches; qualify the worker before updating PDF.js (#878).'); + } + return source.replace(ZERO_FONT_BEFORE, ZERO_FONT_AFTER); +} + +export function patchPdfjsWorker(source) { + return patchZeroInitialFont(patchPostscriptProgress(source)); +} + export function pdfjsPostscriptProgress() { let worker; let devPath; @@ -76,8 +93,8 @@ export function pdfjsPostscriptProgress() { build = config.command === 'build'; const require = createRequire(resolve(config.root, 'package.json')); const version = JSON.parse(readFileSync(require.resolve('pdfjs-dist/package.json'), 'utf8')).version; - if (version !== '6.4.299') throw new Error(`Qualify PDF.js ${version} before removing the #839 backport.`); - worker = patchPostscriptProgress(readFileSync(require.resolve('pdfjs-dist/legacy/build/pdf.worker.mjs'), 'utf8')); + if (version !== '6.4.299') throw new Error(`Qualify PDF.js ${version} before removing the #839/#878 backports.`); + worker = patchPdfjsWorker(readFileSync(require.resolve('pdfjs-dist/legacy/build/pdf.worker.mjs'), 'utf8')); devPath = `${config.base.startsWith('/') ? config.base : '/'}node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs`; }, configureServer(server) { diff --git a/open-pdf-studio/scripts/pdfjs-zero-font.test.mjs b/open-pdf-studio/scripts/pdfjs-zero-font.test.mjs new file mode 100644 index 000000000..96e8ea5b3 --- /dev/null +++ b/open-pdf-studio/scripts/pdfjs-zero-font.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { patchZeroInitialFont, patchPdfjsWorker } from './pdfjs-postscript-progress.mjs'; +const require = createRequire(import.meta.url); +const source = readFileSync(require.resolve('pdfjs-dist/legacy/build/pdf.worker.mjs'), 'utf8'); +test('zero-font backport matches once, composes with PostScript, is idempotent and rejects drift', () => { + const patched = patchZeroInitialFont(source); + assert.notEqual(patched, source); + assert.equal(patchZeroInitialFont(patched), patched); + const both = patchPdfjsWorker(source); + assert.equal(patchPdfjsWorker(both), both); + assert.throws(() => patchZeroInitialFont(source + source), /no longer matches/); + assert.throws(() => patchZeroInitialFont(source.replace('currentTextState.font.name !== textState.font.name', 'differentCode()')), /no longer matches/); +}); +for (const mode of ['original', 'transformed']) + test(`real legacy worker extraction and Office round trip: ${mode}`, () => { + const p = spawnSync(process.execPath, [fileURLToPath(new URL('./zero-font-probe.mjs', import.meta.url)), mode], { encoding: 'utf8', timeout: 30000 }); + if (mode === 'original') { + assert.notEqual(p.status, 0); + assert.match(p.stderr, /Cannot read properties of null/); + } + else { + assert.equal(p.status, 0, p.stdout + p.stderr); + assert.match(p.stdout, /PASS/); + } + }); diff --git a/open-pdf-studio/scripts/unit-testlijst.txt b/open-pdf-studio/scripts/unit-testlijst.txt index cef1ae72f..065843b84 100644 --- a/open-pdf-studio/scripts/unit-testlijst.txt +++ b/open-pdf-studio/scripts/unit-testlijst.txt @@ -626,3 +626,5 @@ js/watermark/watermark-custom-position.test.mjs js/watermark/watermark-autofit.test.mjs js/watermark/watermark-dialog-i18n.test.mjs js/watermark/watermark-text-history.test.mjs + +scripts/pdfjs-zero-font.test.mjs diff --git a/open-pdf-studio/scripts/zero-font-fixture.mjs b/open-pdf-studio/scripts/zero-font-fixture.mjs new file mode 100644 index 000000000..3868c8a12 --- /dev/null +++ b/open-pdf-studio/scripts/zero-font-fixture.mjs @@ -0,0 +1,20 @@ +import { PDFDocument, PDFName, StandardFonts } from 'pdf-lib'; +// Tiny generated fixture: no third-party PDF assets or user documents. +export async function zeroFontFixture({ form = false, zero = true, broken = false } = {}) { + const pdf = await PDFDocument.create(); + const font = await pdf.embedFont(StandardFonts.Helvetica); + const page = pdf.addPage([400, 300]); + const resources = pdf.context.obj({ Font: { F1: font.ref } }); + const program = `BT /F1 ${zero ? 0 : 12} Tf 30 220 Td (hidden) Tj ET\nBT /F1 24 Tf 30 180 Td (Visible text) Tj ET`; + let contents = program; + if (form || broken) { + const ref = pdf.context.register(pdf.context.flateStream(program, { + Type: 'XObject', Subtype: broken ? 'Invalid' : 'Form', BBox: [0, 0, 400, 300], Resources: resources, + })); + resources.set(PDFName.of('XObject'), pdf.context.obj({ X1: ref })); + contents = broken ? '/Missing Do' : '/X1 Do'; + } + page.node.set(PDFName.of('Resources'), resources); + page.node.set(PDFName.of('Contents'), pdf.context.register(pdf.context.flateStream(contents))); + return pdf.save(); +} diff --git a/open-pdf-studio/scripts/zero-font-office-e2e.mjs b/open-pdf-studio/scripts/zero-font-office-e2e.mjs new file mode 100644 index 000000000..04bc158ba --- /dev/null +++ b/open-pdf-studio/scripts/zero-font-office-e2e.mjs @@ -0,0 +1,88 @@ +// node scripts/zero-font-office-e2e.mjs [isolated Vite URL] +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { chromium } from 'playwright'; +import { zeroFontFixture } from './zero-font-fixture.mjs'; +import { unzip } from '../js/bcf/bcf-zip.js'; +const browser = await chromium.launch({ headless: true }); +try { + const page = await browser.newPage({ viewport: { width: 1500, height: 1000 } }); + await page.goto(process.argv[2] || 'http://127.0.0.1:3073', { waitUntil: 'domcontentloaded', timeout: 90000 }); + await page.locator('[data-tab="view"]').waitFor({ timeout: 90000 }); + const load = async (bytes, name) => page.evaluate(async ({ bytes, name }) => { + const { cacheWebFile } = await import('/js/core/platform.js'); + const { createTab } = await import('/js/ui/chrome/tabs.js'); + const { loadPDFIfNeeded } = await import('/js/pdf/loader.js'); + const { getActiveDocument } = await import('/js/core/state.ts'); + cacheWebFile(name, new Uint8Array(bytes)); + const { index } = createTab(name); + await loadPDFIfNeeded(name, index); + window.fixture = { getActiveDocument }; + }, { bytes: [...bytes], name }); + for (const [index, opts] of [{}, { form: true }, { zero: false }].entries()) { + await load(await zeroFontFixture(opts), `zero-${index}.pdf`); + const text = await page.evaluate(async () => { + const doc = window.fixture.getActiveDocument(), p = await doc.pdfDoc.getPage(1); + const search = await (await import('/js/search/find-controller.js')).extractPageText(doc.pdfDoc, 1, doc); + const host = document.createElement('div'); + host.style.cssText = 'position:absolute;left:0;top:0;width:400px;height:300px'; + document.body.append(host); + const layer = await (await import('/js/text/text-layer.js')).createTextLayer(p, p.getViewport({ scale: 1 }), host, 1); + const span = [...layer.querySelectorAll('span')].find(s => s.textContent === 'Visible text'); + if (!span) + throw Error('Visible text missing from selectable layer'); + const range = document.createRange(); + range.selectNodeContents(span); + window.getSelection().removeAllRanges(); + window.getSelection().addRange(range); + document.dispatchEvent(new Event('selectionchange')); + const selected = (await import('/js/text/text-selection.js')).getSelectedText(); + host.remove(); + return { search: search.text, selected }; + }); + assert.match(text.search, /Visible text/); + assert.equal(text.selected, 'Visible text'); + for (const format of ['docx', 'odt', 'xlsx']) { + const [download, ok] = await Promise.all([page.waitForEvent('download'), page.evaluate(async (format) => (await import('/js/pdf/office-export.js')).exportOffice({ format, pages: [1] }), format)]); + assert.equal(ok, true); + const entries = await unzip(new Uint8Array(readFileSync(await download.path()))); + const xml = [...entries].filter(([name]) => name.endsWith('.xml')).map(([, bytes]) => new TextDecoder().decode(bytes)).join('\n'); + assert.match(xml, /Visible text/); + } + } + // Browser opening passwords are not retained. Re-authenticate for strict + // extraction, including a wrong-password retry and cancellation. + const encrypted = new Uint8Array(readFileSync(new URL('../src-tauri/tests/fixtures/beveiligd/wachtwoord-geheim.pdf', import.meta.url))); + const opening = load(encrypted, 'protected-878.pdf'); + await page.locator('input[type="password"]').fill('geheim'); + await page.locator('#pdf-password-ok').click(); + await opening; + const protectedExport = page.evaluate(async () => (await import('/js/pdf/office-export.js')).exportOffice({ format: 'docx', pages: [1] })); + await page.locator('input[type="password"]').fill('wrong'); + await page.locator('#pdf-password-ok').click(); + await page.locator('#pdf-password-ok:not([disabled])').waitFor(); + await page.locator('input[type="password"]').fill('geheim'); + const protectedDownload = page.waitForEvent('download'); + await page.locator('#pdf-password-ok').click(); + assert.equal(await protectedExport, true); + assert.ok((await protectedDownload).suggestedFilename().endsWith('.docx')); + let cancelledDownloads = 0; + page.on('download', () => cancelledDownloads++); + const cancelledExport = page.evaluate(async () => (await import('/js/pdf/office-export.js')).exportOffice({ format: 'docx', pages: [1] })); + await page.locator('#pdf-password-cancel').click(); + assert.equal(await cancelledExport, false); + assert.equal(cancelledDownloads, 0); + // Malformed XObject produces a dependency warning in tolerant display, but + // strict Office extraction must fail visibly and must never download a file. + await load(await zeroFontFixture({ broken: true }), 'broken-878.pdf'); + let downloads = 0; + page.on('download', () => downloads++); + const result = await page.evaluate(async () => (await import('/js/pdf/office-export.js')).exportOffice({ format: 'docx', pages: [1] })); + assert.equal(result, false); + assert.equal(downloads, 0); + await page.getByText(/XObject should be a stream/).waitFor(); + console.log('PASS: actual Office downloads DOCX/ODT/XLSX preserve visible text; search and selection work; malformed extraction reports error without download.'); +} +finally { + await browser.close(); +} diff --git a/open-pdf-studio/scripts/zero-font-probe.mjs b/open-pdf-studio/scripts/zero-font-probe.mjs new file mode 100644 index 000000000..0861e1c30 --- /dev/null +++ b/open-pdf-studio/scripts/zero-font-probe.mjs @@ -0,0 +1,51 @@ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; +import { zeroFontFixture } from './zero-font-fixture.mjs'; +import { patchPdfjsWorker } from './pdfjs-postscript-progress.mjs'; +import { workerVoorOudeWebview } from './oude-webview.mjs'; +import { textItemsToBoxes, recoverLayout } from '../js/pdf/office-layout.js'; +import { buildDocx } from '../js/pdf/office-files.js'; +import { unzip } from '../js/bcf/bcf-zip.js'; +const require = createRequire(import.meta.url); +const root = mkdtempSync(join(tmpdir(), 'opds-zero-font-')); +try { + let source = readFileSync(process.argv[3] || require.resolve('pdfjs-dist/legacy/build/pdf.worker.mjs'), 'utf8'); + if (!['original', 'shipped'].includes(process.argv[2])) + source = patchPdfjsWorker(source); + if (process.argv[2] === 'transformed') + source = await workerVoorOudeWebview(source); + const file = join(root, 'worker.mjs'); + writeFileSync(file, source); + const pdfjs = await import('pdfjs-dist/legacy/build/pdf.mjs'); + pdfjs.GlobalWorkerOptions.workerSrc = pathToFileURL(file).href; + for (const opts of [{}, { form: true }, { zero: false }, { form: true, zero: false }]) { + const task = pdfjs.getDocument({ data: await zeroFontFixture(opts), stopAtErrors: true, useSystemFonts: true }); + try { + const doc = await task.promise, page = await doc.getPage(1), content = await page.getTextContent(); + assert.match(content.items.map(i => i.str).join(''), /Visible text/); + const viewport = page.getViewport({ scale: 1 }); + const bytes = buildDocx([{ number: 1, width: 400, height: 300, blocks: recoverLayout(textItemsToBoxes(content.items, viewport)) }]); + const zip = await unzip(bytes); + assert.match(new TextDecoder().decode(zip.get('word/document.xml')), /Visible text/); + } + finally { + await task.destroy(); + } + } + const task = pdfjs.getDocument({ data: await zeroFontFixture({ broken: true }), stopAtErrors: true }); + try { + const doc = await task.promise; + await assert.rejects(async () => (await doc.getPage(1)).getTextContent(), /XObject|Invalid/); + } + finally { + await task.destroy(); + } + console.log('PASS: zero font, Form, controls, DOCX content, strict extraction error'); +} +finally { + rmSync(root, { recursive: true, force: true }); +} diff --git a/open-pdf-studio/src-tauri/Cargo.lock b/open-pdf-studio/src-tauri/Cargo.lock index fce5c41c3..20afdd9e6 100644 --- a/open-pdf-studio/src-tauri/Cargo.lock +++ b/open-pdf-studio/src-tauri/Cargo.lock @@ -3223,7 +3223,7 @@ dependencies = [ [[package]] name = "open-pdf-render" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "flate2", "hayro-font", @@ -3237,7 +3237,7 @@ dependencies = [ [[package]] name = "open-pdf-studio" -version = "2026.44.4" +version = "2026.44.24" dependencies = [ "axum", "base64 0.22.1", diff --git a/open-pdf-studio/src-tauri/Cargo.toml b/open-pdf-studio/src-tauri/Cargo.toml index 3bfe8d1cb..c895c26d5 100644 --- a/open-pdf-studio/src-tauri/Cargo.toml +++ b/open-pdf-studio/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open-pdf-studio" -version = "2026.44.4" +version = "2026.44.24" description = "A free, open-source PDF annotation editor" authors = ["OpenAEC Foundation"] license = "LGPL-3.0-or-later" diff --git a/open-pdf-studio/src-tauri/tauri.conf.json b/open-pdf-studio/src-tauri/tauri.conf.json index 285c8045e..cda2f593b 100644 --- a/open-pdf-studio/src-tauri/tauri.conf.json +++ b/open-pdf-studio/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Open PDF Studio", - "version": "2026.44.4", + "version": "2026.44.24", "identifier": "org.openaec.openpdfstudio", "build": { "frontendDist": "../dist", diff --git a/open-pdf-thumbnails/Cargo.toml b/open-pdf-thumbnails/Cargo.toml index 4bc60a583..307d56d0e 100644 --- a/open-pdf-thumbnails/Cargo.toml +++ b/open-pdf-thumbnails/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "open-pdf-thumbnails" -version = "2026.44.4" +version = "2026.44.24" edition = "2021" license = "LGPL-3.0-or-later" description = "Windows Shell thumbnail handler for PDF files (File Explorer previews, #605)" diff --git a/pdfium-worker/Cargo.toml b/pdfium-worker/Cargo.toml index c24300e0b..621c1b33a 100644 --- a/pdfium-worker/Cargo.toml +++ b/pdfium-worker/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pdfium-worker" -version = "2026.44.4" +version = "2026.44.24" edition = "2021" [[bin]] diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml index 97278c37b..7ff379544 100644 --- a/snap/snapcraft.yaml +++ b/snap/snapcraft.yaml @@ -1,6 +1,6 @@ name: open-pdf-studio base: core24 -version: '2026.44.4' +version: '2026.44.24' summary: Free, open-source PDF editor for drawings and documents description: | Open PDF Studio is a free, open-source PDF editor and annotator for people