@gongbaodd/qr-renderer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +25 -0
  3. package/dist/node.js +3130 -0
  4. package/dist/types/core/artifacts.d.ts +2 -0
  5. package/dist/types/core/assemble.d.ts +36 -0
  6. package/dist/types/core/errors.d.ts +6 -0
  7. package/dist/types/core/export-sizes.d.ts +21 -0
  8. package/dist/types/core/image.d.ts +14 -0
  9. package/dist/types/core/imaging/browser.d.ts +23 -0
  10. package/dist/types/core/imaging/cloudflare.d.ts +2 -0
  11. package/dist/types/core/imaging/index.d.ts +5 -0
  12. package/dist/types/core/imaging/node.d.ts +4 -0
  13. package/dist/types/core/imaging/pixels.d.ts +28 -0
  14. package/dist/types/core/imaging/types.d.ts +45 -0
  15. package/dist/types/core/mask.d.ts +11 -0
  16. package/dist/types/core/module-cut.d.ts +107 -0
  17. package/dist/types/core/palette.d.ts +80 -0
  18. package/dist/types/core/pattern-cut.d.ts +96 -0
  19. package/dist/types/core/pattern.d.ts +119 -0
  20. package/dist/types/core/placement.d.ts +9 -0
  21. package/dist/types/core/qr.d.ts +57 -0
  22. package/dist/types/core/rotate.d.ts +149 -0
  23. package/dist/types/core/squircle.d.ts +2 -0
  24. package/dist/types/core/types.d.ts +475 -0
  25. package/dist/types/engine/engine.d.ts +68 -0
  26. package/dist/types/engine/index.d.ts +34 -0
  27. package/dist/types/engine/mapping.d.ts +14 -0
  28. package/dist/types/engine/pipeline.d.ts +77 -0
  29. package/dist/types/engine/types.d.ts +66 -0
  30. package/dist/types/node.d.ts +17 -0
  31. package/dist/types/png-guard.d.ts +17 -0
  32. package/dist/types/recipe.d.ts +132 -0
  33. package/dist/types/schema.d.ts +211 -0
  34. package/dist/types/worker-shim.d.ts +12 -0
  35. package/package.json +79 -0
  36. package/src/core/artifacts.ts +6 -0
  37. package/src/core/assemble.ts +1195 -0
  38. package/src/core/errors.ts +24 -0
  39. package/src/core/export-sizes.ts +179 -0
  40. package/src/core/image.ts +57 -0
  41. package/src/core/imaging/browser.ts +186 -0
  42. package/src/core/imaging/cloudflare.ts +15 -0
  43. package/src/core/imaging/index.ts +14 -0
  44. package/src/core/imaging/node.ts +95 -0
  45. package/src/core/imaging/pixels.ts +121 -0
  46. package/src/core/imaging/types.ts +56 -0
  47. package/src/core/mask.ts +295 -0
  48. package/src/core/module-cut.ts +523 -0
  49. package/src/core/palette.ts +227 -0
  50. package/src/core/pattern-cut.ts +522 -0
  51. package/src/core/pattern.ts +478 -0
  52. package/src/core/placement.ts +94 -0
  53. package/src/core/qr.ts +695 -0
  54. package/src/core/rotate.ts +267 -0
  55. package/src/core/squircle.ts +53 -0
  56. package/src/core/types.ts +477 -0
  57. package/src/engine/engine.ts +316 -0
  58. package/src/engine/index.ts +58 -0
  59. package/src/engine/mapping.ts +66 -0
  60. package/src/engine/pipeline.ts +501 -0
  61. package/src/engine/types.ts +61 -0
  62. package/src/node.ts +45 -0
  63. package/src/png-guard.ts +66 -0
  64. package/src/recipe.ts +161 -0
  65. package/src/schema.ts +137 -0
  66. package/src/wasm.d.ts +5 -0
  67. package/src/worker-shim.ts +15 -0
