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.