rasterizeThumbnail function
- required PdfEditingController controller,
- required int pageIndex,
- required Color pageColor,
- required bool annotations,
- required int pixelWidth,
- required PdfRenderWorker? worker,
- int priority = 2,
- bool skipIfWorkerDeclines = false,
- bool deferUiWork()?,
- String reason = 'tile',
- PdfRasterCache? disk,
- PdfPagePreviewCache? previews,
- bool? allowSoftPreview,
Interprets pageIndex and rasterizes it to a tile-resolution image, off
the UI thread via worker when one is active and locally otherwise.
Returns null when the page has no area or can't be rendered.
priority orders the worker's queue (lower served first): on-screen
tiles use 2, the background warm a lower 3, so a tile the user is looking
at preempts an in-flight warm render. When skipIfWorkerDeclines is true
(the warm pass) a page the worker won't offload - or whose render the
worker preempted for a higher-priority tile - returns null instead of
falling back to a heavy UI-thread interpret, so warming never blocks a
frame.
previews is the viewer's preview cache. When it still holds the page's
recorded scene from the last time the page was on screen, the tile is
replayed straight out of it - no content-stream walk and no image decode,
which is what the local fallback below otherwise pays in full to fill a
128px tile (55 ms per tile in the #699 field trace).
Implementation
Future<ui.Image?> rasterizeThumbnail({
required PdfEditingController controller,
required int pageIndex,
required Color pageColor,
required bool annotations,
Set<String> hiddenAnnotationSubtypes = const {},
required int pixelWidth,
required PdfRenderWorker? worker,
int priority = 2,
bool skipIfWorkerDeclines = false,
bool Function()? deferUiWork,
String reason = 'tile',
PdfRasterCache? disk,
PdfPagePreviewCache? previews,
bool? allowSoftPreview,
}) async {
final page = controller.pageAt(pageIndex);
final size = PdfPageRenderer.pageSize(page);
if (size.width <= 0 || size.height <= 0) return null;
final ratio = pixelWidth / size.width;
// an unedited page reopened in a later session can come straight off disk -
// no interpret at all. [disk] is supplied already gated to such pages (its
// key is content-derived, and render stamps reset per session), so loading
// it back is always for the right pixels.
if (disk != null) {
final stored = await disk.loadThumbnail(pageIndex, pixelWidth,
pageColor: pageColor.toARGB32(),
annotations: annotations,
hiddenAnnotationSubtypes: hiddenAnnotationSubtypes);
if (stored != null) {
PdfPerfLog.log(
'thumbnail page=$pageIndex $reason px=$pixelWidth disk-hit');
return stored;
}
}
// a DevTools-timeline span for the whole thumbnail render (free when no
// trace is recording; appears when you capture one), split into its phases
// so a trace shows where the time actually goes - and matching gated
// PdfPerfLog console lines for `--dart-define=PDF_PERF_LOG=true` / `?perf=1`.
final trace = TimelineTask()
..start('thumbnail', arguments: {
'page': pageIndex,
'pixelWidth': pixelWidth,
'priority': priority,
'reason': reason,
});
final sw = Stopwatch()..start();
try {
// Reuse pixels the viewer already produced before replaying its retained
// command scene. Intermediate previews can be sharper than the requested
// tile; on web, the base 200 px preview is also accepted for a nearby
// 256 px request. That small softness avoids the trace's 223 ms CanvasKit
// replay of 39k commands (and the resulting 248 ms raster frame).
final preview = previews?.thumbnailImageFor(
pageIndex,
page,
width: pixelWidth,
height: math.max(1, (size.height * ratio).ceil()),
minimumScale: (allowSoftPreview ?? kIsWeb) ? 0.75 : 1,
);
if (preview != null) {
final previewMs = sw.elapsedMicroseconds / 1000.0;
trace.instant('preview hit', arguments: {
'ms': previewMs,
'width': preview.width,
'height': preview.height,
});
PdfPerfLog.log('thumbnail page=$pageIndex $reason px=$pixelWidth '
'preview-hit ${preview.width}x${preview.height} '
'lookup=${_traceMs(previewMs)}');
return preview;
}
// Cheapest live path first: the page's own retained scene. It is already
// interpreted and its images are already decoded, so a tile costs a
// command replay plus its (tiny) raster - no worker round trip, and none
// of the local walk below. The lookup must name the exact display plan the
// tile wants; a scene recorded under a view rotation, a different paper
// colour, or with annotations the tile does not show simply misses and
// every path below is unchanged.
final retained = _retainedSceneForTile(previews, pageIndex, page,
pageColor: pageColor,
annotations: annotations,
hiddenAnnotationSubtypes: hiddenAnnotationSubtypes);
if (retained != null) {
try {
final sceneImageRatio = retained.imagePixelRatio;
// A scene whose images were decoded below the tile's own ratio would
// draw them softer than a fresh render; leave those to the paths
// below. A tile ratio is a fraction of any display ratio, so this
// holds in practice - it is a guard, not a common case.
if ((sceneImageRatio == null || sceneImageRatio >= ratio) &&
!(deferUiWork?.call() ?? false)) {
final image = await retained.scene.rasterize(pixelRatio: ratio);
final retainedMs = sw.elapsedMicroseconds / 1000.0;
trace.instant('retained replay+raster', arguments: {
'ms': retainedMs,
'commands': retained.scene.commands.length,
});
PdfPerfLog.log('thumbnail page=$pageIndex $reason px=$pixelWidth '
'retained replay+raster=${_traceMs(retainedMs)} '
'commands=${retained.scene.commands.length}');
disk?.storeThumbnail(pageIndex, pixelWidth, image,
pageColor: pageColor.toARGB32(),
annotations: annotations,
hiddenAnnotationSubtypes: hiddenAnnotationSubtypes);
return image;
}
} finally {
retained.dispose();
}
}
// the heavy interpret runs on the isolate, only the small replay + raster
// stays here. Image pages and the web fallback return null and rasterize
// locally.
final usingWorker = worker != null && worker.isActive;
final commands = usingWorker
? await worker.record(pageIndex,
annotations: annotations,
hiddenAnnotationSubtypes: hiddenAnnotationSubtypes,
priority: priority,
imagePixelRatio: ratio)
: null;
final recordMs = sw.elapsedMicroseconds / 1000.0;
if (commands != null) {
// A thumbnail may have started while the viewer was idle, then received
// its worker result after scrolling began. Do not turn that result into
// a picture/raster on the platform thread now: the tile already has a
// soft viewer preview, and foreground motion wins. A visible tile is
// re-queued; a warm pass may leave the page for its on-demand render.
if (deferUiWork?.call() ?? false) {
trace.instant('defer ui replay', arguments: {'ms': recordMs});
PdfPerfLog.log('thumbnail page=$pageIndex $reason px=$pixelWidth '
'defer ui replay record=${_traceMs(recordMs)}');
return null;
}
trace.instant('worker.record',
arguments: {'ms': recordMs, 'commands': commands.length});
sw.reset();
// The replay is the one part of a thumbnail that CANNOT leave the
// platform thread, so it gets the tile's own resolution too: any image
// the worker's codec declined arrives un-decoded, and without the cap it
// would decode at native size here - a 10-megapixel scan run through the
// pure-Dart codec to fill a 256px tile (#603). Sliced into the timeline
// as its own span so a trace attributes it to the thumbnail rather than
// to whatever frame it lands in.
final picture = await _timedReplay(
pageIndex,
reason,
() => PdfPageRenderer.pictureFromCommands(page, commands,
pageColor: pageColor, maxImagePixelRatio: ratio));
final replayMs = sw.elapsedMicroseconds / 1000.0;
sw.reset();
try {
// pictureFromCommands can yield while resolving resources. Re-check
// before the much more expensive CanvasKit raster in case scrolling
// began during that replay.
if (deferUiWork?.call() ?? false) {
trace.instant('defer ui raster',
arguments: {'recordMs': recordMs, 'replayMs': replayMs});
PdfPerfLog.log('thumbnail page=$pageIndex $reason px=$pixelWidth '
'defer ui raster record=${_traceMs(recordMs)} '
'replay=${_traceMs(replayMs)}');
return null;
}
final image = await PdfPageRenderer.rasterize(picture, size, ratio);
final rasterMs = sw.elapsedMicroseconds / 1000.0;
trace.instant('rasterize', arguments: {'ms': rasterMs});
PdfPerfLog.log(
'thumbnail page=$pageIndex $reason px=$pixelWidth worker '
'record=${_traceMs(recordMs)} replay=${_traceMs(replayMs)} '
'raster=${_traceMs(rasterMs)}');
// write through so this page opens straight from disk next session
disk?.storeThumbnail(pageIndex, pixelWidth, image,
pageColor: pageColor.toARGB32(),
annotations: annotations,
hiddenAnnotationSubtypes: hiddenAnnotationSubtypes);
return image;
} finally {
picture.dispose();
}
}
// The warm pass skips whenever it cannot get a worker result: the worker
// may have declined/preempted the page, or there may be no active worker
// (web worker script missing, or a caller omitted renderWorker). On-screen
// tiles still fall back to a local interpret as before.
if (skipIfWorkerDeclines) {
trace.instant('skip local fallback', arguments: {'ms': recordMs});
PdfPerfLog.log('thumbnail page=$pageIndex $reason px=$pixelWidth '
'skip local fallback record=${_traceMs(recordMs)} '
'${usingWorker ? '(worker declined)' : '(no worker)'}');
return null;
}
// The worker may have declined only after the viewer started moving. A
// visible tile normally falls back to a local interpret here, which is
// even more disruptive than replaying a returned command buffer; defer it
// under the same gate and retry after settle.
if (deferUiWork?.call() ?? false) {
trace.instant('defer ui local fallback', arguments: {'ms': recordMs});
PdfPerfLog.log('thumbnail page=$pageIndex $reason px=$pixelWidth '
'defer ui local fallback record=${_traceMs(recordMs)}');
return null;
}
sw.reset();
final image = await PdfPageRenderer.renderImage(page,
pixelRatio: ratio,
pageColor: pageColor,
annotations: annotations,
hiddenAnnotationSubtypes: hiddenAnnotationSubtypes);
final localMs = sw.elapsedMicroseconds / 1000.0;
trace.instant('local interpret+raster', arguments: {'ms': localMs});
PdfPerfLog.log('thumbnail page=$pageIndex $reason px=$pixelWidth '
'local interpret+raster=${_traceMs(localMs)} '
'${usingWorker ? '(worker declined)' : '(no worker)'}');
disk?.storeThumbnail(pageIndex, pixelWidth, image,
pageColor: pageColor.toARGB32(),
annotations: annotations,
hiddenAnnotationSubtypes: hiddenAnnotationSubtypes);
return image;
} finally {
trace.finish();
}
}