@@ -0,0 +1,24 @@
1
+ export type ErrorCode =
2
+ | 'QWEN_API_FAILED'
3
+ | 'INVALID_INPUT'
4
+ | 'MASK_AMBIGUOUS'
5
+ | 'MASK_INVALID'
6
+ | 'QR_INVALID'
7
+ | 'QR_TEXT_MISMATCH'
8
+ | 'QR_LAYOUT_INVALID'
9
+ | 'COLOR_INVALID'
10
+ | 'OUTPUT_EXISTS'
11
+ | 'IMAGE_PROCESSING_FAILED'
12
+ | 'VERIFICATION_FAILED'
13
+
14
+ export class QrPosterError extends Error {
15
+ readonly code: ErrorCode
16
+ readonly exitCode: 2 | 3 | 4
17
+
18
+ constructor(code: ErrorCode, message: string, exitCode: 2 | 3 | 4 = 2, options?: ErrorOptions) {
19
+ super(message, options)
20
+ this.name = 'QrPosterError'
21
+ this.code = code
22
+ this.exitCode = exitCode
23
+ }
24
+ }
@@ -0,0 +1,179 @@
1
+ import { QrPosterError } from './errors'
2
+ import type { RawImage } from './imaging/types'
3
+ import type { RegionMask } from './types'
4
+
5
+ export const RASTER_EXPORT_TARGETS = [320, 640, 1280] as const
6
+
7
+ export interface RasterExportChoice {
8
+ key: string
9
+ targetPitch: number
10
+ width: number
11
+ height: number
12
+ }
13
+
14
+ export function dimensionsAtPitch(
15
+ width: number,
16
+ height: number,
17
+ originalPitch: number,
18
+ targetPitch: number,
19
+ ): { width: number; height: number } {
20
+ const scale = targetPitch / originalPitch
21
+ return {
22
+ width: Math.max(1, Math.round(width * scale)),
23
+ height: Math.max(1, Math.round(height * scale)),
24
+ }
25
+ }
26
+
27
+ /** Build distinct smaller choices while keeping every export on an integer QR-module pitch. */
28
+ export function rasterExportChoices(width: number, height: number, originalPitch: number): RasterExportChoice[] {
29
+ if (!Number.isInteger(originalPitch) || originalPitch <= 4) return []
30
+ const byPitch = new Map<number, RasterExportChoice>()
31
+ const minimumDimensions = dimensionsAtPitch(width, height, originalPitch, 4)
32
+ byPitch.set(4, {
33
+ key: 'minimum',
34
+ targetPitch: 4,
35
+ ...minimumDimensions,
36
+ })
37
+
38
+ for (const target of RASTER_EXPORT_TARGETS) {
39
+ if (target >= Math.max(width, height)) continue
40
+ let targetPitch = 4
41
+ while (
42
+ targetPitch < originalPitch &&
43
+ Math.max(
44
+ dimensionsAtPitch(width, height, originalPitch, targetPitch).width,
45
+ dimensionsAtPitch(width, height, originalPitch, targetPitch).height,
46
+ ) < target
47
+ )
48
+ targetPitch++
49
+ if (targetPitch >= originalPitch) continue
50
+ if (byPitch.has(targetPitch)) continue
51
+ byPitch.set(targetPitch, {
52
+ key: `target-${target}`,
53
+ targetPitch,
54
+ ...dimensionsAtPitch(width, height, originalPitch, targetPitch),
55
+ })
56
+ }
57
+
58
+ return [...byPitch.values()].sort((left, right) => left.targetPitch - right.targetPitch)
59
+ }
60
+
61
+ /** Area-weighted RGBA shrink in premultiplied-alpha space. */
62
+ export function resizeAreaPremultiplied(source: RawImage, width: number, height: number): Uint8Array {
63
+ if (width > source.width || height > source.height)
64
+ throw new QrPosterError('INVALID_INPUT', 'Raster export sizes cannot exceed the assembled poster.')
65
+ if (width === source.width && height === source.height) return Uint8Array.from(source.data)
66
+ const output = new Uint8Array(width * height * 4)
67
+ const scaleX = source.width / width
68
+ const scaleY = source.height / height
69
+
70
+ for (let targetY = 0; targetY < height; targetY++) {
71
+ const sourceTop = targetY * scaleY
72
+ const sourceBottom = (targetY + 1) * scaleY
73
+ const firstY = Math.floor(sourceTop)
74
+ const lastY = Math.min(source.height - 1, Math.ceil(sourceBottom) - 1)
75
+ for (let targetX = 0; targetX < width; targetX++) {
76
+ const sourceLeft = targetX * scaleX
77
+ const sourceRight = (targetX + 1) * scaleX
78
+ const firstX = Math.floor(sourceLeft)
79
+ const lastX = Math.min(source.width - 1, Math.ceil(sourceRight) - 1)
80
+ let totalWeight = 0
81
+ let alphaWeight = 0
82
+ let red = 0
83
+ let green = 0
84
+ let blue = 0
85
+ for (let sourceY = firstY; sourceY <= lastY; sourceY++) {
86
+ const vertical = Math.min(sourceBottom, sourceY + 1) - Math.max(sourceTop, sourceY)
87
+ for (let sourceX = firstX; sourceX <= lastX; sourceX++) {
88
+ const horizontal = Math.min(sourceRight, sourceX + 1) - Math.max(sourceLeft, sourceX)
89
+ const weight = horizontal * vertical
90
+ const offset = (sourceY * source.width + sourceX) * 4
91
+ const alpha = source.data[offset + 3]! / 255
92
+ totalWeight += weight
93
+ alphaWeight += weight * alpha
94
+ red += weight * alpha * source.data[offset]!
95
+ green += weight * alpha * source.data[offset + 1]!
96
+ blue += weight * alpha * source.data[offset + 2]!
97
+ }
98
+ }
99
+ const target = (targetY * width + targetX) * 4
100
+ output[target + 3] = totalWeight > 0 ? Math.round((alphaWeight / totalWeight) * 255) : 0
101
+ if (alphaWeight > 0) {
102
+ output[target] = Math.round(red / alphaWeight)
103
+ output[target + 1] = Math.round(green / alphaWeight)
104
+ output[target + 2] = Math.round(blue / alphaWeight)
105
+ }
106
+ }
107
+ }
108
+ return output
109
+ }
110
+
111
+ /** A target mask pixel is selected only when every source pixel in its footprint is selected. */
112
+ export function resizeRegionMaskConservative(source: RegionMask, width: number, height: number): RegionMask {
113
+ const integralWidth = source.width + 1
114
+ const integral = new Uint32Array(integralWidth * (source.height + 1))
115
+ for (let y = 0; y < source.height; y++) {
116
+ let row = 0
117
+ for (let x = 0; x < source.width; x++) {
118
+ if (source.data[y * source.width + x]) row++
119
+ integral[(y + 1) * integralWidth + x + 1] = integral[y * integralWidth + x + 1]! + row
120
+ }
121
+ }
122
+
123
+ const data = new Uint8Array(width * height)
124
+ let area = 0
125
+ let sumX = 0
126
+ let sumY = 0
127
+ let minX = width
128
+ let minY = height
129
+ let maxX = -1
130
+ let maxY = -1
131
+ for (let y = 0; y < height; y++) {
132
+ const top = Math.floor((y * source.height) / height)
133
+ const bottom = Math.min(source.height, Math.ceil(((y + 1) * source.height) / height))
134
+ for (let x = 0; x < width; x++) {
135
+ const left = Math.floor((x * source.width) / width)
136
+ const right = Math.min(source.width, Math.ceil(((x + 1) * source.width) / width))
137
+ const selected =
138
+ integral[bottom * integralWidth + right]! -
139
+ integral[top * integralWidth + right]! -
140
+ integral[bottom * integralWidth + left]! +
141
+ integral[top * integralWidth + left]!
142
+ if (selected !== (right - left) * (bottom - top)) continue
143
+ data[y * width + x] = 255
144
+ area++
145
+ sumX += x
146
+ sumY += y
147
+ minX = Math.min(minX, x)
148
+ minY = Math.min(minY, y)
149
+ maxX = Math.max(maxX, x)
150
+ maxY = Math.max(maxY, y)
151
+ }
152
+ }
153
+ if (area === 0) throw new QrPosterError('MASK_INVALID', 'The selected region disappears at this export size.')
154
+ return {
155
+ data,
156
+ width,
157
+ height,
158
+ source: source.source,
159
+ area,
160
+ bounds: { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 },
161
+ centroid: { x: sumX / area, y: sumY / area },
162
+ }
163
+ }
164
+
165
+ function bytesToBase64(bytes: Uint8Array): string {
166
+ let binary = ''
167
+ for (let start = 0; start < bytes.length; start += 0x8000)
168
+ binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000))
169
+ return btoa(binary)
170
+ }
171
+
172
+ /** A self-contained full-poster SVG backed by the exact verified assembled PNG. */
173
+ export function buildPosterSvg(png: Uint8Array, width: number, height: number): string {
174
+ return (
175
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n` +
176
+ ` <image x="0" y="0" width="${width}" height="${height}" preserveAspectRatio="none" href="data:image/png;base64,${bytesToBase64(png)}"/>\n` +
177
+ '</svg>\n'
178
+ )
179
+ }
@@ -0,0 +1,57 @@
1
+ import { imaging } from './imaging'
2
+ import { QrPosterError } from './errors'
3
+
4
+ export interface LoadedPng {
5
+ path: string
6
+ file: Uint8Array
7
+ data: Uint8Array
8
+ width: number
9
+ height: number
10
+ sha256: string
11
+ }
12
+
13
+ export async function loadPng(path: string, label: string): Promise<LoadedPng> {
14
+ // The Node-only fs/path imports stay dynamic so browser bundles (the worker)
15
+ // never pull them in; scripts, tests, and the CLI are the only loadPng callers.
16
+ if (!path.toLowerCase().endsWith('.png'))
17
+ throw new QrPosterError('INVALID_INPUT', `${label} must be a PNG file: ${path}`)
18
+
19
+ let file: Uint8Array
20
+ try {
21
+ const { readFile } = await import('node:fs/promises')
22
+ file = await readFile(path)
23
+ } catch (error) {
24
+ throw new QrPosterError('INVALID_INPUT', `Could not read ${label}: ${path}`, 2, { cause: error })
25
+ }
26
+
27
+ return decodePng(file, path, label)
28
+ }
29
+
30
+ /** Decodes an in-memory PNG into the same shape {@link loadPng} returns. */
31
+ export async function decodePng(file: Uint8Array, path: string, label: string): Promise<LoadedPng> {
32
+ try {
33
+ const raw = await imaging().decodePng(file)
34
+ return {
35
+ path,
36
+ file,
37
+ data: raw.data,
38
+ width: raw.width,
39
+ height: raw.height,
40
+ sha256: await imaging().sha256Hex(file),
41
+ }
42
+ } catch (error) {
43
+ throw new QrPosterError('INVALID_INPUT', `Could not decode ${label} as PNG: ${path}`, 2, { cause: error })
44
+ }
45
+ }
46
+
47
+ export async function rgbaToPng(data: Uint8Array, width: number, height: number): Promise<Uint8Array> {
48
+ return imaging().encodePngRgba(data, width, height)
49
+ }
50
+
51
+ export async function grayscaleToPng(data: Uint8Array, width: number, height: number): Promise<Uint8Array> {
52
+ return imaging().encodePngGrayscale(data, width, height)
53
+ }
54
+
55
+ export function luma(r: number, g: number, b: number): number {
56
+ return Math.round((299 * r + 587 * g + 114 * b) / 1000)
57
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The browser imaging backend: @jsquash/png for the lossless PNG codec and
3
+ * @resvg/resvg-wasm as the SVG rasterizer. Pixel work (composite, flatten,
4
+ * nearest resize) shares the pure-TS helpers in pixels.ts so both environments
5
+ * implement the same document semantics.
6
+ *
7
+ * The WASM binaries are initialized lazily on the first operation. In a browser
8
+ * bundle both codecs auto-resolve their `.wasm` assets relative to their own
9
+ * module URL; in Node (parity harness) pass the raw wasm bytes via
10
+ * {@link installBrowserImaging}.
11
+ */
12
+
13
+ import { decode as pngDecodeWasm, encode as pngEncodeWasm } from '@jsquash/png'
14
+ import { init as initPngCodec } from '@jsquash/png/decode.js'
15
+ import type { InitInput as PngWasmInput } from '@jsquash/png/codec/pkg/squoosh_png.js'
16
+ import { Resvg, initWasm } from '@resvg/resvg-wasm'
17
+ import type { InitInput as ResvgWasmInput } from '@resvg/resvg-wasm'
18
+ import { QrPosterError } from '../errors'
19
+ import { compositeOver, flattenOverWhite, resizeNearest } from './pixels'
20
+ import type { Imaging, RawImage, RenderedSvgPng, SvgRenderOptions } from './types'
21
+
22
+ /** Mirrors sharp's `limitInputPixels` on the Node backend. */
23
+ const PNG_DECODE_PIXEL_LIMIT = 16_000_000
24
+
25
+ export interface BrowserImagingOptions {
26
+ /** resvg wasm source; defaults to the codec's bundled URL in browser builds. */
27
+ resvgWasm?: ResvgWasmInput
28
+ /** jSquash png wasm source; defaults to the codec's bundled URL in browser builds. */
29
+ pngWasm?: PngWasmInput
30
+ }
31
+
32
+ let initialized: Promise<void> | null = null
33
+
34
+ /** Pre-initializes the codec WASMs; call once per worker/entry point. */
35
+ export function installBrowserImaging(options: BrowserImagingOptions = {}): Promise<void> {
36
+ if (initialized) {
37
+ if (!options.resvgWasm && !options.pngWasm) return initialized
38
+ return Promise.reject(new Error('The browser imaging backend is already initialized.'))
39
+ }
40
+ initialized = Promise.all([
41
+ initWasm(options.resvgWasm as ResvgWasmInput),
42
+ initPngCodec(options.pngWasm as PngWasmInput),
43
+ ]).then(() => undefined)
44
+ return initialized
45
+ }
46
+
47
+ /**
48
+ * Turbopack emits the wasm binaries as assets and returns their URLs. The
49
+ * `@resvg/resvg-wasm` default auto-init cannot run under Turbopack: it relies on
50
+ * `import.meta.url`, which the bundler replaces with `void 0`, so its internal
51
+ * `new URL('index_bg.wasm', void 0)` throws Invalid URL. We import both assets
52
+ * explicitly and hand the URLs to the inits instead.
53
+ */
54
+ async function defaultWasmInputs(): Promise<BrowserImagingOptions> {
55
+ const [resvgAsset, pngAsset] = await Promise.all([
56
+ import('@resvg/resvg-wasm/index_bg.wasm'),
57
+ import('@jsquash/png/codec/pkg/squoosh_png_bg.wasm'),
58
+ ])
59
+ const resvgUrl = (resvgAsset as { default: string }).default
60
+ const pngUrl = (pngAsset as unknown as { default: string }).default
61
+ return {
62
+ resvgWasm: fetch(resvgUrl) as unknown as ResvgWasmInput,
63
+ pngWasm: fetch(pngUrl) as unknown as PngWasmInput,
64
+ }
65
+ }
66
+
67
+ function ensureInit(): Promise<void> {
68
+ if (!initialized) {
69
+ initialized = defaultWasmInputs()
70
+ .then((options) =>
71
+ Promise.all([initWasm(options.resvgWasm as ResvgWasmInput), initPngCodec(options.pngWasm as PngWasmInput)]),
72
+ )
73
+ .then(() => undefined)
74
+ }
75
+ return initialized
76
+ }
77
+
78
+ interface RgbaLike {
79
+ data: Uint8Array
80
+ width: number
81
+ height: number
82
+ }
83
+
84
+ /** Encodes RGBA pixels through @jsquash/png (it requires an exact-size buffer). */
85
+ async function encodeRgba(data: Uint8Array, width: number, height: number): Promise<Uint8Array> {
86
+ const imageData = { data: new Uint8Array(data), width, height } as unknown as ImageData
87
+ const png = await pngEncodeWasm(imageData)
88
+ return new Uint8Array(png)
89
+ }
90
+
91
+ /** Renders at the SVG's intrinsic size, returning the pixel raster. */
92
+ async function renderResvgIntrinsic(svg: string): Promise<RgbaLike> {
93
+ await ensureInit()
94
+ const renderer = new Resvg(svg, {
95
+ fitTo: { mode: 'original' },
96
+ font: { loadSystemFonts: false },
97
+ })
98
+ const rendered = renderer.render()
99
+ const result = { data: new Uint8Array(rendered.pixels), width: rendered.width, height: rendered.height }
100
+ renderer.free()
101
+ return result
102
+ }
103
+
104
+ /** Renders and enforces exact target dimensions, mirroring the call-site checks. */
105
+ async function renderResvg(svg: string, width: number, height: number, what: string): Promise<RgbaLike> {
106
+ const raw = await renderResvgIntrinsic(svg)
107
+ if (raw.width !== width || raw.height !== height) {
108
+ throw new QrPosterError(
109
+ 'IMAGE_PROCESSING_FAILED',
110
+ `${what} produced ${raw.width}x${raw.height} instead of ${width}x${height}.`,
111
+ 3,
112
+ )
113
+ }
114
+ return raw
115
+ }
116
+
117
+ export const browserImaging: Imaging = {
118
+ async decodePng(file: Uint8Array): Promise<RawImage> {
119
+ try {
120
+ const bytes = new Uint8Array(file)
121
+ const imageData = (await pngDecodeWasm(bytes.buffer)) as unknown as RgbaLike
122
+ const { width, height } = imageData
123
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0)
124
+ throw new Error('undecodable PNG dimensions')
125
+ if (width * height > PNG_DECODE_PIXEL_LIMIT) throw new Error('exceeds the pixel limit')
126
+ return { data: new Uint8Array(imageData.data), width, height }
127
+ } catch (error) {
128
+ throw new QrPosterError('INVALID_INPUT', 'Could not decode the image as PNG.', 2, { cause: error })
129
+ }
130
+ },
131
+
132
+ async encodePngRgba(data: Uint8Array, width: number, height: number): Promise<Uint8Array> {
133
+ return encodeRgba(data, width, height)
134
+ },
135
+
136
+ async encodePngGrayscale(data: Uint8Array, width: number, height: number): Promise<Uint8Array> {
137
+ // Expand one byte per pixel to opaque gray RGBA; PNG bytes differ between encoders,
138
+ // decoded pixels do not.
139
+ const rgba = new Uint8Array(width * height * 4)
140
+ for (let index = 0; index < data.length; index++) {
141
+ const value = data[index]!
142
+ rgba[index * 4] = value
143
+ rgba[index * 4 + 1] = value
144
+ rgba[index * 4 + 2] = value
145
+ rgba[index * 4 + 3] = 255
146
+ }
147
+ return encodeRgba(rgba, width, height)
148
+ },
149
+
150
+ async rasterizeSvg(svg: string, width: number, height: number): Promise<RawImage> {
151
+ return renderResvg(svg, width, height, 'SVG rasterization')
152
+ },
153
+
154
+ async renderSvgToPng(svg: string, options: SvgRenderOptions): Promise<RenderedSvgPng> {
155
+ const raw = await renderResvgIntrinsic(svg)
156
+ const pixels = options.flatten ? flattenOverWhite(raw.data) : raw.data
157
+ const png = await encodeRgba(pixels, raw.width, raw.height)
158
+ return { png, width: raw.width, height: raw.height }
159
+ },
160
+
161
+ async composeQrPng(basePng: Uint8Array, overlaySvgs: string[]): Promise<Uint8Array> {
162
+ const base = await this.decodePng(basePng)
163
+ const overlays = overlaySvgs.filter((svg) => svg.length > 0)
164
+ const rasters: RawImage[] = []
165
+ for (const svg of overlays)
166
+ rasters.push(await renderResvg(svg, base.width, base.height, 'QR overlay rasterization'))
167
+ const composited = overlays.length > 0 ? compositeOver(base, rasters) : Uint8Array.from(base.data)
168
+ return encodeRgba(composited, base.width, base.height)
169
+ },
170
+
171
+ async normalizeQrPng(file: Uint8Array, targetSize: number): Promise<Uint8Array> {
172
+ const source = await this.decodePng(file)
173
+ const flattened = flattenOverWhite(source.data)
174
+ const resized =
175
+ source.width === targetSize
176
+ ? flattened
177
+ : resizeNearest({ data: flattened, width: source.width, height: source.height }, targetSize, targetSize)
178
+ return encodeRgba(resized, targetSize, targetSize)
179
+ },
180
+
181
+ async sha256Hex(input: string | Uint8Array): Promise<string> {
182
+ const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : new Uint8Array(input)
183
+ const digest = await crypto.subtle.digest('SHA-256', bytes.slice().buffer)
184
+ return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
185
+ },
186
+ }
@@ -0,0 +1,15 @@
1
+ import type { InitInput as PngWasmInput } from '@jsquash/png/codec/pkg/squoosh_png.js'
2
+ // Cloudflare bundles this import as a WebAssembly.Module; the package's adjacent
3
+ // declaration describes the wasm-bindgen JavaScript exports instead.
4
+ // @ts-expect-error Worker WASM module import is typed by src/types/wasm.d.ts.
5
+ import pngWasm from '@jsquash/png/codec/pkg/squoosh_png_bg.wasm'
6
+ import type { InitInput as ResvgWasmInput } from '@resvg/resvg-wasm'
7
+ import resvgWasm from '@resvg/resvg-wasm/index_bg.wasm'
8
+ import { setImaging } from './index'
9
+ import { browserImaging, installBrowserImaging } from './browser'
10
+
11
+ /** Installs the same codec backend used in the browser worker using bundled Worker WASM modules. */
12
+ export const cloudflareImagingReady = installBrowserImaging({
13
+ resvgWasm: resvgWasm as ResvgWasmInput,
14
+ pngWasm: pngWasm as PngWasmInput,
15
+ }).then(() => setImaging(browserImaging))
@@ -0,0 +1,14 @@
1
+ import type { Imaging } from './types'
2
+
3
+ let active: Imaging | null = null
4
+
5
+ /** Installs one backend. Entry points only (test setup, scripts, route handlers, the worker). */
6
+ export function setImaging(backend: Imaging): void {
7
+ active = backend
8
+ }
9
+
10
+ /** The installed backend. Core modules call this instead of importing sharp directly. */
11
+ export function imaging(): Imaging {
12
+ if (!active) throw new Error('Imaging backend not installed: this entry point must call setImaging().')
13
+ return active
14
+ }
@@ -0,0 +1,95 @@
1
+ /** The Node imaging backend. Every operation mirrors the exact sharp pipeline the call
2
+ * site used before the seam, so Node-side output bytes stay bit-identical after extraction. */
3
+
4
+ import { createHash } from 'node:crypto'
5
+ import sharp from 'sharp'
6
+ import { QrPosterError } from '../errors'
7
+ import type { Imaging, RawImage, RenderedSvgPng, SvgRenderOptions } from './types'
8
+
9
+ export const nodeImaging: Imaging = {
10
+ async decodePng(file: Uint8Array): Promise<RawImage> {
11
+ try {
12
+ const bytes = Buffer.from(file.buffer, file.byteOffset, file.byteLength)
13
+ const source = sharp(bytes, { failOn: 'error', limitInputPixels: 16_000_000 })
14
+ const metadata = await source.metadata()
15
+ if (metadata.format !== 'png' || !metadata.width || !metadata.height) throw new Error('not a decodable PNG')
16
+ const { data, info } = await source.ensureAlpha().raw().toBuffer({ resolveWithObject: true })
17
+ return {
18
+ data,
19
+ width: info.width,
20
+ height: info.height,
21
+ }
22
+ } catch (error) {
23
+ throw new QrPosterError('INVALID_INPUT', 'Could not decode the image as PNG.', 2, { cause: error })
24
+ }
25
+ },
26
+
27
+ async encodePngRgba(data: Uint8Array, width: number, height: number): Promise<Uint8Array> {
28
+ const png = await sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
29
+ raw: { width, height, channels: 4 },
30
+ })
31
+ .png()
32
+ .toBuffer()
33
+ return png
34
+ },
35
+
36
+ async encodePngGrayscale(data: Uint8Array, width: number, height: number): Promise<Uint8Array> {
37
+ const png = await sharp(Buffer.from(data.buffer, data.byteOffset, data.byteLength), {
38
+ raw: { width, height, channels: 1 },
39
+ })
40
+ .png()
41
+ .toBuffer()
42
+ return png
43
+ },
44
+
45
+ async rasterizeSvg(svg: string, width: number, height: number): Promise<RawImage> {
46
+ const { data, info } = await sharp(Buffer.from(svg)).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
47
+ if (info.width !== width || info.height !== height) {
48
+ throw new QrPosterError(
49
+ 'IMAGE_PROCESSING_FAILED',
50
+ `SVG rasterization produced ${info.width}x${info.height} instead of ${width}x${height}.`,
51
+ 3,
52
+ )
53
+ }
54
+ return { data, width: info.width, height: info.height }
55
+ },
56
+
57
+ async renderSvgToPng(svg: string, options: SvgRenderOptions): Promise<RenderedSvgPng> {
58
+ const raster = sharp(Buffer.from(svg))
59
+ const { data, info } = await (options.flatten ? raster.flatten({ background: '#ffffff' }) : raster)
60
+ .png()
61
+ .toBuffer({ resolveWithObject: true })
62
+ return {
63
+ png: data,
64
+ width: info.width,
65
+ height: info.height,
66
+ }
67
+ },
68
+
69
+ async composeQrPng(basePng: Uint8Array, overlaySvgs: string[]): Promise<Uint8Array> {
70
+ const composites = overlaySvgs.filter((svg) => svg.length > 0).map((svg) => ({ input: Buffer.from(svg) }))
71
+ const png =
72
+ composites.length === 0
73
+ ? await sharp(Buffer.from(basePng.buffer, basePng.byteOffset, basePng.byteLength))
74
+ .png()
75
+ .toBuffer()
76
+ : await sharp(Buffer.from(basePng.buffer, basePng.byteOffset, basePng.byteLength))
77
+ .composite(composites)
78
+ .png()
79
+ .toBuffer()
80
+ return png
81
+ },
82
+
83
+ async normalizeQrPng(file: Uint8Array, targetSize: number): Promise<Uint8Array> {
84
+ const png = await sharp(Buffer.from(file.buffer, file.byteOffset, file.byteLength))
85
+ .flatten({ background: '#ffffff' })
86
+ .resize(targetSize, targetSize, { fit: 'fill', kernel: sharp.kernel.nearest })
87
+ .png()
88
+ .toBuffer()
89
+ return png
90
+ },
91
+
92
+ async sha256Hex(input: string | Uint8Array): Promise<string> {
93
+ return createHash('sha256').update(input).digest('hex')
94
+ },
95
+ }