subdocument-splitting-plan
This commit is contained in:
1 parent
f09384d2e6
commit
ca06ca2050
47 files changed
+4097
-136
No files matched your search
@@ -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]
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user