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
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 | |
| 5 | export const LEVELS = Object.freeze(['L', 'M', 'Q', 'H']); |
| 6 | export const LAYOUTS = Object.freeze(['standard', 'print']); |
| 7 | export const RASTER_POLICIES = Object.freeze(['coverage', 'binary', 'hybrid']); |
| 8 | export const PRESETS = Object.freeze(['plain', 'concept']); |
| 9 | export const STYLE_POLICIES = Object.freeze(['strict', 'adaptive', 'override']); |
| 10 | export const MODULE_SHAPES = Object.freeze(['rounded', 'square', 'circle', 'diamond']); |
| 11 | export const RING_STYLES = Object.freeze(['joined', 'dots']); |
| 12 | export const RAY_CAPS = Object.freeze(['round', 'flat']); |
| 13 | |
| 14 | const isObj = (v) => v !== null && typeof v === 'object'; |
| 15 | |
| 16 | function 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 | |
| 22 | function 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 | |
| 30 | function 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 | |
| 40 | function 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 | |
| 47 | function 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. */ |
| 57 | export 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. */ |
| 65 | export 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. */ |
| 75 | export 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 | */ |
| 106 | export 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 | |
| 155 | function 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. */ |
| 169 | export 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). */ |
| 185 | export 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. */ |
| 196 | export 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). */ |
| 206 | export 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 }. */ |
| 228 | export 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. */ |
| 233 | export 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). */ |
| 238 | export 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 | } |