diff --git a/packages/engines/src/lib/collect-transferables.ts b/packages/engines/src/lib/collect-transferables.ts new file mode 100644 index 000000000..59380d7f7 --- /dev/null +++ b/packages/engines/src/lib/collect-transferables.ts @@ -0,0 +1,50 @@ +/** + * Recursively walks a response object and collects Transferable entries + * (ArrayBuffer from typed arrays, raw ArrayBuffer). + * + * Depth-limited to 6 to cover the deepest nesting in this codebase: + * response.data.result[annotId][mode].data.data (AnnotationAppearanceMap) + */ + +const MAX_DEPTH = 6; + +function walk(value: unknown, seen: Set, depth: number): void { + if (depth > MAX_DEPTH || value == null || typeof value !== 'object') { + return; + } + + if (value instanceof ArrayBuffer) { + seen.add(value); + return; + } + + // SharedArrayBuffer is not transferable — skip + if (typeof SharedArrayBuffer !== 'undefined' && value instanceof SharedArrayBuffer) { + return; + } + + if (ArrayBuffer.isView(value)) { + const buf = value.buffer; + if (buf instanceof ArrayBuffer) { + seen.add(buf); + } + return; + } + + if (Array.isArray(value)) { + for (let i = 0; i < value.length; i++) { + walk(value[i], seen, depth + 1); + } + return; + } + + for (const key of Object.keys(value as Record)) { + walk((value as Record)[key], seen, depth + 1); + } +} + +export function collectTransferables(value: unknown): Transferable[] { + const seen = new Set(); + walk(value, seen, 0); + return Array.from(seen); +} diff --git a/packages/engines/src/lib/orchestrator/pdf-engine.ts b/packages/engines/src/lib/orchestrator/pdf-engine.ts index ce5568d5b..fd1dfa4a6 100644 --- a/packages/engines/src/lib/orchestrator/pdf-engine.ts +++ b/packages/engines/src/lib/orchestrator/pdf-engine.ts @@ -47,8 +47,9 @@ import { ImageDataLike, IPdfiumExecutor, AnnotationAppearanceMap, + RenderPriority as Priority, } from '@embedpdf/models'; -import { WorkerTaskQueue, Priority } from './task-queue'; +import { WorkerTaskQueue } from './task-queue'; import type { ImageDataConverter } from '../converters/types'; // Re-export for convenience @@ -322,7 +323,7 @@ export class PdfEngine implements IPdfEngine { execute: () => this.executor.renderPageRaw(doc, page, options), meta: { docId: doc.id, pageIndex: page.index, operation: 'renderPageRaw' }, }, - { priority: Priority.HIGH }, + { priority: options?.priority ?? Priority.HIGH }, ); } @@ -337,7 +338,7 @@ export class PdfEngine implements IPdfEngine { execute: () => this.executor.renderPageRect(doc, page, rect, options), meta: { docId: doc.id, pageIndex: page.index, operation: 'renderPageRectRaw' }, }, - { priority: Priority.HIGH }, + { priority: options?.priority ?? Priority.HIGH }, ); } @@ -375,38 +376,18 @@ export class PdfEngine implements IPdfEngine { page: PdfPageObject, options?: PdfRenderPageAnnotationOptions, ): PdfTask> { - const resultTask = new Task, PdfErrorReason>(); - - const renderHandle = this.workerQueue.enqueue( - { - execute: () => this.executor.renderPageAnnotationsRaw(doc, page, options), - meta: { docId: doc.id, pageIndex: page.index, operation: 'renderPageAnnotationsRaw' }, - }, - { priority: Priority.MEDIUM }, - ); - - // Wire up abort: when resultTask is aborted, also abort the queue task - const originalAbort = resultTask.abort.bind(resultTask); - resultTask.abort = (reason) => { - renderHandle.abort(reason); - originalAbort(reason); - }; - - renderHandle.wait( - (rawMap) => { - if (resultTask.state.stage !== 0 /* Pending */) { - return; - } - this.encodeAppearanceMap(rawMap, options, resultTask); - }, - (error) => { - if (resultTask.state.stage === 0 /* Pending */) { - resultTask.fail(error); - } - }, - ); - - return resultTask; + return this.workerQueue + .enqueue( + { + execute: () => this.executor.renderPageAnnotationsRaw(doc, page, options), + meta: { docId: doc.id, pageIndex: page.index, operation: 'renderPageAnnotationsRaw' }, + }, + { priority: Priority.MEDIUM }, + ) + .map( + (rawMap) => this.encodeAppearanceMap(rawMap, options), + (err: unknown) => ({ code: PdfErrorCode.Unknown, message: String(err) }), + ); } renderPageAnnotationsRaw( @@ -433,51 +414,24 @@ export class PdfEngine implements IPdfEngine { pageIndex?: number, priority: Priority = Priority.CRITICAL, ): PdfTask { - const resultTask = new Task(); - - // Step 1: Add HIGH/MEDIUM priority task to render raw bytes - const renderHandle = this.workerQueue.enqueue( - { - execute: () => renderFn(), - meta: { docId, pageIndex, operation: 'render' }, - }, - { priority }, - ); - - // Wire up abort: when resultTask is aborted, also abort the queue task - const originalAbort = resultTask.abort.bind(resultTask); - resultTask.abort = (reason) => { - renderHandle.abort(reason); // Cancel the queue task! - originalAbort(reason); - }; - - renderHandle.wait( - (rawImageData) => { - // Check if resultTask was already aborted before encoding - if (resultTask.state.stage !== 0 /* Pending */) { - return; - } - this.encodeImage(rawImageData, options, resultTask); - }, - (error) => { - // Only forward error if resultTask is still pending - if (resultTask.state.stage === 0 /* Pending */) { - resultTask.fail(error); - } - }, - ); - - return resultTask; + return this.workerQueue + .enqueue( + { + execute: () => renderFn(), + meta: { docId, pageIndex, operation: 'render' }, + }, + { priority }, + ) + .map( + (rawImageData) => this.encodeImage(rawImageData, options), + (err: unknown) => ({ code: PdfErrorCode.Unknown, message: String(err) }), + ); } /** * Encode image using encoder pool or inline */ - private encodeImage( - rawImageData: ImageDataLike, - options: any, - resultTask: Task, - ): void { + private encodeImage(rawImageData: ImageDataLike, options: any): Promise { const imageType = options?.imageType ?? 'image/webp'; const quality = options?.quality; @@ -488,20 +442,16 @@ export class PdfEngine implements IPdfEngine { height: rawImageData.height, }; - this.options - .imageConverter(() => plainImageData, imageType, quality) - .then((result) => resultTask.resolve(result)) - .catch((error) => resultTask.reject({ code: PdfErrorCode.Unknown, message: String(error) })); + return this.options.imageConverter(() => plainImageData, imageType, quality); } /** * Encode a full annotation appearance map to the output type T. */ - private encodeAppearanceMap( + private async encodeAppearanceMap( rawMap: AnnotationAppearanceMap, options: PdfRenderPageAnnotationOptions | undefined, - resultTask: Task, PdfErrorReason>, - ): void { + ): Promise> { const imageType = options?.imageType ?? 'image/webp'; const quality = options?.imageQuality; @@ -537,17 +487,8 @@ export class PdfEngine implements IPdfEngine { } } - Promise.all(jobs) - .then(() => { - if (resultTask.state.stage === 0 /* Pending */) { - resultTask.resolve(encodedMap); - } - }) - .catch((error) => { - if (resultTask.state.stage === 0 /* Pending */) { - resultTask.reject({ code: PdfErrorCode.Unknown, message: String(error) }); - } - }); + await Promise.all(jobs); + return encodedMap; } // ========== Annotations ========== diff --git a/packages/engines/src/lib/orchestrator/pdfium-native-runner.ts b/packages/engines/src/lib/orchestrator/pdfium-native-runner.ts index 5fc147343..72b698a1f 100644 --- a/packages/engines/src/lib/orchestrator/pdfium-native-runner.ts +++ b/packages/engines/src/lib/orchestrator/pdfium-native-runner.ts @@ -1,6 +1,7 @@ import { Logger, NoopLogger, Task, TaskError, PdfErrorCode } from '@embedpdf/models'; import { PdfiumNative } from '../pdfium/engine'; import { init } from '@embedpdf/pdfium'; +import { collectTransferables } from '../collect-transferables'; const LOG_SOURCE = 'PdfiumNativeRunner'; const LOG_CATEGORY = 'Worker'; @@ -255,7 +256,7 @@ export class PdfiumNativeRunner { */ private respond(response: WorkerResponse): void { this.logger.debug(LOG_SOURCE, LOG_CATEGORY, 'Sending response:', response.type); - self.postMessage(response); + self.postMessage(response, { transfer: collectTransferables(response) }); } /** diff --git a/packages/engines/src/lib/orchestrator/task-queue.ts b/packages/engines/src/lib/orchestrator/task-queue.ts index e63f51cb0..4880cad24 100644 --- a/packages/engines/src/lib/orchestrator/task-queue.ts +++ b/packages/engines/src/lib/orchestrator/task-queue.ts @@ -1,15 +1,8 @@ -import { Task, TaskError, Logger, NoopLogger } from '@embedpdf/models'; +import { Task, Logger, NoopLogger, RenderPriority } from '@embedpdf/models'; const LOG_SOURCE = 'TaskQueue'; const LOG_CATEGORY = 'Queue'; -export enum Priority { - CRITICAL = 3, - HIGH = 2, - MEDIUM = 1, - LOW = 0, -} - // ============================================================================ // Type Utilities // ============================================================================ @@ -35,14 +28,14 @@ export type ExtractTaskProgress = T extends Task ? P : nev export interface QueuedTask> { id: string; - priority: Priority; + priority: RenderPriority; meta?: Record; executeFactory: () => T; // Factory function - called when it's time to execute! cancelled?: boolean; } export interface EnqueueOptions { - priority?: Priority; + priority?: RenderPriority; meta?: Record; fifo?: boolean; } @@ -152,7 +145,7 @@ export class WorkerTaskQueue { * const task = queue.enqueue({ * execute: () => this.executor.getMetadata(doc), // Factory - not called yet! * meta: { operation: 'getMetadata' } - * }, { priority: Priority.LOW }); + * }, { priority: RenderPriority.LOW }); * * The returned task has the SAME type as executor.getMetadata() would return! */ @@ -164,7 +157,7 @@ export class WorkerTaskQueue { options: EnqueueOptions = {}, ): T { const id = this.generateId(); - const priority = options.priority ?? Priority.MEDIUM; + const priority = options.priority ?? RenderPriority.MEDIUM; // Create a proxy task that we return to the user // This task bridges to the real task that will be created later diff --git a/packages/engines/src/lib/webworker/runner.ts b/packages/engines/src/lib/webworker/runner.ts index 89be46e32..59c7d48d8 100644 --- a/packages/engines/src/lib/webworker/runner.ts +++ b/packages/engines/src/lib/webworker/runner.ts @@ -9,6 +9,7 @@ import { PdfErrorCode, TaskReturn, } from '@embedpdf/models'; +import { collectTransferables } from '../collect-transferables'; /** * Request body that represent method calls of PdfEngine, it contains the @@ -472,6 +473,6 @@ export class EngineRunner { */ respond(response: Response) { this.logger.debug(LOG_SOURCE, LOG_CATEGORY, 'runner respond: ', response); - self.postMessage(response); + self.postMessage(response, { transfer: collectTransferables(response) }); } } diff --git a/packages/models/src/pdf.ts b/packages/models/src/pdf.ts index 7643ef701..8e4635086 100644 --- a/packages/models/src/pdf.ts +++ b/packages/models/src/pdf.ts @@ -2919,6 +2919,17 @@ export interface PdfOpenDocumentUrlOptions { normalizeRotation?: boolean; } +/** + * Scheduling priority for render operations (higher = more urgent). + * Used by the orchestrator engine's task queue. + */ +export enum RenderPriority { + LOW = 0, + MEDIUM = 1, + HIGH = 2, + CRITICAL = 3, +} + export interface PdfRenderOptions { /** * Scale factor @@ -2940,6 +2951,10 @@ export interface PdfRenderOptions { * Image quality (0-1) for jpeg and png */ imageQuality?: number; + /** + * Scheduling priority. + */ + priority?: RenderPriority; } export interface ConvertToBlobOptions { diff --git a/packages/models/src/task.ts b/packages/models/src/task.ts index ad7282ba0..8df445ccf 100644 --- a/packages/models/src/task.ts +++ b/packages/models/src/task.ts @@ -271,6 +271,66 @@ export class Task { } } + /** + * Transform the result of this task using a mapping function. + * Returns a new Task that resolves with the mapped value. + * + * Aborting the derived task also aborts the source task, and source errors + * are forwarded to the derived task directly (bypassing `errMap`). + * + * Note: progress events from the source task are **not** forwarded + * to the derived task. + * + * @param fn - mapping function that transforms the source result + * @param errMap - maps errors from `fn` (sync throws or rejected promises) + * into the error type `D`. Source task errors are forwarded as-is. + * @returns a new Task that resolves with the mapped result + */ + map(fn: (result: R) => U | Promise, errMap: (err: unknown) => D): Task { + const derived = new Task(); + const origAbort = derived.abort.bind(derived); + derived.abort = (reason: D) => { + this.abort(reason); + origAbort(reason); + }; + + this.wait( + (result) => { + if (derived.state.stage !== TaskStage.Pending) return; + try { + const mapped = fn(result); + if (mapped instanceof Promise) { + mapped.then( + (value) => { + if (derived.state.stage === TaskStage.Pending) { + derived.resolve(value); + } + }, + (err) => { + if (derived.state.stage === TaskStage.Pending) { + derived.reject(errMap(err)); + } + }, + ); + } else { + derived.resolve(mapped); + } + } catch (err) { + if (derived.state.stage === TaskStage.Pending) { + derived.reject(errMap(err)); + } + } + }, + (error) => { + if (derived.state.stage === TaskStage.Pending) { + derived.fail(error); + } + }, + ); + + return derived; + } + /** * add a progress callback * @param cb - progress callback diff --git a/packages/plugin-render/src/lib/render-plugin.ts b/packages/plugin-render/src/lib/render-plugin.ts index 18eda1e1a..96be31eda 100644 --- a/packages/plugin-render/src/lib/render-plugin.ts +++ b/packages/plugin-render/src/lib/render-plugin.ts @@ -1,4 +1,13 @@ import { BasePlugin, PluginRegistry } from '@embedpdf/core'; +import { + PdfDocumentObject, + PdfErrorCode, + PdfPageObject, + PdfRenderPageOptions, + RenderPriority, + TaskStage, +} from '@embedpdf/models'; +import type { ImageDataLike, PdfTask } from '@embedpdf/models'; import { RenderCapability, RenderPageOptions, @@ -32,6 +41,8 @@ export class RenderPlugin extends BasePlugin this.renderPageRect(options), renderPageRaw: (options: RenderPageOptions) => this.renderPageRaw(options), renderPageRectRaw: (options: RenderPageRectOptions) => this.renderPageRectRaw(options), + renderPageBitmap: (options: RenderPageOptions) => this.renderPageBitmap(options), + renderPageRectBitmap: (options: RenderPageRectOptions) => this.renderPageRectBitmap(options), // Document-scoped operations forDocument: (documentId: string) => this.createRenderScope(documentId), @@ -49,14 +60,20 @@ export class RenderPlugin extends BasePlugin this.renderPageRaw(options, documentId), renderPageRectRaw: (options: RenderPageRectOptions) => this.renderPageRectRaw(options, documentId), + renderPageBitmap: (options: RenderPageOptions) => this.renderPageBitmap(options, documentId), + renderPageRectBitmap: (options: RenderPageRectOptions) => + this.renderPageRectBitmap(options, documentId), }; } // ───────────────────────────────────────────────────────── - // Core Operations + // Helpers // ───────────────────────────────────────────────────────── - private renderPage({ pageIndex, options }: RenderPageOptions, documentId?: string) { + private resolveDocAndPage( + pageIndex: number, + documentId?: string, + ): { doc: PdfDocumentObject; page: PdfPageObject } { const id = documentId ?? this.getActiveDocumentId(); const coreDoc = this.coreState.core.documents[id]; @@ -69,90 +86,103 @@ export class RenderPlugin extends BasePlugin p.index === pageIndex); - if (!page) { - throw new Error(`Page ${pageIndex} not found in document ${id}`); - } - - const mergedOptions = { + private mergeRawOptions(options?: PdfRenderPageOptions): PdfRenderPageOptions { + return { ...(options ?? {}), withForms: options?.withForms ?? this.config.withForms ?? false, withAnnotations: options?.withAnnotations ?? this.config.withAnnotations ?? false, - imageType: options?.imageType ?? this.config.defaultImageType ?? 'image/png', - imageQuality: options?.imageQuality ?? this.config.defaultImageQuality ?? 0.92, }; - - return this.engine.renderPageRect(coreDoc.document, page, rect, mergedOptions); } // ───────────────────────────────────────────────────────── - // Raw Rendering (returns ImageDataLike, skips encoding) + // Core Operations // ───────────────────────────────────────────────────────── - private renderPageRaw({ pageIndex, options }: RenderPageOptions, documentId?: string) { - const id = documentId ?? this.getActiveDocumentId(); - const coreDoc = this.coreState.core.documents[id]; - - if (!coreDoc?.document) { - throw new Error(`Document ${id} not loaded`); - } + private renderPage({ pageIndex, options }: RenderPageOptions, documentId?: string) { + const { doc, page } = this.resolveDocAndPage(pageIndex, documentId); + return this.engine.renderPage(doc, page, this.mergeImageOptions(options)); + } - const page = coreDoc.document.pages.find((p) => p.index === pageIndex); - if (!page) { - throw new Error(`Page ${pageIndex} not found in document ${id}`); - } + private renderPageRect({ pageIndex, rect, options }: RenderPageRectOptions, documentId?: string) { + const { doc, page } = this.resolveDocAndPage(pageIndex, documentId); + return this.engine.renderPageRect(doc, page, rect, this.mergeImageOptions(options)); + } - const mergedOptions = { - ...(options ?? {}), - withForms: options?.withForms ?? this.config.withForms ?? false, - withAnnotations: options?.withAnnotations ?? this.config.withAnnotations ?? false, - }; + // ───────────────────────────────────────────────────────── + // Raw Rendering (returns ImageDataLike, skips encoding) + // ───────────────────────────────────────────────────────── - return this.engine.renderPageRaw(coreDoc.document, page, mergedOptions); + private renderPageRaw({ pageIndex, options }: RenderPageOptions, documentId?: string) { + const { doc, page } = this.resolveDocAndPage(pageIndex, documentId); + return this.engine.renderPageRaw(doc, page, this.mergeRawOptions(options)); } private renderPageRectRaw( { pageIndex, rect, options }: RenderPageRectOptions, documentId?: string, ) { - const id = documentId ?? this.getActiveDocumentId(); - const coreDoc = this.coreState.core.documents[id]; + const { doc, page } = this.resolveDocAndPage(pageIndex, documentId); + return this.engine.renderPageRectRaw(doc, page, rect, this.mergeRawOptions(options)); + } - if (!coreDoc?.document) { - throw new Error(`Document ${id} not loaded`); - } + // ───────────────────────────────────────────────────────── + // Bitmap Rendering + // ───────────────────────────────────────────────────────── - const page = coreDoc.document.pages.find((p) => p.index === pageIndex); - if (!page) { - throw new Error(`Page ${pageIndex} not found in document ${id}`); - } + /** + * Convert a raw render task to an ImageBitmap task. + * After `createImageBitmap` resolves, we check whether the derived task + * was aborted/rejected while the async conversion was in progress. + * If so, close the bitmap immediately to avoid leaking GPU memory. + */ + private rawToBitmap(rawTask: PdfTask) { + const derived = rawTask.map( + async (raw) => { + const bitmap = await createImageBitmap(new ImageData(raw.data, raw.width, raw.height)); + if (derived.state.stage !== TaskStage.Pending) { + bitmap.close(); + } + return bitmap; + }, + (err: unknown) => ({ code: PdfErrorCode.Unknown, message: String(err) }), + ); + return derived; + } - const mergedOptions = { - ...(options ?? {}), - withForms: options?.withForms ?? this.config.withForms ?? false, - withAnnotations: options?.withAnnotations ?? this.config.withAnnotations ?? false, - }; + private renderPageBitmap({ pageIndex, options }: RenderPageOptions, documentId?: string) { + const { doc, page } = this.resolveDocAndPage(pageIndex, documentId); + return this.rawToBitmap( + this.engine.renderPageRaw(doc, page, { + ...this.mergeRawOptions(options), + priority: RenderPriority.CRITICAL, + }), + ); + } - return this.engine.renderPageRectRaw(coreDoc.document, page, rect, mergedOptions); + private renderPageRectBitmap( + { pageIndex, rect, options }: RenderPageRectOptions, + documentId?: string, + ) { + const { doc, page } = this.resolveDocAndPage(pageIndex, documentId); + return this.rawToBitmap( + this.engine.renderPageRectRaw(doc, page, rect, { + ...this.mergeRawOptions(options), + priority: RenderPriority.HIGH, + }), + ); } // ───────────────────────────────────────────────────────── diff --git a/packages/plugin-render/src/lib/types.ts b/packages/plugin-render/src/lib/types.ts index 0ce1b92e5..d6fadff75 100644 --- a/packages/plugin-render/src/lib/types.ts +++ b/packages/plugin-render/src/lib/types.ts @@ -48,6 +48,24 @@ export interface RenderScope { renderPageRect(options: RenderPageRectOptions): Task; renderPageRaw(options: RenderPageOptions): Task; renderPageRectRaw(options: RenderPageRectOptions): Task; + /** + * Render a full page as an `ImageBitmap`. + * + * Returns a fresh (uncached) bitmap wrapped in a `Task`. + * **Caller owns the bitmap** — transfer it to a canvas via + * `bitmaprenderer` context, or call `bitmap.close()` to free GPU memory. + * If the task is aborted before resolution, the bitmap is closed internally. + */ + renderPageBitmap(options: RenderPageOptions): Task; + /** + * Render a rectangular region of a page as an `ImageBitmap`. + * + * Returns a fresh (uncached) bitmap wrapped in a `Task`. + * **Caller owns the bitmap** — transfer it to a canvas via + * `bitmaprenderer` context, or call `bitmap.close()` to free GPU memory. + * If the task is aborted before resolution, the bitmap is closed internally. + */ + renderPageRectBitmap(options: RenderPageRectOptions): Task; } export interface RenderCapability { @@ -56,6 +74,10 @@ export interface RenderCapability { renderPageRect(options: RenderPageRectOptions): Task; renderPageRaw(options: RenderPageOptions): Task; renderPageRectRaw(options: RenderPageRectOptions): Task; + /** {@inheritDoc RenderScope.renderPageBitmap} */ + renderPageBitmap(options: RenderPageOptions): Task; + /** {@inheritDoc RenderScope.renderPageRectBitmap} */ + renderPageRectBitmap(options: RenderPageRectOptions): Task; // Document-scoped operations forDocument(documentId: string): RenderScope; diff --git a/packages/plugin-render/src/shared/components/render-layer.tsx b/packages/plugin-render/src/shared/components/render-layer.tsx index 7caafd35c..c8c635814 100644 --- a/packages/plugin-render/src/shared/components/render-layer.tsx +++ b/packages/plugin-render/src/shared/components/render-layer.tsx @@ -1,12 +1,12 @@ -import { Fragment, useEffect, useRef, useState, useMemo } from '@framework'; +import { useEffect, useRef, useMemo } from '@framework'; import type { CSSProperties, HTMLAttributes } from '@framework'; -import { ignore, PdfErrorCode, Rotation } from '@embedpdf/models'; +import { ignore, PdfErrorCode } from '@embedpdf/models'; import { useRenderCapability } from '../hooks/use-render'; import { useDocumentState } from '@embedpdf/core/@framework'; -type RenderLayerProps = Omit, 'style'> & { +type RenderLayerProps = Omit, 'style'> & { /** * The ID of the document to render from */ @@ -24,11 +24,21 @@ type RenderLayerProps = Omit, 'style'> & { */ dpr?: number; /** - * Additional styles for the image element + * Additional styles for the canvas element */ style?: CSSProperties; }; +function paintBitmap(canvas: HTMLCanvasElement, bitmap: ImageBitmap) { + try { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('bitmaprenderer')!.transferFromImageBitmap(bitmap); + } catch { + // Bitmap was detached + } +} + /** * RenderLayer Component * @@ -50,8 +60,7 @@ export function RenderLayer({ const { provides: renderProvides } = useRenderCapability(); const documentState = useDocumentState(documentId); - const [imageUrl, setImageUrl] = useState(null); - const urlRef = useRef(null); + const canvasRef = useRef(null); // Get refresh version from core state const refreshVersion = useMemo(() => { @@ -73,7 +82,8 @@ export function RenderLayer({ useEffect(() => { if (!renderProvides) return; - const task = renderProvides.forDocument(documentId).renderPage({ + let currentBitmap: ImageBitmap | null = null; + const task = renderProvides.forDocument(documentId).renderPageBitmap({ pageIndex, options: { scaleFactor: actualScale, @@ -81,46 +91,32 @@ export function RenderLayer({ }, }); - task.wait((blob) => { - const url = URL.createObjectURL(blob); - setImageUrl(url); - urlRef.current = url; + task.wait((output) => { + currentBitmap = output; + const canvas = canvasRef.current; + if (canvas) { + paintBitmap(canvas, output); + currentBitmap = null; // transferred to canvas + } }, ignore); return () => { - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } else { - task.abort({ - code: PdfErrorCode.Cancelled, - message: 'canceled render task', - }); + task.abort({ + code: PdfErrorCode.Cancelled, + message: 'canceled render task', + }); + if (currentBitmap) { + currentBitmap.close(); + currentBitmap = null; } }; }, [documentId, pageIndex, actualScale, actualDpr, renderProvides, refreshVersion]); - const handleImageLoad = () => { - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } + const elementStyle: CSSProperties = { + width: '100%', + height: '100%', + ...(style || {}), }; - return ( - - {imageUrl && ( - - )} - - ); + return ; } diff --git a/packages/plugin-render/src/svelte/components/RenderLayer.svelte b/packages/plugin-render/src/svelte/components/RenderLayer.svelte index 3737196d5..bd6f3391b 100644 --- a/packages/plugin-render/src/svelte/components/RenderLayer.svelte +++ b/packages/plugin-render/src/svelte/components/RenderLayer.svelte @@ -1,10 +1,11 @@ -{#if imageUrl} - -{/if} + diff --git a/packages/plugin-render/src/vue/components/render-layer.vue b/packages/plugin-render/src/vue/components/render-layer.vue index 55abeeadc..2a054e413 100644 --- a/packages/plugin-render/src/vue/components/render-layer.vue +++ b/packages/plugin-render/src/vue/components/render-layer.vue @@ -1,5 +1,5 @@ diff --git a/packages/plugin-thumbnail/src/lib/thumbnail-plugin.ts b/packages/plugin-thumbnail/src/lib/thumbnail-plugin.ts index d555f3d36..df02d9750 100644 --- a/packages/plugin-thumbnail/src/lib/thumbnail-plugin.ts +++ b/packages/plugin-thumbnail/src/lib/thumbnail-plugin.ts @@ -1,12 +1,4 @@ -import { - BasePlugin, - createBehaviorEmitter, - createEmitter, - createScopedEmitter, - Listener, - PluginRegistry, - REFRESH_PAGES, -} from '@embedpdf/core'; +import { BasePlugin, createScopedEmitter, PluginRegistry, REFRESH_PAGES } from '@embedpdf/core'; import { ScrollToOptions, ThumbMeta, @@ -20,7 +12,7 @@ import { ThumbnailState, ThumbnailDocumentState, } from './types'; -import { ignore, PdfErrorReason, Task } from '@embedpdf/models'; +import { ignore, PdfErrorReason, Task, TaskStage } from '@embedpdf/models'; import { RenderCapability, RenderPlugin } from '@embedpdf/plugin-render'; import { ScrollCapability, ScrollPlugin } from '@embedpdf/plugin-scroll'; import { @@ -32,6 +24,9 @@ import { } from './actions'; import { initialDocumentState } from './reducer'; +// LRU cache size - should this be a config option? +const THUMBNAIL_CACHE_SIZE = 30; + export class ThumbnailPlugin extends BasePlugin< ThumbnailPluginConfig, ThumbnailCapability, @@ -43,8 +38,10 @@ export class ThumbnailPlugin extends BasePlugin< private renderCapability: RenderCapability; private scrollCapability: ScrollCapability | null = null; - // Per-document task caches - private readonly taskCaches = new Map>>(); + // Per-document LRU task cache (serves both dedup and caching) + // In-flight tasks dedup concurrent requests; resolved tasks serve cached bitmaps. + // Map insertion-order is used for LRU eviction. + private readonly thumbCaches = new Map>>(); // Per-document auto-scroll tracking private readonly canAutoScroll = new Map(); @@ -76,10 +73,11 @@ export class ThumbnailPlugin extends BasePlugin< this.refreshPages$.emit(documentId, pages); - const taskCache = this.taskCaches.get(documentId); - if (taskCache) { + // Evict affected pages from cache (close resolved bitmaps to free GPU memory) + const cache = this.thumbCaches.get(documentId); + if (cache) { for (const pageIndex of pages) { - taskCache.delete(pageIndex); + this.evictEntry(cache, pageIndex); } } }); @@ -112,8 +110,8 @@ export class ThumbnailPlugin extends BasePlugin< }), ); - // Initialize task cache - this.taskCaches.set(documentId, new Map()); + // Initialize cache + this.thumbCaches.set(documentId, new Map()); this.canAutoScroll.set(documentId, true); this.logger.debug( @@ -132,17 +130,14 @@ export class ThumbnailPlugin extends BasePlugin< // Cleanup state this.dispatch(cleanupThumbnailState(documentId)); - // Cleanup task cache - const taskCache = this.taskCaches.get(documentId); - if (taskCache) { - taskCache.forEach((task) => { - task.abort({ - code: 'cancelled' as any, - message: 'Document closed', - }); + // Cleanup cache (abort in-flight, close resolved bitmaps) + const cache = this.thumbCaches.get(documentId); + if (cache) { + cache.forEach((task) => { + this.closeCacheEntry(task); }); - taskCache.clear(); - this.taskCaches.delete(documentId); + cache.clear(); + this.thumbCaches.delete(documentId); } this.canAutoScroll.delete(documentId); @@ -208,6 +203,51 @@ export class ThumbnailPlugin extends BasePlugin< return this.state.documents[id] ?? null; } + // ───────────────────────────────────────────────────────── + // LRU Cache Helpers + // ───────────────────────────────────────────────────────── + + /** Touch an entry to mark it as most-recently-used (re-insert at end). */ + private lruTouch( + cache: Map>, + key: number, + task: Task, + ): void { + cache.delete(key); + cache.set(key, task); + } + + /** Evict oldest entries until the cache is within capacity. */ + private lruEvict(cache: Map>): void { + while (cache.size > THUMBNAIL_CACHE_SIZE) { + const oldest = cache.keys().next().value!; + const task = cache.get(oldest)!; + cache.delete(oldest); + this.closeCacheEntry(task); + } + } + + /** + * Close the bitmap inside a resolved task, or abort if still pending. + * Rejected tasks need no cleanup — no bitmap was produced. + */ + private closeCacheEntry(task: Task): void { + if (task.state.stage === TaskStage.Resolved) { + task.state.result.close(); + } else if (task.state.stage === TaskStage.Pending) { + task.abort({ code: 'cancelled' as any, message: 'evicted' }); + } + } + + /** Remove a single entry from the cache, closing its bitmap. */ + private evictEntry(cache: Map>, key: number): void { + const task = cache.get(key); + if (task) { + this.closeCacheEntry(task); + cache.delete(key); + } + } + // ───────────────────────────────────────────────────────── // Core Operations // ───────────────────────────────────────────────────────── @@ -362,15 +402,26 @@ export class ThumbnailPlugin extends BasePlugin< } } + /** + * Return a cached (or newly created) thumbnail render task. + * Deduplicates concurrent requests: in-flight tasks are reused. + * Resolved tasks serve cached bitmaps until LRU-evicted. + */ private renderThumb(idx: number, dpr: number, documentId?: string) { const id = documentId ?? this.getActiveDocumentId(); - const taskCache = this.taskCaches.get(id); - if (!taskCache) { - throw new Error(`Task cache not found for document: ${id}`); + const cache = this.thumbCaches.get(id); + if (!cache) { + throw new Error(`Thumb cache not found for document: ${id}`); } - if (taskCache.has(idx)) return taskCache.get(idx)!; + // Cache hit (in-flight or resolved) — touch for LRU and return + const existing = cache.get(idx); + if (existing) { + this.lruTouch(cache, idx, existing); + return existing; + } + // Cache miss — render const coreDoc = this.coreState.core.documents[id]; if (!coreDoc?.document) { throw new Error(`Document not found: ${id}`); @@ -387,7 +438,7 @@ export class ThumbnailPlugin extends BasePlugin< const scale = INNER_W / page.size.width; - const task = this.renderCapability.forDocument(id).renderPageRect({ + const task = this.renderCapability.forDocument(id).renderPageRectBitmap({ pageIndex: idx, rect: { origin: { x: 0, y: 0 }, size: page.size }, options: { @@ -397,8 +448,11 @@ export class ThumbnailPlugin extends BasePlugin< }, }); - taskCache.set(idx, task); - task.wait(ignore, () => taskCache.delete(idx)); + cache.set(idx, task); + // On failure, remove so next request retries + task.wait(ignore, () => cache.delete(idx)); + // Evict oldest if over capacity + this.lruEvict(cache); return task; } @@ -415,17 +469,15 @@ export class ThumbnailPlugin extends BasePlugin< this.refreshPages$.clear(); this.scrollTo$.clear(); - // Cleanup all task caches - this.taskCaches.forEach((cache) => { + // Cleanup all caches (abort in-flight, close resolved bitmaps) + this.thumbCaches.forEach((cache) => { cache.forEach((task) => { - task.abort({ - code: 'cancelled' as any, - message: 'Plugin destroyed', - }); + this.closeCacheEntry(task); }); cache.clear(); }); - this.taskCaches.clear(); + this.thumbCaches.clear(); + this.canAutoScroll.clear(); super.destroy(); diff --git a/packages/plugin-thumbnail/src/lib/types.ts b/packages/plugin-thumbnail/src/lib/types.ts index 94029dc1e..28828c0d1 100644 --- a/packages/plugin-thumbnail/src/lib/types.ts +++ b/packages/plugin-thumbnail/src/lib/types.ts @@ -87,7 +87,21 @@ export interface RefreshPagesEvent { // Scoped thumbnail capability export interface ThumbnailScope { scrollToThumb(pageIdx: number): void; - renderThumb(pageIdx: number, dpr: number): Task; + /** + * Get or create a cached thumbnail bitmap for the given page. + * + * Returns an LRU-cached `Task` — multiple calls for the same + * page return the **same** task instance. + * + * **Caller must NOT close the bitmap.** The LRU cache owns its lifecycle. + * Use `drawImage` (pixel copy) rather than `transferFromImageBitmap` + * (ownership transfer) to paint, since the bitmap must remain valid for + * other consumers. + * + * Bitmaps are automatically freed on LRU eviction, page refresh, or + * document close. + */ + renderThumb(pageIdx: number, dpr: number): Task; updateWindow(scrollY: number, viewportH: number): void; getWindow(): WindowState | null; onWindow: EventHook; @@ -98,7 +112,8 @@ export interface ThumbnailScope { export interface ThumbnailCapability { // Active document operations scrollToThumb(pageIdx: number): void; - renderThumb(pageIdx: number, dpr: number): Task; + /** {@inheritDoc ThumbnailScope.renderThumb} */ + renderThumb(pageIdx: number, dpr: number): Task; updateWindow(scrollY: number, viewportH: number): void; getWindow(): WindowState | null; diff --git a/packages/plugin-thumbnail/src/shared/components/thumbnail-img.tsx b/packages/plugin-thumbnail/src/shared/components/thumbnail-img.tsx index 5cd6d8574..54ebfbde6 100644 --- a/packages/plugin-thumbnail/src/shared/components/thumbnail-img.tsx +++ b/packages/plugin-thumbnail/src/shared/components/thumbnail-img.tsx @@ -3,7 +3,7 @@ import { ThumbMeta } from '@embedpdf/plugin-thumbnail'; import { ignore, PdfErrorCode } from '@embedpdf/models'; import { useThumbnailCapability, useThumbnailPlugin } from '../hooks'; -type ThumbnailImgProps = Omit, 'style'> & { +type ThumbnailImgProps = Omit, 'style'> & { /** * The ID of the document that this thumbnail belongs to */ @@ -12,11 +12,23 @@ type ThumbnailImgProps = Omit, 'style'> & { meta: ThumbMeta; }; +function paintBitmap(canvas: HTMLCanvasElement, bitmap: ImageBitmap) { + try { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')!.drawImage(bitmap, 0, 0); + } catch { + // Bitmap was closed (e.g. LRU eviction) + } +} + export function ThumbImg({ documentId, meta, style, ...props }: ThumbnailImgProps) { const { provides: thumbs } = useThumbnailCapability(); const { plugin: thumbnailPlugin } = useThumbnailPlugin(); - const [url, setUrl] = useState(); - const urlRef = useRef(null); + + const [hasContent, setHasContent] = useState(false); + const canvasRef = useRef(null); + const [refreshTick, setRefreshTick] = useState(0); useEffect(() => { @@ -29,34 +41,33 @@ export function ThumbImg({ documentId, meta, style, ...props }: ThumbnailImgProp }); }, [thumbnailPlugin, documentId, meta.pageIndex]); + // Bitmap render effect useEffect(() => { const scope = thumbs?.forDocument(documentId); const task = scope?.renderThumb(meta.pageIndex, window.devicePixelRatio); - task?.wait((blob) => { - const objectUrl = URL.createObjectURL(blob); - urlRef.current = objectUrl; - setUrl(objectUrl); + task?.wait((output) => { + const canvas = canvasRef.current; + if (canvas) { + paintBitmap(canvas, output); + setHasContent(true); + } }, ignore); return () => { - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } else { - task?.abort({ - code: PdfErrorCode.Cancelled, - message: 'canceled render task', - }); - } + task?.abort({ + code: PdfErrorCode.Cancelled, + message: 'canceled render task', + }); }; }, [thumbs, documentId, meta.pageIndex, refreshTick]); - const handleImageLoad = () => { - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } - }; + // Do NOT close bitmap — LRU cache owns lifecycle - return url ? : null; + return ( + + ); } diff --git a/packages/plugin-thumbnail/src/svelte/components/ThumbImg.svelte b/packages/plugin-thumbnail/src/svelte/components/ThumbImg.svelte index a247239e5..9f2d11d9c 100644 --- a/packages/plugin-thumbnail/src/svelte/components/ThumbImg.svelte +++ b/packages/plugin-thumbnail/src/svelte/components/ThumbImg.svelte @@ -2,9 +2,9 @@ import type { ThumbMeta } from '@embedpdf/plugin-thumbnail'; import { ignore, PdfErrorCode } from '@embedpdf/models'; import { useThumbnailCapability, useThumbnailPlugin } from '../hooks'; - import type { HTMLImgAttributes } from 'svelte/elements'; + import type { HTMLAttributes } from 'svelte/elements'; - interface Props extends HTMLImgAttributes { + interface Props extends HTMLAttributes { /** * The ID of the document that this thumbnail belongs to */ @@ -12,13 +12,13 @@ meta: ThumbMeta; } - const { documentId, meta, ...imgProps }: Props = $props(); + const { documentId, meta, ...canvasProps }: Props = $props(); const thumbnailCapability = useThumbnailCapability(); const thumbnailPlugin = useThumbnailPlugin(); - let url = $state(undefined); - let urlRef: string | null = null; + let canvasEl: HTMLCanvasElement | undefined = $state(undefined); + let hasContent = $state(false); let refreshTick = $state(0); // Listen for refresh events for this specific document @@ -32,6 +32,18 @@ }); }); + function paintBitmap(bitmap: ImageBitmap) { + if (!canvasEl) return; + try { + canvasEl.width = bitmap.width; + canvasEl.height = bitmap.height; + canvasEl.getContext('2d')!.drawImage(bitmap, 0, 0); + hasContent = true; + } catch { + // Bitmap was closed + } + } + // Render thumbnail for this specific document $effect(() => { // Track refreshTick so effect re-runs on refresh @@ -42,33 +54,22 @@ const scope = thumbnailCapability.provides.forDocument(documentId); const task = scope.renderThumb(meta.pageIndex, window.devicePixelRatio); - task.wait((blob) => { - const objectUrl = URL.createObjectURL(blob); - urlRef = objectUrl; - url = objectUrl; + task.wait((bitmap) => { + paintBitmap(bitmap); + // Do NOT close bitmap — LRU cache owns lifecycle }, ignore); return () => { - if (urlRef) { - URL.revokeObjectURL(urlRef); - urlRef = null; - } else { - task.abort({ - code: PdfErrorCode.Cancelled, - message: 'canceled render task', - }); - } + task.abort({ + code: PdfErrorCode.Cancelled, + message: 'canceled render task', + }); }; }); - - const handleImageLoad = () => { - if (urlRef) { - URL.revokeObjectURL(urlRef); - urlRef = null; - } - }; -{#if url} - PDF thumbnail -{/if} + diff --git a/packages/plugin-thumbnail/src/vue/components/thumbnail-img.vue b/packages/plugin-thumbnail/src/vue/components/thumbnail-img.vue index aa74793a3..f9b084415 100644 --- a/packages/plugin-thumbnail/src/vue/components/thumbnail-img.vue +++ b/packages/plugin-thumbnail/src/vue/components/thumbnail-img.vue @@ -17,8 +17,8 @@ const props = defineProps(); const { provides: thumbs } = useThumbnailCapability(); const { plugin: thumbnailPlugin } = useThumbnailPlugin(); -const url = ref(null); -let urlToRevoke: string | null = null; +const canvasRef = ref(null); +const hasContent = ref(false); const refreshTick = ref(0); // Watch for refresh events for this specific document @@ -39,53 +39,41 @@ watch( { immediate: true }, ); -function revoke() { - if (urlToRevoke) { - URL.revokeObjectURL(urlToRevoke); - urlToRevoke = null; +function paintBitmap(bitmap: ImageBitmap) { + if (!canvasRef.value) return; + try { + const canvas = canvasRef.value; + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('2d')!.drawImage(bitmap, 0, 0); + hasContent.value = true; + } catch { + // Bitmap was closed } } -let abortTask: (() => void) | null = null; - // Render thumbnail when dependencies change watch( [() => thumbs.value, () => props.documentId, () => props.meta.pageIndex, refreshTick], ([capability, docId, pageIdx], _, onCleanup) => { - // Cancel previous task - if (abortTask) { - abortTask(); - abortTask = null; - } - if (!capability) { - url.value = null; + hasContent.value = false; return; } const scope = capability.forDocument(docId); const task = scope.renderThumb(pageIdx, window.devicePixelRatio); - abortTask = () => + task.wait((bitmap) => { + paintBitmap(bitmap); + // Do NOT close bitmap — LRU cache owns lifecycle + }, ignore); + + onCleanup(() => { task.abort({ code: PdfErrorCode.Cancelled, message: 'canceled render task', }); - - task.wait((blob) => { - revoke(); - const objectUrl = URL.createObjectURL(blob); - urlToRevoke = objectUrl; - url.value = objectUrl; - abortTask = null; - }, ignore); - - onCleanup(() => { - if (abortTask) { - abortTask(); - abortTask = null; - } - revoke(); }); }, { immediate: true }, @@ -93,5 +81,5 @@ watch( diff --git a/packages/plugin-tiling/src/lib/tiling-plugin.ts b/packages/plugin-tiling/src/lib/tiling-plugin.ts index 85aa4e10f..fac868add 100644 --- a/packages/plugin-tiling/src/lib/tiling-plugin.ts +++ b/packages/plugin-tiling/src/lib/tiling-plugin.ts @@ -200,7 +200,7 @@ export class TilingPlugin extends BasePlugin Task; + /** + * Render a single tile as an `ImageBitmap`. + * + * Returns a fresh (uncached) bitmap per call — no deduplication. + * **Caller owns the bitmap** — transfer it to a canvas or call + * `bitmap.close()` to free GPU memory. + */ + renderTile: (options: RenderTileOptions) => Task; onTileRendering: EventHook>; } export interface TilingCapability { - renderTile: (options: RenderTileOptions, documentId?: string) => Task; + /** {@inheritDoc TilingScope.renderTile} */ + renderTile: ( + options: RenderTileOptions, + documentId?: string, + ) => Task; forDocument(documentId: string): TilingScope; onTileRendering: EventHook; } diff --git a/packages/plugin-tiling/src/shared/components/tile-img.tsx b/packages/plugin-tiling/src/shared/components/tile-img.tsx index ace96746a..869fe3af6 100644 --- a/packages/plugin-tiling/src/shared/components/tile-img.tsx +++ b/packages/plugin-tiling/src/shared/components/tile-img.tsx @@ -12,6 +12,16 @@ interface TileImgProps { scale: number; } +function paintBitmap(canvas: HTMLCanvasElement, bitmap: ImageBitmap) { + try { + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('bitmaprenderer')!.transferFromImageBitmap(bitmap); + } catch { + // Bitmap was detached + } +} + export function TileImg({ documentId, pageIndex, tile, dpr, scale }: TileImgProps) { const { provides: tilingCapability } = useTilingCapability(); const scope = useMemo( @@ -19,55 +29,49 @@ export function TileImg({ documentId, pageIndex, tile, dpr, scale }: TileImgProp [tilingCapability, documentId], ); - const [url, setUrl] = useState(); - const urlRef = useRef(null); - const relativeScale = scale / tile.srcScale; + const [hasContent, setHasContent] = useState(false); + const canvasRef = useRef(null); + /* kick off render exactly once per tile */ useEffect(() => { - if (tile.status === 'ready' && urlRef.current) return; // already done if (!scope) return; + + let currentBitmap: ImageBitmap | null = null; const task = scope.renderTile({ pageIndex, tile, dpr }); - task.wait((blob) => { - const objectUrl = URL.createObjectURL(blob); - urlRef.current = objectUrl; - setUrl(objectUrl); + task.wait((output) => { + currentBitmap = output; + const canvas = canvasRef.current; + if (canvas) { + paintBitmap(canvas, output); + currentBitmap = null; // transferred to canvas + setHasContent(true); + } }, ignore); return () => { - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } else { - task.abort({ - code: PdfErrorCode.Cancelled, - message: 'canceled render task', - }); + task.abort({ + code: PdfErrorCode.Cancelled, + message: 'canceled render task', + }); + if (currentBitmap) { + currentBitmap.close(); + currentBitmap = null; } + setHasContent(false); }; - }, [scope, pageIndex, tile.id]); // id includes scale, so unique + }, [scope, pageIndex, tile.id]); - const handleImageLoad = () => { - if (urlRef.current) { - URL.revokeObjectURL(urlRef.current); - urlRef.current = null; - } + const positionStyle = { + position: 'absolute' as const, + left: tile.screenRect.origin.x * relativeScale, + top: tile.screenRect.origin.y * relativeScale, + width: tile.screenRect.size.width * relativeScale, + height: tile.screenRect.size.height * relativeScale, }; - if (!url) return null; // could render a placeholder return ( - + ); } diff --git a/packages/plugin-tiling/src/svelte/components/TileImg.svelte b/packages/plugin-tiling/src/svelte/components/TileImg.svelte index bf1ee483b..7fe9fe03f 100644 --- a/packages/plugin-tiling/src/svelte/components/TileImg.svelte +++ b/packages/plugin-tiling/src/svelte/components/TileImg.svelte @@ -2,7 +2,7 @@ import type { Tile } from '@embedpdf/plugin-tiling'; import { useTilingCapability } from '../hooks'; import { ignore, PdfErrorCode } from '@embedpdf/models'; - import { untrack } from 'svelte'; + import { untrack, onDestroy } from 'svelte'; interface TileImgProps { documentId: string; @@ -18,9 +18,9 @@ // Derived scoped capability for the specific document const scope = $derived(tilingCapability.provides?.forDocument(documentId) ?? null); - let url = $state(''); - // urlRef is NOT reactive - similar to React's useRef - let urlRef: string | null = null; + let canvasEl: HTMLCanvasElement | undefined = $state(undefined); + let hasContent = $state(false); + let currentBitmap: ImageBitmap | null = null; // Capture these values once per tile change const tileId = $derived(tile.id); @@ -40,14 +40,26 @@ }, }); + function paintBitmap(bitmap: ImageBitmap) { + if (!canvasEl) return; + try { + canvasEl.width = bitmap.width; + canvasEl.height = bitmap.height; + canvasEl.getContext('bitmaprenderer')!.transferFromImageBitmap(bitmap); + hasContent = true; + } catch { + // Bitmap was detached + } + } + /* kick off render exactly once per tile */ $effect(() => { - // Track only tileId and pageIndex as dependencies (like React's [pageIndex, tile.id]) + // Track only tileId and pageIndex as dependencies const _tileId = tileId; const _pageIndex = pageIndex; - // Check if we already have a URL for this tile (already rendered) - if (urlRef) return; + // Check if we already have content for this tile + if (currentBitmap) return; if (!scope) return; @@ -58,43 +70,43 @@ tile: plainTile, dpr, }); - task.wait((blob) => { - const objectUrl = URL.createObjectURL(blob); - urlRef = objectUrl; - url = objectUrl; + task.wait((bitmap) => { + if (currentBitmap) { + currentBitmap.close(); + } + currentBitmap = bitmap; + paintBitmap(bitmap); + currentBitmap = null; // transferred to canvas, don't close }, ignore); return () => { - if (urlRef) { - URL.revokeObjectURL(urlRef); - urlRef = null; - } else { - task.abort({ - code: PdfErrorCode.Cancelled, - message: 'canceled render task', - }); + task.abort({ + code: PdfErrorCode.Cancelled, + message: 'canceled render task', + }); + // Clean up bitmap when tile changes + if (currentBitmap) { + currentBitmap.close(); + currentBitmap = null; + hasContent = false; } }; }); - const handleImageLoad = () => { - if (urlRef) { - URL.revokeObjectURL(urlRef); - urlRef = null; + onDestroy(() => { + if (currentBitmap) { + currentBitmap.close(); + currentBitmap = null; } - }; + }); -{#if url} - -{/if} + diff --git a/packages/plugin-tiling/src/vue/components/tile-img.vue b/packages/plugin-tiling/src/vue/components/tile-img.vue index 459ef008a..65b55d10e 100644 --- a/packages/plugin-tiling/src/vue/components/tile-img.vue +++ b/packages/plugin-tiling/src/vue/components/tile-img.vue @@ -18,13 +18,27 @@ const props = withDefaults(defineProps(), { }); const { provides: tilingCapability } = useTilingCapability(); -const url = ref(); +const canvasRef = ref(null); +const hasContent = ref(false); const relScale = computed(() => props.scale / props.tile.srcScale); -// Track last rendered tile ID to prevent duplicates +let currentBitmap: ImageBitmap | null = null; let lastRenderedId: string | undefined; let currentTask: any = null; +function paintBitmap(bitmap: ImageBitmap) { + if (!canvasRef.value) return; + try { + const canvas = canvasRef.value; + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext('bitmaprenderer')!.transferFromImageBitmap(bitmap); + hasContent.value = true; + } catch { + // Bitmap was detached + } +} + watch( [() => props.tile.id, () => props.documentId, tilingCapability], ([tileId, docId, capability], [prevTileId, prevDocId]) => { @@ -32,17 +46,18 @@ watch( const scope = capability.forDocument(docId); - // CRITICAL: Clear image immediately when documentId changes + // CRITICAL: Clear state immediately when documentId changes if (prevDocId !== undefined && prevDocId !== docId) { - if (url.value) { - URL.revokeObjectURL(url.value); - url.value = undefined; + hasContent.value = false; + if (currentBitmap) { + currentBitmap.close(); + currentBitmap = null; } if (currentTask) { currentTask.abort({ code: PdfErrorCode.Cancelled, message: 'switching documents' }); currentTask = null; } - lastRenderedId = undefined; // Reset so new document tiles render + lastRenderedId = undefined; } // Already rendered this exact tile (for same document) @@ -53,11 +68,10 @@ watch( currentTask.abort({ code: PdfErrorCode.Cancelled, message: 'switching tiles' }); currentTask = null; } - - // Clean up old URL - if (url.value) { - URL.revokeObjectURL(url.value); - url.value = undefined; + if (currentBitmap) { + currentBitmap.close(); + currentBitmap = null; + hasContent.value = false; } lastRenderedId = tileId; @@ -68,8 +82,13 @@ watch( dpr: props.dpr, }); - currentTask.wait((blob: Blob) => { - url.value = URL.createObjectURL(blob); + currentTask.wait((bitmap: ImageBitmap) => { + if (currentBitmap) { + currentBitmap.close(); + } + currentBitmap = bitmap; + paintBitmap(bitmap); + currentBitmap = null; // transferred to canvas, don't close currentTask = null; }, ignore); }, @@ -80,16 +99,17 @@ onBeforeUnmount(() => { if (currentTask) { currentTask.abort({ code: PdfErrorCode.Cancelled, message: 'unmounting' }); } - if (url.value) { - URL.revokeObjectURL(url.value); + if (currentBitmap) { + currentBitmap.close(); + currentBitmap = null; } });