From 0088facd3bc6c5bef8f60677205e9f54a29a93ca Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:02:58 +0200 Subject: [PATCH 01/24] feat(barcodes): QR, Code 128 and EAN-13 encoders The new barcodes addon starts with the encoders. Code 128 switches between code sets B and C on its own, EAN-13 adds or verifies the check digit, and QR codes come from segno, a pure-Python dependency (BSD-3). Refs #529 --- debian/control | 2 +- pixi.lock | 10 + pixi.toml | 1 + .../barcodes/__init__.py | 1 + .../barcodes/encoders.py | 332 ++++++++++++++++++ .../tests/test_encoders.py | 119 +++++++ requirements.txt | 1 + 7 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/__init__.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/encoders.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_encoders.py diff --git a/debian/control b/debian/control index fcac5015a..ae7f7c186 100644 --- a/debian/control +++ b/debian/control @@ -29,7 +29,7 @@ Depends: ${shlibs:Depends}, ${misc:Depends}, ${python3:Depends}, python3-pypdf, python3-yaml, python3-aiohttp, python3-websockets, python3-blinker, python3-platformdirs, python3-opengl, desktop-file-utils, python3-svgelements, python3-semver, python3-fitz, python3-serial, - python3-rpyc, python3-zeroconf + python3-rpyc, python3-zeroconf, python3-segno Description: Desktop application for laser cutting and engraving Rayforge is a powerful, open-source, and cross-platform software for controlling your laser cutter and engraver. diff --git a/pixi.lock b/pixi.lock index 467bf6e67..b07b1fd2f 100644 --- a/pixi.lock +++ b/pixi.lock @@ -288,6 +288,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz - pypi: https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl @@ -517,6 +518,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz - pypi: https://files.pythonhosted.org/packages/e4/62/c3c23511ebe3d76dc679db0bb045dfd20ed3452bec12dcbbe8d655498a7d/vtracer-0.6.15.tar.gz @@ -974,6 +976,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/cf/4c/c73f828fdbcd37eaf21d08fa852544a3ca7c2dbb3ea76873d64f2ea413d1/opencv_python-5.0.0.93-cp37-abi3-manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl @@ -1231,6 +1234,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c8/c3/0bebaaec98aa7cb43e04a8a6079b4aecb83338f6b4b3a32eb837c4c484ea/raydriver-0.3.0-cp311-abi3-macosx_11_0_arm64.whl - pypi: https://files.pythonhosted.org/packages/cd/5b/ff5fc58fa2427c30dea74c90053f4fc5eda1e7f3833ed3ecc7147fe2b311/tornado-6.5.10-cp39-abi3-macosx_10_9_universal2.whl - pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl + - pypi: https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl @@ -9515,6 +9519,7 @@ packages: - pyyaml==6.0.3 - ruida-pa==0.22.0 - scipy==1.18.1 + - segno==1.6.6 - semver==3.1.0 - svgelements==1.9.6 - trimesh==5.1.1 @@ -10353,6 +10358,11 @@ packages: - sqlparse>=0.5.5 ; extra == 'sql' - sqlframe>=3.22.0,!=3.39.3 ; extra == 'sqlframe' requires_python: '>=3.10' +- pypi: https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl + name: segno + version: 1.6.6 + sha256: 28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7 + requires_python: '>=3.5' - pypi: https://files.pythonhosted.org/packages/d7/8b/f837f52905395ba4510fe61f753c24833fb0a9c76e21267bb9f828b664a9/filelock-4.1.1-py3-none-any.whl name: filelock version: 4.1.1 diff --git a/pixi.toml b/pixi.toml index c06d8149f..980abe608 100644 --- a/pixi.toml +++ b/pixi.toml @@ -55,6 +55,7 @@ pymupdf = "==1.28.2" pyserial = "==3.5" pyvips = "==3.2.0" PyYAML = "==6.0.3" +segno = "==1.6.6" semver = "==3.1.0" trimesh = "==5.1.1" vtracer = "==0.6.15" diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/__init__.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/__init__.py new file mode 100644 index 000000000..97ebf103d --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/__init__.py @@ -0,0 +1 @@ +"""Barcode and QR code workpieces generated from templates.""" diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/encoders.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/encoders.py new file mode 100644 index 000000000..196ed6d68 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/encoders.py @@ -0,0 +1,332 @@ +""" +Barcode encoders: content in, dark/light modules out. + +Code 128 and EAN-13 are encoded here; QR codes come from segno. Every +encoder returns rows of modules, ``True`` for dark, without quiet zone. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass, field +from enum import Enum + +import segno + +Modules = tuple[tuple[bool, ...], ...] + +_CODE128_PATTERNS = [ + "212222", + "222122", + "222221", + "121223", + "121322", + "131222", + "122213", + "122312", + "132212", + "221213", + "221312", + "231212", + "112232", + "122132", + "122231", + "113222", + "123122", + "123221", + "223211", + "221132", + "221231", + "213212", + "223112", + "312131", + "311222", + "321122", + "321221", + "312212", + "322112", + "322211", + "212123", + "212321", + "232121", + "111323", + "131123", + "131321", + "112313", + "132113", + "132311", + "211313", + "231113", + "231311", + "112133", + "112331", + "132131", + "113123", + "113321", + "133121", + "313121", + "211331", + "231131", + "213113", + "213311", + "213131", + "311123", + "311321", + "331121", + "312113", + "312311", + "332111", + "314111", + "221411", + "431111", + "111224", + "111422", + "121124", + "121421", + "141122", + "141221", + "112214", + "112412", + "122114", + "122411", + "142112", + "142211", + "241211", + "221114", + "413111", + "241112", + "134111", + "111242", + "121142", + "121241", + "114212", + "124112", + "124211", + "411212", + "421112", + "421211", + "212141", + "214121", + "412121", + "111143", + "111341", + "131141", + "114113", + "114311", + "411113", + "411311", + "113141", + "114131", + "311141", + "411131", + "211412", + "211214", + "211232", + "2331112", +] + +_CODE_C = 99 +_CODE_B = 100 +_START_B = 104 +_START_C = 105 +_STOP = 106 + +_EAN_L = [ + "0001101", + "0011001", + "0010011", + "0111101", + "0100011", + "0110001", + "0101111", + "0111011", + "0110111", + "0001011", +] +_EAN_R = tuple( + "".join("1" if bit == "0" else "0" for bit in code) for code in _EAN_L +) +_EAN_G = tuple(code[::-1] for code in _EAN_R) +_EAN_PARITY = [ + "LLLLLL", + "LLGLGG", + "LLGGLG", + "LLGGGL", + "LGLLGG", + "LGGLLG", + "LGGGLL", + "LGLGLG", + "LGLGGL", + "LGGLGL", +] + + +class Symbology(Enum): + QR = "qr" + CODE128 = "code128" + EAN13 = "ean13" + + +class QrErrorCorrection(Enum): + L = "L" + M = "M" + Q = "Q" + H = "H" + + +MIN_QUIET_ZONE = { + Symbology.QR: 4, + Symbology.CODE128: 10, + Symbology.EAN13: 9, +} + + +@dataclass(frozen=True) +class Encoded: + """ + Encoded modules. ``guards`` marks the EAN-13 guard bar columns, + which are drawn longer when the digits are printed underneath. + ``text`` is the human-readable text for 1D codes. + """ + + modules: Modules + text: str = "" + guards: frozenset[int] = field(default_factory=frozenset) + + @property + def width(self) -> int: + return len(self.modules[0]) if self.modules else 0 + + @property + def height(self) -> int: + return len(self.modules) + + +def _widths_to_modules(patterns: Sequence[str]) -> tuple[bool, ...]: + modules: list[bool] = [] + for pattern in patterns: + dark = True + for width in pattern: + modules.extend([dark] * int(width)) + dark = not dark + return tuple(modules) + + +def _digit_run(text: str, start: int) -> int: + end = start + while end < len(text) and text[end].isdigit(): + end += 1 + return end - start + + +def _code128_values(text: str) -> list[int]: + """Symbol values with automatic code set B/C switching.""" + run = _digit_run(text, 0) + use_c = run >= 4 or (run == len(text) and run >= 2 and run % 2 == 0) + values = [_START_C if use_c else _START_B] + i = 0 + while i < len(text): + run = _digit_run(text, i) + if use_c: + if run >= 2: + values.append(int(text[i : i + 2])) + i += 2 + continue + values.append(_CODE_B) + use_c = False + continue + at_end = i + run == len(text) + if run >= 6 or (at_end and run >= 4): + if run % 2: + values.append(_code128_b_value(text[i])) + i += 1 + values.append(_CODE_C) + use_c = True + continue + values.append(_code128_b_value(text[i])) + i += 1 + return values + + +def _code128_b_value(char: str) -> int: + code = ord(char) + if not 32 <= code <= 127: + raise ValueError( + f"Code 128 cannot encode {char!r}; use printable ASCII" + ) + return code - 32 + + +def encode_code128(text: str) -> Encoded: + """Encodes printable ASCII as Code 128, switching between B and C.""" + if not text: + raise ValueError("Code 128 needs at least one character") + values = _code128_values(text) + checksum = values[0] + for position, value in enumerate(values[1:], start=1): + checksum += position * value + values.append(checksum % 103) + values.append(_STOP) + row = _widths_to_modules([_CODE128_PATTERNS[v] for v in values]) + return Encoded(modules=(row,), text=text) + + +def ean13_check_digit(digits: str) -> int: + """The check digit for the first twelve digits of an EAN-13.""" + total = sum( + int(digit) * (3 if index % 2 else 1) + for index, digit in enumerate(digits[:12]) + ) + return (10 - total % 10) % 10 + + +def encode_ean13(text: str) -> Encoded: + """ + Encodes 12 digits (the check digit is added) or 13 digits (the + check digit is verified) as EAN-13. + """ + digits = text.strip() + if not digits.isdigit() or len(digits) not in (12, 13): + raise ValueError("EAN-13 needs 12 or 13 digits") + check = ean13_check_digit(digits) + if len(digits) == 13 and int(digits[12]) != check: + raise ValueError( + f"Wrong EAN-13 check digit {digits[12]}, expected {check}" + ) + digits = digits[:12] + str(check) + parity = _EAN_PARITY[int(digits[0])] + bits = "101" + for digit, kind in zip(digits[1:7], parity): + table = _EAN_L if kind == "L" else _EAN_G + bits += table[int(digit)] + bits += "01010" + for digit in digits[7:]: + bits += _EAN_R[int(digit)] + bits += "101" + guards = frozenset((0, 1, 2, 45, 46, 47, 48, 49, 92, 93, 94)) + row = tuple(bit == "1" for bit in bits) + return Encoded(modules=(row,), text=digits, guards=guards) + + +def encode_qr( + text: str, error: QrErrorCorrection = QrErrorCorrection.M +) -> Encoded: + """Encodes text as the smallest QR code that holds it.""" + if not text: + raise ValueError("A QR code needs some content") + qr = segno.make_qr(text, error=error.value, boost_error=False) + modules = tuple(tuple(bool(m) for m in row) for row in qr.matrix) + return Encoded(modules=modules) + + +def encode( + symbology: Symbology, + text: str, + error: QrErrorCorrection = QrErrorCorrection.M, +) -> Encoded: + """Encodes *text* in the given symbology.""" + if symbology is Symbology.QR: + return encode_qr(text, error) + if symbology is Symbology.CODE128: + return encode_code128(text) + return encode_ean13(text) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_encoders.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_encoders.py new file mode 100644 index 000000000..4092ce5fb --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_encoders.py @@ -0,0 +1,119 @@ +import pytest +from barcodes.encoders import ( + _CODE128_PATTERNS, + QrErrorCorrection, + Symbology, + _code128_values, + ean13_check_digit, + encode, + encode_code128, + encode_ean13, + encode_qr, +) + +START_B, START_C, CODE_B, CODE_C, STOP = 104, 105, 100, 99, 106 + + +class TestCode128: + def test_patterns_are_well_formed(self): + assert len(_CODE128_PATTERNS) == 107 + assert len(set(_CODE128_PATTERNS)) == 107 + assert all(sum(map(int, p)) == 11 for p in _CODE128_PATTERNS[:106]) + assert sum(map(int, _CODE128_PATTERNS[STOP])) == 13 + + def test_text_uses_code_set_b(self): + assert _code128_values("Ab") == [START_B, 33, 66] + + def test_long_digit_run_starts_in_c(self): + assert _code128_values("123456") == [START_C, 12, 34, 56] + + def test_odd_digits_finish_in_b(self): + assert _code128_values("12345") == [START_C, 12, 34, CODE_B, 21] + + def test_switches_to_c_for_a_long_run(self): + values = _code128_values("X123456") + assert values == [START_B, 56, CODE_C, 12, 34, 56] + + def test_odd_run_keeps_one_digit_in_b(self): + values = _code128_values("X1234567") + assert values == [START_B, 56, 17, CODE_C, 23, 45, 67] + + def test_short_runs_stay_in_b(self): + assert _code128_values("SN-12") == [START_B, 51, 46, 13, 17, 18] + + def test_checksum_and_length(self): + encoded = encode_code128("PJJ123C") + values = _code128_values("PJJ123C") + modules = len(values) * 11 + 11 + 13 + assert encoded.width == modules + assert encoded.height == 1 + assert encoded.modules[0][0] is True + assert encoded.modules[0][-1] is True + assert encoded.text == "PJJ123C" + + def test_rejects_non_ascii_and_empty(self): + with pytest.raises(ValueError): + encode_code128("é") + with pytest.raises(ValueError): + encode_code128("") + + +class TestEan13: + def test_check_digit(self): + assert ean13_check_digit("590123412345") == 7 + assert ean13_check_digit("400638133393") == 1 + + def test_adds_the_check_digit(self): + encoded = encode_ean13("590123412345") + assert encoded.text == "5901234123457" + assert encoded.width == 95 + + def test_verifies_the_check_digit(self): + assert encode_ean13("4006381333931").text == "4006381333931" + with pytest.raises(ValueError, match="check digit"): + encode_ean13("4006381333932") + + def test_rejects_wrong_input(self): + for text in ("12345", "12345678901a", "12345678901234"): + with pytest.raises(ValueError): + encode_ean13(text) + + def test_guards(self): + encoded = encode_ean13("590123412345") + row = encoded.modules[0] + assert [row[i] for i in (0, 1, 2)] == [True, False, True] + assert [row[i] for i in range(45, 50)] == [ + False, + True, + False, + True, + False, + ] + assert {0, 2, 46, 48, 92, 94} <= encoded.guards + + +class TestQr: + def test_square_matrix(self): + encoded = encode_qr("https://rayforge.org") + assert encoded.width == encoded.height + assert (encoded.width - 17) % 4 == 0 + + def test_finder_pattern(self): + row = encode_qr("x").modules[0] + assert row[:7] == (True,) * 7 + + def test_higher_error_correction_needs_more_modules(self): + text = "SN-0042 " * 6 + low = encode_qr(text, QrErrorCorrection.L) + high = encode_qr(text, QrErrorCorrection.H) + assert high.width > low.width + + def test_rejects_empty(self): + with pytest.raises(ValueError): + encode_qr("") + + +def test_encode_dispatches(): + assert encode(Symbology.EAN13, "590123412345").width == 95 + assert encode(Symbology.CODE128, "A").height == 1 + assert encode(Symbology.QR, "A").height > 1 diff --git a/requirements.txt b/requirements.txt index 779acbc2a..97e44a6a1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,6 +20,7 @@ pyvips==3.2.0 PyYAML==6.0.3 ruida-pa==0.22.0 scipy==1.18.1 +segno==1.6.6 semver==3.1.0 svgelements==1.9.6 trimesh==5.1.1 From 0840df94c994d096ffb6ac1ba591211de39b4ba7 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:06:15 +0200 Subject: [PATCH 02/24] feat(barcodes): symbol outlines with holes, text and invert Dark modules are merged into closed outlines with holes, in millimetres. 1D codes get a bar height and optional human-readable text (EAN-13 in its usual layout, with the guard bars reaching into the text). Invert draws a dark quiet zone around a light code. Every loop is oriented by its nesting depth, so non-zero and even-odd filling agree. Refs #529 --- .../barcodes/geometry.py | 166 ++++++++++++++++++ .../tests/test_geometry.py | 82 +++++++++ 2 files changed, 248 insertions(+) create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_geometry.py diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py new file mode 100644 index 000000000..a56e56a14 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py @@ -0,0 +1,166 @@ +""" +Turns encoded modules into closed outlines in millimetres (Y up). + +Dark modules are merged into polygons with holes. Every loop is then +oriented by its nesting depth, so even-odd and non-zero filling agree. +""" + +from dataclasses import dataclass + +from raygeo.geo.shape.polygon import ( + get_polygon_signed_area, + get_polygons_union, + is_point_inside_polygon, +) +from raygeo.geo.shape.text import FontConfig, text_to_geometry + +from .encoders import Encoded, Symbology + +Loop = list[tuple[float, float]] + +TEXT_HEIGHT_MODULES = 8.0 +TEXT_GAP_MODULES = 1.0 +TEXT_TOLERANCE_MM = 0.01 +EAN_FIRST_DIGIT_X = -4.0 +EAN_LEFT_HALF = (3, 45) +EAN_RIGHT_HALF = (50, 92) + + +@dataclass(frozen=True) +class BarcodeStyle: + """ + How a symbol is drawn. ``quiet_zone`` is in modules and only drawn + when ``invert`` is set: then the zone is the dark border around a + light code. ``bar_height_mm`` and ``show_text`` apply to 1D codes. + """ + + module_mm: float = 0.5 + bar_height_mm: float = 15.0 + quiet_zone: int = 0 + show_text: bool = False + invert: bool = False + + +def _probe(loop: Loop) -> tuple[float, float]: + (x1, y1), (x2, y2) = loop[0], loop[1] + return ((x1 + x2) / 2.0, (y1 + y2) / 2.0) + + +def orient_by_nesting(loops: list[Loop]) -> list[Loop]: + """ + Orients each loop counter-clockwise at even nesting depth and + clockwise at odd depth, so nested loops alternate between solid and + hole however they were drawn. + """ + oriented = [] + for index, loop in enumerate(loops): + probe = _probe(loop) + depth = sum( + 1 + for other_index, other in enumerate(loops) + if other_index != index and is_point_inside_polygon(probe, other) + ) + counter_clockwise = get_polygon_signed_area(loop) > 0 + if counter_clockwise != (depth % 2 == 0): + loop = loop[::-1] + oriented.append(loop) + return oriented + + +def _rect(x: float, y: float, w: float, h: float) -> Loop: + return [(x, y), (x + w, y), (x + w, y + h), (x, y + h)] + + +def _runs(row: tuple[bool, ...]) -> list[tuple[int, int]]: + runs, start = [], None + for x, dark in enumerate(row + (False,)): + if dark and start is None: + start = x + elif not dark and start is not None: + runs.append((start, x - start)) + start = None + return runs + + +def _text_loops(text: str, centre_x: float, top: float, height: float): + if not text: + return [] + geometry = text_to_geometry(text, FontConfig()) + x0, y0, x1, y1 = geometry.rect() + if y1 <= y0: + return [] + scale = height / (y1 - y0) + dx = centre_x - (x0 + x1) / 2.0 * scale + dy = top - y1 * scale + return [ + [(x * scale + dx, y * scale + dy) for x, y in polygon] + for polygon in geometry.to_polygons(TEXT_TOLERANCE_MM / scale) + if len(polygon) >= 3 + ] + + +def _human_readable( + encoded: Encoded, symbology: Symbology, m: float +) -> list[Loop]: + """ + The text under a 1D code. EAN-13 puts the first digit left of the + symbol and six digits under each half, between the guard bars. + """ + top = (TEXT_HEIGHT_MODULES - TEXT_GAP_MODULES) * m + height = (TEXT_HEIGHT_MODULES - 2 * TEXT_GAP_MODULES) * m + text = encoded.text + if symbology is not Symbology.EAN13: + return _text_loops(text, encoded.width * m / 2.0, top, height) + groups = ( + (text[0], EAN_FIRST_DIGIT_X), + (text[1:7], sum(EAN_LEFT_HALF) / 2.0), + (text[7:], sum(EAN_RIGHT_HALF) / 2.0), + ) + loops: list[Loop] = [] + for digits, centre in groups: + loops += _text_loops(digits, centre * m, top, height) + return loops + + +def _module_rects( + encoded: Encoded, symbology: Symbology, style: BarcodeStyle +) -> tuple[list[Loop], float]: + """Dark module rectangles and the height of the module area.""" + m = style.module_mm + if symbology is Symbology.QR: + height = encoded.height * m + rects = [ + _rect(x * m, height - (y + 1) * m, w * m, m) + for y, row in enumerate(encoded.modules) + for x, w in _runs(row) + ] + return rects, height + text_space = 0.0 + if style.show_text: + text_space = (TEXT_HEIGHT_MODULES + TEXT_GAP_MODULES) * m + bars = style.bar_height_mm + rects = [] + for x, w in _runs(encoded.modules[0]): + guard = x in encoded.guards and text_space > 0 + extra = text_space / 2.0 if guard else 0.0 + rects.append(_rect(x * m, text_space - extra, w * m, bars + extra)) + return rects, text_space + bars + + +def barcode_loops( + encoded: Encoded, symbology: Symbology, style: BarcodeStyle +) -> list[Loop]: + """The dark areas of a symbol as oriented loops, in mm.""" + rects, height = _module_rects(encoded, symbology, style) + loops: list[Loop] = [ + [(float(x), float(y)) for x, y in polygon] + for polygon in get_polygons_union(rects) + ] + if symbology is not Symbology.QR and style.show_text and encoded.text: + loops += _human_readable(encoded, symbology, style.module_mm) + if style.invert: + q = style.quiet_zone * style.module_mm + width = encoded.width * style.module_mm + loops = [[(x + q, y + q) for x, y in loop] for loop in loops] + loops.append(_rect(0.0, 0.0, width + 2 * q, height + 2 * q)) + return orient_by_nesting(loops) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_geometry.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_geometry.py new file mode 100644 index 000000000..bbf7ad3f3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_geometry.py @@ -0,0 +1,82 @@ +import pytest +from barcodes.encoders import Encoded, Symbology, encode +from barcodes.geometry import BarcodeStyle, barcode_loops, orient_by_nesting +from raygeo.geo.shape.polygon import get_polygon_signed_area + +SQUARE = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)] +INNER = [(1.0, 1.0), (1.0, 3.0), (3.0, 3.0), (3.0, 1.0)] +RING = Encoded( + modules=( + (True, True, True), + (True, False, True), + (True, True, True), + ) +) + + +def _areas(loops): + return sorted(get_polygon_signed_area(loop) for loop in loops) + + +def _bbox(loops): + xs = [x for loop in loops for x, _ in loop] + ys = [y for loop in loops for _, y in loop] + return min(xs), min(ys), max(xs), max(ys) + + +def test_orient_by_nesting(): + hole, outer = orient_by_nesting([INNER[::-1], SQUARE[::-1]]) + assert get_polygon_signed_area(outer) > 0 + assert get_polygon_signed_area(hole) < 0 + + +def test_modules_merge_into_outline_with_hole(): + loops = barcode_loops(RING, Symbology.QR, BarcodeStyle(module_mm=2.0)) + assert _areas(loops) == pytest.approx([-4.0, 36.0]) + assert _bbox(loops) == pytest.approx((0.0, 0.0, 6.0, 6.0)) + + +def test_invert_adds_a_dark_quiet_zone(): + style = BarcodeStyle(module_mm=1.0, quiet_zone=2, invert=True) + loops = barcode_loops(RING, Symbology.QR, style) + assert _bbox(loops) == pytest.approx((0.0, 0.0, 7.0, 7.0)) + assert sum(get_polygon_signed_area(loop) for loop in loops) == ( + pytest.approx(49.0 - 8.0) + ) + + +def test_one_dimensional_bars_get_the_bar_height(): + encoded = encode(Symbology.CODE128, "A") + style = BarcodeStyle(module_mm=0.5, bar_height_mm=12.0) + loops = barcode_loops(encoded, Symbology.CODE128, style) + x0, y0, x1, y1 = _bbox(loops) + assert (y1 - y0) == pytest.approx(12.0) + assert (x1 - x0) == pytest.approx(encoded.width * 0.5) + dark = sum(encoded.modules[0]) * 0.5 * 12.0 + assert sum(get_polygon_signed_area(loop) for loop in loops) == ( + pytest.approx(dark) + ) + + +def test_text_goes_below_and_ean_guards_reach_into_it(): + encoded = encode(Symbology.EAN13, "590123412345") + plain = barcode_loops( + encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33) + ) + with_text = barcode_loops( + encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33, show_text=True) + ) + plain_height = _bbox(plain)[3] - _bbox(plain)[1] + text_height = _bbox(with_text)[3] - _bbox(with_text)[1] + assert text_height > plain_height + assert _bbox(with_text)[1] == pytest.approx(0.33, abs=0.05) + assert len(with_text) > len(plain) + assert all(get_polygon_signed_area(loop) != 0 for loop in with_text) + + +def test_qr_ignores_bar_height_and_text(): + encoded = encode(Symbology.QR, "x") + style = BarcodeStyle(module_mm=1.0, bar_height_mm=99.0, show_text=True) + loops = barcode_loops(encoded, Symbology.QR, style) + x0, y0, x1, y1 = _bbox(loops) + assert (x1 - x0, y1 - y0) == pytest.approx((21.0, 21.0)) From b22663574dc23945c3c23dc4f13e7821599fedd3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:09:13 +0200 Subject: [PATCH 03/24] fix(sketcher): only treat sketch workpieces as sketches Other geometry providers also set a provider uid on their workpieces, so the sketch property group, Edit Sketch and its context menu entry now check that the provider really is a Sketch. Refs #529 --- .../sketcher/core/workpieces.py | 15 ++++++ .../sketcher/ui_gtk/__init__.py | 9 ++-- .../sketcher/ui_gtk/property_provider.py | 7 ++- .../sketcher/ui_gtk/sketch_mode_cmd.py | 20 +++----- .../tests/core/test_sketch_workpieces.py | 50 +++++++++++++++++++ 5 files changed, 78 insertions(+), 23 deletions(-) create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py create mode 100644 rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py new file mode 100644 index 000000000..fcaae61be --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py @@ -0,0 +1,15 @@ +from rayforge.core.item import DocItem +from rayforge.core.workpiece import WorkPiece + +from .sketch import Sketch + + +def is_sketch_workpiece(item: DocItem | None) -> bool: + """ + Whether *item* is a workpiece drawn by a sketch. Other geometry + providers (barcodes, for example) also set a provider uid, so the + uid alone does not tell. + """ + return isinstance(item, WorkPiece) and isinstance( + item.get_geometry_provider(), Sketch + ) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/__init__.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/__init__.py index c3b519129..6b3b63088 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/__init__.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/__init__.py @@ -5,7 +5,6 @@ from gi.repository import Gio, GLib from rayforge.context import get_context -from rayforge.core.workpiece import WorkPiece from rayforge.ui_gtk.action_registry import ( MenuPlacement, action_registry, @@ -19,6 +18,7 @@ ) from rayforge.ui_gtk.shared.keyboard import PRIMARY_ACCEL +from ..core.workpieces import is_sketch_workpiece from .property_provider import SketchPropertyProvider from .sketch_mode_cmd import SketchModeCmd from .sketchelement import SketchElement @@ -53,10 +53,7 @@ def _build_sketch_context_menu_items( Adds "Edit Sketch" and "Export Object" items when the context menu is shown for a sketch-based workpiece. """ - if not isinstance(item, WorkPiece): - return - - if not item.geometry_provider_uid: + if not is_sketch_workpiece(item): return # Create a section for sketch-specific items at the top of the menu @@ -230,7 +227,7 @@ def _update_action_states(action_manager: "ActionManager"): can_export_object = False if len(selected_wps) == 1: wp = selected_wps[0] - if wp.geometry_provider_uid: + if is_sketch_workpiece(wp): can_edit_sketch = True if wp.boundaries is not None and not wp.boundaries.is_empty(): can_export_object = True diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py index 85be1fa4e..823d61957 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py @@ -13,6 +13,7 @@ from rayforge.ui_gtk.shared.pref_rows import SpinRow from rayforge.ui_gtk.varset.varsetwidget import VarSetRowList +from ..core.workpieces import is_sketch_workpiece from .sketch_cmd import SketchCmd if TYPE_CHECKING: @@ -42,11 +43,9 @@ def can_handle(self, items: list[DocItem]) -> bool: first_sketch_uid = None for item in items: - if ( - not isinstance(item, WorkPiece) - or not item.geometry_provider_uid - ): + if not is_sketch_workpiece(item): return False + assert isinstance(item, WorkPiece) if first_sketch_uid is None: first_sketch_uid = item.geometry_provider_uid diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py index 2c0b3938b..b41489b29 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py @@ -1,7 +1,7 @@ import logging from gettext import gettext as _ from pathlib import Path -from typing import TYPE_CHECKING, Optional, cast +from typing import TYPE_CHECKING, Optional from gi.repository import GLib @@ -12,6 +12,7 @@ from rayforge.usage import get_usage_tracker from ..core.sketch import Sketch +from ..core.workpieces import is_sketch_workpiece if TYPE_CHECKING: from rayforge.doceditor.editor import DocEditor @@ -97,16 +98,8 @@ def enter_sketch_mode( self, workpiece: WorkPiece, is_new_sketch: bool = False ): """Switches the view to the SketchStudio to edit a workpiece.""" - sketch = None - if workpiece.geometry_provider_uid: - sketch = cast( - Sketch | None, - self._editor.doc.get_asset_by_uid( - workpiece.geometry_provider_uid - ), - ) - - if not sketch: + sketch = workpiece.get_geometry_provider() + if not isinstance(sketch, Sketch): logger.warning("Attempted to edit a non-sketch workpiece.") return @@ -228,7 +221,7 @@ def on_edit_sketch(self, action, param): selected_items[0], WorkPiece ): wp = selected_items[0] - if wp.geometry_provider_uid: + if is_sketch_workpiece(wp): self.enter_sketch_mode(wp) else: self._win._on_editor_notification( @@ -255,7 +248,8 @@ def on_edit_sketch_item(self, action, param): """Action handler for editing a sketch-based workpiece.""" item_uid = param.get_string() item = self._editor.doc.find_descendant_by_uid(item_uid) - if isinstance(item, WorkPiece) and item.geometry_provider_uid: + if is_sketch_workpiece(item): + assert isinstance(item, WorkPiece) self.enter_sketch_mode(item) def on_export_object(self, action, param): diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py new file mode 100644 index 000000000..e0fae2840 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py @@ -0,0 +1,50 @@ +from typing import Any + +from blinker import Signal +from raygeo.geo import Geometry +from sketcher.core import Sketch +from sketcher.core.workpieces import is_sketch_workpiece + +from rayforge.core.doc import Doc +from rayforge.core.workpiece import WorkPiece + + +class _OtherProvider: + def __init__(self): + self.uid = "other-provider" + self.name = "Other" + self.updated = Signal() + self.provider_type_name = "other" + self.renderer = None + + def get_geometry(self, params=None, *, resolved_text_cache=None): + geometry = Geometry() + geometry.move_to(0.0, 0.0) + geometry.line_to(5.0, 0.0) + geometry.line_to(5.0, 5.0) + geometry.close_path() + return geometry, [] + + def to_dict(self) -> dict[str, Any]: + return {"uid": self.uid, "type": "other"} + + +def test_sketch_workpiece(): + doc = Doc() + sketch = Sketch(name="s") + sketch.add_point(0, 0) + doc.add_asset(sketch) + wp = WorkPiece.from_geometry_provider(sketch) + doc.active_layer.add_child(wp) + assert is_sketch_workpiece(wp) + + +def test_other_provider_is_not_a_sketch(): + wp = WorkPiece.from_geometry_provider(_OtherProvider()) + assert wp.geometry_provider_uid + assert not is_sketch_workpiece(wp) + + +def test_plain_items_are_not_sketches(): + assert not is_sketch_workpiece(WorkPiece("plain")) + assert not is_sketch_workpiece(None) From 3367267818189f108596f441a88e0a4623735bbd Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:09:13 +0200 Subject: [PATCH 04/24] feat(barcodes): barcode asset as a geometry provider A barcode is a document asset that provides the geometry of a QR code, Code 128 or EAN-13 symbol, so it becomes an ordinary workpiece. Its content is a template resolved with the parameters the workpiece passes in; the result is cached like text box templates. Content the symbology cannot encode gives a placeholder square and an error. Refs #529 --- .../rayforge-addon-barcodes/barcodes/asset.py | 250 ++++++++++++++++++ .../barcodes/worker.py | 20 ++ .../rayforge-addon.yaml | 13 + .../rayforge-addon-barcodes/tests/conftest.py | 20 ++ .../tests/test_asset.py | 118 +++++++++ rayforge/resources/icons/barcode-symbolic.svg | 17 ++ 6 files changed, 438 insertions(+) create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/worker.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/tests/conftest.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_asset.py create mode 100644 rayforge/resources/icons/barcode-symbolic.svg diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py new file mode 100644 index 000000000..d26736157 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py @@ -0,0 +1,250 @@ +""" +The barcode asset: a document asset that provides the geometry of a QR +code or barcode generated from a template string. +""" + +import logging +import uuid +from gettext import gettext as _ +from typing import Any, ClassVar + +from blinker import Signal +from raygeo.geo import Geometry + +from rayforge.core.asset import IAsset +from rayforge.core.expression.expression_map import ExpressionMap +from rayforge.core.geometry_provider import IGeometryProvider +from rayforge.image.base_renderer import Renderer +from rayforge.image.geo_renderer import render_geometry_to_png +from rayforge.image.registry import renderer_registry +from rayforge.image.structures import FillRenderData, FillStyle + +from .encoders import ( + MIN_QUIET_ZONE, + QrErrorCorrection, + Symbology, + encode, +) +from .geometry import BarcodeStyle, Loop, barcode_loops + +logger = logging.getLogger(__name__) + +RENDERER_NAME = "SketchRenderer" +CACHE_KEY = 0 +PLACEHOLDER_MM = 10.0 +_BLACK = (0.0, 0.0, 0.0, 1.0) +_UNSET = object() + + +class _Setting: + """An attribute that sends the owner's ``updated`` when it changes.""" + + def __set_name__(self, owner: type, name: str) -> None: + self.attr = "_" + name + + def __get__(self, obj: Any, owner: type | None = None) -> Any: + if obj is None: + return self + return getattr(obj, self.attr) + + def __set__(self, obj: Any, value: Any) -> None: + if getattr(obj, self.attr, _UNSET) == value: + return + setattr(obj, self.attr, value) + updated = getattr(obj, "updated", None) + if updated is not None: + updated.send(obj) + + +def _loops_to_geometry(loops: list[Loop]) -> Geometry: + geometry = Geometry() + for loop in loops: + x, y = loop[0] + geometry.move_to(x, y) + for x, y in loop[1:]: + geometry.line_to(x, y) + geometry.close_path() + return geometry + + +class BarcodeAsset(IAsset, IGeometryProvider): + """ + A QR code or barcode whose content is a template. The template is + resolved with the parameters the workpiece passes in (for example + the document's serial number), then encoded and drawn as outlines + plus a solid fill. + """ + + is_addable: ClassVar[bool] = False + asset_type_name: ClassVar[str] = "barcode" + display_icon_name: ClassVar[str] = "barcode-symbolic" + is_reorderable: ClassVar[bool] = True + is_draggable_to_canvas: ClassVar[bool] = True + type_display_name: ClassVar[str] = _("Barcode") + can_edit: ClassVar[bool] = False + add_action: ClassVar[str | None] = None + activate_action: ClassVar[str | None] = None + edit_item_action: ClassVar[str | None] = None + + content = _Setting() + symbology = _Setting() + error_correction = _Setting() + module_mm = _Setting() + bar_height_mm = _Setting() + quiet_zone = _Setting() + show_text = _Setting() + invert = _Setting() + + def __init__( + self, + name: str | None = None, + content: str = "", + symbology: Symbology = Symbology.QR, + error_correction: QrErrorCorrection = QrErrorCorrection.M, + module_mm: float = 0.5, + bar_height_mm: float = 15.0, + quiet_zone: int | None = None, + show_text: bool = False, + invert: bool = False, + ): + self._uid = str(uuid.uuid4()) + self._name = name or _("Barcode") + self._updated = Signal() + self.content = content + self.symbology = symbology + self.error_correction = error_correction + self.module_mm = module_mm + self.bar_height_mm = bar_height_mm + self.quiet_zone = quiet_zone + self.show_text = show_text + self.invert = invert + self._error: str | None = None + + @property + def uid(self) -> str: + return self._uid + + @property + def name(self) -> str: + return self._name + + @name.setter + def name(self, value: str) -> None: + if value != self._name: + self._name = value + self._updated.send(self) + + @property + def updated(self) -> Signal: + return self._updated + + @property + def hidden(self) -> bool: + return False + + @property + def provider_type_name(self) -> str: + return self.asset_type_name + + @property + def renderer(self) -> Renderer | None: + return renderer_registry.get_by_name(RENDERER_NAME) + + @property + def error(self) -> str | None: + """Why the last content could not be encoded, if it could not.""" + return self._error + + @property + def effective_quiet_zone(self) -> int: + if self.quiet_zone is not None: + return self.quiet_zone + return MIN_QUIET_ZONE[self.symbology] + + def get_template_sources(self) -> list[str]: + return [self.content] + + def resolve(self, params: dict[str, Any] | None = None) -> str: + """The content with its ``{...}`` placeholders resolved.""" + return ExpressionMap(dict(params or {})).format(self.content) + + def _resolved( + self, params: dict[str, Any] | None, cache: dict | None + ) -> str: + cached = cache.get(CACHE_KEY) if cache is not None else None + if cached and cached[0] == self.content: + return cached[1] + text = self.resolve(params) + if cache is not None: + cache[CACHE_KEY] = (self.content, text) + return text + + def _style(self) -> BarcodeStyle: + return BarcodeStyle( + module_mm=self.module_mm, + bar_height_mm=self.bar_height_mm, + quiet_zone=self.effective_quiet_zone, + show_text=self.show_text, + invert=self.invert, + ) + + def get_geometry( + self, + params: dict[str, Any] | None = None, + *, + resolved_text_cache: dict | None = None, + ) -> tuple[Geometry, list[FillRenderData]]: + text = self._resolved(params, resolved_text_cache) + try: + encoded = encode(self.symbology, text, self.error_correction) + except ValueError as e: + self._error = str(e) + logger.info(f"Barcode {self.uid[:8]}: {e}") + size = PLACEHOLDER_MM + outline = [(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)] + return _loops_to_geometry([outline]), [] + self._error = None + geometry = _loops_to_geometry( + barcode_loops(encoded, self.symbology, self._style()) + ) + fill = FillRenderData( + geometry=geometry.copy(), style=FillStyle.SOLID, color=_BLACK + ) + return geometry, [fill] + + def get_thumbnail(self, size: int) -> bytes | None: + geometry, _fills = self.get_geometry() + return render_geometry_to_png(geometry, size) + + def to_dict(self) -> dict[str, Any]: + return { + "uid": self._uid, + "type": self.asset_type_name, + "name": self._name, + "content": self.content, + "symbology": self.symbology.value, + "error_correction": self.error_correction.value, + "module_mm": self.module_mm, + "bar_height_mm": self.bar_height_mm, + "quiet_zone": self.quiet_zone, + "show_text": self.show_text, + "invert": self.invert, + } + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BarcodeAsset": + asset = cls( + name=data.get("name"), + content=data.get("content", ""), + symbology=Symbology(data.get("symbology", "qr")), + error_correction=QrErrorCorrection( + data.get("error_correction", "M") + ), + module_mm=float(data.get("module_mm", 0.5)), + bar_height_mm=float(data.get("bar_height_mm", 15.0)), + quiet_zone=data.get("quiet_zone"), + show_text=bool(data.get("show_text", False)), + invert=bool(data.get("invert", False)), + ) + asset._uid = data.get("uid", asset._uid) + return asset diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/worker.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/worker.py new file mode 100644 index 000000000..a261abe7b --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/worker.py @@ -0,0 +1,20 @@ +""" +Backend entry point for the barcodes addon. + +Registers the barcode asset type, which worker processes need to +rebuild barcode workpieces. +""" + +from rayforge.core.hooks import hookimpl + +from .asset import BarcodeAsset + +ADDON_NAME = "barcodes" + + +@hookimpl +def register_asset_types(asset_type_registry): + """Register the barcode asset type.""" + asset_type_registry.register( + BarcodeAsset, BarcodeAsset.asset_type_name, ADDON_NAME + ) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml new file mode 100644 index 000000000..1360fa2f4 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml @@ -0,0 +1,13 @@ +name: barcodes +display_name: "Barcodes" +description: "QR codes and barcodes generated from templates" +api_version: 21 +requires: + - sketcher +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "barcodes.worker" +license: + name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/conftest.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/conftest.py new file mode 100644 index 000000000..c622cc6f9 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/conftest.py @@ -0,0 +1,20 @@ +""" +Pytest configuration for the barcodes builtin addon tests. +""" + +import pytest +from barcodes.asset import BarcodeAsset + +from rayforge.core.asset_registry import asset_type_registry + +ADDON_NAME = "barcodes" + + +@pytest.fixture(scope="session", autouse=True) +def register_barcode_asset(): + """Registers the barcode asset type for all tests.""" + asset_type_registry.register( + BarcodeAsset, BarcodeAsset.asset_type_name, ADDON_NAME + ) + yield + asset_type_registry.unregister_all_from_addon(ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_asset.py new file mode 100644 index 000000000..2e89afe35 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_asset.py @@ -0,0 +1,118 @@ +from typing import cast + +import pytest +from barcodes.asset import BarcodeAsset +from barcodes.encoders import QrErrorCorrection, Symbology + +from rayforge.core.asset import IAsset +from rayforge.core.doc import Doc +from rayforge.core.geometry_provider import IGeometryProvider +from rayforge.core.workpiece import WorkPiece +from rayforge.image.structures import FillStyle + + +def _size(asset, params=None): + geometry, _fills = asset.get_geometry(params) + x0, y0, x1, y1 = geometry.rect() + return x1 - x0, y1 - y0 + + +def test_is_an_asset_and_a_geometry_provider(): + asset = BarcodeAsset(content="x") + assert isinstance(asset, IAsset) + assert isinstance(asset, IGeometryProvider) + assert asset.provider_type_name == "barcode" + + +def test_round_trip(): + asset = BarcodeAsset( + name="Label", + content="SN-{serial:04d}", + symbology=Symbology.CODE128, + error_correction=QrErrorCorrection.H, + module_mm=0.4, + bar_height_mm=9.0, + quiet_zone=12, + show_text=True, + invert=True, + ) + restored = BarcodeAsset.from_dict(asset.to_dict()) + assert restored.to_dict() == asset.to_dict() + assert restored.uid == asset.uid + + +def test_qr_geometry_and_fill(): + asset = BarcodeAsset(content="hello", module_mm=0.5) + geometry, fills = asset.get_geometry() + assert not geometry.is_empty() + assert len(fills) == 1 + assert fills[0].style is FillStyle.SOLID + width, height = _size(asset) + assert width == pytest.approx(height) + assert width == pytest.approx(21 * 0.5) + + +def test_templates_resolve_with_params_and_are_cached(): + asset = BarcodeAsset( + content="SN-{serial:03d}", symbology=Symbology.CODE128 + ) + cache: dict = {} + asset.get_geometry({"serial": 7}, resolved_text_cache=cache) + assert asset.resolve({"serial": 7}) == "SN-007" + assert cache[0] == ("SN-{serial:03d}", "SN-007") + assert asset.get_template_sources() == ["SN-{serial:03d}"] + + +def test_invalid_content_gives_a_placeholder_and_an_error(): + asset = BarcodeAsset(content="12AB", symbology=Symbology.EAN13) + geometry, fills = asset.get_geometry() + assert not geometry.is_empty() + assert fills == [] + assert asset.error is not None + asset.content = "590123412345" + asset.get_geometry() + assert asset.error is None + + +def test_default_quiet_zone_is_the_symbology_minimum(): + assert BarcodeAsset(symbology=Symbology.QR).effective_quiet_zone == 4 + assert BarcodeAsset(symbology=Symbology.EAN13).effective_quiet_zone == 9 + assert BarcodeAsset(quiet_zone=2).effective_quiet_zone == 2 + + +def test_changing_a_setting_signals_updated(): + asset = BarcodeAsset(content="x") + seen = [] + asset.updated.connect(seen.append, weak=False) + asset.module_mm = 1.0 + asset.module_mm = 1.0 + assert seen == [asset] + + +def test_workpiece_from_barcode(): + doc = Doc() + asset = BarcodeAsset(content="hello", module_mm=0.5) + doc.add_asset(asset) + wp = WorkPiece.from_geometry_provider(asset) + doc.active_layer.add_child(wp) + assert wp.size == pytest.approx((10.5, 10.5)) + assert cast(BarcodeAsset, wp.get_geometry_provider()) is asset + assert wp.boundaries is not None + + +def test_thumbnail(): + assert BarcodeAsset(content="x").get_thumbnail(64) + + +def test_document_round_trip(): + doc = Doc() + asset = BarcodeAsset(content="hello", symbology=Symbology.QR) + doc.add_asset(asset) + wp = WorkPiece.from_geometry_provider(asset) + doc.active_layer.add_child(wp) + restored = Doc.from_dict(doc.to_dict()) + (restored_wp,) = restored.all_workpieces + provider = restored_wp.get_geometry_provider() + assert isinstance(provider, BarcodeAsset) + assert provider.content == "hello" + assert restored_wp.boundaries is not None diff --git a/rayforge/resources/icons/barcode-symbolic.svg b/rayforge/resources/icons/barcode-symbolic.svg new file mode 100644 index 000000000..777eec460 --- /dev/null +++ b/rayforge/resources/icons/barcode-symbolic.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + From 2efe5149c48af683322b0a50fb5b2a99a87524b8 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:12:07 +0200 Subject: [PATCH 05/24] feat(barcodes): Add Barcode action and Barcode properties Object > Add Barcode adds a QR code in the middle of the work area, as one undo step. A Barcode group in the properties panel edits the content, type, error correction, module size, bar height, quiet zone, text and invert; every change is undoable and gives the workpiece the symbol's true size. Content the type cannot encode is flagged. Refs #529 --- .../barcodes/commands.py | 99 ++++++++++ .../barcodes/frontend.py | 52 +++++ .../barcodes/property_provider.py | 182 ++++++++++++++++++ .../rayforge-addon.yaml | 1 + .../tests/test_commands.py | 82 ++++++++ .../tests/ui_gtk/test_property_provider.py | 80 ++++++++ 6 files changed, 496 insertions(+) create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_commands.py create mode 100644 rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_property_provider.py diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py new file mode 100644 index 000000000..b291460f8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py @@ -0,0 +1,99 @@ +""" +Undoable barcode edits: adding a barcode and changing its settings. +""" + +from gettext import gettext as _ +from typing import Any, Protocol + +from rayforge.core.doc import Doc +from rayforge.core.item import DocItem +from rayforge.core.undo import ( + ChangePropertyCommand, + HistoryManager, + ListItemCommand, +) +from rayforge.core.workpiece import WorkPiece + +from .asset import BarcodeAsset + +DEFAULT_CONTENT = "Rayforge" +DEFAULT_POSITION_MM = (50.0, 50.0) + + +class _Editor(Protocol): + doc: Doc + history_manager: HistoryManager + + +def barcode_of(item: DocItem | None) -> BarcodeAsset | None: + """The barcode asset behind a workpiece, if it is a barcode.""" + if not isinstance(item, WorkPiece): + return None + provider = item.get_geometry_provider() + return provider if isinstance(provider, BarcodeAsset) else None + + +def default_position(editor: Any) -> tuple[float, float]: + """The centre of the machine's work area, or a fixed fallback.""" + machine = getattr(getattr(editor, "context", None), "machine", None) + if machine is None: + return DEFAULT_POSITION_MM + x, y, w, h = machine.work_area + return (x + w / 2.0, y + h / 2.0) + + +def add_barcode( + editor: _Editor, position_mm: tuple[float, float] +) -> WorkPiece: + """Adds a QR code centred on *position_mm*, as one undo step.""" + doc = editor.doc + asset = BarcodeAsset(content=DEFAULT_CONTENT) + with editor.history_manager.transaction(_("Add Barcode")) as t: + t.execute( + ListItemCommand( + owner_obj=doc, + item=asset, + undo_command="remove_asset", + redo_command="add_asset", + ) + ) + workpiece = WorkPiece.from_geometry_provider(asset) + width, height = workpiece.natural_size + workpiece.pos = ( + position_mm[0] - width / 2.0, + position_mm[1] - height / 2.0, + ) + t.execute( + ListItemCommand( + owner_obj=doc.active_layer, + item=workpiece, + undo_command="remove_child", + redo_command="add_child", + ) + ) + return workpiece + + +def _resize_instances(doc: Doc, asset: BarcodeAsset) -> None: + """Gives every workpiece of *asset* the symbol's true size.""" + for workpiece in doc.all_workpieces: + if workpiece.geometry_provider_uid == asset.uid: + workpiece.regenerate_from_geometry_provider() + + +def set_barcode_setting( + editor: _Editor, asset: BarcodeAsset, setting: str, value: Any +) -> None: + """Changes one barcode setting as an undo step.""" + if getattr(asset, setting) == value: + return + doc = editor.doc + editor.history_manager.execute( + ChangePropertyCommand( + target=asset, + property_name=setting, + new_value=value, + on_change_callback=lambda: _resize_instances(doc, asset), + name=_("Change Barcode"), + ) + ) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py new file mode 100644 index 000000000..fdca53e23 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py @@ -0,0 +1,52 @@ +""" +Frontend entry point for the barcodes addon: the Add Barcode action in +the Object menu and the Barcode group in the properties panel. +""" + +from gettext import gettext as _ + +from gi.repository import Gio + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.action_registry import MenuPlacement, action_registry +from rayforge.ui_gtk.actions import action_extension_registry +from rayforge.ui_gtk.doceditor.property_providers import ( + property_provider_registry, +) + +from .commands import add_barcode, default_position +from .property_provider import BarcodePropertyProvider + +ADDON_NAME = "barcodes" +ADD_ACTION = "add-barcode" + + +def _register_actions(action_manager): + action = Gio.SimpleAction.new(ADD_ACTION, None) + + def on_activate(action, param): + window = action_manager.win + editor = window.doc_editor + workpiece = add_barcode(editor, default_position(editor)) + window.surface.select_items([workpiece]) + + action.connect("activate", on_activate) + action_manager.win.add_action(action) + action_manager.actions[ADD_ACTION] = action + action_registry.register( + action_name=ADD_ACTION, + action=action, + addon_name=ADDON_NAME, + label=_("Add Barcode"), + menu=MenuPlacement(menu_id="object", priority=60), + ) + + +@hookimpl +def register_actions(action_registry): + action_extension_registry.register_setup(_register_actions, ADDON_NAME) + + +@hookimpl +def main_window_ready(main_window): + property_provider_registry.register(BarcodePropertyProvider, ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py new file mode 100644 index 000000000..b030a3fea --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py @@ -0,0 +1,182 @@ +"""The Barcode group in the properties panel.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +from gi.repository import Adw, GLib, Gtk + +from rayforge.core.item import DocItem +from rayforge.ui_gtk.doceditor.property_providers.base import ( + PropertyProvider, +) +from rayforge.ui_gtk.shared.pref_rows import SpinRow + +from .asset import BarcodeAsset +from .commands import barcode_of, set_barcode_setting +from .encoders import QrErrorCorrection, Symbology + +if TYPE_CHECKING: + from rayforge.doceditor.editor import DocEditor + +SYMBOLOGY_LABELS = { + Symbology.QR: _("QR Code"), + Symbology.CODE128: _("Code 128"), + Symbology.EAN13: _("EAN-13"), +} +ERROR_LABELS = { + QrErrorCorrection.L: _("Low (7 %)"), + QrErrorCorrection.M: _("Medium (15 %)"), + QrErrorCorrection.Q: _("Quartile (25 %)"), + QrErrorCorrection.H: _("High (30 %)"), +} +SYMBOLOGIES = list(SYMBOLOGY_LABELS) +ERROR_LEVELS = list(ERROR_LABELS) + + +def _combo(title: str, labels: list[str]) -> Adw.ComboRow: + return Adw.ComboRow(title=title, model=Gtk.StringList.new(labels)) + + +class BarcodePropertyProvider(PropertyProvider): + """Edits the content, type and size of a selected barcode.""" + + priority = 25 + separate_group = True + group_title = _("Barcode") + + def can_handle(self, items: list[DocItem]) -> bool: + return len(items) == 1 and barcode_of(items[0]) is not None + + def create_widgets(self) -> list[Gtk.Widget]: + self.content_row = Adw.EntryRow( + title=_("Content"), show_apply_button=True + ) + self.content_row.connect("apply", self._on_content_applied) + self.symbology_row = _combo( + _("Type"), [SYMBOLOGY_LABELS[s] for s in SYMBOLOGIES] + ) + self.error_row = _combo( + _("Error Correction"), [ERROR_LABELS[e] for e in ERROR_LEVELS] + ) + self.module_row = SpinRow( + _("Module Size"), + _("Width of the narrowest bar or one QR square, in mm"), + lower=0.05, + upper=20.0, + step_increment=0.05, + digits=2, + ) + self.bar_height_row = SpinRow( + _("Bar Height"), + _("Height of the bars, in mm"), + lower=1.0, + upper=500.0, + step_increment=0.5, + digits=1, + ) + self.quiet_zone_row = SpinRow( + _("Quiet Zone"), + _("Dark border around an inverted code, in modules"), + lower=0, + upper=100, + numeric=True, + ) + self.text_row = Adw.SwitchRow( + title=_("Show Text"), + subtitle=_("Print the content under the bars"), + ) + self.invert_row = Adw.SwitchRow( + title=_("Invert"), + subtitle=_("Engrave the light parts, for dark material"), + ) + self.symbology_row.connect("notify::selected", self._on_symbology) + self.error_row.connect("notify::selected", self._on_error) + self.module_row.value_changed.connect( + lambda row: self._set("module_mm", row.get_value()) + ) + self.bar_height_row.value_changed.connect( + lambda row: self._set("bar_height_mm", row.get_value()) + ) + self.quiet_zone_row.value_changed.connect( + lambda row: self._set("quiet_zone", row.get_int_value()) + ) + self.text_row.connect( + "notify::active", + lambda row, _p: self._set("show_text", row.get_active()), + ) + self.invert_row.connect( + "notify::active", + lambda row, _p: self._set("invert", row.get_active()), + ) + self._rows = [ + self.content_row, + self.symbology_row, + self.error_row, + self.module_row, + self.bar_height_row, + self.quiet_zone_row, + self.text_row, + self.invert_row, + ] + return self._rows + + @property + def asset(self) -> BarcodeAsset | None: + return barcode_of(self.items[0]) if self.items else None + + def update_widgets(self, editor: "DocEditor", items: list[DocItem]): + self.editor = editor + self.items = items + asset = self.asset + if asset is None: + return + self._in_update = True + try: + self.content_row.set_text(asset.content) + self.symbology_row.set_selected(SYMBOLOGIES.index(asset.symbology)) + self.error_row.set_selected( + ERROR_LEVELS.index(asset.error_correction) + ) + self.module_row.set_value(asset.module_mm) + self.bar_height_row.set_value(asset.bar_height_mm) + self.quiet_zone_row.set_value(asset.effective_quiet_zone) + self.text_row.set_active(asset.show_text) + self.invert_row.set_active(asset.invert) + finally: + self._in_update = False + self._update_visibility(asset) + + def _update_visibility(self, asset: BarcodeAsset) -> None: + one_dimensional = asset.symbology is not Symbology.QR + self.error_row.set_visible(not one_dimensional) + self.bar_height_row.set_visible(one_dimensional) + self.text_row.set_visible(one_dimensional) + self.quiet_zone_row.set_sensitive(asset.invert) + error = asset.error + self.group_subtitle = SYMBOLOGY_LABELS[asset.symbology] + if error: + self.content_row.add_css_class("error") + self.content_row.set_tooltip_text(error) + else: + self.content_row.remove_css_class("error") + self.content_row.set_tooltip_text( + GLib.markup_escape_text( + _("Templates work here too, e.g. SN-{serial:04d}") + ) + ) + + def _set(self, setting: str, value: Any) -> None: + asset = self.asset + if self._in_update or asset is None: + return + set_barcode_setting(self.editor, asset, setting, value) + self._update_visibility(asset) + + def _on_content_applied(self, row: Adw.EntryRow) -> None: + self._set("content", row.get_text()) + + def _on_symbology(self, row: Adw.ComboRow, _pspec) -> None: + self._set("symbology", SYMBOLOGIES[row.get_selected()]) + + def _on_error(self, row: Adw.ComboRow, _pspec) -> None: + self._set("error_correction", ERROR_LEVELS[row.get_selected()]) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml index 1360fa2f4..11e309299 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml @@ -9,5 +9,6 @@ author: email: "noreply@rayforge.org" provides: worker: "barcodes.worker" + frontend: "barcodes.frontend" license: name: "MIT" diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_commands.py new file mode 100644 index 000000000..79dbd7ec7 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_commands.py @@ -0,0 +1,82 @@ +from types import SimpleNamespace + +import pytest +from barcodes.asset import BarcodeAsset +from barcodes.commands import ( + DEFAULT_CONTENT, + add_barcode, + barcode_of, + set_barcode_setting, +) +from barcodes.encoders import Symbology + +from rayforge.core.doc import Doc +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def editor(): + doc = Doc() + return SimpleNamespace( + doc=doc, + history_manager=doc.history_manager, + context=SimpleNamespace(machine=None), + ) + + +def test_add_barcode_creates_asset_and_workpiece(editor): + wp = add_barcode(editor, (40.0, 30.0)) + asset = barcode_of(wp) + assert isinstance(asset, BarcodeAsset) + assert asset.content == DEFAULT_CONTENT + assert editor.doc.get_asset_by_uid(asset.uid) is asset + assert wp in editor.doc.all_workpieces + x, y = wp.pos + w, h = wp.size + assert (x + w / 2, y + h / 2) == pytest.approx((40.0, 30.0)) + + +def test_add_barcode_is_one_undo_step(editor): + wp = add_barcode(editor, (0.0, 0.0)) + asset = barcode_of(wp) + assert asset is not None + editor.history_manager.undo() + assert editor.doc.all_workpieces == [] + assert editor.doc.get_asset_by_uid(asset.uid) is None + + +def test_setting_resizes_the_workpiece_and_undoes(editor): + wp = add_barcode(editor, (0.0, 0.0)) + asset = barcode_of(wp) + assert asset is not None + width = wp.size[0] + set_barcode_setting(editor, asset, "module_mm", asset.module_mm * 2) + assert wp.size[0] == pytest.approx(width * 2) + editor.history_manager.undo() + assert wp.size[0] == pytest.approx(width) + + +def test_switching_symbology(editor): + wp = add_barcode(editor, (0.0, 0.0)) + asset = barcode_of(wp) + assert asset is not None + set_barcode_setting(editor, asset, "content", "590123412345") + set_barcode_setting(editor, asset, "symbology", Symbology.EAN13) + assert asset.error is None + width, height = wp.size + assert width == pytest.approx(95 * asset.module_mm) + assert height == pytest.approx(asset.bar_height_mm) + + +def test_unchanged_setting_adds_no_undo_step(editor): + wp = add_barcode(editor, (0.0, 0.0)) + asset = barcode_of(wp) + assert asset is not None + set_barcode_setting(editor, asset, "content", asset.content) + editor.history_manager.undo() + assert editor.doc.all_workpieces == [] + + +def test_barcode_of_other_items(): + assert barcode_of(WorkPiece("plain")) is None + assert barcode_of(None) is None diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_property_provider.py new file mode 100644 index 000000000..d8a0ff254 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_property_provider.py @@ -0,0 +1,80 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from types import SimpleNamespace + +import pytest +from barcodes.commands import add_barcode, barcode_of +from barcodes.encoders import Symbology +from barcodes.property_provider import SYMBOLOGIES, BarcodePropertyProvider + +from rayforge.core.doc import Doc +from rayforge.core.workpiece import WorkPiece + + +@pytest.fixture +def editor(): + doc = Doc() + return SimpleNamespace( + doc=doc, + history_manager=doc.history_manager, + context=SimpleNamespace(machine=None), + ) + + +@pytest.fixture +def provider(): + provider = BarcodePropertyProvider() + provider.create_widgets() + return provider + + +@pytest.mark.ui +def test_handles_only_a_single_barcode(editor, provider): + wp = add_barcode(editor, (0.0, 0.0)) + other = add_barcode(editor, (30.0, 0.0)) + assert provider.can_handle([wp]) + assert not provider.can_handle([wp, other]) + assert not provider.can_handle([WorkPiece("plain")]) + + +@pytest.mark.ui +def test_shows_the_settings(editor, provider): + wp = add_barcode(editor, (0.0, 0.0)) + provider.update_widgets(editor, [wp]) + assert provider.content_row.get_text() == "Rayforge" + assert provider.symbology_row.get_selected() == 0 + assert provider.error_row.get_visible() + assert not provider.bar_height_row.get_visible() + assert not provider.quiet_zone_row.get_sensitive() + + +@pytest.mark.ui +def test_edits_go_through_undo(editor, provider): + wp = add_barcode(editor, (0.0, 0.0)) + asset = barcode_of(wp) + assert asset is not None + provider.update_widgets(editor, [wp]) + provider.content_row.set_text("590123412345") + provider.content_row.emit("apply") + provider.symbology_row.set_selected(SYMBOLOGIES.index(Symbology.EAN13)) + assert asset.symbology is Symbology.EAN13 + assert asset.content == "590123412345" + assert provider.bar_height_row.get_visible() + assert not provider.error_row.get_visible() + editor.history_manager.undo() + assert asset.symbology is Symbology.QR + + +@pytest.mark.ui +def test_invalid_content_is_flagged(editor, provider): + wp = add_barcode(editor, (0.0, 0.0)) + asset = barcode_of(wp) + assert asset is not None + provider.update_widgets(editor, [wp]) + provider.symbology_row.set_selected(SYMBOLOGIES.index(Symbology.EAN13)) + assert asset.error is not None + assert provider.content_row.has_css_class("error") From cdad5d0dcc76a118925a62fc956646fc1081cdbc Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:12:22 +0200 Subject: [PATCH 06/24] docs: document the Barcodes addon Refs #529 --- CHANGELOG.md | 2 ++ website/docs/addons/barcodes.md | 45 +++++++++++++++++++++++++++++++++ website/sidebars.js | 1 + 3 files changed, 48 insertions(+) create mode 100644 website/docs/addons/barcodes.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b8252f80..b1565062b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -99,6 +99,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden +- Barcodes addon: QR codes, Code 128 and EAN-13 as vector workpieces + whose content can be a template (Object > Add Barcode) (#529) ### Changed diff --git a/website/docs/addons/barcodes.md b/website/docs/addons/barcodes.md new file mode 100644 index 000000000..d6c236529 --- /dev/null +++ b/website/docs/addons/barcodes.md @@ -0,0 +1,45 @@ +--- +description: + "Engrave QR codes, Code 128 and EAN-13 barcodes in Rayforge. The content can be a template, so + every piece can carry its own code." +--- + +# Barcodes + +The Barcodes addon adds QR codes and barcodes as workpieces. They are drawn as vector outlines with +a fill, so an Engrave operation engraves them cleanly and a Contour operation can still trace them. + +## Adding a Barcode + +Choose **Object - Add Barcode**. A QR code appears in the middle of the work area and is selected. +Edit it in the **Barcode** group of the properties panel: + +- **Content** — the text to encode. Press `Enter` or the apply button to update the code. +- **Type** — QR Code, Code 128 or EAN-13. +- **Error Correction** (QR) — how much of the code may be damaged and still read: Low, Medium, + Quartile or High. Higher levels make the code larger. +- **Module Size** — the width of the narrowest bar, or of one QR square, in mm. The workpiece takes + the size of the code, so this is the setting that sizes it. +- **Bar Height** (Code 128, EAN-13) — the height of the bars. +- **Show Text** (Code 128, EAN-13) — prints the content under the bars. EAN-13 uses its usual + layout, with the guard bars reaching into the digits. +- **Invert** — engraves the light parts instead, for dark material: the code becomes light on a + dark border. **Quiet Zone** sets that border in modules; it defaults to the minimum the type + needs. + +Leave a clear margin around a code that is not inverted: 4 modules for QR codes, 10 for Code 128 +and 9 for EAN-13. + +## Types + +- **QR Code** holds any text, including links. +- **Code 128** holds printable ASCII. Runs of digits are packed in pairs automatically. +- **EAN-13** takes 12 digits, and the check digit is added. With 13 digits the check digit is + verified. The content field turns red when the content does not fit the type; hover over it to + see why. + +## Templates + +The content can contain `{...}` expressions like a text box, for example `{2 * 21}`. With +[Variable Text](../features/variable-text.md), `SN-{serial:05d}` gives every piece its own serial +number, and array copies count up. diff --git a/website/sidebars.js b/website/sidebars.js index 52e3e43bd..0d1d9ba5a 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -118,6 +118,7 @@ module.exports = { label: 'Addons', items: [ 'addons/ai-workpiece-generator', + 'addons/barcodes', 'addons/deepnest', 'addons/image-editing', 'addons/print-and-cut', From 1611c84670a8760b8c75982a87f0506857d329fb Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Mon, 12 Oct 2026 20:35:26 +0200 Subject: [PATCH 07/24] build: install segno in the Windows build Refs #529 --- scripts/win/win_setup.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/win/win_setup.sh b/scripts/win/win_setup.sh index 4042f3b44..7ebef9a90 100644 --- a/scripts/win/win_setup.sh +++ b/scripts/win/win_setup.sh @@ -245,6 +245,7 @@ if [[ "$1" == "pip" || -z "$1" ]]; then "trimesh$(req_version trimesh)" \ "zeroconf$(req_version zeroconf)" \ "ruida-pa$(req_version ruida-pa)" \ + "segno$(req_version segno)" \ --break-system-packages echo "✅ Windows MSYS2 dependency setup complete." From f50dfaad1cee2394438873b5655c1794965c498b Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 19:26:36 +0200 Subject: [PATCH 08/24] test(barcodes): give the addon tests unique module names pytest imports test modules by basename; test_commands.py clashed with the image editing addon's tests and broke collection. --- .../tests/{test_asset.py => test_barcode_asset.py} | 0 .../tests/{test_commands.py => test_barcode_commands.py} | 0 .../tests/{test_encoders.py => test_barcode_encoders.py} | 0 .../tests/{test_geometry.py => test_barcode_geometry.py} | 0 ...est_property_provider.py => test_barcode_property_provider.py} | 0 5 files changed, 0 insertions(+), 0 deletions(-) rename rayforge/builtin_addons/rayforge-addon-barcodes/tests/{test_asset.py => test_barcode_asset.py} (100%) rename rayforge/builtin_addons/rayforge-addon-barcodes/tests/{test_commands.py => test_barcode_commands.py} (100%) rename rayforge/builtin_addons/rayforge-addon-barcodes/tests/{test_encoders.py => test_barcode_encoders.py} (100%) rename rayforge/builtin_addons/rayforge-addon-barcodes/tests/{test_geometry.py => test_barcode_geometry.py} (100%) rename rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/{test_property_provider.py => test_barcode_property_provider.py} (100%) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py similarity index 100% rename from rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_asset.py rename to rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py similarity index 100% rename from rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_commands.py rename to rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_encoders.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_encoders.py similarity index 100% rename from rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_encoders.py rename to rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_encoders.py diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_geometry.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py similarity index 100% rename from rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_geometry.py rename to rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py similarity index 100% rename from rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_property_provider.py rename to rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py From 68449be14f0cdb5028862eb30ef26042c7410294 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Tue, 13 Oct 2026 19:26:36 +0200 Subject: [PATCH 09/24] refactor(barcodes): register the action directly (addon API 22) Refs #529 --- .../rayforge-addon-barcodes/barcodes/frontend.py | 15 ++++----------- .../rayforge-addon-barcodes/rayforge-addon.yaml | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py index fdca53e23..c1c585093 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py @@ -8,8 +8,7 @@ from gi.repository import Gio from rayforge.core.hooks import hookimpl -from rayforge.ui_gtk.action_registry import MenuPlacement, action_registry -from rayforge.ui_gtk.actions import action_extension_registry +from rayforge.ui_gtk.action_registry import MenuPlacement from rayforge.ui_gtk.doceditor.property_providers import ( property_provider_registry, ) @@ -21,18 +20,17 @@ ADD_ACTION = "add-barcode" -def _register_actions(action_manager): +@hookimpl +def register_actions(action_registry): action = Gio.SimpleAction.new(ADD_ACTION, None) def on_activate(action, param): - window = action_manager.win + window = action_registry.window editor = window.doc_editor workpiece = add_barcode(editor, default_position(editor)) window.surface.select_items([workpiece]) action.connect("activate", on_activate) - action_manager.win.add_action(action) - action_manager.actions[ADD_ACTION] = action action_registry.register( action_name=ADD_ACTION, action=action, @@ -42,11 +40,6 @@ def on_activate(action, param): ) -@hookimpl -def register_actions(action_registry): - action_extension_registry.register_setup(_register_actions, ADDON_NAME) - - @hookimpl def main_window_ready(main_window): property_provider_registry.register(BarcodePropertyProvider, ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml b/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml index 11e309299..43d0778cf 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml @@ -1,7 +1,7 @@ name: barcodes display_name: "Barcodes" description: "QR codes and barcodes generated from templates" -api_version: 21 +api_version: 22 requires: - sketcher author: From 68bfc1a7d545d300edd554b92541576d49eca1ce Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:48:37 +0200 Subject: [PATCH 10/24] fix(barcodes): place new barcodes at the panel's work area center machine.work_area is a machine-space rect, so its center was wrong for origins other than bottom-left. Use MachinePanel.work_area_center(), like the image drop and layer column do. Refs #529 --- .../rayforge-addon-barcodes/barcodes/commands.py | 7 +++---- .../tests/test_barcode_commands.py | 12 ++++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py index b291460f8..3c749d616 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py @@ -34,12 +34,11 @@ def barcode_of(item: DocItem | None) -> BarcodeAsset | None: def default_position(editor: Any) -> tuple[float, float]: - """The centre of the machine's work area, or a fixed fallback.""" - machine = getattr(getattr(editor, "context", None), "machine", None) + """The centre of the machine's work area in world coordinates.""" + machine = editor.context.machine if machine is None: return DEFAULT_POSITION_MM - x, y, w, h = machine.work_area - return (x + w / 2.0, y + h / 2.0) + return machine.panel.work_area_center() def add_barcode( diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py index 79dbd7ec7..8e1997208 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py @@ -4,8 +4,10 @@ from barcodes.asset import BarcodeAsset from barcodes.commands import ( DEFAULT_CONTENT, + DEFAULT_POSITION_MM, add_barcode, barcode_of, + default_position, set_barcode_setting, ) from barcodes.encoders import Symbology @@ -80,3 +82,13 @@ def test_unchanged_setting_adds_no_undo_step(editor): def test_barcode_of_other_items(): assert barcode_of(WorkPiece("plain")) is None assert barcode_of(None) is None + + +def test_default_position_without_machine(editor): + assert default_position(editor) == DEFAULT_POSITION_MM + + +def test_default_position_is_the_panel_work_area_center(editor): + panel = SimpleNamespace(work_area_center=lambda: (120.0, 80.0)) + editor.context.machine = SimpleNamespace(panel=panel) + assert default_position(editor) == (120.0, 80.0) From d4bcd97fda35e1f30d29c5f203350501ef2ca746 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:49:12 +0200 Subject: [PATCH 11/24] refactor(barcodes): orient contours with raygeo Drop the Python orient_by_nesting in favour of raygeo's Geometry.normalize_winding_orders(), and let the geometry module return a Geometry instead of loose loops. Refs #529 --- .../rayforge-addon-barcodes/barcodes/asset.py | 23 +++----- .../barcodes/geometry.py | 53 +++++++------------ .../tests/test_barcode_geometry.py | 31 +++++++---- 3 files changed, 45 insertions(+), 62 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py index d26736157..22891afd5 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py @@ -25,7 +25,11 @@ Symbology, encode, ) -from .geometry import BarcodeStyle, Loop, barcode_loops +from .geometry import ( + BarcodeStyle, + barcode_geometry, + loops_to_geometry, +) logger = logging.getLogger(__name__) @@ -56,17 +60,6 @@ def __set__(self, obj: Any, value: Any) -> None: updated.send(obj) -def _loops_to_geometry(loops: list[Loop]) -> Geometry: - geometry = Geometry() - for loop in loops: - x, y = loop[0] - geometry.move_to(x, y) - for x, y in loop[1:]: - geometry.line_to(x, y) - geometry.close_path() - return geometry - - class BarcodeAsset(IAsset, IGeometryProvider): """ A QR code or barcode whose content is a template. The template is @@ -202,11 +195,9 @@ def get_geometry( logger.info(f"Barcode {self.uid[:8]}: {e}") size = PLACEHOLDER_MM outline = [(0.0, 0.0), (size, 0.0), (size, size), (0.0, size)] - return _loops_to_geometry([outline]), [] + return loops_to_geometry([outline]), [] self._error = None - geometry = _loops_to_geometry( - barcode_loops(encoded, self.symbology, self._style()) - ) + geometry = barcode_geometry(encoded, self.symbology, self._style()) fill = FillRenderData( geometry=geometry.copy(), style=FillStyle.SOLID, color=_BLACK ) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py index a56e56a14..12fa3588e 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py @@ -1,17 +1,14 @@ """ Turns encoded modules into closed outlines in millimetres (Y up). -Dark modules are merged into polygons with holes. Every loop is then -oriented by its nesting depth, so even-odd and non-zero filling agree. +Dark modules are merged into polygons with holes. The winding of every +contour is then normalized, so even-odd and non-zero filling agree. """ from dataclasses import dataclass -from raygeo.geo.shape.polygon import ( - get_polygon_signed_area, - get_polygons_union, - is_point_inside_polygon, -) +from raygeo.geo import Geometry +from raygeo.geo.shape.polygon import get_polygons_union from raygeo.geo.shape.text import FontConfig, text_to_geometry from .encoders import Encoded, Symbology @@ -41,30 +38,16 @@ class BarcodeStyle: invert: bool = False -def _probe(loop: Loop) -> tuple[float, float]: - (x1, y1), (x2, y2) = loop[0], loop[1] - return ((x1 + x2) / 2.0, (y1 + y2) / 2.0) - - -def orient_by_nesting(loops: list[Loop]) -> list[Loop]: - """ - Orients each loop counter-clockwise at even nesting depth and - clockwise at odd depth, so nested loops alternate between solid and - hole however they were drawn. - """ - oriented = [] - for index, loop in enumerate(loops): - probe = _probe(loop) - depth = sum( - 1 - for other_index, other in enumerate(loops) - if other_index != index and is_point_inside_polygon(probe, other) - ) - counter_clockwise = get_polygon_signed_area(loop) > 0 - if counter_clockwise != (depth % 2 == 0): - loop = loop[::-1] - oriented.append(loop) - return oriented +def loops_to_geometry(loops: list[Loop]) -> Geometry: + """Closed contours from a list of loops.""" + geometry = Geometry() + for loop in loops: + x, y = loop[0] + geometry.move_to(x, y) + for x, y in loop[1:]: + geometry.line_to(x, y) + geometry.close_path() + return geometry def _rect(x: float, y: float, w: float, h: float) -> Loop: @@ -147,10 +130,10 @@ def _module_rects( return rects, text_space + bars -def barcode_loops( +def barcode_geometry( encoded: Encoded, symbology: Symbology, style: BarcodeStyle -) -> list[Loop]: - """The dark areas of a symbol as oriented loops, in mm.""" +) -> Geometry: + """The dark areas of a symbol as closed contours, in mm.""" rects, height = _module_rects(encoded, symbology, style) loops: list[Loop] = [ [(float(x), float(y)) for x, y in polygon] @@ -163,4 +146,4 @@ def barcode_loops( width = encoded.width * style.module_mm loops = [[(x + q, y + q) for x, y in loop] for loop in loops] loops.append(_rect(0.0, 0.0, width + 2 * q, height + 2 * q)) - return orient_by_nesting(loops) + return loops_to_geometry(loops).normalize_winding_orders() diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py index bbf7ad3f3..8a8db70dd 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py @@ -1,6 +1,10 @@ import pytest from barcodes.encoders import Encoded, Symbology, encode -from barcodes.geometry import BarcodeStyle, barcode_loops, orient_by_nesting +from barcodes.geometry import ( + BarcodeStyle, + barcode_geometry, + loops_to_geometry, +) from raygeo.geo.shape.polygon import get_polygon_signed_area SQUARE = [(0.0, 0.0), (4.0, 0.0), (4.0, 4.0), (0.0, 4.0)] @@ -24,21 +28,26 @@ def _bbox(loops): return min(xs), min(ys), max(xs), max(ys) -def test_orient_by_nesting(): - hole, outer = orient_by_nesting([INNER[::-1], SQUARE[::-1]]) - assert get_polygon_signed_area(outer) > 0 - assert get_polygon_signed_area(hole) < 0 +def _loops(encoded, symbology, style): + geometry = barcode_geometry(encoded, symbology, style) + return geometry.to_polygons(0.001) + + +def test_contours_are_oriented_by_nesting(): + geometry = loops_to_geometry([INNER[::-1], SQUARE[::-1]]) + geometry.normalize_winding_orders() + assert _areas(geometry.to_polygons(0.001)) == pytest.approx([-4.0, 16.0]) def test_modules_merge_into_outline_with_hole(): - loops = barcode_loops(RING, Symbology.QR, BarcodeStyle(module_mm=2.0)) + loops = _loops(RING, Symbology.QR, BarcodeStyle(module_mm=2.0)) assert _areas(loops) == pytest.approx([-4.0, 36.0]) assert _bbox(loops) == pytest.approx((0.0, 0.0, 6.0, 6.0)) def test_invert_adds_a_dark_quiet_zone(): style = BarcodeStyle(module_mm=1.0, quiet_zone=2, invert=True) - loops = barcode_loops(RING, Symbology.QR, style) + loops = _loops(RING, Symbology.QR, style) assert _bbox(loops) == pytest.approx((0.0, 0.0, 7.0, 7.0)) assert sum(get_polygon_signed_area(loop) for loop in loops) == ( pytest.approx(49.0 - 8.0) @@ -48,7 +57,7 @@ def test_invert_adds_a_dark_quiet_zone(): def test_one_dimensional_bars_get_the_bar_height(): encoded = encode(Symbology.CODE128, "A") style = BarcodeStyle(module_mm=0.5, bar_height_mm=12.0) - loops = barcode_loops(encoded, Symbology.CODE128, style) + loops = _loops(encoded, Symbology.CODE128, style) x0, y0, x1, y1 = _bbox(loops) assert (y1 - y0) == pytest.approx(12.0) assert (x1 - x0) == pytest.approx(encoded.width * 0.5) @@ -60,10 +69,10 @@ def test_one_dimensional_bars_get_the_bar_height(): def test_text_goes_below_and_ean_guards_reach_into_it(): encoded = encode(Symbology.EAN13, "590123412345") - plain = barcode_loops( + plain = _loops( encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33) ) - with_text = barcode_loops( + with_text = _loops( encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33, show_text=True) ) plain_height = _bbox(plain)[3] - _bbox(plain)[1] @@ -77,6 +86,6 @@ def test_text_goes_below_and_ean_guards_reach_into_it(): def test_qr_ignores_bar_height_and_text(): encoded = encode(Symbology.QR, "x") style = BarcodeStyle(module_mm=1.0, bar_height_mm=99.0, show_text=True) - loops = barcode_loops(encoded, Symbology.QR, style) + loops = _loops(encoded, Symbology.QR, style) x0, y0, x1, y1 = _bbox(loops) assert (x1 - x0, y1 - y0) == pytest.approx((21.0, 21.0)) From ad11ef4a240097bef6a7d619be7fc99454366c7e Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:49:23 +0200 Subject: [PATCH 12/24] style(barcodes): format the geometry test Refs #529 --- .../rayforge-addon-barcodes/tests/test_barcode_geometry.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py index 8a8db70dd..e67b7d354 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py @@ -69,9 +69,7 @@ def test_one_dimensional_bars_get_the_bar_height(): def test_text_goes_below_and_ean_guards_reach_into_it(): encoded = encode(Symbology.EAN13, "590123412345") - plain = _loops( - encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33) - ) + plain = _loops(encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33)) with_text = _loops( encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33, show_text=True) ) From ad5ddeac4a97ed9bc873165ad754a5815a622dc3 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:52:28 +0200 Subject: [PATCH 13/24] refactor(barcodes): edit settings through UpdateAssetCommand Barcode settings went through ChangePropertyCommand, a self-made descriptor that sent updated, and a separate resize loop. The core UpdateAssetCommand already updates a geometry provider asset, resizes its workpieces and restores their matrices on undo, as the sketcher does. Use it and make the settings plain attributes. Refs #529 --- .../rayforge-addon-barcodes/barcodes/asset.py | 30 ------------------ .../barcodes/commands.py | 31 +++++++------------ .../barcodes/property_provider.py | 4 ++- .../tests/test_barcode_asset.py | 9 ------ .../tests/test_barcode_commands.py | 4 +++ .../ui_gtk/test_barcode_property_provider.py | 6 ++++ 6 files changed, 25 insertions(+), 59 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py index 22891afd5..6fbdfc60c 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py @@ -37,27 +37,6 @@ CACHE_KEY = 0 PLACEHOLDER_MM = 10.0 _BLACK = (0.0, 0.0, 0.0, 1.0) -_UNSET = object() - - -class _Setting: - """An attribute that sends the owner's ``updated`` when it changes.""" - - def __set_name__(self, owner: type, name: str) -> None: - self.attr = "_" + name - - def __get__(self, obj: Any, owner: type | None = None) -> Any: - if obj is None: - return self - return getattr(obj, self.attr) - - def __set__(self, obj: Any, value: Any) -> None: - if getattr(obj, self.attr, _UNSET) == value: - return - setattr(obj, self.attr, value) - updated = getattr(obj, "updated", None) - if updated is not None: - updated.send(obj) class BarcodeAsset(IAsset, IGeometryProvider): @@ -79,15 +58,6 @@ class BarcodeAsset(IAsset, IGeometryProvider): activate_action: ClassVar[str | None] = None edit_item_action: ClassVar[str | None] = None - content = _Setting() - symbology = _Setting() - error_correction = _Setting() - module_mm = _Setting() - bar_height_mm = _Setting() - quiet_zone = _Setting() - show_text = _Setting() - invert = _Setting() - def __init__( self, name: str | None = None, diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py index 3c749d616..c3c3d126e 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py @@ -7,12 +7,9 @@ from rayforge.core.doc import Doc from rayforge.core.item import DocItem -from rayforge.core.undo import ( - ChangePropertyCommand, - HistoryManager, - ListItemCommand, -) +from rayforge.core.undo import HistoryManager, ListItemCommand from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.asset_cmd import UpdateAssetCommand from .asset import BarcodeAsset @@ -73,26 +70,22 @@ def add_barcode( return workpiece -def _resize_instances(doc: Doc, asset: BarcodeAsset) -> None: - """Gives every workpiece of *asset* the symbol's true size.""" - for workpiece in doc.all_workpieces: - if workpiece.geometry_provider_uid == asset.uid: - workpiece.regenerate_from_geometry_provider() - - def set_barcode_setting( editor: _Editor, asset: BarcodeAsset, setting: str, value: Any ) -> None: - """Changes one barcode setting as an undo step.""" + """ + Changes one barcode setting as an undo step. The asset is replaced + and its workpieces are resized by the generic asset update. + """ if getattr(asset, setting) == value: return - doc = editor.doc + changed = BarcodeAsset.from_dict(asset.to_dict()) + setattr(changed, setting, value) editor.history_manager.execute( - ChangePropertyCommand( - target=asset, - property_name=setting, - new_value=value, - on_change_callback=lambda: _resize_instances(doc, asset), + UpdateAssetCommand( + doc=editor.doc, + asset_uid=asset.uid, + new_data=changed.to_dict(), name=_("Change Barcode"), ) ) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py index b030a3fea..f700b43b2 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py @@ -170,7 +170,9 @@ def _set(self, setting: str, value: Any) -> None: if self._in_update or asset is None: return set_barcode_setting(self.editor, asset, setting, value) - self._update_visibility(asset) + changed = self.asset + if changed is not None: + self._update_visibility(changed) def _on_content_applied(self, row: Adw.EntryRow) -> None: self._set("content", row.get_text()) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py index 2e89afe35..7b0e89392 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py @@ -80,15 +80,6 @@ def test_default_quiet_zone_is_the_symbology_minimum(): assert BarcodeAsset(quiet_zone=2).effective_quiet_zone == 2 -def test_changing_a_setting_signals_updated(): - asset = BarcodeAsset(content="x") - seen = [] - asset.updated.connect(seen.append, weak=False) - asset.module_mm = 1.0 - asset.module_mm = 1.0 - assert seen == [asset] - - def test_workpiece_from_barcode(): doc = Doc() asset = BarcodeAsset(content="hello", module_mm=0.5) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py index 8e1997208..d056512d8 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py @@ -63,7 +63,11 @@ def test_switching_symbology(editor): asset = barcode_of(wp) assert asset is not None set_barcode_setting(editor, asset, "content", "590123412345") + asset = barcode_of(wp) + assert asset is not None set_barcode_setting(editor, asset, "symbology", Symbology.EAN13) + asset = barcode_of(wp) + assert asset is not None assert asset.error is None width, height = wp.size assert width == pytest.approx(95 * asset.module_mm) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py index d8a0ff254..9dd1631d8 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py @@ -61,11 +61,15 @@ def test_edits_go_through_undo(editor, provider): provider.content_row.set_text("590123412345") provider.content_row.emit("apply") provider.symbology_row.set_selected(SYMBOLOGIES.index(Symbology.EAN13)) + asset = barcode_of(wp) + assert asset is not None assert asset.symbology is Symbology.EAN13 assert asset.content == "590123412345" assert provider.bar_height_row.get_visible() assert not provider.error_row.get_visible() editor.history_manager.undo() + asset = barcode_of(wp) + assert asset is not None assert asset.symbology is Symbology.QR @@ -76,5 +80,7 @@ def test_invalid_content_is_flagged(editor, provider): assert asset is not None provider.update_widgets(editor, [wp]) provider.symbology_row.set_selected(SYMBOLOGIES.index(Symbology.EAN13)) + asset = barcode_of(wp) + assert asset is not None assert asset.error is not None assert provider.content_row.has_css_class("error") From 96f8bafb9cef410721f01cea1f9c01d9f6e72eae Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:52:52 +0200 Subject: [PATCH 14/24] refactor(barcodes): unit-aware rows for module size and bar height Use LengthSpinRow like the other length settings, so the values follow the user's length unit instead of always showing mm. Drop the markup escaping of a plain-text tooltip. Refs #529 --- .../barcodes/property_provider.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py index f700b43b2..82af71994 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py @@ -3,13 +3,13 @@ from gettext import gettext as _ from typing import TYPE_CHECKING, Any -from gi.repository import Adw, GLib, Gtk +from gi.repository import Adw, Gtk from rayforge.core.item import DocItem from rayforge.ui_gtk.doceditor.property_providers.base import ( PropertyProvider, ) -from rayforge.ui_gtk.shared.pref_rows import SpinRow +from rayforge.ui_gtk.shared.pref_rows import LengthSpinRow, SpinRow from .asset import BarcodeAsset from .commands import barcode_of, set_barcode_setting @@ -58,17 +58,17 @@ def create_widgets(self) -> list[Gtk.Widget]: self.error_row = _combo( _("Error Correction"), [ERROR_LABELS[e] for e in ERROR_LEVELS] ) - self.module_row = SpinRow( + self.module_row = LengthSpinRow( _("Module Size"), - _("Width of the narrowest bar or one QR square, in mm"), + _("Width of the narrowest bar or one QR square"), lower=0.05, upper=20.0, step_increment=0.05, digits=2, ) - self.bar_height_row = SpinRow( + self.bar_height_row = LengthSpinRow( _("Bar Height"), - _("Height of the bars, in mm"), + _("Height of the bars"), lower=1.0, upper=500.0, step_increment=0.5, @@ -92,10 +92,12 @@ def create_widgets(self) -> list[Gtk.Widget]: self.symbology_row.connect("notify::selected", self._on_symbology) self.error_row.connect("notify::selected", self._on_error) self.module_row.value_changed.connect( - lambda row: self._set("module_mm", row.get_value()) + lambda row: self._set("module_mm", row.get_value_in_base_units()) ) self.bar_height_row.value_changed.connect( - lambda row: self._set("bar_height_mm", row.get_value()) + lambda row: self._set( + "bar_height_mm", row.get_value_in_base_units() + ) ) self.quiet_zone_row.value_changed.connect( lambda row: self._set("quiet_zone", row.get_int_value()) @@ -137,8 +139,8 @@ def update_widgets(self, editor: "DocEditor", items: list[DocItem]): self.error_row.set_selected( ERROR_LEVELS.index(asset.error_correction) ) - self.module_row.set_value(asset.module_mm) - self.bar_height_row.set_value(asset.bar_height_mm) + self.module_row.set_value_in_base_units(asset.module_mm) + self.bar_height_row.set_value_in_base_units(asset.bar_height_mm) self.quiet_zone_row.set_value(asset.effective_quiet_zone) self.text_row.set_active(asset.show_text) self.invert_row.set_active(asset.invert) @@ -160,9 +162,7 @@ def _update_visibility(self, asset: BarcodeAsset) -> None: else: self.content_row.remove_css_class("error") self.content_row.set_tooltip_text( - GLib.markup_escape_text( - _("Templates work here too, e.g. SN-{serial:04d}") - ) + _("Templates work here too, e.g. SN-{serial:04d}") ) def _set(self, setting: str, value: Any) -> None: From 24cbcc81b5df7e5ce9e3197cd0e1d94b4b54d6b7 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:53:18 +0200 Subject: [PATCH 15/24] refactor(barcodes): ship the barcode icon with the addon Addon icons live in the addon's resources/icons and are registered from its frontend, as in the laser, CNC and automation addons. Refs #529 --- .../rayforge-addon-barcodes/barcodes/frontend.py | 5 +++++ .../barcodes}/resources/icons/barcode-symbolic.svg | 0 2 files changed, 5 insertions(+) rename rayforge/{ => builtin_addons/rayforge-addon-barcodes/barcodes}/resources/icons/barcode-symbolic.svg (100%) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py index c1c585093..6bf57ba38 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py @@ -4,6 +4,7 @@ """ from gettext import gettext as _ +from pathlib import Path from gi.repository import Gio @@ -12,12 +13,16 @@ from rayforge.ui_gtk.doceditor.property_providers import ( property_provider_registry, ) +from rayforge.ui_gtk.icons import register_icon_path from .commands import add_barcode, default_position from .property_provider import BarcodePropertyProvider ADDON_NAME = "barcodes" ADD_ACTION = "add-barcode" +_ICONS_DIR = Path(__file__).parent / "resources" / "icons" + +register_icon_path(_ICONS_DIR) @hookimpl diff --git a/rayforge/resources/icons/barcode-symbolic.svg b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/resources/icons/barcode-symbolic.svg similarity index 100% rename from rayforge/resources/icons/barcode-symbolic.svg rename to rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/resources/icons/barcode-symbolic.svg From 91853f675f372a962933fbb04f2885f752750b24 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:53:43 +0200 Subject: [PATCH 16/24] refactor(sketcher): narrow is_sketch_workpiece with TypeGuard Lets the type checker narrow to WorkPiece, so the asserts that only served narrowing go away. Refs #529 --- .../rayforge-addon-sketcher/sketcher/core/workpieces.py | 4 +++- .../sketcher/ui_gtk/property_provider.py | 1 - .../sketcher/ui_gtk/sketch_mode_cmd.py | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py index fcaae61be..2b5041125 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py @@ -1,10 +1,12 @@ +from typing import TypeGuard + from rayforge.core.item import DocItem from rayforge.core.workpiece import WorkPiece from .sketch import Sketch -def is_sketch_workpiece(item: DocItem | None) -> bool: +def is_sketch_workpiece(item: DocItem | None) -> TypeGuard[WorkPiece]: """ Whether *item* is a workpiece drawn by a sketch. Other geometry providers (barcodes, for example) also set a provider uid, so the diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py index 823d61957..51da8b8ee 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/property_provider.py @@ -45,7 +45,6 @@ def can_handle(self, items: list[DocItem]) -> bool: for item in items: if not is_sketch_workpiece(item): return False - assert isinstance(item, WorkPiece) if first_sketch_uid is None: first_sketch_uid = item.geometry_provider_uid diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py index b41489b29..84cb97cf3 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/ui_gtk/sketch_mode_cmd.py @@ -249,7 +249,6 @@ def on_edit_sketch_item(self, action, param): item_uid = param.get_string() item = self._editor.doc.find_descendant_by_uid(item_uid) if is_sketch_workpiece(item): - assert isinstance(item, WorkPiece) self.enter_sketch_mode(item) def on_export_object(self, action, param): From afea1bc442fb9cd31e7b6ab2c550d9f58e832ac0 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:57:47 +0200 Subject: [PATCH 17/24] docs: match the Barcodes page to the unit-aware size rows Module Size and Bar Height follow the chosen length unit, and Quiet Zone can only be changed while Invert is on. Refs #529 --- website/docs/addons/barcodes.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/website/docs/addons/barcodes.md b/website/docs/addons/barcodes.md index d6c236529..457c5df01 100644 --- a/website/docs/addons/barcodes.md +++ b/website/docs/addons/barcodes.md @@ -18,25 +18,26 @@ Edit it in the **Barcode** group of the properties panel: - **Type** — QR Code, Code 128 or EAN-13. - **Error Correction** (QR) — how much of the code may be damaged and still read: Low, Medium, Quartile or High. Higher levels make the code larger. -- **Module Size** — the width of the narrowest bar, or of one QR square, in mm. The workpiece takes - the size of the code, so this is the setting that sizes it. +- **Module Size** — the width of the narrowest bar, or of one QR square. Module Size and Bar Height + are shown in your chosen length unit. The workpiece takes the size of the code, so this is the + setting that sizes it. - **Bar Height** (Code 128, EAN-13) — the height of the bars. - **Show Text** (Code 128, EAN-13) — prints the content under the bars. EAN-13 uses its usual layout, with the guard bars reaching into the digits. -- **Invert** — engraves the light parts instead, for dark material: the code becomes light on a - dark border. **Quiet Zone** sets that border in modules; it defaults to the minimum the type - needs. +- **Invert** — engraves the light parts instead, for dark material: the code becomes light on a dark + border. **Quiet Zone** sets that border in modules and can only be changed while **Invert** is on; + it defaults to the minimum the type needs. -Leave a clear margin around a code that is not inverted: 4 modules for QR codes, 10 for Code 128 -and 9 for EAN-13. +Leave a clear margin around a code that is not inverted: 4 modules for QR codes, 10 for Code 128 and +9 for EAN-13. ## Types - **QR Code** holds any text, including links. - **Code 128** holds printable ASCII. Runs of digits are packed in pairs automatically. - **EAN-13** takes 12 digits, and the check digit is added. With 13 digits the check digit is - verified. The content field turns red when the content does not fit the type; hover over it to - see why. + verified. The content field turns red when the content does not fit the type; hover over it to see + why. ## Templates From bcd3dc7cf6abd8f874baf9f832cb20d51a2551cf Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Thu, 15 Oct 2026 05:57:48 +0200 Subject: [PATCH 18/24] docs: translate the Barcodes addon page Add the Barcodes addon page to every translated docs directory. Refs #529 --- .../current/addons/barcodes.md | 48 ++++++++++++++++++ .../current/addons/barcodes.md | 49 ++++++++++++++++++ .../current/addons/barcodes.md | 50 +++++++++++++++++++ .../current/addons/barcodes.md | 49 ++++++++++++++++++ .../current/addons/barcodes.md | 49 ++++++++++++++++++ .../current/addons/barcodes.md | 47 +++++++++++++++++ .../current/addons/barcodes.md | 37 ++++++++++++++ 7 files changed, 329 insertions(+) create mode 100644 website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md create mode 100644 website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md create mode 100644 website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md create mode 100644 website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md create mode 100644 website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md create mode 100644 website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md create mode 100644 website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md new file mode 100644 index 000000000..63c7483ce --- /dev/null +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -0,0 +1,48 @@ +--- +description: + "QR-Codes, Code 128 und EAN-13-Barcodes in Rayforge gravieren. Der Inhalt kann eine Vorlage sein, + sodass jedes Teil seinen eigenen Code trägt." +--- + +# Barcodes + +Das Barcodes-Addon fügt QR-Codes und Barcodes als Werkstücke hinzu. Sie werden als Vektorumrisse mit +Füllung gezeichnet, sodass eine Gravur-Operation sie sauber graviert und eine Kontur-Operation sie +trotzdem nachfahren kann. + +## Einen Barcode hinzufügen + +Wähle **Objekt - Barcode hinzufügen**. In der Mitte des Arbeitsbereichs erscheint ein QR-Code und +ist ausgewählt. Bearbeite ihn in der Gruppe **Barcode** des Eigenschaftenbereichs: + +- **Inhalt** — der zu kodierende Text. Drücke `Enter` oder die Übernehmen-Schaltfläche, um den Code + zu aktualisieren. +- **Typ** — QR-Code, Code 128 oder EAN-13. +- **Fehlerkorrektur** (QR) — wie viel des Codes beschädigt sein darf, damit er noch lesbar ist: + Niedrig, Mittel, Quartil oder Hoch. Höhere Stufen machen den Code größer. +- **Modulgröße** — die Breite des schmalsten Strichs oder eines QR-Quadrats. Modulgröße und + Strichhöhe werden in deiner gewählten Längeneinheit angezeigt. Das Werkstück nimmt die Größe des + Codes an, daher bestimmt diese Einstellung seine Größe. +- **Strichhöhe** (Code 128, EAN-13) — die Höhe der Striche. +- **Text anzeigen** (Code 128, EAN-13) — druckt den Inhalt unter die Striche. EAN-13 verwendet das + übliche Layout, bei dem die Begrenzungszeichen zwischen die Ziffern hinunterreichen. +- **Invertieren** — graviert stattdessen die hellen Teile, für dunkles Material: Der Code wird hell + auf einem dunklen Rand. **Ruhezone** legt diesen Rand in Modulen fest und lässt sich nur ändern, + solange **Invertieren** aktiv ist; standardmäßig ist es das Minimum, das der Typ benötigt. + +Lass um einen nicht invertierten Code einen freien Rand: 4 Module bei QR-Codes, 10 bei Code 128 und +9 bei EAN-13. + +## Typen + +- **QR-Code** enthält beliebigen Text, auch Links. +- **Code 128** enthält druckbare ASCII-Zeichen. Ziffernfolgen werden automatisch paarweise gepackt. +- **EAN-13** nimmt 12 Ziffern, die Prüfziffer wird ergänzt. Bei 13 Ziffern wird die Prüfziffer + kontrolliert. Das Inhaltsfeld wird rot, wenn der Inhalt nicht zum Typ passt; fahre mit der Maus + darüber, um den Grund zu sehen. + +## Vorlagen + +Der Inhalt kann wie ein Textfeld `{...}`-Ausdrücke enthalten, zum Beispiel `{2 * 21}`. Mit +[Variabler Text](../features/variable-text.md) erhält mit `SN-{serial:05d}` jedes Teil seine eigene +Seriennummer, und Array-Kopien zählen weiter. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md new file mode 100644 index 000000000..62ab9c32d --- /dev/null +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -0,0 +1,49 @@ +--- +description: + "Graba códigos QR, Code 128 y códigos de barras EAN-13 en Rayforge. El contenido puede ser una + plantilla, para que cada pieza lleve su propio código." +--- + +# Códigos de barras + +El complemento Códigos de barras añade códigos QR y códigos de barras como piezas de trabajo. Se +dibujan como contornos vectoriales con relleno, de modo que una operación de Grabado los graba +limpiamente y una operación de Contorno aún puede seguirlos. + +## Añadir un código de barras + +Elige **Objeto - Añadir código de barras**. Aparece un código QR en el centro del área de trabajo, +ya seleccionado. Edítalo en el grupo **Código de barras** del panel de propiedades: + +- **Contenido** — el texto a codificar. Pulsa `Enter` o el botón de aplicar para actualizar el + código. +- **Tipo** — Código QR, Code 128 o EAN-13. +- **Corrección de errores** (QR) — cuánto del código puede estar dañado y seguir leyéndose: Baja, + Media, Cuartil o Alta. Los niveles más altos hacen el código más grande. +- **Tamaño de módulo** — el ancho de la barra más estrecha o de un cuadrado QR. El tamaño de módulo + y la altura de barra se muestran en la unidad de longitud que hayas elegido. La pieza toma el + tamaño del código, así que este es el ajuste que determina su tamaño. +- **Altura de barra** (Code 128, EAN-13) — la altura de las barras. +- **Mostrar texto** (Code 128, EAN-13) — imprime el contenido debajo de las barras. EAN-13 usa su + disposición habitual, con las barras de guarda bajando entre los dígitos. +- **Invertir** — graba las partes claras en su lugar, para material oscuro: el código queda claro + sobre un borde oscuro. **Zona de silencio** fija ese borde en módulos y solo se puede cambiar + mientras **Invertir** está activado; por defecto es el mínimo que necesita el tipo. + +Deja un margen libre alrededor de un código no invertido: 4 módulos para códigos QR, 10 para Code +128 y 9 para EAN-13. + +## Tipos + +- **Código QR** admite cualquier texto, incluidos enlaces. +- **Code 128** admite ASCII imprimible. Las secuencias de dígitos se empaquetan por pares + automáticamente. +- **EAN-13** toma 12 dígitos y añade el dígito de control. Con 13 dígitos, el dígito de control se + verifica. El campo de contenido se vuelve rojo cuando el contenido no encaja con el tipo; pasa el + ratón por encima para ver el motivo. + +## Plantillas + +El contenido puede contener expresiones `{...}` como un cuadro de texto, por ejemplo `{2 * 21}`. Con +[Texto variable](../features/variable-text.md), `SN-{serial:05d}` da a cada pieza su propio número +de serie, y las copias de una matriz siguen contando. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md new file mode 100644 index 000000000..e40b62909 --- /dev/null +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -0,0 +1,50 @@ +--- +description: + "Gravez des QR codes, des codes-barres Code 128 et EAN-13 dans Rayforge. Le contenu peut être un + modèle, pour que chaque pièce porte son propre code." +--- + +# Codes-barres + +L'extension Codes-barres ajoute des QR codes et des codes-barres en tant que pièces. Ils sont +dessinés sous forme de contours vectoriels avec un remplissage : une opération de Gravure les grave +proprement et une opération de Contour peut quand même les suivre. + +## Ajouter un code-barres + +Choisissez **Objet - Ajouter un code-barres**. Un QR code apparaît au centre de la zone de travail, +déjà sélectionné. Modifiez-le dans le groupe **Code-barres** du panneau des propriétés : + +- **Contenu** — le texte à encoder. Appuyez sur `Entrée` ou sur le bouton d'application pour mettre + à jour le code. +- **Type** — QR code, Code 128 ou EAN-13. +- **Correction d'erreurs** (QR) — la part du code qui peut être endommagée tout en restant lisible : + Faible, Moyenne, Quartile ou Élevée. Les niveaux plus élevés agrandissent le code. +- **Taille de module** — la largeur de la barre la plus fine, ou d'un carré du QR code. La taille de + module et la hauteur des barres s'affichent dans l'unité de longueur que vous avez choisie. La + pièce prend la taille du code : c'est donc ce réglage qui la dimensionne. +- **Hauteur des barres** (Code 128, EAN-13) — la hauteur des barres. +- **Afficher le texte** (Code 128, EAN-13) — imprime le contenu sous les barres. L'EAN-13 utilise sa + disposition habituelle, avec les barres de garde qui descendent entre les chiffres. +- **Inverser** — grave plutôt les parties claires, pour les matériaux sombres : le code devient + clair sur une bordure sombre. **Zone de silence** règle cette bordure en modules et ne peut être + modifiée que lorsque **Inverser** est activé ; par défaut, elle vaut le minimum requis par le + type. + +Laissez une marge libre autour d'un code non inversé : 4 modules pour les QR codes, 10 pour le Code +128 et 9 pour l'EAN-13. + +## Types + +- **QR code** accepte n'importe quel texte, y compris des liens. +- **Code 128** accepte l'ASCII imprimable. Les suites de chiffres sont automatiquement regroupées + par paires. +- **EAN-13** prend 12 chiffres et ajoute le chiffre de contrôle. Avec 13 chiffres, le chiffre de + contrôle est vérifié. Le champ de contenu devient rouge lorsque le contenu ne convient pas au type + ; survolez-le pour en voir la raison. + +## Modèles + +Le contenu peut contenir des expressions `{...}` comme une zone de texte, par exemple `{2 * 21}`. +Avec [Texte variable](../features/variable-text.md), `SN-{serial:05d}` donne à chaque pièce son +propre numéro de série, et les copies d'un réseau continuent de compter. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md new file mode 100644 index 000000000..89e79fcb6 --- /dev/null +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -0,0 +1,49 @@ +--- +description: + "Rayforge में QR कोड, Code 128 और EAN-13 बारकोड उत्कीर्ण करें। सामग्री एक टेम्पलेट हो सकती है, + ताकि हर टुकड़े पर उसका अपना कोड हो।" +--- + +# बारकोड + +बारकोड ऐडऑन QR कोड और बारकोड को वर्कपीस के रूप में जोड़ता है। वे भराव (fill) वाली वेक्टर रूपरेखाओं +के रूप में बनाए जाते हैं, इसलिए एक उत्कीर्णन (Engrave) ऑपरेशन उन्हें साफ़ उत्कीर्ण करता है और एक +कॉन्टूर (Contour) ऑपरेशन फिर भी उनका अनुसरण कर सकता है। + +## बारकोड जोड़ना + +**Object - Add Barcode** चुनें। कार्य क्षेत्र के बीच में एक QR कोड दिखाई देता है और चयनित होता है। +इसे गुण पैनल के **Barcode** समूह में संपादित करें: + +- **Content** — एन्कोड करने वाला टेक्स्ट। कोड अपडेट करने के लिए `Enter` दबाएँ या लागू करें बटन + दबाएँ। +- **Type** — QR Code, Code 128 या EAN-13। +- **Error Correction** (QR) — कोड का कितना हिस्सा क्षतिग्रस्त होकर भी पढ़ा जा सकता है: Low, Medium, + Quartile या High। ऊँचे स्तर कोड को बड़ा बनाते हैं। +- **Module Size** — सबसे पतली पट्टी या एक QR वर्ग की चौड़ाई। Module Size और Bar Height आपकी चुनी हुई + लंबाई इकाई में दिखाए जाते हैं। वर्कपीस कोड का आकार ले लेता है, इसलिए यही सेटिंग उसका आकार तय करती + है। +- **Bar Height** (Code 128, EAN-13) — पट्टियों की ऊँचाई। +- **Show Text** (Code 128, EAN-13) — सामग्री को पट्टियों के नीचे छापता है। EAN-13 अपने सामान्य लेआउट + का उपयोग करता है, जिसमें गार्ड पट्टियाँ अंकों के बीच तक जाती हैं। +- **Invert** — गहरे रंग की सामग्री के लिए, इसके बजाय हल्के हिस्सों को उत्कीर्ण करता है: कोड एक गहरी + सीमा पर हल्का दिखता है। **Quiet Zone** उस सीमा को मॉड्यूल में तय करता है और केवल तब बदला जा सकता + है जब **Invert** चालू हो; डिफ़ॉल्ट रूप से यह उस प्रकार के लिए आवश्यक न्यूनतम मान है। + +जो कोड इनवर्ट नहीं है उसके चारों ओर खाली हाशिया छोड़ें: QR कोड के लिए 4 मॉड्यूल, Code 128 के लिए 10 +और EAN-13 के लिए 9। + +## प्रकार + +- **QR Code** कोई भी टेक्स्ट रख सकता है, लिंक सहित। +- **Code 128** मुद्रण योग्य ASCII रखता है। अंकों की शृंखलाएँ स्वचालित रूप से जोड़ों में पैक की जाती + हैं। +- **EAN-13** 12 अंक लेता है, और चेक अंक जोड़ा जाता है। 13 अंकों के साथ चेक अंक की जाँच की जाती है। + जब सामग्री प्रकार में फिट नहीं होती तो सामग्री फ़ील्ड लाल हो जाता है; कारण देखने के लिए उस पर माउस + ले जाएँ। + +## टेम्पलेट + +सामग्री में टेक्स्ट बॉक्स की तरह `{...}` एक्सप्रेशन हो सकते हैं, उदाहरण के लिए `{2 * 21}`। +[वेरिएबल टेक्स्ट](../features/variable-text.md) के साथ, `SN-{serial:05d}` हर टुकड़े को उसका अपना +सीरियल नंबर देता है, और ऐरे की प्रतियाँ गिनती आगे बढ़ाती हैं। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md new file mode 100644 index 000000000..000e8190c --- /dev/null +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -0,0 +1,49 @@ +--- +description: + "Grave códigos QR e códigos de barras Code 128 e EAN-13 no Rayforge. O conteúdo pode ser um + modelo, para que cada peça tenha seu próprio código." +--- + +# Códigos de barras + +O complemento Códigos de barras adiciona códigos QR e códigos de barras como peças. Eles são +desenhados como contornos vetoriais com preenchimento, de modo que uma operação de Gravação os grava +de forma limpa e uma operação de Contorno ainda pode segui-los. + +## Adicionar um código de barras + +Escolha **Objeto - Adicionar código de barras**. Um código QR aparece no centro da área de trabalho, +já selecionado. Edite-o no grupo **Código de barras** do painel de propriedades: + +- **Conteúdo** — o texto a codificar. Pressione `Enter` ou o botão de aplicar para atualizar o + código. +- **Tipo** — Código QR, Code 128 ou EAN-13. +- **Correção de erros** (QR) — quanto do código pode estar danificado e ainda ser lido: Baixa, + Média, Quartil ou Alta. Níveis mais altos deixam o código maior. +- **Tamanho do módulo** — a largura da barra mais estreita, ou de um quadrado do QR. O tamanho do + módulo e a altura das barras são mostrados na unidade de comprimento escolhida. A peça assume o + tamanho do código, então é essa configuração que define o tamanho. +- **Altura das barras** (Code 128, EAN-13) — a altura das barras. +- **Mostrar texto** (Code 128, EAN-13) — imprime o conteúdo abaixo das barras. O EAN-13 usa seu + layout habitual, com as barras de guarda descendo entre os dígitos. +- **Inverter** — grava as partes claras em vez disso, para material escuro: o código fica claro + sobre uma borda escura. **Zona de silêncio** define essa borda em módulos e só pode ser alterada + enquanto **Inverter** estiver ativado; por padrão, é o mínimo que o tipo exige. + +Deixe uma margem livre em volta de um código não invertido: 4 módulos para códigos QR, 10 para Code +128 e 9 para EAN-13. + +## Tipos + +- **Código QR** aceita qualquer texto, incluindo links. +- **Code 128** aceita ASCII imprimível. Sequências de dígitos são agrupadas em pares + automaticamente. +- **EAN-13** recebe 12 dígitos e o dígito verificador é adicionado. Com 13 dígitos, o dígito + verificador é conferido. O campo de conteúdo fica vermelho quando o conteúdo não se encaixa no + tipo; passe o mouse sobre ele para ver o motivo. + +## Modelos + +O conteúdo pode conter expressões `{...}` como uma caixa de texto, por exemplo `{2 * 21}`. Com +[Texto variável](../features/variable-text.md), `SN-{serial:05d}` dá a cada peça seu próprio número +de série, e as cópias de uma matriz continuam a contagem. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md new file mode 100644 index 000000000..97b7f2a88 --- /dev/null +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -0,0 +1,47 @@ +--- +description: + "Гравіюйте QR-коди, штрихкоди Code 128 та EAN-13 у Rayforge. Вміст може бути шаблоном, тож кожна + деталь отримує власний код." +--- + +# Штрихкоди + +Додаток «Штрихкоди» додає QR-коди та штрихкоди як робочі елементи. Вони малюються як векторні +контури із заливкою, тому операція Гравіювання гравіює їх чисто, а операція Контур усе одно може їх +обвести. + +## Додавання штрихкоду + +Виберіть **Об'єкт - Додати штрихкод**. У центрі робочої області з'являється QR-код, і він виділений. +Редагуйте його в групі **Штрихкод** панелі властивостей: + +- **Вміст** — текст для кодування. Натисніть `Enter` або кнопку застосування, щоб оновити код. +- **Тип** — QR-код, Code 128 або EAN-13. +- **Корекція помилок** (QR) — яку частину коду може бути пошкоджено, щоб він усе ще читався: Низька, + Середня, Квартиль або Висока. Вищі рівні роблять код більшим. +- **Розмір модуля** — ширина найвужчої смуги або одного квадрата QR. Розмір модуля та висота смуг + показуються у вибраній вами одиниці довжини. Робочий елемент набуває розміру коду, тож саме цей + параметр задає розмір. +- **Висота смуг** (Code 128, EAN-13) — висота смуг. +- **Показувати текст** (Code 128, EAN-13) — друкує вміст під смугами. EAN-13 використовує звичне + компонування, у якому обмежувальні смуги заходять між цифрами. +- **Інвертувати** — гравіює натомість світлі частини, для темного матеріалу: код стає світлим на + темній рамці. **Тиха зона** задає цю рамку в модулях і змінюється лише тоді, коли **Інвертувати** + увімкнено; за замовчуванням це мінімум, потрібний для типу. + +Залишайте вільне поле навколо неінвертованого коду: 4 модулі для QR-кодів, 10 для Code 128 і 9 для +EAN-13. + +## Типи + +- **QR-код** містить будь-який текст, зокрема посилання. +- **Code 128** містить друковані символи ASCII. Послідовності цифр автоматично пакуються парами. +- **EAN-13** приймає 12 цифр, контрольна цифра додається. З 13 цифрами контрольна цифра + перевіряється. Поле вмісту стає червоним, коли вміст не підходить для типу; наведіть на нього + курсор, щоб побачити причину. + +## Шаблони + +Вміст може містити вирази `{...}`, як текстовий блок, наприклад `{2 * 21}`. Зі +[Змінним текстом](../features/variable-text.md) `SN-{serial:05d}` дає кожній деталі власний серійний +номер, а копії масиву продовжують відлік. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md new file mode 100644 index 000000000..37b524524 --- /dev/null +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -0,0 +1,37 @@ +--- +description: + "在 Rayforge 中雕刻 QR 码、Code 128 和 EAN-13 条形码。内容可以是模板,让每个工件都带有自己的编码。" +--- + +# 条形码 + +条形码插件将 QR 码和条形码作为工件添加。它们被绘制为带填充的矢量轮廓,因此雕刻操作可以干净地雕刻它们,轮廓操作也仍然可以沿其切割。 + +## 添加条形码 + +选择 **对象 - 添加条形码**。工作区中央会出现一个已选中的 QR 码。在属性面板的 **条形码** 组中编辑它: + +- **内容** — 要编码的文本。按 `Enter` 或应用按钮以更新编码。 +- **类型** — QR 码、Code 128 或 EAN-13。 +- **纠错** (QR) — 编码受损多少仍可读取:低、中、四分之一或高。级别越高,编码越大。 +- **模块尺寸** + — 最窄条或一个 QR 方块的宽度。模块尺寸和条高以您选择的长度单位显示。工件会采用编码的尺寸,因此由此设置决定其大小。 +- **条高** (Code 128、EAN-13) — 条的高度。 +- **显示文本** (Code 128、EAN-13) — 在条下方打印内容。EAN-13 使用其常规布局,保护条延伸到数字之间。 +- **反转** — 改为雕刻浅色部分,适用于深色材料:编码在深色边框上呈浅色。**静区** + 以模块为单位设置该边框,仅在 **反转** 开启时可更改;默认为该类型所需的最小值。 + +在未反转的编码周围留出空白边距:QR 码 4 个模块,Code 128 10 个,EAN-13 9 个。 + +## 类型 + +- **QR 码** 可容纳任意文本,包括链接。 +- **Code 128** 可容纳可打印的 ASCII 字符。连续数字会自动按对打包。 +- **EAN-13** + 接受 12 位数字,并自动添加校验位。输入 13 位数字时会验证校验位。当内容不适合该类型时,内容字段会变红;将鼠标悬停在其上可查看原因。 + +## 模板 + +内容可以像文本框一样包含 `{...}` 表达式,例如 `{2 * 21}`。配合 +[可变文本](../features/variable-text.md),`SN-{serial:05d}` +会为每个工件提供各自的序列号,阵列副本会继续计数。 From 795f2323b71627c339d9087fa9f5b9871db26cae Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:31:52 +0200 Subject: [PATCH 19/24] refactor(barcodes): use {counter} from the job counter, not {serial} #554 renamed the template variable to {counter} and the docs page to features/job-counter. The content tooltip, the tests and the Barcodes page in all languages follow. Refs #529 --- .../rayforge-addon-barcodes/barcodes/asset.py | 2 +- .../barcodes/property_provider.py | 2 +- .../tests/test_barcode_asset.py | 12 ++++++------ website/docs/addons/barcodes.md | 6 +++--- .../current/addons/barcodes.md | 6 +++--- .../current/addons/barcodes.md | 6 +++--- .../current/addons/barcodes.md | 4 ++-- .../current/addons/barcodes.md | 4 ++-- .../current/addons/barcodes.md | 6 +++--- .../current/addons/barcodes.md | 4 ++-- .../current/addons/barcodes.md | 4 ++-- 11 files changed, 28 insertions(+), 28 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py index 6fbdfc60c..59a640e29 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py @@ -43,7 +43,7 @@ class BarcodeAsset(IAsset, IGeometryProvider): """ A QR code or barcode whose content is a template. The template is resolved with the parameters the workpiece passes in (for example - the document's serial number), then encoded and drawn as outlines + the document's job counter), then encoded and drawn as outlines plus a solid fill. """ diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py index 82af71994..816cc52b3 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py @@ -162,7 +162,7 @@ def _update_visibility(self, asset: BarcodeAsset) -> None: else: self.content_row.remove_css_class("error") self.content_row.set_tooltip_text( - _("Templates work here too, e.g. SN-{serial:04d}") + _("Templates work here too, e.g. SN-{counter:04d}") ) def _set(self, setting: str, value: Any) -> None: diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py index 7b0e89392..bc4215936 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py @@ -27,7 +27,7 @@ def test_is_an_asset_and_a_geometry_provider(): def test_round_trip(): asset = BarcodeAsset( name="Label", - content="SN-{serial:04d}", + content="SN-{counter:04d}", symbology=Symbology.CODE128, error_correction=QrErrorCorrection.H, module_mm=0.4, @@ -54,13 +54,13 @@ def test_qr_geometry_and_fill(): def test_templates_resolve_with_params_and_are_cached(): asset = BarcodeAsset( - content="SN-{serial:03d}", symbology=Symbology.CODE128 + content="SN-{counter:03d}", symbology=Symbology.CODE128 ) cache: dict = {} - asset.get_geometry({"serial": 7}, resolved_text_cache=cache) - assert asset.resolve({"serial": 7}) == "SN-007" - assert cache[0] == ("SN-{serial:03d}", "SN-007") - assert asset.get_template_sources() == ["SN-{serial:03d}"] + asset.get_geometry({"counter": 7}, resolved_text_cache=cache) + assert asset.resolve({"counter": 7}) == "SN-007" + assert cache[0] == ("SN-{counter:03d}", "SN-007") + assert asset.get_template_sources() == ["SN-{counter:03d}"] def test_invalid_content_gives_a_placeholder_and_an_error(): diff --git a/website/docs/addons/barcodes.md b/website/docs/addons/barcodes.md index 457c5df01..65ea30fb4 100644 --- a/website/docs/addons/barcodes.md +++ b/website/docs/addons/barcodes.md @@ -41,6 +41,6 @@ Leave a clear margin around a code that is not inverted: 4 modules for QR codes, ## Templates -The content can contain `{...}` expressions like a text box, for example `{2 * 21}`. With -[Variable Text](../features/variable-text.md), `SN-{serial:05d}` gives every piece its own serial -number, and array copies count up. +The content can contain `{...}` expressions like a text box, for example `{2 * 21}`. With the +[Job Counter](../features/job-counter.md), `SN-{counter:05d}` gives every piece its own number, +and array copies count up. diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md index 63c7483ce..b78e09305 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -43,6 +43,6 @@ Lass um einen nicht invertierten Code einen freien Rand: 4 Module bei QR-Codes, ## Vorlagen -Der Inhalt kann wie ein Textfeld `{...}`-Ausdrücke enthalten, zum Beispiel `{2 * 21}`. Mit -[Variabler Text](../features/variable-text.md) erhält mit `SN-{serial:05d}` jedes Teil seine eigene -Seriennummer, und Array-Kopien zählen weiter. +Der Inhalt kann wie ein Textfeld `{...}`-Ausdrücke enthalten, zum Beispiel `{2 * 21}`. Mit dem +[Auftragszähler](../features/job-counter.md) erhält mit `SN-{counter:05d}` jedes Teil seine eigene +Nummer, und Array-Kopien zählen weiter. diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md index 62ab9c32d..0d094c116 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -44,6 +44,6 @@ Deja un margen libre alrededor de un código no invertido: 4 módulos para códi ## Plantillas -El contenido puede contener expresiones `{...}` como un cuadro de texto, por ejemplo `{2 * 21}`. Con -[Texto variable](../features/variable-text.md), `SN-{serial:05d}` da a cada pieza su propio número -de serie, y las copias de una matriz siguen contando. +El contenido puede contener expresiones `{...}` como un cuadro de texto, por ejemplo `{2 * 21}`. Con el +[Contador de trabajos](../features/job-counter.md), `SN-{counter:05d}` da a cada pieza su propio +número, y las copias de una matriz siguen contando. diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md index e40b62909..cd29fa772 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -46,5 +46,5 @@ Laissez une marge libre autour d'un code non inversé : 4 modules pour les QR co ## Modèles Le contenu peut contenir des expressions `{...}` comme une zone de texte, par exemple `{2 * 21}`. -Avec [Texte variable](../features/variable-text.md), `SN-{serial:05d}` donne à chaque pièce son -propre numéro de série, et les copies d'un réseau continuent de compter. +Avec le [Compteur de tâches](../features/job-counter.md), `SN-{counter:05d}` donne à chaque pièce +son propre numéro, et les copies d'un réseau continuent de compter. diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md index 89e79fcb6..fd229b0a5 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -45,5 +45,5 @@ description: ## टेम्पलेट सामग्री में टेक्स्ट बॉक्स की तरह `{...}` एक्सप्रेशन हो सकते हैं, उदाहरण के लिए `{2 * 21}`। -[वेरिएबल टेक्स्ट](../features/variable-text.md) के साथ, `SN-{serial:05d}` हर टुकड़े को उसका अपना -सीरियल नंबर देता है, और ऐरे की प्रतियाँ गिनती आगे बढ़ाती हैं। +[जॉब काउंटर](../features/job-counter.md) के साथ, `SN-{counter:05d}` हर टुकड़े को उसका अपना +नंबर देता है, और ऐरे की प्रतियाँ गिनती आगे बढ़ाती हैं। diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md index 000e8190c..dbb8fa66a 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -44,6 +44,6 @@ Deixe uma margem livre em volta de um código não invertido: 4 módulos para c ## Modelos -O conteúdo pode conter expressões `{...}` como uma caixa de texto, por exemplo `{2 * 21}`. Com -[Texto variável](../features/variable-text.md), `SN-{serial:05d}` dá a cada peça seu próprio número -de série, e as cópias de uma matriz continuam a contagem. +O conteúdo pode conter expressões `{...}` como uma caixa de texto, por exemplo `{2 * 21}`. Com o +[Contador de trabalhos](../features/job-counter.md), `SN-{counter:05d}` dá a cada peça seu próprio +número, e as cópias de uma matriz continuam a contagem. diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md index 97b7f2a88..d4975d503 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -42,6 +42,6 @@ EAN-13. ## Шаблони -Вміст може містити вирази `{...}`, як текстовий блок, наприклад `{2 * 21}`. Зі -[Змінним текстом](../features/variable-text.md) `SN-{serial:05d}` дає кожній деталі власний серійний +Вміст може містити вирази `{...}`, як текстовий блок, наприклад `{2 * 21}`. З +[Лічильником завдань](../features/job-counter.md) `SN-{counter:05d}` дає кожній деталі власний номер, а копії масиву продовжують відлік. diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md index 37b524524..a55322ba9 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/addons/barcodes.md @@ -33,5 +33,5 @@ description: ## 模板 内容可以像文本框一样包含 `{...}` 表达式,例如 `{2 * 21}`。配合 -[可变文本](../features/variable-text.md),`SN-{serial:05d}` -会为每个工件提供各自的序列号,阵列副本会继续计数。 +[任务计数器](../features/job-counter.md),`SN-{counter:05d}` +会为每个工件提供各自的编号,阵列副本会继续计数。 From e2b6990d2bd3c31b0ef853246ae7d3f48022ce7e Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 17:31:53 +0200 Subject: [PATCH 20/24] docs: move the barcodes changelog entry to UNRELEASED After the merge with main it sat in the released 1.13.0-beta1 section. Refs #529 --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c89cec2ab..b49221526 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to Rayforge will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## UNRELEASED + +### Added + +- Barcodes addon: QR codes, Code 128 and EAN-13 as vector workpieces + whose content can be a template (Object > Add Barcode) (#529) + ## 1.13.0-beta4 ### Changed @@ -203,8 +210,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 its layer; generated content like the Material Test Grid restricts its step to the workpiece it creates, and that selection is now visible instead of hidden -- Barcodes addon: QR codes, Code 128 and EAN-13 as vector workpieces - whose content can be a template (Object > Add Barcode) (#529) ### Changed From 1cfe497e60e1ae3c198d73df7db728d1c9c6aee9 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:03:57 +0200 Subject: [PATCH 21/24] test: give the sketcher's other-provider fake a reference point Main's anchor modes ask every geometry provider for one; the fake from the is_sketch_workpiece tests failed after the merge. Refs #529 --- .../tests/core/test_sketch_workpieces.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py index e0fae2840..e812c20bf 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py @@ -25,6 +25,9 @@ def get_geometry(self, params=None, *, resolved_text_cache=None): geometry.close_path() return geometry, [] + def get_reference_point(self): + return None + def to_dict(self) -> dict[str, Any]: return {"uid": self.uid, "type": "other"} From 21f61c92a96490c3b69e6e51ab92c89a71873f34 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:03:57 +0200 Subject: [PATCH 22/24] fix(barcodes): register the property provider before the main window The properties panel creates its providers while the main window is built. main_window_ready runs after that, so the Barcode group never showed up in the app. Register on import, like the sketcher does. Refs #529 --- .../rayforge-addon-barcodes/barcodes/frontend.py | 10 +++++----- .../tests/ui_gtk/test_barcode_property_provider.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py index 6bf57ba38..7edf60d44 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py @@ -1,6 +1,10 @@ """ Frontend entry point for the barcodes addon: the Add Barcode action in the Object menu and the Barcode group in the properties panel. + +The property provider is registered on import, like the sketcher's: the +properties panel creates its providers when the main window is built, +before main_window_ready runs. """ from gettext import gettext as _ @@ -23,6 +27,7 @@ _ICONS_DIR = Path(__file__).parent / "resources" / "icons" register_icon_path(_ICONS_DIR) +property_provider_registry.register(BarcodePropertyProvider, ADDON_NAME) @hookimpl @@ -43,8 +48,3 @@ def on_activate(action, param): label=_("Add Barcode"), menu=MenuPlacement(menu_id="object", priority=60), ) - - -@hookimpl -def main_window_ready(main_window): - property_provider_registry.register(BarcodePropertyProvider, ADDON_NAME) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py index 9dd1631d8..e88063352 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py @@ -6,6 +6,7 @@ from types import SimpleNamespace +import barcodes.frontend # noqa: F401 import pytest from barcodes.commands import add_barcode, barcode_of from barcodes.encoders import Symbology @@ -13,6 +14,9 @@ from rayforge.core.doc import Doc from rayforge.core.workpiece import WorkPiece +from rayforge.ui_gtk.doceditor.property_providers import ( + property_provider_registry, +) @pytest.fixture @@ -84,3 +88,11 @@ def test_invalid_content_is_flagged(editor, provider): assert asset is not None assert asset.error is not None assert provider.content_row.has_css_class("error") + + +@pytest.mark.ui +def test_provider_is_registered_before_the_main_window(): + assert any( + isinstance(p, BarcodePropertyProvider) + for p in property_provider_registry.create_instances() + ) From 124163993262124b8822dee993e8e577475313f7 Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:20:40 +0200 Subject: [PATCH 23/24] test(barcodes): use the frontend module in the registration test pyflakes ignores noqa and flagged the side-effect import. Refs #529 --- .../tests/ui_gtk/test_barcode_property_provider.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py index e88063352..2340f5586 100644 --- a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py @@ -6,8 +6,8 @@ from types import SimpleNamespace -import barcodes.frontend # noqa: F401 import pytest +from barcodes import frontend from barcodes.commands import add_barcode, barcode_of from barcodes.encoders import Symbology from barcodes.property_provider import SYMBOLOGIES, BarcodePropertyProvider @@ -93,6 +93,6 @@ def test_invalid_content_is_flagged(editor, provider): @pytest.mark.ui def test_provider_is_registered_before_the_main_window(): assert any( - isinstance(p, BarcodePropertyProvider) + isinstance(p, frontend.BarcodePropertyProvider) for p in property_provider_registry.create_instances() ) From c4039760b77f634a8e75138e7c852c328894948a Mon Sep 17 00:00:00 2001 From: Gert Pellin Date: Wed, 21 Oct 2026 18:24:21 +0200 Subject: [PATCH 24/24] test: give the other-provider fake the template methods #554 adds get_template_sources() and variables_used() to the provider protocol; with them the fake keeps passing pyright whichever PR lands first. Refs #529 --- .../tests/core/test_sketch_workpieces.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py index e812c20bf..2d7954bdf 100644 --- a/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py @@ -28,6 +28,12 @@ def get_geometry(self, params=None, *, resolved_text_cache=None): def get_reference_point(self): return None + def get_template_sources(self) -> list[str]: + return [] + + def variables_used(self) -> set[str]: + return set() + def to_dict(self) -> dict[str, Any]: return {"uid": self.uid, "type": "other"}