refactoring, linting, formatting

This commit is contained in:
2026-05-17 02:05:27 +02:00
parent bdbb6c0a1c
commit 07f4361573
38 changed files with 6121 additions and 2647 deletions

View File

@@ -1,10 +1,20 @@
import { PDFDocument, degrees } from 'pdf-lib';
import type { PdfFile, PageRef, SplitResult, Range } from './pdfTypes';
import { PDFDocument, degrees } from "pdf-lib";
import type { PdfFile, PageRef, SplitResult, Range } from "./pdfTypes";
function createId() {
return Math.random().toString(36).slice(2);
}
function pdfBytesToArrayBuffer(bytes: Uint8Array): ArrayBuffer {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
function pdfBytesToBlob(bytes: Uint8Array): Blob {
return new Blob([pdfBytesToArrayBuffer(bytes)], { type: "application/pdf" });
}
export async function loadPdfFromFile(file: File): Promise<PdfFile> {
const arrayBuffer = await file.arrayBuffer();
const doc = await PDFDocument.load(arrayBuffer);
@@ -21,10 +31,10 @@ export async function loadPdfFromFile(file: File): Promise<PdfFile> {
export async function mergePdfFiles(
basePdf: PdfFile,
newPdf: PdfFile,
insertAt: number
insertAt: number,
): Promise<PdfFile> {
const baseDoc = basePdf.doc ?? await PDFDocument.load(basePdf.arrayBuffer);
const newDoc = newPdf.doc ?? await PDFDocument.load(newPdf.arrayBuffer);
const baseDoc = basePdf.doc ?? (await PDFDocument.load(basePdf.arrayBuffer));
const newDoc = newPdf.doc ?? (await PDFDocument.load(newPdf.arrayBuffer));
const mergedDoc = await PDFDocument.create();
@@ -35,11 +45,11 @@ export async function mergePdfFiles(
const basePages = await mergedDoc.copyPages(
baseDoc,
Array.from({ length: basePageCount }, (_, i) => i)
Array.from({ length: basePageCount }, (_, i) => i),
);
const newPages = await mergedDoc.copyPages(
newDoc,
Array.from({ length: newPageCount }, (_, i) => i)
Array.from({ length: newPageCount }, (_, i) => i),
);
for (let i = 0; i < clampedInsertAt; i += 1) {
@@ -53,11 +63,10 @@ export async function mergePdfFiles(
}
const bytes = await mergedDoc.save();
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
const buffer = pdfBytesToArrayBuffer(bytes);
const baseName = basePdf.name.replace(/\.pdf$/i, '');
const newName = newPdf.name.replace(/\.pdf$/i, '');
const baseName = basePdf.name.replace(/\.pdf$/i, "");
const newName = newPdf.name.replace(/\.pdf$/i, "");
return {
id: createId(),
@@ -69,7 +78,7 @@ export async function mergePdfFiles(
}
export async function splitIntoSinglePages(
pdf: PdfFile
pdf: PdfFile,
): Promise<SplitResult[]> {
const { doc, name } = pdf;
@@ -99,10 +108,10 @@ export async function splitIntoSinglePages(
if (modificationDate) newDoc.setModificationDate(modificationDate);
const bytes = await newDoc.save();
const blob = new Blob([bytes], { type: 'application/pdf' });
const blob = pdfBytesToBlob(bytes);
const base = name.replace(/\.pdf$/i, '');
const filename = `${base}_page_${String(i + 1).padStart(3, '0')}.pdf`;
const base = name.replace(/\.pdf$/i, "");
const filename = `${base}_page_${String(i + 1).padStart(3, "0")}.pdf`;
results.push({
pageIndex: i,
@@ -114,10 +123,7 @@ export async function splitIntoSinglePages(
return results;
}
export async function extractRange(
pdf: PdfFile,
range: Range
): Promise<Blob> {
export async function extractRange(pdf: PdfFile, range: Range): Promise<Blob> {
const { doc } = pdf;
const pageCount = doc.getPageCount();
@@ -125,7 +131,7 @@ export async function extractRange(
const toIndex = Math.min(pageCount - 1, range.to - 1);
if (fromIndex > toIndex) {
throw new Error('Invalid range: from > to');
throw new Error("Invalid range: from > to");
}
const newDoc = await PDFDocument.create();
@@ -136,7 +142,7 @@ export async function extractRange(
copiedPages.forEach((p) => newDoc.addPage(p));
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
@@ -150,26 +156,26 @@ export async function mergePdfs(pdfs: PdfFile[]): Promise<Blob> {
}
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function exportPages(
pdf: PdfFile,
pages: PageRef[]
pages: PageRef[],
): Promise<Blob> {
const { doc } = pdf;
const pageCount = doc.getPageCount();
if (pages.length === 0) {
throw new Error('Pages must contain at least one page');
throw new Error("Pages must contain at least one page");
}
if (
pages.some(
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount
(page) => page.sourcePageIndex < 0 || page.sourcePageIndex >= pageCount,
)
) {
throw new Error('Pages contain invalid source page indices');
throw new Error("Pages contain invalid source page indices");
}
const newDoc = await PDFDocument.create();
@@ -180,7 +186,7 @@ export async function exportPages(
copiedPages.forEach((page, idx) => {
const angle = pages[idx].rotation;
if (typeof angle === 'number' && angle % 360 !== 0) {
if (typeof angle === "number" && angle % 360 !== 0) {
page.setRotation(degrees(angle));
}
@@ -188,13 +194,13 @@ export async function exportPages(
});
const bytes = await newDoc.save();
return new Blob([bytes], { type: 'application/pdf' });
return pdfBytesToBlob(bytes);
}
export async function exportReordered(
pdf: PdfFile,
order: number[],
rotations?: Record<number, number>
rotations?: Record<number, number>,
): Promise<Blob> {
return exportPages(
pdf,
@@ -202,6 +208,6 @@ export async function exportReordered(
id: String(sourcePageIndex),
sourcePageIndex,
rotation: rotations?.[sourcePageIndex] ?? 0,
}))
})),
);
}
}

View File

@@ -1,5 +1,5 @@
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker?worker&url';
import * as pdfjsLib from "pdfjs-dist";
import pdfjsWorker from "pdfjs-dist/build/pdf.worker?worker&url";
// pdf.js worker setup for Vite
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -37,13 +37,12 @@ interface ThumbnailGenerationOptions {
onThumbnail?: (update: ThumbnailUpdate) => void;
}
/**
* Unrotated thumbnails used e.g. in the Split/Extract view.
*/
export async function generateThumbnailsProgressive(
arrayBuffer: ArrayBuffer,
options: ThumbnailGenerationOptions = {}
options: ThumbnailGenerationOptions = {},
): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, {}, options);
}
@@ -54,7 +53,7 @@ export async function generateThumbnailsProgressive(
export async function generateThumbnailsWithRotationsProgressive(
arrayBuffer: ArrayBuffer,
rotations: RotationsMap,
options: ThumbnailGenerationOptions = {}
options: ThumbnailGenerationOptions = {},
): Promise<string[]> {
return generateThumbnailsInternal(arrayBuffer, rotations, options);
}
@@ -62,7 +61,7 @@ export async function generateThumbnailsWithRotationsProgressive(
async function generateThumbnailsInternal(
arrayBuffer: ArrayBuffer,
rotations: RotationsMap,
options: ThumbnailGenerationOptions = {}
options: ThumbnailGenerationOptions = {},
): Promise<string[]> {
const maxHeight = options.maxHeight ?? 150;
const maxWidth = options.maxWidth ?? 140;
@@ -73,15 +72,15 @@ async function generateThumbnailsInternal(
const loadingTask = pdfjsLib.getDocument({ data: dataCopy });
const pdf = await loadingTask.promise;
const thumbs = Array<string>(pdf.numPages).fill('');
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)
)
.map((pageIndex) => pageIndex + 1),
),
)
: Array.from({ length: pdf.numPages }, (_, index) => index + 1);
@@ -99,7 +98,7 @@ async function generateThumbnailsInternal(
pageIndex,
rotations,
maxHeight,
maxWidth
maxWidth,
);
if (signal?.aborted) return;
@@ -112,7 +111,7 @@ async function generateThumbnailsInternal(
while (!signal?.aborted) {
const pageNum = pageNums[nextPageIndex];
nextPageIndex += 1;
if (pageNum == null) return;
await renderOne(pageNum);
@@ -133,11 +132,15 @@ async function generateThumbnailsInternal(
}
async function renderPageThumbnail(
page: Awaited<ReturnType<Awaited<ReturnType<typeof pdfjsLib.getDocument>['promise']>['getPage']>>,
page: Awaited<
ReturnType<
Awaited<ReturnType<typeof pdfjsLib.getDocument>["promise"]>["getPage"]
>
>,
originalIndex: number,
rotations: RotationsMap,
maxHeight: number,
maxWidth: number
maxWidth: number,
): Promise<string> {
const viewport = page.getViewport({ scale: 1 });
const scaleH = maxHeight / viewport.height;
@@ -145,10 +148,10 @@ async function renderPageThumbnail(
const scale = Math.min(scaleH, scaleW);
const scaledViewport = page.getViewport({ scale });
const baseCanvas = document.createElement('canvas');
const baseCtx = baseCanvas.getContext('2d');
const baseCanvas = document.createElement("canvas");
const baseCtx = baseCanvas.getContext("2d");
if (!baseCtx) return '';
if (!baseCtx) return "";
baseCanvas.width = scaledViewport.width;
baseCanvas.height = scaledViewport.height;
@@ -164,14 +167,14 @@ async function renderPageThumbnail(
const rotationDeg = ((rotationDegRaw % 360) + 360) % 360;
if (rotationDeg === 0) {
return baseCanvas.toDataURL('image/png');
return baseCanvas.toDataURL("image/png");
}
const rotatedCanvas = document.createElement('canvas');
const rotatedCtx = rotatedCanvas.getContext('2d');
const rotatedCanvas = document.createElement("canvas");
const rotatedCtx = rotatedCanvas.getContext("2d");
if (!rotatedCtx) {
return baseCanvas.toDataURL('image/png');
return baseCanvas.toDataURL("image/png");
}
const rad = (rotationDeg * Math.PI) / 180;
@@ -204,5 +207,5 @@ async function renderPageThumbnail(
rotatedCtx.drawImage(baseCanvas, 0, 0);
rotatedCtx.restore();
return rotatedCanvas.toDataURL('image/png');
}
return rotatedCanvas.toDataURL("image/png");
}

View File

@@ -1,4 +1,4 @@
import type { PDFDocument } from 'pdf-lib';
import type { PDFDocument } from "pdf-lib";
export interface PdfFile {
id: string;

212
src/pdf/usePdfThumbnails.ts Normal file
View File

@@ -0,0 +1,212 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { PageRef, PdfFile } from "./pdfTypes";
import { generateThumbnailsWithRotationsProgressive } from "./pdfThumbnailService";
import { normalizeRotation } from "../workspace/useWorkspaceState";
const DEFAULT_MAX_HEIGHT = 150;
const DEFAULT_MAX_WIDTH = 140;
const DEFAULT_CONCURRENCY = 3;
interface UsePdfThumbnailsOptions {
pdf: PdfFile | null;
pages: PageRef[];
maxHeight?: number;
maxWidth?: number;
concurrency?: number;
onError?: (message: string, error: unknown) => void;
}
interface UsePdfThumbnailsResult {
thumbnails: Record<string, string>;
resetThumbnails: () => void;
clearThumbnailCache: () => void;
}
function thumbnailCacheKey(
pdfId: string,
sourcePageIndex: number,
rotation: number,
maxWidth: number,
maxHeight: number,
): string {
return [
pdfId,
sourcePageIndex,
normalizeRotation(rotation),
maxWidth,
maxHeight,
].join(":");
}
function pruneAndMergeThumbnails(
previous: Record<string, string>,
pages: PageRef[],
updates: Record<string, string>,
): Record<string, string> {
const pageIds = new Set(pages.map((page) => page.id));
const next: Record<string, string> = {};
for (const [pageId, thumbnail] of Object.entries(previous)) {
if (pageIds.has(pageId)) {
next[pageId] = thumbnail;
}
}
return {
...next,
...updates,
};
}
export function usePdfThumbnails({
pdf,
pages,
maxHeight = DEFAULT_MAX_HEIGHT,
maxWidth = DEFAULT_MAX_WIDTH,
concurrency = DEFAULT_CONCURRENCY,
onError,
}: UsePdfThumbnailsOptions): UsePdfThumbnailsResult {
const [thumbnails, setThumbnails] = useState<Record<string, string>>({});
const thumbnailCacheRef = useRef<Map<string, string>>(new Map());
const latestPagesRef = useRef<PageRef[]>(pages);
const previousPdfIdRef = useRef<string | null>(null);
useEffect(() => {
latestPagesRef.current = pages;
}, [pages]);
const resetThumbnails = useCallback(() => {
setThumbnails({});
}, []);
const clearThumbnailCache = useCallback(() => {
thumbnailCacheRef.current.clear();
setThumbnails({});
}, []);
useEffect(() => {
const currentPdfId = pdf?.id ?? null;
if (!pdf) {
previousPdfIdRef.current = null;
clearThumbnailCache();
return;
}
if (previousPdfIdRef.current !== currentPdfId) {
previousPdfIdRef.current = currentPdfId;
clearThumbnailCache();
}
}, [clearThumbnailCache, pdf]);
useEffect(() => {
if (!pdf) return;
const controller = new AbortController();
const cachedUpdates: Record<string, string> = {};
const renderGroups = new Map<number, Set<number>>();
for (const page of pages) {
const rotation = normalizeRotation(page.rotation);
const cacheKey = thumbnailCacheKey(
pdf.id,
page.sourcePageIndex,
rotation,
maxWidth,
maxHeight,
);
const cached = thumbnailCacheRef.current.get(cacheKey);
if (cached) {
cachedUpdates[page.id] = cached;
continue;
}
const pageIndices = renderGroups.get(rotation) ?? new Set<number>();
pageIndices.add(page.sourcePageIndex);
renderGroups.set(rotation, pageIndices);
}
setThumbnails((previous) =>
pruneAndMergeThumbnails(previous, pages, cachedUpdates),
);
if (renderGroups.size === 0) return;
const renderMissingThumbnails = async () => {
for (const [rotation, pageIndexSet] of renderGroups) {
if (controller.signal.aborted) return;
const pageIndices = Array.from(pageIndexSet);
const rotationsBySourcePage: Record<number, number> = {};
for (const pageIndex of pageIndices) {
rotationsBySourcePage[pageIndex] = rotation;
}
await generateThumbnailsWithRotationsProgressive(
pdf.arrayBuffer,
rotationsBySourcePage,
{
maxHeight,
maxWidth,
concurrency: Math.min(concurrency, pageIndices.length),
pageIndices,
signal: controller.signal,
onThumbnail: ({ pageIndex, dataUrl }) => {
if (controller.signal.aborted) return;
thumbnailCacheRef.current.set(
thumbnailCacheKey(
pdf.id,
pageIndex,
rotation,
maxWidth,
maxHeight,
),
dataUrl,
);
const updates: Record<string, string> = {};
for (const page of latestPagesRef.current) {
if (
page.sourcePageIndex === pageIndex &&
normalizeRotation(page.rotation) === rotation
) {
updates[page.id] = dataUrl;
}
}
if (Object.keys(updates).length === 0) return;
setThumbnails((previous) =>
pruneAndMergeThumbnails(
previous,
latestPagesRef.current,
updates,
),
);
},
},
);
}
};
void renderMissingThumbnails().catch((error) => {
if (!controller.signal.aborted) {
onError?.("Failed to generate thumbnails (see console).", error);
}
});
return () => {
controller.abort();
};
}, [concurrency, maxHeight, maxWidth, onError, pages, pdf]);
return {
thumbnails,
resetThumbnails,
clearThumbnailCache,
};
}