diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..c6151c3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,22 @@ +# Keeps third-party dependencies current. Dependabot only opens pull requests; nothing here builds, publishes or signs anything. +version: 2 +updates: + # NuGet: one grouped pull request per week for minor and patch updates, so the review stays small. + # Major versions are left as separate pull requests on purpose: they can change behaviour and deserve their own look + # (and, for a dependency that ships inside the app, a licence check against THIRD-PARTY-NOTICES.md). + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + groups: + nuget-minor-and-patch: + update-types: + - "minor" + - "patch" + + # GitHub Actions: the workflows pin actions by version tag, so a monthly check is enough to pick up new releases. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d2492..9768e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,11 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [0.4.0] - 2026-11-10 + ### Added +- **Print to any Windows printer.** A DYMO, Brother, TSC, SATO, Epson or office printer that does not understand ZPL can now print your labels through its Windows driver. Each printer has a **print mode**: *Printer language (ZPL)*, as before, or *Windows driver (picture)*, which draws the label at the printer's real resolution and the label's real size. The mode is remembered per printer. The first time you print to a printer with no saved mode, the Print dialog asks once "How does this printer understand labels?" and suggests an answer from the printer's name and driver (ZPL for Zebra-style names and "Generic / Text Only" queues); network printers added in Rotula by address (TCP/IP, port 9100) always get ZPL. Nothing is switched for you: the printer saved in your settings stays on ZPL without a question, and other printers you used before this version are asked about once, with ZPL already selected for Zebra-style names. Before a picture print, Rotula lists what a driver cannot print the same way (raw ZPL kept from an imported file, barcodes only a ZPL printer can draw, recalls of graphics stored on the printer, images too large to draw, UL and CSA marks, a barcode whose bars are narrower than their safe size, a label larger than the area the printer can print on, whose outer edge may be cut off) and asks for one tick for the whole job; a label larger than the paper the driver uses is refused; a field the printer would number, date or fill in itself (`^SN`/`^SF`, `^FC`, `^FE`) cannot be printed this way and is refused. It works for a single label, for a whole data-merge batch (checked once, not per record) and for the print station. Because every copy is a page of its own in the Windows print queue, one job may print at most 100,000 labels (all the ticked records times their copies); more is refused with a plain message that says to print in smaller batches, and no tick lets it through. A label that does not fit the driver's paper but fits it turned a quarter turn (sideways stock) is turned clockwise for you, pixel for pixel so barcodes stay exact, and Rotula says so before printing (one tick); when neither way fits you get the usual paper error. The preview says it shows the printed picture. Printer-side counters, cutter, tear-off and status checks stay ZPL-only; the MCP server and the command line print ZPL only, and ZPL output does not change. No label printer to hand? Choose "Microsoft Print to PDF" and the Windows driver mode to try it. See [docs/printing.md](docs/printing.md). +- **Set up print station** (File > Set up print station...): checks every label of a print station with the printer it uses and, for a printer in Windows driver mode, shows the points a driver cannot print the same way. When the label is fine as it is, you tick one box and save, and operators can print it. The acceptance is kept in `station.acknowledged.json` next to `station.json` (which is never changed). If the points found for a label change after you accepted them, operators are told to ask you to check it again. See [docs/print-station.md](docs/print-station.md). - **Print engine for scripts and warehouse systems** (`rotula.exe`): print `.rotula` labels with no window. `rotula print` takes a label, field values (`--set`) or records from a CSV, Excel or JSON file, and a Windows printer or network address, and prints through the designer's own print path: resolution conversion, darkness and speed, variables, questions (`--answer`, otherwise the designer default; never a sample value), copies, and serial numbers used once. Before every job it checks the printer status (`~HS`) and holds the job back on a fault; after the last job it waits until the printer reports it finished. `rotula status` and `rotula printers` report printers, `--json` gives a machine-readable result, and exit codes tell a script what happened. `rotula serve` offers the same as a REST API (`/api/v1/jobs`, printer status and lists, OpenAPI at `/openapi/v1.json`) that can run as a Windows service: off until started, on `127.0.0.1` by default, an API key on every call (only its hash is stored), and only the label folders and printers its settings file lists. One job at a time per printer. Messages in English, Spanish, Portuguese (Brazil) and French (Canada). A new `PrintEngine` policy lets an administrator switch it off. The setup program, the MSI and the portable zip include `rotula.exe` beside the app. See [docs/print-engine.md](docs/print-engine.md). - **Label sheets for office printers** (File > Export > Label sheets (PDF), or Label sheets in the Print dialog): put the label on sheets for a laser or inkjet printer and save them as a PDF at the sheet's real size, each label at its own physical size. Pick a generic template (Letter 30-up, 20-up, 14-up, 10-up, 6-up, 80-up and half sheets; A4 3 x 8, 21-up, 65-up, 16-up, 14-up, 8-up and 2-up) or type your own layout, start at any position of a partly used sheet, and add crop marks or label outlines. With data loaded the ticked records go on the sheets with their copies, and serial numbers are used once, exactly as when printing. The layout is saved with the label; older versions of Rotula open such a file and ignore it. Sending the sheets straight to a Windows printer is not offered: print the PDF at actual size. - **Machine-wide MSI for IT departments**: every release now also has `Rotula-X.Y.Z-win-x64.msi`, signed like the setup program. It installs to Program Files for every user with a Start menu entry and the `.rotula` association, upgrades older versions in place, and is made for silent deployment with Intune, Group Policy or Configuration Manager. The setup program can now also install for all users silently with `/ALLUSERS`. Silent install and uninstall commands, exit codes, Intune detection rules and Group Policy steps are in the new [docs/deployment.md](docs/deployment.md). @@ -19,10 +23,22 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - **Print an object only when you want it**: in the new Printing section of the properties, mark an object "Do not print this object" (a note or guide that never reaches the printer or an export), set "Print only when" a field equals, does not equal, contains, is empty or is not empty a value (so a warning can appear on some labels only), and "Lock position" so nothing moves it by accident. The Objects list and the canvas show small No print, Hidden and Locked badges. - **The Preview window shows a label the way it prints when it is set to print upside down (`^POI`) or mirrored (`^PMY`).** It opens on "As printed", says in one sentence why the picture is turned, and has an "As designed" switch to see it upright. The canvas stays upright for editing and the status bar says "Prints upside down" (or "Prints mirrored"). Imported ZPL with `^POI` / `^PON` and `^PMY` sets the label's setting, so the preview follows it. PNG, JPEG and PDF exports stay as designed. - **Portuguese (Brazil) and French (Canada)**: the whole interface is now also available in Brazilian Portuguese and Canadian French (View > Language, where each language is listed in its own name), and the installer offers both. On first run Rotula follows Windows: a Portuguese desktop gets Portuguese (Brazil), a French one French (Canada), Spanish stays Spanish and anything else English. Numbers and dates follow the chosen language. A language you picked before stays as it was. The "Language of Rotula" policy (Group Policy / Intune) also accepts `pt-BR` and `fr-CA`. +- **The ZPL panel is editable.** Type or paste ZPL under the canvas and, after a short pause, the label is rebuilt from it through the same importer as File > Import ZPL; every edit is one undo step (Ctrl+Z works from inside the panel). A badge says whether the text is *Generated* from the design or *Edited by hand - re-imported*; "Revert to design" regenerates it. Commands the designer does not model are never dropped: the status line says how many were kept as raw ZPL and names the first ones (hover for every notice). Element names, data bindings, print conditions, locks, text fit and "do not print" objects survive an edit when the element is still there. A hint line explains the command under the caret: its name, its parameters and what each one means, for every command Rotula supports. +- **Numbered fields (`^FN`) are data fields.** Importing ZPL turns `^FN3"Customer name"` into the placeholder `{{Customer_name}}` (or `{{Field3}}` without a prompt) with the field's data as the sample value, so the field can be merged from a file or database; the number and prompt are written back unchanged. A label can be written as a **stored format** (`ZplGenerator.GenerateStoredFormat`: `^DF` plus one `^FN` per placeholder) and its **recall** (`GenerateFormatRecall`: `^XF` plus the values), with the same numbering both ways. +- **Barcode size you can understand, and checked against the printer.** Under the module width of a barcode (and the module size of QR Code, Data Matrix and Aztec) the Properties panel now says what the setting means on paper: "Module: 9.9 mil (0.25 mm) - symbol 17 mm wide", plus the quiet zone the symbol needs (for example 10X on each side for Code 128, 11X before and 7X after for EAN-13, 4X for QR Code), and a small - / + pair changes the module one dot at a time with the readout following live. While one barcode is selected the design canvas shows its quiet zone as a dotted rectangle. The Problems list now warns, in plain words, when the module is a single dot at 300 dpi or more, when a linear code has bars thinner than 2 dots at 203 dpi, and when the symbol plus its quiet zones does not fit the label width. Nothing about the file format changed. +- **Relative position with an anchor point** (Properties panel, Position): pick one of nine anchor points (corners, edge middles, centre) and what the object is relative to, the label or another object. X and Y then mean the distance between the two anchor points, so an object anchored bottom-right stays in the bottom-right corner when the label is resized, and an object relative to another one follows it when it moves or grows (a longer text on one record moves its right-anchored neighbour on that record only). Choosing an anchor never moves the object: only the numbers change. The canvas marks the anchor point of the selected object, deleting a reference keeps its followers where they are, and a reference that would make two objects relative to each other is refused with an explanation. The printer always receives absolute `^FO`/`^FT` positions. Objects without an anchor behave exactly as before and such labels are saved exactly as before; a label that uses anchors is saved as format version 11, which older versions of Rotula refuse to open. Also new: **Align to label** (Object menu) lines several selected objects up with the label's edges or centre instead of with each other. ### Fixed - **Windows fonts that are named after a style now draw in that style.** "Arial Black", "Segoe UI Semibold", "Segoe UI Black", "Bahnschrift Condensed" and similar names used to be replaced by the default font in the preview, PNG/PDF export and print picture; they now resolve to their parent family at the weight and width the name says. - **Arial Narrow, Franklin Gothic Medium and other families Windows lists only under their own name now draw in that font** instead of the default one: when the font manager does not know the family, Rotula loads the installed font file. +- **Serial numbers are never reissued, and are given back when a job cannot start.** A number that may have reached the printer is never used again. If the counter store cannot be read or saved (full disk, locked file, no permission), the run stops before anything prints, the numbers already taken are given back, and the message says what happened and what to check, instead of an error window. +- **Printing is more careful when something goes wrong.** An unfinished job is cancelled in the Windows print queue instead of being left behind, bytes still on their way to a network printer are counted, and a printer that cannot start the job is reported as a result with a reason. +- **Settings are saved safely.** `settings.json` is written to a temporary file first and then swapped in, so a crash or power cut during the save cannot leave it half written. +- **Very large or hostile input no longer exhausts memory.** Images above 40 million pixels are refused with a placeholder (the same limit in the preview, exports and printing), and imports and data loads are bounded. + +### Security +- **The read-only guard for database sources is stricter.** It now refuses spellings of a query that different SQL readers could understand differently, and refuses PostgreSQL functions that run text or read files on ODBC connections too. A few unusual but valid queries may be refused: for example `SELECT 1AS x` (no space) must be written `SELECT 1 AS x`. +- **The MCP server checks every label it is asked to print.** All the ZPL is scanned for commands that change the printer itself (memory, network, stored settings); such a job needs an explicit acknowledgement, and raw ZPL blocks are shown in full before printing. ## [0.3.5] - 2026-10-12 @@ -143,6 +159,15 @@ First public version. - Releases are signed with Azure Artifact Signing, but Windows SmartScreen can still show a reputation warning for a new, little-downloaded file until downloads build trust (More info, then Run anyway). -[Unreleased]: https://github.com/P4Software/Rotula/compare/v0.1.1...HEAD +[Unreleased]: https://github.com/P4Software/Rotula/compare/v0.4.0...HEAD +[0.4.0]: https://github.com/P4Software/Rotula/compare/v0.3.5...v0.4.0 +[0.3.5]: https://github.com/P4Software/Rotula/compare/v0.3.4...v0.3.5 +[0.3.4]: https://github.com/P4Software/Rotula/compare/v0.3.3...v0.3.4 +[0.3.3]: https://github.com/P4Software/Rotula/compare/v0.3.2...v0.3.3 +[0.3.2]: https://github.com/P4Software/Rotula/compare/v0.3.1...v0.3.2 +[0.3.1]: https://github.com/P4Software/Rotula/compare/v0.3.0...v0.3.1 +[0.3.0]: https://github.com/P4Software/Rotula/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/P4Software/Rotula/compare/v0.1.2...v0.2.0 +[0.1.2]: https://github.com/P4Software/Rotula/compare/v0.1.1...v0.1.2 [0.1.1]: https://github.com/P4Software/Rotula/compare/v0.1.0...v0.1.1 [0.1.0]: https://github.com/P4Software/Rotula/releases/tag/v0.1.0 diff --git a/Directory.Build.props b/Directory.Build.props index 8bf6f8b..428e735 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -8,7 +8,7 @@ P4 Software Rotula - 0.3.5 + 0.4.0 MIT https://github.com/P4Software/Rotula diff --git a/README.es.md b/README.es.md index 26f6e54..96f4511 100644 --- a/README.es.md +++ b/README.es.md @@ -4,7 +4,7 @@ # Rotula -**Diseñador gratuito de códigos de barras y etiquetas para Windows (ZPL)** +**Diseñador gratuito de códigos de barras y etiquetas para Windows (ZPL y cualquier impresora de Windows)** Diséñala, previsualízala, combina tus datos e imprímela. Sin cuenta, sin nube, sin telemetría. @@ -41,6 +41,7 @@ Diséñala, previsualízala, combina tus datos e imprímela. Sin cuenta, sin nub - [Etiquetas y rollos de la biblioteca](#etiquetas-y-rollos-de-la-biblioteca) - [Fuentes de bases de datos](#fuentes-de-bases-de-datos) - [Impresión](#impresión) +- [Impresión en cualquier impresora de Windows](#impresión-en-cualquier-impresora-de-windows) - [Puesto de impresión](#puesto-de-impresión) - [Servidor MCP](#servidor-mcp) - [Códigos de barras compatibles](#códigos-de-barras-compatibles) @@ -158,10 +159,13 @@ dentro de la etiqueta. ### Lee exactamente lo que recibe la impresora -El panel ZPL muestra el código generado, en vivo y de solo lectura, con un botón Copiar ZPL y un botón Exportar .zpl. La -salida es determinista y en ASCII puro, así que puedes versionarla, compararla o enviarla con cualquier otra herramienta. +El panel ZPL muestra el código generado en vivo, con un botón Copiar ZPL y un botón Exportar .zpl. La salida es determinista y +en ASCII puro, así que puedes versionarla, compararla o enviarla con cualquier otra herramienta. El panel también es un editor: +escribe o pega ZPL y, tras una breve pausa, la etiqueta se reconstruye a partir de él (un paso de deshacer por edición); una +insignia indica si el texto está generado o editado a mano, "Volver al diseño" lo regenera, la línea de estado nombra cualquier +comando que se conservó como ZPL sin procesar, y una línea de ayuda explica el comando bajo el cursor y sus parámetros. -

