Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

options.mjs

v0.1.0-build.85 Latest Commit ede38f5

Size
11 KB
Lines
241
Language
javascript
SHA-256
be9f7e16e199…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1// Option validation for the CKey Web SDK. Pure JavaScript (no WebAssembly),
2// so it is unit-tested without a build. Wrong types throw TypeError and
3// out-of-range values throw RangeError; nothing is silently replaced.
4
5export const LEVELS = Object.freeze(['L', 'M', 'Q', 'H']);
6export const LAYOUTS = Object.freeze(['standard', 'print']);
7export const RASTER_POLICIES = Object.freeze(['coverage', 'binary', 'hybrid']);
8export const PRESETS = Object.freeze(['plain', 'concept']);
9export const STYLE_POLICIES = Object.freeze(['strict', 'adaptive', 'override']);
10export const MODULE_SHAPES = Object.freeze(['rounded', 'square', 'circle', 'diamond']);
11export const RING_STYLES = Object.freeze(['joined', 'dots']);
12export const RAY_CAPS = Object.freeze(['round', 'flat']);
13
14const isObj = (v) => v !== null && typeof v === 'object';
15
16function opts(o, what) {
17 if (o === undefined || o === null) return {};
18 if (!isObj(o)) throw new TypeError(`${what} options must be an object`);
19 return o;
20}
21
22function int(o, key, def, min, max) {
23 const v = o[key];
24 if (v === undefined) return def;
25 if (typeof v !== 'number' || !Number.isInteger(v)) throw new TypeError(`${key} must be an integer`);
26 if (v < min || v > max) throw new RangeError(`${key} must be ${min}..${max}, got ${v}`);
27 return v;
28}
29
30function num(o, key, def, min, max, { exclusiveMin = false } = {}) {
31 const v = o[key];
32 if (v === undefined) return def;
33 if (typeof v !== 'number' || !Number.isFinite(v)) throw new TypeError(`${key} must be a finite number`);
34 if ((exclusiveMin ? v <= min : v < min) || v > max) {
35 throw new RangeError(`${key} must be ${exclusiveMin ? '>' : '>='} ${min} and <= ${max}, got ${v}`);
36 }
37 return v;
38}
39
40function bool(o, key, def) {
41 const v = o[key];
42 if (v === undefined) return def;
43 if (typeof v !== 'boolean') throw new TypeError(`${key} must be a boolean`);
44 return v;
45}
46
47function oneOf(o, key, list, def) {
48 const v = o[key];
49 if (v === undefined) return def;
50 if (typeof v !== 'string') throw new TypeError(`${key} must be one of ${list.join(', ')}`);
51 const i = list.indexOf(key === 'level' ? v.toUpperCase() : v.toLowerCase());
52 if (i < 0) throw new RangeError(`${key} must be one of ${list.join(', ')}, got ${JSON.stringify(v)}`);
53 return i;
54}
55
56/** Payload to bytes: a string is UTF-8 encoded; bytes are copied. */
57export function payloadBytes(input) {
58 if (typeof input === 'string') return new TextEncoder().encode(input);
59 if (input instanceof ArrayBuffer) return new Uint8Array(input.slice(0));
60 if (ArrayBuffer.isView(input)) return new Uint8Array(input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength));
61 throw new TypeError('payload must be a string, ArrayBuffer or typed array');
62}
63
64/** { version 0..31 (0 = auto), level 'L'|'M'|'Q'|'H', layout 'standard'|'print' } -> numeric codes. */
65export function encodeOptions(o) {
66 o = opts(o, 'encode');
67 return {
68 version: int(o, 'version', 0, 0, 31),
69 level: oneOf(o, 'level', LEVELS, 1),
70 layout: oneOf(o, 'layout', LAYOUTS, 0),
71 };
72}
73
74/** Colour: '#rgb', '#rrggbb', [r, g, b] or { r, g, b } with 0..255 channels. */
75export function parseColour(v, key = 'colour') {
76 let c;
77 if (typeof v === 'string') {
78 const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(v.trim());
79 if (!m) throw new RangeError(`${key} must be #rgb or #rrggbb, got ${JSON.stringify(v)}`);
80 const h = m[1].length === 3 ? [...m[1]].map((x) => x + x).join('') : m[1];
81 c = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
82 } else if (Array.isArray(v) && v.length === 3) {
83 c = v;
84 } else if (isObj(v) && 'r' in v && 'g' in v && 'b' in v) {
85 c = [v.r, v.g, v.b];
86 } else {
87 throw new TypeError(`${key} must be '#rrggbb', [r, g, b] or {r, g, b}`);
88 }
89 for (const x of c) {
90 if (typeof x !== 'number' || !Number.isInteger(x) || x < 0 || x > 255) {
91 throw new RangeError(`${key} channels must be integers 0..255`);
92 }
93 }
94 return c;
95}
96
97/**
98 * Style options -> a plain description the wrapper applies to a core style.
99 * Returns null when neither a preset nor a style is given (the core default:
100 * black on white). Range limits on effects are the core style check's job,
101 * which explains any refusal.
102 *
103 * Setting `background` without `backgroundEdge` makes the background solid;
104 * setting `ink` without `inkShade` makes the ink flat.
105 */
106export function styleSpec(o) {
107 o = opts(o, 'render');
108 const hasPreset = o.preset !== undefined;
109 const preset = oneOf(o, 'preset', PRESETS, 0);
110 if (o.style === undefined || o.style === null) return hasPreset ? { preset, rgb: [], num: [], logoHref: null } : null;
111 const s = o.style;
112 if (!isObj(s)) throw new TypeError('style must be an object');
113 const known = new Set([
114 'background', 'backgroundEdge', 'ink', 'inkShade', 'accents', 'accentPercent', 'modules', 'rings', 'rays',
115 'bevel', 'shadow', 'texture', 'logoHref', 'logoRadius', 'logoPlate', 'policy',
116 ]);
117 for (const k of Object.keys(s)) if (!known.has(k)) throw new TypeError(`unknown style option ${JSON.stringify(k)}`);
118 const rgb = []; // [field, r, g, b] 0 background, 1 edge, 2 ink, 3 shade, 4..7 accents
119 const numf = []; // [field, value] see ckw_style_set_num
120 if (s.background !== undefined) {
121 const c = parseColour(s.background, 'background');
122 rgb.push([0, ...c]);
123 if (s.backgroundEdge === undefined) rgb.push([1, ...c]);
124 }
125 if (s.backgroundEdge !== undefined) rgb.push([1, ...parseColour(s.backgroundEdge, 'backgroundEdge')]);
126 if (s.ink !== undefined) {
127 const c = parseColour(s.ink, 'ink');
128 rgb.push([2, ...c]);
129 if (s.inkShade === undefined) rgb.push([3, ...c]);
130 }
131 if (s.inkShade !== undefined) rgb.push([3, ...parseColour(s.inkShade, 'inkShade')]);
132 if (s.accents !== undefined) {
133 if (!Array.isArray(s.accents) || s.accents.length > 4) throw new RangeError('accents must be an array of at most 4 colours');
134 s.accents.forEach((a, i) => rgb.push([4 + i, ...parseColour(a, `accents[${i}]`)]));
135 numf.push([0, s.accents.length]);
136 }
137 if (s.accentPercent !== undefined) numf.push([1, int(s, 'accentPercent', 0, 0, 100)]);
138 if (s.modules !== undefined) numf.push([2, oneOf(s, 'modules', MODULE_SHAPES, 0)]);
139 if (s.rings !== undefined) numf.push([3, oneOf(s, 'rings', RING_STYLES, 0)]);
140 if (s.rays !== undefined) numf.push([4, oneOf(s, 'rays', RAY_CAPS, 0)]);
141 if (s.bevel !== undefined) numf.push([5, num(s, 'bevel', 0, 0, 1)]);
142 if (s.shadow !== undefined) numf.push([6, num(s, 'shadow', 0, 0, 1)]);
143 if (s.texture !== undefined) numf.push([7, num(s, 'texture', 0, 0, 1)]);
144 if (s.logoRadius !== undefined) numf.push([8, num(s, 'logoRadius', 0, 0, 29)]);
145 if (s.logoPlate !== undefined) numf.push([9, bool(s, 'logoPlate', false) ? 1 : 0]);
146 if (s.policy !== undefined) numf.push([10, oneOf(s, 'policy', STYLE_POLICIES, 0)]);
147 let logoHref = null;
148 if (s.logoHref !== undefined && s.logoHref !== null) {
149 if (typeof s.logoHref !== 'string') throw new TypeError('logoHref must be a string');
150 logoHref = s.logoHref;
151 }
152 return { preset, rgb, num: numf, logoHref };
153}
154
155function imageLike(v, key, channels) {
156 if (!isObj(v)) throw new TypeError(`${key} must be { width, height, data }`);
157 const { width, height, data } = v;
158 if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0) {
159 throw new RangeError(`${key} width and height must be positive integers`);
160 }
161 if (!(data instanceof Uint8Array || data instanceof Uint8ClampedArray)) {
162 throw new TypeError(`${key}.data must be a Uint8Array or Uint8ClampedArray`);
163 }
164 if (data.length < width * height * channels) throw new RangeError(`${key}.data is too short for ${width}x${height}`);
165 return { width, height, data };
166}
167
168/** Raster options for renderPixels / renderToCanvas / renderPNG. */
169export function rasterOptions(o) {
170 o = opts(o, 'render');
171 return {
172 size: int(o, 'size', 512, 16, 16384),
173 policy: oneOf(o, 'policy', RASTER_POLICIES, 0),
174 transparent: bool(o, 'transparent', false),
175 supersample: int(o, 'supersample', 4, 1, 8),
176 minFeaturePx: num(o, 'minFeaturePx', 1, 0, 1024, { exclusiveMin: true }),
177 materials: bool(o, 'materials', true),
178 dpi: num(o, 'dpi', 0, 0, 100000),
179 logo: o.logo === undefined || o.logo === null ? null : imageLike(o.logo, 'logo', 4),
180 style: styleSpec(o),
181 };
182}
183
184/** SVG options: scale (px per canonical unit, 1..256, default 16) or size (px, -> scale). */
185export function svgOptions(o) {
186 o = opts(o, 'renderSVG');
187 let scale = int(o, 'scale', 16, 1, 256);
188 if (o.scale === undefined && o.size !== undefined) {
189 const size = int(o, 'size', 496, 31, 31 * 256);
190 scale = Math.max(1, Math.round(size / 31));
191 }
192 return { scale, style: styleSpec(o) };
193}
194
195/** PDF options: widthPt (points, 72 per inch; default 144 = 2 in) and transparent. */
196export function pdfOptions(o) {
197 o = opts(o, 'renderPDF');
198 return {
199 widthPt: num(o, 'widthPt', 144, 0, 14400, { exclusiveMin: true }),
200 transparent: bool(o, 'transparent', false),
201 style: styleSpec(o),
202 };
203}
204
205/** Certification options (ckey.certify). */
206export function certifyOptions(o) {
207 o = opts(o, 'certify');
208 const backdrop = o.backdrop === undefined || o.backdrop === null ? {} : o.backdrop;
209 if (!isObj(backdrop)) throw new TypeError('backdrop must be an object');
210 const colours = backdrop.colours === undefined ? [] : backdrop.colours;
211 if (!Array.isArray(colours) || colours.length > 16) throw new RangeError('backdrop.colours must be an array of at most 16 colours');
212 const model = o.model === undefined || o.model === null ? {} : o.model;
213 if (!isObj(model)) throw new TypeError('model must be an object');
214 return {
215 transparent: bool(o, 'transparent', false),
216 colours: colours.map((c, i) => parseColour(c, `backdrop.colours[${i}]`)),
217 pixels: backdrop.pixels === undefined || backdrop.pixels === null ? null : imageLike(backdrop.pixels, 'backdrop.pixels', 4),
218 pxPerModule: num(model, 'pxPerModule', -1, 0, 1000, { exclusiveMin: model.pxPerModule !== undefined }),
219 blurPx: num(model, 'blurPx', -1, 0, 100),
220 noise: num(model, 'noise', -1, 0, 1),
221 glare: num(model, 'glare', -1, 0, 0.9),
222 trials: int(model, 'trials', 0, 1, 16),
223 style: styleSpec(o),
224 };
225}
226
227/** RGBA frame for decodeImageData: ImageData or { width, height, data }. */
228export function rgbaFrame(v) {
229 return imageLike(v, 'image', 4);
230}
231
232/** 8-bit grayscale frame for decodeGray: { width, height, data } with width * height bytes. */
233export function grayFrame(v) {
234 return imageLike(v, 'image', 1);
235}
236
237/** Telemetry mode: exactly 0, 1 or 2. Anything else is a RangeError (never silently 1). */
238export function telemetryMode(v) {
239 if (v === 0 || v === 1 || v === 2) return v;
240 throw new RangeError(`telemetry must be 0 (off), 1 (on) or 2 (local only), got ${typeof v === 'string' ? JSON.stringify(v) : String(v)}`);
241}