adds agents.md, splits zip-processing.ts

This commit is contained in:
2026-06-15 20:13:39 +02:00
parent ef96f523e3
commit 8a5ee9fe20
6 changed files with 209 additions and 155 deletions
+16 -8
View File
@@ -3,11 +3,8 @@
<component name="ChangeListManager">
<list default="true" id="89f9fc3a-63ad-41cb-8d25-f07df352eb98" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/package.json" beforeDir="false" afterPath="$PROJECT_DIR$/package.json" afterDir="false" />
<change beforePath="$PROJECT_DIR$/pnpm-lock.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/pnpm-lock.yaml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/lib/components/AttachmentPreview.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/components/AttachmentPreview.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/lib/components/ProcessedZipArchiveEditor.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/components/ProcessedZipArchiveEditor.svelte" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/lib/zip-processing.ts" beforeDir="false" afterPath="$PROJECT_DIR$/src/lib/zip-processing.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/src/routes/+page.svelte" beforeDir="false" afterPath="$PROJECT_DIR$/src/routes/+page.svelte" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
@@ -41,7 +38,7 @@
"git-widget-placeholder": "features/zip-processing",
"last_opened_file_path": "/home/rehlert/repos/juri-merger",
"nodejs_package_manager_path": "pnpm",
"settings.editor.selected.configurable": "preferences.pluginManager",
"settings.editor.selected.configurable": "proofread",
"ts.external.directory.path": "/home/rehlert/repos/juri-merger/node_modules/typescript/lib",
"vue.rearranger.settings.migration": "true"
}
@@ -55,7 +52,9 @@
<updated>1781169139290</updated>
<workItem from="1781169141138" duration="309000" />
<workItem from="1781251694220" duration="22281000" />
<workItem from="1781349431536" duration="774000" />
<workItem from="1781349431536" duration="3602000" />
<workItem from="1781378743498" duration="306000" />
<workItem from="1781534483559" duration="2103000" />
</task>
<task id="LOCAL-00001" summary="adapts styling of rvg.legal">
<option name="closed" value="true" />
@@ -145,7 +144,15 @@
<option name="project" value="LOCAL" />
<updated>1781268046478</updated>
</task>
<option name="localTasksCounter" value="12" />
<task id="LOCAL-00012" summary="The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts: &#10; - Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF. - Implemented helpers to embed PDF pages and convert/embed images. - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing. &#10; - src/lib/components/ProcessedZipArchiveEditor.svelte: &#10; - Added a &quot;PDF herunterladen&quot; (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages).&#10; - Integrated the mergeProcessedZipArchive function with the UI.&#10; - src/lib/components/AttachmentPreview.svelte: &#10; - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents.">
<option name="closed" value="true" />
<created>1781350375505</created>
<option name="number" value="00012" />
<option name="presentableId" value="LOCAL-00012" />
<option name="project" value="LOCAL" />
<updated>1781350375505</updated>
</task>
<option name="localTasksCounter" value="13" />
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
@@ -166,7 +173,8 @@
<MESSAGE value="handles zips in zip correctly" />
<MESSAGE value="range settings" />
<MESSAGE value="name generation improvement" />
<option name="LAST_COMMIT_MESSAGE" value="name generation improvement" />
<MESSAGE value="The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts: &#10; - Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF. - Implemented helpers to embed PDF pages and convert/embed images. - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing. &#10; - src/lib/components/ProcessedZipArchiveEditor.svelte: &#10; - Added a &quot;PDF herunterladen&quot; (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages).&#10; - Integrated the mergeProcessedZipArchive function with the UI.&#10; - src/lib/components/AttachmentPreview.svelte: &#10; - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents." />
<option name="LAST_COMMIT_MESSAGE" value="The git changes introduce functionality to merge attachments from a processed ZIP archive into a single PDF file. Here is a summary of the changes: ### New Features - PDF Merging: Added the ability to merge multiple attachments (PDFs and images) from a processed ZIP archive into a single PDF document. - Image Conversion: Implemented automatic conversion of non-PNG images (like JPGs) to PNG format before embedding them into the merged PDF. ### File Changes - Dependencies: Added pdf-lib to the project's dependencies. - src/lib/zip-processing.ts: &#10; - Added mergeProcessedZipArchive to handle the core logic of creating a merged PDF. - Implemented helpers to embed PDF pages and convert/embed images. - Added toArrayBuffer and convertImageBytesToPng utilities for handling binary data and canvas-based image processing. &#10; - src/lib/components/ProcessedZipArchiveEditor.svelte: &#10; - Added a &quot;PDF herunterladen&quot; (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages).&#10; - Integrated the mergeProcessedZipArchive function with the UI.&#10; - src/lib/components/AttachmentPreview.svelte: &#10; - Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents." />
</component>
<component name="XDebuggerManager">
<breakpoint-manager>
+50
View File
@@ -0,0 +1,50 @@
# Scout findings
## Project snapshot
- **Tech stack:** SvelteKit + TypeScript + Vite + Tailwind v4.
- **App name/branding:** `beA-Edit`.
- **Core purpose:** upload one or more ZIP files, extract relevant PDFs/images, preview them, optionally reorder attachments, and download a merged PDF.
## Main user flow
1. User adds ZIP files via drag/drop or file picker (`ZipDropzone`).
2. `+page.svelte` starts async extraction for each file.
3. Each ZIP is processed into one or more `ProcessedZipArchive` entries.
4. `ProcessedZipArchiveEditor` shows thumbnails, allows reordering, and can download the merged PDF.
5. `AttachmentPreview` renders image previews directly and PDF previews via `pdfjs-dist`.
## Important source files
- `src/routes/+page.svelte` — top-level page state, file selection, localStorage-backed thumbnail width setting, async ZIP processing.
- `src/lib/zip-processing.ts` — archive extraction, recursive nested ZIP handling, metadata ordering, PDF merge/export, image conversion.
- `src/lib/services/zip-inflating.service.ts` — thin wrapper around `fflate` unzip.
- `src/lib/services/xml-reading.service.ts` — parses XJustiz XML metadata (`sender`, `receiver`, `documentNames`).
- `src/lib/components/ZipDropzone.svelte` — file picker + drag/drop ZIP intake.
- `src/lib/components/ProcessedZipArchiveEditor.svelte` — archive editor, drag reordering, download button.
- `src/lib/components/AttachmentPreview.svelte` — preview generation for images/PDFs.
- `src/lib/components/Card.svelte` — generic page container.
## ZIP/PDF processing details
- ZIP entries are filtered to ignore macOS metadata (`__MACOSX`, `.DS_Store`, `._*`).
- Relevant attachments are PDFs and common image formats.
- Nested ZIP archives are processed recursively.
- `xjustiz_nachricht.xml` is used to derive archive naming and ordering.
- Ordering prefers document names from metadata before falling back to original attachment order.
- PDF merging uses `pdf-lib`; images are embedded as PNG/JPG, with browser-side canvas conversion for other image types.
## UI/state details
- Thumbnail width is stored in `localStorage` under `thumbnailWidth` and clamped to `100..400`.
- Processing progress is shown as a count of pending ZIP files.
- The editor supports manual attachment reordering via drag-and-drop.
## Dependencies worth noting
- `fflate` for ZIP extraction
- `pdf-lib` for PDF generation/merging
- `pdfjs-dist` for PDF thumbnail previews
- `@skeletonlabs/skeleton` / `@skeletonlabs/skeleton-svelte` for styling components
## Current working tree observations
- `agents.md` is newly created.
- Existing local changes are present in:
- `src/lib/zip-processing.ts`
- `src/routes/+page.svelte`
- New/untracked directory present:
- `src/lib/services/`
+93
View File
@@ -0,0 +1,93 @@
export type XJustizMetadata = {
sender: string;
receiver: string;
documentNames: string[];
};
const XML_TEXT_DECODER = new TextDecoder();
const matchesElementName = (element: Element, requestedName: string) => {
const requestedLocalName = requestedName.split('.').pop() ?? requestedName;
return (
element.localName === requestedName ||
element.localName === requestedLocalName ||
element.tagName === requestedName ||
element.tagName === requestedLocalName ||
element.tagName.endsWith(`:${requestedLocalName}`)
);
};
const getDirectChildByLocalName = (element: Element, localName: string) =>
Array.from(element.children).find((child) => matchesElementName(child, localName)) ?? null;
const getFirstDescendantByLocalName = (root: Document | Element, localName: string) =>
Array.from(root.querySelectorAll('*')).find((element) =>
matchesElementName(element, localName)
) ?? null;
const getTextContentByPath = (root: Element, path: string[]) => {
let current: Element | null = root;
for (const localName of path) {
current = current ? getDirectChildByLocalName(current, localName) : null;
if (!current) {
return null;
}
}
const textContent = current.textContent?.trim();
return textContent ? textContent : null;
};
const getTextContentByLocalName = (root: Document | Element, localName: string) =>
getFirstDescendantByLocalName(root, localName)?.textContent?.trim() ?? null;
const parseXmlDocument = (xmlBytes: Uint8Array) => {
const parser = new DOMParser();
const document = parser.parseFromString(XML_TEXT_DECODER.decode(xmlBytes), 'application/xml');
if (document.querySelector('parsererror')) {
return null;
}
return document;
};
export const parseXJustizMetadata = (xmlBytes: Uint8Array): XJustizMetadata | null => {
const document = parseXmlDocument(xmlBytes);
if (!document) {
return null;
}
const sender = getTextContentByLocalName(document, 'aktenzeichen.absender') ?? '';
const receiver = getTextContentByLocalName(document, 'aktenzeichen.empfaenger') ?? '';
const schriftgutobjekte = getFirstDescendantByLocalName(document, 'schriftgutobjekte');
if (!schriftgutobjekte) {
return {
sender,
receiver,
documentNames: []
};
}
const dokumentNodes = Array.from(schriftgutobjekte.children).filter((child) =>
matchesElementName(child, 'dokument')
);
const documentNames = dokumentNodes
.map((dokument) =>
getTextContentByPath(dokument, ['xjustiz.fachspezifischeDaten', 'datei', 'dateiname'])
)
.filter((name): name is string => Boolean(name));
return {
sender,
receiver,
documentNames
};
};
+13
View File
@@ -0,0 +1,13 @@
import { unzip } from 'fflate';
export const unzipArchive = (data: Uint8Array) =>
new Promise<Record<string, Uint8Array>>((resolve, reject) => {
unzip(data, (error, files) => {
if (error) {
reject(error);
return;
}
resolve(files);
});
});
+36 -146
View File
@@ -1,6 +1,8 @@
import { unzip } from 'fflate';
import { PDFDocument } from 'pdf-lib';
import { parseXJustizMetadata, type XJustizMetadata } from './services/xml-reading.service';
import { unzipArchive } from './services/zip-inflating.service';
export type ZipAttachmentKind = 'pdf' | 'image';
export type ZipAttachment = {
@@ -15,12 +17,6 @@ export type ProcessedZipArchive = {
attachments: ZipAttachment[];
};
type XJustizMetadata = {
sender: string;
receiver: string;
documentNames: string[];
};
const IMAGE_EXTENSIONS = new Set([
'.avif',
'.bmp',
@@ -36,22 +32,22 @@ const IMAGE_EXTENSIONS = new Set([
]);
const ZIP_META_FILE_NAME = 'xjustiz_nachricht.xml';
const XML_TEXT_DECODER = new TextDecoder();
const unzipArchive = (data: Uint8Array) =>
new Promise<Record<string, Uint8Array>>((resolve, reject) => {
unzip(data, (error, files) => {
if (error) {
reject(error);
return;
}
resolve(files);
});
});
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('.');
@@ -78,93 +74,6 @@ const isMacMetadataEntry = (path: string) => {
baseName.startsWith('._')
);
};
const matchesElementName = (element: Element, requestedName: string) => {
const requestedLocalName = requestedName.split('.').pop() ?? requestedName;
return (
element.localName === requestedName ||
element.localName === requestedLocalName ||
element.tagName === requestedName ||
element.tagName === requestedLocalName ||
element.tagName.endsWith(`:${requestedLocalName}`)
);
};
const getDirectChildByLocalName = (element: Element, localName: string) =>
Array.from(element.children).find((child) => matchesElementName(child, localName)) ?? null;
const getFirstDescendantByLocalName = (root: Document | Element, localName: string) =>
Array.from(root.querySelectorAll('*')).find((element) =>
matchesElementName(element, localName)
) ?? null;
const getTextContentByPath = (root: Element, path: string[]) => {
let current: Element | null = root;
for (const localName of path) {
current = current ? getDirectChildByLocalName(current, localName) : null;
if (!current) {
return null;
}
}
const textContent = current.textContent?.trim();
return textContent ? textContent : null;
};
const getTextContentByLocalName = (root: Document | Element, localName: string) =>
getFirstDescendantByLocalName(root, localName)?.textContent?.trim() ?? null;
const parseXmlDocument = (xmlBytes: Uint8Array) => {
const parser = new DOMParser();
const document = parser.parseFromString(XML_TEXT_DECODER.decode(xmlBytes), 'application/xml');
if (document.querySelector('parsererror')) {
return null;
}
return document;
};
const parseXJustizMetadata = (xmlBytes: Uint8Array): XJustizMetadata | null => {
const document = parseXmlDocument(xmlBytes);
if (!document) {
return null;
}
const sender = getTextContentByLocalName(document, 'aktenzeichen.absender') ?? '';
const receiver = getTextContentByLocalName(document, 'aktenzeichen.empfaenger') ?? '';
const schriftgutobjekte = getFirstDescendantByLocalName(document, 'schriftgutobjekte');
if (!schriftgutobjekte) {
return {
sender,
receiver,
documentNames: []
};
}
const dokumentNodes = Array.from(schriftgutobjekte.children).filter((child) =>
matchesElementName(child, 'dokument')
);
const documentNames = dokumentNodes
.map((dokument) =>
getTextContentByPath(dokument, ['xjustiz.fachspezifischeDaten', 'datei', 'dateiname'])
)
.filter((name): name is string => Boolean(name));
return {
sender,
receiver,
documentNames
};
};
const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentNames: string[]) => {
const buckets = new Map<string, ZipAttachment[]>();
@@ -216,61 +125,48 @@ const orderAttachmentsByDocumentNames = (attachments: ZipAttachment[], documentN
return orderedAttachments;
};
const extractZipEntries = async (
archiveBytes: Uint8Array,
_fallbackName: string
): Promise<ProcessedZipArchive[]> => {
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)) {
const baseName = getBaseName(path);
if (isMacMetadataEntry(path)) {
if (shouldSkipZipEntry(path)) {
continue;
}
if (baseName.toLowerCase() === ZIP_META_FILE_NAME) {
if (isMetadataFile(path)) {
xjustizNachrichtXml = data;
continue;
}
if (isZipArchive(path)) {
const childArchives = await extractZipEntries(data, baseName);
const childArchives = await extractZipEntries(data);
nestedArchives.push(...childArchives);
continue;
}
if (isPdf(path)) {
attachments.push({
name: baseName,
path,
kind: 'pdf',
data
});
continue;
}
if (isImage(path)) {
attachments.push({
name: baseName,
path,
kind: 'image',
data
});
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 now = new Date();
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const archiveName = metadata
? `${date}_${metadata.sender}_${metadata.receiver}.pdf`
: `${date}_unbekannt_unbekannt.pdf`;
const archiveName = createArchiveName(new Date(), metadata);
const archives: ProcessedZipArchive[] = [];
if (orderedAttachments.length > 0) {
@@ -286,7 +182,7 @@ const extractZipEntries = async (
export const extractZipArchives = async (file: File): Promise<ProcessedZipArchive[]> => {
const archiveBytes = new Uint8Array(await file.arrayBuffer());
return extractZipEntries(archiveBytes, file.name);
return extractZipEntries(archiveBytes);
};
const loadImageElement = (src: string) =>
@@ -361,9 +257,7 @@ const embedAttachmentImage = async (pdfDocument: PDFDocument, attachment: ZipAtt
};
const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAttachment) => {
const canLoadAsPdf = attachment.kind === 'pdf'
if (canLoadAsPdf) {
if (attachment.kind === 'pdf') {
const sourcePdf = await PDFDocument.load(attachment.data, { ignoreEncryption: true });
const copiedPages = await mergedPdf.copyPages(sourcePdf, sourcePdf.getPageIndices());
@@ -385,10 +279,6 @@ const mergeAttachmentIntoPdf = async (mergedPdf: PDFDocument, attachment: ZipAtt
height: page.getHeight()
});
} catch (error) {
if (attachment.kind === 'pdf') {
throw new Error(`Attachment "${attachment.name}" is not a valid PDF and could not be rendered as an image.`);
}
throw error;
}
};
+1 -1
View File
@@ -107,7 +107,7 @@
<div class="absolute right-0 z-10 mt-2 w-64 rounded-xl border border-primary-100 bg-white p-4 shadow-lg">
<label class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-3 text-sm font-medium text-primary-900">
<span>Thumbnail width</span>
<span>Thumbnail Breite</span>
<span class="tabular-nums text-primary-700">{thumbnailWidth}px</span>
</div>