El panel de código ZPL de solo lectura bajo el lienzo, con el ZPL generado

+

El panel de código ZPL bajo el lienzo, con el ZPL generado

### Todo de un vistazo @@ -177,7 +181,7 @@ salida es determinista y en ASCII puro, así que puedes versionarla, compararla | **Bases de datos** | SQL Server, PostgreSQL, MySQL/MariaDB, SQLite y ODBC, más ajustes predefinidos para servicios en la nube comunes ([detalles](#fuentes-de-bases-de-datos)) | | **Cobertura ZPL** | 222 comandos ZPL revisados uno por uno en una [página de cobertura generada](docs/zpl-coverage.md) (en inglés) | | **Servidor MCP** | Un programa aparte que permite a los clientes compatibles con MCP diseñar y revisar etiquetas ([detalles](#servidor-mcp)) | -| **Impresión** | Cola de Windows (RAW, incluido USB) y puerto TCP 9100, detección de DPI y papel, perfiles de impresora, etiqueta de prueba, comprobación del estado de la impresora antes y después de cada trabajo | +| **Impresión** | Cola de Windows (RAW, incluido USB) y puerto TCP 9100, detección de DPI y papel, perfiles de impresora, etiqueta de prueba, comprobación del estado de la impresora antes y después de cada trabajo. Cualquier otra impresora de Windows mediante su controlador, como imagen ([detalles](#impresión-en-cualquier-impresora-de-windows)) | | **Puesto de impresión** | Una pantalla bloqueada, solo para imprimir, pensada para el almacén: elige o escanea una etiqueta, responde a sus preguntas con un lector de códigos, imprime y lee la respuesta de la impresora en letra grande y palabras sencillas ([detalles](#puesto-de-impresión)) | | **Resolución** | Convierte una etiqueta entre 203, 300 y 600 dpi conservando su tamaño físico | | **Idiomas** | Español, inglés, portugués (Brasil) y francés (Canadá), con cambio en vivo | @@ -240,7 +244,7 @@ trata de en qué se diferencia Rotula, no de quién es mejor. | **Cuenta o activación** | Ninguna | Depende del producto y la edición | | **Uso de la red** | Ninguno, salvo imprimir a la dirección que indicas | Depende del producto | | **Código fuente** | Abierto, en GitHub | Propietario | -| **Salida a la impresora** | ZPL (impresoras compatibles con ZPL II) | Normalmente muchos lenguajes y controladores de impresora | +| **Salida a la impresora** | ZPL (impresoras compatibles con ZPL II), o una imagen mediante el controlador de cualquier impresora de Windows | Normalmente muchos lenguajes y controladores de impresora | | **Fuentes de datos** | CSV, Excel (`.xlsx`) y consultas de solo lectura a bases de datos | Normalmente también bases de datos, ERP y otras integraciones | | **Medios de etiqueta** | Biblioteca integrada con búsqueda por número de parte | Depende del producto; algunos traen sus propias bibliotecas | | **Automatización** | Servidor MCP para clientes compatibles con MCP | Depende del producto | @@ -374,7 +378,8 @@ Guía para desarrolladores: [docs/data-providers.md](docs/data-providers.md) (en ## Impresión -Rotula envía el ZPL como datos RAW, así que el controlador lo transmite sin tocarlo. +Para una impresora ZPL, Rotula envía el ZPL como datos RAW, así que el controlador lo transmite sin tocarlo. Una impresora que +no entiende ZPL también se puede usar: consulta [Impresión en cualquier impresora de Windows](#impresión-en-cualquier-impresora-de-windows). | Destino | Úsalo para | |---|---| @@ -418,6 +423,35 @@ a lo ancho y a lo alto, distancia entre etiquetas). --- +## Impresión en cualquier impresora de Windows + +Rotula no se limita a las impresoras Zebra y ZPL. Una DYMO, Brother, TSC, SATO, Epson o incluso una impresora láser de oficina +puede imprimir tus etiquetas mediante su controlador de Windows. + +- **Dos modos de impresión.** *Lenguaje de la impresora (ZPL)* envía ZPL, igual que siempre. *Controlador de Windows (imagen)* + dibuja la etiqueta como una imagen, a la resolución real de la impresora y al tamaño real de la etiqueta, y se la entrega al + controlador instalado. +- **Se elige por impresora.** Rotula recuerda el modo de cada impresora de Windows por su nombre. La primera vez que imprimes en + una impresora sin modo guardado, el cuadro Imprimir pregunta una sola vez "¿Cómo entiende las etiquetas esta impresora?" y + sugiere una respuesta según el nombre y el controlador de la impresora (ZPL para nombres tipo Zebra y colas "Generic / Text + Only"; controlador de Windows para todo lo demás). Decides tú. Las impresoras de red que agregas en Rotula por su dirección + (TCP/IP, puerto 9100) siempre reciben el lenguaje de la impresora y nunca se pregunta por ellas. +- **No se cambia nada por ti.** Al actualizar Rotula, la impresora guardada en tu configuración (la última que elegiste) + sigue en ZPL sin preguntar. Las demás impresoras que usabas antes de esta versión se preguntan una sola vez, la primera vez que + imprimes en ellas, con ZPL ya seleccionado para los nombres tipo Zebra. +- **Se revisa antes de imprimir.** Antes de enviar nada, Rotula compara la etiqueta con lo que informa el controlador y lista los + problemas en el cuadro Imprimir: partes que un controlador no puede dibujar, un código de barras con barras más angostas que su + tamaño seguro, una etiqueta que no cabe en el papel y similares. Lo que dejaría la etiqueta mal impresa detiene la impresión. + Para los demás avisos marcas una sola casilla, "Lo entiendo y quiero imprimir de todos modos", para todo el trabajo. Sin la + marca no se envía nada. +- **Una etiqueta, un lote y el puesto de impresión.** La misma revisión y la misma casilla valen para una etiqueta, para un lote + completo de combinación de datos (se revisa una vez el diseño, no cada registro) y para el [puesto de impresión](#puesto-de-impresión), + donde quien lo configura acepta los avisos una sola vez con **Archivo > Configurar puesto de impresión...**. +- **¿Sin impresora de etiquetas a mano?** Elige "Microsoft Print to PDF", pon el modo en Controlador de Windows (imagen), + imprime y abre el PDF. Los detalles están en [docs/printing.md](docs/printing.md) (en inglés). + +--- + ## Puesto de impresión Una pantalla solo para imprimir, para operarios, que se abre con `Rotula.exe --station ` (o **Archivo > Abrir puesto de @@ -512,7 +546,7 @@ administrador puede activarla o desactivarla para todos ([instalación en empres - Windows 10 versión 1809 o posterior, o Windows 11, de 64 bits - No hace falta instalar .NET por separado: las versiones publicadas son autocontenidas -- Para imprimir: una impresora compatible con ZPL, instalada en Windows (USB o red) o accesible por el puerto TCP 9100 +- Para imprimir: una impresora compatible con ZPL, instalada en Windows (USB o red) o accesible por el puerto TCP 9100; o cualquier otra impresora instalada en Windows --- @@ -605,7 +639,15 @@ Léelas antes de depender de Rotula para etiquetas de producción, e **imprime s - **Los comandos no compatibles** (consulta [la página de cobertura](docs/zpl-coverage.md)) se conservan como ZPL sin procesar cuando es posible, pero no se interpretan ni se previsualizan. - Rotula se prueba con decodificadores y archivos de referencia, no con todos los modelos de impresora. -- Solo salida ZPL, y solo Windows. +- **Impresión mediante un controlador de Windows.** Una etiqueta impresa mediante un controlador de Windows se imprime al tamaño correcto y pasa la comprobación del tamaño del código de barras; el escalado y los márgenes propios del controlador quedan fuera del control de Rotula. Los comandos de impresora conservados de un archivo importado (ZPL sin procesar) no se pueden dibujar en este modo. Los contadores de la impresora, el cortador, el tear-off y las comprobaciones de estado solo funcionan con ZPL. MCP y la línea de comandos imprimen solo ZPL. + - Las partes que el controlador no puede dibujar se dejan fuera de la imagen y se listan (contadas) antes de imprimir: el ZPL sin procesar conservado de un archivo importado, un código de barras que solo una impresora ZPL puede dibujar, la llamada a un gráfico guardado en la impresora, una imagen demasiado grande para dibujarla y las marcas de certificación UL y CSA. + - Un campo que la impresora numeraría, fecharía o completaría con datos de otros campos (`^SN`/`^SF`, `^FC`, `^FE`) no se puede usar en este modo y la impresión se rechaza. Usa en su lugar los contadores y las variables de fecha de Rotula, que se calculan en tu PC y se imprimen correctamente. + - Las imágenes de mapa de bits (logotipos) se escalan de forma suave a la resolución de la impresora, no punto por punto, así que los bordes de un logotipo en blanco y negro pueden salir un poco grises o tramados. Imprime una etiqueta de prueba con tu logotipo. + - Muchos controladores de etiquetas no pueden imprimir el último milímetro, más o menos, del borde de la etiqueta. Rotula avisa una sola vez antes de imprimir cuando la etiqueta es más grande que el área que el controlador puede imprimir (toda etiqueta de tamaño completo en ese controlador) e indica el ancho de ese borde; lo que quede en esa franja puede salir cortado. + - Las etiquetas que avanzan al revés de como se diseñaron no se giran por ti. Una etiqueta diseñada de 89 x 28 mm, impresa en un rollo de 28 mm de ancho que la hace avanzar a lo largo, no se gira automáticamente: recibes un aviso o un error del papel. Diseña la etiqueta tal como sale el material de la impresora. + - El puesto de impresión usa el modo guardado para su impresora en el PC donde se ejecuta. +- Otros lenguajes de impresora (EPL, TSPL, CPCL) no se escriben. Hoy, el modo imagen es la forma de usar esas impresoras. +- Solo Windows. --- diff --git a/README.md b/README.md index c9a60b8..f8649b1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Rotula: free ZPL label designer for Windows -**Free, open-source ZPL label designer and barcode label maker for Zebra and other ZPL printers** +**Free, open-source ZPL label designer and barcode label maker for Zebra and other ZPL printers, and any Windows printer** Design it, preview it, merge your data, print it. No account, no cloud, no telemetry. @@ -41,6 +41,7 @@ Design it, preview it, merge your data, print it. No account, no cloud, no telem - [Label stocks](#label-stocks) - [Database sources](#database-sources) - [Printing](#printing) +- [Printing to any Windows printer](#printing-to-any-windows-printer) - [Print station](#print-station) - [MCP server](#mcp-server) - [Print engine (command line and REST API)](#print-engine-command-line-and-rest-api) @@ -154,10 +155,13 @@ every record for problems and print the whole batch. The data file itself is nev ### Read exactly what the printer receives -The ZPL panel shows the generated code, live, read-only, with a Copy button and an Export .zpl button. The output is -deterministic and plain ASCII, so you can version it, diff it or send it with any other tool. +The ZPL panel shows the generated code live, with a Copy button and an Export .zpl button. The output is deterministic and +plain ASCII, so you can version it, diff it or send it with any other tool. The panel is also an editor: type or paste ZPL and, +after a short pause, the label is rebuilt from it (one undo step per edit); a badge says whether the text is generated or edited +by hand, "Revert to design" regenerates it, the status line names any command that was kept as raw ZPL, and a hint line explains +the command under the caret and its parameters. -

The read-only ZPL code panel under the canvas showing the generated ZPL

+

The ZPL code panel under the canvas showing the generated ZPL

