@sythos/js_barcode_universal 1.5.4 → 1.5.6

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.4"></script>
122
- <script src="https://cdn.jsdelivr.net/npm/@sythos/js_barcode_universal@1.5.4"></script>
121
+ <script src="https://unpkg.com/@sythos/js_barcode_universal@1.5.6"></script>
122
+ <script src="https://cdn.jsdelivr.net/npm/@sythos/js_barcode_universal@1.5.6"></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,25 @@ 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 `[]`. No partial, structurally inconsistent or low-confidence value is
212
+ emitted to the caller.
213
+
214
+ Camera-profile 1D results add `confidence` (0–1), `bounds`, `rotation`, and
215
+ `quality: { quietZone, checksum, rows, consistency }`. `bounds` is reported in the raster
216
+ orientation that was scanned; unavailable quality data is represented by `null` where applicable.
217
+
199
218
  ---
200
219
 
201
220
  ## Supported formats
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Sythos Barcode Suite v1.5.4
2
+ * Sythos Barcode Suite v1.5.6
3
3
  *
4
4
  * MIT License
5
5
  *
@@ -3935,6 +3935,51 @@ function withoutEANAddon(result) {
3935
3935
  return parent;
3936
3936
  }
3937
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
+
3938
3983
  /**
3939
3984
  * Read every linear symbol found in a binarized image.
3940
3985
  *
@@ -3943,10 +3988,13 @@ function withoutEANAddon(result) {
3943
3988
  * @param {string[]} [options.formats] Restrict to these format ids.
3944
3989
  * @param {number} [options.rows] How many horizontal slices to try.
3945
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.
3946
3993
  * @returns {Array<{format: string, text: string, row: number}>}
3947
3994
  */
