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 with buffer:null.
  • {kind:'bin', id, page, annotations, m0..m5, deviceWidth, deviceHeight, pixelRatio, slugGlyphs} → replies with an encoded StripPlan in 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;
}