Roadmap, robust page refs, copy behaviour

This commit is contained in:
2026-05-16 02:37:20 +02:00
parent a649ede010
commit 2461cf3d64
9 changed files with 1588 additions and 744 deletions

View File

@@ -1,5 +1,5 @@
import { PDFDocument, degrees } from 'pdf-lib';
import type { PdfFile, SplitResult, Range } from './pdfTypes';
import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes';
function createId() {
return Math.random().toString(36).slice(2);
@@ -53,19 +53,18 @@ export async function mergePdfFiles(
}
const bytes = await mergedDoc.save();
const buffer = bytes.buffer.slice(
bytes.byteOffset,
bytes.byteOffset + bytes.byteLength
);
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const baseName = basePdf.name.replace(/\.pdf$/i, '');
const newName = newPdf.name.replace(/\.pdf$/i, '');
return {
id: createId(),
name: `${baseName}_plus_${newName}.pdf`,
arrayBuffer: buffer,
pageCount: mergedDoc.getPageCount(),
doc: mergedDoc, // 👈 important
doc: mergedDoc,
};
}
@@ -93,7 +92,7 @@ export async function splitIntoSinglePages(
if (title) newDoc.setTitle(title);
if (author) newDoc.setAuthor(author);
if (subject) newDoc.setSubject(subject);
if (keywords) newDoc.setKeywords(keywords);
if (keywords) newDoc.setKeywords([keywords]);
if (producer) newDoc.setProducer(producer);
if (creator) newDoc.setCreator(creator);
if (creationDate) newDoc.setCreationDate(creationDate);
@@ -154,30 +153,32 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
return new Blob([bytes], { type: 'application/pdf' });
}
export async function exportReordered(
export async function exportPages(
pdf: PdfFile,
order: number[],
rotations?: Record<number, number>
pages: PageRef[]
): Promise<Blob> {
const { doc } = pdf;
const pageCount = doc.getPageCount();
if (order.length === 0) {
throw new Error('Order must contain at least one page');
if (pages.length === 0) {
throw new Error('Pages must contain at least one page');
}
if (order.some((i) => i < 0 || i >= pageCount)) {
throw new Error('Order contains invalid page indices');
if (
pages.some(
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount
)
) {
throw new Error('Pages contain invalid source page indices');
}
const newDoc = await PDFDocument.create();
const indices = [...order];
const indices = pages.map((page) => page.sourcePageIndex);
const copiedPages = await newDoc.copyPages(doc, indices);
copiedPages.forEach((page, idx) => {
const originalIndex = indices[idx];
const angle = rotations?.[originalIndex];
const angle = pages[idx].rotation;
if (typeof angle === 'number' && angle % 360 !== 0) {
page.setRotation(degrees(angle));
@@ -189,3 +190,18 @@ export async function exportReordered(
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
}
export async function exportReordered(
pdf: PdfFile,
order: number[],
rotations?: Record<number, number>
): Promise<Blob> {
return exportPages(
pdf,
order.map((sourcePageIndex) => ({
id: String(sourcePageIndex),
sourcePageIndex,
rotation: rotations?.[sourcePageIndex] ?? 0,
}))
);
}

View File

@@ -19,115 +19,190 @@ function makePdfJsDataCopy(arrayBuffer: ArrayBuffer): Uint8Array {
return copy;
}
interface ThumbnailUpdate {
pageIndex: number;
dataUrl: string;
}
interface ThumbnailGenerationOptions {
maxHeight?: number;
maxWidth?: number;
concurrency?: number;
/**
* Optional subset of 0-based page indices to render.
* If omitted, all pages are rendered.
*/
pageIndices?: number[];
signal?: AbortSignal;
onThumbnail?: (update: ThumbnailUpdate) => void;
}
/**
* Unrotated thumbnails used e.g. in the Split/Extract view.
*/
export async function generateThumbnails(
export async function generateThumbnailsProgressive(
arrayBuffer: ArrayBuffer,
maxHeight = 150
options: ThumbnailGenerationOptions = {}
): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, {}, maxHeight);
return generateThumbnailsInternal(arrayBuffer, {}, options);
}
/**
* Thumbnails that respect per-page rotations (for the Reorder view).
*/
export async function generateThumbnailsWithRotations(
export async function generateThumbnailsWithRotationsProgressive(
arrayBuffer: ArrayBuffer,
rotations: RotationsMap,
maxHeight = 150
options: ThumbnailGenerationOptions = {}
): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, rotations, maxHeight);
return generateThumbnailsInternal(arrayBuffer, rotations, options);
}
async function generateThumbnailsInternal(
arrayBuffer: ArrayBuffer,
rotations: RotationsMap,
maxHeight: number
options: ThumbnailGenerationOptions = {}
): Promise<string[]> {
// IMPORTANT: use a COPY so pdf.js can detach it without breaking future calls
const dataCopy = makePdfJsDataCopy(arrayBuffer);
const maxHeight = options.maxHeight ?? 150;
const maxWidth = options.maxWidth ?? 140;
const concurrency = Math.max(1, Math.min(options.concurrency ?? 3, 6));
const signal = options.signal;
const dataCopy = makePdfJsDataCopy(arrayBuffer);
const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
const pdf = await loadingTask.promise;
const thumbs: string[] = [];
const thumbs = Array<string>(pdf.numPages).fill('');
const pageNums = options.pageIndices
? Array.from(
new Set(
options.pageIndices
.filter((pageIndex) => pageIndex >= 0 && pageIndex < pdf.numPages)
.map((pageIndex) => pageIndex + 1)
)
)
: Array.from({ length: pdf.numPages }, (_, index) => index + 1);
let nextPageIndex = 0;
const renderOne = async (pageNum: number) => {
if (signal?.aborted) return;
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale: 1 });
const scale = maxHeight / viewport.height;
const scaledViewport = page.getViewport({ scale });
if (signal?.aborted) return;
// First render unrotated page into a canvas
const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d');
if (!baseCtx) {
thumbs.push('');
continue;
const pageIndex = pageNum - 1;
const dataUrl = await renderPageThumbnail(
page,
pageIndex,
rotations,
maxHeight,
maxWidth
);
if (signal?.aborted) return;
thumbs[pageIndex] = dataUrl;
options.onThumbnail?.({ pageIndex, dataUrl });
};
const worker = async () => {
while (!signal?.aborted) {
const pageNum = pageNums[nextPageIndex];
nextPageIndex += 1;
if (pageNum == null) return;
await renderOne(pageNum);
// Let React/browser paint between batches.
await new Promise<void>((resolve) => {
requestAnimationFrame(() => resolve());
});
}
};
baseCanvas.width = scaledViewport.width;
baseCanvas.height = scaledViewport.height;
const workerCount = Math.min(concurrency, pageNums.length);
if (workerCount === 0) return thumbs;
const renderTask = page.render({
canvasContext: baseCtx,
viewport: scaledViewport,
});
await renderTask.promise;
const originalIndex = pageNum - 1;
const rotationDegRaw = rotations[originalIndex] ?? 0;
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360; // normalize 0359
if (rotationDeg === 0) {
thumbs.push(baseCanvas.toDataURL('image/png'));
continue;
}
// Re-render onto a second canvas with rotation applied
const rotatedCanvas = document.createElement('canvas');
const rotatedCtx = rotatedCanvas.getContext('2d');
if (!rotatedCtx) {
thumbs.push(baseCanvas.toDataURL('image/png'));
continue;
}
const rad = (rotationDeg * Math.PI) / 180;
if (rotationDeg === 90 || rotationDeg === 270) {
rotatedCanvas.width = baseCanvas.height;
rotatedCanvas.height = baseCanvas.width;
} else {
rotatedCanvas.width = baseCanvas.width;
rotatedCanvas.height = baseCanvas.height;
}
rotatedCtx.save();
switch (rotationDeg) {
case 90:
rotatedCtx.translate(rotatedCanvas.width, 0);
rotatedCtx.rotate(rad);
break;
case 180:
rotatedCtx.translate(rotatedCanvas.width, rotatedCanvas.height);
rotatedCtx.rotate(rad);
break;
case 270:
rotatedCtx.translate(0, rotatedCanvas.height);
rotatedCtx.rotate(rad);
break;
default:
// fallback: no rotation
break;
}
rotatedCtx.drawImage(baseCanvas, 0, 0);
rotatedCtx.restore();
thumbs.push(rotatedCanvas.toDataURL('image/png'));
}
await Promise.all(Array.from({ length: workerCount }, worker));
return thumbs;
}
async function renderPageThumbnail(
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>,
originalIndex: number,
rotations: RotationsMap,
maxHeight: number,
maxWidth: number
): Promise<string> {
const viewport = page.getViewport({ scale: 1 });
const scaleH = maxHeight / viewport.height;
const scaleW = maxWidth / viewport.width;
const scale = Math.min(scaleH, scaleW);
const scaledViewport = page.getViewport({ scale });
const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d');
if (!baseCtx) return '';
baseCanvas.width = scaledViewport.width;
baseCanvas.height = scaledViewport.height;
const renderTask = page.render({
canvasContext: baseCtx,
viewport: scaledViewport,
});
await renderTask.promise;
const rotationDegRaw = rotations[originalIndex] ?? 0;
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
if (rotationDeg === 0) {
return baseCanvas.toDataURL('image/png');
}
const rotatedCanvas = document.createElement('canvas');
const rotatedCtx = rotatedCanvas.getContext('2d');
if (!rotatedCtx) {
return baseCanvas.toDataURL('image/png');
}
const rad = (rotationDeg * Math.PI) / 180;
if (rotationDeg === 90 || rotationDeg === 270) {
rotatedCanvas.width = baseCanvas.height;
rotatedCanvas.height = baseCanvas.width;
} else {
rotatedCanvas.width = baseCanvas.width;
rotatedCanvas.height = baseCanvas.height;
}
rotatedCtx.save();
switch (rotationDeg) {
case 90:
rotatedCtx.translate(rotatedCanvas.width, 0);
rotatedCtx.rotate(rad);
break;
case 180:
rotatedCtx.translate(rotatedCanvas.width, rotatedCanvas.height);
rotatedCtx.rotate(rad);
break;
case 270:
rotatedCtx.translate(0, rotatedCanvas.height);
rotatedCtx.rotate(rad);
break;
}
rotatedCtx.drawImage(baseCanvas, 0, 0);
rotatedCtx.restore();
return rotatedCanvas.toDataURL('image/png');
}

View File

@@ -8,6 +8,12 @@ export interface PdfFile {
arrayBuffer: ArrayBuffer;
}
export interface PageRef {
id: string;
sourcePageIndex: number;
rotation: number;
}
export interface SplitResult {
pageIndex: number;
blob: Blob;