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