diff --git a/CHANGELOG.md b/CHANGELOG.md index 754f744ba..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 diff --git a/debian/control b/debian/control index 6a44dfb7d..f592a6a2e 100644 --- a/debian/control +++ b/debian/control @@ -30,7 +30,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 f753c354f..7676af90f 100644 --- a/pixi.lock +++ b/pixi.lock @@ -292,6 +292,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/fa/06/6ab120a209276d8dced861ee11434ae2732ae70f8604df9b26c8ee3120d9/raydriver-0.5.1.tar.gz @@ -526,6 +527,7 @@ environments: - pypi: https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.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/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/fa/01/3591f781b417b382a8487a2356e927acfe858b1043bab0ec47f6805bb109/pymupdf-1.28.2-cp310-abi3-macosx_11_0_arm64.whl @@ -1010,6 +1012,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/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 - pypi: https://files.pythonhosted.org/packages/de/29/48fca82a7ebf7ff7b2e35019cc9537e7f65e4d2aa1215cc5a8792c989251/coverage-7.16.2-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl @@ -1272,6 +1275,7 @@ environments: - 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/ce/6b/d85da8982b41190eafcb9df91dff9d83fc6989e98af4b5be4c8f85510c95/virtualenv-21.14.11-py3-none-any.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/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 - pypi: https://files.pythonhosted.org/packages/df/f3/90993aab504fa2e1f28fcc09aa16b6ea4f00e75a037d9136e737855833e2/pyvips-3.2.0.tar.gz @@ -9663,6 +9667,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 - threadpoolctl==3.7.0 @@ -10640,6 +10645,13 @@ 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_dist: + - importlib-metadata>=3.6.0 ; python_full_version < '3.10' + requires_python: '>=3.5' - pypi: https://files.pythonhosted.org/packages/d7/ba/18b6e682ead424ad24bcc134339ae5d1b931cd9ae260540592a058a91279/pyright-1.1.414-py3-none-any.whl name: pyright version: 1.1.414 diff --git a/pixi.toml b/pixi.toml index 6a388556a..20b13d5f1 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/asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py new file mode 100644 index 000000000..59a640e29 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/asset.py @@ -0,0 +1,211 @@ +""" +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, + barcode_geometry, + loops_to_geometry, +) + +logger = logging.getLogger(__name__) + +RENDERER_NAME = "SketchRenderer" +CACHE_KEY = 0 +PLACEHOLDER_MM = 10.0 +_BLACK = (0.0, 0.0, 0.0, 1.0) + + +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 job counter), 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 + + 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 = barcode_geometry(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/commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py new file mode 100644 index 000000000..c3c3d126e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/commands.py @@ -0,0 +1,91 @@ +""" +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 HistoryManager, ListItemCommand +from rayforge.core.workpiece import WorkPiece +from rayforge.doceditor.asset_cmd import UpdateAssetCommand + +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 in world coordinates.""" + machine = editor.context.machine + if machine is None: + return DEFAULT_POSITION_MM + return machine.panel.work_area_center() + + +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 set_barcode_setting( + editor: _Editor, asset: BarcodeAsset, setting: str, value: Any +) -> None: + """ + 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 + changed = BarcodeAsset.from_dict(asset.to_dict()) + setattr(changed, setting, value) + editor.history_manager.execute( + 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/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/barcodes/frontend.py b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py new file mode 100644 index 000000000..7edf60d44 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/frontend.py @@ -0,0 +1,50 @@ +""" +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 _ +from pathlib import Path + +from gi.repository import Gio + +from rayforge.core.hooks import hookimpl +from rayforge.ui_gtk.action_registry import MenuPlacement +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) +property_provider_registry.register(BarcodePropertyProvider, ADDON_NAME) + + +@hookimpl +def register_actions(action_registry): + action = Gio.SimpleAction.new(ADD_ACTION, None) + + def on_activate(action, param): + 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_registry.register( + action_name=ADD_ACTION, + action=action, + addon_name=ADDON_NAME, + label=_("Add Barcode"), + menu=MenuPlacement(menu_id="object", priority=60), + ) 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..12fa3588e --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/geometry.py @@ -0,0 +1,149 @@ +""" +Turns encoded modules into closed outlines in millimetres (Y up). + +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 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 + +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 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: + 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_geometry( + encoded: Encoded, symbology: Symbology, style: BarcodeStyle +) -> 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] + 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 loops_to_geometry(loops).normalize_winding_orders() 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..816cc52b3 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/property_provider.py @@ -0,0 +1,184 @@ +"""The Barcode group in the properties panel.""" + +from gettext import gettext as _ +from typing import TYPE_CHECKING, Any + +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 LengthSpinRow, 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 = LengthSpinRow( + _("Module Size"), + _("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 = LengthSpinRow( + _("Bar Height"), + _("Height of the bars"), + 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_in_base_units()) + ) + self.bar_height_row.value_changed.connect( + 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()) + ) + 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_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) + 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( + _("Templates work here too, e.g. SN-{counter: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) + 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()) + + 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/barcodes/resources/icons/barcode-symbolic.svg b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/resources/icons/barcode-symbolic.svg new file mode 100644 index 000000000..777eec460 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/barcodes/resources/icons/barcode-symbolic.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + 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..43d0778cf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/rayforge-addon.yaml @@ -0,0 +1,14 @@ +name: barcodes +display_name: "Barcodes" +description: "QR codes and barcodes generated from templates" +api_version: 22 +requires: + - sketcher +author: + name: "Rayforge Team" + email: "noreply@rayforge.org" +provides: + worker: "barcodes.worker" + frontend: "barcodes.frontend" +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_barcode_asset.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py new file mode 100644 index 000000000..bc4215936 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_asset.py @@ -0,0 +1,109 @@ +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-{counter: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-{counter:03d}", symbology=Symbology.CODE128 + ) + cache: dict = {} + 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(): + 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_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/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py new file mode 100644 index 000000000..d056512d8 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_commands.py @@ -0,0 +1,98 @@ +from types import SimpleNamespace + +import pytest +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 + +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") + 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) + 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 + + +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) diff --git a/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_encoders.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_encoders.py new file mode 100644 index 000000000..4092ce5fb --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_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/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py new file mode 100644 index 000000000..e67b7d354 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/test_barcode_geometry.py @@ -0,0 +1,89 @@ +import pytest +from barcodes.encoders import Encoded, Symbology, encode +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)] +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 _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 = _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 = _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 = _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 = _loops(encoded, Symbology.EAN13, BarcodeStyle(module_mm=0.33)) + with_text = _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 = _loops(encoded, Symbology.QR, style) + x0, y0, x1, y1 = _bbox(loops) + assert (x1 - x0, y1 - y0) == pytest.approx((21.0, 21.0)) 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 new file mode 100644 index 000000000..2340f5586 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-barcodes/tests/ui_gtk/test_barcode_property_provider.py @@ -0,0 +1,98 @@ +# flake8: noqa: E402 +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +from types import SimpleNamespace + +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 + +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 +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)) + 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 + + +@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)) + asset = barcode_of(wp) + 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, frontend.BarcodePropertyProvider) + for p in property_provider_registry.create_instances() + ) 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..2b5041125 --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/sketcher/core/workpieces.py @@ -0,0 +1,17 @@ +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) -> TypeGuard[WorkPiece]: + """ + 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..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 @@ -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,10 +43,7 @@ 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 if first_sketch_uid is None: 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..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 @@ -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,7 @@ 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): 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..2d7954bdf --- /dev/null +++ b/rayforge/builtin_addons/rayforge-addon-sketcher/tests/core/test_sketch_workpieces.py @@ -0,0 +1,59 @@ +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 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"} + + +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) diff --git a/requirements.txt b/requirements.txt index e0638ae89..3a53403fb 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 threadpoolctl==3.7.0 diff --git a/scripts/win/win_setup.sh b/scripts/win/win_setup.sh index dc2911faf..c840db474 100644 --- a/scripts/win/win_setup.sh +++ b/scripts/win/win_setup.sh @@ -246,6 +246,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." diff --git a/website/docs/addons/barcodes.md b/website/docs/addons/barcodes.md new file mode 100644 index 000000000..65ea30fb4 --- /dev/null +++ b/website/docs/addons/barcodes.md @@ -0,0 +1,46 @@ +--- +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. 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 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. + +## 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 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 new file mode 100644 index 000000000..b78e09305 --- /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 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 new file mode 100644 index 000000000..0d094c116 --- /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 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 new file mode 100644 index 000000000..cd29fa772 --- /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 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 new file mode 100644 index 000000000..fd229b0a5 --- /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/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 new file mode 100644 index 000000000..dbb8fa66a --- /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 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 new file mode 100644 index 000000000..d4975d503 --- /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/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 new file mode 100644 index 000000000..a55322ba9 --- /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/job-counter.md),`SN-{counter:05d}` +会为每个工件提供各自的编号,阵列副本会继续计数。 diff --git a/website/sidebars.js b/website/sidebars.js index 865677101..49b7aebb7 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -119,6 +119,7 @@ module.exports = { label: 'Addons', items: [ 'addons/ai-workpiece-generator', + 'addons/barcodes', 'addons/deepnest', 'addons/image-editing', 'addons/print-and-cut',