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:
- 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.
- src/lib/components/ProcessedZipArchiveEditor.svelte:
- Added a "PDF herunterladen" (Download PDF) button. - Added UI states to handle the merging process (loading state, error messages).
- Integrated the mergeProcessedZipArchive function with the UI.
- src/lib/components/AttachmentPreview.svelte:
- Added a fix to ensure attachment.data is treated as a Uint8Array when loading PDF documents.
This commit is contained in:
@@ -50,7 +50,9 @@
|
||||
}
|
||||
|
||||
try {
|
||||
const loadingTask = getDocument({ data: attachment.data });
|
||||
// create a copy, getDocument seems to manipulate the data -> creating pdfs fails
|
||||
const buffer = new Uint8Array(attachment.data);
|
||||
const loadingTask = getDocument({ data: buffer });
|
||||
const pdfDocument = await loadingTask.promise;
|
||||
const page = await pdfDocument.getPage(1);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import AttachmentPreview from "$lib/components/AttachmentPreview.svelte";
|
||||
import type {ProcessedZipArchive, ZipAttachment} from "$lib/zip-processing";
|
||||
import { mergeProcessedZipArchive, type ProcessedZipArchive, type ZipAttachment } from "$lib/zip-processing";
|
||||
|
||||
type Props = {
|
||||
archive: ProcessedZipArchive;
|
||||
@@ -19,6 +19,14 @@
|
||||
let archiveName = $state("");
|
||||
let dragIndex = $state<number | null>(null);
|
||||
let dropIndex = $state<number | null>(null);
|
||||
let isMerging = $state(false);
|
||||
let mergeError = $state<string | null>(null);
|
||||
|
||||
const toArrayBuffer = (bytes: Uint8Array) => {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy.buffer;
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
archiveName = archive.name;
|
||||
@@ -33,6 +41,37 @@
|
||||
});
|
||||
};
|
||||
|
||||
const downloadMergedPdf = async () => {
|
||||
if (attachments.length === 0 || isMerging) {
|
||||
return;
|
||||
}
|
||||
|
||||
isMerging = true;
|
||||
mergeError = null;
|
||||
|
||||
try {
|
||||
const mergedBytes = await mergeProcessedZipArchive({
|
||||
...archive,
|
||||
name: archiveName,
|
||||
attachments: attachments.map((attachment) => ({...attachment}))
|
||||
});
|
||||
|
||||
const pdfBlob = new Blob([toArrayBuffer(mergedBytes)], {type: "application/pdf"});
|
||||
const objectUrl = URL.createObjectURL(pdfBlob);
|
||||
const downloadLink = document.createElement("a");
|
||||
downloadLink.href = objectUrl;
|
||||
downloadLink.download = archiveName.toLowerCase().endsWith(".pdf") ? archiveName : `${archiveName}.pdf`;
|
||||
downloadLink.rel = "noopener";
|
||||
downloadLink.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
} catch (error) {
|
||||
console.error(`Failed to merge archive ${archive.name}`, error);
|
||||
mergeError = "PDF konnte nicht erstellt werden.";
|
||||
} finally {
|
||||
isMerging = false;
|
||||
}
|
||||
};
|
||||
|
||||
const moveAttachment = (fromIndex: number, toIndex: number) => {
|
||||
if (
|
||||
fromIndex === toIndex ||
|
||||
@@ -107,8 +146,23 @@
|
||||
{attachments.length} relevante Datei{attachments.length === 1 ? "" : "en"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="rounded-container whitespace-nowrap bg-primary-900 px-4 py-2 text-[14px] font-bold leading-6 text-white shadow-sm transition hover:bg-primary-800 disabled:cursor-not-allowed disabled:bg-primary-300"
|
||||
type="button"
|
||||
disabled={attachments.length === 0 || isMerging}
|
||||
onclick={downloadMergedPdf}
|
||||
>
|
||||
{isMerging ? "PDF wird erstellt" : "PDF herunterladen"}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if mergeError}
|
||||
<div class="rounded-container bg-red-50 px-4 py-3 text-[14px] font-medium text-red-900">
|
||||
{mergeError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<ul class="flex gap-3 overflow-x-auto pb-1">
|
||||
{#each attachments as attachment, index (attachment.path)}
|
||||
<li
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { unzip } from 'fflate';
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
|
||||
export type ZipAttachmentKind = 'pdf' | 'image';
|
||||
|
||||
@@ -287,3 +288,121 @@ export const extractZipArchives = async (file: File): Promise<ProcessedZipArchiv
|
||||
|
||||
return extractZipEntries(archiveBytes, file.name);
|
||||
};
|
||||
|
||||
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) => {
|
||||
const canLoadAsPdf = attachment.kind === 'pdf'
|
||||
|
||||
if (canLoadAsPdf) {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
export const mergeProcessedZipArchive = async (archive: ProcessedZipArchive) => {
|
||||
if (archive.attachments.length === 0) {
|
||||
throw new Error('Cannot create a PDF from an empty archive.');
|
||||
}
|
||||
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
|
||||
for (const attachment of archive.attachments) {
|
||||
await mergeAttachmentIntoPdf(mergedPdf, attachment);
|
||||
}
|
||||
|
||||
return mergedPdf.save();
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user