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:'init', bytes:ArrayBuffer|SharedArrayBuffer, shared}→ opens the document, replies{kind:'ready', shared}.{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;
// 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;
Future<void>? adventorFontsReady;
PdfCancellationToken? activeToken;
int? activeRequestId;
final transcriptCache = PdfWorkerTranscriptCache();
final pageSurfaces = <int, web.OffscreenCanvas>{};
final pageSurfaceBitmaps = <int, _PageSurfaceBitmapCache>{};
// 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 == '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();
document = PdfDocument.open(bytes);
imageCache = PdfImageDecodeCache(); // new document, new streams
flateSampleCache = _BrowserFlateSampleCache();
flatePredecoder = _BrowserFlatePredecoder(flateSampleCache);
for (final cached in pageSurfaceBitmaps.values) {
cached.dispose();
}
pageSurfaceBitmaps.clear();
} 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);
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;
() 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) {
if (commands.any((command) =>
command is PdfDrawTextCommand &&
pdfUsesAdventorSubstitute(command.run.fontName))) {
await (adventorFontsReady ??=
_loadWorkerAdventorFonts(scope));
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 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
? '${tally!.format()} '
'cache=${imageCache.hits}h/'
'${imageCache.misses}m/${imageCache.length}e/'
'${imageCache.bytes}B '
'flateSamples=${flateSampleCache.bytes}B'
: '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;
() 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,
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;
() 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;
// Text extraction has no cancellation seam and runs synchronously; it
// still lands off the UI thread here, which is the point (#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(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 != '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.
() 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;
}