// src/lib/pdf/render.ts — from tinytinytools.com // Served verbatim from the working tree. Method: https://tinytinytools.com/guides export interface PdfJsPage { commonObjs?: { get(id: string): { loadedName?: string; bold?: boolean; italic?: boolean; cssFontInfo?: { fontFamily?: string; fontWeight?: string; italicAngle?: number }; } | undefined; }; getViewport(options: { scale: number }): { width: number; height: number; transform?: number[] }; render(options: { canvasContext: CanvasRenderingContext2D; viewport: { width: number; height: number }; }): { promise: Promise }; getTextContent?(): Promise<{ items: Array<{ str?: string; fontName?: string; transform?: number[]; width?: number; height?: number }>; styles?: Record; lang?: string | null; }>; } export interface PdfJsDocument { readonly numPages: number; getPage(pageNumber: number): Promise; } export interface PdfJsLoadingTask { readonly promise: Promise; destroy(): Promise; } export interface RenderAdapters { readonly getDocument: (bytes: Uint8Array) => Promise; readonly createCanvas: () => HTMLCanvasElement; } export interface RenderedPageInfo { readonly pageNumber: number; readonly widthPt: number; readonly heightPt: number; } export interface RenderOptions { readonly scale: number; readonly format: 'png' | 'jpeg'; readonly quality: number; } export interface RenderedImage { readonly blob: Blob; readonly width: number; readonly height: number; } export interface RenderedTextItem { readonly text: string; readonly left: number; readonly top: number; readonly width: number; readonly height: number; readonly fontSize: number; readonly angle: number; readonly fontFamily?: string; readonly fontWeight?: 'normal' | 'bold'; readonly fontStyle?: 'normal' | 'italic'; } export interface PdfThumbnailOptions { readonly maxWidth: number; readonly maxHeight: number; } export interface RenderDocument { readonly pageCount: number; readonly pages: readonly RenderedPageInfo[]; renderPage(pageNumber: number, options: RenderOptions): Promise; getTextItems(pageNumber: number, scale: number): Promise; renderTextLayer(pageNumber: number, scale: number, container: HTMLElement): Promise; destroy(): Promise; } const multiplyTransforms = (first: readonly number[], second: readonly number[]): number[] => [ first[0]! * second[0]! + first[2]! * second[1]!, first[1]! * second[0]! + first[3]! * second[1]!, first[0]! * second[2]! + first[2]! * second[3]!, first[1]! * second[2]! + first[3]! * second[3]!, first[0]! * second[4]! + first[2]! * second[5]! + first[4]!, first[1]! * second[4]! + first[3]! * second[5]! + first[5]!, ]; const textFontStyle = (fontName = '', fontFamily = '') => { const descriptor = `${fontName} ${fontFamily}`; const numericWeight = Number.parseInt(fontName, 10); return { fontFamily: fontFamily.trim() || 'Arial', fontWeight: /bold|black|heavy|semibold|demi/i.test(descriptor) || (Number.isFinite(numericWeight) && numericWeight >= 600) ? 'bold' as const : 'normal' as const, fontStyle: /italic|oblique/i.test(descriptor) ? 'italic' as const : 'normal' as const, }; }; const browserAdapters = (): RenderAdapters => ({ getDocument: async (bytes) => { const pdfjs = await import('pdfjs-dist'); pdfjs.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString(); return pdfjs.getDocument({ data: bytes, fontExtraProperties: true }) as unknown as PdfJsLoadingTask; }, createCanvas: () => document.createElement('canvas'), }); const isPasswordError = (error: unknown): boolean => { const value = error as { name?: string; code?: number; message?: string } | null; return value?.name === 'PasswordException' || value?.code === 1 || /password/i.test(value?.message ?? ''); }; export async function renderPdfFirstPageThumbnail( bytes: Uint8Array, options: PdfThumbnailOptions = { maxWidth: 240, maxHeight: 300 }, adapters: RenderAdapters = browserAdapters(), ): Promise { let loadingTask: PdfJsLoadingTask | undefined; let canvas: HTMLCanvasElement | undefined; try { loadingTask = await adapters.getDocument(bytes); const doc = await loadingTask.promise; if (doc.numPages < 1) throw new Error('This PDF does not contain any pages.'); const page = await doc.getPage(1); const original = page.getViewport({ scale: 1 }); const scale = Math.min(options.maxWidth / original.width, options.maxHeight / original.height, 1); const viewport = page.getViewport({ scale: Math.max(scale, 0.01) }); canvas = adapters.createCanvas(); canvas.width = Math.max(1, Math.floor(viewport.width)); canvas.height = Math.max(1, Math.floor(viewport.height)); const context = canvas.getContext('2d'); if (!context) throw new Error('The browser could not create an image canvas.'); await page.render({ canvasContext: context, viewport }).promise; const blob = await new Promise(resolve => canvas!.toBlob(resolve, 'image/png')); if (!blob) throw new Error('The browser could not create the PDF preview.'); return blob; } catch (error) { if (isPasswordError(error)) throw new Error('This PDF is encrypted or password-protected and cannot be previewed.'); throw error instanceof Error ? error : new Error('This PDF preview could not be created.'); } finally { if (canvas) { canvas.width = 1; canvas.height = 1; } await loadingTask?.destroy().catch(() => {}); } } export async function openRenderDocument( bytes: Uint8Array, adapters: RenderAdapters = browserAdapters(), ): Promise { let loadingTask: PdfJsLoadingTask; let doc: PdfJsDocument; try { loadingTask = await adapters.getDocument(bytes); doc = await loadingTask.promise; } catch (error) { if (isPasswordError(error)) throw new Error('This PDF is encrypted or password-protected and cannot be processed by this tool.'); throw new Error('This PDF could not be read.'); } let pages: RenderedPageInfo[]; try { pages = []; for (let pageNumber = 1; pageNumber <= doc.numPages; pageNumber += 1) { const page = await doc.getPage(pageNumber); const viewport = page.getViewport({ scale: 1 }); pages.push({ pageNumber, widthPt: viewport.width, heightPt: viewport.height }); } } catch { await loadingTask.destroy().catch(() => {}); throw new Error('This PDF could not be read.'); } return { pageCount: doc.numPages, pages, renderPage: async (pageNumber, options) => { try { const page = await doc.getPage(pageNumber); const viewport = page.getViewport({ scale: options.scale }); const canvas = adapters.createCanvas(); canvas.width = Math.max(1, Math.floor(viewport.width)); canvas.height = Math.max(1, Math.floor(viewport.height)); const context = canvas.getContext('2d'); if (!context) throw new Error('The browser could not create an image canvas.'); await page.render({ canvasContext: context, viewport }).promise; const mimeType = options.format === 'jpeg' ? 'image/jpeg' : 'image/png'; const blob = await new Promise((resolve) => { if (options.format === 'jpeg') canvas.toBlob(resolve, mimeType, options.quality / 100); else canvas.toBlob(resolve, mimeType); }); if (!blob) throw new Error(`The browser could not encode ${mimeType}.`); return { blob, width: canvas.width, height: canvas.height }; } catch { throw new Error('This page could not be rendered.'); } }, getTextItems: async (pageNumber, scale) => { try { const page = await doc.getPage(pageNumber); if (!page.getTextContent) return []; const viewport = page.getViewport({ scale }); const viewportTransform = viewport.transform ?? [scale, 0, 0, -scale, 0, viewport.height]; const content = await page.getTextContent(); return content.items.flatMap(item => { const sourceText = item.str ?? ''; const text = sourceText.trim(); const transform = item.transform; if (!text || !transform || transform.length < 6) return []; const positioned = multiplyTransforms(viewportTransform, transform); const fontSize = Math.max(1, Math.hypot(positioned[2]!, positioned[3]!)); const sourceWidth = Math.max(1, Math.abs((item.width ?? 0) * scale)); const height = Math.max(fontSize, Math.abs((item.height ?? 0) * scale)); const angleRadians = Math.atan2(positioned[1]!, positioned[0]!); const angle = angleRadians * 180 / Math.PI; const fontObject = item.fontName ? page.commonObjs?.get(item.fontName) : undefined; const cssFont = fontObject?.cssFontInfo; const style = content.styles?.[item.fontName ?? '']; const font = cssFont?.fontFamily ? textFontStyle(`${cssFont.fontWeight ?? ''} ${cssFont.italicAngle ? 'italic' : ''}`, cssFont.fontFamily) : fontObject?.loadedName ? { fontFamily: fontObject.loadedName, fontWeight: 'normal' as const, fontStyle: 'normal' as const } : textFontStyle(item.fontName, style?.fontFamily); const startRatio = sourceText.indexOf(text) / sourceText.length; const textRatio = text.length / sourceText.length; const offset = sourceWidth * startRatio; return [{ text, left: positioned[4]! + Math.cos(angleRadians) * offset, top: positioned[5]! - fontSize + Math.sin(angleRadians) * offset, width: Math.max(1, sourceWidth * textRatio), height, fontSize, angle, ...font, } satisfies RenderedTextItem]; }); } catch { return []; } }, renderTextLayer: async (pageNumber, scale, container) => { try { const page = await doc.getPage(pageNumber); if (!page.getTextContent) return 0; const textContent = await page.getTextContent(); const viewport = page.getViewport({ scale }); const { TextLayer } = await import('pdfjs-dist'); container.replaceChildren(); container.style.setProperty('--scale-factor', String(scale)); container.style.setProperty('--total-scale-factor', String(scale)); const textLayer = new TextLayer({ textContentSource: textContent as never, container, viewport: viewport as never, }); await textLayer.render(); return textLayer.textContentItemsStr.filter(text => /\S/.test(text)).length; } catch { container.replaceChildren(); return 0; } }, destroy: () => loadingTask.destroy(), }; }