@sythos/js_barcode_universal 1.5.3 → 1.5.5
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/README.md +26 -2
- package/bundle/sythos-barcode.esm.js +191 -17
- package/bundle/sythos-barcode.js +191 -17
- package/package.json +1 -1
- package/src/index.js +51 -5
- package/src/oned/reader.js +139 -11
package/README.md
CHANGED
|
@@ -118,8 +118,8 @@ The `unpkg` and `jsdelivr` fields point at the IIFE bundle, so a CDN needs no in
|
|
|
118
118
|
|
|
119
119
|
```html
|
|
120
120
|
<script src="https://unpkg.com/@sythos/js_barcode_universal"></script>
|
|
121
|
-
<script src="https://unpkg.com/@sythos/js_barcode_universal@1.5.
|
|
122
|
-
<script src="https://cdn.jsdelivr.net/npm/@sythos/js_barcode_universal@1.5.
|
|
121
|
+
<script src="https://unpkg.com/@sythos/js_barcode_universal@1.5.5"></script>
|
|
122
|
+
<script src="https://cdn.jsdelivr.net/npm/@sythos/js_barcode_universal@1.5.5"></script>
|
|
123
123
|
```
|
|
124
124
|
|
|
125
125
|
Pin the version for anything you ship; the unpinned form resolves to `latest` and will move under
|
|
@@ -196,6 +196,24 @@ for (const hit of decode(ctx.getImageData(0, 0, canvas.width, canvas.height))) {
|
|
|
196
196
|
outcome for a camera loop, not an error, so the common case needs no `try`/`catch`. Use
|
|
197
197
|
`decodeStrict` when absence really is a failure.
|
|
198
198
|
|
|
199
|
+
### Strict camera profile
|
|
200
|
+
|
|
201
|
+
For a live camera loop, opt into the stricter 1D policy:
|
|
202
|
+
|
|
203
|
+
```js
|
|
204
|
+
decode(frame, { formats, profile: 'camera', tryHarder: true })
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The profile requires a compatible quiet zone and the same complete 1D symbol on at least two
|
|
208
|
+
scan samples. It retries only the two quarter-turn orientations needed for 1D symbols when the
|
|
209
|
+
native orientation has no validated read. Code 11 and MSI require a verified check digit in this
|
|
210
|
+
profile; other formats retain their own structural and checksum validation. A frame without a
|
|
211
|
+
validated barcode still returns `[]`.
|
|
212
|
+
|
|
213
|
+
Camera-profile 1D results add `confidence` (0–1), `bounds`, `rotation`, and
|
|
214
|
+
`quality: { quietZone, checksum, rows, consistency }`. `bounds` is reported in the raster
|
|
215
|
+
orientation that was scanned; unavailable quality data is represented by `null` where applicable.
|
|
216
|
+
|
|
199
217
|
---
|
|
200
218
|
|
|
201
219
|
## Supported formats
|
|
@@ -407,6 +425,12 @@ the generic `ean2` and `ean5` format IDs. The image reader recognizes them only
|
|
|
407
425
|
when attached to a validated EAN/UPC parent; use the composition helpers with
|
|
408
426
|
an EAN/UPC base symbol.
|
|
409
427
|
|
|
428
|
+
EAN-2 and EAN-5 are parent-bound supplements, never standalone image results. They may be
|
|
429
|
+
listed with EAN-13, EAN-8, UPC-A, UPC-E or Bookland ISBN in `formats`: the parent remains valid
|
|
430
|
+
without a supplement, and a valid requested supplement is exposed only through `result.addon`.
|
|
431
|
+
If only `ean2` or `ean5` is requested, a validated EAN/UPC parent is still required and remains
|
|
432
|
+
the returned `format`; an absent, malformed or unrequested supplement never rejects the parent.
|
|
433
|
+
|
|
410
434
|
### GS1 DataBar
|
|
411
435
|
|
|
412
436
|
The `databar` subpath exposes original GS1 GTIN/AI codecs plus physical
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Sythos Barcode Suite v1.5.
|
|
2
|
+
* Sythos Barcode Suite v1.5.5
|
|
3
3
|
*
|
|
4
4
|
* MIT License
|
|
5
5
|
*
|
|
@@ -3907,6 +3907,79 @@ const DECODERS = [
|
|
|
3907
3907
|
['codabar', decodeCodabar],
|
|
3908
3908
|
];
|
|
3909
3909
|
|
|
3910
|
+
const EAN_PARENT_FORMATS = new Set(['ean13', 'ean8', 'upca', 'upce', 'isbn']);
|
|
3911
|
+
const EAN_SUPPLEMENT_FORMATS = new Set(['ean2', 'ean5']);
|
|
3912
|
+
|
|
3913
|
+
/** @param {string} format @returns {boolean} */
|
|
3914
|
+
function isEANParentFormat(format) {
|
|
3915
|
+
return format === 'ean13' || format === 'ean8' || format === 'upca' || format === 'upce';
|
|
3916
|
+
}
|
|
3917
|
+
|
|
3918
|
+
/**
|
|
3919
|
+
* An ISBN is printed as a Bookland EAN-13, so its decoded parent remains
|
|
3920
|
+
* `ean13` while an ISBN filter accepts only the Bookland prefixes.
|
|
3921
|
+
*
|
|
3922
|
+
* @param {{format:string, text:string}} result
|
|
3923
|
+
* @param {Set<string>} enabled
|
|
3924
|
+
* @returns {boolean}
|
|
3925
|
+
*/
|
|
3926
|
+
function isRequestedEANParent(result, enabled) {
|
|
3927
|
+
if (enabled.has(result.format)) return true;
|
|
3928
|
+
return result.format === 'ean13' && enabled.has('isbn') && /^97[89]/.test(result.text);
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3931
|
+
/** @param {object} result @returns {object} */
|
|
3932
|
+
function withoutEANAddon(result) {
|
|
3933
|
+
const { addon, ...parent } = result;
|
|
3934
|
+
void addon;
|
|
3935
|
+
return parent;
|
|
3936
|
+
}
|
|
3937
|
+
|
|
3938
|
+
/** @param {Uint8Array} row @returns {{x:number, width:number, quietZone:boolean}|null} */
|
|
3939
|
+
function cameraRowGeometry(row) {
|
|
3940
|
+
let first = 0;
|
|
3941
|
+
while (first < row.length && row[first] === 0) first++;
|
|
3942
|
+
if (first === row.length) return null;
|
|
3943
|
+
let last = row.length - 1;
|
|
3944
|
+
while (last >= 0 && row[last] === 0) last--;
|
|
3945
|
+
return {
|
|
3946
|
+
x: first,
|
|
3947
|
+
width: last - first + 1,
|
|
3948
|
+
quietZone: first >= 2 && row.length - 1 - last >= 2,
|
|
3949
|
+
};
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3952
|
+
/** @param {string} format @param {object} options @returns {boolean|null} */
|
|
3953
|
+
function checksumStatus(format, options) {
|
|
3954
|
+
if (format === 'ean13' || format === 'ean8' || format === 'upca' || format === 'upce' ||
|
|
3955
|
+
format === 'code93' || format === 'code128' || format === 'gs1128' ||
|
|
3956
|
+
format === 'gs1databar14') return true;
|
|
3957
|
+
if (format === 'code11' || format === 'msi' || format === 'code39') {
|
|
3958
|
+
return options.profile === 'camera' || options.checkDigit === true ? true : null;
|
|
3959
|
+
}
|
|
3960
|
+
return null;
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
/** @param {object} result @param {object} geometry @param {Set<number>} rows @param {object} options @returns {object} */
|
|
3964
|
+
function cameraMetadata(result, geometry, rows, options) {
|
|
3965
|
+
const checksum = checksumStatus(result.format, options);
|
|
3966
|
+
const consistency = Math.min(1, rows.size / 3);
|
|
3967
|
+
const confidence = Math.min(1, 0.4 + (geometry.quietZone ? 0.2 : 0) +
|
|
3968
|
+
(checksum === true ? 0.2 : 0) + consistency * 0.2);
|
|
3969
|
+
return {
|
|
3970
|
+
...result,
|
|
3971
|
+
confidence,
|
|
3972
|
+
bounds: {
|
|
3973
|
+
x: geometry.x,
|
|
3974
|
+
y: Math.min(...rows),
|
|
3975
|
+
width: geometry.width,
|
|
3976
|
+
height: Math.max(...rows) - Math.min(...rows) + 1,
|
|
3977
|
+
},
|
|
3978
|
+
rotation: options.cameraRotation ?? 0,
|
|
3979
|
+
quality: { quietZone: geometry.quietZone, checksum, rows: rows.size, consistency },
|
|
3980
|
+
};
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3910
3983
|
/**
|
|
3911
3984
|
* Read every linear symbol found in a binarized image.
|
|
3912
3985
|
*
|
|
@@ -3915,16 +3988,20 @@ const DECODERS = [
|
|
|
3915
3988
|
* @param {string[]} [options.formats] Restrict to these format ids.
|
|
3916
3989
|
* @param {number} [options.rows] How many horizontal slices to try.
|
|
3917
3990
|
* @param {boolean} [options.tryHarder] Also scan reversed rows, for mirrored symbols.
|
|
3991
|
+
* @param {'camera'} [options.profile] Require stable, quiet-zone-qualified reads.
|
|
3992
|
+
* @param {0|90|180|270} [options.cameraRotation] Orientation already normalized by the caller.
|
|
3918
3993
|
* @returns {Array<{format: string, text: string, row: number}>}
|
|
3919
3994
|
*/
|
|
3920
3995
|
function decodeOneD(image, options = {}) {
|
|
3921
|
-
const { formats = null, rows = 15, tryHarder = true } = options;
|
|
3996
|
+
const { formats = null, rows = 15, tryHarder = true, profile = null } = options;
|
|
3997
|
+
const cameraProfile = profile === 'camera';
|
|
3922
3998
|
const enabled = formats ? new Set(formats) : null;
|
|
3923
3999
|
const active = DECODERS.filter(([id]) => {
|
|
3924
4000
|
if (!enabled) return true;
|
|
3925
4001
|
if (enabled.has(id)) return true;
|
|
3926
4002
|
if (id === 'ean13' || id === 'ean8' || id === 'upca' || id === 'upce') {
|
|
3927
|
-
return enabled.has('ean2') || enabled.has('ean5')
|
|
4003
|
+
return enabled.has('ean2') || enabled.has('ean5') ||
|
|
4004
|
+
(id === 'ean13' && enabled.has('isbn'));
|
|
3928
4005
|
}
|
|
3929
4006
|
if (id === 'code128') return enabled.has('gs1128');
|
|
3930
4007
|
if (id === 'gs1databar14') return enabled.has('databar') || enabled.has('gs1-databar14');
|
|
@@ -3936,13 +4013,15 @@ function decodeOneD(image, options = {}) {
|
|
|
3936
4013
|
const seen = new Set();
|
|
3937
4014
|
const height = image.height;
|
|
3938
4015
|
const buffer = new Uint8Array(image.width);
|
|
4016
|
+
const cameraCandidates = new Map();
|
|
3939
4017
|
|
|
3940
4018
|
// Sample rows from the middle outward: symbols are usually centred, and the
|
|
3941
4019
|
// middle of a linear barcode is the part least likely to be clipped.
|
|
3942
4020
|
const middle = height >> 1;
|
|
3943
|
-
const
|
|
4021
|
+
const sampleRows = cameraProfile ? Math.min(height, Math.max(rows, 48)) : rows;
|
|
4022
|
+
const step = Math.max(1, Math.round(height / sampleRows));
|
|
3944
4023
|
|
|
3945
|
-
for (let attempt = 0; attempt <
|
|
4024
|
+
for (let attempt = 0; attempt < sampleRows; attempt++) {
|
|
3946
4025
|
const delta = Math.ceil(attempt / 2) * step * (attempt % 2 === 0 ? 1 : -1);
|
|
3947
4026
|
const y = middle + delta;
|
|
3948
4027
|
if (y < 0 || y >= height) continue;
|
|
@@ -3955,17 +4034,32 @@ function decodeOneD(image, options = {}) {
|
|
|
3955
4034
|
for (const [id, decoder] of active) {
|
|
3956
4035
|
let result = null;
|
|
3957
4036
|
try {
|
|
3958
|
-
|
|
4037
|
+
// Code 11 and MSI checks are optional in their base standards, but
|
|
4038
|
+
// a camera frame cannot safely promote their short unchecked forms.
|
|
4039
|
+
const decoderOptions = cameraProfile && (id === 'code11' || id === 'msi')
|
|
4040
|
+
? { ...options, checkDigit: true }
|
|
4041
|
+
: options;
|
|
4042
|
+
result = decoder(scan, decoderOptions);
|
|
3959
4043
|
} catch {
|
|
3960
4044
|
result = null; // a malformed candidate is not an error
|
|
3961
4045
|
}
|
|
3962
4046
|
if (!result) continue;
|
|
3963
4047
|
if (enabled) {
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
4048
|
+
if (isEANParentFormat(result.format)) {
|
|
4049
|
+
const baseRequested = [...EAN_PARENT_FORMATS].some((format) => enabled.has(format));
|
|
4050
|
+
const parentRequested = isRequestedEANParent(result, enabled);
|
|
4051
|
+
if (baseRequested && !parentRequested) {
|
|
4052
|
+
continue;
|
|
4053
|
+
}
|
|
4054
|
+
if (!baseRequested) {
|
|
4055
|
+
// A supplement is never an independent barcode. When it is the
|
|
4056
|
+
// only requested format, return its validated EAN/UPC parent.
|
|
4057
|
+
if (!result.addon || !EAN_SUPPLEMENT_FORMATS.has(result.addon.format) ||
|
|
4058
|
+
!enabled.has(result.addon.format)) continue;
|
|
4059
|
+
} else if (result.addon && !enabled.has(result.addon.format)) {
|
|
4060
|
+
// Supplements are optional whenever a requested parent exists.
|
|
4061
|
+
result = withoutEANAddon(result);
|
|
4062
|
+
}
|
|
3969
4063
|
} else if (result.format === 'gs1128') {
|
|
3970
4064
|
if (!enabled.has('gs1128') && !enabled.has('code128')) continue;
|
|
3971
4065
|
} else if (result.format === 'gs1databar14') {
|
|
@@ -3977,6 +4071,20 @@ function decodeOneD(image, options = {}) {
|
|
|
3977
4071
|
|
|
3978
4072
|
const addonKey = result.addon ? `:${result.addon.format}:${result.addon.text}` : '';
|
|
3979
4073
|
const key = `${result.format}:${result.text}${addonKey}`;
|
|
4074
|
+
if (cameraProfile) {
|
|
4075
|
+
const geometry = cameraRowGeometry(row);
|
|
4076
|
+
// Do not promote partial row fragments from a camera frame.
|
|
4077
|
+
if (!geometry || !geometry.quietZone) continue;
|
|
4078
|
+
const candidate = cameraCandidates.get(key) ?? {
|
|
4079
|
+
result,
|
|
4080
|
+
geometry,
|
|
4081
|
+
rows: new Set(),
|
|
4082
|
+
rotation: ((options.cameraRotation ?? 0) + (pass ? 180 : 0)) % 360,
|
|
4083
|
+
};
|
|
4084
|
+
candidate.rows.add(y);
|
|
4085
|
+
cameraCandidates.set(key, candidate);
|
|
4086
|
+
continue;
|
|
4087
|
+
}
|
|
3980
4088
|
if (seen.has(key)) continue;
|
|
3981
4089
|
seen.add(key);
|
|
3982
4090
|
results.push({ ...result, row: y });
|
|
@@ -3985,7 +4093,27 @@ function decodeOneD(image, options = {}) {
|
|
|
3985
4093
|
}
|
|
3986
4094
|
}
|
|
3987
4095
|
|
|
3988
|
-
|
|
4096
|
+
if (cameraProfile) {
|
|
4097
|
+
for (const candidate of cameraCandidates.values()) {
|
|
4098
|
+
// A complete symbol must survive at least two nearby scan samples. This
|
|
4099
|
+
// rejects isolated run coincidences without imposing a payload length.
|
|
4100
|
+
if (candidate.rows.size < 2) continue;
|
|
4101
|
+
results.push(cameraMetadata(candidate.result, candidate.geometry, candidate.rows, {
|
|
4102
|
+
...options,
|
|
4103
|
+
cameraRotation: candidate.rotation,
|
|
4104
|
+
}));
|
|
4105
|
+
}
|
|
4106
|
+
}
|
|
4107
|
+
|
|
4108
|
+
// A valid EAN/UPC parent is substantially more constrained than a generic
|
|
4109
|
+
// narrow/wide candidate. Suppress competing interpretations of the same
|
|
4110
|
+
// scanline, while retaining symbols detected on other rows.
|
|
4111
|
+
const eanRows = new Set(results
|
|
4112
|
+
.filter((result) => isEANParentFormat(result.format))
|
|
4113
|
+
.map((result) => result.row));
|
|
4114
|
+
return eanRows.size === 0
|
|
4115
|
+
? results
|
|
4116
|
+
: results.filter((result) => !eanRows.has(result.row) || isEANParentFormat(result.format));
|
|
3989
4117
|
}
|
|
3990
4118
|
|
|
3991
4119
|
/**
|
|
@@ -16573,6 +16701,10 @@ function encode(text, options = {}) {
|
|
|
16573
16701
|
* @property {boolean} [certified] Whether the profile is certified by its originator.
|
|
16574
16702
|
* @property {object} [canvas] Canvas reservation metadata for the Sythos profile.
|
|
16575
16703
|
* @property {{format:'ean2'|'ean5', text:string, parity:string, checksum?:number}} [addon] Attached EAN/UPC supplement.
|
|
16704
|
+
* @property {number} [confidence] Camera-profile confidence from 0 to 1.
|
|
16705
|
+
* @property {{x:number,y:number,width:number,height:number}} [bounds] Camera-profile bounds in the scanned orientation.
|
|
16706
|
+
* @property {0|90|180|270} [rotation] Camera-profile orientation in degrees.
|
|
16707
|
+
* @property {{quietZone:boolean,checksum:boolean|null,rows:number|null,consistency:number|null}} [quality] Camera-profile validation evidence.
|
|
16576
16708
|
* @property {boolean} [gs1] Whether the physical symbol is classified as GS1.
|
|
16577
16709
|
* @property {string} [symbologyIdentifier] GS1 symbology identifier.
|
|
16578
16710
|
* @property {Array<{ai:string,value:string,fixed?:boolean}>} [elements] Parsed GS1 Application Identifier fields.
|
|
@@ -16593,12 +16725,13 @@ function encode(text, options = {}) {
|
|
|
16593
16725
|
* @param {string[]} [options.formats] Restrict to these format ids.
|
|
16594
16726
|
* @param {boolean} [options.tryHarder] Retry inverted and rotated. Default true.
|
|
16595
16727
|
* @param {'global'|'hybrid'|'auto'} [options.binarizer]
|
|
16728
|
+
* @param {'camera'} [options.profile] Opt-in strict camera profile for validated 1D reads.
|
|
16596
16729
|
* @param {object} [options.frameqr] Sythos Canvas QR detector options when
|
|
16597
16730
|
* the profile marker is not preserved through image rendering.
|
|
16598
16731
|
* @returns {DecodeResult[]}
|
|
16599
16732
|
*/
|
|
16600
16733
|
function decode(image, options = {}) {
|
|
16601
|
-
const { formats = null, tryHarder = true, binarizer = 'auto' } = options;
|
|
16734
|
+
const { formats = null, tryHarder = true, binarizer = 'auto', profile = null } = options;
|
|
16602
16735
|
const want = formats ? new Set(formats.map((f) => f.toLowerCase())) : null;
|
|
16603
16736
|
const wantQR = !want || want.has('qr') || want.has('qrcode');
|
|
16604
16737
|
const wantDataMatrix = !want || want.has('datamatrix') || want.has('data-matrix');
|
|
@@ -16738,7 +16871,24 @@ function decode(image, options = {}) {
|
|
|
16738
16871
|
const oneDFormats = want
|
|
16739
16872
|
? [...want].filter((f) => f in ONED_FORMATS || oneDAliases.has(f))
|
|
16740
16873
|
: null;
|
|
16741
|
-
|
|
16874
|
+
const oneDPasses = [{ bits, rotation: 0 }];
|
|
16875
|
+
// A linear symbol rotated by 90° has no usable horizontal scanline.
|
|
16876
|
+
// The strict camera profile adds exactly two normalized orientations,
|
|
16877
|
+
// only when the native orientation found no validated 1D result.
|
|
16878
|
+
const readOneD = (candidateBits, rotation) => decodeOneD(candidateBits, {
|
|
16879
|
+
...options, formats: oneDFormats, tryHarder, profile, cameraRotation: rotation,
|
|
16880
|
+
});
|
|
16881
|
+
let oneDResults = readOneD(bits, 0);
|
|
16882
|
+
if (profile === 'camera' && oneDResults.length === 0) {
|
|
16883
|
+
oneDPasses.push(
|
|
16884
|
+
{ bits: rotateBitMatrix90(bits, false), rotation: 90 },
|
|
16885
|
+
{ bits: rotateBitMatrix90(bits, true), rotation: 270 },
|
|
16886
|
+
);
|
|
16887
|
+
for (let i = 1; i < oneDPasses.length && oneDResults.length === 0; i++) {
|
|
16888
|
+
oneDResults = readOneD(oneDPasses[i].bits, oneDPasses[i].rotation);
|
|
16889
|
+
}
|
|
16890
|
+
}
|
|
16891
|
+
for (const found of oneDResults) {
|
|
16742
16892
|
const { row, ...publicFound } = found;
|
|
16743
16893
|
void row;
|
|
16744
16894
|
if (publicFound.gs1) {
|
|
@@ -16783,7 +16933,11 @@ function decode(image, options = {}) {
|
|
|
16783
16933
|
&& (binarizer === 'auto' || binarizer === 'hybrid')
|
|
16784
16934
|
&& retryFormats.length > 0;
|
|
16785
16935
|
|
|
16786
|
-
if (!shouldRetryGlobal)
|
|
16936
|
+
if (!shouldRetryGlobal) {
|
|
16937
|
+
return profile === 'camera'
|
|
16938
|
+
? unique.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
16939
|
+
: unique;
|
|
16940
|
+
}
|
|
16787
16941
|
|
|
16788
16942
|
const fallback = decode(image, {
|
|
16789
16943
|
...options,
|
|
@@ -16791,12 +16945,32 @@ function decode(image, options = {}) {
|
|
|
16791
16945
|
binarizer: 'global',
|
|
16792
16946
|
});
|
|
16793
16947
|
const fallbackSeen = new Set();
|
|
16794
|
-
|
|
16948
|
+
const merged = [...unique, ...fallback].filter((r) => {
|
|
16795
16949
|
const key = `${r.format}:${r.text}`;
|
|
16796
16950
|
if (fallbackSeen.has(key)) return false;
|
|
16797
16951
|
fallbackSeen.add(key);
|
|
16798
16952
|
return true;
|
|
16799
16953
|
});
|
|
16954
|
+
return profile === 'camera'
|
|
16955
|
+
? merged.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
16956
|
+
: merged;
|
|
16957
|
+
}
|
|
16958
|
+
|
|
16959
|
+
/**
|
|
16960
|
+
* @param {BitMatrix} matrix
|
|
16961
|
+
* @param {boolean} clockwise
|
|
16962
|
+
* @returns {BitMatrix}
|
|
16963
|
+
*/
|
|
16964
|
+
function rotateBitMatrix90(matrix, clockwise) {
|
|
16965
|
+
const rotated = new BitMatrix(matrix.height, matrix.width);
|
|
16966
|
+
for (let y = 0; y < matrix.height; y++) {
|
|
16967
|
+
for (let x = 0; x < matrix.width; x++) {
|
|
16968
|
+
if (!matrix.get(x, y)) continue;
|
|
16969
|
+
if (clockwise) rotated.set(matrix.height - 1 - y, x);
|
|
16970
|
+
else rotated.set(y, matrix.width - 1 - x);
|
|
16971
|
+
}
|
|
16972
|
+
}
|
|
16973
|
+
return rotated;
|
|
16800
16974
|
}
|
|
16801
16975
|
|
|
16802
16976
|
/**
|
|
@@ -16813,7 +16987,7 @@ function decodeStrict(image, options) {
|
|
|
16813
16987
|
}
|
|
16814
16988
|
|
|
16815
16989
|
/** Library version, matching package.json. */
|
|
16816
|
-
const VERSION = '1.5.
|
|
16990
|
+
const VERSION = '1.5.5';
|
|
16817
16991
|
|
|
16818
16992
|
__exports.listFormats = listFormats;
|
|
16819
16993
|
__exports.encode = encode;
|
package/bundle/sythos-barcode.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/*!
|
|
2
|
-
* Sythos Barcode Suite v1.5.
|
|
2
|
+
* Sythos Barcode Suite v1.5.5
|
|
3
3
|
*
|
|
4
4
|
* MIT License
|
|
5
5
|
*
|
|
@@ -3908,6 +3908,79 @@ const DECODERS = [
|
|
|
3908
3908
|
['codabar', decodeCodabar],
|
|
3909
3909
|
];
|
|
3910
3910
|
|
|
3911
|
+
const EAN_PARENT_FORMATS = new Set(['ean13', 'ean8', 'upca', 'upce', 'isbn']);
|
|
3912
|
+
const EAN_SUPPLEMENT_FORMATS = new Set(['ean2', 'ean5']);
|
|
3913
|
+
|
|
3914
|
+
/** @param {string} format @returns {boolean} */
|
|
3915
|
+
function isEANParentFormat(format) {
|
|
3916
|
+
return format === 'ean13' || format === 'ean8' || format === 'upca' || format === 'upce';
|
|
3917
|
+
}
|
|
3918
|
+
|
|
3919
|
+
/**
|
|
3920
|
+
* An ISBN is printed as a Bookland EAN-13, so its decoded parent remains
|
|
3921
|
+
* `ean13` while an ISBN filter accepts only the Bookland prefixes.
|
|
3922
|
+
*
|
|
3923
|
+
* @param {{format:string, text:string}} result
|
|
3924
|
+
* @param {Set<string>} enabled
|
|
3925
|
+
* @returns {boolean}
|
|
3926
|
+
*/
|
|
3927
|
+
function isRequestedEANParent(result, enabled) {
|
|
3928
|
+
if (enabled.has(result.format)) return true;
|
|
3929
|
+
return result.format === 'ean13' && enabled.has('isbn') && /^97[89]/.test(result.text);
|
|
3930
|
+
}
|
|
3931
|
+
|
|
3932
|
+
/** @param {object} result @returns {object} */
|
|
3933
|
+
function withoutEANAddon(result) {
|
|
3934
|
+
const { addon, ...parent } = result;
|
|
3935
|
+
void addon;
|
|
3936
|
+
return parent;
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
/** @param {Uint8Array} row @returns {{x:number, width:number, quietZone:boolean}|null} */
|
|
3940
|
+
function cameraRowGeometry(row) {
|
|
3941
|
+
let first = 0;
|
|
3942
|
+
while (first < row.length && row[first] === 0) first++;
|
|
3943
|
+
if (first === row.length) return null;
|
|
3944
|
+
let last = row.length - 1;
|
|
3945
|
+
while (last >= 0 && row[last] === 0) last--;
|
|
3946
|
+
return {
|
|
3947
|
+
x: first,
|
|
3948
|
+
width: last - first + 1,
|
|
3949
|
+
quietZone: first >= 2 && row.length - 1 - last >= 2,
|
|
3950
|
+
};
|
|
3951
|
+
}
|
|
3952
|
+
|
|
3953
|
+
/** @param {string} format @param {object} options @returns {boolean|null} */
|
|
3954
|
+
function checksumStatus(format, options) {
|
|
3955
|
+
if (format === 'ean13' || format === 'ean8' || format === 'upca' || format === 'upce' ||
|
|
3956
|
+
format === 'code93' || format === 'code128' || format === 'gs1128' ||
|
|
3957
|
+
format === 'gs1databar14') return true;
|
|
3958
|
+
if (format === 'code11' || format === 'msi' || format === 'code39') {
|
|
3959
|
+
return options.profile === 'camera' || options.checkDigit === true ? true : null;
|
|
3960
|
+
}
|
|
3961
|
+
return null;
|
|
3962
|
+
}
|
|
3963
|
+
|
|
3964
|
+
/** @param {object} result @param {object} geometry @param {Set<number>} rows @param {object} options @returns {object} */
|
|
3965
|
+
function cameraMetadata(result, geometry, rows, options) {
|
|
3966
|
+
const checksum = checksumStatus(result.format, options);
|
|
3967
|
+
const consistency = Math.min(1, rows.size / 3);
|
|
3968
|
+
const confidence = Math.min(1, 0.4 + (geometry.quietZone ? 0.2 : 0) +
|
|
3969
|
+
(checksum === true ? 0.2 : 0) + consistency * 0.2);
|
|
3970
|
+
return {
|
|
3971
|
+
...result,
|
|
3972
|
+
confidence,
|
|
3973
|
+
bounds: {
|
|
3974
|
+
x: geometry.x,
|
|
3975
|
+
y: Math.min(...rows),
|
|
3976
|
+
width: geometry.width,
|
|
3977
|
+
height: Math.max(...rows) - Math.min(...rows) + 1,
|
|
3978
|
+
},
|
|
3979
|
+
rotation: options.cameraRotation ?? 0,
|
|
3980
|
+
quality: { quietZone: geometry.quietZone, checksum, rows: rows.size, consistency },
|
|
3981
|
+
};
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3911
3984
|
/**
|
|
3912
3985
|
* Read every linear symbol found in a binarized image.
|
|
3913
3986
|
*
|
|
@@ -3916,16 +3989,20 @@ const DECODERS = [
|
|
|
3916
3989
|
* @param {string[]} [options.formats] Restrict to these format ids.
|
|
3917
3990
|
* @param {number} [options.rows] How many horizontal slices to try.
|
|
3918
3991
|
* @param {boolean} [options.tryHarder] Also scan reversed rows, for mirrored symbols.
|
|
3992
|
+
* @param {'camera'} [options.profile] Require stable, quiet-zone-qualified reads.
|
|
3993
|
+
* @param {0|90|180|270} [options.cameraRotation] Orientation already normalized by the caller.
|
|
3919
3994
|
* @returns {Array<{format: string, text: string, row: number}>}
|
|
3920
3995
|
*/
|
|
3921
3996
|
function decodeOneD(image, options = {}) {
|
|
3922
|
-
const { formats = null, rows = 15, tryHarder = true } = options;
|
|
3997
|
+
const { formats = null, rows = 15, tryHarder = true, profile = null } = options;
|
|
3998
|
+
const cameraProfile = profile === 'camera';
|
|
3923
3999
|
const enabled = formats ? new Set(formats) : null;
|
|
3924
4000
|
const active = DECODERS.filter(([id]) => {
|
|
3925
4001
|
if (!enabled) return true;
|
|
3926
4002
|
if (enabled.has(id)) return true;
|
|
3927
4003
|
if (id === 'ean13' || id === 'ean8' || id === 'upca' || id === 'upce') {
|
|
3928
|
-
return enabled.has('ean2') || enabled.has('ean5')
|
|
4004
|
+
return enabled.has('ean2') || enabled.has('ean5') ||
|
|
4005
|
+
(id === 'ean13' && enabled.has('isbn'));
|
|
3929
4006
|
}
|
|
3930
4007
|
if (id === 'code128') return enabled.has('gs1128');
|
|
3931
4008
|
if (id === 'gs1databar14') return enabled.has('databar') || enabled.has('gs1-databar14');
|
|
@@ -3937,13 +4014,15 @@ function decodeOneD(image, options = {}) {
|
|
|
3937
4014
|
const seen = new Set();
|
|
3938
4015
|
const height = image.height;
|
|
3939
4016
|
const buffer = new Uint8Array(image.width);
|
|
4017
|
+
const cameraCandidates = new Map();
|
|
3940
4018
|
|
|
3941
4019
|
// Sample rows from the middle outward: symbols are usually centred, and the
|
|
3942
4020
|
// middle of a linear barcode is the part least likely to be clipped.
|
|
3943
4021
|
const middle = height >> 1;
|
|
3944
|
-
const
|
|
4022
|
+
const sampleRows = cameraProfile ? Math.min(height, Math.max(rows, 48)) : rows;
|
|
4023
|
+
const step = Math.max(1, Math.round(height / sampleRows));
|
|
3945
4024
|
|
|
3946
|
-
for (let attempt = 0; attempt <
|
|
4025
|
+
for (let attempt = 0; attempt < sampleRows; attempt++) {
|
|
3947
4026
|
const delta = Math.ceil(attempt / 2) * step * (attempt % 2 === 0 ? 1 : -1);
|
|
3948
4027
|
const y = middle + delta;
|
|
3949
4028
|
if (y < 0 || y >= height) continue;
|
|
@@ -3956,17 +4035,32 @@ function decodeOneD(image, options = {}) {
|
|
|
3956
4035
|
for (const [id, decoder] of active) {
|
|
3957
4036
|
let result = null;
|
|
3958
4037
|
try {
|
|
3959
|
-
|
|
4038
|
+
// Code 11 and MSI checks are optional in their base standards, but
|
|
4039
|
+
// a camera frame cannot safely promote their short unchecked forms.
|
|
4040
|
+
const decoderOptions = cameraProfile && (id === 'code11' || id === 'msi')
|
|
4041
|
+
? { ...options, checkDigit: true }
|
|
4042
|
+
: options;
|
|
4043
|
+
result = decoder(scan, decoderOptions);
|
|
3960
4044
|
} catch {
|
|
3961
4045
|
result = null; // a malformed candidate is not an error
|
|
3962
4046
|
}
|
|
3963
4047
|
if (!result) continue;
|
|
3964
4048
|
if (enabled) {
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
4049
|
+
if (isEANParentFormat(result.format)) {
|
|
4050
|
+
const baseRequested = [...EAN_PARENT_FORMATS].some((format) => enabled.has(format));
|
|
4051
|
+
const parentRequested = isRequestedEANParent(result, enabled);
|
|
4052
|
+
if (baseRequested && !parentRequested) {
|
|
4053
|
+
continue;
|
|
4054
|
+
}
|
|
4055
|
+
if (!baseRequested) {
|
|
4056
|
+
// A supplement is never an independent barcode. When it is the
|
|
4057
|
+
// only requested format, return its validated EAN/UPC parent.
|
|
4058
|
+
if (!result.addon || !EAN_SUPPLEMENT_FORMATS.has(result.addon.format) ||
|
|
4059
|
+
!enabled.has(result.addon.format)) continue;
|
|
4060
|
+
} else if (result.addon && !enabled.has(result.addon.format)) {
|
|
4061
|
+
// Supplements are optional whenever a requested parent exists.
|
|
4062
|
+
result = withoutEANAddon(result);
|
|
4063
|
+
}
|
|
3970
4064
|
} else if (result.format === 'gs1128') {
|
|
3971
4065
|
if (!enabled.has('gs1128') && !enabled.has('code128')) continue;
|
|
3972
4066
|
} else if (result.format === 'gs1databar14') {
|
|
@@ -3978,6 +4072,20 @@ function decodeOneD(image, options = {}) {
|
|
|
3978
4072
|
|
|
3979
4073
|
const addonKey = result.addon ? `:${result.addon.format}:${result.addon.text}` : '';
|
|
3980
4074
|
const key = `${result.format}:${result.text}${addonKey}`;
|
|
4075
|
+
if (cameraProfile) {
|
|
4076
|
+
const geometry = cameraRowGeometry(row);
|
|
4077
|
+
// Do not promote partial row fragments from a camera frame.
|
|
4078
|
+
if (!geometry || !geometry.quietZone) continue;
|
|
4079
|
+
const candidate = cameraCandidates.get(key) ?? {
|
|
4080
|
+
result,
|
|
4081
|
+
geometry,
|
|
4082
|
+
rows: new Set(),
|
|
4083
|
+
rotation: ((options.cameraRotation ?? 0) + (pass ? 180 : 0)) % 360,
|
|
4084
|
+
};
|
|
4085
|
+
candidate.rows.add(y);
|
|
4086
|
+
cameraCandidates.set(key, candidate);
|
|
4087
|
+
continue;
|
|
4088
|
+
}
|
|
3981
4089
|
if (seen.has(key)) continue;
|
|
3982
4090
|
seen.add(key);
|
|
3983
4091
|
results.push({ ...result, row: y });
|
|
@@ -3986,7 +4094,27 @@ function decodeOneD(image, options = {}) {
|
|
|
3986
4094
|
}
|
|
3987
4095
|
}
|
|
3988
4096
|
|
|
3989
|
-
|
|
4097
|
+
if (cameraProfile) {
|
|
4098
|
+
for (const candidate of cameraCandidates.values()) {
|
|
4099
|
+
// A complete symbol must survive at least two nearby scan samples. This
|
|
4100
|
+
// rejects isolated run coincidences without imposing a payload length.
|
|
4101
|
+
if (candidate.rows.size < 2) continue;
|
|
4102
|
+
results.push(cameraMetadata(candidate.result, candidate.geometry, candidate.rows, {
|
|
4103
|
+
...options,
|
|
4104
|
+
cameraRotation: candidate.rotation,
|
|
4105
|
+
}));
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
// A valid EAN/UPC parent is substantially more constrained than a generic
|
|
4110
|
+
// narrow/wide candidate. Suppress competing interpretations of the same
|
|
4111
|
+
// scanline, while retaining symbols detected on other rows.
|
|
4112
|
+
const eanRows = new Set(results
|
|
4113
|
+
.filter((result) => isEANParentFormat(result.format))
|
|
4114
|
+
.map((result) => result.row));
|
|
4115
|
+
return eanRows.size === 0
|
|
4116
|
+
? results
|
|
4117
|
+
: results.filter((result) => !eanRows.has(result.row) || isEANParentFormat(result.format));
|
|
3990
4118
|
}
|
|
3991
4119
|
|
|
3992
4120
|
/**
|
|
@@ -16574,6 +16702,10 @@ function encode(text, options = {}) {
|
|
|
16574
16702
|
* @property {boolean} [certified] Whether the profile is certified by its originator.
|
|
16575
16703
|
* @property {object} [canvas] Canvas reservation metadata for the Sythos profile.
|
|
16576
16704
|
* @property {{format:'ean2'|'ean5', text:string, parity:string, checksum?:number}} [addon] Attached EAN/UPC supplement.
|
|
16705
|
+
* @property {number} [confidence] Camera-profile confidence from 0 to 1.
|
|
16706
|
+
* @property {{x:number,y:number,width:number,height:number}} [bounds] Camera-profile bounds in the scanned orientation.
|
|
16707
|
+
* @property {0|90|180|270} [rotation] Camera-profile orientation in degrees.
|
|
16708
|
+
* @property {{quietZone:boolean,checksum:boolean|null,rows:number|null,consistency:number|null}} [quality] Camera-profile validation evidence.
|
|
16577
16709
|
* @property {boolean} [gs1] Whether the physical symbol is classified as GS1.
|
|
16578
16710
|
* @property {string} [symbologyIdentifier] GS1 symbology identifier.
|
|
16579
16711
|
* @property {Array<{ai:string,value:string,fixed?:boolean}>} [elements] Parsed GS1 Application Identifier fields.
|
|
@@ -16594,12 +16726,13 @@ function encode(text, options = {}) {
|
|
|
16594
16726
|
* @param {string[]} [options.formats] Restrict to these format ids.
|
|
16595
16727
|
* @param {boolean} [options.tryHarder] Retry inverted and rotated. Default true.
|
|
16596
16728
|
* @param {'global'|'hybrid'|'auto'} [options.binarizer]
|
|
16729
|
+
* @param {'camera'} [options.profile] Opt-in strict camera profile for validated 1D reads.
|
|
16597
16730
|
* @param {object} [options.frameqr] Sythos Canvas QR detector options when
|
|
16598
16731
|
* the profile marker is not preserved through image rendering.
|
|
16599
16732
|
* @returns {DecodeResult[]}
|
|
16600
16733
|
*/
|
|
16601
16734
|
function decode(image, options = {}) {
|
|
16602
|
-
const { formats = null, tryHarder = true, binarizer = 'auto' } = options;
|
|
16735
|
+
const { formats = null, tryHarder = true, binarizer = 'auto', profile = null } = options;
|
|
16603
16736
|
const want = formats ? new Set(formats.map((f) => f.toLowerCase())) : null;
|
|
16604
16737
|
const wantQR = !want || want.has('qr') || want.has('qrcode');
|
|
16605
16738
|
const wantDataMatrix = !want || want.has('datamatrix') || want.has('data-matrix');
|
|
@@ -16739,7 +16872,24 @@ function decode(image, options = {}) {
|
|
|
16739
16872
|
const oneDFormats = want
|
|
16740
16873
|
? [...want].filter((f) => f in ONED_FORMATS || oneDAliases.has(f))
|
|
16741
16874
|
: null;
|
|
16742
|
-
|
|
16875
|
+
const oneDPasses = [{ bits, rotation: 0 }];
|
|
16876
|
+
// A linear symbol rotated by 90° has no usable horizontal scanline.
|
|
16877
|
+
// The strict camera profile adds exactly two normalized orientations,
|
|
16878
|
+
// only when the native orientation found no validated 1D result.
|
|
16879
|
+
const readOneD = (candidateBits, rotation) => decodeOneD(candidateBits, {
|
|
16880
|
+
...options, formats: oneDFormats, tryHarder, profile, cameraRotation: rotation,
|
|
16881
|
+
});
|
|
16882
|
+
let oneDResults = readOneD(bits, 0);
|
|
16883
|
+
if (profile === 'camera' && oneDResults.length === 0) {
|
|
16884
|
+
oneDPasses.push(
|
|
16885
|
+
{ bits: rotateBitMatrix90(bits, false), rotation: 90 },
|
|
16886
|
+
{ bits: rotateBitMatrix90(bits, true), rotation: 270 },
|
|
16887
|
+
);
|
|
16888
|
+
for (let i = 1; i < oneDPasses.length && oneDResults.length === 0; i++) {
|
|
16889
|
+
oneDResults = readOneD(oneDPasses[i].bits, oneDPasses[i].rotation);
|
|
16890
|
+
}
|
|
16891
|
+
}
|
|
16892
|
+
for (const found of oneDResults) {
|
|
16743
16893
|
const { row, ...publicFound } = found;
|
|
16744
16894
|
void row;
|
|
16745
16895
|
if (publicFound.gs1) {
|
|
@@ -16784,7 +16934,11 @@ function decode(image, options = {}) {
|
|
|
16784
16934
|
&& (binarizer === 'auto' || binarizer === 'hybrid')
|
|
16785
16935
|
&& retryFormats.length > 0;
|
|
16786
16936
|
|
|
16787
|
-
if (!shouldRetryGlobal)
|
|
16937
|
+
if (!shouldRetryGlobal) {
|
|
16938
|
+
return profile === 'camera'
|
|
16939
|
+
? unique.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
16940
|
+
: unique;
|
|
16941
|
+
}
|
|
16788
16942
|
|
|
16789
16943
|
const fallback = decode(image, {
|
|
16790
16944
|
...options,
|
|
@@ -16792,12 +16946,32 @@ function decode(image, options = {}) {
|
|
|
16792
16946
|
binarizer: 'global',
|
|
16793
16947
|
});
|
|
16794
16948
|
const fallbackSeen = new Set();
|
|
16795
|
-
|
|
16949
|
+
const merged = [...unique, ...fallback].filter((r) => {
|
|
16796
16950
|
const key = `${r.format}:${r.text}`;
|
|
16797
16951
|
if (fallbackSeen.has(key)) return false;
|
|
16798
16952
|
fallbackSeen.add(key);
|
|
16799
16953
|
return true;
|
|
16800
16954
|
});
|
|
16955
|
+
return profile === 'camera'
|
|
16956
|
+
? merged.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
16957
|
+
: merged;
|
|
16958
|
+
}
|
|
16959
|
+
|
|
16960
|
+
/**
|
|
16961
|
+
* @param {BitMatrix} matrix
|
|
16962
|
+
* @param {boolean} clockwise
|
|
16963
|
+
* @returns {BitMatrix}
|
|
16964
|
+
*/
|
|
16965
|
+
function rotateBitMatrix90(matrix, clockwise) {
|
|
16966
|
+
const rotated = new BitMatrix(matrix.height, matrix.width);
|
|
16967
|
+
for (let y = 0; y < matrix.height; y++) {
|
|
16968
|
+
for (let x = 0; x < matrix.width; x++) {
|
|
16969
|
+
if (!matrix.get(x, y)) continue;
|
|
16970
|
+
if (clockwise) rotated.set(matrix.height - 1 - y, x);
|
|
16971
|
+
else rotated.set(y, matrix.width - 1 - x);
|
|
16972
|
+
}
|
|
16973
|
+
}
|
|
16974
|
+
return rotated;
|
|
16801
16975
|
}
|
|
16802
16976
|
|
|
16803
16977
|
/**
|
|
@@ -16814,7 +16988,7 @@ function decodeStrict(image, options) {
|
|
|
16814
16988
|
}
|
|
16815
16989
|
|
|
16816
16990
|
/** Library version, matching package.json. */
|
|
16817
|
-
const VERSION = '1.5.
|
|
16991
|
+
const VERSION = '1.5.5';
|
|
16818
16992
|
|
|
16819
16993
|
__exports.listFormats = listFormats;
|
|
16820
16994
|
__exports.encode = encode;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sythos/js_barcode_universal",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.5",
|
|
4
4
|
"description": "Read and write barcodes in JavaScript with zero runtime dependencies. QR Code, Micro QR, rMQR, FrameQR Code, Aztec Code and Rune, PDF417 variants, GS1 DataBar Omnidirectional/Truncated, EAN supplements and one-dimensional formats.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Sythos",
|
package/src/index.js
CHANGED
|
@@ -348,6 +348,10 @@ export function encode(text, options = {}) {
|
|
|
348
348
|
* @property {boolean} [certified] Whether the profile is certified by its originator.
|
|
349
349
|
* @property {object} [canvas] Canvas reservation metadata for the FrameQR Code profile.
|
|
350
350
|
* @property {{format:'ean2'|'ean5', text:string, parity:string, checksum?:number}} [addon] Attached EAN/UPC supplement.
|
|
351
|
+
* @property {number} [confidence] Camera-profile confidence from 0 to 1.
|
|
352
|
+
* @property {{x:number,y:number,width:number,height:number}} [bounds] Camera-profile bounds in the scanned orientation.
|
|
353
|
+
* @property {0|90|180|270} [rotation] Camera-profile orientation in degrees.
|
|
354
|
+
* @property {{quietZone:boolean,checksum:boolean|null,rows:number|null,consistency:number|null}} [quality] Camera-profile validation evidence.
|
|
351
355
|
* @property {boolean} [gs1] Whether the physical symbol is classified as GS1.
|
|
352
356
|
* @property {string} [symbologyIdentifier] GS1 symbology identifier.
|
|
353
357
|
* @property {Array<{ai:string,value:string,fixed?:boolean}>} [elements] Parsed GS1 Application Identifier fields.
|
|
@@ -368,12 +372,13 @@ export function encode(text, options = {}) {
|
|
|
368
372
|
* @param {string[]} [options.formats] Restrict to these format ids.
|
|
369
373
|
* @param {boolean} [options.tryHarder] Retry inverted and rotated. Default true.
|
|
370
374
|
* @param {'global'|'hybrid'|'auto'} [options.binarizer]
|
|
375
|
+
* @param {'camera'} [options.profile] Opt-in strict camera profile for validated 1D reads.
|
|
371
376
|
* @param {object} [options.frameqr] FrameQR Code detector options when
|
|
372
377
|
* the profile marker is not preserved through image rendering.
|
|
373
378
|
* @returns {DecodeResult[]}
|
|
374
379
|
*/
|
|
375
380
|
export function decode(image, options = {}) {
|
|
376
|
-
const { formats = null, tryHarder = true, binarizer = 'auto' } = options;
|
|
381
|
+
const { formats = null, tryHarder = true, binarizer = 'auto', profile = null } = options;
|
|
377
382
|
const want = formats ? new Set(formats.map((f) => f.toLowerCase())) : null;
|
|
378
383
|
const wantQR = !want || want.has('qr') || want.has('qrcode');
|
|
379
384
|
const wantDataMatrix = !want || want.has('datamatrix') || want.has('data-matrix');
|
|
@@ -513,7 +518,24 @@ export function decode(image, options = {}) {
|
|
|
513
518
|
const oneDFormats = want
|
|
514
519
|
? [...want].filter((f) => f in ONED_FORMATS || oneDAliases.has(f))
|
|
515
520
|
: null;
|
|
516
|
-
|
|
521
|
+
const oneDPasses = [{ bits, rotation: 0 }];
|
|
522
|
+
// A linear symbol rotated by 90° has no usable horizontal scanline.
|
|
523
|
+
// The strict camera profile adds exactly two normalized orientations,
|
|
524
|
+
// only when the native orientation found no validated 1D result.
|
|
525
|
+
const readOneD = (candidateBits, rotation) => decodeOneD(candidateBits, {
|
|
526
|
+
...options, formats: oneDFormats, tryHarder, profile, cameraRotation: rotation,
|
|
527
|
+
});
|
|
528
|
+
let oneDResults = readOneD(bits, 0);
|
|
529
|
+
if (profile === 'camera' && oneDResults.length === 0) {
|
|
530
|
+
oneDPasses.push(
|
|
531
|
+
{ bits: rotateBitMatrix90(bits, false), rotation: 90 },
|
|
532
|
+
{ bits: rotateBitMatrix90(bits, true), rotation: 270 },
|
|
533
|
+
);
|
|
534
|
+
for (let i = 1; i < oneDPasses.length && oneDResults.length === 0; i++) {
|
|
535
|
+
oneDResults = readOneD(oneDPasses[i].bits, oneDPasses[i].rotation);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
for (const found of oneDResults) {
|
|
517
539
|
const { row, ...publicFound } = found;
|
|
518
540
|
void row;
|
|
519
541
|
if (publicFound.gs1) {
|
|
@@ -558,7 +580,11 @@ export function decode(image, options = {}) {
|
|
|
558
580
|
&& (binarizer === 'auto' || binarizer === 'hybrid')
|
|
559
581
|
&& retryFormats.length > 0;
|
|
560
582
|
|
|
561
|
-
if (!shouldRetryGlobal)
|
|
583
|
+
if (!shouldRetryGlobal) {
|
|
584
|
+
return profile === 'camera'
|
|
585
|
+
? unique.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
586
|
+
: unique;
|
|
587
|
+
}
|
|
562
588
|
|
|
563
589
|
const fallback = decode(image, {
|
|
564
590
|
...options,
|
|
@@ -566,12 +592,32 @@ export function decode(image, options = {}) {
|
|
|
566
592
|
binarizer: 'global',
|
|
567
593
|
});
|
|
568
594
|
const fallbackSeen = new Set();
|
|
569
|
-
|
|
595
|
+
const merged = [...unique, ...fallback].filter((r) => {
|
|
570
596
|
const key = `${r.format}:${r.text}`;
|
|
571
597
|
if (fallbackSeen.has(key)) return false;
|
|
572
598
|
fallbackSeen.add(key);
|
|
573
599
|
return true;
|
|
574
600
|
});
|
|
601
|
+
return profile === 'camera'
|
|
602
|
+
? merged.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0))
|
|
603
|
+
: merged;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* @param {BitMatrix} matrix
|
|
608
|
+
* @param {boolean} clockwise
|
|
609
|
+
* @returns {BitMatrix}
|
|
610
|
+
*/
|
|
611
|
+
function rotateBitMatrix90(matrix, clockwise) {
|
|
612
|
+
const rotated = new BitMatrix(matrix.height, matrix.width);
|
|
613
|
+
for (let y = 0; y < matrix.height; y++) {
|
|
614
|
+
for (let x = 0; x < matrix.width; x++) {
|
|
615
|
+
if (!matrix.get(x, y)) continue;
|
|
616
|
+
if (clockwise) rotated.set(matrix.height - 1 - y, x);
|
|
617
|
+
else rotated.set(y, matrix.width - 1 - x);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return rotated;
|
|
575
621
|
}
|
|
576
622
|
|
|
577
623
|
/**
|
|
@@ -588,4 +634,4 @@ export function decodeStrict(image, options) {
|
|
|
588
634
|
}
|
|
589
635
|
|
|
590
636
|
/** Library version, matching package.json. */
|
|
591
|
-
export const VERSION = '1.5.
|
|
637
|
+
export const VERSION = '1.5.5';
|
package/src/oned/reader.js
CHANGED
|
@@ -1142,6 +1142,79 @@ const DECODERS = [
|
|
|
1142
1142
|
['codabar', decodeCodabar],
|
|
1143
1143
|
];
|
|
1144
1144
|
|
|
1145
|
+
const EAN_PARENT_FORMATS = new Set(['ean13', 'ean8', 'upca', 'upce', 'isbn']);
|
|
1146
|
+
const EAN_SUPPLEMENT_FORMATS = new Set(['ean2', 'ean5']);
|
|
1147
|
+
|
|
1148
|
+
/** @param {string} format @returns {boolean} */
|
|
1149
|
+
function isEANParentFormat(format) {
|
|
1150
|
+
return format === 'ean13' || format === 'ean8' || format === 'upca' || format === 'upce';
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* An ISBN is printed as a Bookland EAN-13, so its decoded parent remains
|
|
1155
|
+
* `ean13` while an ISBN filter accepts only the Bookland prefixes.
|
|
1156
|
+
*
|
|
1157
|
+
* @param {{format:string, text:string}} result
|
|
1158
|
+
* @param {Set<string>} enabled
|
|
1159
|
+
* @returns {boolean}
|
|
1160
|
+
*/
|
|
1161
|
+
function isRequestedEANParent(result, enabled) {
|
|
1162
|
+
if (enabled.has(result.format)) return true;
|
|
1163
|
+
return result.format === 'ean13' && enabled.has('isbn') && /^97[89]/.test(result.text);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** @param {object} result @returns {object} */
|
|
1167
|
+
function withoutEANAddon(result) {
|
|
1168
|
+
const { addon, ...parent } = result;
|
|
1169
|
+
void addon;
|
|
1170
|
+
return parent;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** @param {Uint8Array} row @returns {{x:number, width:number, quietZone:boolean}|null} */
|
|
1174
|
+
function cameraRowGeometry(row) {
|
|
1175
|
+
let first = 0;
|
|
1176
|
+
while (first < row.length && row[first] === 0) first++;
|
|
1177
|
+
if (first === row.length) return null;
|
|
1178
|
+
let last = row.length - 1;
|
|
1179
|
+
while (last >= 0 && row[last] === 0) last--;
|
|
1180
|
+
return {
|
|
1181
|
+
x: first,
|
|
1182
|
+
width: last - first + 1,
|
|
1183
|
+
quietZone: first >= 2 && row.length - 1 - last >= 2,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** @param {string} format @param {object} options @returns {boolean|null} */
|
|
1188
|
+
function checksumStatus(format, options) {
|
|
1189
|
+
if (format === 'ean13' || format === 'ean8' || format === 'upca' || format === 'upce' ||
|
|
1190
|
+
format === 'code93' || format === 'code128' || format === 'gs1128' ||
|
|
1191
|
+
format === 'gs1databar14') return true;
|
|
1192
|
+
if (format === 'code11' || format === 'msi' || format === 'code39') {
|
|
1193
|
+
return options.profile === 'camera' || options.checkDigit === true ? true : null;
|
|
1194
|
+
}
|
|
1195
|
+
return null;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/** @param {object} result @param {object} geometry @param {Set<number>} rows @param {object} options @returns {object} */
|
|
1199
|
+
function cameraMetadata(result, geometry, rows, options) {
|
|
1200
|
+
const checksum = checksumStatus(result.format, options);
|
|
1201
|
+
const consistency = Math.min(1, rows.size / 3);
|
|
1202
|
+
const confidence = Math.min(1, 0.4 + (geometry.quietZone ? 0.2 : 0) +
|
|
1203
|
+
(checksum === true ? 0.2 : 0) + consistency * 0.2);
|
|
1204
|
+
return {
|
|
1205
|
+
...result,
|
|
1206
|
+
confidence,
|
|
1207
|
+
bounds: {
|
|
1208
|
+
x: geometry.x,
|
|
1209
|
+
y: Math.min(...rows),
|
|
1210
|
+
width: geometry.width,
|
|
1211
|
+
height: Math.max(...rows) - Math.min(...rows) + 1,
|
|
1212
|
+
},
|
|
1213
|
+
rotation: options.cameraRotation ?? 0,
|
|
1214
|
+
quality: { quietZone: geometry.quietZone, checksum, rows: rows.size, consistency },
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1145
1218
|
/**
|
|
1146
1219
|
* Read every linear symbol found in a binarized image.
|
|
1147
1220
|
*
|
|
@@ -1150,16 +1223,20 @@ const DECODERS = [
|
|
|
1150
1223
|
* @param {string[]} [options.formats] Restrict to these format ids.
|
|
1151
1224
|
* @param {number} [options.rows] How many horizontal slices to try.
|
|
1152
1225
|
* @param {boolean} [options.tryHarder] Also scan reversed rows, for mirrored symbols.
|
|
1226
|
+
* @param {'camera'} [options.profile] Require stable, quiet-zone-qualified reads.
|
|
1227
|
+
* @param {0|90|180|270} [options.cameraRotation] Orientation already normalized by the caller.
|
|
1153
1228
|
* @returns {Array<{format: string, text: string, row: number}>}
|
|
1154
1229
|
*/
|
|
1155
1230
|
export function decodeOneD(image, options = {}) {
|
|
1156
|
-
const { formats = null, rows = 15, tryHarder = true } = options;
|
|
1231
|
+
const { formats = null, rows = 15, tryHarder = true, profile = null } = options;
|
|
1232
|
+
const cameraProfile = profile === 'camera';
|
|
1157
1233
|
const enabled = formats ? new Set(formats) : null;
|
|
1158
1234
|
const active = DECODERS.filter(([id]) => {
|
|
1159
1235
|
if (!enabled) return true;
|
|
1160
1236
|
if (enabled.has(id)) return true;
|
|
1161
1237
|
if (id === 'ean13' || id === 'ean8' || id === 'upca' || id === 'upce') {
|
|
1162
|
-
return enabled.has('ean2') || enabled.has('ean5')
|
|
1238
|
+
return enabled.has('ean2') || enabled.has('ean5') ||
|
|
1239
|
+
(id === 'ean13' && enabled.has('isbn'));
|
|
1163
1240
|
}
|
|
1164
1241
|
if (id === 'code128') return enabled.has('gs1128');
|
|
1165
1242
|
if (id === 'gs1databar14') return enabled.has('databar') || enabled.has('gs1-databar14');
|
|
@@ -1171,13 +1248,15 @@ export function decodeOneD(image, options = {}) {
|
|
|
1171
1248
|
const seen = new Set();
|
|
1172
1249
|
const height = image.height;
|
|
1173
1250
|
const buffer = new Uint8Array(image.width);
|
|
1251
|
+
const cameraCandidates = new Map();
|
|
1174
1252
|
|
|
1175
1253
|
// Sample rows from the middle outward: symbols are usually centred, and the
|
|
1176
1254
|
// middle of a linear barcode is the part least likely to be clipped.
|
|
1177
1255
|
const middle = height >> 1;
|
|
1178
|
-
const
|
|
1256
|
+
const sampleRows = cameraProfile ? Math.min(height, Math.max(rows, 48)) : rows;
|
|
1257
|
+
const step = Math.max(1, Math.round(height / sampleRows));
|
|
1179
1258
|
|
|
1180
|
-
for (let attempt = 0; attempt <
|
|
1259
|
+
for (let attempt = 0; attempt < sampleRows; attempt++) {
|
|
1181
1260
|
const delta = Math.ceil(attempt / 2) * step * (attempt % 2 === 0 ? 1 : -1);
|
|
1182
1261
|
const y = middle + delta;
|
|
1183
1262
|
if (y < 0 || y >= height) continue;
|
|
@@ -1190,17 +1269,32 @@ export function decodeOneD(image, options = {}) {
|
|
|
1190
1269
|
for (const [id, decoder] of active) {
|
|
1191
1270
|
let result = null;
|
|
1192
1271
|
try {
|
|
1193
|
-
|
|
1272
|
+
// Code 11 and MSI checks are optional in their base standards, but
|
|
1273
|
+
// a camera frame cannot safely promote their short unchecked forms.
|
|
1274
|
+
const decoderOptions = cameraProfile && (id === 'code11' || id === 'msi')
|
|
1275
|
+
? { ...options, checkDigit: true }
|
|
1276
|
+
: options;
|
|
1277
|
+
result = decoder(scan, decoderOptions);
|
|
1194
1278
|
} catch {
|
|
1195
1279
|
result = null; // a malformed candidate is not an error
|
|
1196
1280
|
}
|
|
1197
1281
|
if (!result) continue;
|
|
1198
1282
|
if (enabled) {
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1283
|
+
if (isEANParentFormat(result.format)) {
|
|
1284
|
+
const baseRequested = [...EAN_PARENT_FORMATS].some((format) => enabled.has(format));
|
|
1285
|
+
const parentRequested = isRequestedEANParent(result, enabled);
|
|
1286
|
+
if (baseRequested && !parentRequested) {
|
|
1287
|
+
continue;
|
|
1288
|
+
}
|
|
1289
|
+
if (!baseRequested) {
|
|
1290
|
+
// A supplement is never an independent barcode. When it is the
|
|
1291
|
+
// only requested format, return its validated EAN/UPC parent.
|
|
1292
|
+
if (!result.addon || !EAN_SUPPLEMENT_FORMATS.has(result.addon.format) ||
|
|
1293
|
+
!enabled.has(result.addon.format)) continue;
|
|
1294
|
+
} else if (result.addon && !enabled.has(result.addon.format)) {
|
|
1295
|
+
// Supplements are optional whenever a requested parent exists.
|
|
1296
|
+
result = withoutEANAddon(result);
|
|
1297
|
+
}
|
|
1204
1298
|
} else if (result.format === 'gs1128') {
|
|
1205
1299
|
if (!enabled.has('gs1128') && !enabled.has('code128')) continue;
|
|
1206
1300
|
} else if (result.format === 'gs1databar14') {
|
|
@@ -1212,6 +1306,20 @@ export function decodeOneD(image, options = {}) {
|
|
|
1212
1306
|
|
|
1213
1307
|
const addonKey = result.addon ? `:${result.addon.format}:${result.addon.text}` : '';
|
|
1214
1308
|
const key = `${result.format}:${result.text}${addonKey}`;
|
|
1309
|
+
if (cameraProfile) {
|
|
1310
|
+
const geometry = cameraRowGeometry(row);
|
|
1311
|
+
// Do not promote partial row fragments from a camera frame.
|
|
1312
|
+
if (!geometry || !geometry.quietZone) continue;
|
|
1313
|
+
const candidate = cameraCandidates.get(key) ?? {
|
|
1314
|
+
result,
|
|
1315
|
+
geometry,
|
|
1316
|
+
rows: new Set(),
|
|
1317
|
+
rotation: ((options.cameraRotation ?? 0) + (pass ? 180 : 0)) % 360,
|
|
1318
|
+
};
|
|
1319
|
+
candidate.rows.add(y);
|
|
1320
|
+
cameraCandidates.set(key, candidate);
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1215
1323
|
if (seen.has(key)) continue;
|
|
1216
1324
|
seen.add(key);
|
|
1217
1325
|
results.push({ ...result, row: y });
|
|
@@ -1220,7 +1328,27 @@ export function decodeOneD(image, options = {}) {
|
|
|
1220
1328
|
}
|
|
1221
1329
|
}
|
|
1222
1330
|
|
|
1223
|
-
|
|
1331
|
+
if (cameraProfile) {
|
|
1332
|
+
for (const candidate of cameraCandidates.values()) {
|
|
1333
|
+
// A complete symbol must survive at least two nearby scan samples. This
|
|
1334
|
+
// rejects isolated run coincidences without imposing a payload length.
|
|
1335
|
+
if (candidate.rows.size < 2) continue;
|
|
1336
|
+
results.push(cameraMetadata(candidate.result, candidate.geometry, candidate.rows, {
|
|
1337
|
+
...options,
|
|
1338
|
+
cameraRotation: candidate.rotation,
|
|
1339
|
+
}));
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// A valid EAN/UPC parent is substantially more constrained than a generic
|
|
1344
|
+
// narrow/wide candidate. Suppress competing interpretations of the same
|
|
1345
|
+
// scanline, while retaining symbols detected on other rows.
|
|
1346
|
+
const eanRows = new Set(results
|
|
1347
|
+
.filter((result) => isEANParentFormat(result.format))
|
|
1348
|
+
.map((result) => result.row));
|
|
1349
|
+
return eanRows.size === 0
|
|
1350
|
+
? results
|
|
1351
|
+
: results.filter((result) => !eanRows.has(result.row) || isEANParentFormat(result.format));
|
|
1224
1352
|
}
|
|
1225
1353
|
|
|
1226
1354
|
/**
|