From 62266e2ab8aad8a54e6d0482edbe214920e66867 Mon Sep 17 00:00:00 2001 From: Alex Russell Date: Fri, 25 Sep 2026 17:10:01 +0200 Subject: [PATCH] feat(render): make the QR quiet zone configurable A base URL with a scheme (https://sngn.top/i/ instead of SNGN.TOP/I/) is what makes a phone offer to open a label's QR rather than show it as text. The payload is one QR version larger, though, and in the 12mm presets the QR shares its row with the text, so at the spec's 4-module quiet zone it drops to 2px per module and fails a degraded read. Add render.qr_quiet_zone (default 4, 1..8), threaded through the worker, RenderConfig and PresetContext to every preset QR, plus a --qr-quiet-zone flag on the render commands. Measured through the wire bytes on the part preset at 12mm: 3 does not help, 2 brings the prefixed code back to 3px per module and survives the phone-camera proxy. That table is pinned in test_scannability and in the README. --- README.md | 13 +++++++++++++ deploy/agent.toml.example | 4 ++++ src/labelfab/agent/config.py | 6 ++++++ src/labelfab/agent/worker.py | 1 + src/labelfab/cli.py | 9 +++++++++ src/labelfab/render/__init__.py | 3 ++- src/labelfab/render/presets.py | 7 ++++--- tests/test_agent_config.py | 25 +++++++++++++++++++++++++ tests/test_render.py | 9 +++++++++ tests/test_scannability.py | 26 ++++++++++++++++++++++++++ 10 files changed, 99 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 71c50f9..1573112 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,19 @@ actually care about. Set `render.qr_base_url` to a short redirector and encode a a full URL. The table is pinned in `tests/test_scannability.py`, so if layout changes the test fails and this table gets revisited. +A scheme in the base (`https://sngn.top/i/` rather than `SNGN.TOP/I/`) is worth having, +since phones then offer to open the code instead of showing it as text. It costs one QR +version, though, and in the 12mm presets the QR shares its row with the text. Pay for it +with `render.qr_quiet_zone`: + +| Base on the `part` preset, 12mm | quiet zone 4 | 3 | 2 | +|---|:--:|:--:|:--:| +| `SNGN.TOP/I/` | ✅ | ✅ | ✅ | +| `https://sngn.top/i/` | ❌ | ❌ | ✅ | + +That is the degraded read again. At 2 the prefixed code is back to 3px per module and +physically larger than the bare one at 4. Pinned in the same test file. + ## Layout | Package | What | diff --git a/deploy/agent.toml.example b/deploy/agent.toml.example index a97b2f1..744ca97 100644 --- a/deploy/agent.toml.example +++ b/deploy/agent.toml.example @@ -69,6 +69,10 @@ separator_mm = 2.0 [render] # A short redirector buys back QR module size; encode a token, not a full URL. qr_base_url = "" +# QR quiet zone in modules; the spec asks for 4. A base with a scheme +# (https://...) makes the code one version bigger, and on 12mm tape that only +# survives a phone-camera read at 2. Leave it at 4 for a bare base. +qr_quiet_zone = 4 threshold = 128 [spool] diff --git a/src/labelfab/agent/config.py b/src/labelfab/agent/config.py index 1d37230..8967e82 100644 --- a/src/labelfab/agent/config.py +++ b/src/labelfab/agent/config.py @@ -158,6 +158,12 @@ class StripSection(BaseModel): class RenderSection(BaseModel): qr_base_url: str = "" + #: QR quiet zone in modules. The spec asks for 4, and 4 is right for a bare code. + #: A base URL with a scheme (``https://...``) is what lowering it pays for: that + #: payload is one QR version larger, and in the 12mm presets the QR shares its row + #: with the text, so at 4 it drops to 2px per module. 3 still lands on 2px; 2 brings + #: it back to 3px, with the code physically larger than a bare one at 4. + qr_quiet_zone: int = Field(default=4, ge=1, le=8) threshold: int = Field(default=128, ge=0, le=255) diff --git a/src/labelfab/agent/worker.py b/src/labelfab/agent/worker.py index c57cce7..e0e2470 100644 --- a/src/labelfab/agent/worker.py +++ b/src/labelfab/agent/worker.py @@ -119,6 +119,7 @@ def __init__( def _render_cfg(self) -> RenderConfig: return RenderConfig( qr_base_url=self.config.render.qr_base_url, + qr_quiet_zone=self.config.render.qr_quiet_zone, threshold=self.config.render.threshold, rotation=self.config.tape.rotation, mirror=self.config.tape.mirror, diff --git a/src/labelfab/cli.py b/src/labelfab/cli.py index 0bd7a3d..3aff739 100644 --- a/src/labelfab/cli.py +++ b/src/labelfab/cli.py @@ -30,6 +30,7 @@ def _load_job(path: str) -> PrintJob: def _config(args) -> RenderConfig: return RenderConfig( qr_base_url=args.qr_base_url, + qr_quiet_zone=args.qr_quiet_zone, threshold=args.threshold, rotation=args.rotation, mirror=args.mirror, @@ -277,6 +278,14 @@ def cmd_probe(args) -> int: def _add_render_flags(p: argparse.ArgumentParser) -> None: p.add_argument("job", help="path to a job JSON file, or - for stdin") p.add_argument("--qr-base-url", default="", help="prefix codes with a short-link base") + p.add_argument( + "--qr-quiet-zone", + type=int, + default=4, + choices=range(1, 9), + metavar="{1..8}", + help="QR quiet zone in modules (spec: 4)", + ) p.add_argument("--threshold", type=int, default=128, help="1-bit cutoff (0-255)") p.add_argument("--rotation", type=int, default=270, choices=[0, 90, 180, 270]) p.add_argument("--mirror", action="store_true") diff --git a/src/labelfab/render/__init__.py b/src/labelfab/render/__init__.py index 3950c5a..fc32145 100644 --- a/src/labelfab/render/__init__.py +++ b/src/labelfab/render/__init__.py @@ -43,6 +43,7 @@ class RenderConfig: """Render-side knobs. Everything here is hardware- or taste-dependent.""" qr_base_url: str = "" + qr_quiet_zone: int = 4 threshold: int = 128 rotation: int = 270 mirror: bool = False @@ -51,7 +52,7 @@ class RenderConfig: def _tree(label: LabelSpec, cfg: RenderConfig) -> Box: if label.preset is not None: - ctx = _presets.PresetContext(qr_base_url=cfg.qr_base_url) + ctx = _presets.PresetContext(qr_base_url=cfg.qr_base_url, qr_quiet_zone=cfg.qr_quiet_zone) return _presets.get(label.preset)(label.vars, ctx) children = list(label.elements or []) if len(children) == 1 and isinstance(children[0], Box): diff --git a/src/labelfab/render/presets.py b/src/labelfab/render/presets.py index f13a1dd..26c22ba 100644 --- a/src/labelfab/render/presets.py +++ b/src/labelfab/render/presets.py @@ -21,8 +21,9 @@ class PresetContext: """Render-side configuration a preset may consult.""" - def __init__(self, qr_base_url: str = "") -> None: + def __init__(self, qr_base_url: str = "", qr_quiet_zone: int = 4) -> None: self.qr_base_url = qr_base_url + self.qr_quiet_zone = qr_quiet_zone def qr_value(self, code: str) -> str: """Prefer a short redirector: QR module size, not capacity, is the limit. @@ -54,7 +55,7 @@ def qr_caption(v: Mapping[str, str], ctx: PresetContext) -> Box: direction="row", gap_mm=1.0, children=[ - QrElement(value=ctx.qr_value(_g(v, "code", "pk"))), + QrElement(value=ctx.qr_value(_g(v, "code", "pk")), quiet_zone=ctx.qr_quiet_zone), Box( direction="col", align="start", @@ -115,7 +116,7 @@ def location(v: Mapping[str, str], ctx: PresetContext) -> Box: ), ], ), - QrElement(value=ctx.qr_value(_g(v, "code", "pk"))), + QrElement(value=ctx.qr_value(_g(v, "code", "pk")), quiet_zone=ctx.qr_quiet_zone), ], ) diff --git a/tests/test_agent_config.py b/tests/test_agent_config.py index 8195462..97e59e6 100644 --- a/tests/test_agent_config.py +++ b/tests/test_agent_config.py @@ -64,3 +64,28 @@ def test_an_unknown_density_is_rejected_at_load(): with pytest.raises(ValidationError, match="not one of"): DeviceSection(density=3) + + +def test_qr_quiet_zone_defaults_to_the_spec_and_reaches_the_renderer(tmp_path, harness): + """A value that stopped at the config section would still print at 4, so check it + arrives in the RenderConfig the worker hands to the renderer.""" + assert Config().render.qr_quiet_zone == 4 + + toml = tmp_path / "agent.toml" + toml.write_text("[render]\nqr_quiet_zone = 2\n") + assert load(toml).render.qr_quiet_zone == 2 + + h = harness() + h.config.render.qr_quiet_zone = 2 + assert h.worker._render_cfg().qr_quiet_zone == 2 + + +def test_a_quiet_zone_outside_the_contract_is_rejected_at_load(): + import pytest + from pydantic import ValidationError + + from labelfab.agent.config import RenderSection + + for bad in (0, 9): + with pytest.raises(ValidationError): + RenderSection(qr_quiet_zone=bad) diff --git a/tests/test_render.py b/tests/test_render.py index 86c346d..4599977 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -110,6 +110,15 @@ def test_qr_base_url_is_applied_by_presets(): assert plain.tobytes() != prefixed.tobytes() +def test_qr_quiet_zone_is_applied_by_presets(): + spec = LabelSpec(preset="part", vars={"code": "PA39", "title": "t"}, length_mm=30) + spec_default = render_label(spec, TAPE_12, RenderConfig(qr_base_url="https://sngn.top/i/")) + tighter = render_label( + spec, TAPE_12, RenderConfig(qr_base_url="https://sngn.top/i/", qr_quiet_zone=2) + ) + assert spec_default.tobytes() != tighter.tobytes() + + # --------------------------------------------------------------------------- # # Barcode # --------------------------------------------------------------------------- # diff --git a/tests/test_scannability.py b/tests/test_scannability.py index a37379e..ff76578 100644 --- a/tests/test_scannability.py +++ b/tests/test_scannability.py @@ -97,6 +97,32 @@ def test_qr_still_decodes_at_phone_camera_resolution(payload): ] +#: A scheme in the short-link base is paid for in quiet zone. Measured on the part +#: preset at 12mm, where the QR shares its row with the text: the prefixed payload is +#: one QR version bigger, so at the spec's 4 modules it drops to 2px per module and +#: fails a degraded read. 3 does not help. 2 brings it back to 3px. The bare base +#: survives at any setting, which is why 4 stays the default. +PRESET_QUIET_ZONE = [ + ("SNGN.TOP/I/", 4, True), + ("https://sngn.top/i/", 4, False), + ("https://sngn.top/i/", 3, False), + ("https://sngn.top/i/", 2, True), +] + + +@pytest.mark.parametrize(("base", "quiet_zone", "survives_degraded"), PRESET_QUIET_ZONE) +def test_a_scheme_in_the_base_costs_two_quiet_zone_modules(base, quiet_zone, survives_degraded): + cfg = RenderConfig(qr_base_url=base, qr_quiet_zone=quiet_zone) + label = LabelSpec( + preset="part", + vars={"code": "PA39", "title": "INV-PA39 · A1533", "sub": "iPhone 5s"}, + length_mm=30, + ) + img = _through_the_wire(label, TAPE_12, cfg) + assert _decode(img) == f"{base}PA39", "should always decode at 1:1" + assert (_decode(_as_phone_camera(img)) == f"{base}PA39") is survives_degraded + + @pytest.mark.parametrize(("tape_mm", "payload", "survives_degraded"), DENSITY_CLIFF) def test_density_cliff_is_where_we_think_it_is(tape_mm, payload, survives_degraded): """Pin the payload lengths that survive a degraded scan on each tape width.