Skip to content

Commit 3a4896e

Browse files
authored
Merge pull request #557 from jonashaag/feature/bmp-encoding
Use BMP image encoding between worker and main thread
2 parents ea67dfc + 4d70a66 commit 3a4896e

10 files changed

Lines changed: 113 additions & 14 deletions

File tree

.changeset/bmp-encoding.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@embedpdf/models': patch
3+
'@embedpdf/engines': patch
4+
'@embedpdf/plugin-render': patch
5+
---
6+
7+
Add BMP encoding support as an optional image format
8+
9+
BMP encoding bypasses canvas.toBlob() entirely by prepending a 66-byte header to the raw RGBA pixel data. This eliminates the dominant rendering bottleneck — in benchmarks, encoding dropped from ~76ms average (PNG via canvas.toBlob) to <1ms, reducing total tile render time by ~60%.
10+
11+
The BMP uses BI_BITFIELDS with channel masks matching PDFium's RGBA output byte order, so no per-pixel conversion is needed. Top-down row order avoids row flipping. The result is a valid BMP that all modern browsers decode natively in `<img>` elements.
12+
13+
Users who want to opt into the faster BMP path can set `defaultImageType: 'image/bmp'` in the render plugin config, while PNG remains the default output format.

packages/engines/src/lib/converters/browser.ts

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ImageConversionTypes } from '@embedpdf/models';
22
import type { ImageDataConverter, LazyImageData } from './types';
33
import { ImageEncoderWorkerPool } from '../image-encoder';
4+
import { rgbaToBmpBlob } from '../image-encoder/bmp';
45