3948
3995
  function decodeOneD(image, options = {}) {
3949
- const { formats = null, rows = 15, tryHarder = true } = options;
3996
+ const { formats = null, rows = 15, tryHarder = true, profile = null } = options;
3997
+ const cameraProfile = profile === 'camera';
3950
3998
  const enabled = formats ? new Set(formats) : null;
3951
3999
  const active = DECODERS.filter(([id]) => {
3952
4000
  if (!enabled) return true;
@@ -3965,13 +4013,15 @@ function decodeOneD(image, options = {}) {
3965
4013
  const seen = new Set();
3966
4014
  const height = image.height;
3967
4015
  const buffer = new Uint8Array(image.width);
4016
+ const cameraCandidates = new Map();
3968
4017
 
3969
4018
  // Sample rows from the middle outward: symbols are usually centred, and the
3970
4019
  // middle of a linear barcode is the part least likely to be clipped.
3971
4020
  const middle = height >> 1;
3972
- 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));
3973
4023
 
3974
- for (let attempt = 0; attempt < rows; attempt++) {
4024
+ for (let attempt = 0; attempt < sampleRows; attempt++) {
3975
4025
  const delta = Math.ceil(attempt / 2) * step * (attempt % 2 === 0 ? 1 : -1);
3976
4026
  const y = middle + delta;
3977
4027
  if (y < 0 || y >= height) continue;
@@ -3984,7 +4034,12 @@ function decodeOneD(image, options = {}) {
3984
4034
  for (const [id, decoder] of active) {
3985
4035
  let result = null;
3986
4036
  try {
3987
- 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);
3988
4043
  } catch {
3989
4044
  result = null; // a malformed candidate is not an error
3990
4045
  }
@@ -4016,6 +4071,20 @@ function decodeOneD(image, options = {}) {
4016
4071
 
4017
4072
  const addonKey = result.addon ? `:${result.addon.format}:${result.addon.text}` : '';
4018
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
+ }
4019
4088
  if (seen.has(key)) continue;
4020
4089
  seen.add(key);
4021
4090
  results.push({ ...result, row: y });
@@ -4024,6 +4093,18 @@ function decodeOneD(image, options = {}) {
4024
4093
  }
4025
4094
  }
4026
4095
 
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
+
4027
4108
  // A valid EAN/UPC parent is substantially more constrained than a generic
4028
4109
  // narrow/wide candidate. Suppress competing interpretations of the same
4029
4110
  // scanline, while retaining symbols detected on other rows.
@@ -16620,6 +16701,10 @@ function encode(text, options = {}) {
16620
16701
  * @property {boolean} [certified] Whether the profile is certified by its originator.
16621
16702
  * @property {object} [canvas] Canvas reservation metadata for the Sythos profile.
16622
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.
16623
16708
  * @property {boolean} [gs1] Whether the physical symbol is classified as GS1.
16624
16709
  * @property {string} [symbologyIdentifier] GS1 symbology identifier.
16625
16710
  * @property {Array<{ai:string,value:string,fixed?:boolean}>} [elements] Parsed GS1 Application Identifier fields.
@@ -16640,12 +16725,13 @@ function encode(text, options = {}) {
16640
16725
  * @param {string[]} [options.formats] Restrict to these format ids.
16641
16726
  * @param {boolean} [options.tryHarder] Retry inverted and rotated. Default true.
16642
16727
  * @param {'global'|'hybrid'|'auto'} [options.binarizer]
16728
+ * @param {'camera'} [options.profile] Opt-in strict camera profile for validated 1D reads.
16643
16729
  * @param {object} [options.frameqr] Sythos Canvas QR detector options when
16644
16730
  * the profile marker is not preserved through image rendering.
16645
16731
  * @returns {DecodeResult[]}
16646
16732
  */
16647
16733
  function decode(image, options = {}) {
16648
- const { formats = null, tryHarder = true, binarizer = 'auto' } = options;
16734
+ const { formats = null, tryHarder = true, binarizer = 'auto', profile = null } = options;
16649
16735
  const want = formats ? new Set(formats.map((f) => f.toLowerCase())) : null;
16650
16736
  const wantQR = !want || want.has('qr') || want.has('qrcode');
16651
16737
  const wantDataMatrix = !want || want.has('datamatrix') || want.has('data-matrix');
@@ -16785,7 +16871,24 @@ function decode(image, options = {}) {
16785
16871
  const oneDFormats = want
16786
16872
  ? [...want].filter((f) => f in ONED_FORMATS || oneDAliases.has(f))
16787
16873
  : null;
16788
- 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) {
16789
16892
  const { row, ...publicFound } = found;
16790
16893
  void row;
16791
16894
  if (publicFound.gs1) {
@@ -16830,7 +16933,11 @@ function decode(image, options = {}) {
16830
16933
  && (binarizer === 'auto' || binarizer === 'hybrid')
16831
16934
  && retryFormats.length > 0;
16832
16935
 
16833
- 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
+ }
16834
16941
 
16835
16942
  const fallback = decode(image, {
16836
16943
  ...options,
@@ -16838,12 +16945,32 @@ function decode(image, options = {}) {
16838
16945
  binarizer: 'global',
16839
16946
  });
16840
16947
  const fallbackSeen = new Set();
16841
- return [...unique, ...fallback].filter((r) => {
16948
+ const merged = [...unique, ...fallback].filter((r) => {
16842
16949
  const key = `${r.format}:${r.text}`;
16843
16950
  if (fallbackSeen.has(key)) return false;
16844
16951
  fallbackSeen.add(key);
16845
16952
  return true;
16846
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;
16847
16974
  }
16848
16975
 
16849
16976
  /**
@@ -16860,7 +16987,7 @@ function decodeStrict(image, options) {
16860
16987
  }
16861
16988
 
16862
16989
  /** Library version, matching package.json. */
16863
- const VERSION = '1.5.4';
16990
+ const VERSION = '1.5.6';
16864
16991
 
16865
16992
  __exports.listFormats = listFormats;
16866
16993
  __exports.encode = encode;
@@ -1,5 +1,5 @@
1
1
  /*!
2
- * Sythos Barcode Suite v1.5.4
2
+ * Sythos Barcode Suite v1.5.6
3
3
  *
4
4
  * MIT License
5
5
  *
@@ -3936,6 +3936,51 @@ function withoutEANAddon(result) {
3936
3936
  return parent;
3937
3937
  }
3938
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
+
3939
3984
  /**
3940
3985
  * Read every linear symbol found in a binarized image.
3941
3986
  *
@@ -3944,10 +3989,13 @@ function withoutEANAddon(result) {
3944
3989
  * @param {string[]} [options.formats] Restrict to these format ids.
3945
3990
  * @param {number} [options.rows] How many horizontal slices to try.
3946
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.
3947
3994
  * @returns {Array<{format: string, text: string, row: number}>}
3948
3995
  */
3949
3996
  function decodeOneD(image, options = {}) {
3950
- const { formats = null, rows = 15, tryHarder = true } = options;
3997
+ const { formats = null, rows = 15, tryHarder = true, profile = null } = options;
3998
+ const cameraProfile = profile === 'camera';
3951
3999
  const enabled = formats ? new Set(formats) : null;
3952
4000
  const active = DECODERS.filter(([id]) => {
3953
4001
  if (!enabled) return true;
@@ -3966,13 +4014,15 @@ function decodeOneD(image, options = {}) {
3966
4014
  const seen = new Set();
3967
4015
  const height = image.height;
3968
4016
  const buffer = new Uint8Array(image.width);
4017
+ const cameraCandidates = new Map();
3969
4018
 
3970
4019
  // Sample rows from the middle outward: symbols are usually centred, and the
3971
4020
  // middle of a linear barcode is the part least likely to be clipped.
3972
4021
  const middle = height >> 1;
3973
- 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));
3974
4024
 
3975
- for (let attempt = 0; attempt < rows; attempt++) {
4025
+ for (let attempt = 0; attempt < sampleRows; attempt++) {
3976
4026
  const delta = Math.ceil(attempt / 2) * step * (attempt % 2 === 0 ? 1 : -1);
3977
4027
  const y = middle + delta;
3978
4028
  if (y < 0 || y >= height) continue;
@@ -3985,7 +4035,12 @@ function decodeOneD(image, options = {}) {
3985
4035
  for (const [id, decoder] of active) {
3986
4036
  let result = null;
3987
4037
  try {
3988
- 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);
3989
4044
  } catch {
3990
4045
  result = null; // a malformed candidate is not an error
3991
4046
  }
@@ -4017,6 +4072,20 @@ function decodeOneD(image, options = {}) {
4017
4072
 
4018
4073
  const addonKey = result.addon ? `:${result.addon.format}:${result.addon.text}` : '';
4019
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
+ }
4020
4089
  if (seen.has(key)) continue;
4021
4090
  seen.add(key);
4022
4091
  results.push({ ...result, row: y });
@@ -4025,6 +4094,18 @@ function decodeOneD(image, options = {}) {
4025
4094
  }
4026
4095
  }
4027
4096
 
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
+
4028
4109
  // A valid EAN/UPC parent is substantially more constrained than a generic
4029
4110
  // narrow/wide candidate. Suppress competing interpretations of the same
4030
4111
  // scanline, while retaining symbols detected on other rows.
@@ -16621,6 +16702,10 @@ function encode(text, options = {}) {
16621
16702
  * @property {boolean} [certified] Whether the profile is certified by its originator.
16622
16703
  * @property {object} [canvas] Canvas reservation metadata for the Sythos profile.
16623
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.
16624
16709
  * @property {boolean} [gs1] Whether the physical symbol is classified as GS1.
16625
16710
  * @property {string} [symbologyIdentifier] GS1 symbology identifier.
16626
16711
  * @property {Array<{ai:string,value:string,fixed?:boolean}>} [elements] Parsed GS1 Application Identifier fields.
@@ -16641,12 +16726,13 @@ function encode(text, options = {}) {
16641
16726
  * @param {string[]} [options.formats] Restrict to these format ids.
16642
16727
  * @param {boolean} [options.tryHarder] Retry inverted and rotated. Default true.
16643
16728
  * @param {'global'|'hybrid'|'auto'} [options.binarizer]
16729
+ * @param {'camera'} [options.profile] Opt-in strict camera profile for validated 1D reads.
16644
16730
  * @param {object} [options.frameqr] Sythos Canvas QR detector options when
16645
16731
  * the profile marker is not preserved through image rendering.
16646
16732
  * @returns {DecodeResult[]}
16647
16733
  */
16648
16734
  function decode(image, options = {}) {
16649
- const { formats = null, tryHarder = true, binarizer = 'auto' } = options;
16735
+ const { formats = null, tryHarder = true, binarizer = 'auto', profile = null } = options;
16650
16736
  const want = formats ? new Set(formats.map((f) => f.toLowerCase())) : null;
16651
16737
  const wantQR = !want || want.has('qr') || want.has('qrcode');
16652
16738
  const wantDataMatrix = !want || want.has('datamatrix') || want.has('data-matrix');
@@ -16786,7 +16872,24 @@ function decode(image, options = {}) {
16786
16872
  const oneDFormats = want
16787
16873
  ? [...want].filter((f) => f in ONED_FORMATS || oneDAliases.has(f))
16788
16874
  : null;
16789
- 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) {
16790
16893
  const { row, ...publicFound } = found;
16791
16894
  void row;
16792
16895
  if (publicFound.gs1) {
@@ -16831,7 +16934,11 @@ function decode(image, options = {}) {
16831
16934
  && (binarizer === 'auto' || binarizer === 'hybrid')
16832
16935
  && retryFormats.length > 0;
16833
16936
 
16834
- 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
+ }
16835
16942
 
16836
16943
  const fallback = decode(image, {
16837
16944
  ...options,
@@ -16839,12 +16946,32 @@ function decode(image, options = {}) {
16839
16946
  binarizer: 'global',
16840
16947
  });
16841
16948
  const fallbackSeen = new Set();
16842
- return [...unique, ...fallback].filter((r) => {
16949
+ const merged = [...unique, ...fallback].filter((r) => {
16843
16950
  const key = `${r.format}:${r.text}`;
16844
16951
  if (fallbackSeen.has(key)) return false;
16845
16952
  fallbackSeen.add(key);
16846
16953
  return true;
16847
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;
16848
16975
  }
16849
16976
 
16850
16977
  /**
@@ -16861,7 +16988,7 @@ function decodeStrict(image, options) {
16861
16988
  }
16862
16989
 
16863
16990
  /** Library version, matching package.json. */
16864
- const VERSION = '1.5.4';
16991
+ const VERSION = '1.5.6';
16865
16992
 
16866
16993
  __exports.listFormats = listFormats;
16867
16994
  __exports.encode = encode;
@@ -180,12 +180,14 @@
180
180
  return true;
181
181
  }
182
182
 
183
- function scanCanvas(canvas) {
183
+ function scanCanvas(canvas, profile) {
184
184
  var ctx = canvas.getContext('2d', { willReadFrequently: true });
185
185
  if (!ctx || !canvas.width || !canvas.height) return [];
186
186
  var image = ctx.getImageData(0, 0, canvas.width, canvas.height);
187
187
  try {
188
- return B.decode(image, { tryHarder: true });
188
+ var options = { tryHarder: true };
189
+ if (profile) options.profile = profile;
190
+ return B.decode(image, options);
189
191
  } catch (e) {
190
192
  return [];
191
193
  }
@@ -325,7 +327,7 @@
325
327
  .drawImage(video, 0, 0, work.width, work.height);
326
328
 
327
329
  frames++;
328
- var hits = scanCanvas(work);
330
+ var hits = scanCanvas(work, 'camera');
329
331
  if (hits.length) {
330
332
  show(hits, 'camera');
331
333
  stopCamera();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sythos/js_barcode_universal",
3
- "version": "1.5.4",
3
+ "version": "1.5.6",
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.4';
637
+ export const VERSION = '1.5.6';
@@ -1170,6 +1170,51 @@ function withoutEANAddon(result) {
1170
1170
  return parent;
1171
1171
  }
1172
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
+
1173
1218
  /**
1174
1219
  * Read every linear symbol found in a binarized image.
1175
1220
  *
@@ -1178,10 +1223,13 @@ function withoutEANAddon(result) {
1178
1223
  * @param {string[]} [options.formats] Restrict to these format ids.
1179
1224
  * @param {number} [options.rows] How many horizontal slices to try.
1180
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.
1181
1228
  * @returns {Array<{format: string, text: string, row: number}>}
1182
1229
  */
1183
1230
  export function decodeOneD(image, options = {}) {
1184
- const { formats = null, rows = 15, tryHarder = true } = options;
1231
+ const { formats = null, rows = 15, tryHarder = true, profile = null } = options;
1232
+ const cameraProfile = profile === 'camera';
1185
1233
  const enabled = formats ? new Set(formats) : null;
1186
1234
  const active = DECODERS.filter(([id]) => {
1187
1235
  if (!enabled) return true;
@@ -1200,13 +1248,15 @@ export function decodeOneD(image, options = {}) {
1200
1248
  const seen = new Set();
1201
1249
  const height = image.height;
1202
1250
  const buffer = new Uint8Array(image.width);
1251
+ const cameraCandidates = new Map();
1203
1252
 
1204
1253
  // Sample rows from the middle outward: symbols are usually centred, and the
1205
1254
  // middle of a linear barcode is the part least likely to be clipped.
1206
1255
  const middle = height >> 1;
1207
- 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));
1208
1258
 
1209
- for (let attempt = 0; attempt < rows; attempt++) {
1259
+ for (let attempt = 0; attempt < sampleRows; attempt++) {
1210
1260
  const delta = Math.ceil(attempt / 2) * step * (attempt % 2 === 0 ? 1 : -1);
1211
1261
  const y = middle + delta;
1212
1262
  if (y < 0 || y >= height) continue;
@@ -1219,7 +1269,12 @@ export function decodeOneD(image, options = {}) {
1219
1269
  for (const [id, decoder] of active) {
1220
1270
  let result = null;
1221
1271
  try {
1222
- 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);
1223
1278
  } catch {
1224
1279
  result = null; // a malformed candidate is not an error
1225
1280
  }
@@ -1251,6 +1306,20 @@ export function decodeOneD(image, options = {}) {
1251
1306
 
1252
1307
  const addonKey = result.addon ? `:${result.addon.format}:${result.addon.text}` : '';
1253
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
+ }
1254
1323
  if (seen.has(key)) continue;
1255
1324
  seen.add(key);
1256
1325
  results.push({ ...result, row: y });
@@ -1259,6 +1328,18 @@ export function decodeOneD(image, options = {}) {
1259
1328
  }
1260
1329
  }
1261
1330
 
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
+
1262
1343
  // A valid EAN/UPC parent is substantially more constrained than a generic
1263
1344
  // narrow/wide candidate. Suppress competing interpretations of the same
1264
1345
  // scanline, while retaining symbols detected on other rows.