subdocument-splitting-plan

This commit is contained in:
rehlert committed 2026-10-06 22:52:35 +02:00
1 parent f09384d2e6
commit ca06ca2050
47 files changed
+4097 -136

No files matched your search

+355
View File
@@ -1,5 +1,6 @@
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';
@@ -642,3 +643,357 @@ export const buildArchiveExport = async (
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]
};
};