### Everything at a glance @@ -173,7 +177,7 @@ deterministic and plain ASCII, so you can version it, diff it or send it with an | **Database sources** | SQL Server, PostgreSQL, MySQL/MariaDB, SQLite and ODBC, plus presets for common cloud services ([details](#database-sources)) | | **ZPL coverage** | 222 ZPL commands tracked one by one on a [generated coverage page](docs/zpl-coverage.md) | | **MCP server** | A separate program that lets MCP-capable clients design and check labels ([details](#mcp-server)) | -| **Printing** | Windows spooler (RAW, including USB) and TCP port 9100, DPI and paper detection, printer profiles, test label, printer status check before and after a job | +| **Printing** | Windows spooler (RAW, including USB) and TCP port 9100, DPI and paper detection, printer profiles, test label, printer status check before and after a job. Any other Windows printer through its driver, as a picture ([details](#printing-to-any-windows-printer)) | | **Print station** | A locked, print-only screen for the warehouse floor: pick or scan a label, answer its questions with a barcode scanner, print, and read the printer's answer in large plain words ([details](#print-station)) | | **Resolution** | Convert a label between 203, 300 and 600 dpi while keeping its physical size | | **Languages** | English, Spanish, Portuguese (Brazil) and French (Canada), switchable live | @@ -236,7 +240,7 @@ differs, not about who is better. | **Account or activation** | None | Varies by product and edition | | **Network use** | None, except printing to the address you enter | Varies by product | | **Source code** | Open, on GitHub | Proprietary | -| **Printer output** | ZPL (ZPL II compatible printers) | Typically many printer languages and drivers | +| **Printer output** | ZPL (ZPL II compatible printers), or a picture through any Windows printer driver | Typically many printer languages and drivers | | **Data sources** | CSV, Excel (`.xlsx`) and read-only database queries | Typically databases, ERP and other integrations as well | | **Label stocks** | Built-in library with part-number lookup | Varies by product; some ship their own stock libraries | | **Automation** | MCP server for MCP-capable clients | Varies by product | @@ -367,7 +371,8 @@ Developer guide: [docs/data-providers.md](docs/data-providers.md). ## Printing -Rotula sends ZPL as RAW data, so the driver passes it through untouched. +For a ZPL printer, Rotula sends ZPL as RAW data, so the driver passes it through untouched. A printer that does not +understand ZPL can still be used: see [Printing to any Windows printer](#printing-to-any-windows-printer). | Target | Use it for | |---|---| @@ -406,6 +411,32 @@ printer: Letter 30-up (2-5/8 x 1 in), 20-up, 14-up, 10-up shipping (4 x 2 in), 6 --- +## Printing to any Windows printer + +Rotula is not limited to Zebra and ZPL printers. A DYMO, Brother, TSC, SATO, Epson or even an office laser printer can print +your labels through its Windows driver. + +- **Two print modes.** *Printer language (ZPL)* sends ZPL, exactly as before. *Windows driver (picture)* draws the label as a + picture, at the printer's real resolution and the label's real size, and hands it to the installed driver. +- **Chosen per printer.** Rotula remembers the mode for each Windows printer by name. The first time you print to a printer + that has no saved mode, the Print dialog asks once, "How does this printer understand labels?", and suggests an answer from the + printer's name and driver (ZPL for Zebra-style names and "Generic / Text Only" queues; Windows driver for everything else). You + decide. Network printers you add in Rotula by address (TCP/IP, port 9100) always get the printer language and are never asked. +- **Nothing is switched for you.** When you update Rotula, the printer saved in your settings (the one you chose last) stays + on ZPL without a question. Other printers you used before this version are asked about once, the first time you print to them, + with ZPL already selected for Zebra-style names. +- **Checked before printing.** Before anything is sent, Rotula checks the label against what the driver reports and lists the + problems in the Print dialog: parts a driver cannot draw, a barcode whose bars are narrower than its safe size, a label that + does not fit the paper, and similar. A problem that makes the label wrong stops the print. For the others you tick one box, + "I understand and want to print anyway", for the whole job. Without the tick, nothing is sent. +- **Single label, batch and print station.** The same check and the same one tick cover a single label, a whole data-merge + batch (checked once for the design, not once per record) and the [print station](#print-station), where whoever sets the + station up accepts the points once with **File > Set up print station...**. +- **No label printer to hand?** Choose "Microsoft Print to PDF", set the mode to Windows driver (picture), print, and open the + PDF. Details in [docs/printing.md](docs/printing.md). + +--- + ## Print station A print-only screen for operators, started with `Rotula.exe --station ` (or **File > Open print station**). It shows @@ -523,7 +554,7 @@ for calls only when you start it, only on the addresses in its settings file, an - Windows 10 version 1809 or later, or Windows 11, 64-bit - No separate .NET install: the release builds are self-contained -- For printing: a ZPL-compatible printer, installed in Windows (USB or network) or reachable on TCP port 9100 +- For printing: a ZPL-compatible printer, installed in Windows (USB or network) or reachable on TCP port 9100; or any other printer installed in Windows --- @@ -615,7 +646,15 @@ Read these before relying on Rotula for production labels, and **always print a - **Unsupported commands** (see [the coverage page](docs/zpl-coverage.md)) are kept as raw ZPL where possible but are not interpreted or previewed. - Rotula is tested against decoders and golden files, not against every printer model. -- ZPL output only, and Windows only. +- **Printing through a Windows driver.** A label printed through a Windows driver prints at the correct size and passes the barcode size check; the driver's own scaling and margins are outside Rotula's control. Printer commands kept from an imported file (raw ZPL) cannot be drawn in this mode. Printer-side counters, cutter, tear-off and status checks are ZPL-only. MCP and the command line print ZPL only. + - Parts the driver cannot draw are left out of the picture and listed (counted) before printing: raw ZPL kept from an imported file, a barcode only a ZPL printer can draw, a recall of a graphic stored on the printer, an image too large to draw, and UL and CSA certification marks. + - A field that the printer would number, date or fill in from other fields (`^SN`/`^SF`, `^FC`, `^FE`) cannot be used in this mode, and the print is refused. Use Rotula's own counters and date variables instead; they are worked out on your PC and print correctly. + - Bitmap images (logos) are scaled smoothly to the printer's resolution, not dot for dot, so the edges of a black-and-white logo can come out slightly grey or dithered. Print a test label with your logo. + - Many label drivers cannot print the outermost millimetre or so of the label. Rotula warns once before printing when the label is larger than the area the driver can print on (every full-size label on such a driver), and names the width of that edge; whatever is in that strip may be cut off. + - Labels fed the other way round are turned for you, always clockwise. A label designed 89 x 28 mm, printed on a 28 mm wide roll that feeds it lengthwise, is turned a quarter turn to fit and Rotula says so before printing (one tick). If the printout comes out upside down, set the label's print orientation to upside down. When neither way fits you get the usual paper error. + - The print station uses the mode saved for its printer on the PC where it runs. +- Other printer languages (EPL, TSPL, CPCL) are not written. Picture mode is the way to reach those printers today. +- Windows only. --- diff --git a/docs/data-providers.md b/docs/data-providers.md index c8f14d9..d54de18 100644 --- a/docs/data-providers.md +++ b/docs/data-providers.md @@ -96,7 +96,16 @@ For every feature in `SqlDialect.AmbiguityChecks` the guard lexes the text again boundaries differ the query is refused with `Data.Query.Rejected.Ambiguous`. Use it for anything that depends on a server setting or that the vendor documents loosely. A quoted identifier that the dialect reads WITHOUT backslash escapes but that is checked for them (Snowflake `"..."`, MySQL backticks) is refused if it contains a backslash. (Quoted strings are not refused just for containing a backslash: only the readings that move a boundary matter, and -those are exactly what the differential lexing catches; a Windows path in a MySQL string still works.) +those are exactly what the differential lexing catches; a Windows path in a MySQL string still works.) PostgreSQL and DuckDB check +`DoubleBackslash` too, so a backslash in a `"..."` name is refused there (it is how a `U&"..\0065.."` escape would be smuggled). + +Text that no honest query contains and that engines lex differently is refused with `Data.Query.Rejected.UnclearText` before any keyword +check: a NUL byte anywhere; hidden characters (zero-width, soft hyphen, bidi marks, BOM, control characters, surrogates) outside strings, quoted +names and comments; the `U&"..."` / `U&'...'` prefix; and a word that starts with a digit but is not a plain number (`1DROP` is one word in +MySQL and PostgreSQL but `1` then `DROP` in SQL Server, so an unquoted table such as `2024sales` must be written in quotes). Non-ASCII words +are compared in their compatibility form (full-width `DROP` is `DROP`) and split into ASCII and non-ASCII runs so a keyword glued to an +accented letter is still seen. A line comment ends at LF, CR, VT, FF, NEL, U+2028 and U+2029 (the guard may only err by looking at more text), +and MySQL's `--` needs ASCII white space after it (a no-break space does not make a comment). **The ODBC engine hint only adds rules.** With `underlyingEngine` set, the text must still be clean under the four classic lexings and under the engine's, with every backslash reading checked, and the engine's forbidden lists apply on top. Rotula cannot see what is behind a driver and the diff --git a/docs/decisions.md b/docs/decisions.md index 2a9624c..7d65415 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -106,6 +106,25 @@ written as "one field" turns it into an ordinary text element with the same id a Memento command: each edit stores the whole document before/after (documents are small; image pixels are shared). Unlimited. Arrow nudges and typing in one property merge into one step within one second. Drags are gestures: one undo step per drag. +### Editable ZPL panel (`ZplSourceViewModel`) +The panel under the canvas is a plain `TextBox` bound two-way to `ZplSource.Text`; the view only reports the caret. Typing starts a 300 ms +quiet period (`IDebouncer`, a `DispatcherTimer` in the app, fired by hand in tests); when it elapses the text goes through `ZplParser` (the +same importer as File > Import ZPL) and the session is edited once (`Undo.ZplSource`), so every pause is one undo step. Source of truth is an +explicit state: *Generated* (the text mirrors the design), *Pending* (typing) or *Edited* (the design was rebuilt from the text, which stays as +typed). Two guards break the loop between the two directions: while the panel applies its own text the regenerated text is ignored, and while an +edit is pending nothing overwrites what is being typed; any other change of the design (canvas, undo, language) regenerates the text and the +state goes back to *Generated*. "Revert to design" cancels a pending edit and regenerates. The editor's own undo is switched off so Ctrl+Z +reaches the label's undo stack. Syntax colouring was left out on purpose: a highlighting editor package would need its own two-way text +binding, dark-theme styling, accessibility checks and a licence entry for a cosmetic gain. +- **ZPL cannot say everything the design knows**, so `DesignCarryOver` pairs each previous element with the re-imported one of the same kind + (same place and content first, then same content anywhere, then same place) and copies ids, names, print conditions, locks and text fit; a text + or barcode whose content equals the shown value of a `{{field}}` gets the field back; objects marked "do not print" (never written to ZPL) + are put back. The resolution, the label id, the stock and the declared variable definitions are restored too; an empty text changes nothing. +- **Nothing is dropped silently**: the status line counts the commands kept as raw ZPL (`ZplImportWarning.KeptAsRaw`) and names the first + three; the other notices are counted and listed in the tooltip, like the Import ZPL dialog. +- **Command hints** come from `ZplCommandReference` (Core, C# data: the parameters of every supported command and the usual kept-verbatim + ones, in Rotula's own words) merged with the name and summary of `commands.json`; a test fails when a supported command has no signature. + ### Preview `PreviewCache` keeps the `RenderLabel` per document revision and the bitmap per (revision, scale). Selection, handles, grid and guides are drawn by WPF on top, so pointer movement never re-renders the label. A visible notice says the text preview is @@ -569,6 +588,17 @@ which means "used when generated", the safe side; a caller that wants "used when `CounterStoreException`s with message keys, never bare `InvalidOperationException`s. A read-only look (previews, the MCP server) creates no folder and no lock when `counters.json` does not exist. +### Numbered fields are placeholders; stored formats are a generator mode, not a document property +A `^FN` field on import becomes `{{name}}` (the prompt made into a valid name, or `Field` plus the number) with the field's data as the +variable's sample value, so the printed output is unchanged and the field can be merged; the number and prompt stay on the element so the +ZPL written back is byte-identical. Fields that share a number share the variable (they share their data on the printer); a prompt already used by +another number gets a suffix. `^SN` fields are left alone (their data is a start value). `^DF` stays out of the label model: a stored format is +written on request (`GenerateStoredFormat`, `^DF` first, every pure `{{placeholder}}` as `^FN n"prompt"` without data, no `^PQ`) and its recall +(`GenerateFormatRecall`, `^XF`, `^CI`, one `^FN n^FD value` per number, `^PQ`) reuses the same numbering (element numbers first, then the lowest +free numbers in label order, one number per name). A field that mixes fixed text and placeholders, a Windows-font text and a QR code with a +data header cannot be numbered: they are written with their current value and reported (`Format.Field.Mixed`, `Format.Field.Picture`). The +catalogue keeps `^DF` and `^XF` as *Planned*: opening such a file still keeps the commands verbatim with a notice. + ### The printer-clock codes and ^SO parameter order are not confirmed on a printer The `^FC` code table and the `^SO` order (clock set, months, days, years, hours, minutes, seconds) come from the ZPL II Programming Guide as remembered by the author; no excerpt is in the repository and no printer with a clock was used. `docs/variables.md` says so. Confirm on hardware @@ -626,6 +656,20 @@ leaving by accident; it is not a security boundary, because anyone who can edit Keyboard-wedge scanners end a code with Enter or Tab, which `ScanSequencer` turns into "stay", "next field", "focus Print" or "print". Timing heuristics (telling a scan from typing by speed) were left out: they misfire on slow PCs and remote sessions, and the terminator already works for both people and scanners. + +### Picture mode: the findings are accepted at set-up, never by the operator +A station printer in Windows-driver (picture) mode is pre-flighted like the Print dialog, but the operator cannot judge findings, so the person +who sets the station up accepts them once with **File > Set up print station...**, and the fingerprint is stored per label in +`station.acknowledged.json` next to station.json. The station compares it with the fingerprint worked out at print time: equal prints, anything else (never accepted, or +the label was edited since) refuses with one plain sentence, and an error finding never prints. The set-up builds the printer and the document +with the same code as the station (`StationPrinting`), because the fingerprint depends on both and on the driver's media; it must therefore run on +the station PC. The print mode itself comes from the per-printer map in the settings of the account the station runs under, read through a copy +(`PrintSettings.Copy`) so the station never changes the designer's settings; a queue with no saved mode prints ZPL and the operator is never +asked. The acknowledgements are a file of their own rather than a setting in station.json (ruling of 2026-11-09): station.json refuses +unknown settings, so an older Rotula would refuse a station.json that held them, and station.json is written by hand, so Rotula never rewrites +it or its comments. `StationAcknowledgements` writes the file whole through a temporary file, deletes it when nothing is accepted (a ZPL +station never gets one), and reads a missing, unreadable, damaged or newer file as "nothing accepted", so the operator is refused with the +plain message rather than the label printed unchecked. The tool never creates station.json; a bare folder of labels is told to create it first. ## Print orientation preview (Rotula.Rendering) ### ^PO and ^PM are shown "as printed" only in the Preview window @@ -663,3 +707,77 @@ wildcards), printers must be listed, inline label content is off by default, and file and request bodies refuse unknown names, so a typo never widens what is allowed. The audit log carries an 8-character key id from the hash, never the key or label data. `PrintEngine = 0` (policy) switches it all off. Kestrel and ASP.NET Core come with .NET; the only packages added are Microsoft's OpenAPI and Windows service hosting (MIT). + +## Barcode sizing (Rotula.Core/Barcodes/BarcodeSizing) + +### Dots stay the stored unit; mil and mm are derived +The file keeps `moduleWidth` / `moduleSize` in dots (what ^BY and ^BQ take), so the format did not change. `BarcodeSizing` derives the X-dimension in +mils and mm from the dots and the label dpi, and the panel shows it. The module is edited in dots, with a - / + stepper, because that is what the +printer can do: a mil value that is not a whole number of dots would silently be rounded. + +### The symbol width is only claimed where the symbology's rules fix it +Core measures Code 128 (automatic mode, no GS1 parentheses), Code 39/LOGMARS, Code 93, Interleaved 2 of 5, EAN/UPC and QR Code model 2 from their element +counts (wide bar = narrow x ratio rounded to whole dots, as ZPL does). Core cannot call the preview encoders (they live in Rotula.Rendering), so for +the other symbologies the panel passes the encoder's module count (`BarcodeSizeInfo`), and the generator's fit warning simply stays silent: it never +guesses a width. The preview draws wide bars at a fixed 3:1, so the encoder count is used only when the element's ratio is 3.0. + +### Quiet zones follow the standards, not one number +Values are per symbology (Code 128/39/93, I2of5, Codabar 10X; EAN-13 11X/7X; UPC-A 9X/9X; EAN-8 7X/7X; UPC-E 9X/7X; QR 4X; Data Matrix 1X; +PDF417 2X; Aztec none) with the source named next to each in the code. A symbology without an entry gets a conservative family default, which is a +safe margin and not a claim about a standard. The fit check (symbol plus quiet zones against the label width) runs only for upright fields; the canvas +guide handles every rotation because it grows the real frame along the reading axis. All three new findings are warnings, never errors: the label still +prints. Thresholds: a 1-dot module at 300 dpi or more; a linear narrow bar under 2 dots at 203 dpi. + +## Relative position (Rotula.Core/Layout) + +### One anchor point, taken on both the object and its reference; X/Y are the vector between them +An element may carry an `Anchor` (one of nine points: corners, edge middles, centre) and a reference (`RelativeTo`: the label, or another element +by id). The same point is taken on the object's box and on the reference's box, and X/Y are the signed distance from the reference's point to the +object's point (so a bottom-right object 10 dots inside the corner has X = Y = -10). One point rather than two keeps the panel a single 3 x 3 +grid, which is what people know from other designers; the signed offset is the plain reading of "X/Y are an offset" and needs no inward/outward +convention. An element without an anchor is untouched: its X/Y stay the absolute origin and its ZPL is byte-for-byte as before. The label +reference is the full label (margins included, at minus the home), the same box the Align commands use. + +### The ZPL stays absolute; one resolver places things, for every record, and the canvas uses it too +`AnchorResolver` turns anchors into absolute origins; the ZPL generator (so every record of a data merge), the preview adapter and the designer's +bounds calculator all call it, so what the canvas shows is where the printer puts the object. It runs per record because text widths change per +record. Sizes come from `ElementSizeEstimator`: exact for shapes and images, exact for Windows-font text (the rasterizer the Text Fit feature uses, +so the fitted size is honoured), and an **estimate** for printer-font text (`TextFitEstimator`, the same nominal character width behind the +overflow warning) and barcodes (module count of the symbology times the module width, text band as the preview draws it). An anchored +printer-font text can therefore sit a few dots off on paper, as the overflow warning can be a few dots off; the canvas and the ZPL always agree +with each other, and an object relative to a Windows-font text is exact. Core gets no drawing code and the generator gets no new parameter. + +### Circles are refused in the panel and are an error in the file; a missing reference falls back to the label +A reference that would make two objects relative to each other (directly or through others) is refused in the "Relative to" drop-down with a +sentence naming the other object, and nothing is written. A file that carries a circle anyway (hand edited) gets an error per object on the +circle and those objects are left out of the ZPL, like any other invalid field; the preview draws them at their raw numbers. A reference to an +element that no longer exists (a file edited elsewhere, a paste from another label) is a warning and the label stands in, so the object keeps a +place. Deleting a reference in the designer never produces that case: its followers are made relative to the label with the numbers that keep +them exactly where they are, in the same undo step. + +### Choosing an anchor changes the numbers, never the picture; moving a reference moves its followers once +Picking an anchor point or a reference in the panel recomputes X/Y so the object stays put (`OffsetKeepingPosition`). Dragging, nudging and +aligning a selection skip the elements that follow another selected element, because those move with their reference already. Resizing works on +bounds, so an anchored object keeps its anchored edge where the handle rule says. Duplicating or pasting a pair rewires the copy's reference to the +copied reference. The .rotula file stores `anchor`, `relativeTo` and `referenceElementId` (version 11, stamped only when an anchor is used; the +field is `relativeTo` rather than `reference` because a recall element already writes its stored-object name as `reference`). ZPL import never +creates anchors; the MCP server reads and writes X/Y as they are (offsets for an anchored object) and does not expose anchors yet. + +## Printing through the Windows driver (Rotula.Printing/Driver) + +Rulings of 2026-11-09, one per line; the details are in [printing.md](printing.md#printing-through-the-windows-driver-any-printer). +- **Coarser axis**: when the X and Y resolutions differ, the picture is drawn at the coarser one and the device stretches it; a ratio that is not whole is a warning. +- **Physical-page fit**: an accepted label size is judged against the physical page (larger is an error); more than 1 mm larger than the printable area is the MarginBand warning, whatever the design. +- **MarginBand arguments**: label size, printable area and the widest of the four unprintable edges (mm, one decimal), all from the library, so no formatter computes anything. +- **Refused size**: judged against the printable area of the driver's own paper, and always reported as PaperMismatch. +- **1 mm tolerance** for every size comparison (pre-flight, negotiation, probe against print), because drivers round paper sizes; except the DriverUnavailable rule, where a physical page smaller than the printable area is unusable with no tolerance. +- **Parts left out and counted**, never replaced by a stand-in: raw blocks, barcodes only the printer draws, stored-graphic recalls, oversize images, UL/CSA marks. +- **Printer-side fields refused**: `^SN`/`^SF`, `^FC` and `^FE` are an error no acknowledgement passes, because a picture drawn once repeats one value. +- **Render error mark = failed label**: a label the renderer would draw with an error mark fails before its document is sent. +- **128 MB per driver document** of distinct pictures; ending a document early only adds a boundary, which is safe for serial numbers. +- **The driver's page count is the authority for serial numbers**: a page counts once the driver accepted it; without a count, every picture handed over counts. +- **Probe and print agree**: each document is printed against the probed media; another resolution or a changed accepted page is refused before StartDoc. +- **Mode per printer name** in `settings.json`; only a settings file from before driver printing is seeded (its configured printer as ZPL), other printers are asked once. +- **Station acknowledgements** live in `station.acknowledged.json` next to `station.json`, accepted by whoever sets the station up, never by the operator. +- **QR minimum**: a generic QR Code takes the matrix fallback of 0.255 mm (ISO/IEC 18004 states none); 0.375 mm only for GS1 Digital Link content. +- **The fingerprint covers the findings only** (keys and arguments), not the printer, the media or the rest of the design. diff --git a/docs/mcp.md b/docs/mcp.md index ffaa035..ada5dc3 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -171,6 +171,36 @@ the printer, the copies and the final ZPL, so changing the label, the printer or Only the newest code is valid. Copies are capped by `maxCopies`. Copies are applied to a copy of the label (`^PQ`); the open label is not changed. A label with validation errors is refused. +### Commands that change the printer + +A label made from imported ZPL or a downloaded `.rotula` file can carry raw printer commands that do much more than print: +cancel every job (`~JA`), delete stored fonts and graphics (`^ID`, including wildcards such as `^IDE:*.*`), reset the printer +(`^JUF`, `~JR`), change its settings or network configuration, or store objects in its memory. The confirmation code goes back +to the assistant that asked for the print, so it cannot by itself prove that a person looked at such a job. Therefore: + +- The dry run scans **all** of the ZPL that would be sent, not just the part it displays, and lists every such command by name + in `riskyCommands` (name, what it does, how many times, and the first occurrence with its parameters). `requiresAcknowledgement` + is `true` and the message starts the list with a warning. Padding in front of a command or hundreds of raw blocks hide nothing: + the scan reads every character. A graphic stored with `~DG` is only listed when it goes to permanent (flash) memory + (`A:`, `B:`, `E:`); `R:` is routine. Commands that change the command characters (`^CC`, `~CT`, `^CD`) are listed too, because + the text after them cannot be checked. +- The second call must pass `acknowledgedCommands` with the name of **every** listed command (for example + `["~JA", "^ID"]`, case does not matter). Without it, or with a name missing, nothing is sent **and the confirmation code is + used up**, so the assistant has to make a new preview and the list is put in front of the person again. The assistant is told + to set `acknowledgedCommands` only after the person agreed. +- The code is still single use and tied to the fingerprint of the exact ZPL, so a command added after the preview invalidates it. +- `rawZplBlocks` shows each raw block whole, on one line. A block longer than 4,000 characters is cut and says how many characters + are missing; more than 100 blocks are counted in a last entry. The scan does not depend on what is displayed. + +The scanned commands are listed in `PrinterCommandScanner` (in the Rotula.Printing project; job control `~JA ~JB ~JC ~JD ~JL ~JP ~JR ~JX ~PP ~PS ^JJ ^JU`, stored +objects `^ID ~EG ^EG ^TO ~DY ~DB ~DS ~DT ~DU ~DE ~DG(flash) ^DF ^CW`, settings `~WC ~RO ^MP ^MM ^MF ~SD ~TA ^SS ^SZ ^ST ^KP ^KN ^KL`, +network and wireless `^NI ^NS ^ND ^NN ^NP ^NT ^NW ^NB ~NC ~NR ~NT ^WA ^WE ^WI ^WL ^WP ^WR ^WS ~WR`, other state changes +`^CM ^IS ^JI ~JI ^SX ^SC ^ZZ ^XS`, and the command-character changes above). `^CM` remaps the memory letters, so once it appears every +`~DG` is listed whatever its device letter. Line breaks inside a command are ignored by printers, so the scan reads the text with +them removed. A label that is only text, barcodes, graphics and the usual page set-up (`^PW ^LL ^MN ^MT ^MD ^PR ...`) lists none and +needs no acknowledgement. The scan is a safety net, not a ZPL parser: it can list a command that sits inside binary data, but it +does not miss one that is written the normal way (upper or lower case), wherever it sits in the text. + ## Databases Off by default (`allowDatabase`). When on: @@ -195,7 +225,8 @@ Off by default (`allowDatabase`). When on: data file) is escaped so it cannot add ZPL commands. **One exception to be aware of:** ZPL that `import_zpl` (or a saved `.rotula` file made from an import) could not interpret is kept as "raw ZPL" so the label still prints the same, and `print_label` sends those blocks to the printer unchanged, including any printer-control commands they contain. The dry run - lists every raw block (`rawZplBlocks`) so the person confirming can see them; `get_label` shows them as `rawZpl` or + lists every raw block (`rawZplBlocks`) and names every printer-changing command in it (`riskyCommands`), and the job is not sent + unless the second call acknowledges each of them (see "Commands that change the printer"); `get_label` shows them as `rawZpl` or `recall` elements and `delete_element` removes them. Sizes are limited: elements per label, characters per text or barcode, image pixels (decoded size is read from the header first), the output size of an image on the label, workbook size after unzipping, and preview size (large labels are scaled down). diff --git a/docs/print-station.md b/docs/print-station.md index 6f59c2b..1dccaa6 100644 --- a/docs/print-station.md +++ b/docs/print-station.md @@ -53,7 +53,70 @@ again instead of printing a wrong label. | **Not printed** | Something else stopped the job (for example an unreachable printer). The reason is shown below. | The status check uses the same print path as the designer's Print dialog (see [printing.md](printing.md)). It is -on for every station printer unless that printer sets `"checkStatus": false`. +on for every station printer unless that printer sets `"checkStatus": false`. A printer that prints through its +Windows driver (below) is never asked for its status: the question is a ZPL command it would print as text. + +### Picture mode (any Windows printer) + +A station printer can be an ordinary office or label printer that does not understand ZPL. The station then draws +each label as a picture and prints it through the printer's Windows driver, exactly as the designer's Print dialog +does. Which way a printer is used is its **print mode**, saved per Windows printer on this PC (`print.printModes` +in the designer's settings.json). The station reads the modes of the Windows account it runs under: a printer saved +as "Windows driver (picture)" prints pictures at the station, every other printer prints ZPL as before. An operator +is never asked about the mode; a printer with no saved mode prints ZPL. The mode is chosen in the designer's Print +dialog. + +Some labels have parts a Windows driver cannot print the same way (raw ZPL kept from an imported file, a barcode +whose bars come out narrower than its safe size, a label size the driver does not accept, ...). In the Print dialog +the person ticks one box to accept them. At the station they are accepted once, by whoever sets the station up: + +1. In the designer, on the PC where the station runs and signed in as the account it runs under, choose + **File > Set up print station...** and pick the station folder. The check uses that PC's printer drivers, so + it must run there. +2. Rotula checks every label with the printer it uses and shows its print mode. For a label printed through a + Windows driver it lists what the driver cannot print the same way, in the same words as the Print dialog. +3. Read the points. When the label is fine as it is, tick **I checked these points. Let the station print this + label anyway.** and press **Save**. A label with a point that cannot be printed at all has no box: change the + label, or set the printer to printer language (ZPL) if it understands ZPL, then press **Check again**. + +**Check again** reads station.json, every label file and the saved acceptances again, so it checks what is on +disk now: a label you just changed in the designer, a label added to station.json, a print mode chosen in the +Print dialog. A tick you have not saved yet stays when the label's points are still the same. If station.json no +longer opens, the tool says why, shows no results and saves nothing until it is corrected. A label file that no +longer opens, or a printer that does not answer (switched off, disconnected), is shown as a problem on its own +row; whatever was accepted for that label before is kept. + +Saving writes **`station.acknowledged.json`** next to station.json. station.json itself is never changed, so its +comments stay as written and an older Rotula still opens it. The file is small and written by the tool, not by hand: + +```json +{ + "version": 1, + "labels": { + "pallet.rotula": "3f2a9c..." + } +} +``` + +Each key is a label file, relative to `labelFolder` (a label outside that folder keeps its full path), and each +value is the fingerprint of the points that were accepted for it. The file is written whole through a temporary +file, so a station that starts at that moment reads either the old or the new version. Nothing is written when +nothing changed. A label taken out of station.json loses its entry at the next save, and the file is deleted when +no label is accepted any more, so a station that prints ZPL never gets one. A missing, unreadable or damaged file +counts as "nothing accepted". The tool needs the station's station.json: for a folder without one it still checks +the labels, but saves nothing and says to create station.json first. + +One label file offered on two picture printers that find different points can be accepted for one of them only +(the file has one entry per label file). The tool says so when you save; give each printer its own copy of the +label file to print it on both. + +When an operator prints a label whose points were never accepted, or a label that was edited since the set-up (its +points are no longer the ones accepted), nothing is sent and the status says, in plain words, to ask the person who +set up the station to check the label and set the station up again. A label with a point that cannot be printed +at all is never printed. The station reads both files when it starts, so restart it after saving. + +The note under the preview says that the preview shows the printed picture (instead of "approximate") when the +label's printer prints through its Windows driver. ### Printing the label's data @@ -180,6 +243,12 @@ can remove the PIN. ## For developers - `Rotula.Files.Station.StationConfigLoader` reads and checks the configuration; `StationConfig` is the result. +- `StationAcknowledgements` reads and writes `station.acknowledged.json` (never throws on read; writes through a + temporary file; deletes the file when nothing is accepted). The loader fills `StationConfig.AcknowledgedFindings` + from it. +- `StationPrinting` (app) builds a label's printer and the document it prints, shared by the station and + `StationSetupViewModel`, so the set-up checks exactly the target and document the station prints (the fingerprint + depends on both, and on the driver's media). - `StationPin` makes and checks the PIN codes. - `ScanSequencer` holds the scanner rules (which terminator moves where, when a scan prints, which scan opens a label) with no UI, so it is tested on its own. diff --git a/docs/printing.md b/docs/printing.md index b3e8774..5159da0 100644 --- a/docs/printing.md +++ b/docs/printing.md @@ -98,6 +98,164 @@ after printing", stored per transport in `settings.json` (`print.statusCheckNetw `IPrintService.GetStatusAsync(target)` backs the "Check printer status" button of the printer settings and the MCP tool `get_printer_status` (which needs the printing permission because it sends a command to the printer). +## Printing through the Windows driver (any printer) + +A printer that does not understand ZPL (DYMO, Brother, TSC, SATO, Epson, an office laser printer) can still print Rotula +labels. Rotula draws the label as a picture and hands it to the printer's installed Windows driver. Only a Windows queue can +be printed this way; a network printer on TCP port 9100 always gets ZPL. + +### How the mode is chosen and stored + +- There are two print modes: *Printer language (ZPL)* and *Windows driver (picture)* (`PrinterOutputMode.PrinterLanguage` and + `PrinterOutputMode.WindowsDriver`). +- The mode belongs to the **printer**, not to the label. It is saved per Windows queue name in `settings.json`, in the + `printModes` map under `print`, for example `"printModes": { "ZDesigner ZD420": "PrinterLanguage", "DYMO LabelWriter 450": "WindowsDriver" }`. + The names are matched without regard to case, and choosing a mode for one printer never changes another. +- The first time you print to a printer that has no saved mode, the Print dialog asks once, "How does this printer understand + labels?", with the two answers. Nothing is sent until you answer. Rotula pre-selects a suggestion: ZPL when the queue or driver + name contains ZDesigner, Zebra or ZPL, or the driver is "Generic / Text Only"; Windows driver for everything else. The + suggestion is only that. Detection never changes a saved mode. +- A `settings.json` written by an older Rotula has no `printModes` map. The first time it is read, the printer that was + already configured is recorded as ZPL, because it has always printed ZPL, so its user is not asked. This happens once: a map + that exists, even an empty one, is never filled in again. Other printers are asked about when first used. +- The Print dialog shows the mode of the chosen printer and a button to change it. To change a mode by hand, close Rotula and + edit the entry (`"PrinterLanguage"` or `"WindowsDriver"`); a printer that is not listed is asked about again. + +### What the check before printing finds + +In picture mode Rotula asks the driver for its resolution and printable area and checks the whole job **once**, before +anything is sent. A batch of 500 records is checked once for the design. The Print dialog lists what it found, in plain words. +Findings that make the label wrong are **errors** and stop the print. The others are **warnings** and can be accepted. + +| Finding | Kind | In plain words | +|---|---|---| +| Driver not reachable | Error | Rotula could not read the page size and resolution from the driver (printer off, disconnected or not installed) | +| Label bigger than the paper | Error | The label is more than 1 mm wider or taller than the paper the driver will use, so part would be cut off. When the driver accepted the label size, that is the page itself; when it did not, it is the printable area of the driver's own paper | +| Picture too large | Error | At this resolution the picture would have more dots than Rotula can draw | +| Too many labels | Error | The whole job would print more than 100,000 labels (every record times its copies). Every copy is a page of its own in the Windows print queue. Print in smaller batches. Reported alone, without asking the driver | +| Bars one dot wide | Error | A barcode's narrowest bar would be a single printer dot, which scanners cannot read reliably | +| Printer-side field | Error | A text or barcode that the printer would number, date or fill from other fields (`^SN`/`^SF`, `^FC`, `^FE`). A picture cannot do that. Use Rotula's own counters and date variables | +| Parts the driver cannot draw | Warning | Raw ZPL kept from an imported file, a barcode only the printer can draw, a graphic stored on the printer, an image too large to draw, and UL and CSA certification marks. They are left out of the picture and counted | +| Bars under the safe size | Warning | A barcode's narrowest bar is under the minimum for its kind of barcode, so it may not scan. Make the barcode larger or use a higher resolution | +| Label size not accepted | Warning | The driver refused the label size as a paper size, so the label is printed in the middle of the paper it has selected | +| Label in the unprintable edge | Warning | The driver accepted the label size and the label fits the page, but the label is more than 1 mm larger than the area the printer can print on (every full-size label on a driver that keeps a margin; many DYMO and Brother drivers keep about 1.5 mm). Anything in that strip along the edges may be cut off, whatever the design. The message names the label size, the printable area and the widest unprintable edge | +| Resolutions that do not divide | Warning | The printer's horizontal and vertical dots per inch are not a whole ratio, so bars may come out slightly uneven | + +Objects set to "Do not print" are not checked, and an object with a print condition is checked because some record may print it. + +#### The limit on labels in one job + +In picture mode each copy of a label is a page of its own in the Windows print queue (50 pages to a driver document by +default), so 99,999,999 copies would be about two million documents. One picture job therefore prints **at most 100,000 +labels**, the same number the printer-language path allows for separately numbered labels (`PrintVariableOptions.MaxIndividualLabels`, +shown in the library as `DriverPreflight.MaxLabelsPerJob`). What counts is the whole job: for a batch merge, the Print dialog's +ticked records and the print station's data, every record times its copies; for a single print, the copies. A job over the limit +gets the error *Too many labels* (`Driver.Preflight.TooManyLabels`, arguments: the labels asked for and the limit) and says to +print in smaller batches. It is an error, so no tick lets it through, and it is worked out without asking the driver. + +Library callers: `IPrintService.PreflightAsync(target, document, labelCount, ct)` does this check for a job of `labelCount` +labels (the overload without a count is unchanged and cannot know the size of a batch). `PrintResolvedAsync` and `PrintAsync` +repeat it for the labels of the call itself, before the driver is asked and before a serial number is reserved, and fail with +`InvalidLabel` and the same finding as a notice, so a caller that skips the pre-flight cannot flood the queue either. A batch +is sent in jobs of a few records, so the limit of the whole batch is enforced by the pre-flight, which the app always runs. + +#### Sideways stock is turned to fit + +A roll printer's driver often gives a page of one shape whatever is asked: a 100 x 150 mm label on a driver whose paper is 150 x 100 +mm (landscape), or a 150 x 100 mm label on stock the driver only knows as 100 x 150 mm. When the label as designed is larger than +the paper the driver will use, but the same label **turned a quarter turn** fits, Rotula turns it itself instead of refusing: + +- **How it decides.** The driver is asked for its page at the designed size. Only if the label is larger than that page (the error + *Label bigger than the paper*) is the driver asked a second time, for the turned size (width and height swapped). If the turned + label fits that answer (the same rules as ever: within 1 mm, the same unprintable-edge check), it is printed turned. A label that + fits as designed is never turned, and a square label has nothing to turn. If neither way fits, the ordinary refusal stands and + names the label as designed. +- **What is turned.** The finished picture, pixel for pixel (no resampling), so every barcode module keeps its exact size in + device dots. The driver is told the turned size, and the check before the job starts compares the page the driver really uses with + the media of the turned size, so the printed result is the one that was judged. The pre-flight and the print call take the same + decision (the print call asks again and reaches the same answer from the same driver), so they cannot disagree. +- **The direction is clockwise** (the top of the label ends up on the right-hand edge of the paper). No direction is right for every + printer: whether a roll leaves the printer top edge first or bottom edge first depends on the printer and its driver, and Windows + itself turns landscape pages one way on some drivers and the other way on others. Clockwise is the fixed default. If the first + printout comes out upside down, set the label's print orientation to upside down; it is applied to the picture before it is turned. +- **What the person sees.** A warning, *Label turned to fit* (`Driver.Preflight.LabelTurned`; arguments: the label as designed and the + paper it lies on), needing the usual single acknowledgement. A warning rather than a note because the printout differs from the + preview the person designed in and may need the one-time orientation check above; the tick is remembered for exactly these + findings, so it costs one click. The other paper findings after it are the turned label's. +- **Not in printer-language mode.** ZPL printing never turns anything, and the pre-flight is not run for it. + +### The one acknowledgement + +If there are warnings, the Print dialog shows them in a card and a box, "I understand and want to print anyway". Tick it and +press Print again: it covers the whole job, however many labels. Without the tick nothing is sent. The tick is for exactly +these findings: change the label so the findings change and Rotula asks again. If there are errors, there is no box; fix the +label (or choose another printer or mode) and print again. + +### Batch printing and the print station + +- **Batch (Data merge).** The same check runs once for the design, before the question about the batch. Copies and serial + numbers follow the same rules as in ZPL mode: a number is reserved before the job and given back only when nothing could have + reached the printer; a number that may have printed is never reused. If a job fails part way, the result says up to which + record labels may have printed. +- **Print station.** The station prints in picture mode when the mode saved for its printer on that PC is Windows driver. An + operator is never asked about the mode. The findings are accepted once by whoever sets the station up, with **File > Set up + print station...** (see [print-station.md](print-station.md#picture-mode-any-windows-printer)). The acceptance is kept in + `station.acknowledged.json` next to `station.json`. An operator who prints a label whose findings were not accepted gets a + plain message to ask that person; nothing is sent. + +### What stays ZPL-only + +Printer set-up commands (darkness, speed), the printer status check before and after a job, printer-side counters, cutter and +tear-off belong to the printer language. In picture mode they are not sent: use the driver's own settings dialog for those. The +Darkness and Speed boxes of the Print dialog are therefore hidden in picture mode (with the line that says to leave them empty). +Hidden, they are not read, so they can never stop a print, and the values you set stay in `settings.json` and come back when you +switch the printer back to printer language. The resolution, width and height boxes stay. The +library refuses them too: `PrintZplAsync` and `ApplyProfileAsync` answer `Unsupported` for a Windows queue in driver mode, and a +status query (`GetStatusAsync`, `VerifyPrintedAsync`) answers "not supported" without sending `~HS`. The MCP server and the command +line print ZPL only. ZPL output, export and the ZPL panel are the same as before. + +### Known limitations of picture mode + +- **Parts left out.** Raw ZPL kept from an imported file, a barcode only a ZPL printer can draw, a recall of a graphic stored on + the printer (not in the file), an image too large to draw, and UL and CSA certification marks are left out of the picture. They + are counted and listed before printing, never replaced by a stand-in. +- **Bitmap images are scaled smoothly.** A logo is resampled to the printer's resolution with smooth (not black-and-white, + dot-for-dot) sampling, so its edges can come out slightly grey and the driver then dithers them. Check a logo on a test label. +- **The unprintable edge.** Many label drivers keep a strip of about a millimetre or two along the edges that they cannot print. + When the driver accepts the label size, the label still prints, but Rotula warns once when the label is larger than the area the + driver can print on (every full-size label on such a driver), whatever the design, and names the widest edge: anything in that + strip may be cut off. Leave a margin of at least that width around the content. +- **Sideways stock is turned for you, one way.** See [Sideways stock](#sideways-stock-is-turned-to-fit). The direction is fixed + (clockwise); if your printer feeds the label the other way up, set the label's print orientation to upside down. +- **The driver's own scaling and margins** are outside Rotula's control: a driver set to "fit to page" or with its own offsets moves + the label. Just before printing, Rotula compares the resolution and page the driver is about to use with the ones the check was + made for, and refuses the job when they changed (for example when the driver's defaults were changed in between). A label is never + printed on another page than the one the check judged; print again to check the label against the new settings. + +### Trying it without a label printer + +1. Open the Print dialog and choose **Microsoft Print to PDF** as the printer. +2. When the dialog asks how the printer understands labels, choose **Windows driver (picture)** (or change it with the + button under the printer name). +3. Press **Print**, and give the PDF a name. +4. Open the PDF. The page is the label's size and the label is drawn on it. Check the size and look at the barcodes. + +A PDF has almost no unprintable margin and no label gap, so this shows the drawing and the page size, not what a real +printer's driver does with scaling and margins. The automated tests use Microsoft Print to PDF where it is available. + +## Before a release + +Automated tests cannot do these, so do them by hand before publishing a version that touches printing: + +- [ ] Print one label with a barcode through a real non-Zebra label printer (DYMO, Brother, TSC...) in Windows driver mode and scan it. +- [ ] On that printer, print a label with a bitmap logo and a text in a Windows font, and look at the result. +- [ ] On a real printer whose driver keeps an unprintable margin, print a full-size label whose content reaches the edge. Rotula must print it after one warning that the printer cannot print the outer edge of the page (it is not refused); check on paper that the edge it names matches what is cut off. A label larger than the page itself must be refused. +- [ ] Open the Print dialog for a new printer in English and in French: the question "How does this printer understand labels?" and its two answers fit and read well. +- [ ] In the Print dialog in picture mode, with a label that has findings: the findings card reads well, the tick box "I understand and want to print anyway" is easy to see, and without the tick nothing is sent (English and French). +- [ ] Open **File > Set up print station...**: the window opens, lists the labels, shows the points, and Save and Check again work. +- [ ] In the Preview window and the status bar in picture mode, the longer note about what the preview shows fits and is not cut off. +- [ ] Print a label in ZPL mode to a ZPL printer and check that nothing about it changed. + ## Tests Tests never print. The spooler is faked (`IRawPrinter`), status replies are scripted (`IPrinterStatusReader`); TCP printing and the TCP status query are tested against an in-process `TcpListener`. diff --git a/docs/releasing.md b/docs/releasing.md index ad51681..b93d6ad 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -25,7 +25,8 @@ gate and the hygiene check, and keeps an unsigned portable zip as a workflow art portable zip from the signed programs, writes the winget manifests and `SHA256SUMS.txt`. Everything is in `artifacts\` (git-ignored). If you are not logged in, the files are still built but labelled `UNSIGNED` and must not be published. Set `ROTULA_NOPAUSE=1` to skip the final "press Enter" (automation only). -6. Check the output (see "Verifying" below). +6. Check the output (see "Verifying" below). If the release touches printing, also do the hand checks in + [printing.md, "Before a release"](printing.md#before-a-release): they need a real label printer. 7. Publish. Either set `"publishToGitHub": true` in `packaging\installer\build-settings.json` (created next to the script on first run) and build again, or run it by hand: diff --git a/docs/variables.md b/docs/variables.md index 8f06a98..2ef138b 100644 --- a/docs/variables.md +++ b/docs/variables.md @@ -269,6 +269,27 @@ The `.rotula` file stores the definitions. A label that only uses plain text var still open it). A label with a definition, or with a label id, is saved as **format version 7**; an older Rotula says the file is newer instead of silently dropping the definition. Files are untrusted input: lists are capped, text is clipped, unknown kinds open as plain variables. +### Numbered fields in ZPL (`^FN`) and stored formats (`^DF` / `^XF`) + +ZPL has its own idea of a variable: a *numbered field*. `^FN3"Customer name"` marks a field whose data is filled in later, usually when a +format stored on the printer is recalled. Rotula treats numbered fields as placeholders, both ways: + +* **Import.** A field with `^FN` becomes `{{Customer_name}}` (the prompt text made into a name: letters, digits and underscores) or + `{{Field3}}` when there is no prompt. The data the field carried (`^FD`) becomes the variable's sample value, so the label prints exactly as + before and the field can be filled from a data file or a database. Fields that share a number share the variable, as they share their data + on the printer. The number and prompt stay on the element, so exporting the label writes the same `^FN3"Customer name"` again. A field with + a printer-side serial number (`^SN`) is left alone: its data is a start value, not something to fill in. +* **Stored format.** `ZplGenerator.GenerateStoredFormat(label, "R:SHIP.ZPL")` writes the label with `^DF` right after `^XA` and every text + or barcode whose content is exactly one `{{placeholder}}` as a numbered field without data (`^FN1"Customer"`). Elements that already have a + field number keep it; the others get the lowest free numbers in label order, one number per name. The print quantity is left out. +* **Recall.** `ZplGenerator.GenerateFormatRecall(label, "R:SHIP.ZPL", data)` writes `^XF` followed by one `^FN n^FD value` per number (data + values, or the sample values) and the print quantity. The numbering is the one the stored format got, so the two always match + (`ZplGenerationResult.FormatFields` lists it). +* **What cannot be numbered**: a field that mixes fixed text with placeholders (`Order {{Order}}`, the printer replaces the whole data), a text + in a Windows font (printed as a picture) and a QR code (its data starts with a header). They are written with their current value and + reported as `Format.Field.Mixed` / `Format.Field.Picture`. Opening a stored format keeps the `^DF` line as raw ZPL with a notice; the fields + come back as placeholders. + ### Using it from an AI assistant (MCP) `set_variable` takes a `kind` (`text`, `date`, `counter`, `prompt`, `computed`) and a `definition` object. See [mcp.md](mcp.md). @@ -421,6 +442,8 @@ lists them; a test checks that every key has text in both languages. | `Variable.Expr.Cycle` | variable, position (0), chain `a > b > a` | | `Variable.Expr.ResultTooLong` | variable, position, limit | | `Variable.Expr.NotNumber` / `.NotDate` / `.BadFormat` | variable, position, function, value | +| `Format.Path.Invalid` | the stored format name (error) | +| `Format.Field.Mixed` / `Format.Field.Picture` | none; the issue carries the element id (warning) | ### File format version 7 diff --git a/docs/zpl-coverage.md b/docs/zpl-coverage.md index 365ad1d..212f30a 100644 --- a/docs/zpl-coverage.md +++ b/docs/zpl-coverage.md @@ -99,7 +99,7 @@ Nothing is silently dropped. | `^FE` | Field number embedding character | Supported | Yes | Yes | No | Chooses the character that marks references to numbered fields inside the data of a field. The text is written unchanged; the printer expands the references. | | `^FH` | Hexadecimal escapes in field data | Supported | Yes | Yes | Close | Lets field data carry any byte as an underscore and two hex digits, so accents and control characters survive. Written automatically whenever the text needs it. | | `^FM` | Multiple field origins | Passthrough | No | No | Box | Gives the places where each part of a PDF417 or MicroPDF417 symbol that is split over several symbols is printed. A field that uses it is kept whole as raw ZPL. | -| `^FN` | Field number | Supported | Yes | Yes | No | Gives a field a number so its data can be filled in or reused later; an optional prompt can be stored with it. Kept on the field and written back. Filling numbered fields from a stored template is not done by Rotula yet. | +| `^FN` | Field number | Supported | Yes | Yes | No | Gives a field a number so its data can be filled in or reused later; an optional prompt can be stored with it. A numbered field becomes a {{placeholder}} named after the prompt (Customer name gives {{Customer_name}}) or Field plus the number, with the field's data as the sample value, so it can be filled from a data merge. The number and prompt stay on the field and are written back unchanged. Saving the label as a stored format writes every placeholder as a numbered field. | | `^FO` | Field origin | Supported | Yes | Yes | Exact | Starts a field and places its top-left corner at the given position. | | `^FP` | Field direction | Supported | Yes | Yes | No | Prints the characters of a text field top to bottom or right to left, with an optional gap between characters. Text fields only. The preview always draws left to right. | | `^FR` | Reverse a field | Supported | Yes | Yes | Exact | Prints one field as the opposite of what is already under it. | @@ -180,7 +180,7 @@ Nothing is silently dropped. | Command | Name | Status | Generate | Read | Preview | What it does | |---|---|---|:---:|:---:|:---:|---| -| `^DF` | Store format | Planned | No | No | No | Saves the commands that follow as a reusable template in the printer's memory. Templates, with numbered fields filled in at print time, are planned. Today the command is kept verbatim with a warning. | +| `^DF` | Store format | Planned | No | No | No | Saves the commands that follow as a reusable template in the printer's memory. Rotula can write a label as a stored format (ZplGenerator.GenerateStoredFormat): ^DF right after ^XA and every {{placeholder}} as a numbered field without data. Opening such a file keeps the ^DF command verbatim with a notice, and its numbered fields come back as placeholders; the stored-format name itself is not part of the label model yet. | | `~DG` | Download graphic | Supported | Yes | Yes | Exact | Sends a bitmap to the printer's memory under a name, to be printed later with ^XG or ^IM. Kept as a document setting and written before the label. A recall in the same file is drawn in the preview. | | `~DN` | Abort graphic download | Out of scope | No | No | No | Stops a graphic download that is in progress. *Out of scope: A diagnostic or immediate printer action that prints nothing on the label.* | | `~DY` | Download object | Supported | No | Yes | Exact | Sends a file (a bitmap, font or other object) to the printer's memory. Only plain bitmap data (hex or Z64 text, format A, type G or B) is understood; it is written back as ~DG. PNG, TrueType and binary downloads are kept as raw ZPL. | @@ -190,7 +190,7 @@ Nothing is silently dropped. | `^IM` | Image move | Passthrough | No | No | Box | Prints a graphic stored on the printer at its original size. Kept as a stored-object placeholder. When the same file downloads the graphic with ~DG, the preview draws it. | | `^IS` | Image save | Passthrough | No | No | No | Saves the label format as an image in the printer's memory instead of printing it. Kept verbatim with a warning. | | `^TO` | Transfer object | Out of scope | No | No | No | Copies stored objects from one storage device to another. *Out of scope: Manages the printer's own storage; it does not change what a label looks like.* | -| `^XF` | Recall format | Planned | No | No | No | Prints a stored template, filling its numbered fields. Planned together with ^DF. Today the recall is kept as a stored-object placeholder with a warning. | +| `^XF` | Recall format | Planned | No | No | No | Prints a stored template, filling its numbered fields. Rotula can write the recall of a label saved as a stored format (ZplGenerator.GenerateFormatRecall): ^XF followed by one numbered field per placeholder with its value. Opening a file with a recall keeps it as a stored-object placeholder with a notice, because the format lives on the printer; numbered fields after it come back as placeholders. | | `^XG` | Recall graphic | Passthrough | No | No | Box | Prints a graphic stored on the printer, at a chosen magnification. Kept as a stored-object placeholder. When the same file downloads the graphic with ~DG, the preview draws it. | ### Media diff --git a/src/Rotula.App/Controls/DesignCanvas.cs b/src/Rotula.App/Controls/DesignCanvas.cs index aae497e..b00eba2 100644 --- a/src/Rotula.App/Controls/DesignCanvas.cs +++ b/src/Rotula.App/Controls/DesignCanvas.cs @@ -36,6 +36,9 @@ private enum Mode { None, Move, Resize, Rotate, Marquee } private static readonly Brush GridDotBrush = new SolidColorBrush(Color.FromArgb(120, 110, 100, 90)); private static readonly Pen MarginPen = CreatePen(Color.FromArgb(150, 200, 120, 0), 1, new DashStyle(new double[] { 3, 3 }, 0)); + // Subtle blue dots: quieter than the orange selection frame, readable on white paper and on a dark barcode edge. + private static readonly Pen QuietZonePen = CreatePen(Color.FromArgb(230, 40, 120, 190), 1, new DashStyle(new double[] { 2, 3 }, 0)); + private MainViewModel? _vm; private WriteableBitmap? _bitmap; private long _bitmapRevision = -1; @@ -282,23 +285,69 @@ private void DrawSelection(DrawingContext dc) foreach (var e in selected) dc.DrawRectangle(null, pen, ToPixelRect(_vm.Session.Bounds.GetFrame(e))); + // The blank margin a scanner needs around a selected barcode, as a dotted guide; it is drawn only while that one barcode is selected, + // so it never clutters the label or reaches a print or export (this canvas is the only place that draws it). + if (selected is [BarcodeElement single] && BarcodeSizeInfo.QuietZoneFrame(_vm.Session.Bounds.GetFrame(single), single, Doc.DefaultRotation) is { } quiet) + dc.DrawRectangle(null, QuietZonePen, ToPixelRect(quiet)); + var union = RectD.UnionAll(selected.Select(_vm.Session.Bounds.GetFrame)); if (selected.Count > 1) dc.DrawRectangle(null, new Pen(AccentBrush, 1), ToPixelRect(union)); - // No handles on a locked object (the frame alone shows it is selected). - if (selected.Count != 1 || selected[0].Locked) return; - var r = ToPixelRect(union); - foreach (var h in Enum.GetValues()) + // No handles on a locked object (the frame alone shows it is selected); its anchor marker is still drawn below. + if (selected.Count == 1 && !selected[0].Locked) + { + var r = ToPixelRect(union); + // The anchor point of a single anchored object coincides with one of the eight handles (the ninth, the centre, has no handle); that + // handle is left out so the anchor dot drawn afterwards stands in for it, instead of the two fighting for the same pixels. + var anchorPixel = AnchorPixel(selected[0]); + foreach (var h in Enum.GetValues()) + { + var (hx, hy) = HitTesting.HandlePosition(union, h); + var p = ToPixel(hx, hy); + if (anchorPixel is { } a && Math.Abs(a.X - p.X) < 0.5 && Math.Abs(a.Y - p.Y) < 0.5) continue; + dc.DrawRectangle(Brushes.White, new Pen(AccentBrush, 1), new Rect(p.X - HandleSize / 2, p.Y - HandleSize / 2, HandleSize, HandleSize)); + } + // Rotate handle: a circle above the top edge, joined to it by a thin stem. + var top = new Point(r.X + r.Width / 2, r.Y); + var rot = new Point(top.X, top.Y - HitTesting.RotateHandleOffsetPixels); + dc.DrawLine(new Pen(AccentBrush, 1), top, rot); + dc.DrawEllipse(Brushes.White, new Pen(AccentBrush, 1.5), rot, HandleSize / 2 + 1, HandleSize / 2 + 1); + } + + // Last, so the handles never cover it: a white handle square drawn on top of the anchor dot hid it completely. + if (selected.Count == 1) DrawAnchorMarker(dc, selected[0]); + } + + /// The pixel position of the element's anchor point on its selection frame, or null when the element is free. + private Point? AnchorPixel(LabelElement e) + { + if (e.Anchor is not { } anchor) return null; + var frame = _vm!.Session.Bounds.GetFrame(e); + var (ax, ay) = new Rotula.Core.Layout.DotRect(frame.X, frame.Y, frame.Width, frame.Height).PointAt(anchor); + return ToPixel(ax, ay); + } + + private static readonly Pen AnchorLinkPen = CreatePen(Color.FromArgb(170, 0xC8, 0x42, 0x0F), 1, new DashStyle(new double[] { 4, 3 }, 0)); + + /// + /// Marks the anchor point of a selected anchored object: a filled dot on the corner, edge or centre of its frame, and a dashed line to the + /// same point of the object it is relative to (nothing for the label, whose edges speak for themselves). Drawn by the canvas only; it never + /// reaches a print or an export. + /// + private void DrawAnchorMarker(DrawingContext dc, LabelElement e) + { + if (e.Anchor is not { } anchor || AnchorPixel(e) is not { } own) return; + var session = _vm!.Session; + if (e.RelativeTo == Rotula.Core.Model.AnchorReference.Element && session.Document.Elements.FirstOrDefault(x => x.Id == e.ReferenceElementId) is { } reference + && !ReferenceEquals(reference, e)) { - var (hx, hy) = HitTesting.HandlePosition(union, h); - var p = ToPixel(hx, hy); - dc.DrawRectangle(Brushes.White, new Pen(AccentBrush, 1), new Rect(p.X - HandleSize / 2, p.Y - HandleSize / 2, HandleSize, HandleSize)); + var rf = session.Bounds.GetFrame(reference); + var (rx, ry) = new Rotula.Core.Layout.DotRect(rf.X, rf.Y, rf.Width, rf.Height).PointAt(anchor); + var target = ToPixel(rx, ry); + dc.DrawLine(AnchorLinkPen, own, target); + dc.DrawEllipse(Brushes.White, new Pen(AccentBrush, 1.5), target, 4, 4); } - // Rotate handle: a circle above the top edge, joined to it by a thin stem. - var top = new Point(r.X + r.Width / 2, r.Y); - var rot = new Point(top.X, top.Y - HitTesting.RotateHandleOffsetPixels); - dc.DrawLine(new Pen(AccentBrush, 1), top, rot); - dc.DrawEllipse(Brushes.White, new Pen(AccentBrush, 1.5), rot, HandleSize / 2 + 1, HandleSize / 2 + 1); + dc.DrawEllipse(AccentBrush, new Pen(Brushes.White, 1.5), own, 5, 5); } private void DrawGuides(DrawingContext dc, Rect labelRect) @@ -363,7 +412,10 @@ protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) else if (!session.IsSelected(hit)) session.Select(hit); _mode = Mode.Move; // Locked objects stay where they are even when they are part of a dragged selection; with only locked ones there is nothing to drag. - _origins = session.SelectedElements.Where(el => !el.Locked).ToDictionary(el => el.Id, el => (el.X, el.Y)); + // An object positioned relative to another dragged one follows it by itself, so only the independent ones get the distance. + var unlocked = session.SelectedElements.Where(el => !el.Locked).ToList(); + var independent = EditorCommands.IndependentlyMovable(Doc, unlocked); + _origins = unlocked.Where(el => independent.Contains(el.Id)).ToDictionary(el => el.Id, el => (el.X, el.Y)); _gesture = _origins.Count > 0 ? session.BeginGesture() : null; _startBounds = session.Bounds.GetSelectionBounds(session.SelectedElements.Where(el => !el.Locked).ToList()); CaptureMouse(); diff --git a/src/Rotula.App/Editing/DesignCarryOver.cs b/src/Rotula.App/Editing/DesignCarryOver.cs new file mode 100644 index 0000000..11c2026 --- /dev/null +++ b/src/Rotula.App/Editing/DesignCarryOver.cs @@ -0,0 +1,123 @@ +using Rotula.Core.Barcodes; +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Core.Validation; +using Rotula.Core.Zpl; + +namespace Rotula.App.Editing; + +/// What managed to bring across from the previous design. +/// Elements of the re-imported label that were recognised as elements of the previous design (same kind and place). +/// Texts and barcodes whose {{field}} content was put back because the text held the field's value. +/// Objects marked "do not print" that were put back, since ZPL never carries them. +public sealed record CarryOverReport(int Matched, int RestoredBindings, int ReinsertedNotPrintable); + +/// +/// When the label is rebuilt from ZPL the user edited by hand, everything the printer language cannot express would be lost: element +/// names, data bindings (the text shows the record's values, not {{fields}}), print conditions, locks, text fit and the objects +/// marked "do not print". This pairs each element of the previous design with the re-imported element of the same kind at the same +/// place and copies those designer-only settings across, so editing a position or a font in the text does not silently unbind a field. +/// Elements the user removed or moved in the text simply find no partner and come through as parsed. +/// +public static class DesignCarryOver +{ + /// + /// Carries the designer-only settings of onto the elements of (in place) and + /// puts back the "do not print" objects. Nothing the ZPL text states is changed, except that a text equal to a field's shown + /// value becomes the field again. + /// + /// The design before the edit. + /// The label read from the edited text; its elements are modified. + /// The record shown on the canvas when the text was generated (its values are what the text contains), or null for the sample values. + public static CarryOverReport Apply(LabelDocument previous, LabelDocument parsed, IReadOnlyDictionary? data) + { + ArgumentNullException.ThrowIfNull(previous); + ArgumentNullException.ThrowIfNull(parsed); + int matched = 0, restored = 0, reinserted = 0; + var taken = new HashSet(ReferenceEqualityComparer.Instance); + var scratch = new List(); + + foreach (var old in previous.Elements) + { + if (old.NotPrintable) continue; // handled below: it was never in the text + string? shown = ShownContent(previous, old, data, scratch); + // Best partner first: same place and the very content the text held; then the same content anywhere (the user moved it in the + // text); then the same kind at the same place (the user changed its content). Each parsed element is claimed at most once. + var candidates = parsed.Elements.Where(p => !taken.Contains(p) && SameKind(old, p)).ToList(); + var partner = (shown is null ? null : candidates.FirstOrDefault(p => SamePlace(old, p) && Content(p) == shown)) + ?? (shown is null ? null : candidates.FirstOrDefault(p => Content(p) == shown)) + ?? candidates.FirstOrDefault(p => SamePlace(old, p)); + if (partner is null) continue; + taken.Add(partner); + matched++; + + partner.Id = old.Id; + partner.Name = old.Name; + old.CopyObjectSettingsTo(partner); + if (old is TextElement oldText && partner is TextElement newText) + { + if (shown is not null && newText.Text == shown && oldText.GetTemplate() != shown && HasField(oldText.GetTemplate())) + { + newText.Text = oldText.GetTemplate(); + restored++; + } + // Text fit is Rotula metadata the ZPL only carries as computed values. + if (newText.Block is not null && oldText.Block?.Fit is { } fit) newText.Block.Fit = fit.Clone(); + } + else if (old is BarcodeElement oldBarcode && partner is BarcodeElement newBarcode && shown is not null && newBarcode.Data == shown + && oldBarcode.Data != shown && HasField(oldBarcode.Data)) + { + newBarcode.Data = oldBarcode.Data; + newBarcode.QrContentSpec = oldBarcode.QrContentSpec; + restored++; + } + } + + // Objects that never reach the printer cannot be in the text; losing them because the text was edited would be a surprise. + for (int i = 0; i < previous.Elements.Count; i++) + { + var old = previous.Elements[i]; + if (!old.NotPrintable) continue; + parsed.Elements.Insert(Math.Min(i, parsed.Elements.Count), ElementCloner.Clone(old)); + reinserted++; + } + return new CarryOverReport(matched, restored, reinserted); + } + + private static bool HasField(string template) => VariableResolver.FindVariables(template).Count > 0; + + // Data fields and plain texts are both texts in ZPL, so the exact class is not compared for them. + private static bool SameKind(LabelElement a, LabelElement b) => a.GetType() == b.GetType() || (a is TextElement && b is TextElement); + + private static bool SamePlace(LabelElement a, LabelElement b) => a.X == b.X && a.Y == b.Y && a.OriginKind == b.OriginKind; + + /// The content of a text or barcode as it is after parsing; null for elements without data. + private static string? Content(LabelElement e) => e switch + { + TextElement t => t.Text, + BarcodeElement b => b.Data, + _ => null, + }; + + /// + /// What the generated text contained for the element: its template with the fields replaced by the shown record's values (or the + /// sample values), normalised the way the generator writes it. Null for elements without data. + /// + private static string? ShownContent(LabelDocument doc, LabelElement e, IReadOnlyDictionary? data, List scratch) + { + switch (e) + { + case TextElement t: + return VariableResolver.Resolve(t.GetTemplate(), data, doc.Variables, null, scratch); + case BarcodeElement b: + { + string resolved = Rotula.Core.Barcodes.QrContent.QrContentBuilder.ResolveForElement(b, data, doc.Variables, scratch) + ?? VariableResolver.Resolve(b.Data, data, doc.Variables, null, scratch); + // EAN-13 and UPC-A lose a typed check digit on the way out, so the parsed data is the normalised form. + return BarcodeValidator.NormalizeForPrinter(b, resolved); + } + default: + return null; + } + } +} diff --git a/src/Rotula.App/Editing/EditorCommands.cs b/src/Rotula.App/Editing/EditorCommands.cs index b876ce9..32c20e1 100644 --- a/src/Rotula.App/Editing/EditorCommands.cs +++ b/src/Rotula.App/Editing/EditorCommands.cs @@ -1,4 +1,5 @@ using Rotula.Core.Elements; +using Rotula.Core.Layout; using Rotula.Core.Model; namespace Rotula.App.Editing; @@ -96,12 +97,30 @@ public void Add(LabelElement element, Action? alsoChange = null) }, selectionAfter: () => new[] { element.Id }); } - /// Deletes the selected elements. + /// + /// Deletes the selected elements. An object that was positioned relative to a deleted one becomes relative to the label instead, with the + /// numbers that keep it exactly where it is, so deleting a reference never moves anything else. + /// public void DeleteSelected() { var ids = Unlocked(_s.SelectedElements).Select(e => e.Id).ToHashSet(); if (ids.Count == 0) return; - _s.Edit("Undo.Delete", d => d.Elements.RemoveAll(e => ids.Contains(e.Id)), selectionAfter: () => Array.Empty()); + // Worked out before the delete, while the references still exist to measure against. + var rebound = new Dictionary(StringComparer.Ordinal); + foreach (var e in _s.Document.Elements) + if (!ids.Contains(e.Id) && e.Anchor is not null && e.RelativeTo == AnchorReference.Element && e.ReferenceElementId is { } refId && ids.Contains(refId)) + rebound[e.Id] = _s.Bounds.OffsetKeepingPosition(e, e.Anchor, AnchorReference.Label, null); + _s.Edit("Undo.Delete", d => + { + d.Elements.RemoveAll(e => ids.Contains(e.Id)); + foreach (var e in d.Elements) + if (rebound.TryGetValue(e.Id, out var offset)) + { + e.RelativeTo = AnchorReference.Label; + e.ReferenceElementId = null; + (e.X, e.Y) = offset; + } + }, selectionAfter: () => Array.Empty()); } /// Copies the selection with fresh ids, offset a little so the copy is visible, and selects the copies. @@ -109,6 +128,7 @@ public void DuplicateSelected() { var copies = _s.SelectedElements.Select(e => ElementCloner.Clone(e, newId: true)).ToList(); if (copies.Count == 0) return; + RemapReferences(_s.SelectedElements, copies, _s.Document); foreach (var c in copies) { c.X += PasteOffsetDots; @@ -117,6 +137,28 @@ public void DuplicateSelected() _s.Edit("Undo.Duplicate", d => d.Elements.AddRange(copies), selectionAfter: () => copies.Select(c => c.Id).ToList()); } + /// + /// Points the copies' references at the copies: when A and B are copied together and B is relative to A, B' becomes relative to A'. A + /// reference to something that is neither copied nor on this label (a paste from another label) falls back to the label, so the copy + /// still has a place. + /// + private static void RemapReferences(IReadOnlyList originals, IReadOnlyList copies, LabelDocument doc) + { + var map = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < originals.Count && i < copies.Count; i++) map[originals[i].Id] = copies[i].Id; + var present = doc.Elements.Select(e => e.Id).ToHashSet(StringComparer.Ordinal); + foreach (var c in copies) + { + if (c.Anchor is null || c.RelativeTo != AnchorReference.Element || c.ReferenceElementId is not { } refId) continue; + if (map.TryGetValue(refId, out var copied)) c.ReferenceElementId = copied; + else if (!present.Contains(refId)) + { + c.RelativeTo = AnchorReference.Label; + c.ReferenceElementId = null; + } + } + } + /// The clipboard text for the current selection, or null when nothing is selected. public string? CopySelected() { @@ -140,6 +182,7 @@ public bool Paste(string? clipboardText) c.Y += PasteOffsetDots * _pasteCount; fresh.Add(c); } + RemapReferences(items, fresh, _s.Document); _s.Edit("Undo.Paste", d => d.Elements.AddRange(fresh), selectionAfter: () => fresh.Select(c => c.Id).ToList()); return true; } @@ -211,6 +254,17 @@ public void MoveLayer(LabelElement element, int newIndex) // ------------------------------------------------------------------------------- move / nudge / rotate + /// + /// The ids of the elements a move of must shift itself: an element positioned relative to another one of the + /// selection (directly or through others) moves along with it, so adding the distance to it as well would move it twice. + /// + public static HashSet IndependentlyMovable(LabelDocument doc, IReadOnlyList selection) + { + var ids = selection.Select(e => e.Id).ToHashSet(StringComparer.Ordinal); + if (!AnchorResolver.HasAnchors(doc)) return ids; + return selection.Where(e => !AnchorResolver.FollowsAny(doc, e, ids)).Select(e => e.Id).ToHashSet(StringComparer.Ordinal); + } + /// /// Moves the selection. Repeated nudges within a second merge into one undo step. /// @@ -218,7 +272,7 @@ public void Nudge(int dx, int dy) { var sel = Unlocked(_s.SelectedElements); if (sel.Count == 0 || (dx == 0 && dy == 0)) return; - var movable = sel.Select(e => e.Id).ToHashSet(); + var movable = IndependentlyMovable(_s.Document, sel); _s.Edit("Undo.Move", d => { foreach (var e in d.Elements.Where(e => movable.Contains(e.Id))) @@ -264,8 +318,21 @@ public void Align(AlignKind kind) var sel = _s.SelectedElements; if (sel.Count == 0) return; // A locked object still counts for the reference box (it is a fine anchor to line the others up with) but it is never moved. - RectD reference = sel.Count == 1 ? LabelRect(_s.Document) : _s.Bounds.GetSelectionBounds(sel); - var movable = Unlocked(sel).Select(e => e.Id).ToHashSet(); + AlignTo(kind, sel.Count == 1 ? LabelRect(_s.Document) : _s.Bounds.GetSelectionBounds(sel)); + } + + /// Aligns every selected element to the label's edges or centre, however many are selected. + public void AlignToLabel(AlignKind kind) + { + if (_s.SelectedElements.Count == 0) return; + AlignTo(kind, LabelRect(_s.Document)); + } + + private void AlignTo(AlignKind kind, RectD reference) + { + var sel = _s.SelectedElements; + // An element that follows another selected one lands where its reference lands; moving it as well would double the shift. + var movable = IndependentlyMovable(_s.Document, Unlocked(sel)); if (movable.Count == 0) return; _s.Edit("Undo.Align", d => { diff --git a/src/Rotula.App/Editing/ElementBoundsCalculator.cs b/src/Rotula.App/Editing/ElementBoundsCalculator.cs index e58e6ca..ee61985 100644 --- a/src/Rotula.App/Editing/ElementBoundsCalculator.cs +++ b/src/Rotula.App/Editing/ElementBoundsCalculator.cs @@ -1,5 +1,6 @@ using Rotula.Core.Elements; using Rotula.Core.Imaging; +using Rotula.Core.Layout; using Rotula.Core.Text; using Rotula.Core.Model; using Rotula.Rendering; @@ -135,6 +136,52 @@ private string DataKey(IReadOnlyDictionary? data) /// The rotation an element is drawn with (its own, or the label default). public static FieldRotation EffectiveRotation(LabelElement e, LabelDocument doc) => e.Rotation ?? doc.DefaultRotation; + // Measures Windows fonts for the anchor layout; stateless and thread safe, like the one the preview uses. + private static readonly SkiaTextRasterizer AnchorRasterizer = new(); + + /// + /// The anchor layout of the current document: the same resolver, with the same size estimates, that the ZPL generator and the preview use, + /// so the frame the designer draws around an anchored object is where the object prints. Sizes of text and barcodes are cached by their + /// content, so dragging never re-estimates them. + /// + public AnchorLayout GetLayout() + { + var doc = _document(); + var data = DataProvider?.Invoke(); + return AnchorResolver.Resolve(doc, data, AnchorRasterizer, e => + { + // Only text and barcodes are worth caching (the others are read straight from the model). + if (e is not (TextElement or BarcodeElement)) return ElementSizeEstimator.Estimate(doc, e, data, AnchorRasterizer); + string key = DataKey(data) + "" + MeasureKey(e, doc); + if (!_sizeCache.TryGetValue(key, out var size)) + { + size = ElementSizeEstimator.Estimate(doc, e, data, AnchorRasterizer); + if (_sizeCache.Count > 2000) _sizeCache.Clear(); + _sizeCache[key] = size; + } + return size; + }); + } + + private readonly Dictionary _sizeCache = new(); + + /// + /// The absolute origin of the element (the point its ^FO/^FT gets): its own X/Y, or the resolved point when it is anchored. + /// Everything the designer draws or hit-tests starts from this point, never from the raw X/Y of an anchored object. + /// + public (int X, int Y) GetOrigin(LabelElement e) + { + var doc = _document(); + return !AnchorResolver.HasAnchors(doc) ? (e.X, e.Y) : GetLayout().OriginOf(e); + } + + /// + /// The X/Y that keep where it is when its anchor or reference becomes the given values (null anchor = absolute), so + /// choosing an anchor in the panel changes the numbers and not the picture. + /// + public (int X, int Y) OffsetKeepingPosition(LabelElement e, AnchorPoint? anchor, AnchorReference relativeTo, string? referenceElementId) => + AnchorResolver.OffsetKeepingPosition(GetLayout(), e, anchor, relativeTo, referenceElementId); + /// Bounding box of the element in label dots (relative to the label home, like element coordinates). public RectD GetBounds(LabelElement e) { @@ -179,7 +226,7 @@ public RectD GetFrame(LabelElement e) if (!HasTextBoxFrame(e)) return bounds; var block = ((TextElement)e).Block!; // The ink may start right of the origin (centred or right-aligned lines), but the box always starts at the origin. - return new RectD(e.X, bounds.Y, Math.Max(1, block.WidthDots), bounds.Height); + return new RectD(GetOrigin(e).X, bounds.Y, Math.Max(1, block.WidthDots), bounds.Height); } /// True when is a text block's box rather than the element's ink (the frame can then be resized by width). @@ -189,13 +236,15 @@ public bool HasTextBoxFrame(LabelElement e) => /// Bounds of every element, same order as the document. public RectD GetSelectionBounds(IEnumerable elements) => RectD.UnionAll(elements.Select(GetBounds)); - private static RectD FromSize(LabelElement e, LabelDocument doc, int w, int h, FieldRotation rotation) + private RectD FromSize(LabelElement e, LabelDocument doc, int w, int h, FieldRotation rotation) { w = Math.Max(1, w); h = Math.Max(1, h); + // The resolved origin, not the raw X/Y: for an anchored object those are an offset from its reference. + var (ox, oy) = GetOrigin(e); bool swap = rotation is FieldRotation.Rotated90 or FieldRotation.Rotated270; if (e.OriginKind == FieldOriginKind.Origin) - return new RectD(e.X, e.Y, swap ? h : w, swap ? w : h); + return new RectD(ox, oy, swap ? h : w, swap ? w : h); // ^FT: the origin is the bottom-left corner of the unrotated box and the box rotates clockwise around it. var corners = new (double X, double Y)[] { (0, -h), (w, -h), (w, 0), (0, 0) }; @@ -212,19 +261,20 @@ private static RectD FromSize(LabelElement e, LabelDocument doc, int w, int h, F minX = Math.Min(minX, rx); maxX = Math.Max(maxX, rx); minY = Math.Min(minY, ry); maxY = Math.Max(maxY, ry); } - return new RectD(e.X + minX, e.Y + minY, maxX - minX, maxY - minY); + return new RectD(ox + minX, oy + minY, maxX - minX, maxY - minY); } private RectD MeasureInk(LabelElement e, LabelDocument doc) { var data = DataProvider?.Invoke(); + var (ox, oy) = GetOrigin(e); // A record's values change the text width, so they are part of the cache key (empty when no record is shown). string key = DataKey(data) + "" + MeasureKey(e, doc); if (!_cache.TryGetValue(key, out var m)) { bool failed = false; m = Probe(e, doc, data, ref failed); - if (failed && _lastGood.TryGetValue(e.Id, out var known)) return new RectD(e.X + known.OffX, e.Y + known.OffY, known.W, known.H); + if (failed && _lastGood.TryGetValue(e.Id, out var known)) return new RectD(ox + known.OffX, oy + known.OffY, known.W, known.H); if (!failed) { if (_cache.Count > 2000) _cache.Clear(); @@ -232,7 +282,7 @@ private RectD MeasureInk(LabelElement e, LabelDocument doc) _lastGood[e.Id] = m; } } - return new RectD(e.X + m.OffX, e.Y + m.OffY, m.W, m.H); + return new RectD(ox + m.OffX, oy + m.OffY, m.W, m.H); } /// Everything that influences how large a text/barcode is, except its position. @@ -261,6 +311,10 @@ private static string MeasureKey(LabelElement e, LabelDocument doc) var clone = ElementCloner.Clone(e, newId: false); clone.X = ProbePad; clone.Y = ProbePad; + // The probe measures the ink alone; an anchor would move it (and would point at an element the probe label does not have). + clone.Anchor = null; + clone.RelativeTo = AnchorReference.Label; + clone.ReferenceElementId = null; // Reverse printing inverts what is underneath; on a blank probe that would make the whole area ink. clone.ReverseField = false; // An object that is switched off for the shown record still has a size and must stay selectable; the renderer would otherwise skip it and diff --git a/src/Rotula.App/MainWindow.xaml b/src/Rotula.App/MainWindow.xaml index 2620f11..c84c8b9 100644 --- a/src/Rotula.App/MainWindow.xaml +++ b/src/Rotula.App/MainWindow.xaml @@ -177,6 +177,7 @@ + @@ -243,6 +244,15 @@ + + + + + + + + + @@ -648,8 +658,8 @@ - - + @@ -660,15 +670,52 @@ +