Skip to content

Commit 19876ec

Browse files
committed
feat(runtime-web): image utils (canvasToJpegBlob, reencodeJpeg) + ImageUploadAdapter contract
1 parent 335ecde commit 19876ec

5 files changed

Lines changed: 194 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function canvasToJpegBlob(canvas: HTMLCanvasElement, quality = 0.9): Promise<Blob> {
2+
return new Promise<Blob>((resolve, reject) => {
3+
canvas.toBlob(
4+
(blob) => (blob ? resolve(blob) : reject(new Error("toBlob returned null"))),
5+
"image/jpeg",
6+
quality,
7+
);
8+
});
9+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/** The upload/serve seam a consumer implements for <ImageUpload>. The library ships
2+
* no concrete adapter — the app supplies one (see docs). `store` is an opaque
3+
* namespace hint from view.image's @store attr, not infrastructure. */
4+
export interface ImageUploadAdapter {
5+
upload(blob: Blob, opts: { store?: string }): Promise<{ key: string }>;
6+
imageUrl(key: string): string;
7+
}
8+
9+
/** view.image's declared attrs, resolved into props for <ImageUpload>. */
10+
export interface ImageMeta {
11+
aspectRatio?: number;
12+
maxEdge?: number;
13+
store?: string;
14+
accept?: string[];
15+
maxBytes?: number;
16+
}

client/web/packages/runtime-web/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ export { buildFilterQs } from "./filter-qs.js";
44
export type { EntityFetcher, GridConfig } from "./fetcher.js";
55
export { buildGrid } from "./grid-from-metadata.js";
66
export type { MetaColumn, MetaGrid } from "./grid-from-metadata.js";
7+
export { canvasToJpegBlob } from "./canvas-to-jpeg-blob.js";
8+
export { reencodeJpeg } from "./reencode-jpeg.js";
9+
export type { ImageUploadAdapter, ImageMeta } from "./image-adapter.js";
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { canvasToJpegBlob } from "./canvas-to-jpeg-blob.js";
2+
3+
/** Whole-image re-encode to JPEG (down-scaled to maxEdge, EXIF stripped via canvas). */
4+
export async function reencodeJpeg(file: File | Blob, maxEdge = 2000): Promise<Blob> {
5+
const bitmap = await createImageBitmap(file, { imageOrientation: "from-image" });
6+
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
7+
const width = Math.round(bitmap.width * scale);
8+
const height = Math.round(bitmap.height * scale);
9+
const canvas = document.createElement("canvas");
10+
canvas.width = width;
11+
canvas.height = height;
12+
const ctx = canvas.getContext("2d");
13+
if (!ctx) throw new Error("canvas 2d context unavailable");
14+
ctx.drawImage(bitmap, 0, 0, width, height);
15+
bitmap.close();
16+
return canvasToJpegBlob(canvas, 0.9);
17+
}
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { canvasToJpegBlob } from "../src/canvas-to-jpeg-blob.js";
3+
import { reencodeJpeg } from "../src/reencode-jpeg.js";
4+
5+
// runtime-web ships zero DOM dependency (no jsdom) — the canvas/document/
6+
// createImageBitmap surface these units touch is stubbed by hand per test.
7+
8+
interface FakeCanvasCtx {
9+
drawImage: (...args: unknown[]) => void;
10+
}
11+
12+
interface FakeCanvas {
13+
width: number;
14+
height: number;
15+
getContext: (kind: string) => FakeCanvasCtx | null;
16+
toBlob: (cb: (blob: Blob | null) => void, type: string, quality: number) => void;
17+
}
18+
19+
interface ToBlobCall {
20+
type: string;
21+
quality: number;
22+
}
23+
24+
function makeFakeCanvas(opts: {
25+
contextUnavailable: boolean | undefined;
26+
toBlobCalls: ToBlobCall[];
27+
drawImageCalls: unknown[][];
28+
toBlobReturnsNull: boolean | undefined;
29+
}): FakeCanvas {
30+
const ctx: FakeCanvasCtx = {
31+
drawImage: (...args: unknown[]) => {
32+
opts.drawImageCalls.push(args);
33+
},
34+
};
35+
return {
36+
width: 0,
37+
height: 0,
38+
getContext: () => (opts.contextUnavailable ? null : ctx),
39+
toBlob: (cb, type, quality) => {
40+
opts.toBlobCalls.push({ type, quality });
41+
cb(opts.toBlobReturnsNull ? null : new Blob([], { type }));
42+
},
43+
};
44+
}
45+
46+
/** Installs `globalThis.createImageBitmap` + `globalThis.document` for the
47+
* duration of one test. Returns the fake canvas + call-capture arrays so
48+
* assertions can inspect what reencodeJpeg did to them. */
49+
function installFakeDom(opts: {
50+
bitmapWidth: number;
51+
bitmapHeight: number;
52+
contextUnavailable?: boolean;
53+
toBlobReturnsNull?: boolean;
54+
}) {
55+
const closeCalls: number[] = [];
56+
const toBlobCalls: ToBlobCall[] = [];
57+
const drawImageCalls: unknown[][] = [];
58+
59+
const bitmap = {
60+
width: opts.bitmapWidth,
61+
height: opts.bitmapHeight,
62+
close: () => {
63+
closeCalls.push(1);
64+
},
65+
};
66+
67+
const canvas = makeFakeCanvas({
68+
contextUnavailable: opts.contextUnavailable,
69+
toBlobCalls,
70+
drawImageCalls,
71+
toBlobReturnsNull: opts.toBlobReturnsNull,
72+
});
73+
74+
(globalThis as unknown as { createImageBitmap: unknown }).createImageBitmap = async () => bitmap;
75+
(globalThis as unknown as { document: unknown }).document = {
76+
createElement: () => canvas,
77+
};
78+
79+
return { canvas, closeCalls, toBlobCalls, drawImageCalls };
80+
}
81+
82+
describe("canvasToJpegBlob", () => {
83+
test("passes image/jpeg + default quality 0.9 to canvas.toBlob and resolves the blob", async () => {
84+
const calls: ToBlobCall[] = [];
85+
let resolvedBlob: Blob | undefined;
86+
const canvas = {
87+
toBlob: (cb: (blob: Blob | null) => void, type: string, quality: number) => {
88+
calls.push({ type, quality });
89+
resolvedBlob = new Blob([], { type });
90+
cb(resolvedBlob);
91+
},
92+
} as unknown as HTMLCanvasElement;
93+
94+
const result = await canvasToJpegBlob(canvas);
95+
96+
expect(calls).toEqual([{ type: "image/jpeg", quality: 0.9 }]);
97+
expect(result).toBe(resolvedBlob as Blob);
98+
});
99+
100+
test("(d) rejects with 'toBlob returned null' when toBlob yields no blob", async () => {
101+
const canvas = {
102+
toBlob: (cb: (blob: Blob | null) => void) => cb(null),
103+
} as unknown as HTMLCanvasElement;
104+
105+
await expect(canvasToJpegBlob(canvas)).rejects.toThrow("toBlob returned null");
106+
});
107+
});
108+
109+
describe("reencodeJpeg", () => {
110+
afterEach(() => {
111+
delete (globalThis as Record<string, unknown>).createImageBitmap;
112+
delete (globalThis as Record<string, unknown>).document;
113+
});
114+
115+
test("(a) re-encodes as JPEG at quality 0.9 and closes the source bitmap", async () => {
116+
const { closeCalls, toBlobCalls } = installFakeDom({ bitmapWidth: 800, bitmapHeight: 600 });
117+
118+
await reencodeJpeg(new Blob([], { type: "image/png" }));
119+
120+
expect(toBlobCalls).toEqual([{ type: "image/jpeg", quality: 0.9 }]);
121+
expect(closeCalls).toHaveLength(1);
122+
});
123+
124+
test("(b) down-scales 4000x2000 to 2000x1000 at maxEdge=2000", async () => {
125+
const { canvas } = installFakeDom({ bitmapWidth: 4000, bitmapHeight: 2000 });
126+
127+
await reencodeJpeg(new Blob([], { type: "image/png" }), 2000);
128+
129+
expect(canvas.width).toBe(2000);
130+
expect(canvas.height).toBe(1000);
131+
});
132+
133+
test("(c) never upscales — 800x600 stays 800x600 under the default maxEdge", async () => {
134+
const { canvas } = installFakeDom({ bitmapWidth: 800, bitmapHeight: 600 });
135+
136+
await reencodeJpeg(new Blob([], { type: "image/png" }));
137+
138+
expect(canvas.width).toBe(800);
139+
expect(canvas.height).toBe(600);
140+
});
141+
142+
test("(e) throws 'canvas 2d context unavailable' when getContext returns null", async () => {
143+
installFakeDom({ bitmapWidth: 800, bitmapHeight: 600, contextUnavailable: true });
144+
145+
await expect(reencodeJpeg(new Blob([], { type: "image/png" }))).rejects.toThrow(
146+
"canvas 2d context unavailable",
147+
);
148+
});
149+
});

0 commit comments

Comments
 (0)