@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,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pattern palette: pixel (ink), marker (finder/alignment ink), and background
|
|
3
|
+
* (light) colors, plus the OKLCH machinery that derives suggestions from the pixel
|
|
4
|
+
* color and guards decoding contrast. Pure and dependency-free: UI, worker, and
|
|
5
|
+
* Node tests share the exact same numbers.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface QrPalette {
|
|
9
|
+
/** Ink of every dark module: texture cells, data modules, the rim, marker-refill bits. */
|
|
10
|
+
pixel: string
|
|
11
|
+
/** Ink of the three finder markers and the circular alignment marker. */
|
|
12
|
+
marker: string
|
|
13
|
+
/** Light modules: quiet zone, marker rings, the plate's light band, the region margin. */
|
|
14
|
+
background: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_PALETTE: QrPalette = { pixel: '#000000', marker: '#000000', background: '#ffffff' }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The mahu-QR mascot's own palette: near-black pixel ink, vermilion marker ink,
|
|
21
|
+
* white background. A brand suggestion the UI offers in one tap — never an
|
|
22
|
+
* engine default, so `DEFAULT_PALETTE` and every golden output stay untouched.
|
|
23
|
+
*/
|
|
24
|
+
export const TIGER_PRESET: QrPalette = { pixel: '#101211', marker: '#ff321e', background: '#ffffff' }
|
|
25
|
+
|
|
26
|
+
export interface Oklch {
|
|
27
|
+
/** Perceptual lightness [0, 1]. */
|
|
28
|
+
l: number
|
|
29
|
+
/** Chroma, roughly [0, 0.37] inside sRGB. */
|
|
30
|
+
c: number
|
|
31
|
+
/** Hue in degrees [0, 360). */
|
|
32
|
+
h: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Guard limits. Luma thresholds mirror qr.ts: the quiet-zone rule needs 200 and ink detection is 128. */
|
|
36
|
+
export const GUARD = {
|
|
37
|
+
/** Rec.601 luma a selectable background must stay above (quiet-zone rule needs 200; kept headroom). */
|
|
38
|
+
lightLumaMin: 205,
|
|
39
|
+
/** Rec.601 luma every ink must stay at or below (INK_LUMA_THRESHOLD is 128; kept headroom). */
|
|
40
|
+
inkLumaMax: 120,
|
|
41
|
+
/** OKLCH lightness distance each ink must keep from the background. */
|
|
42
|
+
minLightnessDistance: 0.3,
|
|
43
|
+
} as const
|
|
44
|
+
|
|
45
|
+
export const HEX_PATTERN = /^#[0-9a-fA-F]{6}$/
|
|
46
|
+
|
|
47
|
+
export type Rgb = [number, number, number]
|
|
48
|
+
|
|
49
|
+
/** Parses `#rgb`/`#rrggbb` (any case); null for anything else. */
|
|
50
|
+
export function parseHex(hex: string): Rgb | null {
|
|
51
|
+
const value = hex.trim()
|
|
52
|
+
if (!HEX_PATTERN.test(value) && !/^#[0-9a-fA-F]{3}$/.test(value)) return null
|
|
53
|
+
const body = value.slice(1)
|
|
54
|
+
const digits = body.length === 3 ? [...body].map((c) => c + c).join('') : body
|
|
55
|
+
const channels = [0, 2, 4].map((start) => Number.parseInt(digits.slice(start, start + 2), 16))
|
|
56
|
+
if (channels.some((channel) => Number.isNaN(channel))) return null
|
|
57
|
+
return [channels[0]!, channels[1]!, channels[2]!]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Lowercase six-digit form; null when the input is not a parseable hex color. */
|
|
61
|
+
export function normalizeHex(hex: string): string | null {
|
|
62
|
+
const rgb = parseHex(hex)
|
|
63
|
+
return rgb ? hexFromRgb(rgb) : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hexFromRgb([r, g, b]: Rgb): string {
|
|
67
|
+
return `#${[r, g, b].map((channel) => channel.toString(16).padStart(2, '0')).join('')}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Rec.601 luma — the same grayscale weights the QR pipeline's ink and quiet-zone rules use. */
|
|
71
|
+
export function luma([r, g, b]: Rgb): number {
|
|
72
|
+
return Math.round((299 * r + 587 * g + 114 * b) / 1000)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Convenience for renderers turning a stored hex into 0-255 channels. */
|
|
76
|
+
export function hexToRgb(hex: string): Rgb {
|
|
77
|
+
return parseHex(hex) ?? [0, 0, 0]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Björn Ottosson's OKLab matrices (linear sRGB ↔ OKLab). */
|
|
81
|
+
const LINEAR_SRGB_TO_LMS = [
|
|
82
|
+
[0.4122214708, 0.5363325363, 0.0514459929],
|
|
83
|
+
[0.2119034982, 0.6806995451, 0.1073969566],
|
|
84
|
+
[0.0883024619, 0.2817188376, 0.6299787005],
|
|
85
|
+
] as const
|
|
86
|
+
const LMS_CUBIC_TO_OKLAB = [
|
|
87
|
+
[0.2104542553, 0.793617785, -0.0040720468],
|
|
88
|
+
[1.9779984951, -2.428592205, 0.4505937099],
|
|
89
|
+
[0.0259040371, 0.7827717662, -0.808675766],
|
|
90
|
+
] as const
|
|
91
|
+
const OKLAB_TO_LMS_CUBIC_ROOTS = [
|
|
92
|
+
[1, 0.3963377774, 0.2158037573],
|
|
93
|
+
[1, -0.1055613458, -0.0638541728],
|
|
94
|
+
[1, -0.0894841775, -1.291485548],
|
|
95
|
+
] as const
|
|
96
|
+
const LMS_CUBED_TO_LINEAR_SRGB = [
|
|
97
|
+
[4.0767416621, -3.3077115913, 0.2309699292],
|
|
98
|
+
[-1.2684380046, 2.6097574011, -0.3413193965],
|
|
99
|
+
[-0.0041960863, -0.7034186147, 1.707614701],
|
|
100
|
+
] as const
|
|
101
|
+
|
|
102
|
+
function mix(row: readonly [number, number, number], values: readonly number[]): number {
|
|
103
|
+
return row[0] * values[0]! + row[1] * values[1]! + row[2] * values[2]!
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function decodeSrgb(channel: number): number {
|
|
107
|
+
const u = channel / 255
|
|
108
|
+
return u <= 0.04045 ? u / 12.92 : ((u + 0.055) / 1.055) ** 2.4
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function encodeSrgb(linear: number): number {
|
|
112
|
+
const u = linear <= 0.0031308 ? 12.92 * linear : 1.055 * linear ** (1 / 2.4) - 0.055
|
|
113
|
+
return Math.round(u * 255)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function rgbToOklch(rgb: Rgb): Oklch {
|
|
117
|
+
const linear = [rgb[0], rgb[1], rgb[2]].map(decodeSrgb)
|
|
118
|
+
const lmsCubeRoots = LINEAR_SRGB_TO_LMS.map((row) =>
|
|
119
|
+
Math.cbrt(Math.max(0, mix(row as [number, number, number], linear))),
|
|
120
|
+
)
|
|
121
|
+
const [L, a, b] = LMS_CUBIC_TO_OKLAB.map((row) => mix(row as [number, number, number], lmsCubeRoots))
|
|
122
|
+
const c = Math.sqrt(a! * a! + b! * b!)
|
|
123
|
+
const h = ((Math.atan2(b!, a!) * 180) / Math.PI + 360) % 360
|
|
124
|
+
return { l: L!, c, h }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** OKLCH → 8-bit sRGB when in gamut, null otherwise. */
|
|
128
|
+
function oklchToRgb({ l, c, h }: Oklch): Rgb | null {
|
|
129
|
+
const rad = (h * Math.PI) / 180
|
|
130
|
+
const a = Math.cos(rad) * c
|
|
131
|
+
const b = Math.sin(rad) * c
|
|
132
|
+
const cubeRoots = OKLAB_TO_LMS_CUBIC_ROOTS.map((row) => mix(row as [number, number, number], [l, a, b]))
|
|
133
|
+
const lin = cubeRoots.map((root) => root ** 3)
|
|
134
|
+
const channels = LMS_CUBED_TO_LINEAR_SRGB.map((row) => encodeSrgb(mix(row as [number, number, number], lin)))
|
|
135
|
+
return channels.some((channel) => channel < 0 || channel > 255) ? null : (channels as Rgb)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* OKLCH → sRGB, always in gamut: chroma is reduced in fixed `CHROMA_STEP` decrements
|
|
140
|
+
* until every channel lands in [0, 255], so the same OKLCH maps to the same hex.
|
|
141
|
+
*/
|
|
142
|
+
export function oklchToHex({ l, c, h }: Oklch, step = 0.005): string {
|
|
143
|
+
for (let chroma = Math.max(0, c); ; chroma = Math.max(0, chroma - step)) {
|
|
144
|
+
const rgb = oklchToRgb({ l, c: chroma, h })
|
|
145
|
+
if (rgb) return hexFromRgb(rgb)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function hexToOklch(hex: string): Oklch | null {
|
|
150
|
+
const rgb = parseHex(hex)
|
|
151
|
+
return rgb ? rgbToOklch(rgb) : null
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Suggests marker and background colors for a picked pixel color, derived in OKLCH:
|
|
156
|
+
* the background is a faint tint of the same hue near white, the marker is a tonal
|
|
157
|
+
* step of the pixel's own tone (same hue and chroma, stepped down the lightness axis)
|
|
158
|
+
* that stays decode-dark. Both always pass the palette guard.
|
|
159
|
+
*/
|
|
160
|
+
export function suggestPalette(pixel: string): { marker: string; background: string } {
|
|
161
|
+
const rgb = parseHex(pixel)
|
|
162
|
+
if (!rgb) return { marker: DEFAULT_PALETTE.marker, background: DEFAULT_PALETTE.background }
|
|
163
|
+
const { l, c, h } = rgbToOklch(rgb)
|
|
164
|
+
|
|
165
|
+
// Background: same hue, near-white, faintly tinted. Achromatic pixels stay pure white.
|
|
166
|
+
const background = c < 0.01 ? DEFAULT_PALETTE.background : tintedLight({ l: 0.97, c: Math.min(c, 0.04), h })
|
|
167
|
+
|
|
168
|
+
// Marker: a lightness step of the pixel's own tone — darker than the pixel, ink-dark.
|
|
169
|
+
let markerLightness = Math.min(l * 0.85, l)
|
|
170
|
+
let marker = oklchToHex({ l: markerLightness, c, h })
|
|
171
|
+
while (luma(hexToRgb(marker)) > GUARD.inkLumaMax && markerLightness > 0) {
|
|
172
|
+
markerLightness = Math.max(0, markerLightness - 0.05)
|
|
173
|
+
marker = oklchToHex({ l: markerLightness, c, h })
|
|
174
|
+
}
|
|
175
|
+
return { marker, background }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Halves the tint until the suggestion satisfies the background luma guard (chroma 0 always passes). */
|
|
179
|
+
function tintedLight(candidate: Oklch): string {
|
|
180
|
+
let chroma = candidate.c
|
|
181
|
+
for (;;) {
|
|
182
|
+
const hex = oklchToHex({ l: candidate.l, c: chroma, h: candidate.h })
|
|
183
|
+
if (luma(hexToRgb(hex)) >= GUARD.lightLumaMin) return hex
|
|
184
|
+
if (chroma <= 0) return DEFAULT_PALETTE.background
|
|
185
|
+
chroma = chroma / 2
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export interface PaletteIssue {
|
|
190
|
+
color: keyof QrPalette
|
|
191
|
+
message: string
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decoding-safety guard for a user-edited palette: the background must stay light
|
|
196
|
+
* (the engine's quiet-zone rule counts brightness ≥ 200 as light), every ink must
|
|
197
|
+
* stay dark, and each ink must separate from the background in OKLCH lightness.
|
|
198
|
+
* The generated QR's decode round-trip remains the authoritative gate; this reports
|
|
199
|
+
* early, before a prepare request is spent.
|
|
200
|
+
*/
|
|
201
|
+
export function paletteGuard(palette: QrPalette): { ok: boolean; issues: PaletteIssue[] } {
|
|
202
|
+
const issues: PaletteIssue[] = []
|
|
203
|
+
const keys = ['pixel', 'marker', 'background'] as const
|
|
204
|
+
const colors = new Map<keyof QrPalette, Rgb | null>()
|
|
205
|
+
for (const key of keys) {
|
|
206
|
+
const rgb = normalizeHex(palette[key]) ? parseHex(palette[key]) : null
|
|
207
|
+
colors.set(key, rgb)
|
|
208
|
+
if (!rgb) issues.push({ color: key, message: 'Use a 6-digit hex color like #0b3d91.' })
|
|
209
|
+
}
|
|
210
|
+
if (issues.length > 0) return { ok: false, issues }
|
|
211
|
+
|
|
212
|
+
const background = colors.get('background')!
|
|
213
|
+
if (luma(background) < GUARD.lightLumaMin)
|
|
214
|
+
issues.push({ color: 'background', message: 'The background is too dark to keep the QR light modules readable.' })
|
|
215
|
+
const backgroundLightness = rgbToOklch(background).l
|
|
216
|
+
for (const key of ['pixel', 'marker'] as const) {
|
|
217
|
+
const rgb = colors.get(key)!
|
|
218
|
+
if (luma(rgb) > GUARD.inkLumaMax)
|
|
219
|
+
issues.push({ color: key, message: 'This ink is too light to read as a dark module; pick a darker color.' })
|
|
220
|
+
if (Math.abs(rgbToOklch(rgb).l - backgroundLightness) < GUARD.minLightnessDistance)
|
|
221
|
+
issues.push({
|
|
222
|
+
color: key,
|
|
223
|
+
message: 'Not enough lightness separation from the background for reliable scanning.',
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
return { ok: issues.length === 0, issues }
|
|
227
|
+
}
|