@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,316 @@
1
+ /**
2
+ * The editor engine: prepares and assembles posters with session state kept in
3
+ * memory, so an edit only re-runs what changed. Runs identically in Node
4
+ * (tests/scripts, sharp-backed imaging) and inside the browser worker (browser
5
+ * imaging) — it depends on neither platform.
6
+ *
7
+ * - Source cache: decoded poster and derived region keyed by the file sha256
8
+ * identity; a placement/seed/settings edit re-runs geometry only, a
9
+ * content/ecc/style edit regenerates the QR only, a new file re-decodes.
10
+ * - Stale suppression: latest-revision-wins. When a newer revision arrives
11
+ * while an older one is still in flight, the older one's settled result is
12
+ * dropped at the outcome boundary — no cancellation machinery is added
13
+ * around codecs or detectors.
14
+ */
15
+
16
+ import type { Imaging } from '../core/imaging/types'
17
+ import type { LoadedPng } from '../core/image'
18
+ import type { RegionMask, ResolvedLayout } from '../core/types'
19
+ import { setImaging } from '../core/imaging/index'
20
+ import type { QrBundle } from './pipeline'
21
+ import {
22
+ assembleRasterVariant,
23
+ engineDefaults,
24
+ prepareSource,
25
+ resolveQr,
26
+ applyPlacement,
27
+ toPreparedPayload,
28
+ assemblePayload,
29
+ validatePlacement,
30
+ } from './pipeline'
31
+ import { QrPosterError } from '../core/errors'
32
+ import { toEngineError } from './mapping'
33
+ import { createRecipeBlob } from '../recipe'
34
+ import type {
35
+ EngineError,
36
+ EngineInput,
37
+ EngineOutcome,
38
+ PreparedPayload,
39
+ AssemblePayload,
40
+ RasterExportPayload,
41
+ } from './types'
42
+ import type { Placement, Settings } from '../schema'
43
+
44
+ /** Assembled layout shared by prepare and assemble. */
45
+ interface EngineSourcedLayout {
46
+ layout: ResolvedLayout
47
+ validation: string | null
48
+ bestPlacement: Placement
49
+ }
50
+
51
+ /** Bounded source cache: at most this many posters stay alive per session. */
52
+ const MAX_SOURCE_ENTRIES = 4
53
+
54
+ interface SourceEntry {
55
+ poster: LoadedPng
56
+ /** Region variants derived per mask file, keyed by the mask file sha256 ('' = auto detection). */
57
+ regions: Map<string, { mask: RegionMask; maskInput?: LoadedPng }>
58
+ }
59
+
60
+ const AUTO_REGION_KEY = ''
61
+
62
+ /** The QR-shaping settings — the only settings that invalidate the generated QR bundle. */
63
+ function qrCacheKey(input: Pick<EngineInput, 'content' | 'settings'>): string {
64
+ const s = input.settings ?? {}
65
+ return [
66
+ input.content,
67
+ s.ecc ?? engineDefaults.ecc,
68
+ s.pixelStyle ?? engineDefaults.pixelStyle,
69
+ JSON.stringify(s.finderMarkers ?? engineDefaults.finderMarkers),
70
+ s.markerSub ?? engineDefaults.markerSub,
71
+ JSON.stringify(s.colors ?? engineDefaults.colors),
72
+ ].join('\u0000')
73
+ }
74
+
75
+ export class EditorEngine {
76
+ private imaging: Imaging
77
+ private sources = new Map<string, SourceEntry>()
78
+ private qrBundles = new Map<string, Promise<QrBundle>>()
79
+ private latestPrepareRevision = 0
80
+ private latestAssemblyRevision = 0
81
+
82
+ constructor(imaging: Imaging) {
83
+ // Core modules resolve the backend through the seam singleton; installing it
84
+ // here is the same injection point the parity harness and worker use.
85
+ setImaging(imaging)
86
+ this.imaging = imaging
87
+ }
88
+
89
+ /** Frees every cached source/QR bundle. */
90
+ invalidate(): void {
91
+ this.sources.clear()
92
+ this.qrBundles.clear()
93
+ this.latestPrepareRevision = 0
94
+ this.latestAssemblyRevision = 0
95
+ }
96
+
97
+ /** Step-2 payload for the current editor state. */
98
+ async prepare(input: EngineInput, revision: number): Promise<EngineOutcome<PreparedPayload>> {
99
+ if (revision > this.latestPrepareRevision) this.latestPrepareRevision = revision
100
+ try {
101
+ const resolved = await this.resolveLayout(input)
102
+ const palette = { ...engineDefaults.colors, ...input.settings?.colors }
103
+ const value = await toPreparedPayload(this.imaging, resolved.layout, null, palette, resolved.bestPlacement)
104
+ return this.settle(revision, { ok: true, value }, 'prepare')
105
+ } catch (error) {
106
+ return this.settle(revision, { ok: false, error: toEngineError(error) }, 'prepare')
107
+ }
108
+ }
109
+
110
+ /** Step-4 payload: the schema-8 report plus Blob artifacts. */
111
+ async assemble(
112
+ input: EngineInput & {
113
+ placement: Placement
114
+ seed: number
115
+ qrMargin: 1
116
+ plateCorners: Settings['plateCorners']
117
+ regionMargin?: boolean
118
+ rimModules?: number
119
+ rimRounded?: boolean
120
+ ecc?: Settings['ecc']
121
+ pixelStyle?: Settings['pixelStyle']
122
+ finderMarkers?: Settings['finderMarkers']
123
+ markerSub?: Settings['markerSub']
124
+ colors?: Settings['colors']
125
+ },
126
+ revision: number,
127
+ ): Promise<EngineOutcome<AssemblePayload>> {
128
+ if (revision > this.latestAssemblyRevision) this.latestAssemblyRevision = revision
129
+ try {
130
+ const value = await assemblePayload(this.imaging, input)
131
+ const regionMask = value.artifacts['region-mask.png']
132
+ if (!regionMask)
133
+ throw new QrPosterError('IMAGE_PROCESSING_FAILED', 'Assembly did not produce the final region mask.')
134
+ try {
135
+ value.recipe = await createRecipeBlob(input, regionMask)
136
+ } catch (error) {
137
+ value.recipeError = error instanceof Error ? error.message : 'Could not create the portable recipe.'
138
+ }
139
+ return this.settle(revision, { ok: true, value }, 'assemble')
140
+ } catch (error) {
141
+ return this.settle(revision, { ok: false, error: toEngineError(error) }, 'assemble')
142
+ }
143
+ }
144
+
145
+ /** Render the first verified smaller poster at or above the requested integer module pitch. */
146
+ async exportRaster(
147
+ input: EngineInput & {
148
+ placement: Placement
149
+ seed: number
150
+ qrMargin: 1
151
+ plateCorners: Settings['plateCorners']
152
+ regionMargin?: boolean
153
+ rimModules?: number
154
+ rimRounded?: boolean
155
+ ecc?: Settings['ecc']
156
+ pixelStyle?: Settings['pixelStyle']
157
+ finderMarkers?: Settings['finderMarkers']
158
+ markerSub?: Settings['markerSub']
159
+ colors?: Settings['colors']
160
+ },
161
+ targetPitch: number,
162
+ revision: number,
163
+ ): Promise<EngineOutcome<RasterExportPayload>> {
164
+ if (revision > this.latestAssemblyRevision) this.latestAssemblyRevision = revision
165
+ try {
166
+ const settings: Settings = {
167
+ ...engineDefaults,
168
+ seed: input.seed,
169
+ qrMargin: input.qrMargin,
170
+ plateCorners: input.plateCorners,
171
+ regionMargin: input.regionMargin ?? engineDefaults.regionMargin,
172
+ rimModules: input.rimModules ?? engineDefaults.rimModules,
173
+ rimRounded: input.rimRounded ?? engineDefaults.rimRounded,
174
+ ecc: input.ecc ?? engineDefaults.ecc,
175
+ pixelStyle: input.pixelStyle ?? engineDefaults.pixelStyle,
176
+ finderMarkers: input.finderMarkers ?? engineDefaults.finderMarkers,
177
+ markerSub: input.markerSub ?? engineDefaults.markerSub,
178
+ colors: input.colors ?? engineDefaults.colors,
179
+ }
180
+ const resolved = await this.resolveLayout({ ...input, settings })
181
+ const originalPlacement = validatePlacement({
182
+ regionMask: resolved.layout.regionMask,
183
+ qrMetadata: resolved.layout.qrMetadata,
184
+ placement: input.placement,
185
+ settings,
186
+ })
187
+ let lastLayoutError: QrPosterError | undefined
188
+ for (let pitch = Math.max(4, Math.ceil(targetPitch)); pitch < originalPlacement.modulePixels; pitch++) {
189
+ try {
190
+ const value = await assembleRasterVariant(
191
+ this.imaging,
192
+ resolved.layout,
193
+ settings,
194
+ originalPlacement,
195
+ pitch,
196
+ input.transparentBlank ?? false,
197
+ )
198
+ return this.settle(revision, { ok: true, value }, 'assemble')
199
+ } catch (error) {
200
+ if (
201
+ error instanceof QrPosterError &&
202
+ ['QR_LAYOUT_INVALID', 'MASK_INVALID', 'VERIFICATION_FAILED'].includes(error.code)
203
+ ) {
204
+ lastLayoutError = error
205
+ continue
206
+ }
207
+ throw error
208
+ }
209
+ }
210
+ throw new QrPosterError(
211
+ 'QR_LAYOUT_INVALID',
212
+ lastLayoutError?.message ?? 'The original poster is the smallest verified export for this layout.',
213
+ )
214
+ } catch (error) {
215
+ return this.settle(revision, { ok: false, error: toEngineError(error) }, 'assemble')
216
+ }
217
+ }
218
+
219
+ /** The settle-time revision check is the only stale gate: superseded runs drop here. */
220
+ private settle<T>(
221
+ revision: number,
222
+ result: { ok: true; value: T } | { ok: false; error: EngineError },
223
+ mode: 'prepare' | 'assemble',
224
+ ): EngineOutcome<T> {
225
+ if (revision < (mode === 'prepare' ? this.latestPrepareRevision : this.latestAssemblyRevision))
226
+ return { ok: false, stale: true, revision }
227
+ return { ...result, revision }
228
+ }
229
+
230
+ /** Shared prepare/assemble resolution with the source and QR caches applied. */
231
+ private async resolveLayout(input: EngineInput): Promise<EngineSourcedLayout> {
232
+ const { source } = await this.cachedSource(input.posterBytes, input.maskBytes)
233
+ const qr = await this.cachedQr(input)
234
+ const settings = { ...engineDefaults, ...input.settings }
235
+ const applied = applyPlacement({ source, qr, input, settings, unchecked: true })
236
+ return applied
237
+ }
238
+
239
+ /** Poster decode + region detection, keyed by content identity (sha256). */
240
+ private async cachedSource(
241
+ posterBytes: Uint8Array,
242
+ maskBytes?: Uint8Array,
243
+ ): Promise<{
244
+ source: { poster: LoadedPng; maskInput?: LoadedPng; regionMask: RegionMask }
245
+ posterBytes: Uint8Array
246
+ }> {
247
+ const posterSha = await this.imaging.sha256Hex(posterBytes)
248
+ const regionKey = maskBytes ? await this.imaging.sha256Hex(maskBytes) : AUTO_REGION_KEY
249
+ let entry = this.sources.get(posterSha)
250
+ if (!entry) {
251
+ // First touch: decode with the mask included so region and maskInput fill together.
252
+ const fresh = await prepareSource(this.imaging, posterBytes, maskBytes)
253
+ entry = {
254
+ poster: fresh.poster,
255
+ regions: new Map([
256
+ [
257
+ regionKey,
258
+ {
259
+ mask: fresh.regionMask,
260
+ ...(fresh.maskInput ? { maskInput: fresh.maskInput } : {}),
261
+ },
262
+ ],
263
+ ]),
264
+ }
265
+ this.sources.set(posterSha, entry)
266
+ this.evictSources()
267
+ return {
268
+ posterBytes,
269
+ source: {
270
+ poster: fresh.poster,
271
+ ...(fresh.maskInput ? { maskInput: fresh.maskInput } : {}),
272
+ regionMask: fresh.regionMask,
273
+ },
274
+ }
275
+ }
276
+ let region = entry.regions.get(regionKey)
277
+ if (!region) {
278
+ const fresh = await prepareSource(this.imaging, posterBytes, maskBytes)
279
+ region = {
280
+ mask: fresh.regionMask,
281
+ ...(fresh.maskInput ? { maskInput: fresh.maskInput } : {}),
282
+ }
283
+ entry.regions.set(regionKey, region)
284
+ }
285
+ return {
286
+ posterBytes,
287
+ source: {
288
+ poster: entry.poster,
289
+ ...(region.maskInput ? { maskInput: region.maskInput } : {}),
290
+ regionMask: region.mask,
291
+ },
292
+ }
293
+ }
294
+
295
+ private evictSources(): void {
296
+ while (this.sources.size > MAX_SOURCE_ENTRIES) {
297
+ const oldest = this.sources.keys().next().value
298
+ if (oldest === undefined) break
299
+ this.sources.delete(oldest)
300
+ }
301
+ }
302
+
303
+ /** Generated QR bundle keyed by the content and the QR-shaping settings. */
304
+ private async cachedQr(input: EngineInput): Promise<QrBundle> {
305
+ const key = qrCacheKey(input)
306
+ // Await any same-key generation already running instead of decoding twice.
307
+ const cached = this.qrBundles.get(key) ?? resolveQr(this.imaging, input)
308
+ this.qrBundles.set(key, cached)
309
+ try {
310
+ return await cached
311
+ } catch (error) {
312
+ this.qrBundles.delete(key)
313
+ throw error
314
+ }
315
+ }
316
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Engine entry point. `createEditorEngine` is the only way to build an engine:
3
+ * callers inject their environment's `Imaging` implementation (sharp in Node,
4
+ * jSquash + resvg in the browser worker), so no platform leak enters the engine.
5
+ */
6
+
7
+ import type { Imaging } from '../core/imaging/types'
8
+ import { EditorEngine } from './engine'
9
+ import type {
10
+ EngineOutcome,
11
+ PreparedPayload,
12
+ AssemblePayload,
13
+ RasterExportPayload,
14
+ EngineInput,
15
+ EngineError,
16
+ } from './types'
17
+ import type { Placement, Settings } from '../schema'
18
+
19
+ export type { EditorEngine }
20
+ export type { EngineOutcome, PreparedPayload, AssemblePayload, RasterExportPayload, EngineInput, EngineError }
21
+ export type { EngineInput as EngineRequest }
22
+
23
+ export type AssembleInput = EngineInput & {
24
+ placement: Placement
25
+ seed: number
26
+ qrMargin: 1
27
+ plateCorners: Settings['plateCorners']
28
+ regionMargin?: boolean
29
+ rimModules?: number
30
+ rimRounded?: boolean
31
+ ecc?: Settings['ecc']
32
+ pixelStyle?: Settings['pixelStyle']
33
+ finderMarkers?: Settings['finderMarkers']
34
+ markerSub?: Settings['markerSub']
35
+ colors?: Settings['colors']
36
+ }
37
+
38
+ /** The Comlink-exposed surface: every call goes through {@link EngineOutcome}. */
39
+ export interface EditorEngineApi {
40
+ prepare: (input: EngineInput, revision: number) => Promise<EngineOutcome<PreparedPayload>>
41
+ assemble: (input: AssembleInput, revision: number) => Promise<EngineOutcome<AssemblePayload>>
42
+ exportRaster: (
43
+ input: AssembleInput,
44
+ targetPitch: number,
45
+ revision: number,
46
+ ) => Promise<EngineOutcome<RasterExportPayload>>
47
+ invalidate: () => void
48
+ }
49
+
50
+ export async function createEditorEngine(imaging: Imaging): Promise<EditorEngineApi> {
51
+ const engine = new EditorEngine(imaging)
52
+ return {
53
+ prepare: (input, revision) => engine.prepare(input, revision),
54
+ assemble: (input, revision) => engine.assemble(input, revision),
55
+ exportRaster: (input, targetPitch, revision) => engine.exportRaster(input, targetPitch, revision),
56
+ invalidate: () => engine.invalidate(),
57
+ }
58
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Error mapping, moved verbatim from the former `src/server/http.ts` catch
3
+ * block (its route handler dies in phase 5). Every engine entry wraps thrown
4
+ * domain errors with the same codes and editor-field mapping, so UI error
5
+ * handling does not change when the pipeline leaves the server.
6
+ */
7
+
8
+ import { z } from 'zod'
9
+ import { QrPosterError } from '../core/errors'
10
+ import type { PngGuardError } from '../png-guard'
11
+ import type { EngineError } from './types'
12
+
13
+ export class EngineMappingError extends Error {
14
+ readonly code: string
15
+ readonly field: string | undefined
16
+
17
+ constructor(code: string, message: string, field: string | undefined) {
18
+ super(message)
19
+ this.name = 'EngineMappingError'
20
+ this.code = code
21
+ this.field = field
22
+ }
23
+ }
24
+
25
+ /** Wraps any thrown error with the editor-facing code/message/field. */
26
+ export function toEngineError(error: unknown): EngineError {
27
+ if (error instanceof EngineMappingError) return { code: error.code, message: error.message, field: error.field }
28
+ if ((error as PngGuardError | null)?.name === 'PngGuardError') {
29
+ const guard = error as PngGuardError
30
+ return { code: guard.code, message: guard.message, field: guard.field }
31
+ }
32
+ if (error instanceof z.ZodError) {
33
+ const issue = error.issues[0]!
34
+ // The engine validates content with `contentSchema.parse` directly (no request
35
+ // envelope), so a bare ZodError carries an empty path — map it to the content field.
36
+ return {
37
+ code: 'REQUEST_INVALID',
38
+ message: issue.message,
39
+ field: String(issue.path[0] ?? 'content'),
40
+ }
41
+ }
42
+ if (error instanceof QrPosterError) {
43
+ const field = error.code.startsWith('MASK')
44
+ ? 'mask'
45
+ : error.code === 'QR_LAYOUT_INVALID'
46
+ ? 'placement'
47
+ : error.code === 'COLOR_INVALID'
48
+ ? 'colors'
49
+ : error.code.startsWith('QR')
50
+ ? 'content'
51
+ : 'poster'
52
+ return {
53
+ code: error.code,
54
+ message: (
55
+ error.message +
56
+ (error.code.startsWith('MASK')
57
+ ? ' Choose a poster with a larger solid black region, or supply a same-size white-selects-region mask.'
58
+ : '')
59
+ )
60
+ .replaceAll('--qr-box', 'QR position')
61
+ .replaceAll('--content', 'Text'),
62
+ field,
63
+ }
64
+ }
65
+ return { code: 'RENDER_FAILED', message: 'Could not render this poster. Please retry.', field: 'poster' }
66
+ }