Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion mcp-stdio/package.json
Original file line number Diff line number Diff line change
@@ -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.",
Expand Down
2 changes: 1 addition & 1 deletion mcpb/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion open-pdf-cad/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion open-pdf-cmyk/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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)"
Expand Down
2 changes: 1 addition & 1 deletion open-pdf-render/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion open-pdf-render/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
46 changes: 44 additions & 2 deletions open-pdf-studio/js/pdf/office-document.test.mjs
Original file line number Diff line number Diff line change
@@ -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 }),
Expand All @@ -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 }),
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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);
});
53 changes: 50 additions & 3 deletions open-pdf-studio/js/pdf/office-export.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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.',
}));
Expand All @@ -54,16 +59,56 @@ 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) {
const page = await pdf.getPage(number);
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) {
Expand All @@ -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(); }
}
}
4 changes: 2 additions & 2 deletions open-pdf-studio/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion open-pdf-studio/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
21 changes: 19 additions & 2 deletions open-pdf-studio/scripts/pdfjs-postscript-progress.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions open-pdf-studio/scripts/pdfjs-zero-font.test.mjs
Original file line number Diff line number Diff line change
@@ -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/);
}
});
2 changes: 2 additions & 0 deletions open-pdf-studio/scripts/unit-testlijst.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions open-pdf-studio/scripts/zero-font-fixture.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
Loading
Loading