runPdfRenderWorker function
void
runPdfRenderWorker()
Runs the render worker inside a dedicated Web Worker.
dart_pdf_editor ships a prebuilt worker as a Flutter package asset, so most consuming apps do not call this directly. Apps that want to self-host a custom worker can still compile a tiny worker script that calls this:
// web/pdf_render_worker.dart
import 'package:dart_pdf_editor/render_worker_web.dart';
void main() => runPdfRenderWorker();
compiled with dart compile js web/pdf_render_worker.dart -o web/pdf_render_worker.dart.js and served alongside the app; set
pdfRenderWorkerScriptUrl = 'pdf_render_worker.dart.js' before opening a
viewer to use that custom script. See doc/render_worker_web.md for the
full wiring.
Protocol (mirrors the native isolate backend):
{kind:'compressionHello'}→{kind:'compressionReady', version:1}.{kind:'compress', bytes:ArrayBuffer, options:String}→{kind:'compressionStarted'}followed by{kind:'compressionResult', bytes:ArrayBuffer, report:String}or{kind:'compressionError', error:String}. These standalone requests need noinit; clients use a dedicated worker and terminate it to cancel.{kind:'init', bytes:ArrayBuffer|SharedArrayBuffer, shared, populatedRanges?}→ opens the document, replies{kind:'ready', shared, revisionUpdate:1, ...}.{kind:'update', id, baseLength, newLength, bytes:Uint8Array, changed?}→ moves the open document to the revision made of its firstbaseLengthbytes plusbytes(seePdfRenderWorker.updateRevision), in place when that is an append, else by re-opening. Always replies{kind:'result', id, incremental}, witherrorset when the worker could not reach the revision.ready'srevisionUpdateadvertises this.{kind:'record', id, page, annotations}→ replies{kind:'result', id, buffer:ArrayBuffer|null}(null = the page can't be offloaded; the main thread renders it locally).{kind:'cancel', id}→ cancels only the matching active request, so a late message cannot abort its successor; a match abandons the interpreter walk early and replies withbuffer:null.{kind:'bin', id, page, annotations, m0..m5, deviceWidth, deviceHeight, pixelRatio, slugGlyphs}→ replies with an encodedStripPlanin the same result shape (null = bin locally).{kind:'detail', id, page, annotations, m0..m5, deviceWidth, deviceHeight, pixelRatio, regionLeft..regionTop}→ replies with transferable command and plan buffers produced by one cancellable worker job.
Implementation
void runPdfRenderWorker() {
installPdfJpegAccelerator();
final scope = globalContext as web.DedicatedWorkerGlobalScope;
PdfDocument? document;
// The worker's copy of the document image, as in the isolate backend:
// [workerBytes] is capacity and its first [workerLength] bytes are the
// revision the worker reflects. It starts as the init buffer. That is the
// pool's SharedArrayBuffer when the page is cross-origin isolated - shared
// by every lane and the main thread, so never written - and otherwise an
// exact-size transferred copy; the first update that appends bytes moves to
// a private buffer with headroom.
Uint8List? workerBytes;
var workerLength = 0;
var workerBytesShared = false;
List<int>? populatedRanges;
final walks = _WorkerWalks();
// One decoded-image cache per open document. A worker records the same page
// several times in a scroll (vector-first, full, prerender warm, thumbnail)
// and each record re-decoded every image; #451's device trace showed one page
// paying ~900ms of pure-Dart CMYK decode three times. Reuse is exact-match on
// (stream, target size), so it cannot change what a record renders.
var imageCache = PdfImageDecodeCache();
var flateSampleCache = _BrowserFlateSampleCache();
var flatePredecoder = _BrowserFlatePredecoder(flateSampleCache);
var reuseTranscripts = true;
var collectTimings = false;
final substituteFontsReady = <String, Future<void>>{};
PdfCancellationToken? activeToken;
int? activeRequestId;
final transcriptCache = PdfWorkerTranscriptCache();
final pageSurfaces = <int, web.OffscreenCanvas>{};
final pageSurfaceBitmaps = <int, _PageSurfaceBitmapCache>{};
// A newly opened document has new streams and may have other pages: drop
// every cache keyed by the previous document's objects or pages.
void resetDocumentCaches() {
imageCache = PdfImageDecodeCache();
flateSampleCache = _BrowserFlateSampleCache();
flatePredecoder = _BrowserFlatePredecoder(flateSampleCache);
for (final cached in pageSurfaceBitmaps.values) {
cached.dispose();
}
pageSurfaceBitmaps.clear();
transcriptCache.evictPages(null);
}
// Moves the open document to the revision an 'update' message describes and
// returns whether that happened in place. Mirrors the isolate backend: the
// tail is appended into the worker's own buffer, an append is folded into
// the open document, and anything else (an undo, or an undo coalesced with
// the next edit) re-opens the prefix. Throws when the message is malformed
// (before anything is written) or the revision does not open.
bool applyRevision(JSObject data) {
final baseLength =
(data.getProperty('baseLength'.toJS) as JSNumber).toDartInt;
final newLength =
(data.getProperty('newLength'.toJS) as JSNumber).toDartInt;
final appended = (data.getProperty('bytes'.toJS) as JSUint8Array).toDart;
final changed = (data.getProperty('changed'.toJS) as JSArray<JSNumber>?)
?.toDart
.map((page) => page.toDartInt)
.toSet();
final held = workerBytes;
if (held == null) throw StateError('no document to update');
if (newLength != baseLength + appended.length) {
throw ArgumentError('inconsistent revision length');
}
if (baseLength > workerLength) {
throw ArgumentError('revision base past the held prefix');
}
final nextRanges =
renderWorkerRevisionRanges(populatedRanges, baseLength, newLength);
// Everything above is validation; nothing is written until here.
//
// The tail lands at or past [baseLength]. For an append that is past
// everything the open document views. Otherwise [baseLength] is not the
// document's length, so it re-opens below and every cache that saw the old
// document is dropped before anything reads the overwritten bytes (no walk
// is running: the caller waited for them).
//
// A shared seed is never written: the other lanes and the main thread
// read it. The first append copies the prefix out instead, which is the
// one-time cost of keeping the worker across edits. Growth is ~6% plus
// 1 MB, not doubling, as in the isolate backend: every worker owns its
// buffer, and the slack absorbs thousands of annotation-sized tails.
var buffer = held;
var shared = workerBytesShared;
if (appended.isNotEmpty) {
if (shared || newLength > buffer.length) {
buffer = Uint8List(newLength + (newLength >> 4) + (1 << 20))
..setRange(0, baseLength, held);
shared = false;
}
buffer.setRange(baseLength, newLength, appended);
}
final live = Uint8List.sublistView(buffer, 0, newLength);
var incremental = false;
final doc = document;
if (doc != null && baseLength == doc.cos.bytes.length) {
try {
doc.applyIncrementalUpdate(live);
incremental = true;
} catch (_) {
// Not an append the open document can take (a recovered xref, a
// malformed section): re-open from the prefix instead.
}
}
if (!incremental) {
try {
document = PdfDocument.open(live, populatedRanges: nextRanges);
} catch (_) {
document = null;
}
}
workerBytes = buffer;
workerLength = newLength;
workerBytesShared = shared;
populatedRanges = nextRanges;
if (incremental) {
// Only the changed pages' renders are stale. The decoded-image and
// flate-sample caches are keyed by stream object, and an update gives
// every object it redefines a fresh one, so they stay.
transcriptCache.evictPages(changed);
for (final cached in pageSurfaceBitmaps.values) {
cached.evictPages(changed);
}
flatePredecoder.evictPages(changed);
} else {
resetDocumentCaches();
}
if (document == null) throw StateError('the revision did not open');
return incremental;
}
// The handler MUST stay synchronous (return void): `.toJS` cannot convert a
// Future-returning function, so an `async` handler fails `dart compile js`
// ("invalid types in its function signature: Future<Null> Function(...)").
// The cancellable record below therefore runs in a fire-and-forget inner
// async closure instead of making the handler itself async.
scope.onmessage = ((web.MessageEvent event) {
final data = event.data as JSObject?;
if (data == null) return;
final kind = (data.getProperty('kind'.toJS) as JSString?)?.toDart;
if (kind == 'compressionHello') {
scope.postMessage(JSObject()
..setProperty('kind'.toJS, 'compressionReady'.toJS)
..setProperty('version'.toJS, compressionWorkerProtocolVersion.toJS));
return;
}
if (kind == 'compress') {
try {
final buffer = data.getProperty('bytes'.toJS) as JSArrayBuffer?;
final options = data.getProperty('options'.toJS) as JSString?;
if (buffer == null || options == null) {
throw const FormatException('Missing compression bytes or options.');
}
final bytes = buffer.toDart.asUint8List();
final compressionOptions = compressionOptionsFromJson(options.toDart);
scope.postMessage(
JSObject()..setProperty('kind'.toJS, 'compressionStarted'.toJS),
);
final result = PdfCompressor.optimize(
PdfDocument.open(bytes),
options: compressionOptions,
);
final report = compressionReportToJson(result);
final out = _tightTransferBuffer(result.bytes);
scope.postMessage(
JSObject()
..setProperty('kind'.toJS, 'compressionResult'.toJS)
..setProperty('bytes'.toJS, out)
..setProperty('report'.toJS, report.toJS),
<JSAny>[out].toJS,
);
} catch (error) {
scope.postMessage(JSObject()
..setProperty('kind'.toJS, 'compressionError'.toJS)
..setProperty('error'.toJS, error.toString().toJS));
}
return;
}
if (kind == 'init') {
// Extract AND open inside the try: a malformed transfer (the cast or the
// ArrayBuffer view can throw on some hosts) must NOT skip the 'ready'
// reply below, or the main thread waits on it forever. A null document
// simply declines every page to a local render.
var shared = false;
Stopwatch? openClock;
try {
shared =
(data.getProperty('shared'.toJS) as JSBoolean?)?.toDart ?? false;
reuseTranscripts =
(data.getProperty('reuseTranscripts'.toJS) as JSBoolean?)?.toDart ??
true;
collectTimings =
(data.getProperty('timings'.toJS) as JSBoolean?)?.toDart ?? false;
// Light up the COS-layer facade alongside the trace timings; each
// result attaches (and resets) its per-job snapshot.
PdfPerf.enabled = collectTimings;
if (collectTimings) openClock = Stopwatch()..start();
final buffer = data.getProperty('bytes'.toJS) as JSObject;
final bytes = shared
? _jsUint8View(buffer).toDart
: (buffer as JSArrayBuffer).toDart.asUint8List();
final ranges =
(data.getProperty('populatedRanges'.toJS) as JSArray<JSNumber>?)
?.toDart
.map((value) => value.toDartInt)
.toList();
workerBytes = bytes;
workerLength = bytes.length;
workerBytesShared = shared;
populatedRanges = ranges;
document = PdfDocument.open(bytes, populatedRanges: ranges);
resetDocumentCaches(); // new document, new streams
} catch (_) {
document = null; // bad transfer / broken document → local renders
}
// ALWAYS reply ready, even on failure, so the client never hangs.
openClock?.stop();
final ready = JSObject()
..setProperty('kind'.toJS, 'ready'.toJS)
..setProperty('shared'.toJS, shared.toJS);
// A bootstrap client can paint page zero before Flutter starts only if
// it can size the DOM canvas without opening the PDF a second time on
// the main thread. Keep this optional metadata on the existing ready
// envelope; established clients ignore unknown fields.
final readyDocument = document;
if (readyDocument != null) {
try {
final pageCount = readyDocument.pageCount;
ready.setProperty('pageCount'.toJS, pageCount.toJS);
if (pageCount > 0) {
final page = readyDocument.page(0);
ready
..setProperty('pageWidth'.toJS, page.cropBox.width.toJS)
..setProperty('pageHeight'.toJS, page.cropBox.height.toJS)
..setProperty('pageRotation'.toJS, page.rotation.toJS);
}
} catch (_) {
// Metadata is an optimization only. A malformed page tree still
// reaches the normal ready/fallback path rather than wedging init.
}
}
// Report the browser-codec capability so a worker that will decline every
// image (no OffscreenCanvas, etc.) is visible up front rather than
// discovered as an unexplained main-thread decode cost. See #458.
final missing = _browserImageDecodeMissing();
ready.setProperty('browserImageDecode'.toJS, missing.isEmpty.toJS);
// The worker ships as its own `dart compile js` bundle, separate from
// the app's main.dart.js, so the app being current is no evidence that
// the worker is. Report the decode-reuse capability (#451): a worker
// built before it simply omits the field, which is the only way a trace
// can distinguish "reuse found nothing" from "this worker cannot reuse".
ready.setProperty('imageDecodeCache'.toJS, true.toJS);
// Likewise the 'update' message (protocol version 1): a main bundle
// only sends one to a worker that says it understands it, so a stale
// cached worker keeps the restart-per-edit path rather than dropping
// the update and serving the old revision.
ready.setProperty('revisionUpdate'.toJS, 1.toJS);
if (missing.isNotEmpty) {
ready.setProperty(
'browserImageDecodeMissing'.toJS,
missing.join('+').toJS,
);
}
if (openClock != null) {
ready.setProperty('openUs'.toJS, openClock.elapsedMicroseconds.toJS);
}
scope.postMessage(ready);
return;
}
if (kind == 'cancel') {
final id = (data.getProperty('id'.toJS) as JSNumber?)?.toDartInt;
if (id != null && id == activeRequestId) {
activeToken?.cancelled = true;
} else if (id != null) {
final ignored = JSObject()
..setProperty('kind'.toJS, 'cancelIgnored'.toJS)
..setProperty('targetId'.toJS, id.toJS);
final active = activeRequestId;
if (active != null) {
ignored.setProperty('activeId'.toJS, active.toJS);
}
scope.postMessage(ignored);
}
return;
}
if (kind == 'releaseSurface') {
final surfaceId =
(data.getProperty('surfaceId'.toJS) as JSNumber?)?.toDartInt;
if (surfaceId != null) {
pageSurfaces.remove(surfaceId);
pageSurfaceBitmaps.remove(surfaceId)?.dispose();
}
return;
}
if (kind == 'surface') {
final id = (data.getProperty('id'.toJS) as JSNumber).toDartInt;
final pageIndex = (data.getProperty('page'.toJS) as JSNumber).toDartInt;
final annotations =
(data.getProperty('annotations'.toJS) as JSBoolean).toDart;
final surfaceId =
(data.getProperty('surfaceId'.toJS) as JSNumber).toDartInt;
final width =
(data.getProperty('deviceWidth'.toJS) as JSNumber).toDartInt;
final height =
(data.getProperty('deviceHeight'.toJS) as JSNumber).toDartInt;
final pageColor =
(data.getProperty('pageColor'.toJS) as JSNumber).toDartInt;
final regionLeft =
(data.getProperty('regionLeft'.toJS) as JSNumber?)?.toDartDouble;
final regionTop =
(data.getProperty('regionTop'.toJS) as JSNumber?)?.toDartDouble;
final regionRight =
(data.getProperty('regionRight'.toJS) as JSNumber?)?.toDartDouble;
final regionBottom =
(data.getProperty('regionBottom'.toJS) as JSNumber?)?.toDartDouble;
final surfacePixelRatio =
(data.getProperty('pixelRatio'.toJS) as JSNumber?)?.toDartDouble;
final surfaceRegion = regionLeft != null &&
regionTop != null &&
regionRight != null &&
regionBottom != null &&
surfacePixelRatio != null
? (
left: regionLeft,
top: regionTop,
right: regionRight,
bottom: regionBottom,
pixelRatio: surfacePixelRatio,
)
: null;
final rotation =
(data.getProperty('rotation'.toJS) as JSNumber?)?.toDartInt;
final supplied = data.getProperty('surface'.toJS);
if (supplied != null) {
pageSurfaces[surfaceId] = supplied as web.OffscreenCanvas;
}
final token = PdfCancellationToken();
activeToken = token;
activeRequestId = id;
walks.run(token, () async {
final timings = collectTimings ? PdfWorkerPhaseTimings() : null;
final workerClock = collectTimings ? (Stopwatch()..start()) : null;
var painted = false;
String? error;
try {
final doc = document;
final surface = pageSurfaces[surfaceId];
if (doc != null &&
surface != null &&
pageIndex >= 0 &&
pageIndex < doc.pageCount) {
final cacheKey = _PageSurfaceBitmapKey(
pageIndex,
annotations,
width,
height,
pageColor,
rotation,
);
final cached = surfaceRegion == null
? pageSurfaceBitmaps[surfaceId]?.lookup(cacheKey)
: null;
if (cached != null) {
painted = _presentPageSurfaceBitmap(
surface,
cached,
width,
height,
);
}
if (!painted) {
await flatePredecoder.prepare(
doc,
pageIndex,
token,
timings: timings,
);
final transcript = await transcriptCache.transcriptFor(
doc,
pageIndex,
annotations,
token,
yieldInterval: _webRecordYieldOperations,
timings: timings,
);
if (transcript != null && !token.cancelled) {
var commands = transcript.sourceCommands;
final profile = pdfBrowserPageSurfaceProfile(
commands,
allowUndecodedImages: true,
);
if (profile != null) {
final substituteFaces = _workerSubstituteFaces(commands);
if (substituteFaces.isNotEmpty) {
await Future.wait([
for (final entry in substituteFaces.entries)
substituteFontsReady.putIfAbsent(
entry.key,
() => _loadWorkerSubstituteFont(scope, entry.value),
),
]);
if (token.cancelled) throw PdfCancelledException();
}
Map<Object, web.CanvasImageSource> browserImages = const {};
var browserFrames = const <web.VideoFrame>[];
if (profile.needsImageDecode) {
final decodeClock =
timings == null ? null : (Stopwatch()..start());
final tally =
timings == null ? null : _BrowserDecodeTally();
final page = doc.page(pageIndex);
final swap = (rotation ?? page.rotation) == 90 ||
(rotation ?? page.rotation) == 270;
final pageWidth =
swap ? page.cropBox.height : page.cropBox.width;
final pageHeight =
swap ? page.cropBox.width : page.cropBox.height;
final ratio = surfaceRegion?.pixelRatio ??
(pageWidth > 0 && pageHeight > 0
? (width / pageWidth + height / pageHeight) / 2
: null);
final imageCacheBefore = timings == null
? null
: _snapshotImageCache(imageCache);
final grayFrames = await _browserGrayFlateFrames(
doc.cos,
commands,
token,
flateSampleCache,
tally,
// Full-page VideoFrame presentation past ~2x caused a
// deferred compositor tail because the destination
// canvas itself was enormous. A region surface is
// viewport-sized, so it keeps the zero-copy browser
// frame at deep zoom.
maxPixelRatio: surfaceRegion == null ? ratio : 2,
);
if (grayFrames == null) {
final budgetScale = ratio == null
? 1.0
: pdfCommandImageBudgetScale(
commands,
doc.cos,
ratio,
pageRasterPixels: width * height,
);
commands = await _withBrowserDecodedImages(
doc.cos,
imageCache,
commands,
token,
tally,
maxImagePixelRatio: ratio,
imageBudgetScale: budgetScale,
flateSampleCache: flateSampleCache,
);
} else {
browserImages = grayFrames.images;
browserFrames = grayFrames.frames;
}
if (decodeClock != null) {
decodeClock.stop();
timings!.decodeUs += decodeClock.elapsedMicroseconds;
timings.imageDecodeSummary = grayFrames == null
? _formatImageDecodeSummary(
tally!,
imageCache,
imageCacheBefore!,
flateSampleBytes: flateSampleCache.bytes,
)
: 'videoGray=${grayFrames.frames.length}';
}
}
final cacheable = surfaceRegion == null &&
width * height <= _pageSurfaceBitmapMaxPixels;
final paintCanvas =
cacheable ? web.OffscreenCanvas(width, height) : surface;
try {
painted = paintPdfBrowserPageSurface(
canvas: paintCanvas,
page: doc.page(pageIndex),
commands: commands,
width: width,
height: height,
pageColor: pageColor,
rotation: rotation,
region: surfaceRegion,
commandsAreValidated:
browserImages.isNotEmpty || !profile.needsImageDecode,
browserImages: browserImages,
);
if (painted && cacheable) {
final bitmap = paintCanvas.transferToImageBitmap();
pageSurfaceBitmaps
.putIfAbsent(
surfaceId,
_PageSurfaceBitmapCache.new,
)
.store(cacheKey, bitmap);
painted = _presentPageSurfaceBitmap(
surface,
bitmap,
width,
height,
);
}
} finally {
for (final frame in browserFrames) {
frame.close();
}
}
}
}
}
}
} on PdfCancelledException {
painted = false;
} catch (e, st) {
painted = false;
error = '$e\n$st';
}
if (identical(activeToken, token)) {
activeToken = null;
activeRequestId = null;
}
workerClock?.stop();
_postResult(
scope,
id,
token.cancelled ? null : Uint8List.fromList([painted ? 1 : 0]),
error,
timings,
workerClock?.elapsedMicroseconds,
);
});
return;
}
if (kind == 'bin' || kind == 'detail') {
final id = (data.getProperty('id'.toJS) as JSNumber).toDartInt;
final page = (data.getProperty('page'.toJS) as JSNumber).toDartInt;
final annotations =
(data.getProperty('annotations'.toJS) as JSBoolean).toDart;
final matrix = <double>[
for (var i = 0; i < 6; i++)
(data.getProperty('m$i'.toJS) as JSNumber).toDartDouble,
];
final deviceWidth =
(data.getProperty('deviceWidth'.toJS) as JSNumber).toDartInt;
final deviceHeight =
(data.getProperty('deviceHeight'.toJS) as JSNumber).toDartInt;
final pixelRatio =
(data.getProperty('pixelRatio'.toJS) as JSNumber).toDartDouble;
final slugGlyphs =
(data.getProperty('slugGlyphs'.toJS) as JSBoolean?)?.toDart ?? false;
final token = PdfCancellationToken();
activeToken = token;
activeRequestId = id;
walks.run(token, () async {
final timings = collectTimings ? PdfWorkerPhaseTimings() : null;
final workerClock = collectTimings ? (Stopwatch()..start()) : null;
Uint8List? out;
Uint8List? detailPlan;
String? error;
final doc = document;
try {
if (doc != null) {
await flatePredecoder.prepare(
doc,
page,
token,
timings: timings,
);
if (kind == 'detail') {
final region = PdfRect(
(data.getProperty('regionLeft'.toJS) as JSNumber).toDartDouble,
(data.getProperty('regionBottom'.toJS) as JSNumber)
.toDartDouble,
(data.getProperty('regionRight'.toJS) as JSNumber).toDartDouble,
(data.getProperty('regionTop'.toJS) as JSNumber).toDartDouble,
);
final detail = await _recordStripDetailAsync(
doc,
imageCache,
flateSampleCache,
transcriptCache,
page,
annotations,
matrix,
deviceWidth,
deviceHeight,
pixelRatio,
region,
token,
timings: timings,
);
out = detail?.$1;
detailPlan = detail?.$2;
} else {
out = await _binStripsAsync(
doc,
transcriptCache,
page,
annotations,
matrix,
deviceWidth,
deviceHeight,
pixelRatio,
slugGlyphs,
token,
timings: timings,
);
}
}
} on PdfCancelledException {
out = null;
} catch (e, st) {
out = null;
error = '$e\n$st';
}
if (identical(activeToken, token)) {
activeToken = null;
activeRequestId = null;
}
workerClock?.stop();
if (detailPlan == null) {
_postResult(
scope,
id,
out,
error,
timings,
workerClock?.elapsedMicroseconds,
);
} else {
_postDetailResult(
scope,
id,
out!,
detailPlan,
timings,
workerClock?.elapsedMicroseconds,
);
}
});
return;
}
if (kind == 'regionIndex') {
final id = (data.getProperty('id'.toJS) as JSNumber).toDartInt;
final page = (data.getProperty('page'.toJS) as JSNumber).toDartInt;
final annotations =
(data.getProperty('annotations'.toJS) as JSBoolean).toDart;
final maxCommands =
(data.getProperty('maxCommands'.toJS) as JSNumber).toDartInt;
final buildGrid =
(data.getProperty('buildGrid'.toJS) as JSBoolean?)?.toDart ?? false;
final token = PdfCancellationToken();
activeToken = token;
activeRequestId = id;
walks.run(token, () async {
final timings = collectTimings ? PdfWorkerPhaseTimings() : null;
final workerClock = collectTimings ? (Stopwatch()..start()) : null;
Uint8List? out;
String? error;
final doc = document;
try {
if (doc != null) {
await flatePredecoder.prepare(
doc,
page,
token,
timings: timings,
);
out = await _buildRegionIndexAsync(
doc,
transcriptCache,
page,
annotations,
maxCommands,
buildGrid,
token,
timings: timings,
);
}
} on PdfCancelledException {
out = null;
} catch (e, st) {
out = null;
error = '$e\n$st';
}
if (identical(activeToken, token)) {
activeToken = null;
activeRequestId = null;
}
workerClock?.stop();
_postResult(
scope,
id,
out,
error,
timings,
workerClock?.elapsedMicroseconds,
);
});
return;
}
if (kind == 'extractText') {
final id = (data.getProperty('id'.toJS) as JSNumber).toDartInt;
final page = (data.getProperty('page'.toJS) as JSNumber).toDartInt;
// Reuse exact metadata captured during a complete page recording. A miss
// still extracts synchronously off the UI thread (#396).
activeToken = null;
activeRequestId = id;
() async {
final timings = collectTimings ? PdfWorkerPhaseTimings() : null;
final workerClock = collectTimings ? (Stopwatch()..start()) : null;
Uint8List? out;
String? error;
final doc = document;
try {
if (doc != null && page >= 0 && page < doc.pageCount) {
out = serializePageText(transcriptCache.textCache.extract(page) ??
PdfTextExtractor.extract(doc, page));
}
} catch (e, st) {
out = null;
error = '$e\n$st';
}
if (activeRequestId == id) {
activeToken = null;
activeRequestId = null;
}
workerClock?.stop();
_postResult(
scope,
id,
out,
error,
timings,
workerClock?.elapsedMicroseconds,
);
}();
return;
}
if (kind == 'update') {
final id = (data.getProperty('id'.toJS) as JSNumber).toDartInt;
() async {
var incremental = false;
String? error;
try {
// The main side sends an update only when its slot is free, but
// its record watchdog can free the slot while a walk is still
// running here, and the update rewrites the document that walk
// reads. Cancel the walks and apply once they have unwound.
while (!walks.isEmpty) {
await walks.cancelAll();
}
incremental = applyRevision(data);
} catch (e) {
error = '$e';
} finally {
// Always answer: the main side's slot waits on this reply.
final ack = JSObject()
..setProperty('kind'.toJS, 'result'.toJS)
..setProperty('id'.toJS, id.toJS)
..setProperty('incremental'.toJS, incremental.toJS);
if (error != null) ack.setProperty('error'.toJS, error.toJS);
scope.postMessage(ack);
}
}();
return;
}
if (kind != 'record') return;
final id = (data.getProperty('id'.toJS) as JSNumber).toDartInt;
final page = (data.getProperty('page'.toJS) as JSNumber).toDartInt;
final annotations =
(data.getProperty('annotations'.toJS) as JSBoolean).toDart;
final imagePixelRatio =
(data.getProperty('imageRatio'.toJS) as JSNumber?)?.toDartDouble;
// Default true so an older client that doesn't send the flag still decodes.
final decodeImages =
(data.getProperty('decodeImages'.toJS) as JSBoolean?)?.toDart ?? true;
final commandLimit =
(data.getProperty('commandLimit'.toJS) as JSNumber?)?.toDartInt;
final regionLeft =
(data.getProperty('regionLeft'.toJS) as JSNumber?)?.toDartDouble;
final regionBottom =
(data.getProperty('regionBottom'.toJS) as JSNumber?)?.toDartDouble;
final regionRight =
(data.getProperty('regionRight'.toJS) as JSNumber?)?.toDartDouble;
final regionTop =
(data.getProperty('regionTop'.toJS) as JSNumber?)?.toDartDouble;
final imageDecodeRegion = regionLeft != null &&
regionBottom != null &&
regionRight != null &&
regionTop != null
? PdfRect(regionLeft, regionBottom, regionRight, regionTop)
: null;
final wantsPartials =
(data.getProperty('wantsPartials'.toJS) as JSBoolean?)?.toDart ?? false;
final token = PdfCancellationToken();
activeToken = token;
activeRequestId = id;
// Progressive partials (#564): stream each interim linework prefix only while
// this record still owns the slot and has not been cancelled, so a preempted
// record stops immediately (the main thread drops any partial whose id no
// longer matches its in-flight request anyway).
void emitPartial(Uint8List bytes) {
if (activeRequestId == id && !token.cancelled) {
_postPartial(scope, id, bytes);
}
}
// Fire-and-forget: launch the cancellable walk without awaiting it here, so
// the message handler returns void (see the note above) while a subsequent
// 'cancel' message can still flip token.cancelled mid-walk.
walks.run(token, () async {
final timings = collectTimings ? PdfWorkerPhaseTimings() : null;
final workerClock = collectTimings ? (Stopwatch()..start()) : null;
Uint8List? out;
String? error;
final doc = document;
try {
if (doc != null) {
await flatePredecoder.prepare(
doc,
page,
token,
timings: timings,
);
out = await _recordPageAsync(
doc,
imageCache,
flateSampleCache,
transcriptCache,
reuseTranscripts,
page,
annotations,
imagePixelRatio,
decodeImages,
commandLimit,
imageDecodeRegion,
token,
timings: timings,
onPartial: wantsPartials ? emitPartial : null,
);
}
} on PdfCancelledException {
out = null;
} catch (e, st) {
out = null; // any failure → the main thread renders this page locally
error = '$e\n$st';
}
// Only clear the active token if it is still ours - a newer record may
// have replaced it while this one was running.
if (identical(activeToken, token)) {
activeToken = null;
activeRequestId = null;
}
workerClock?.stop();
_postResult(
scope,
id,
out,
error,
timings,
workerClock?.elapsedMicroseconds,
);
});
}).toJS;
}