@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,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
|
+
}
|