import { PDFDocument } from 'pdf-lib'; import { copyPagesToNewPdf } from './pdf-processing'; import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service'; import { unzipArchive } from './services/zip-inflating.service'; export type ZipAttachmentKind = 'pdf' | 'image'; export type ZipAttachment = { name: string; path: string; kind: ZipAttachmentKind; data: Uint8Array; }; export type SubDocument = { id: string; name: string; attachments: ZipAttachment[]; }; export type ProcessedZipArchive = { name: string; attachments: ZipAttachment[]; subDocuments: SubDocument[]; }; export type ExportMode = 'single' | 'separate'; export type ExportUnit = { name: string; bytes: Uint8Array; }; /** * Drag source/target descriptors shared between the archive editor and the * sub-document editor so HTML5 `dataTransfer` payloads stay consistent. */ export type AttachmentLocation = | { kind: 'loose'; index: number } | { kind: 'subDocument'; id: string; index: number }; const IMAGE_EXTENSIONS = new Set([ '.avif', '.bmp', '.gif', '.heic', '.jpeg', '.jpg', '.jp2', '.png', '.tif', '.tiff', '.webp' ]); const ZIP_META_FILE_NAME = 'xjustiz_nachricht.xml'; const getBaseName = (path: string) => path.split('/').pop() ?? path; const getCurrentDateString = (date: Date) => `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; const createArchiveName = (date: Date, metadata: XJustizMetadata | null) => { const datePrefix = getCurrentDateString(date); if (!metadata) { return `${datePrefix}_unbekannt_unbekannt.pdf`; } return `${datePrefix}_${metadata.sender}_${metadata.receiver}.pdf`; }; const getExtension = (path: string) => { const baseName = getBaseName(path).toLowerCase(); const lastDotIndex = baseName.lastIndexOf('.'); return lastDotIndex === -1 ? '' : baseName.slice(lastDotIndex); }; const isPdf = (path: string) => getExtension(path) === '.pdf'; const isImage = (path: string) => IMAGE_EXTENSIONS.has(getExtension(path)); const isZipArchive = (path: string) => getExtension(path) === '.zip'; const normalizeKey = (value: string) => value.replaceAll('\\', '/').toLowerCase().trim(); const isMacMetadataEntry = (path: string) => { const normalizedPath = normalizeKey(path); const baseName = getBaseName(normalizedPath); return ( normalizedPath.startsWith('__macosx/') || normalizedPath.includes('/__macosx/') || baseName === '.ds_store' || baseName.startsWith('._') ); }; const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentNames: string[]) => { const buckets = new Map(); for (const attachment of attachments) { const keys = new Set([normalizeKey(attachment.path), normalizeKey(attachment.name)]); for (const key of keys) { const bucket = buckets.get(key) ?? []; bucket.push(attachment); buckets.set(key, bucket); } } const usedAttachments = new Set(); const orderedAttachments: ZipAttachment[] = []; const takeAttachment = (key: string) => { const bucket = buckets.get(key); if (!bucket) { return null; } const nextAttachment = bucket.find((attachment) => !usedAttachments.has(attachment)) ?? null; if (nextAttachment) { usedAttachments.add(nextAttachment); } return nextAttachment; }; for (const documentName of documentNames) { const normalizedName = normalizeKey(documentName); const normalizedBaseName = normalizeKey(getBaseName(documentName)); const matchedAttachment = takeAttachment(normalizedName) ?? takeAttachment(normalizedBaseName); if (matchedAttachment) { orderedAttachments.push(matchedAttachment); } } for (const attachment of attachments) { if (!usedAttachments.has(attachment)) { orderedAttachments.push(attachment); } } return orderedAttachments; }; const shouldSkipZipEntry = (path: string) => isMacMetadataEntry(path); const isMetadataFile = (path: string) => getBaseName(path).toLowerCase() === ZIP_META_FILE_NAME; const createAttachment = (path: string, data: Uint8Array): ZipAttachment => ({ name: getBaseName(path), path, kind: isPdf(path) ? 'pdf' : 'image', data }); const extractZipEntries = async (archiveBytes: Uint8Array): Promise => { const files = await unzipArchive(archiveBytes); const attachments: ZipAttachment[] = []; const nestedArchives: ProcessedZipArchive[] = []; let xjustizNachrichtXml: Uint8Array | null = null; for (const [path, data] of Object.entries(files)) { if (shouldSkipZipEntry(path)) { continue; } if (isMetadataFile(path)) { xjustizNachrichtXml = data; continue; } if (isZipArchive(path)) { const childArchives = await extractZipEntries(data); nestedArchives.push(...childArchives); continue; } if (isPdf(path) || isImage(path)) { attachments.push(createAttachment(path, data)); } } const metadata = xjustizNachrichtXml ? parseXJustizMetadata(xjustizNachrichtXml) : null; const documentNames = metadata?.documentNames ?? []; const orderedAttachments = orderAttachmentsByDocumentNames(attachments, documentNames); const archiveName = createArchiveName(new Date(), metadata); const archives: ProcessedZipArchive[] = []; if (orderedAttachments.length > 0) { archives.push({ name: archiveName, attachments: orderedAttachments, subDocuments: [] }); } return [...archives, ...nestedArchives]; }; export const extractZipArchives = async (file: File): Promise => { const archiveBytes = new Uint8Array(await file.arrayBuffer()); return extractZipEntries(archiveBytes); }; const loadImageElement = (src: string) => new Promise((resolve, reject) => { const image = new Image(); image.decoding = 'async'; image.onload = () => resolve(image); image.onerror = () => reject(new Error(`Unable to decode image asset: ${src}`)); image.src = src; }); const toArrayBuffer = (bytes: Uint8Array) => (() => { const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); return copy.buffer; })(); const convertImageBytesToPng = async (imageBytes: Uint8Array) => { if (typeof document === 'undefined') { throw new Error('Image conversion requires a browser environment.'); } const objectUrl = URL.createObjectURL(new Blob([toArrayBuffer(imageBytes)])); try { const image = await loadImageElement(objectUrl); const canvas = document.createElement('canvas'); canvas.width = image.naturalWidth || image.width; canvas.height = image.naturalHeight || image.height; const context = canvas.getContext('2d'); if (!context) { throw new Error('Could not create a canvas context for image conversion.'); } context.drawImage(image, 0, 0); const pngBlob = await new Promise((resolve, reject) => { canvas.toBlob((blob) => { if (blob) { resolve(blob); return; } reject(new Error('Could not encode image asset as PNG.')); }, 'image/png'); }); return new Uint8Array(await pngBlob.arrayBuffer()); } finally { URL.revokeObjectURL(objectUrl); } }; const embedAttachmentImage = async (pdfDocument: PDFDocument, attachment: ZipAttachment) => { const extension = getExtension(attachment.path); const imageData = attachment.data; if (extension === '.jpg' || extension === '.jpeg') { return pdfDocument.embedJpg(imageData); } if (extension === '.png') { return pdfDocument.embedPng(imageData); } const pngBytes = await convertImageBytesToPng(imageData); return pdfDocument.embedPng(pngBytes); }; const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAttachment) => { if (attachment.kind === 'pdf') { const sourcePdf = await PDFDocument.load(attachment.data, { ignoreEncryption: true }); const copiedPages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices()); for (const page of copiedPages) { mergedPdf.addPage(page); } return; } try { const embeddedImage = await embedAttachmentImage(mergedPdf, attachment); const page = mergedPdf.addPage([embeddedImage.width, embeddedImage.height]); page.drawImage(embeddedImage, { x: 0, y: 0, width: page.getWidth(), height: page.getHeight() }); } catch (error) { throw error; } }; export const mergeAttachments = async (attachments: ZipAttachment[]): Promise => { if (attachments.length === 0) { throw new Error('Cannot create a PDF from an empty attachment list.'); } const mergedPdf = await PDFDocument.create(); for (const attachment of attachments) { await mergeAttachmentIntoPdf(mergedPdf, attachment); } return mergedPdf.save(); }; export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => { if (archive.attachments.length === 0 && archive.subDocuments.length === 0) { throw new Error('Cannot create a PDF from an empty archive.'); } return mergeAttachments(flattenArchiveForMerge(archive)); }; /** * Flatten the archive into a single ordered attachment list, preserving * sub-document order first and appending loose attachments at the end. */ export const flattenArchiveForMerge = (archive: ProcessedZipArchive): ZipAttachment[] => { const ordered: ZipAttachment[] = []; for (const subDocument of archive.subDocuments) { ordered.push(...subDocument.attachments); } ordered.push(...archive.attachments); return ordered; }; /** * Create a new sub-document with a stable id from a list of attachments. */ export const createSubDocument = (name: string, attachments: ZipAttachment[]): SubDocument => ({ id: crypto.randomUUID(), name, attachments: attachments.map((attachment) => ({ ...attachment })) }); /** * Reorder sub-documents inside an archive. Matches the semantics of a single * `splice(fromIndex, 1)` -> `splice(toIndex, 0, moved)` move. */ export const moveSubDocument = ( archive: ProcessedZipArchive, fromIndex: number, toIndex: number ): ProcessedZipArchive => { if ( fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= archive.subDocuments.length || toIndex >= archive.subDocuments.length ) { return archive; } const nextSubDocuments = [...archive.subDocuments]; const [movedSubDocument] = nextSubDocuments.splice(fromIndex, 1); nextSubDocuments.splice(toIndex, 0, movedSubDocument); return { ...archive, subDocuments: nextSubDocuments }; }; const removeAt = (list: T[], index: number): T | null => { if (index < 0 || index >= list.length) { return null; } const [item] = list.splice(index, 1); return item ?? null; }; const insertAt = (list: T[], index: number, item: T) => { const clampedIndex = Math.max(0, Math.min(index, list.length)); list.splice(clampedIndex, 0, item); }; /** * Reorder an attachment within its current location (loose list or a single * sub-document's attachment list). Used by the per-list drag handlers. */ export const moveAttachmentInPlace = ( archive: ProcessedZipArchive, location: AttachmentLocation, toIndex: number ): ProcessedZipArchive => { if (location.kind === 'loose') { const nextAttachments = [...archive.attachments]; const moved = removeAt(nextAttachments, location.index); if (!moved) { return archive; } insertAt(nextAttachments, toIndex, moved); return { ...archive, attachments: nextAttachments }; } let changed = false; const nextSubDocuments = archive.subDocuments.map((subDocument) => { if (subDocument.id !== location.id) { return subDocument; } const nextAttachments = [...subDocument.attachments]; const moved = removeAt(nextAttachments, location.index); if (!moved) { return subDocument; } insertAt(nextAttachments, toIndex, moved); changed = true; return { ...subDocument, attachments: nextAttachments }; }); return changed ? { ...archive, subDocuments: nextSubDocuments } : archive; }; /** * Move an attachment from a source location to a target location. Either side * may be the loose list or any sub-document. The attachment moves (not copies), * so the total attachment count is invariant. When source and target point to * the same list, this degenerates to an in-place reorder (explicitly delegated * to `moveAttachmentInPlace` so the indices are normalized correctly). */ export const moveAttachmentIntoSubDocument = ( archive: ProcessedZipArchive, source: AttachmentLocation, target: AttachmentLocation ): ProcessedZipArchive => { const sameList = source.kind === 'loose' && target.kind === 'loose' ? true : source.kind === 'subDocument' && target.kind === 'subDocument' ? source.id === target.id : false; if (sameList) { return moveAttachmentInPlace(archive, source, target.index); } const nextSubDocuments = archive.subDocuments.map((subDocument) => ({ ...subDocument, attachments: [...subDocument.attachments] })); const looseAttachments = [...archive.attachments]; const removeFromList = (location: AttachmentLocation): ZipAttachment | null => { if (location.kind === 'loose') { return removeAt(looseAttachments, location.index); } const target_ = nextSubDocuments.find((item) => item.id === location.id); return target_ ? removeAt(target_.attachments, location.index) : null; }; const insertIntoList = (attachment: ZipAttachment, location: AttachmentLocation) => { if (location.kind === 'loose') { insertAt(looseAttachments, location.index, attachment); return; } const target_ = nextSubDocuments.find((item) => item.id === location.id); if (!target_) { // fall back to loose list end if the referenced sub-document vanished looseAttachments.push(attachment); return; } insertAt(target_.attachments, location.index, attachment); }; const movedAttachment = removeFromList(source); if (!movedAttachment) { return archive; } insertIntoList(movedAttachment, target); return { ...archive, attachments: looseAttachments, subDocuments: nextSubDocuments }; }; /** * Remove a single attachment from a sub-document and return it to the end of * the loose list. Useful as a targeted "move back to loose" action. */ export const removeAttachmentFromSubDocument = ( archive: ProcessedZipArchive, subDocumentId: string, attachmentIndex: number ): ProcessedZipArchive => { const nextSubDocuments = archive.subDocuments.map((subDocument) => ({ ...subDocument, attachments: [...subDocument.attachments] })); const nextLooseAttachments = [...archive.attachments]; const target_ = nextSubDocuments.find((item) => item.id === subDocumentId); if (!target_) { return archive; } const moved = removeAt(target_.attachments, attachmentIndex); if (!moved) { return archive; } nextLooseAttachments.push(moved); return { ...archive, attachments: nextLooseAttachments, subDocuments: nextSubDocuments }; }; /** * Remove an entire sub-document and return its attachments to the end of the * loose list. The sub-document ids of the remaining sub-documents are * preserved. */ export const dissolveSubDocument = ( archive: ProcessedZipArchive, subDocumentId: string ): ProcessedZipArchive => { const dissolved = archive.subDocuments.find((item) => item.id === subDocumentId); if (!dissolved) { return archive; } return { ...archive, attachments: [...archive.attachments, ...dissolved.attachments], subDocuments: archive.subDocuments.filter((item) => item.id !== subDocumentId) }; }; const stripPdfExtension = (name: string) => name.toLowerCase().endsWith('.pdf') ? name.slice(0, -'.pdf'.length) : name; /** * Build a single PDF export unit for one sub-document. The resulting filename * follows the same convention as the separate archive export: * `{archiveName} - {subDocumentName}` (without duplicate `.pdf` suffixes). */ export const buildSubDocumentExport = async ( archiveName: string, subDocument: SubDocument ): Promise => { if (subDocument.attachments.length === 0) { throw new Error('Cannot create a PDF from an empty sub-document.'); } return { name: `${stripPdfExtension(archiveName)} - ${stripPdfExtension(subDocument.name)}`, bytes: await mergeAttachments(subDocument.attachments) }; }; /** * Build one or more PDF export units from the archive. * * - `mode: 'single'` mirrors today's behaviour: one merged PDF covering * sub-documents first, then loose attachments, named after the archive. * - `mode: 'separate'` produces one PDF per sub-document (named * `{archiveName} - {subDocumentName}.pdf`) plus a single merged PDF for any * loose attachments (named after the archive). Empty groups are skipped. */ export const buildArchiveExport = async ( archive: ProcessedZipArchive, mode: ExportMode ): Promise => { if (mode === 'single') { const attachments = flattenArchiveForMerge(archive); if (attachments.length === 0) { throw new Error('Cannot create a PDF from an empty archive.'); } return [ { name: stripPdfExtension(archive.name), bytes: await mergeAttachments(attachments) } ]; } const separateUnits: ExportUnit[] = []; for (const subDocument of archive.subDocuments) { if (subDocument.attachments.length === 0) { continue; } separateUnits.push(await buildSubDocumentExport(archive.name, subDocument)); } if (archive.attachments.length > 0) { separateUnits.push({ name: stripPdfExtension(archive.name), bytes: await mergeAttachments(archive.attachments) }); } if (separateUnits.length === 0) { throw new Error('Cannot create a PDF from an empty archive.'); } return separateUnits; }; /** * One sub-document produced by splitting a PDF attachment into page groups. */ export type PdfSplitGroup = { name: string; attachment: ZipAttachment; }; /** * Result of a page-level PDF split. `source` is the remaining source * attachment after cutting out the assigned pages; `null` removes the * source from the archive entirely (all pages assigned or copy mode off). */ export type PdfSplitResult = { groups: PdfSplitGroup[]; source: ZipAttachment | null; }; /** * Turn a PDF file dropped into the beA workspace into a single-attachment * archive so the editor's splitting/grouping workflow can operate on it. */ export const pdfFileToArchive = async (file: File): Promise => { const bytes = new Uint8Array(await file.arrayBuffer()); const sourcePdf = await PDFDocument.load(bytes); if (sourcePdf.getPageCount() === 0) { throw new Error('The PDF does not contain any pages.'); } const name = file.name.toLowerCase().endsWith('.pdf') ? file.name : `${file.name}.pdf`; return { name, attachments: [ { name, path: file.name, kind: 'pdf', data: bytes } ], subDocuments: [] }; }; /** * Copy a set of pages (1-based) of a PDF attachment into a new synthetic * attachment. The generated `path` carries a unique suffix because `path` is * the identity used for keyed lists, drag payloads, and selection state. */ export const createAttachmentFromPages = async ( source: ZipAttachment, pages: number[], name?: string ): Promise => { if (pages.length === 0) { throw new Error('Cannot create an attachment from an empty page selection.'); } const minPage = Math.min(...pages); const maxPage = Math.max(...pages); const baseName = stripPdfExtension(source.name); const attachmentName = name ?? (pages.length === 1 ? `${baseName}_Seite_${pages[0]}.pdf` : `${baseName}_Seiten_${minPage}-${maxPage}.pdf`); const bytes = await copyPagesToNewPdf( source.data, pages.map((page) => page - 1) ); return { name: attachmentName, path: `${source.path}#seiten-${crypto.randomUUID()}`, kind: 'pdf', data: bytes }; }; /** * Remove a set of pages (1-based) from a PDF attachment. Returns the remaining * attachment, `null` when no pages are left, or the unchanged source when the * page set removes nothing. */ export const removePagesFromAttachment = async ( source: ZipAttachment, pages: number[] ): Promise => { const sourcePdf = await PDFDocument.load(source.data); const pageCount = sourcePdf.getPageCount(); const pagesToRemove = new Set(pages); const keepIndexes: number[] = []; for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { if (!pagesToRemove.has(pageIndex + 1)) { keepIndexes.push(pageIndex); } } if (keepIndexes.length === 0) { return null; } if (keepIndexes.length === pageCount) { return { ...source }; } return { ...source, data: await copyPagesToNewPdf(source.data, keepIndexes) }; }; /** * Apply a completed PDF split to the archive: the source attachment (located * by its unique `path`) is replaced by the remaining pages (or removed), and * the new sub-documents are inserted directly after the source's parent * sub-document so the reading order is preserved. A loose source appends the * new sub-documents at the end. */ export const applyPdfSplit = ( archive: ProcessedZipArchive, sourcePath: string, result: PdfSplitResult ): ProcessedZipArchive => { let sourceSubDocumentId: string | null = null; let insertIndex = -1; const nextSubDocuments: SubDocument[] = []; for (const subDocument of archive.subDocuments) { const sourceIndex = subDocument.attachments.findIndex( (attachment) => attachment.path === sourcePath ); if (sourceIndex === -1) { nextSubDocuments.push(subDocument); continue; } const remainingAttachments = subDocument.attachments.filter( (attachment) => attachment.path !== sourcePath ); if (result.source) { remainingAttachments.splice(sourceIndex, 0, { ...result.source }); } sourceSubDocumentId = subDocument.id; insertIndex = nextSubDocuments.length; nextSubDocuments.push({ ...subDocument, attachments: remainingAttachments }); } const looseSourceIndex = archive.attachments.findIndex( (attachment) => attachment.path === sourcePath ); if (looseSourceIndex === -1 && sourceSubDocumentId === null) { return archive; } const newSubDocuments = result.groups.map((group) => createSubDocument(group.name, [group.attachment]) ); const nextLooseAttachments = archive.attachments.filter( (attachment) => attachment.path !== sourcePath ); if (sourceSubDocumentId === null) { if (result.source) { nextLooseAttachments.splice(looseSourceIndex, 0, { ...result.source }); } return { ...archive, attachments: nextLooseAttachments, subDocuments: [...nextSubDocuments, ...newSubDocuments] }; } nextSubDocuments.splice(insertIndex + 1, 0, ...newSubDocuments); return { ...archive, attachments: nextLooseAttachments, subDocuments: nextSubDocuments }; }; /** * Next free "Teildokument N" number, derived from the maximum existing * trailing number rather than the list length, so dissolve/re-create cycles * cannot produce duplicate names. */ export const nextSubDocumentNumber = (subDocuments: SubDocument[]): number => { const namePattern = /^Teildokument (\d+)$/; let maxNumber = 0; for (const subDocument of subDocuments) { const match = namePattern.exec(subDocument.name.trim()); if (match) { maxNumber = Math.max(maxNumber, Number.parseInt(match[1], 10)); } } return maxNumber + 1; }; const detachAttachmentsByPaths = ( archive: ProcessedZipArchive, paths: Set ): { extracted: ZipAttachment[]; archive: ProcessedZipArchive } => { const extracted: ZipAttachment[] = []; const subDocuments = archive.subDocuments.map((subDocument) => ({ ...subDocument, attachments: subDocument.attachments.filter((attachment) => { if (paths.has(attachment.path)) { extracted.push(attachment); return false; } return true; }) })); const attachments = archive.attachments.filter((attachment) => { if (paths.has(attachment.path)) { extracted.push(attachment); return false; } return true; }); return { extracted, archive: { ...archive, attachments, subDocuments } }; }; /** * Group the attachments identified by `paths` (loose or inside any * sub-document) into a new sub-document appended at the end. Returns the * input archive unchanged when no selected attachment exists. */ export const createSubDocumentFromPaths = ( archive: ProcessedZipArchive, paths: Set ): ProcessedZipArchive => { const { extracted, archive: strippedArchive } = detachAttachmentsByPaths(archive, paths); if (extracted.length === 0) { return archive; } const name = `Teildokument ${nextSubDocumentNumber(archive.subDocuments)}`; return { ...strippedArchive, subDocuments: [...strippedArchive.subDocuments, createSubDocument(name, extracted)] }; }; /** * Move the attachments identified by `paths` (loose or inside any * sub-document) to the end of an existing sub-document's attachment list. */ export const moveAttachmentsByPathsToSubDocument = ( archive: ProcessedZipArchive, paths: Set, targetSubDocumentId: string ): ProcessedZipArchive => { const { extracted, archive: strippedArchive } = detachAttachmentsByPaths(archive, paths); if (extracted.length === 0) { return archive; } let targetFound = false; const subDocuments = strippedArchive.subDocuments.map((subDocument) => { if (subDocument.id !== targetSubDocumentId) { return subDocument; } targetFound = true; return { ...subDocument, attachments: [ ...subDocument.attachments, ...extracted.map((attachment) => ({ ...attachment })) ] }; }); if (!targetFound) { return archive; } return { ...strippedArchive, subDocuments }; }; /** * Remove a single attachment from the archive, wherever it currently lives. * Located by its unique `path`; returns the input archive unchanged when no * attachment matches. */ export const removeAttachmentFromArchive = ( archive: ProcessedZipArchive, path: string ): ProcessedZipArchive => { const attachments = archive.attachments.filter((attachment) => attachment.path !== path); let removed = attachments.length !== archive.attachments.length; const subDocuments = archive.subDocuments.map((subDocument) => { const nextAttachments = subDocument.attachments.filter( (attachment) => attachment.path !== path ); if (nextAttachments.length !== subDocument.attachments.length) { removed = true; } return nextAttachments.length === subDocument.attachments.length ? subDocument : { ...subDocument, attachments: nextAttachments }; }); return removed ? { ...archive, attachments, subDocuments } : archive; }; /** * Move every loose attachment into its own sub-document named after the * attachment. The sub-document order matches the loose attachment order. */ export const groupEachAttachmentAsSubDocument = ( archive: ProcessedZipArchive ): ProcessedZipArchive => { if (archive.attachments.length === 0) { return archive; } const newSubDocuments = archive.attachments.map((attachment) => createSubDocument(stripPdfExtension(attachment.name), [attachment]) ); return { ...archive, attachments: [], subDocuments: [...archive.subDocuments, ...newSubDocuments] }; };