From f7a8a60440ab64011740d809840e7ead17589d94 Mon Sep 17 00:00:00 2001 From: alam00000 Date: Sun, 25 Oct 2026 13:19:57 +0530 Subject: [PATCH] fix(xps): repair scanner XPS packages that MuPDF fails to parse --- src/js/logic/xps-to-pdf-page.ts | 9 +- src/js/utils/xps-repair.ts | 147 +++++++++++++++++++++++ src/js/workflow/nodes/xps-to-pdf-node.ts | 3 +- src/tests/xps-repair.test.ts | 57 +++++++++ 4 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 src/js/utils/xps-repair.ts create mode 100644 src/tests/xps-repair.test.ts diff --git a/src/js/logic/xps-to-pdf-page.ts b/src/js/logic/xps-to-pdf-page.ts index 0b4f89d3..e9489380 100644 --- a/src/js/logic/xps-to-pdf-page.ts +++ b/src/js/logic/xps-to-pdf-page.ts @@ -3,6 +3,7 @@ import { downloadFile, formatBytes } from '../utils/helpers.js'; import { state } from '../state.js'; import { createIcons, icons } from 'lucide'; import { loadPyMuPDF } from '../utils/pymupdf-loader.js'; +import { convertXpsToPdf } from '../utils/xps-repair.js'; import { deduplicateFileName } from '../utils/deduplicate-filename.js'; import { trText, onPageReady } from '../i18n/i18n.js'; @@ -104,9 +105,7 @@ onPageReady(() => { trText('Converting {{name}}...', { name: originalFile.name }) ); - const pdfBlob = await pymupdf.convertToPdf(originalFile, { - filetype: FILETYPE, - }); + const pdfBlob = await convertXpsToPdf(pymupdf, originalFile); const fileName = originalFile.name.replace(/\.[^.]+$/, '') + '.pdf'; downloadFile(pdfBlob, fileName); @@ -136,9 +135,7 @@ onPageReady(() => { }) ); - const pdfBlob = await pymupdf.convertToPdf(file, { - filetype: FILETYPE, - }); + const pdfBlob = await convertXpsToPdf(pymupdf, file); const baseName = file.name.replace(/\.[^.]+$/, ''); const pdfBuffer = await pdfBlob.arrayBuffer(); const zipEntryName = deduplicateFileName( diff --git a/src/js/utils/xps-repair.ts b/src/js/utils/xps-repair.ts new file mode 100644 index 00000000..1ca81646 --- /dev/null +++ b/src/js/utils/xps-repair.ts @@ -0,0 +1,147 @@ +import JSZip from 'jszip'; +import type { PyMuPDFInstance } from '@/types'; + +const XML_PART = /\.(rels|fdseq|fdoc|fpage|dict|xml)$/i; +const PIECE = /^(.*)[/\\]\[(\d+)\](?:\.last)?\.piece$/i; +const ATTRIBUTE_NAME = /^[A-Za-z0-9._:-]+$/; +const ATTRIBUTE = + /([^\s\p{Cc}=/>"']+)\s*=\s*("[^"]*"|'[^']*'|[^\s"'>]*[^\s"'>/])/gu; +const VERBATIM: Array<[string, string]> = [ + [''], + [''], + [''], + [''], +]; + +function decodeXml(bytes: Uint8Array): string { + const [a, b, c] = bytes; + if (a === 0xff && b === 0xfe) + return new TextDecoder('utf-16le').decode(bytes); + if (a === 0xfe && b === 0xff) + return new TextDecoder('utf-16be').decode(bytes); + if (a === 0x3c && b === 0) return new TextDecoder('utf-16le').decode(bytes); + if (a === 0 && b === 0x3c) return new TextDecoder('utf-16be').decode(bytes); + if (a === 0xef && b === 0xbb && c === 0xbf) { + return new TextDecoder().decode(bytes); + } + const head = new TextDecoder('latin1').decode(bytes.subarray(0, 256)); + const declared = head.match( + /^\s*<\?xml[^>]*?encoding\s*=\s*["']([^"']+)["']/i + )?.[1]; + if (!declared || /^utf-?16/i.test(declared)) { + return new TextDecoder().decode(bytes); + } + try { + return new TextDecoder(declared).decode(bytes); + } catch { + return new TextDecoder().decode(bytes); + } +} + +function rebuildTag(inner: string): string { + if (inner.startsWith('/')) return ``; + const name = inner.match(/^\s*([^\s/>]+)/); + if (!name) return `<${inner}>`; + const rest = inner.slice(name[0].length); + const attributes = Array.from(rest.matchAll(ATTRIBUTE)) + .filter(([, attribute]) => ATTRIBUTE_NAME.test(attribute)) + .map(([, attribute, value]) => + /^["']/.test(value) ? `${attribute}=${value}` : `${attribute}="${value}"` + ); + const end = /\/\s*$/.test(rest) ? '/>' : '>'; + return `<${[name[1], ...attributes].join(' ')}${end}`; +} + +export function repairXml(source: string): string { + const text = source.replace(/\0/g, ''); + let out = ''; + let i = 0; + while (i < text.length) { + const start = text.indexOf('<', i); + if (start === -1) { + out += text.slice(i); + break; + } + out += text.slice(i, start); + const verbatim = VERBATIM.find(([open]) => text.startsWith(open, start)); + if (verbatim) { + const close = text.indexOf(verbatim[1], start + verbatim[0].length); + const stop = close === -1 ? text.length : close + verbatim[1].length; + out += text.slice(start, stop); + i = stop; + continue; + } + let j = start + 1; + let quote = ''; + while (j < text.length && (quote || text[j] !== '>')) { + if (quote) { + if (text[j] === quote) quote = ''; + } else if (text[j] === '"' || text[j] === "'") { + quote = text[j]; + } + j++; + } + out += rebuildTag(text.slice(start + 1, j)); + i = j + 1; + } + return out.replace( + /^(\s*<\?xml[^>]*?encoding\s*=\s*)["'][^"']*["']/, + '$1"utf-8"' + ); +} + +function repairPart(name: string, bytes: Uint8Array): Uint8Array { + if (!XML_PART.test(name)) return bytes; + return new TextEncoder().encode(repairXml(decodeXml(bytes))); +} + +export async function repairXpsPackage(file: Blob): Promise { + const zip = await JSZip.loadAsync(await file.arrayBuffer()); + const repaired = new JSZip(); + const pieces = new Map>(); + + for (const entry of Object.values(zip.files)) { + if (entry.dir) continue; + const piece = entry.name.match(PIECE); + if (piece) { + const list = pieces.get(piece[1]) ?? []; + list.push({ + index: Number(piece[2]), + data: await entry.async('arraybuffer'), + }); + pieces.set(piece[1], list); + continue; + } + repaired.file( + entry.name, + repairPart(entry.name, await entry.async('uint8array')) + ); + } + + for (const [name, list] of pieces) { + list.sort((x, y) => x.index - y.index); + const merged = await new Blob(list.map((p) => p.data)).arrayBuffer(); + repaired.file(name, repairPart(name, new Uint8Array(merged))); + } + + return new Blob([ + await repaired.generateAsync({ type: 'arraybuffer', compression: 'STORE' }), + ]); +} + +export async function convertXpsToPdf( + pymupdf: PyMuPDFInstance, + file: Blob +): Promise { + try { + return await pymupdf.convertToPdf(file, { filetype: 'xps' }); + } catch (error) { + try { + return await pymupdf.convertToPdf(await repairXpsPackage(file), { + filetype: 'xps', + }); + } catch { + throw error; + } + } +} diff --git a/src/js/workflow/nodes/xps-to-pdf-node.ts b/src/js/workflow/nodes/xps-to-pdf-node.ts index 56ac3a36..79fe47b4 100644 --- a/src/js/workflow/nodes/xps-to-pdf-node.ts +++ b/src/js/workflow/nodes/xps-to-pdf-node.ts @@ -3,6 +3,7 @@ import { BaseWorkflowNode } from './base-node'; import { pdfSocket } from '../sockets'; import type { PDFData, SocketData, MultiPDFData } from '../types'; import { loadPyMuPDF } from '../../utils/pymupdf-loader.js'; +import { convertXpsToPdf } from '../../utils/xps-repair.js'; import { loadPdfDocument } from '../../utils/load-pdf-document.js'; import { wfError } from '../errors'; @@ -53,7 +54,7 @@ export class XpsToPdfNode extends BaseWorkflowNode { const pymupdf = await loadPyMuPDF(); const results: PDFData[] = []; for (const file of this.files) { - const blob = await pymupdf.convertToPdf(file, { filetype: 'xps' }); + const blob = await convertXpsToPdf(pymupdf, file); const bytes = new Uint8Array(await blob.arrayBuffer()); const document = await loadPdfDocument(bytes); results.push({ diff --git a/src/tests/xps-repair.test.ts b/src/tests/xps-repair.test.ts new file mode 100644 index 00000000..b984f8aa --- /dev/null +++ b/src/tests/xps-repair.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node +import { describe, it, expect } from 'vitest'; +import JSZip from 'jszip'; +import { repairXml, repairXpsPackage } from '@/js/utils/xps-repair'; + +describe('repairXml', () => { + it('turns stray characters between attributes into plain spaces', () => { + expect(repairXml('')).toBe(''); + }); + + it('drops attributes MuPDF cannot read and fixes "/ >"', () => { + expect(repairXml('')).toBe(''); + }); + + it('keeps comments, CDATA, values with > and closing tags intact', () => { + const xml = + ']]>'; + expect(repairXml(xml)).toBe( + ']]>' + ); + }); +}); + +describe('repairXpsPackage', () => { + it('decodes BOM-less UTF-16 and joins interleaved pieces', async () => { + const page = ''; + const zip = new JSZip(); + zip.file( + '_rels/.rels', + new Uint8Array( + Buffer.from('', 'utf16le') + ) + ); + zip.file('1.fpage/[1].last.piece', page.slice(10)); + zip.file('1.fpage/[0].piece', page.slice(0, 10)); + zip.file('a.png', new Uint8Array([0, 0xff, 1])); + + const out = await JSZip.loadAsync( + await ( + await repairXpsPackage( + new Blob([await zip.generateAsync({ type: 'arraybuffer' })]) + ) + ).arrayBuffer() + ); + + expect(await out.file('_rels/.rels')?.async('string')).toBe( + '' + ); + expect(await out.file('1.fpage')?.async('string')).toBe( + '' + ); + expect(Array.from(await out.file('a.png')!.async('uint8array'))).toEqual([ + 0, 0xff, 1, + ]); + expect(out.file(/piece/)).toHaveLength(0); + }); +});