56
// ============================================================================
67
// Error Classes
@@ -25,9 +26,16 @@ export class ImageConverterError extends Error {
2526
*/
2627
export const browserImageDataToBlobConverter: ImageDataConverter<Blob> = (
2728
getImageData: LazyImageData,
28-
imageType: ImageConversionTypes = 'image/webp',
29+
imageType: ImageConversionTypes = 'image/png',
2930
quality?: number,
3031
): Promise<Blob> => {
32+
const pdfImage = getImageData();
33+
34+
// Fast path: BMP needs no canvas — just a header prepended to raw pixels
35+
if (imageType === 'image/bmp') {
36+
return Promise.resolve(rgbaToBmpBlob(pdfImage.data, pdfImage.width, pdfImage.height));
37+
}
38+
3139
if (typeof document === 'undefined') {
3240
return Promise.reject(
3341
new ImageConverterError(
@@ -36,7 +44,6 @@ export const browserImageDataToBlobConverter: ImageDataConverter<Blob> = (
3644
);
3745
}
3846

39-
const pdfImage = getImageData();
4047
const imageData = new ImageData(pdfImage.data, pdfImage.width, pdfImage.height);
4148

4249
return new Promise((resolve, reject) => {
@@ -73,11 +80,16 @@ export function createWorkerPoolImageConverter(
7380
): ImageDataConverter<Blob> {
7481
const converter: ImageDataConverter<Blob> = (
7582
getImageData: LazyImageData,
76-
imageType: ImageConversionTypes = 'image/webp',
83+
imageType: ImageConversionTypes = 'image/png',
7784
quality?: number,
7885
): Promise<Blob> => {
7986
const pdfImage = getImageData();
8087

88+
// Fast path: BMP needs no worker round-trip
89+
if (imageType === 'image/bmp') {
90+
return Promise.resolve(rgbaToBmpBlob(pdfImage.data, pdfImage.width, pdfImage.height));
91+
}
92+
8193
// Copy the data since we'll transfer it to another worker
8294
const dataCopy = new Uint8ClampedArray(pdfImage.data);
8395

@@ -113,12 +125,18 @@ export function createHybridImageConverter(
113125
): ImageDataConverter<Blob> {
114126
const converter: ImageDataConverter<Blob> = async (
115127
getImageData: LazyImageData,
116-
imageType: ImageConversionTypes = 'image/webp',
128+
imageType: ImageConversionTypes = 'image/png',
117129
quality?: number,
118130
): Promise<Blob> => {
131+
const pdfImage = getImageData();
132+
133+
// Fast path: BMP needs no worker round-trip
134+
if (imageType === 'image/bmp') {
135+
return rgbaToBmpBlob(pdfImage.data, pdfImage.width, pdfImage.height);
136+
}
137+
119138
try {
120139
// Try worker pool encoding first (OffscreenCanvas in worker)
121-
const pdfImage = getImageData();
122140
const dataCopy = new Uint8ClampedArray(pdfImage.data);
123141

124142
return await workerPool.encode(
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Creates an uncompressed BMP blob from raw RGBA pixel data.
3+
*
4+
* Uses BI_BITFIELDS with channel masks matching RGBA byte order, so no
5+
* per-pixel byte swapping is needed. Top-down row order (negative height)
6+
* avoids row flipping. The result is a valid BMP that all modern browsers
7+
* can decode natively in `<img>` elements.
8+
*
9+
* This is dramatically faster than PNG/WebP/JPEG encoding via canvas.toBlob()
10+
* because it performs no compression — just a 66-byte header prepended to the
11+
* raw pixel buffer.
12+
*/
13+
export function rgbaToBmpBlob(rgba: Uint8ClampedArray, width: number, height: number): Blob {
14+
const pixels = width * height * 4;
15+
const headerLength = 66;
16+
const le32 = (v: number) => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff];
17+
18+
// prettier-ignore
19+
const header = new Uint8Array([
20+
0x42, 0x4D, // 'BM' signature
21+
...le32(headerLength + pixels), // file size
22+
0, 0, 0, 0, // reserved
23+
headerLength, 0, 0, 0, // pixel data offset
24+
40, 0, 0, 0, // DIB header size
25+
...le32(width), // width
26+
...le32(-height), // height (negative = top-down)
27+
1, 0, // color planes
28+
32, 0, // bits per pixel
29+
3, 0, 0, 0, // compression = BI_BITFIELDS
30+
...le32(pixels), // image data size
31+
0, 0, 0, 0, // h resolution
32+
0, 0, 0, 0, // v resolution
33+
0, 0, 0, 0, // colors in palette
34+
0, 0, 0, 0, // important colors
35+
0xFF, 0, 0, 0, // R channel mask
36+
0, 0xFF, 0, 0, // G channel mask
37+
0, 0, 0xFF, 0, // B channel mask
38+
]);
39+
40+
return new Blob([header, rgba], { type: 'image/bmp' });
41+
}

packages/engines/src/lib/image-encoder/image-encoder-worker.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Offloads OffscreenCanvas.convertToBlob() from the main PDFium worker
44
*/
55

6+
import { rgbaToBmpBlob } from './bmp';
7+
68
export interface EncodeImageRequest {
79
id: string;
810
type: 'encode';
@@ -12,7 +14,7 @@ export interface EncodeImageRequest {
1214
width: number;
1315
height: number;
1416
};
15-
imageType: 'image/png' | 'image/jpeg' | 'image/webp';
17+
imageType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp';
1618
quality?: number;
1719
};
1820
}
@@ -24,13 +26,18 @@ export interface EncodeImageResponse {
2426
}
2527

2628
/**
27-
* Encode ImageData to Blob using OffscreenCanvas
29+
* Encode ImageData to Blob using OffscreenCanvas (or BMP fast path)
2830
*/
2931
async function encodeImage(
3032
imageData: { data: Uint8ClampedArray; width: number; height: number },
31-
imageType: 'image/png' | 'image/jpeg' | 'image/webp',
33+
imageType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp',
3234
quality?: number,
3335
): Promise<Blob> {
36+
// Fast path: BMP needs no canvas
37+
if (imageType === 'image/bmp') {
38+
return rgbaToBmpBlob(imageData.data, imageData.width, imageData.height);
39+
}
40+
3441
if (typeof OffscreenCanvas === 'undefined') {
3542
throw new Error('OffscreenCanvas is not available in this worker environment');
3643
}

packages/engines/src/lib/image-encoder/worker-pool.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ export class ImageEncoderWorkerPool {
114114
*/
115115
encode(
116116
imageData: { data: Uint8ClampedArray; width: number; height: number },
117-
imageType: 'image/png' | 'image/jpeg' | 'image/webp' = 'image/webp',
117+
imageType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp' = 'image/png',
118118
quality?: number,
119119
): Promise<Blob> {
120120
return new Promise((resolve, reject) => {

packages/engines/src/lib/orchestrator/pdf-engine.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ export class PdfEngine<T = Blob> implements IPdfEngine<T> {
481481
options: any,
482482
resultTask: Task<T, PdfErrorReason>,
483483
): void {
484-
const imageType = options?.imageType ?? 'image/webp';
484+
const imageType = options?.imageType ?? 'image/png';
485485
const quality = options?.quality;
486486

487487
// Convert to plain object for encoding
@@ -505,7 +505,7 @@ export class PdfEngine<T = Blob> implements IPdfEngine<T> {
505505
options: PdfRenderPageAnnotationOptions | undefined,
506506
resultTask: Task<AnnotationAppearanceMap<T>, PdfErrorReason>,
507507
): void {
508-
const imageType = options?.imageType ?? 'image/webp';
508+
const imageType = options?.imageType ?? 'image/png';
509509
const quality = options?.imageQuality;
510510

511511
const convertImage = (rawImageData: ImageDataLike): Promise<T> => {

packages/models/src/pdf.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2537,7 +2537,7 @@ export function unionFlags(flags: MatchFlag[]) {
25372537
*
25382538
* @public
25392539
*/
2540-
export type ImageConversionTypes = 'image/webp' | 'image/png' | 'image/jpeg';
2540+
export type ImageConversionTypes = 'image/webp' | 'image/png' | 'image/jpeg' | 'image/bmp';
25412541

25422542
/**
25432543
* Targe for searching

packages/plugin-render/src/lib/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export interface RenderPluginConfig extends BasePluginConfig {
2121
withAnnotations?: boolean;
2222
/**
2323
* The image type to use for rendering.
24-
* Defaults to `'image/webp'`.
24+
* Defaults to `'image/png'`.
2525
*/
2626
defaultImageType?: ImageConversionTypes;
2727
/**

packages/plugin-tiling/src/lib/tiling-plugin.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,9 @@ export class TilingPlugin extends BasePlugin<TilingPluginConfig, TilingCapabilit
206206
options: {
207207
scaleFactor: options.tile.srcScale,
208208
dpr: options.dpr,
209+
...(options.imageType || this.config.defaultImageType
210+
? { imageType: options.imageType ?? this.config.defaultImageType }
211+
: {}),
209212
},
210213
});
211214

packages/plugin-tiling/src/lib/types.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,23 @@
11
import { BasePluginConfig, EventHook } from '@embedpdf/core';
2-
import { PdfErrorReason, PdfPageObject, Rect, Rotation, Task } from '@embedpdf/models';
2+
import {
3+
ImageConversionTypes,
4+
PdfErrorReason,
5+
PdfPageObject,
6+
Rect,
7+
Rotation,
8+
Task,
9+
} from '@embedpdf/models';
310
import { PageVisibilityMetrics } from '@embedpdf/plugin-scroll';
411

512
export interface TilingPluginConfig extends BasePluginConfig {
613
tileSize: number;
714
overlapPx: number;
815
extraRings: number;
16+
/**
17+
* Optional image type override for tile rendering.
18+
* When omitted, tile rendering falls back to the render plugin defaults.
19+
*/
20+
defaultImageType?: ImageConversionTypes;
921
}
1022

1123
export interface VisibleRect {
@@ -66,4 +78,9 @@ export interface RenderTileOptions {
6678
pageIndex: number;
6779
tile: Tile;
6880
dpr: number;
81+
/**
82+
* Optional image type override for this tile render.
83+
* Falls back to the tiling plugin config, then to the render plugin default.
84+
*/
85+
imageType?: ImageConversionTypes;
6986
}

0 commit comments

Comments
 (0)