@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 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.3"></script>
122
- <script src="https://cdn.jsdelivr.net/npm/@sythos/js_barcode_universal@1.5.3"></script>
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.3
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 step = Math.max(1, Math.round(height / rows));
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 < rows; 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
- result = decoder(scan, options);
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
- const addonRequested = enabled.has('ean2') || enabled.has('ean5');
3965
- const isEANBase = result.format === 'ean13' || result.format === 'ean8' ||
3966
- result.format === 'upca' || result.format === 'upce';
3967
- if (isEANBase && addonRequested) {
3968
- if (!result.addon || !enabled.has(result.addon.format)) continue;
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
- return results;
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
- for (const found of decodeOneD(bits, { ...options, formats: oneDFormats, tryHarder })) {
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) return unique;
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
- return [...unique, ...fallback].filter((r) => {
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.3';
16990
+ const VERSION = '1.5.5';
16817
16991
 
16818
16992
  __exports.listFormats = listFormats;
16819
16993
  __exports.encode = encode;
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Sythos Barcode Suite v1.5.3
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 step = Math.max(1, Math.round(height / rows));
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 < rows; 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
- result = decoder(scan, options);
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
- const addonRequested = enabled.has('ean2') || enabled.has('ean5');
3966
- const isEANBase = result.format === 'ean13' || result.format === 'ean8' ||
3967
- result.format === 'upca' || result.format === 'upce';
3968
- if (isEANBase && addonRequested) {
3969
- if (!result.addon || !enabled.has(result.addon.format)) continue;
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
- return results;
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
- for (const found of decodeOneD(bits, { ...options, formats: oneDFormats, tryHarder })) {
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) return unique;
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
- return [...unique, ...fallback].filter((r) => {
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.3';
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",
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
- for (const found of decodeOneD(bits, { ...options, formats: oneDFormats, tryHarder })) {
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) return unique;
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
- return [...unique, ...fallback].filter((r) => {
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.3';
637
+ export const VERSION = '1.5.5';
@@ -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 step = Math.max(1, Math.round(height / rows));
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 < rows; 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
- result = decoder(scan, options);
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
- const addonRequested = enabled.has('ean2') || enabled.has('ean5');
1200
- const isEANBase = result.format === 'ean13' || result.format === 'ean8' ||
1201
- result.format === 'upca' || result.format === 'upce';
1202
- if (isEANBase && addonRequested) {
1203
- if (!result.addon || !enabled.has(result.addon.format)) continue;
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
- return results;
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
  /**