/*! * Sythos Barcode Suite * * MIT License * * Copyright (c) 2026 Sythos * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all * copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. * * SPDX-FileCopyrightText: 2026 Sythos (https://www.sythos.net) * SPDX-License-Identifier: MIT * * Original work. No code from any other barcode implementation. */ /** * Shared, format-neutral detector contracts. * * The module deliberately has no dependency on a particular matrix * implementation. A detector can supply its own matrix type through the * `TMatrix` generic while keeping the common geometry and candidate metadata. * * @module core/detection-contract */ /** A point in the source image, expressed in image coordinates. */ export type Point = { x: number; y: number; }; /** The canonical in-plane orientations supported by shared detectors. */ export type Rotation = 0 | 45 | 90 | 135 | 180 | 225 | 270 | 315; /** * Geometry recovered by a detector. * * `corners` are ordered top-left, top-right, bottom-right, bottom-left in the * source image. `matrix` is the detector's rectified or otherwise decoder-ready * representation, and is intentionally generic to keep this contract * dependency-free. */ export interface DetectionGeometry { corners: Point[]; moduleSize: number; rotation: Rotation; matrix: TMatrix; confidence?: number; } /** * Evidence collected while validating a candidate. * * Fields are optional because a 1D or 2D detector may not be able to provide * every signal. When present, `rows` is a non-negative row count and * `consistency` is a normalized value from zero to one. */ export interface ValidationQuality { quietZone?: boolean; checksum?: boolean | null; rows?: number | null; consistency?: number | null; [key: string]: unknown; } /** Optional decoded result and ranking metadata for a detection. */ export interface DetectionCandidateOptions { result?: TResult; quality?: ValidationQuality; score?: number; } /** A validated detector geometry with optional decode and quality metadata. */ export type DetectionCandidate = DetectionGeometry & DetectionCandidateOptions; function hasOwn(value: object, property: string): boolean { return Object.prototype.hasOwnProperty.call(value, property); } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function isFiniteNumber(value: unknown): value is number { return typeof value === 'number' && Number.isFinite(value); } function cross(a: Point, b: Point, c: Point): number { return (b.x - a.x) * (c.y - b.y) - (b.y - a.y) * (c.x - b.x); } /** * Normalize a detector rotation to one of the supported 45-degree turns. * * This function is intentionally strict: arbitrary angles are not silently * snapped to a nearby orientation. The accepted domain is the integer range * `0..359`, and only exact multiples of 45 degrees are canonical. * * @throws {TypeError} If `rotation` is not a number. * @throws {RangeError} If `rotation` is outside the canonical domain. */ export function normalizeRotation(rotation: number): Rotation { if (typeof rotation !== 'number') { throw new TypeError('Rotation must be a number'); } if (!Number.isFinite(rotation) || !Number.isInteger(rotation) || rotation < 0 || rotation >= 360 || rotation % 45 !== 0) { throw new RangeError('Rotation must be an integer multiple of 45 degrees in 0..359'); } return rotation as Rotation; } /** * Return whether four finite points form an ordered, non-degenerate quad. * * The required order is top-left, top-right, bottom-right, bottom-left. Both * clockwise and counter-clockwise winding are accepted; repeated points, * zero-length edges, collinear turns, concave quads, and self-intersections * are rejected. */ export function isValidCorners(corners: unknown): corners is [Point, Point, Point, Point] { if (!Array.isArray(corners) || corners.length !== 4) return false; const points: Point[] = []; for (const value of corners) { if (!isRecord(value) || !isFiniteNumber(value.x) || !isFiniteNumber(value.y)) { return false; } points.push({ x: value.x, y: value.y }); } for (let first = 0; first < points.length; first++) { for (let second = first + 1; second < points.length; second++) { if (points[first].x === points[second].x && points[first].y === points[second].y) { return false; } } } let winding = 0; for (let index = 0; index < points.length; index++) { const turn = cross(points[index], points[(index + 1) % points.length], points[(index + 2) % points.length]); if (!Number.isFinite(turn) || turn === 0) return false; const sign = Math.sign(turn); if (winding === 0) winding = sign; else if (sign !== winding) return false; } return true; } function isValidQuality(value: unknown): value is ValidationQuality { if (!isRecord(value)) return false; if (hasOwn(value, 'quietZone') && typeof value.quietZone !== 'boolean') return false; if (hasOwn(value, 'checksum') && value.checksum !== null && typeof value.checksum !== 'boolean') return false; if (hasOwn(value, 'rows') && value.rows !== null && (!isFiniteNumber(value.rows) || !Number.isInteger(value.rows) || value.rows < 0)) { return false; } if (hasOwn(value, 'consistency') && value.consistency !== null && (!isFiniteNumber(value.consistency) || value.consistency < 0 || value.consistency > 1)) { return false; } return true; } function isValidConfidence(value: unknown): value is number | undefined { return value === undefined || (isFiniteNumber(value) && value >= 0 && value <= 1); } function isValidScore(value: unknown): value is number | undefined { return value === undefined || isFiniteNumber(value); } /** * Create a validated detector candidate. * * All optional decoded-result and ranking metadata must be supplied in the * options object: `createDetectionCandidate(geometry, { result, quality, score })`. * Keeping the decoded result under the explicit `result` key avoids confusing * a result object with candidate metadata. The returned value owns a fresh * corner array and canonical rotation, and the input objects are never * mutated. * * @throws {TypeError} If the geometry, matrix, or options have an invalid type. * @throws {RangeError} If a numeric geometry or metadata value is invalid. */ export function createDetectionCandidate( geometry: DetectionGeometry, options?: DetectionCandidateOptions, ): DetectionCandidate { if (!isRecord(geometry)) { throw new TypeError('Detection geometry must be an object'); } const geometryValue = geometry as unknown as Record; const corners = geometryValue.corners; if (!isValidCorners(corners)) { throw new RangeError('Detection geometry must contain four non-degenerate corners'); } const moduleSize = geometryValue.moduleSize; if (!isFiniteNumber(moduleSize) || moduleSize <= 0) { throw new RangeError('Detection geometry moduleSize must be a positive finite number'); } const rotation = normalizeRotation(geometryValue.rotation as number); if (!hasOwn(geometryValue, 'matrix') || geometryValue.matrix === null || geometryValue.matrix === undefined) { throw new TypeError('Detection geometry must contain a matrix'); } if (!isValidConfidence(geometryValue.confidence)) { throw new RangeError('Detection geometry confidence must be a finite number in 0..1'); } const candidateOptions = options === undefined ? {} : options; if (!isRecord(candidateOptions)) { throw new TypeError('Detection candidate options must be an object'); } const validatedOptions = candidateOptions as DetectionCandidateOptions; const quality = validatedOptions.quality; if (quality !== undefined && !isValidQuality(quality)) { throw new TypeError('Detection candidate quality must be an object'); } if (!isValidScore(validatedOptions.score)) { throw new RangeError('Detection candidate score must be a finite number'); } const candidate = { ...geometry, corners: corners.map((point) => ({ x: point.x, y: point.y })), moduleSize, rotation, } as DetectionCandidate; if (hasOwn(validatedOptions, 'result') && validatedOptions.result !== undefined) candidate.result = validatedOptions.result as TResult; if (hasOwn(validatedOptions, 'quality') && quality !== undefined) candidate.quality = quality; if (hasOwn(validatedOptions, 'score') && validatedOptions.score !== undefined) candidate.score = validatedOptions.score; return candidate; }