diff --git a/package-lock.json b/package-lock.json index ed5640f..b15c357 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "diff": "^8.0.3", "dompurify": "^3.4.0", "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz", + "fast-xml-parser": "^5.7.1", "heic2any": "^0.0.4", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", @@ -2074,6 +2075,18 @@ "integrity": "sha512-ObyTmabopTMwN6AGwrEkmDbdmM5wnwlRdn9jnJYQ2KbBUPdPDcfNf1QkcWFOVD+qIgSqILz9nK51e22T+x+gkQ==", "license": "ISC" }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@oxc-project/types": { "version": "0.122.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.122.0.tgz", @@ -6993,6 +7006,42 @@ "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", "license": "(MIT AND Zlib)" }, + "node_modules/fast-xml-builder": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.1.tgz", + "integrity": "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -9861,6 +9910,21 @@ "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -11347,6 +11411,18 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/strnum": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz", + "integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/stylis": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", diff --git a/package.json b/package.json index e7fc220..5d0b6e6 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "diff": "^8.0.3", "dompurify": "^3.4.0", "embedpdf-snippet": "file:vendor/embedpdf/embedpdf-snippet-2.9.1.tgz", + "fast-xml-parser": "^5.7.1", "heic2any": "^0.0.4", "highlight.js": "^11.11.1", "html2canvas": "^1.4.1", diff --git a/public/sw.js b/public/sw.js index 4f37700..bd4b8ca 100644 --- a/public/sw.js +++ b/public/sw.js @@ -330,6 +330,22 @@ async function cacheInBatches(cache, urls, batchSize = 5) { self.addEventListener('message', (event) => { if (!event.data) return; + if (event.origin && event.origin !== self.location.origin) { + return; + } + + const source = event.source; + if (source && typeof source === 'object' && 'url' in source) { + try { + const sourceOrigin = new URL(source.url).origin; + if (sourceOrigin !== self.location.origin) { + return; + } + } catch (e) { + return; + } + } + if (event.data.type === 'SKIP_WAITING') { self.skipWaiting(); return; diff --git a/src/js/compare/engine/compare.worker.ts b/src/js/compare/engine/compare.worker.ts index 9c485e3..afc2564 100644 --- a/src/js/compare/engine/compare.worker.ts +++ b/src/js/compare/engine/compare.worker.ts @@ -43,7 +43,26 @@ interface ErrorResult { message: string; } -self.onmessage = function (e: MessageEvent) { +function isValidMessage(data: unknown): data is WorkerMessage { + if (!data || typeof data !== 'object') return false; + const m = data as Record; + if (typeof m.id !== 'number') return false; + if (m.type === 'diff') { + return Array.isArray(m.beforeItems) && Array.isArray(m.afterItems); + } + if (m.type === 'pair') { + return Array.isArray(m.leftPages) && Array.isArray(m.rightPages); + } + return false; +} + +self.onmessage = function (e: MessageEvent) { + if (e.origin && e.origin !== '' && e.origin !== self.location.origin) { + return; + } + if (!isValidMessage(e.data)) { + return; + } const msg = e.data; try { if (msg.type === 'diff') { diff --git a/src/js/handlers/fileHandler.ts b/src/js/handlers/fileHandler.ts index bd0e094..fa5820b 100644 --- a/src/js/handlers/fileHandler.ts +++ b/src/js/handlers/fileHandler.ts @@ -720,7 +720,14 @@ async function handleMultiFileUpload(toolId: string) { 'w-full h-36 sm:h-40 md:h-44 bg-gray-900 rounded-md border-2 border-gray-600 flex items-center justify-center overflow-hidden'; const img = document.createElement('img'); - img.src = url; + try { + const parsed = new URL(url); + if (parsed.protocol === 'blob:') { + img.src = parsed.href; + } + } catch { + console.warn('Invalid blob URL for preview'); + } img.className = 'max-w-full max-h-full object-contain'; const p = document.createElement('p'); diff --git a/src/js/logic/digital-sign-pdf-page.ts b/src/js/logic/digital-sign-pdf-page.ts index 82f2459..f97a1a6 100644 --- a/src/js/logic/digital-sign-pdf-page.ts +++ b/src/js/logic/digital-sign-pdf-page.ts @@ -203,8 +203,15 @@ function initializePage(): void { if (sigImageThumb && sigImagePreview) { const url = URL.createObjectURL(file); - sigImageThumb.src = url; - sigImagePreview.classList.remove('hidden'); + try { + const parsed = new URL(url); + if (parsed.protocol === 'blob:') { + sigImageThumb.src = parsed.href; + sigImagePreview.classList.remove('hidden'); + } + } catch { + console.warn('Invalid blob URL for signature preview'); + } } } }); diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts index b6d7e09..318b482 100644 --- a/src/js/logic/form-creator.ts +++ b/src/js/logic/form-creator.ts @@ -19,6 +19,7 @@ type LucideWindow = Window & { }; }; +import DOMPurify from 'dompurify'; import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js'; import { downloadFile, escapeHtml, hexToRgb } from '../utils/helpers.js'; import { loadPdfWithPasswordPrompt } from '../utils/password-prompt.js'; @@ -1327,7 +1328,7 @@ function showProperties(field: FormField): void { `; } - propertiesPanel.innerHTML = ` + const propertiesHtml = `
@@ -1399,6 +1400,10 @@ function showProperties(field: FormField): void {
`; + propertiesPanel.innerHTML = DOMPurify.sanitize(propertiesHtml, { + ADD_ATTR: ['target'], + }); + // Common listeners const propName = document.getElementById('propName') as HTMLInputElement; const nameError = document.getElementById('nameError') as HTMLDivElement; diff --git a/src/js/utils/xml-to-pdf.ts b/src/js/utils/xml-to-pdf.ts index 506b443..28a9dae 100644 --- a/src/js/utils/xml-to-pdf.ts +++ b/src/js/utils/xml-to-pdf.ts @@ -1,196 +1,280 @@ import { jsPDF } from 'jspdf'; import autoTable from 'jspdf-autotable'; +import { XMLParser } from 'fast-xml-parser'; export interface XmlToPdfOptions { - onProgress?: (percent: number, message: string) => void; + onProgress?: (percent: number, message: string) => void; } interface jsPDFWithAutoTable extends jsPDF { - lastAutoTable?: { finalY: number }; + lastAutoTable?: { finalY: number }; } +const ATTR_PREFIX = '@_'; +const TEXT_KEY = '#text'; + export async function convertXmlToPdf( - file: File, - options?: XmlToPdfOptions + file: File, + options?: XmlToPdfOptions ): Promise { - const { onProgress } = options || {}; + const { onProgress } = options || {}; - onProgress?.(10, 'Reading XML file...'); - const xmlText = await file.text(); + onProgress?.(10, 'Reading XML file...'); + const rawXmlText = await file.text(); + const xmlText = String(rawXmlText) + .replace(//gi, '') + .replace(//gi, '') + .replace(/<\?xml-stylesheet[\s\S]*?\?>/gi, ''); - onProgress?.(30, 'Parsing XML structure...'); - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(xmlText, 'text/xml'); + onProgress?.(30, 'Parsing XML structure...'); + const parser = new XMLParser({ + ignoreAttributes: false, + attributeNamePrefix: ATTR_PREFIX, + textNodeName: TEXT_KEY, + allowBooleanAttributes: true, + parseTagValue: false, + parseAttributeValue: false, + trimValues: true, + processEntities: true, + ignoreDeclaration: true, + ignorePiTags: true, + }); - const parseError = xmlDoc.querySelector('parsererror'); - if (parseError) { - throw new Error('Invalid XML: ' + parseError.textContent); - } + let parsed: Record; + try { + parsed = parser.parse(xmlText) as Record; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error('Invalid XML: ' + toSafeText(msg), { cause: err }); + } - onProgress?.(50, 'Analyzing data structure...'); + const rootKeys = Object.keys(parsed); + if (rootKeys.length === 0) { + throw new Error('Invalid XML: no root element'); + } + const rootName = rootKeys[0]; + const rootValue = parsed[rootName]; - const doc: jsPDFWithAutoTable = new jsPDF({ - orientation: 'landscape', - unit: 'mm', - format: 'a4' - }); + onProgress?.(50, 'Analyzing data structure...'); - const pageWidth = doc.internal.pageSize.getWidth(); - let yPosition = 20; + const doc: jsPDFWithAutoTable = new jsPDF({ + orientation: 'landscape', + unit: 'mm', + format: 'a4', + }); - const root = xmlDoc.documentElement; - const rootName = formatTitle(root.tagName); + const pageWidth = doc.internal.pageSize.getWidth(); + let yPosition = 20; - doc.setFontSize(18); - doc.setFont('helvetica', 'bold'); - doc.text(rootName, pageWidth / 2, yPosition, { align: 'center' }); - yPosition += 15; + doc.setFontSize(18); + doc.setFont('helvetica', 'bold'); + doc.text(formatTitle(rootName), pageWidth / 2, yPosition, { + align: 'center', + }); + yPosition += 15; - onProgress?.(60, 'Generating formatted content...'); + onProgress?.(60, 'Generating formatted content...'); - const children = Array.from(root.children); + if (isPlainObject(rootValue)) { + const rootObj = rootValue; + const childEntries = Object.entries(rootObj).filter( + ([k]) => !k.startsWith(ATTR_PREFIX) && k !== TEXT_KEY + ); - if (children.length > 0) { - const groups = groupByTagName(children); + if (childEntries.length > 0) { + const groups = groupChildrenByTagName(childEntries); - for (const [groupName, elements] of Object.entries(groups)) { - const { headers, rows } = extractTableData(elements); - - if (headers.length > 0 && rows.length > 0) { - if (Object.keys(groups).length > 1) { - doc.setFontSize(14); - doc.setFont('helvetica', 'bold'); - doc.text(formatTitle(groupName), 14, yPosition); - yPosition += 8; - } - - autoTable(doc, { - head: [headers.map(h => formatTitle(h))], - body: rows, - startY: yPosition, - styles: { - fontSize: 9, - cellPadding: 4, - overflow: 'linebreak', - }, - headStyles: { - fillColor: [79, 70, 229], - textColor: 255, - fontStyle: 'bold', - }, - alternateRowStyles: { - fillColor: [243, 244, 246], - }, - margin: { top: 20, left: 14, right: 14 }, - theme: 'striped', - didDrawPage: (data) => { - yPosition = (data.cursor?.y || yPosition) + 10; - } - }); - - yPosition = (doc.lastAutoTable?.finalY || yPosition) + 15; - } + const renderableGroups: Array<[string, Record[]]> = []; + for (const [groupName, elements] of Object.entries(groups)) { + const { headers, rows } = extractTableData(elements); + if (headers.length > 0 && rows.length > 0) { + renderableGroups.push([groupName, elements]); } + } + + for (const [groupName, elements] of renderableGroups) { + const { headers, rows } = extractTableData(elements); + + if (renderableGroups.length > 1) { + doc.setFontSize(14); + doc.setFont('helvetica', 'bold'); + doc.text(formatTitle(groupName), 14, yPosition); + yPosition += 8; + } + + autoTable(doc, { + head: [headers.map((h) => formatTitle(h))], + body: rows, + startY: yPosition, + styles: { + fontSize: 9, + cellPadding: 4, + overflow: 'linebreak', + }, + headStyles: { + fillColor: [79, 70, 229], + textColor: 255, + fontStyle: 'bold', + }, + alternateRowStyles: { + fillColor: [243, 244, 246], + }, + margin: { top: 20, left: 14, right: 14 }, + theme: 'striped', + didDrawPage: (data) => { + yPosition = (data.cursor?.y || yPosition) + 10; + }, + }); + + yPosition = (doc.lastAutoTable?.finalY || yPosition) + 15; + } } else { - const kvPairs = extractKeyValuePairs(root); - if (kvPairs.length > 0) { - autoTable(doc, { - head: [['Property', 'Value']], - body: kvPairs, - startY: yPosition, - styles: { - fontSize: 10, - cellPadding: 5, - }, - headStyles: { - fillColor: [79, 70, 229], - textColor: 255, - fontStyle: 'bold', - }, - columnStyles: { - 0: { fontStyle: 'bold', cellWidth: 60 }, - 1: { cellWidth: 'auto' }, - }, - margin: { left: 14, right: 14 }, - theme: 'striped', - }); - } + const kvPairs = extractKeyValuePairs(rootObj); + if (kvPairs.length > 0) { + autoTable(doc, { + head: [['Property', 'Value']], + body: kvPairs, + startY: yPosition, + styles: { + fontSize: 10, + cellPadding: 5, + }, + headStyles: { + fillColor: [79, 70, 229], + textColor: 255, + fontStyle: 'bold', + }, + columnStyles: { + 0: { fontStyle: 'bold', cellWidth: 60 }, + 1: { cellWidth: 'auto' }, + }, + margin: { left: 14, right: 14 }, + theme: 'striped', + }); + } } + } - onProgress?.(90, 'Finalizing PDF...'); + onProgress?.(90, 'Finalizing PDF...'); - const pdfBlob = doc.output('blob'); + const pdfBlob = doc.output('blob'); - onProgress?.(100, 'Complete!'); - return pdfBlob; + onProgress?.(100, 'Complete!'); + return pdfBlob; } - -function groupByTagName(elements: Element[]): Record { - const groups: Record = {}; - - for (const element of elements) { - const tagName = element.tagName; - if (!groups[tagName]) { - groups[tagName] = []; - } - groups[tagName].push(element); - } - - return groups; +function isPlainObject(val: unknown): val is Record { + return val !== null && typeof val === 'object' && !Array.isArray(val); } -function extractTableData(elements: Element[]): { headers: string[], rows: string[][] } { - if (elements.length === 0) { - return { headers: [], rows: [] }; - } - - const headerSet = new Set(); - for (const element of elements) { - for (const child of Array.from(element.children)) { - headerSet.add(child.tagName); - } - } - const headers = Array.from(headerSet); - - const rows: string[][] = []; - for (const element of elements) { - const row: string[] = []; - for (const header of headers) { - const child = element.querySelector(header); - row.push(child?.textContent?.trim() || ''); - } - rows.push(row); - } - - return { headers, rows }; +function groupChildrenByTagName( + entries: [string, unknown][] +): Record[]> { + const groups: Record[]> = {}; + for (const [tagName, value] of entries) { + const items = Array.isArray(value) ? value : [value]; + const normalized: Record[] = items.map((v) => { + if (isPlainObject(v)) return v; + if (v == null) return {}; + return { [TEXT_KEY]: String(v) }; + }); + groups[tagName] = normalized; + } + return groups; } +function extractTableData(elements: Record[]): { + headers: string[]; + rows: string[][]; +} { + if (elements.length === 0) return { headers: [], rows: [] }; -function extractKeyValuePairs(element: Element): string[][] { - const pairs: string[][] = []; - - for (const child of Array.from(element.children)) { - const key = child.tagName; - const value = child.textContent?.trim() || ''; - if (value) { - pairs.push([formatTitle(key), value]); - } + const headerSet = new Set(); + for (const element of elements) { + for (const key of Object.keys(element)) { + if (key.startsWith(ATTR_PREFIX)) continue; + if (key === TEXT_KEY) continue; + headerSet.add(key); } + } + const headers = Array.from(headerSet); + if (headers.length === 0) return { headers: [], rows: [] }; - for (const attr of Array.from(element.attributes)) { - pairs.push([formatTitle(attr.name), attr.value]); + const rows: string[][] = []; + for (const element of elements) { + const row: string[] = []; + for (const header of headers) { + row.push(toSafeText(stringifyValue(element[header]))); } + rows.push(row); + } - return pairs; + return { headers, rows }; } +function extractKeyValuePairs(obj: Record): string[][] { + const pairs: string[][] = []; + + for (const [key, val] of Object.entries(obj)) { + if (key.startsWith(ATTR_PREFIX)) continue; + if (key === TEXT_KEY) continue; + const strVal = toSafeText(stringifyValue(val)); + if (strVal) { + pairs.push([formatTitle(key), strVal]); + } + } + + for (const [key, val] of Object.entries(obj)) { + if (!key.startsWith(ATTR_PREFIX)) continue; + const attrName = key.slice(ATTR_PREFIX.length); + pairs.push([formatTitle(attrName), toSafeText(stringifyValue(val))]); + } + + return pairs; +} + +function stringifyValue(val: unknown): string { + if (val == null) return ''; + if (typeof val === 'string') return val; + if (typeof val === 'number' || typeof val === 'boolean') return String(val); + if (Array.isArray(val)) { + return val + .map((v) => stringifyValue(v)) + .filter((s) => s.length > 0) + .join(', '); + } + if (isPlainObject(val)) { + const parts: string[] = []; + if (TEXT_KEY in val) { + const t = stringifyValue(val[TEXT_KEY]); + if (t) parts.push(t); + } + for (const [k, v] of Object.entries(val)) { + if (k.startsWith(ATTR_PREFIX)) continue; + if (k === TEXT_KEY) continue; + const inner = stringifyValue(v); + if (inner) parts.push(inner); + } + return parts.join(' '); + } + return String(val); +} + +function toSafeText(raw: string | null | undefined): string { + if (raw == null) return ''; + return ( + String(raw) + // eslint-disable-next-line no-control-regex + .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') + .trim() + ); +} function formatTitle(tagName: string): string { - return tagName - .replace(/[_-]/g, ' ') - .replace(/([a-z])([A-Z])/g, '$1 $2') - .split(' ') - .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(' '); + return tagName + .replace(/[_-]/g, ' ') + .replace(/([a-z])([A-Z])/g, '$1 $2') + .split(' ') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); }