@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
package/src/recipe.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type { Imaging } from './core/imaging/types'
|
|
3
|
+
import { assemblePayload } from './engine/pipeline'
|
|
4
|
+
import type { AssembleInput } from './engine/index'
|
|
5
|
+
import { parsePngHeader } from './png-guard'
|
|
6
|
+
import { MAX_IMAGE_BYTES, MAX_PIXELS, contentSchema, placementSchema, settingsSchema } from './schema'
|
|
7
|
+
import type { Settings } from './schema'
|
|
8
|
+
import { engineDefaults } from './engine/pipeline'
|
|
9
|
+
|
|
10
|
+
export const RECIPE_FORMAT = 'mahu-qr-recipe'
|
|
11
|
+
export const RECIPE_SCHEMA_VERSION = 1
|
|
12
|
+
export const RECIPE_RENDERER_VERSION = 'mahu-qr-renderer-1'
|
|
13
|
+
export const MAX_RECIPE_BYTES = 28 * 1024 * 1024
|
|
14
|
+
|
|
15
|
+
const MAX_BASE64_IMAGE_CHARS = Math.ceil(MAX_IMAGE_BYTES / 3) * 4
|
|
16
|
+
const digestSchema = z.string().regex(/^[0-9a-f]{64}$/)
|
|
17
|
+
const embeddedPngSchema = z
|
|
18
|
+
.object({
|
|
19
|
+
mimeType: z.literal('image/png'),
|
|
20
|
+
encoding: z.literal('base64'),
|
|
21
|
+
sha256: digestSchema,
|
|
22
|
+
data: z.string().min(4).max(MAX_BASE64_IMAGE_CHARS),
|
|
23
|
+
})
|
|
24
|
+
.strict()
|
|
25
|
+
|
|
26
|
+
export const recipeSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
format: z.literal(RECIPE_FORMAT),
|
|
29
|
+
schemaVersion: z.literal(RECIPE_SCHEMA_VERSION),
|
|
30
|
+
rendererVersion: z.literal(RECIPE_RENDERER_VERSION),
|
|
31
|
+
content: contentSchema,
|
|
32
|
+
source: z
|
|
33
|
+
.object({
|
|
34
|
+
poster: embeddedPngSchema,
|
|
35
|
+
regionMask: embeddedPngSchema,
|
|
36
|
+
width: z.number().int().positive().max(MAX_PIXELS),
|
|
37
|
+
height: z.number().int().positive().max(MAX_PIXELS),
|
|
38
|
+
transparentBlank: z.boolean(),
|
|
39
|
+
})
|
|
40
|
+
.strict()
|
|
41
|
+
.refine(({ width, height }) => width * height <= MAX_PIXELS, 'Image exceeds the pixel limit.'),
|
|
42
|
+
placement: placementSchema,
|
|
43
|
+
settings: settingsSchema,
|
|
44
|
+
})
|
|
45
|
+
.strict()
|
|
46
|
+
|
|
47
|
+
export type PortableRecipe = z.infer<typeof recipeSchema>
|
|
48
|
+
|
|
49
|
+
function toBase64(bytes: Uint8Array): string {
|
|
50
|
+
const blockSize = 0x6000
|
|
51
|
+
const encoded: string[] = []
|
|
52
|
+
for (let offset = 0; offset < bytes.length; offset += blockSize) {
|
|
53
|
+
const end = Math.min(offset + blockSize, bytes.length)
|
|
54
|
+
let chunk = ''
|
|
55
|
+
for (let index = offset; index < end; index++) chunk += String.fromCharCode(bytes[index]!)
|
|
56
|
+
encoded.push(btoa(chunk))
|
|
57
|
+
}
|
|
58
|
+
return encoded.join('')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function fromBase64(value: string): Uint8Array {
|
|
62
|
+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))
|
|
63
|
+
throw new Error('Recipe image data is not valid base64.')
|
|
64
|
+
const binary = atob(value)
|
|
65
|
+
if (binary.length > MAX_IMAGE_BYTES) throw new Error('Recipe PNG must be 10 MiB or smaller.')
|
|
66
|
+
const bytes = new Uint8Array(binary.length)
|
|
67
|
+
for (let index = 0; index < binary.length; index++) bytes[index] = binary.charCodeAt(index)
|
|
68
|
+
return bytes
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|
72
|
+
const digest = await crypto.subtle.digest('SHA-256', bytes.slice().buffer)
|
|
73
|
+
return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join('')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function normalizedSettings(input: AssembleInput): Settings {
|
|
77
|
+
return settingsSchema.parse({
|
|
78
|
+
...engineDefaults,
|
|
79
|
+
seed: input.seed,
|
|
80
|
+
qrMargin: input.qrMargin,
|
|
81
|
+
plateCorners: input.plateCorners,
|
|
82
|
+
regionMargin: input.regionMargin,
|
|
83
|
+
rimModules: input.rimModules,
|
|
84
|
+
rimRounded: input.rimRounded,
|
|
85
|
+
ecc: input.ecc,
|
|
86
|
+
pixelStyle: input.pixelStyle,
|
|
87
|
+
finderMarkers: input.finderMarkers,
|
|
88
|
+
markerSub: input.markerSub,
|
|
89
|
+
colors: input.colors,
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function createRecipeBlob(input: AssembleInput, finalMask: Blob): Promise<Blob> {
|
|
94
|
+
const [poster, regionMask] = await Promise.all([
|
|
95
|
+
Promise.resolve(new Uint8Array(input.posterBytes)),
|
|
96
|
+
finalMask.arrayBuffer().then((bytes) => new Uint8Array(bytes)),
|
|
97
|
+
])
|
|
98
|
+
if (poster.length > MAX_IMAGE_BYTES || regionMask.length > MAX_IMAGE_BYTES)
|
|
99
|
+
throw new Error('The source poster and final region mask must each be 10 MiB or smaller to export a recipe.')
|
|
100
|
+
const posterHeader = parsePngHeader(poster, 'poster')
|
|
101
|
+
const maskHeader = parsePngHeader(regionMask, 'mask')
|
|
102
|
+
if (posterHeader.width !== maskHeader.width || posterHeader.height !== maskHeader.height)
|
|
103
|
+
throw new Error('The final region mask must match the poster dimensions.')
|
|
104
|
+
const [posterSha, maskSha] = await Promise.all([sha256Hex(poster), sha256Hex(regionMask)])
|
|
105
|
+
const recipe = recipeSchema.parse({
|
|
106
|
+
format: RECIPE_FORMAT,
|
|
107
|
+
schemaVersion: RECIPE_SCHEMA_VERSION,
|
|
108
|
+
rendererVersion: RECIPE_RENDERER_VERSION,
|
|
109
|
+
content: input.content,
|
|
110
|
+
source: {
|
|
111
|
+
poster: { mimeType: 'image/png', encoding: 'base64', sha256: posterSha, data: toBase64(poster) },
|
|
112
|
+
regionMask: {
|
|
113
|
+
mimeType: 'image/png',
|
|
114
|
+
encoding: 'base64',
|
|
115
|
+
sha256: maskSha,
|
|
116
|
+
data: toBase64(regionMask),
|
|
117
|
+
},
|
|
118
|
+
width: posterHeader.width,
|
|
119
|
+
height: posterHeader.height,
|
|
120
|
+
transparentBlank: input.transparentBlank ?? false,
|
|
121
|
+
},
|
|
122
|
+
placement: input.placement,
|
|
123
|
+
settings: normalizedSettings(input),
|
|
124
|
+
})
|
|
125
|
+
return new Blob([JSON.stringify(recipe)], { type: 'application/json' })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function renderRecipe(
|
|
129
|
+
imaging: Imaging,
|
|
130
|
+
rawRecipe: unknown,
|
|
131
|
+
artifact: 'poster.png' | 'qr.png',
|
|
132
|
+
): Promise<{ png: Blob; filename: string }> {
|
|
133
|
+
const recipe = recipeSchema.parse(rawRecipe)
|
|
134
|
+
const poster = fromBase64(recipe.source.poster.data)
|
|
135
|
+
const regionMask = fromBase64(recipe.source.regionMask.data)
|
|
136
|
+
const [posterSha, maskSha] = await Promise.all([sha256Hex(poster), sha256Hex(regionMask)])
|
|
137
|
+
if (posterSha !== recipe.source.poster.sha256 || maskSha !== recipe.source.regionMask.sha256)
|
|
138
|
+
throw new Error('Recipe image digest does not match its bytes.')
|
|
139
|
+
const posterHeader = parsePngHeader(poster, 'poster')
|
|
140
|
+
const maskHeader = parsePngHeader(regionMask, 'mask')
|
|
141
|
+
if (
|
|
142
|
+
posterHeader.width !== recipe.source.width ||
|
|
143
|
+
posterHeader.height !== recipe.source.height ||
|
|
144
|
+
maskHeader.width !== recipe.source.width ||
|
|
145
|
+
maskHeader.height !== recipe.source.height
|
|
146
|
+
)
|
|
147
|
+
throw new Error('Recipe image dimensions do not match the declared dimensions.')
|
|
148
|
+
|
|
149
|
+
const settings = recipe.settings
|
|
150
|
+
const assembled = await assemblePayload(imaging, {
|
|
151
|
+
posterBytes: poster,
|
|
152
|
+
maskBytes: regionMask,
|
|
153
|
+
transparentBlank: recipe.source.transparentBlank,
|
|
154
|
+
content: recipe.content,
|
|
155
|
+
placement: recipe.placement,
|
|
156
|
+
...settings,
|
|
157
|
+
})
|
|
158
|
+
const png = assembled.artifacts[artifact]
|
|
159
|
+
if (!png) throw new Error(`Renderer did not produce ${artifact}.`)
|
|
160
|
+
return { png, filename: artifact }
|
|
161
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { DEFAULT_PALETTE, HEX_PATTERN } from './core/palette'
|
|
3
|
+
import { canonicalizeRotation, localPointInPlate, posterToPlatePoint, rotatedFootprintBounds } from './core/rotate'
|
|
4
|
+
|
|
5
|
+
export const MAX_IMAGE_BYTES = 10 * 1024 * 1024
|
|
6
|
+
export const MAX_PIXELS = 4_000_000
|
|
7
|
+
export const MAX_BODY_BYTES = 2 * MAX_IMAGE_BYTES + 64 * 1024
|
|
8
|
+
export const contentSchema = z
|
|
9
|
+
.string()
|
|
10
|
+
.max(8000, 'Text exceeds QR capacity.')
|
|
11
|
+
.refine((s) => s.trim().length > 0, 'Enter text or a URL.')
|
|
12
|
+
.refine((s) => !/[\r\n]/.test(s), 'Use one line only.')
|
|
13
|
+
export const rotationSchema = z.number().finite().default(0)
|
|
14
|
+
export const placementSchema = z
|
|
15
|
+
.object({
|
|
16
|
+
x: z.number().int(),
|
|
17
|
+
y: z.number().int(),
|
|
18
|
+
size: z.number().int().positive(),
|
|
19
|
+
rotation: rotationSchema,
|
|
20
|
+
})
|
|
21
|
+
.strict()
|
|
22
|
+
export const settingsSchema = z
|
|
23
|
+
.object({
|
|
24
|
+
seed: z.number().int().min(0).max(0xffffffff),
|
|
25
|
+
qrMargin: z.literal(1).default(1),
|
|
26
|
+
plateCorners: z.enum(['texture', 'light']).default('texture'),
|
|
27
|
+
regionMargin: z.boolean().default(false),
|
|
28
|
+
rimModules: z.number().int().min(0).max(5).default(1),
|
|
29
|
+
rimRounded: z.boolean().default(false),
|
|
30
|
+
ecc: z.enum(['L', 'M', 'Q', 'H']).default('M'),
|
|
31
|
+
pixelStyle: z.enum(['square', 'rounded', 'dot']).default('dot'),
|
|
32
|
+
finderMarkers: z
|
|
33
|
+
.object({
|
|
34
|
+
tl: z
|
|
35
|
+
.object({
|
|
36
|
+
style: z.enum(['square', 'rounded']).default('rounded'),
|
|
37
|
+
shape: z.enum(['square', 'circle', 'octagon', 'squircle']).default('circle'),
|
|
38
|
+
inner: z.enum(['square', 'circle', 'plus', 'diamond', 'squircle']).default('circle'),
|
|
39
|
+
})
|
|
40
|
+
.default({ style: 'rounded', shape: 'circle', inner: 'circle' }),
|
|
41
|
+
tr: z
|
|
42
|
+
.object({
|
|
43
|
+
style: z.enum(['square', 'rounded']).default('rounded'),
|
|
44
|
+
shape: z.enum(['square', 'circle', 'octagon', 'squircle']).default('circle'),
|
|
45
|
+
inner: z.enum(['square', 'circle', 'plus', 'diamond', 'squircle']).default('circle'),
|
|
46
|
+
})
|
|
47
|
+
.default({ style: 'rounded', shape: 'circle', inner: 'circle' }),
|
|
48
|
+
bl: z
|
|
49
|
+
.object({
|
|
50
|
+
style: z.enum(['square', 'rounded']).default('rounded'),
|
|
51
|
+
shape: z.enum(['square', 'circle', 'octagon', 'squircle']).default('circle'),
|
|
52
|
+
inner: z.enum(['square', 'circle', 'plus', 'diamond', 'squircle']).default('circle'),
|
|
53
|
+
})
|
|
54
|
+
.default({ style: 'rounded', shape: 'circle', inner: 'circle' }),
|
|
55
|
+
})
|
|
56
|
+
.default({
|
|
57
|
+
tl: { style: 'rounded', shape: 'circle', inner: 'circle' },
|
|
58
|
+
tr: { style: 'rounded', shape: 'circle', inner: 'circle' },
|
|
59
|
+
bl: { style: 'rounded', shape: 'circle', inner: 'circle' },
|
|
60
|
+
}),
|
|
61
|
+
markerSub: z.enum(['square', 'circle']).default('square'),
|
|
62
|
+
colors: z
|
|
63
|
+
.object({
|
|
64
|
+
pixel: z.string().regex(HEX_PATTERN),
|
|
65
|
+
marker: z.string().regex(HEX_PATTERN),
|
|
66
|
+
background: z.string().regex(HEX_PATTERN),
|
|
67
|
+
})
|
|
68
|
+
.default(DEFAULT_PALETTE),
|
|
69
|
+
})
|
|
70
|
+
.strict()
|
|
71
|
+
export const requestSchema = z
|
|
72
|
+
.object({
|
|
73
|
+
revision: z.number().int().nonnegative(),
|
|
74
|
+
content: contentSchema,
|
|
75
|
+
placement: placementSchema.optional(),
|
|
76
|
+
previousTotalModules: z.number().int().min(25).max(181).optional(),
|
|
77
|
+
settings: settingsSchema,
|
|
78
|
+
})
|
|
79
|
+
.strict()
|
|
80
|
+
export type Placement = z.infer<typeof placementSchema>
|
|
81
|
+
export type Settings = z.infer<typeof settingsSchema>
|
|
82
|
+
export type EditorRequest = z.infer<typeof requestSchema>
|
|
83
|
+
|
|
84
|
+
/** A placement under construction may omit rotation (canonicalized to 0). */
|
|
85
|
+
export type PlacementInput = z.input<typeof placementSchema>
|
|
86
|
+
|
|
87
|
+
export function canonicalPlacement(box: PlacementInput, totalModules: number): Placement {
|
|
88
|
+
// Signed origins keep off-canvas placement editable; export performs strict bounds checks.
|
|
89
|
+
return {
|
|
90
|
+
x: Math.round(box.x),
|
|
91
|
+
y: Math.round(box.y),
|
|
92
|
+
size: Math.max(4, Math.round(box.size / totalModules)) * totalModules,
|
|
93
|
+
rotation: canonicalizeRotation(box.rotation),
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function recenterPlacement(box: PlacementInput, previousModules: number, nextModules: number): Placement {
|
|
98
|
+
const size = Math.max(4, Math.round(box.size / previousModules)) * nextModules
|
|
99
|
+
const offset = (box.size - size) / 2
|
|
100
|
+
return {
|
|
101
|
+
x: Math.round(box.x + offset),
|
|
102
|
+
y: Math.round(box.y + offset),
|
|
103
|
+
size,
|
|
104
|
+
rotation: canonicalizeRotation(box.rotation),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function fitsMask(mask: Uint8Array, width: number, height: number, box: PlacementInput): boolean {
|
|
109
|
+
if (
|
|
110
|
+
![box.x, box.y, box.size].every(Number.isInteger) ||
|
|
111
|
+
box.size <= 0 ||
|
|
112
|
+
box.x < 0 ||
|
|
113
|
+
box.y < 0 ||
|
|
114
|
+
box.x + box.size > width ||
|
|
115
|
+
box.y + box.size > height
|
|
116
|
+
)
|
|
117
|
+
return false
|
|
118
|
+
const size = box.size
|
|
119
|
+
const rotation = canonicalizeRotation(box.rotation)
|
|
120
|
+
if (rotation === 0) {
|
|
121
|
+
for (let y = box.y; y < box.y + size; y++)
|
|
122
|
+
for (let x = box.x; x < box.x + size; x++) if (!mask[y * width + x]) return false
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
// Rotated square: containment is tested against the actual rotated footprint —
|
|
126
|
+
// the axis-aligned bounding box is necessary but not sufficient for irregular masks.
|
|
127
|
+
const footprint = rotatedFootprintBounds({ x: box.x, y: box.y, size, rotation }, width, height)
|
|
128
|
+
const placementBox = { x: box.x, y: box.y, size, rotation }
|
|
129
|
+
for (let y = footprint.top; y < footprint.bottom; y++) {
|
|
130
|
+
for (let x = footprint.left; x < footprint.right; x++) {
|
|
131
|
+
const local = posterToPlatePoint(x + 0.5, y + 0.5, placementBox)
|
|
132
|
+
if (!localPointInPlate(local.x, local.y, size)) continue
|
|
133
|
+
if (!mask[y * width + x]) return false
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return true
|
|
137
|
+
}
|
package/src/wasm.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dedicated workers have no `window`, but the engine's QR decoder imports
|
|
3
|
+
* ZXing, whose PDF417 tables touch `window.BigInt` at module scope. Aliasing
|
|
4
|
+
* `window` to the worker global keeps that import alive in every bundler
|
|
5
|
+
* (Turbopack shims `window` in worker output; Vite does not). Must be the
|
|
6
|
+
* first import of the worker entry, before the engine evaluates. The check
|
|
7
|
+
* reads the property off `globalThis` so minifiers cannot fold it away, and
|
|
8
|
+
* it is a no-op on the main thread, where `window` already exists.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const workerGlobal = globalThis as { window?: unknown }
|
|
12
|
+
|
|
13
|
+
if (workerGlobal.window === undefined) {
|
|
14
|
+
workerGlobal.window = workerGlobal
|
|
15
|
+
}
|