Roadmap, robust page refs, copy behaviour
This commit is contained in:
@@ -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,
|
||||
}))
|
||||
);
|
||||
}
|
||||
@@ -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 0–359
|
||||
|
||||
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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user