@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.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/dist/node.js +3130 -0
- package/dist/types/core/artifacts.d.ts +2 -0
- package/dist/types/core/assemble.d.ts +36 -0
- package/dist/types/core/errors.d.ts +6 -0
- package/dist/types/core/export-sizes.d.ts +21 -0
- package/dist/types/core/image.d.ts +14 -0
- package/dist/types/core/imaging/browser.d.ts +23 -0
- package/dist/types/core/imaging/cloudflare.d.ts +2 -0
- package/dist/types/core/imaging/index.d.ts +5 -0
- package/dist/types/core/imaging/node.d.ts +4 -0
- package/dist/types/core/imaging/pixels.d.ts +28 -0
- package/dist/types/core/imaging/types.d.ts +45 -0
- package/dist/types/core/mask.d.ts +11 -0
- package/dist/types/core/module-cut.d.ts +107 -0
- package/dist/types/core/palette.d.ts +80 -0
- package/dist/types/core/pattern-cut.d.ts +96 -0
- package/dist/types/core/pattern.d.ts +119 -0
- package/dist/types/core/placement.d.ts +9 -0
- package/dist/types/core/qr.d.ts +57 -0
- package/dist/types/core/rotate.d.ts +149 -0
- package/dist/types/core/squircle.d.ts +2 -0
- package/dist/types/core/types.d.ts +475 -0
- package/dist/types/engine/engine.d.ts +68 -0
- package/dist/types/engine/index.d.ts +34 -0
- package/dist/types/engine/mapping.d.ts +14 -0
- package/dist/types/engine/pipeline.d.ts +77 -0
- package/dist/types/engine/types.d.ts +66 -0
- package/dist/types/node.d.ts +17 -0
- package/dist/types/png-guard.d.ts +17 -0
- package/dist/types/recipe.d.ts +132 -0
- package/dist/types/schema.d.ts +211 -0
- package/dist/types/worker-shim.d.ts +12 -0
- package/package.json +79 -0
- package/src/core/artifacts.ts +6 -0
- package/src/core/assemble.ts +1195 -0
- package/src/core/errors.ts +24 -0
- package/src/core/export-sizes.ts +179 -0
- package/src/core/image.ts +57 -0
- package/src/core/imaging/browser.ts +186 -0
- package/src/core/imaging/cloudflare.ts +15 -0
- package/src/core/imaging/index.ts +14 -0
- package/src/core/imaging/node.ts +95 -0
- package/src/core/imaging/pixels.ts +121 -0
- package/src/core/imaging/types.ts +56 -0
- package/src/core/mask.ts +295 -0
- package/src/core/module-cut.ts +523 -0
- package/src/core/palette.ts +227 -0
- package/src/core/pattern-cut.ts +522 -0
- package/src/core/pattern.ts +478 -0
- package/src/core/placement.ts +94 -0
- package/src/core/qr.ts +695 -0
- package/src/core/rotate.ts +267 -0
- package/src/core/squircle.ts +53 -0
- package/src/core/types.ts +477 -0
- package/src/engine/engine.ts +316 -0
- package/src/engine/index.ts +58 -0
- package/src/engine/mapping.ts +66 -0
- package/src/engine/pipeline.ts +501 -0
- package/src/engine/types.ts +61 -0
- package/src/node.ts +45 -0
- package/src/png-guard.ts +66 -0
- package/src/recipe.ts +161 -0
- package/src/schema.ts +137 -0
- package/src/wasm.d.ts +5 -0
- 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
|
+
}
|