Skip to content

fix: decode EAN-2 extensions from the located guard, bound the add-on gap - #111

Merged
hschimke merged 1 commit into
rxing-core:mainfrom
porxyz:fix/ean2-extension-start-range
Oct 24, 2026
Merged

hschimke merged 1 commit into
rxing-core:mainfrom
porxyz:fix/ean2-extension-start-range

Conversation

@porxyz

@porxyz porxyz commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Problem

EAN-13, UPC-A and UPC-E symbols with a two-digit (EAN-2) add-on decode without the add-on: the result carries no UPC_EAN_EXTENSION or ISSUE_NUMBER metadata. Five-digit add-ons are not affected.

To reproduce with the image this PR adds (EAN-13 9771234567003 with add-on 05, the usual layout for a periodical's issue number):

use rxing::{BarcodeFormat, RXingResultMetadataType};

let result = rxing::helpers::detect_in_file(
    "test_resources/blackbox/upcean-extension-1/3.png",
    Some(BarcodeFormat::EAN_13),
)?;
println!("{}", result.getText()); // 9771234567003
println!(
    "{:?}",
    result
        .getRXingResultMetadata()
        .get(&RXingResultMetadataType::UPC_EAN_EXTENSION)
);
// main:        None
// this branch: Some(UpcEanExtension("05"))

Cause

UPCEANExtensionSupport::decodeRow (src/oned/upc_ean_extension_support.rs) locates the extension start guard and passes that range to the five-digit helper. The two-digit helper gets the guard pattern instead:

self.twoSupport
    .decodeRow(rowNumber, row, &Self::EXTENSION_START_PATTERN)

EXTENSION_START_PATTERN is [1, 1, 2], so UPCEANExtension2Support starts reading at bit 1 of the row rather than after the guard, and the two-digit decode never succeeds. ZXing passes extensionStartRange to both helpers.

The two helpers take differently shaped ranges: &[u32; 3] for two digits, &[usize; 2] for five. That is probably how the constant slipped in, since it is the only nearby value whose type fits.

Fix

  1. Pass the located range to the two-digit helper, converted to its [u32; 3]; only the first two slots are read. This keeps UPCEANExtension2Support::decodeRow's public signature. If you prefer, I can change it to &[usize; 2] to match the five-digit helper instead, which would be a breaking change.

  2. Bound the gap between the symbol and the add-on. Once two-digit decoding worked, zxing_bench_issue_1 and the two zxing_bench_grey_image_* tests in tests/github_issues.rs failed with "must detect 9 barcodes, found: 2". The extension guard search has no distance limit, so the EAN-13 in that image picked up a false add-on 19 from the UPC-E about 40 modules to its right. The extra result points then derailed GenericMultipleBarcodeReader.

    The five-digit path has the same exposure, but a false match there needs five valid characters and a matching check value, so it is rarer. ZXing Java has the same unbounded search. zxing-cpp limits the gap: ODMultiUPCEANReader.cpp accepts at most 3.5 times the main start guard's width (about 10.5 modules).

    GS1 puts an add-on 7 to 12 modules after the main symbol. This PR rejects an extension start guard that begins more than 4 guard widths (16 modules) after the symbol's end. That covers the GS1 range with margin for print gain and rules out the neighbouring symbol. The check runs before both helpers, so it applies to five-digit add-ons too. With it, all 9 symbols are found again.

Test

This PR adds 3.png to test_resources/blackbox/upcean-extension-1, with its expected text and metadata (UPC_EAN_EXTENSION=05, ISSUE_NUMBER=5), and raises that black-box test's thresholds from 2 to 3. The image is a synthetic symbol made with an encoder I'm writing, at 3 px per module.

Without the fix, cargo test --release --test upc_ean_extension_black_box_1_testcase fails. The new image decodes but has no UPC_EAN_EXTENSION or ISSUE_NUMBER metadata:

INFO ::  2 of 3 images passed (3 required)

With the fix, it passes. The existing zxing_bench_* tests cover the gap bound: without the bound they fail as described above, and with it they pass.

I also decoded 1,200 synthetic symbols: every add-on value from 00 to 99, on EAN-13, UPC-A and UPC-E, in four variants:

  • 3 px per module with the minimum gap (7 modules, 9 for UPC-A);
  • 2 px per module with the minimum gap;
  • 2 px per module with a 12-module gap;
  • 3 px per module with a 12-module gap.

main reads none of the 1,200 add-ons and this branch reads all of them, so the bound accepts the GS1 maximum gap even at 2 px per module.

Both CI test steps, cargo test --workspace --release and the same with --no-default-features, pass on this branch.


I found this while using rxing to check a barcode encoder by decoding its output. The change and this description were prepared with AI assistance (Claude, via Claude Code). I reviewed the change and the test results before opening this PR. It is contributed under the Apache License 2.0.

🤖 Generated with Claude Code

… gap

UPCEANExtensionSupport::decodeRow gave UPCEANExtension2Support the guard
pattern constant [1, 1, 2] instead of the range where the extension start
guard was found, so every attached two-digit extension was dropped. ZXing
passes extensionStartRange to both helpers.

With two-digit extensions decoding, the unbounded guard search could read
a neighbouring symbol as an add-on: in the zxing_bench images in
tests/github_issues.rs an EAN-13 picked up "19" from the UPC-E about 40
modules to its right, and multi-barcode detection fell from 9 symbols to
2. An add-on starts 7 to 12 modules after the main symbol, so reject an
extension start guard more than 4 guard widths (16 modules) away, as
zxing-cpp bounds the gap.

Adds an EAN-13 + EAN-2 image to the upcean-extension-1 black-box set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hschimke
hschimke merged commit 2415e4d into rxing-core:main Oct 24, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants