@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,501 @@
1
+ /**
2
+ * Environment-agnostic prepare/assemble pipeline for the editor. This is a
3
+ * faithful decomposition of the former `src/server/editor.ts` orchestration;
4
+ * server specifics (sharp metadata checks, multipart bounds) are replaced by
5
+ * the shared `png-guard` header checks and the injected `Imaging` backend.
6
+ *
7
+ * Keep this module independent of Worker, React, HTTP, and Node: it takes the
8
+ * `Imaging` implementation as an argument instead of importing a backend.
9
+ */
10
+
11
+ import { assembleResolved, markerBandRects } from '../core/assemble'
12
+ import { renderRegionMask } from '../core/artifacts'
13
+ import {
14
+ buildPosterSvg,
15
+ dimensionsAtPitch,
16
+ resizeAreaPremultiplied,
17
+ resizeRegionMaskConservative,
18
+ } from '../core/export-sizes'
19
+ import { decodePng, rgbaToPng } from '../core/image'
20
+ import { buildManualRegionMask, detectRegionMask } from '../core/mask'
21
+ import { placeQr, findClosestSquare } from '../core/placement'
22
+ import {
23
+ generateQrFromContent,
24
+ decodeQrRawDetailed,
25
+ inspectQrSource,
26
+ normalizeQr,
27
+ transparentQrBackground,
28
+ } from '../core/qr'
29
+ import { DEFAULT_PALETTE, paletteGuard, type QrPalette } from '../core/palette'
30
+ import {
31
+ buildModuleLattice,
32
+ computePlateModules,
33
+ computeRegionBands,
34
+ computeSafeArea,
35
+ computeTightBlockQuietZone,
36
+ } from '../core/module-cut'
37
+ import { selectPatternVersion } from '../core/pattern'
38
+ import { qrWorkingFrame, sampleMaskIntoQrFrame, assertFrameHoldsPlacement } from '../core/rotate'
39
+ import { regionPixelBounds } from '../core/rotate'
40
+ import { QrPosterError } from '../core/errors'
41
+ import type { Imaging } from '../core/imaging/types'
42
+ import type { LoadedPng } from '../core/image'
43
+ import type { QrMetadata, QrPlacement, RegionMask, ResolvedLayout, AssembleReport } from '../core/types'
44
+ import { parsePngHeader } from '../png-guard'
45
+ import { contentSchema, recenterPlacement } from '../schema'
46
+ import type { Placement, Settings } from '../schema'
47
+ import type { EngineInput, PreparedPayload, AssemblePayload, RasterExportPayload } from './types'
48
+
49
+ /** Builds a Blob with the mime type implied by the artifact name (PNG/SVG/JSON). */
50
+ export function bytesToBlob(name: string, bytes: Uint8Array): Blob {
51
+ const type = name.endsWith('.svg') ? 'image/svg+xml' : name.endsWith('.json') ? 'application/json' : 'image/png'
52
+ return new Blob([new Uint8Array(bytes)], { type })
53
+ }
54
+
55
+ export const engineDefaults: Settings = {
56
+ seed: 0,
57
+ qrMargin: 1,
58
+ plateCorners: 'texture',
59
+ regionMargin: false,
60
+ rimModules: 1,
61
+ rimRounded: false,
62
+ ecc: 'M',
63
+ pixelStyle: 'dot',
64
+ finderMarkers: {
65
+ tl: { style: 'rounded', shape: 'circle', inner: 'circle' },
66
+ tr: { style: 'rounded', shape: 'circle', inner: 'circle' },
67
+ bl: { style: 'rounded', shape: 'circle', inner: 'circle' },
68
+ },
69
+ markerSub: 'square',
70
+ colors: { ...DEFAULT_PALETTE },
71
+ }
72
+
73
+ /** Client-side replacement for the old sharp-metadata `readImage`. */
74
+ export { normalizeQr }
75
+
76
+ export async function readImage(imaging: Imaging, bytes: Uint8Array, name: string): Promise<LoadedPng> {
77
+ parsePngHeader(bytes, name) // signature, IHDR, acTL, byte and megapixel limits
78
+ try {
79
+ return await decodePng(bytes, `${name}.png`, name)
80
+ } catch {
81
+ throw new QrPosterError('INVALID_INPUT', `Use a single-frame PNG with at most ${4_000_000 / 1e6} megapixels.`)
82
+ }
83
+ }
84
+
85
+ /** Same whole-square constraint and module geometry as assembly; no raster rendering needed. */
86
+ export function validatePlacement({
87
+ regionMask,
88
+ qrMetadata,
89
+ placement,
90
+ settings,
91
+ }: {
92
+ regionMask: RegionMask
93
+ qrMetadata: QrMetadata
94
+ placement: Placement
95
+ settings: Settings
96
+ }) {
97
+ const p = placeQr(regionMask, qrMetadata.totalModules, placement)
98
+ const pitch = p.modulePixels
99
+ /**
100
+ * A rotated placement is validated in the QR's upright frame: the region mask is sampled into
101
+ * the working frame, and the lattice, safe area, rim, upright plate, and pattern version all
102
+ * see that size (doc/plan/rotated-mask-fill.md). The upright path keeps the poster canvas.
103
+ */
104
+ const rotated = p.rotation !== 0
105
+ const frame = rotated ? qrWorkingFrame(p, regionPixelBounds(regionMask)) : undefined
106
+ const canvasWidth = frame?.width ?? regionMask.width
107
+ const canvasHeight = frame?.height ?? regionMask.height
108
+ if (frame) assertFrameHoldsPlacement(frame, p, regionMask.width, regionMask.height)
109
+ const origin = frame?.qr ?? { x: p.x, y: p.y }
110
+ const mask = frame
111
+ ? sampleMaskIntoQrFrame(regionMask.data, regionMask.width, regionMask.height, frame, p)
112
+ : regionMask.data
113
+ selectPatternVersion(pitch, canvasWidth, canvasHeight, pitch)
114
+ const lattice = buildModuleLattice(canvasWidth, canvasHeight, pitch, origin)
115
+ const safe = computeSafeArea(mask, canvasWidth, canvasHeight, lattice)
116
+ const { margin, rim } = computeRegionBands(safe.safe, lattice, settings.rimModules, settings.regionMargin)
117
+ const grid = {
118
+ x: origin.x + 2 * pitch,
119
+ y: origin.y + 2 * pitch,
120
+ width: qrMetadata.qrModules * pitch,
121
+ height: qrMetadata.qrModules * pitch,
122
+ }
123
+ const { arms, cornerBlocks } = markerBandRects(grid, qrMetadata.qrModules, pitch, settings.qrMargin)
124
+ const corners = settings.plateCorners === 'texture'
125
+ // Generation is upright, so rotated placements keep the 0° plate semantics: code grid plus
126
+ // finder arms, with the corner hand-back applied in the QR's frame instead of being skipped.
127
+ const plate = computePlateModules(
128
+ lattice,
129
+ [grid, ...arms, ...(corners ? [] : cornerBlocks)],
130
+ corners ? cornerBlocks : [],
131
+ )
132
+ const quietZone = computeTightBlockQuietZone(
133
+ mask,
134
+ canvasWidth,
135
+ canvasHeight,
136
+ safe.safe,
137
+ plate.cells,
138
+ lattice,
139
+ { x: origin.x, y: origin.y, size: p.size },
140
+ settings.rimModules === 0,
141
+ )
142
+ const hasDecorativeTexture = safe.safe.some(
143
+ (cell, i) => cell && !margin[i] && !rim[i] && !plate.cells[i] && !quietZone.cells[i],
144
+ )
145
+ if (!hasDecorativeTexture && quietZone.modules === 0)
146
+ throw new QrPosterError(
147
+ 'QR_LAYOUT_INVALID',
148
+ 'No decorative texture fits beside this QR. Reduce its size or choose a larger region.',
149
+ )
150
+ return p
151
+ }
152
+
153
+ /* ------------------------------ cached stages ------------------------------ */
154
+
155
+ /** The decoded poster and its region: the expensive, source-bound half of resolveBuffers. */
156
+ export interface PreparedSource {
157
+ poster: LoadedPng
158
+ maskInput?: LoadedPng
159
+ regionMask: RegionMask
160
+ }
161
+
162
+ export async function prepareSource(
163
+ imaging: Imaging,
164
+ posterBytes: Uint8Array,
165
+ maskBytes?: Uint8Array,
166
+ ): Promise<PreparedSource> {
167
+ const poster = await readImage(imaging, posterBytes, 'poster')
168
+ const maskInput = maskBytes ? await readImage(imaging, maskBytes, 'mask') : undefined
169
+ const regionMask = maskInput
170
+ ? buildManualRegionMask(maskInput, poster.width, poster.height)
171
+ : detectRegionMask(poster)
172
+ return {
173
+ poster,
174
+ ...(maskInput ? { maskInput } : {}),
175
+ regionMask,
176
+ }
177
+ }
178
+
179
+ /** The content-bound half: the generated QR, its decode round trip, and metadata. */
180
+ export interface QrBundle {
181
+ qrSource: LoadedPng
182
+ decoded: ResolvedLayout['decoded']
183
+ qrMetadata: QrMetadata
184
+ }
185
+
186
+ export async function resolveQr(imaging: Imaging, input: EngineInput): Promise<QrBundle> {
187
+ contentSchema.parse(input.content)
188
+ const settings = { ...engineDefaults, ...input.settings }
189
+ const palette: QrPalette = { ...engineDefaults.colors, ...settings.colors }
190
+ const guard = paletteGuard(palette)
191
+ if (!guard.ok)
192
+ throw new QrPosterError(
193
+ 'COLOR_INVALID',
194
+ `Invalid pattern colors: ${guard.issues.map((issue) => issue.message).join(' ')}`,
195
+ )
196
+ const generated = await generateQrFromContent(
197
+ input.content,
198
+ settings.ecc,
199
+ settings.pixelStyle,
200
+ undefined,
201
+ undefined,
202
+ undefined,
203
+ settings.markerSub,
204
+ settings.finderMarkers,
205
+ palette,
206
+ )
207
+ const qrSource = generated.image
208
+ const decoded = {
209
+ ...decodeQrRawDetailed(qrSource.data, qrSource.width, qrSource.height),
210
+ version: generated.version,
211
+ }
212
+ if (decoded.text !== input.content)
213
+ throw new QrPosterError('QR_TEXT_MISMATCH', 'Generated QR did not preserve the entered text.')
214
+ const qrMetadata = inspectQrSource(qrSource, input.content, generated.version)
215
+ return { qrSource, decoded, qrMetadata }
216
+ }
217
+
218
+ /** Verbatim placement/recentering/fit logic from the former `resolveBuffers`. */
219
+ export function applyPlacement({
220
+ source,
221
+ qr,
222
+ input,
223
+ settings,
224
+ unchecked = false,
225
+ }: {
226
+ source: PreparedSource
227
+ qr: QrBundle
228
+ input: Pick<EngineInput, 'placement' | 'previousTotalModules'>
229
+ settings: Settings
230
+ unchecked?: boolean
231
+ }): { layout: ResolvedLayout; validation: string | null; bestPlacement: Placement } {
232
+ const { regionMask } = source
233
+ const qrMetadata = qr.qrMetadata
234
+ const initialPlacement = suggestPlacement(regionMask, qrMetadata.totalModules)
235
+ const previous = input.placement
236
+ let requested = previous
237
+ if (previous && input.previousTotalModules && input.previousTotalModules !== qrMetadata.totalModules) {
238
+ requested = recenterPlacement(previous, input.previousTotalModules, qrMetadata.totalModules)
239
+ }
240
+ let validation: string | null = null
241
+ let placement
242
+ if (requested && unchecked) {
243
+ placement = requested
244
+ } else if (requested) {
245
+ try {
246
+ placement = validatePlacement({ regionMask, qrMetadata, placement: requested, settings })
247
+ } catch (e) {
248
+ validation = e instanceof Error ? e.message : 'Invalid placement.'
249
+ placement = {
250
+ ...requested,
251
+ modulePixels: requested.size / qrMetadata.totalModules,
252
+ totalModules: qrMetadata.totalModules,
253
+ mode: 'manual' as const,
254
+ artPaddingModules: 0,
255
+ }
256
+ }
257
+ } else if (unchecked) {
258
+ placement = {
259
+ ...initialPlacement,
260
+ modulePixels: initialPlacement.size / qrMetadata.totalModules,
261
+ totalModules: qrMetadata.totalModules,
262
+ mode: 'auto' as const,
263
+ artPaddingModules: 0,
264
+ }
265
+ } else {
266
+ const largest = placeQr(regionMask, qrMetadata.totalModules)
267
+ for (let pitch = largest.modulePixels; pitch >= 4; pitch--) {
268
+ try {
269
+ const size = qrMetadata.totalModules * pitch
270
+ const point = findClosestSquare(regionMask, size)
271
+ placement = validatePlacement({ regionMask, qrMetadata, placement: { ...point, size, rotation: 0 }, settings })
272
+ placement.mode = 'auto'
273
+ break
274
+ } catch (e) {
275
+ if (!(e instanceof QrPosterError)) throw e
276
+ }
277
+ }
278
+ if (!placement)
279
+ throw new QrPosterError(
280
+ 'QR_LAYOUT_INVALID',
281
+ 'This region cannot fit the QR and decorative texture. Use shorter text or a larger black region.',
282
+ )
283
+ // Auto-place searches only upright placements.
284
+ placement.rotation = 0
285
+ }
286
+ const bestPlacement = findBestInsetPlacement(regionMask, qrMetadata.totalModules, placement)
287
+ const normalizedQr = validation ? qr.qrSource.file : undefined
288
+ return {
289
+ layout: {
290
+ poster: source.poster,
291
+ qrSource: qr.qrSource,
292
+ qrImage: qr.qrSource,
293
+ ...(source.maskInput ? { maskInput: source.maskInput } : {}),
294
+ regionMask,
295
+ decoded: qr.decoded,
296
+ qrMetadata,
297
+ placement,
298
+ // Filled by the caller after any async normalize step.
299
+ ...(normalizedQr !== undefined ? { normalizedQr } : {}),
300
+ } as ResolvedLayout,
301
+ validation,
302
+ bestPlacement,
303
+ }
304
+ }
305
+
306
+ function findBestInsetPlacement(regionMask: RegionMask, totalModules: number, fallback: Placement): Placement {
307
+ try {
308
+ const placement = placeQr(regionMask, totalModules)
309
+ return { x: placement.x, y: placement.y, size: placement.size, rotation: placement.rotation }
310
+ } catch (error) {
311
+ if (error instanceof QrPosterError) {
312
+ return {
313
+ ...fallback,
314
+ x: Math.round(regionMask.centroid.x - fallback.size / 2),
315
+ y: Math.round(regionMask.centroid.y - fallback.size / 2),
316
+ }
317
+ }
318
+ throw error
319
+ }
320
+ }
321
+
322
+ function suggestPlacement(regionMask: RegionMask, totalModules: number): Placement {
323
+ const bounds = regionMask.bounds
324
+ const pitch = Math.max(4, Math.floor(Math.min(bounds.width, bounds.height) / totalModules))
325
+ const size = pitch * totalModules
326
+ return {
327
+ x: Math.round(bounds.x + (bounds.width - size) / 2),
328
+ y: Math.round(bounds.y + (bounds.height - size) / 2),
329
+ size,
330
+ rotation: 0,
331
+ }
332
+ }
333
+
334
+ /** Straight composition of the three stages — the uncached path, used by tests and scripts. */
335
+ export async function resolveBuffers(imaging: Imaging, input: EngineInput) {
336
+ const source = await prepareSource(imaging, input.posterBytes, input.maskBytes)
337
+ const qr = await resolveQr(imaging, input)
338
+ const settings = { ...engineDefaults, ...input.settings }
339
+ const { layout, validation } = applyPlacement({ source, qr, input, settings })
340
+ if (!validation) layout.normalizedQr = await normalizeQr(qr.qrSource, layout.placement.size)
341
+ return { layout, validation }
342
+ }
343
+
344
+ export async function toPreparedPayload(
345
+ imaging: Imaging,
346
+ layout: ResolvedLayout,
347
+ validation: string | null,
348
+ palette: QrPalette = DEFAULT_PALETTE,
349
+ bestPlacement: Placement = layout.placement,
350
+ ): Promise<PreparedPayload> {
351
+ const { poster, regionMask, qrMetadata, placement } = layout
352
+ const overlay = new Uint8Array(poster.width * poster.height * 4)
353
+ for (let i = 0; i < regionMask.data.length; i++) if (regionMask.data[i]) overlay.set([75, 224, 182, 95], i * 4)
354
+ return {
355
+ width: poster.width,
356
+ height: poster.height,
357
+ mask: bytesToBlob('mask.png', await renderRegionMask(regionMask)),
358
+ overlay: bytesToBlob('overlay.png', await rgbaToPng(overlay, poster.width, poster.height)),
359
+ // The upright reusable QR preview is independent of its editable placement size.
360
+ qr: bytesToBlob('qr.png', await transparentQrBackground(layout.qrSource.file, palette)),
361
+ qrMetadata: { totalModules: qrMetadata.totalModules, version: qrMetadata.version },
362
+ placement: { x: placement.x, y: placement.y, size: placement.size, rotation: placement.rotation },
363
+ bestPlacement,
364
+ validation,
365
+ }
366
+ }
367
+
368
+ /** Identical request merge and verification gate as the former `assembleFromBuffers`. */
369
+ export async function assemblePayload(
370
+ imaging: Imaging,
371
+ input: EngineInput & {
372
+ placement: Placement
373
+ seed: number
374
+ qrMargin: 1
375
+ plateCorners: Settings['plateCorners']
376
+ regionMargin?: boolean
377
+ rimModules?: number
378
+ rimRounded?: boolean
379
+ ecc?: Settings['ecc']
380
+ pixelStyle?: Settings['pixelStyle']
381
+ finderMarkers?: Settings['finderMarkers']
382
+ markerSub?: Settings['markerSub']
383
+ colors?: Settings['colors']
384
+ },
385
+ ): Promise<AssemblePayload> {
386
+ const flat = {
387
+ seed: input.seed,
388
+ qrMargin: input.qrMargin,
389
+ plateCorners: input.plateCorners,
390
+ regionMargin: input.regionMargin ?? input.settings?.regionMargin ?? engineDefaults.regionMargin,
391
+ rimModules: input.rimModules ?? input.settings?.rimModules ?? engineDefaults.rimModules,
392
+ rimRounded: input.rimRounded ?? input.settings?.rimRounded ?? engineDefaults.rimRounded,
393
+ ecc: input.ecc ?? input.settings?.ecc ?? engineDefaults.ecc,
394
+ pixelStyle: input.pixelStyle ?? input.settings?.pixelStyle ?? engineDefaults.pixelStyle,
395
+ finderMarkers: input.finderMarkers ?? input.settings?.finderMarkers ?? engineDefaults.finderMarkers,
396
+ markerSub: input.markerSub ?? input.settings?.markerSub ?? engineDefaults.markerSub,
397
+ colors: input.colors ?? input.settings?.colors ?? engineDefaults.colors,
398
+ }
399
+ const { layout, validation } = await resolveBuffers(imaging, {
400
+ ...input,
401
+ previousTotalModules: undefined,
402
+ settings: flat,
403
+ })
404
+ if (validation) throw new QrPosterError('QR_LAYOUT_INVALID', validation)
405
+ const result = await assembleResolved(layout, {
406
+ seed: flat.seed,
407
+ qrMargin: flat.qrMargin,
408
+ radius: flat.plateCorners === 'light' ? 0 : layout.placement.modulePixels * 2,
409
+ regionMargin: flat.regionMargin,
410
+ rimModules: flat.rimModules,
411
+ rimRounded: flat.rimRounded,
412
+ pixelStyle: flat.pixelStyle,
413
+ transparentBlank: input.transparentBlank ?? false,
414
+ palette: flat.colors,
415
+ })
416
+ if (!result.report.qualified)
417
+ throw new QrPosterError(
418
+ 'VERIFICATION_FAILED',
419
+ `Pixel verification failed (${result.report.verification.checks
420
+ .filter((c) => !c.passed)
421
+ .map((c) => c.name)
422
+ .join(', ')}). Adjust the placement and try again.`,
423
+ 4,
424
+ )
425
+ const artifacts = Object.fromEntries(
426
+ Object.entries(result.artifacts).map(([name, bytes]) => [name, bytesToBlob(name, bytes)]),
427
+ )
428
+ artifacts['poster.svg'] = bytesToBlob(
429
+ 'poster.svg',
430
+ new TextEncoder().encode(
431
+ buildPosterSvg(result.artifacts['poster.png']!, layout.poster.width, layout.poster.height),
432
+ ),
433
+ )
434
+ return {
435
+ report: result.report as AssembleReport,
436
+ artifacts,
437
+ }
438
+ }
439
+
440
+ export async function assembleRasterVariant(
441
+ imaging: Imaging,
442
+ original: ResolvedLayout,
443
+ settings: Settings,
444
+ originalPlacement: QrPlacement,
445
+ targetPitch: number,
446
+ transparentBlank: boolean,
447
+ ): Promise<RasterExportPayload> {
448
+ const originalPitch = originalPlacement.modulePixels
449
+ if (!Number.isInteger(targetPitch) || targetPitch < 4 || targetPitch >= originalPitch)
450
+ throw new QrPosterError('INVALID_INPUT', 'Choose a smaller export with modules of at least 4px.')
451
+
452
+ const dimensions = dimensionsAtPitch(original.poster.width, original.poster.height, originalPitch, targetPitch)
453
+ const regionMask = resizeRegionMaskConservative(original.regionMask, dimensions.width, dimensions.height)
454
+ const scale = targetPitch / originalPitch
455
+ const size = original.qrMetadata.totalModules * targetPitch
456
+ const requested = {
457
+ x: Math.round((originalPlacement.x + originalPlacement.size / 2) * scale - size / 2),
458
+ y: Math.round((originalPlacement.y + originalPlacement.size / 2) * scale - size / 2),
459
+ size,
460
+ rotation: originalPlacement.rotation,
461
+ }
462
+ const placement = validatePlacement({ regionMask, qrMetadata: original.qrMetadata, placement: requested, settings })
463
+ const posterData = resizeAreaPremultiplied(original.poster, dimensions.width, dimensions.height)
464
+ const posterFile = await imaging.encodePngRgba(posterData, dimensions.width, dimensions.height)
465
+ const poster = {
466
+ path: `poster-${dimensions.width}x${dimensions.height}.png`,
467
+ file: posterFile,
468
+ data: posterData,
469
+ ...dimensions,
470
+ sha256: await imaging.sha256Hex(posterFile),
471
+ }
472
+ const normalizedQr = await normalizeQr(original.qrSource, size)
473
+ const { maskInput: _maskInput, ...withoutMaskInput } = original
474
+ const layout: ResolvedLayout = {
475
+ ...withoutMaskInput,
476
+ poster,
477
+ regionMask,
478
+ placement,
479
+ normalizedQr,
480
+ }
481
+ const result = await assembleResolved(layout, {
482
+ seed: settings.seed,
483
+ qrMargin: settings.qrMargin,
484
+ radius: settings.plateCorners === 'light' ? 0 : targetPitch * 2,
485
+ regionMargin: settings.regionMargin,
486
+ rimModules: settings.rimModules,
487
+ rimRounded: settings.rimRounded,
488
+ pixelStyle: settings.pixelStyle,
489
+ transparentBlank,
490
+ palette: settings.colors,
491
+ })
492
+ if (!result.report.qualified)
493
+ throw new QrPosterError('VERIFICATION_FAILED', 'This raster size did not pass pixel verification.', 4)
494
+ const png = result.artifacts['poster.png']!
495
+ return {
496
+ poster: bytesToBlob(`poster-${dimensions.width}x${dimensions.height}.png`, png),
497
+ ...dimensions,
498
+ modulePixels: targetPitch,
499
+ bytes: png.byteLength,
500
+ }
501
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Engine contracts shared by the Node test harness and the Comlink worker.
3
+ * Engine operations never throw across an API boundary: they settle to a
4
+ * discriminated {@link EngineOutcome}, so every consumer (the editor store,
5
+ * worker client, tests) handles errors and stale runs the same way.
6
+ */
7
+
8
+ import type { Placement, Settings } from '../schema'
9
+
10
+ /** An editor-facing error: the exact codes/fields the old HTTP pipeline produced. */
11
+ export interface EngineError {
12
+ code: string
13
+ message: string
14
+ /** Editor field the error maps to (`content`, `poster`, `mask`, `placement`). */
15
+ field?: string | undefined
16
+ }
17
+
18
+ /** Engine input: source bytes plus the current editor revision. */
19
+ export interface EngineInput {
20
+ posterBytes: Uint8Array
21
+ transparentBlank?: boolean
22
+ content: string
23
+ maskBytes?: Uint8Array
24
+ placement?: Placement
25
+ previousTotalModules?: number | undefined
26
+ settings?: Partial<Settings>
27
+ }
28
+
29
+ /** The step-2 payload: preview artifacts as Blobs (built in the worker; Blobs cross postMessage by reference). */
30
+ export interface PreparedPayload {
31
+ width: number
32
+ height: number
33
+ mask: Blob
34
+ overlay: Blob
35
+ qr: Blob
36
+ qrMetadata: { totalModules: number; version: number }
37
+ placement: Placement
38
+ bestPlacement: Placement
39
+ validation: string | null
40
+ }
41
+
42
+ /** The step-4 payload mirrors the schema-8 envelope: report plus Blob artifacts. */
43
+ export interface AssemblePayload {
44
+ report: import('../core/types').AssembleReport
45
+ artifacts: Record<string, Blob>
46
+ recipe?: Blob
47
+ recipeError?: string
48
+ }
49
+
50
+ export interface RasterExportPayload {
51
+ poster: Blob
52
+ width: number
53
+ height: number
54
+ modulePixels: number
55
+ bytes: number
56
+ }
57
+
58
+ export type EngineOutcome<T> =
59
+ | { ok: true; revision: number; value: T }
60
+ | { ok: false; stale: true; revision: number }
61
+ | { ok: false; error: EngineError; revision: number }
package/src/node.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { nodeImaging } from './core/imaging/node'
2
+ import { Buffer } from 'node:buffer'
3
+ import { MAX_RECIPE_BYTES, renderRecipe, type PortableRecipe } from './recipe'
4
+
5
+ export type RecipeArtifact = 'poster.png' | 'qr.png'
6
+
7
+ export interface RenderRecipeOptions {
8
+ artifact?: RecipeArtifact
9
+ }
10
+
11
+ export interface RenderRecipeResult {
12
+ png: Uint8Array
13
+ filename: RecipeArtifact
14
+ }
15
+
16
+ /** Render an already parsed editor recipe with the Node.js imaging backend. */
17
+ export async function renderRecipeJson(
18
+ rawRecipe: unknown,
19
+ options: RenderRecipeOptions = {},
20
+ ): Promise<RenderRecipeResult> {
21
+ const artifact = options.artifact ?? 'poster.png'
22
+ const result = await renderRecipe(nodeImaging, rawRecipe, artifact)
23
+ return { png: new Uint8Array(await result.png.arrayBuffer()), filename: result.filename as RecipeArtifact }
24
+ }
25
+
26
+ /**
27
+ * Parse and render UTF-8 JSON bytes/string from the editor's recipe export.
28
+ * Filesystem access stays with the caller so this entry never writes or reads paths.
29
+ */
30
+ export async function renderRecipeFile(
31
+ input: Uint8Array | string,
32
+ options: RenderRecipeOptions = {},
33
+ ): Promise<RenderRecipeResult> {
34
+ const inputBytes = typeof input === 'string' ? Buffer.byteLength(input, 'utf8') : input.byteLength
35
+ if (inputBytes > MAX_RECIPE_BYTES) {
36
+ throw new Error(`Recipe exceeds the ${MAX_RECIPE_BYTES}-byte size limit.`)
37
+ }
38
+
39
+ const bytes = typeof input === 'string' ? new TextEncoder().encode(input) : input
40
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
41
+ const rawRecipe: unknown = JSON.parse(text)
42
+ return renderRecipeJson(rawRecipe, options)
43
+ }
44
+
45
+ export type { PortableRecipe }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Client-side upload guards with the same semantics as the old server `readImage`:
3
+ * PNG signature, dimensions from IHDR, single-frame (no `acTL` animation chunks),
4
+ * and the byte/megapixel limits. Parses the header bytes only — no full decode.
5
+ */
6
+
7
+ import { MAX_IMAGE_BYTES, MAX_PIXELS } from './schema'
8
+
9
+ const PNG_SIGNATURE = Uint8Array.from([137, 80, 78, 71, 13, 10, 26, 10])
10
+ const TYPE_IHDR = Uint8Array.from([73, 72, 68, 82])
11
+ const TYPE_ACTL = Uint8Array.from([97, 99, 84, 76])
12
+
13
+ export type GuardCode = 'PNG_INVALID' | 'UPLOAD_LIMIT'
14
+
15
+ /** Thrown with the exact codes the editor UI maps to field errors. */
16
+ export class PngGuardError extends Error {
17
+ readonly code: GuardCode
18
+ readonly field: string
19
+
20
+ constructor(code: GuardCode, message: string, field: string) {
21
+ super(message)
22
+ this.name = 'PngGuardError'
23
+ this.code = code
24
+ this.field = field
25
+ }
26
+ }
27
+
28
+ export interface PngHeaderInfo {
29
+ width: number
30
+ height: number
31
+ }
32
+
33
+ function matches(bytes: Uint8Array, offset: number, expected: Uint8Array): boolean {
34
+ for (let index = 0; index < expected.length; index++) if (bytes[offset + index] !== expected[index]) return false
35
+ return true
36
+ }
37
+
38
+ export function parsePngHeader(bytes: Uint8Array, field: string): PngHeaderInfo {
39
+ if (bytes.length > MAX_IMAGE_BYTES)
40
+ throw new PngGuardError('UPLOAD_LIMIT', 'Each PNG must be 10 MiB or smaller.', field)
41
+ if (bytes.length < 33 || !matches(bytes, 0, PNG_SIGNATURE))
42
+ throw new PngGuardError('PNG_INVALID', 'Choose a valid PNG image.', field)
43
+ if (!matches(bytes, 12, TYPE_IHDR)) throw new PngGuardError('PNG_INVALID', 'Choose a valid PNG image.', field)
44
+
45
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
46
+ const width = view.getUint32(16)
47
+ const height = view.getUint32(20) // IHDR data starts at byte 16: width then height
48
+ if (width === 0 || height === 0) throw new PngGuardError('PNG_INVALID', 'Choose a valid PNG image.', field)
49
+ if (width * height > MAX_PIXELS)
50
+ throw new PngGuardError('PNG_INVALID', `Use a single-frame PNG with at most ${MAX_PIXELS / 1e6} megapixels.`, field)
51
+
52
+ // An acTL chunk marks the file as animated (APNG); parity with the old `pages !== 1` check.
53
+ let offset = 8
54
+ while (offset + 8 <= bytes.length) {
55
+ const length = view.getUint32(offset)
56
+ if (offset + 12 + length > bytes.length) break
57
+ if (matches(bytes, offset + 4, TYPE_ACTL))
58
+ throw new PngGuardError(
59
+ 'PNG_INVALID',
60
+ `Use a single-frame PNG with at most ${MAX_PIXELS / 1e6} megapixels.`,
61
+ field,
62
+ )
63
+ offset += 12 + length // length + type + data + CRC
64
+ }
65
+ return { width, height }
66
+ }