1000 lines
26 KiB
TypeScript
1000 lines
26 KiB
TypeScript
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<string, ZipAttachment[]>();
|
|
|
|
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<ZipAttachment>();
|
|
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<ProcessedZipArchive[]> => {
|
|
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<ProcessedZipArchive[]> => {
|
|
const archiveBytes = new Uint8Array(await file.arrayBuffer());
|
|
|
|
return extractZipEntries(archiveBytes);
|
|
};
|
|
|
|
const loadImageElement = (src: string) =>
|
|
new Promise<HTMLImageElement>((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<Blob>((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<Uint8Array> => {
|
|
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 = <T>(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 = <T>(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<ExportUnit> => {
|
|
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<ExportUnit[]> => {
|
|
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<ProcessedZipArchive> => {
|
|
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<ZipAttachment> => {
|
|
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<ZipAttachment | null> => {
|
|
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<string>
|
|
): { 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<string>
|
|
): 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<string>,
|
|
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]
|
|
};
|
|
};
|