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
+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>