@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,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 }
|
package/src/png-guard.ts
ADDED
|
@@ -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
|
+
}
|