mirror of
https://github.com/alam00000/bentopdf.git
synced 2026-11-07 16:41:00 +01:00
fix(xps): repair scanner XPS packages that MuPDF fails to parse
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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]> = [
|
||||
['<!--', '-->'],
|
||||
['<![CDATA[', ']]>'],
|
||||
['<?', '?>'],
|
||||
['<!', '>'],
|
||||
];
|
||||
|
||||
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 `</${inner.slice(1).trim()}>`;
|
||||
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<Blob> {
|
||||
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||
const repaired = new JSZip();
|
||||
const pieces = new Map<string, Array<{ index: number; data: ArrayBuffer }>>();
|
||||
|
||||
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<Blob> {
|
||||
try {
|
||||
return await pymupdf.convertToPdf(file, { filetype: 'xps' });
|
||||
} catch (error) {
|
||||
try {
|
||||
return await pymupdf.convertToPdf(await repairXpsPackage(file), {
|
||||
filetype: 'xps',
|
||||
});
|
||||
} catch {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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('<A B="1" C="2"\0/>')).toBe('<A B="1" C="2"/>');
|
||||
});
|
||||
|
||||
it('drops attributes MuPDF cannot read and fixes "/ >"', () => {
|
||||
expect(repairXml('<A Ïd="x" Width="1" / >')).toBe('<A Width="1"/>');
|
||||
});
|
||||
|
||||
it('keeps comments, CDATA, values with > and closing tags intact', () => {
|
||||
const xml =
|
||||
'<?xml version="1.0" encoding="windows-1252"?><!-- a > b --><A N="a>b"><![CDATA[<x y>]]></A >';
|
||||
expect(repairXml(xml)).toBe(
|
||||
'<?xml version="1.0" encoding="utf-8"?><!-- a > b --><A N="a>b"><![CDATA[<x y>]]></A>'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repairXpsPackage', () => {
|
||||
it('decodes BOM-less UTF-16 and joins interleaved pieces', async () => {
|
||||
const page = '<FixedPage Width="816"/>';
|
||||
const zip = new JSZip();
|
||||
zip.file(
|
||||
'_rels/.rels',
|
||||
new Uint8Array(
|
||||
Buffer.from('<?xml version="1.0" encoding="utf-16"?><R/>', '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(
|
||||
'<?xml version="1.0" encoding="utf-8"?><R/>'
|
||||
);
|
||||
expect(await out.file('1.fpage')?.async('string')).toBe(
|
||||
'<FixedPage Width="816"/>'
|
||||
);
|
||||
expect(Array.from(await out.file('a.png')!.async('uint8array'))).toEqual([
|
||||
0, 0xff, 1,
|
||||
]);
|
||||
expect(out.file(/piece/)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user