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 SoftwareRotula
- 0.3.5
+ 0.4.0MIThttps://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.
-
+
### 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.
-
+
### 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 @@
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
@@ -717,7 +764,7 @@
-
diff --git a/src/Rotula.App/MainWindow.xaml.cs b/src/Rotula.App/MainWindow.xaml.cs
index dd3e746..07363b0 100644
--- a/src/Rotula.App/MainWindow.xaml.cs
+++ b/src/Rotula.App/MainWindow.xaml.cs
@@ -45,6 +45,9 @@ public MainWindow(MainViewModel viewModel)
};
}
+ /// Keeps the view-model's caret position current, so the hint line under the ZPL editor follows the caret.
+ private void ZplEditor_SelectionChanged(object sender, RoutedEventArgs e) => _vm.ZplSource.CaretIndex = ZplEditor.CaretIndex;
+
///
protected override void OnClosing(CancelEventArgs e)
{
diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx
index cc6b2d0..27e3831 100644
--- a/src/Rotula.App/Resources/Strings.en.resx
+++ b/src/Rotula.App/Resources/Strings.en.resx
@@ -887,7 +887,7 @@
Variables
- ZPL code (read-only)
+ ZPL code (editable: the label follows what you type)100 x 150 mm
@@ -2051,7 +2051,7 @@
Kind of the selected object
- ZPL OUTPUT
+ ZPL CODEExport .zpl
@@ -2794,6 +2794,9 @@
This query can be read in more than one way (for example a backslash before a quote), so it was not run. Use two quotes ('') instead of a backslash.
+
+ This query contains something that different databases read in different ways, such as a hidden character, a word that starts with a digit (1AS) or a U&"..." name, so it was not run. Remove it, or write names in quotes and keep a space between words.
+
provider
@@ -3043,6 +3046,36 @@
The printer always prints the text under a LOGMARS barcode, so switching it off has no effect. Turn the text on to match what will print.
+
+ The narrowest bar or module is 1 dot ({1} mil, {2} mm) at {3} dpi. Many scanners cannot read bars this narrow, and a little ink spread closes the gaps. Use a module of 2 dots or more.
+
+
+ The narrowest bar is {0} dot ({1} mil, {2} mm) at {3} dpi. Bars thinner than 2 dots print unevenly on a 203 dpi printer, which makes the code unreliable to scan. Use a module of 2 dots or more.
+
+
+ The barcode and its quiet zones (the blank margin a scanner needs) span {0} mm to {1} mm, but the label is {2} mm wide. A cut-off margin can stop the code from scanning. Move the barcode, make the module narrower or shorten the data.
+
+
+ Module: {0} mil ({1} mm) - symbol {2} mm wide
+
+
+ Module: {0} mil ({1} mm)
+
+
+ Quiet zone: {0} mm on each side ({1}X)
+
+
+ Quiet zone: {0} mm before and {1} mm after the symbol ({2}X and {3}X)
+
+
+ No quiet zone is needed.
+
+
+ Narrower module (1 dot less)
+
+
+ Wider module (1 dot more)
+
Codabar allows only digits and - $ : / . + in the data. The start and stop letters A to D are chosen in the options, not typed in the data.
@@ -6684,4 +6717,397 @@ Hash to add to apiKeyHashes in the settings file: {1}
The server accepts calls from other computers over plain http: API keys and label data cross the network unencrypted. Use https.
+
+ Anchor point
+
+
+ Which point of this object the position refers to. With an anchor, X and Y are the distance from the same point of the reference (the label, or another object), so the object keeps its place when the label is resized or the reference moves. Choosing a point never moves the object: only the numbers change.
+
+
+ Free
+
+
+ No anchor: X and Y are the top-left corner (or the baseline) measured from the label home, as before.
+
+
+ Free position (X and Y from the label home)
+
+
+ Top left
+
+
+ Top centre
+
+
+ Top right
+
+
+ Middle left
+
+
+ Centre
+
+
+ Middle right
+
+
+ Bottom left
+
+
+ Bottom centre
+
+
+ Bottom right
+
+
+ Relative to
+
+
+ What the anchor is measured from: the label itself, or another object of this label. An object that is relative to another one follows it when it moves or changes size.
+
+
+ The label
+
+
+ (object no longer exists)
+
+
+ X offset (dots)
+
+
+ Y offset (dots)
+
+
+ Distance from the anchor point of the reference to the anchor point of this object. Negative values go left or up.
+
+
+ {0} is already positioned relative to this object (directly or through another object), so this object cannot be relative to it. Choose another reference.
+
+
+ This object and "{0}" are positioned relative to each other, so neither has a place and this one is left out of the label. Make one of them relative to the label.
+
+
+ The object this one is positioned relative to no longer exists, so it is placed relative to the label instead.
+
+
+ Align to label
+
+
+ Revert to design
+
+
+ Throw away the typed text and show the ZPL generated from the design again.
+
+
+ Generated
+
+
+ Editing...
+
+
+ Edited by hand - re-imported
+
+
+ Generated from the design. Type or paste ZPL here; the label follows after a short pause.
+
+
+ Waiting for a pause in typing before re-importing...
+
+
+ Nothing to import: the text is empty. The label was left as it was.
+
+
+ Re-imported. Every command was understood.
+
+
+ Re-imported. 1 command is not supported and was kept as raw ZPL: {0}
+
+
+ Re-imported. {0} commands are not supported and were kept as raw ZPL: {1}
+
+
+ {0} more
+
+
+ 1 other notice (hover for details)
+
+
+ {0} other notices (hover for details)
+
+
+ The text holds {0} labels; the first one is used
+
+
+ {0} data field(s) bound again
+
+
+ Put the caret on a command (^XX or ~XX) to see what it does and what its parameters mean.
+
+
+ {0} is not a ZPL command Rotula knows. It will be kept as raw ZPL.
+
+
+ not modelled by the designer: kept as raw ZPL
+
+
+ ZPL command reference
+
+
+ Result of the last re-import
+
+
+ ZPL text edit
+
+
+ The stored format name {0} is not valid. Use a drive letter, up to 16 letters or digits and the .ZPL extension, for example R:SHIP.ZPL.
+
+
+ This field mixes fixed text with data fields, so the stored format cannot fill it in at print time. It was written with its current value.
+
+
+ This text uses a Windows font and prints as a picture, so the stored format cannot fill its data fields in at print time.
+
+
+ A graphic of {0} bytes was not decoded because the graphics in this file together need more memory than one import may use. It was kept as raw ZPL. Split the file into smaller files to see every graphic.
+
+
+ The counters file already remembers {0} counters, the most it can hold, so a new counter cannot be added. Use a counter that already exists, or move the counters file aside to start a new one (the numbers then begin again, so only do this if the old numbers are no longer on any label).
+
+
+ The data file has {0} columns, and Rotula reads at most {1}. For a text file, check that the separator is right; otherwise remove the extra columns. Then load the file again.
+
+
+ The data file would need {0} cells (columns times rows), and Rotula reads at most {1}. Remove columns you do not use or split the file, then load it again.
+
+
+ {0} copies written one after another are too long to build (the limit for this label is {1} copies). Use fewer copies, or make the copies with the print quantity instead of repeating the label.
+
+
+ The image has too many dots in total (more than {0} million). Make the image smaller.
+
+
+ The label uses character set ^CI{0}, which this computer cannot produce, so no ZPL was generated. Choose another character set in the label properties.
+
+
+ The counters could not be read or saved for "{0}" ({1}). No serial number was lost or repeated: numbers already taken are given back or skipped. Check that the disk has free space and that you can write to the counters folder, then try again.
+
+
+ Nothing was sent: the label has parts the Windows driver cannot print the same way as the preview. Tick the box to print it anyway, or fix the label, then print again.
+
+
+ The Windows driver for "{0}" would not accept the label ({1}). Labels that Windows had already accepted may still print, so check what came out before printing again. Check that the printer is on and has paper, and that its driver settings allow this label size.
+
+
+ Print mode
+
+
+ Printer language (ZPL)
+
+
+ Windows driver (picture)
+
+
+ How does this printer understand labels?
+
+
+ Rotula suggests "{0}" from this printer's name and driver; choose the other one if you know your printer works differently.
+
+
+ This printer gets labels in its own language (ZPL), the fastest and most exact way for Zebra and compatible printers.
+
+
+ This printer gets labels as a picture through its Windows driver, so it can print them even though it does not understand ZPL.
+
+
+ Rotula could not read the page size and resolution from this printer's Windows driver, so the label cannot be printed through it. Check that the printer is installed, switched on and connected, then try again, or choose another printer.
+
+
+ {0} parts of this label are printer commands or features the Windows driver cannot print and will not appear on the label (such as ZPL kept from an imported file, a barcode only the printer can draw, or a UL or CSA certification mark). Print anyway only if the label is fine without them, or print it on a printer that understands ZPL.
+
+
+ 1 part of this label is a printer command or feature the Windows driver cannot print and will not appear on the label (such as ZPL kept from an imported file, a barcode only the printer can draw, or a UL or CSA certification mark). Print anyway only if the label is fine without it, or print it on a printer that understands ZPL.
+
+
+ The barcode "{0}" would have bars only one printer dot wide at this printer's resolution, which scanners cannot read reliably, so it cannot be printed this way. Make the barcode larger (a wider module) or use a printer with a higher resolution.
+
+
+ The narrowest bars of the barcode "{0}" would print {1} mils wide, under the {2} mils this kind of barcode needs, so it may not scan. Make the barcode larger or use a printer with a higher resolution.
+
+
+ The narrowest bars of the barcode "{0}" would print slightly under the {2} mils this kind of barcode needs, so it may not scan. Make the barcode a little larger or use a printer with a higher resolution.
+
+
+ The label ({0} mm) is larger than the area this printer can print on ({1} mm), so part of it would be cut off. Choose a larger paper size in the printer's settings, or make the label smaller.
+
+
+ The Windows driver did not accept the label size ({0} mm) as a paper size, so the label will be printed in the middle of the paper the driver has selected (printable area {1} mm). Choose the label size in the printer's settings if the label should fill the page.
+
+
+ The label ({0} mm) is the other way round from the paper this printer uses ({1} mm), so Rotula turns it a quarter turn clockwise to fit. Check the first printout. If it comes out upside down, set the label's print orientation to upside down.
+
+
+ This printer prints {0} dots per inch across and {1} down, which do not divide evenly, so barcode bars may come out slightly uneven. If a barcode does not scan, choose a resolution in the printer's settings where both numbers are the same.
+
+
+ At this printer's resolution the label would be a picture of {0} dots, more than the {1} Rotula can draw, so it cannot be printed this way. Make the label smaller or choose a lower resolution in the printer's settings.
+
+
+ This print would produce {0} labels, more than the {1} Rotula sends to a Windows driver in one go. Every copy is a page of its own in the print queue, so a job this large would overload it. Print in smaller batches, for example {1} labels or fewer at a time.
+
+
+ The field "{0}" is filled in by the printer itself (a counter, a date or another field's data), which a picture cannot do, so the label would show the wrong value. Use a Rotula counter or date variable instead, or print this label on a printer that understands ZPL.
+
+
+ A field on this label is filled in by the printer itself (a counter, a date or another field's data), which a picture cannot do, so the label would show the wrong value. Use a Rotula counter or date variable instead, or print this label on a printer that understands ZPL.
+
+
+ The check of the label for this printer was stopped before the driver answered, so nothing was printed. Print again to run the check.
+
+
+ The {0} barcode cannot be drawn with the data "{1}", so this label was not printed (a wrong barcode would be worse than none). Correct the data for this barcode, then print again.
+
+
+ An image on this label could not be read or is too large to draw, so this label was not printed. Replace the image or make it smaller, then print again.
+
+
+ Part of this label could not be drawn as designed, so this label was not printed. Check the objects on the label (for example a barcode's data or an image), then print again.
+
+
+ Before printing through the Windows driver
+
+
+ I understand and want to print anyway
+
+
+ Nothing was sent yet. Read the points listed at the top of this window, tick "I understand and want to print anyway" if the label is fine as it is, then press Print again.
+
+
+ Checking the label with the printer's Windows driver...
+
+
+ Nothing was sent: Rotula does not know yet how this printer understands labels. Choose one of the two answers under the printer name, then press Print again.
+
+
+ Nothing was sent: the printer "{0}" is not in the list of printers installed on this PC, so Rotula cannot ask how it understands labels. Refresh the printer list or choose a printer from the list, then press Print again.
+
+
+ Change to "Windows driver (picture)"
+
+
+ Change to "Printer language (ZPL)"
+
+
+ The preview shows what this printer will print, except the parts the Print dialog lists before printing: it prints this same picture through its Windows driver, so text uses Windows fonts instead of the printer's own fonts.
+
+
+ Preview shows the print, except listed parts
+
+
+ Note: the preview shows the printed picture, except the parts the Print dialog lists before printing
+
+
+ Plus {0} more, listed at the top of this window.
+
+
+ This label will not print exactly as designed through this printer's Windows driver:{0}Press OK to print the batch anyway, or Cancel to send nothing.
+
+
+ Nothing was sent: this label cannot be printed through this printer's Windows driver. Fix what is listed below, or switch the printer to "Printer language (ZPL)" if it understands ZPL, then print again.
+
+
+ Record {0} could not be drawn for the Windows driver, so the batch stopped and nothing from record {1} on was printed. Correct the data of record {0} (the reason follows), then print the records from record {1} on again.
+
+
+ One of records {0} to {1} could not be drawn for the Windows driver, so the batch stopped and nothing from record {0} on was printed. Correct that record's data (the reason follows), then print the records from record {0} on again.
+
+
+ Labels up to record {0} may have printed; do not print those again.
+
+
+ Labels up to record {0} may have printed, record {0} itself only in part: {1} of its {2} copies. Its serial number stays used. Do not print those labels again, and check how many copies of record {0} came out.
+
+
+ The Windows driver check stopped the batch at record {0}: the printer now reports something about this label that was not there when printing started (the reason follows), so nothing from record {0} on was sent. Check the label and the printer, then print the records from record {0} on again.
+
+
+ This label has parts that the Windows driver cannot print the same way. Ask the person who set up this station to check the label and set the station up again.
+
+
+ What the Windows driver cannot print the same way
+
+
+ This label cannot be printed on this printer as it is, so nothing was sent. Ask the person who set up this station to check the label. The reason:
+
+
+ The preview shows what this printer will print, except the parts checked when the station was set up: it prints this same picture through its Windows driver, so text uses Windows fonts instead of the printer's own fonts.
+
+
+ Set up print station...
+
+
+ Set up print station
+
+
+ Rotula checks every label of the station with the printer it uses. A printer that prints through its Windows driver may not print some parts of a label the same way: read them and, when the label is fine as it is, tick the box and save, so operators can print it. Do this on the PC where the station runs, signed in as the account it runs under: the check uses that PC's printer drivers and print modes.
+
+
+ Settings file: {0}
+
+
+ Check again
+
+
+ Save
+
+
+ Checking the labels...
+
+
+ Print mode: {0}
+
+
+ Prints in printer language (ZPL). Nothing to check.
+
+
+ No print mode is chosen for this printer on this PC, so the station prints in printer language (ZPL). To print through its Windows driver instead, choose that mode for this printer in the Print dialog, then press Check again.
+
+
+ No printer is set up for this label. Add one to station.json, or print once from the designer on this PC, then press Check again.
+
+
+ Prints through the Windows driver. Nothing stands in the way.
+
+
+ Prints through the Windows driver, which cannot print the points below the same way. Operators can print this label only after you tick the box and save.
+
+
+ Cannot be printed through the Windows driver. Change the label as described below, or set this printer to printer language (ZPL) if it understands ZPL, then press Check again.
+
+
+ I checked these points. Let the station print this label anyway.
+
+
+ Saved in {0}. The station uses it from the next time it starts.
+
+
+ Nothing changed, so nothing was saved.
+
+
+ This folder has no station.json yet. The accepted labels are kept next to the station's station.json, so create station.json in this folder first, then set up the station again.
+
+
+ Rotula could not save {0}. Check that you may change files in that folder, then press Save again.
+
+
+ Saved. No label needs to be accepted any more, so {0} was removed. The station uses this from the next time it starts.
+
+
+ Saved in {0}, but the label file "{1}" is offered on two printers that find different points, and only one set can be accepted for one file. Operators can print it on {2} only. To print it on both, give each printer its own copy of the label file, then set up the station again.
+
+
+ This printer cannot print the outer {2} mm of the page, so anything on the label that reaches that edge may be cut off (label {0} mm, printable area {1} mm). Check the paper size in the printer's settings, or leave a margin of at least {2} mm around the label's content. If nothing that matters is that close to the edge, you can print anyway.
+
+
+ A label with errors stopped the job, so the rest was not sent. Labels already sent to the printer before it: {0}. Check which labels came out, fix the problems below, then print only the ones that are missing.
+
diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx
index 1139cb1..5904e7a 100644
--- a/src/Rotula.App/Resources/Strings.es.resx
+++ b/src/Rotula.App/Resources/Strings.es.resx
@@ -887,7 +887,7 @@
Variables
- Código ZPL (solo lectura)
+ Código ZPL (editable: la etiqueta sigue lo que escribe)100 x 150 mm
@@ -2051,7 +2051,7 @@
Tipo del objeto seleccionado
- SALIDA ZPL
+ CÓDIGO ZPLExportar .zpl
@@ -2794,6 +2794,9 @@
Esta consulta puede leerse de más de una forma (por ejemplo una barra invertida antes de una comilla), así que no se ejecutó. Use dos comillas ('') en lugar de la barra invertida.
+
+ Esta consulta contiene algo que las bases de datos leen de formas distintas, como un carácter oculto, una palabra que empieza con un dígito (1AS) o un nombre U&"...", así que no se ejecutó. Quítelo, o escriba los nombres entre comillas y deje un espacio entre las palabras.
+
proveedor
@@ -3043,6 +3046,36 @@
La impresora siempre imprime el texto debajo de un código LOGMARS, así que desactivarlo no tiene efecto. Active el texto para que coincida con lo que se imprimirá.
+
+ La barra o módulo más estrecho mide 1 punto ({1} mil, {2} mm) a {3} dpi. Muchos lectores no pueden leer barras tan estrechas y un poco de tinta extendida cierra los espacios. Use un módulo de 2 puntos o más.
+
+
+ La barra más estrecha mide {0} punto ({1} mil, {2} mm) a {3} dpi. Las barras de menos de 2 puntos se imprimen de forma irregular en una impresora de 203 dpi, y el código se lee con poca fiabilidad. Use un módulo de 2 puntos o más.
+
+
+ El código de barras y sus zonas de silencio (el margen en blanco que necesita el lector) ocupan de {0} mm a {1} mm, pero la etiqueta mide {2} mm de ancho. Un margen cortado puede impedir la lectura. Mueva el código, use un módulo más estrecho o acorte los datos.
+
+
+ Módulo: {0} mil ({1} mm) - símbolo de {2} mm de ancho
+
+
+ Módulo: {0} mil ({1} mm)
+
+
+ Zona de silencio: {0} mm a cada lado ({1}X)
+
+
+ Zona de silencio: {0} mm antes y {1} mm después del símbolo ({2}X y {3}X)
+
+
+ No se necesita zona de silencio.
+
+
+ Módulo más estrecho (1 punto menos)
+
+
+ Módulo más ancho (1 punto más)
+
Codabar solo admite dígitos y - $ : / . + en los datos. Las letras de inicio y fin (A a D) se eligen en las opciones, no se escriben en los datos.
@@ -6684,4 +6717,397 @@ Hash que debe agregar a apiKeyHashes en el archivo de configuración: {1}
El servidor acepta llamadas de otros equipos por http sin cifrar: las claves de API y los datos de las etiquetas viajan por la red sin cifrado. Use https.
+
+ Punto de anclaje
+
+
+ A qué punto de este objeto se refiere la posición. Con un anclaje, X e Y son la distancia desde el mismo punto de la referencia (la etiqueta u otro objeto), así que el objeto conserva su lugar cuando la etiqueta cambia de tamaño o la referencia se mueve. Elegir un punto nunca mueve el objeto: solo cambian los números.
+
+
+ Libre
+
+
+ Sin anclaje: X e Y son la esquina superior izquierda (o la línea base) medidas desde el origen de la etiqueta, como antes.
+
+
+ Posición libre (X e Y desde el origen de la etiqueta)
+
+
+ Superior izquierda
+
+
+ Superior centro
+
+
+ Superior derecha
+
+
+ Centro izquierda
+
+
+ Centro
+
+
+ Centro derecha
+
+
+ Inferior izquierda
+
+
+ Inferior centro
+
+
+ Inferior derecha
+
+
+ Relativo a
+
+
+ Desde dónde se mide el anclaje: la propia etiqueta u otro objeto de esta etiqueta. Un objeto relativo a otro lo sigue cuando este se mueve o cambia de tamaño.
+
+
+ La etiqueta
+
+
+ (el objeto ya no existe)
+
+
+ Desplazamiento X (puntos)
+
+
+ Desplazamiento Y (puntos)
+
+
+ Distancia desde el punto de anclaje de la referencia hasta el punto de anclaje de este objeto. Los valores negativos van hacia la izquierda o hacia arriba.
+
+
+ {0} ya está posicionado relativo a este objeto (directamente o a través de otro objeto), así que este objeto no puede ser relativo a él. Elija otra referencia.
+
+
+ Este objeto y "{0}" están posicionados uno relativo al otro, así que ninguno tiene lugar y este se deja fuera de la etiqueta. Haga que uno de ellos sea relativo a la etiqueta.
+
+
+ El objeto respecto al cual está posicionado este ya no existe, así que se coloca relativo a la etiqueta.
+
+
+ Alinear con la etiqueta
+
+
+ Volver al diseño
+
+
+ Descarta el texto escrito y muestra de nuevo el ZPL generado a partir del diseño.
+
+
+ Generado
+
+
+ Editando...
+
+
+ Editado a mano - reimportado
+
+
+ Generado a partir del diseño. Escriba o pegue ZPL aquí; la etiqueta se actualiza tras una breve pausa.
+
+
+ Esperando una pausa en la escritura antes de reimportar...
+
+
+ Nada que importar: el texto está vacío. La etiqueta se dejó como estaba.
+
+
+ Reimportado. Se entendieron todos los comandos.
+
+
+ Reimportado. 1 comando no es compatible y se conservó como ZPL sin procesar: {0}
+
+
+ Reimportado. {0} comandos no son compatibles y se conservaron como ZPL sin procesar: {1}
+
+
+ {0} más
+
+
+ 1 aviso más (pase el ratón para ver los detalles)
+
+
+ {0} avisos más (pase el ratón para ver los detalles)
+
+
+ El texto contiene {0} etiquetas; se usa la primera
+
+
+ {0} campo(s) de datos vinculados de nuevo
+
+
+ Coloque el cursor sobre un comando (^XX o ~XX) para ver qué hace y qué significan sus parámetros.
+
+
+ {0} no es un comando ZPL que Rotula conozca. Se conservará como ZPL sin procesar.
+
+
+ el diseñador no lo modela: se conserva como ZPL sin procesar
+
+
+ Referencia de comandos ZPL
+
+
+ Resultado de la última reimportación
+
+
+ edición del texto ZPL
+
+
+ El nombre de formato almacenado {0} no es válido. Use una letra de unidad, hasta 16 letras o dígitos y la extensión .ZPL, por ejemplo R:SHIP.ZPL.
+
+
+ Este campo mezcla texto fijo con campos de datos, así que el formato almacenado no puede rellenarlo al imprimir. Se escribió con su valor actual.
+
+
+ Este texto usa una fuente de Windows y se imprime como imagen, así que el formato almacenado no puede rellenar sus campos de datos al imprimir.
+
+
+ No se decodificó un gráfico de {0} bytes porque los gráficos de este archivo necesitan juntos más memoria de la que puede usar una importación. Se conservó como ZPL sin procesar. Divida el archivo en archivos más pequeños para ver todos los gráficos.
+
+
+ El archivo de contadores ya recuerda {0} contadores, el máximo que puede guardar, así que no se puede añadir uno nuevo. Use un contador que ya exista, o aparte el archivo de contadores para empezar uno nuevo (los números vuelven a empezar, así que hágalo solo si los números anteriores ya no están en ninguna etiqueta).
+
+
+ El archivo de datos tiene {0} columnas y Rotula lee como máximo {1}. En un archivo de texto, compruebe que el separador es correcto; si no, quite las columnas sobrantes. Después cargue el archivo de nuevo.
+
+
+ El archivo de datos necesitaría {0} celdas (columnas por filas) y Rotula lee como máximo {1}. Quite las columnas que no use o divida el archivo y cárguelo de nuevo.
+
+
+ {0} copias escritas una tras otra son demasiado largas para generarse (el límite de esta etiqueta es de {1} copias). Use menos copias o haga las copias con la cantidad de impresión en lugar de repetir la etiqueta.
+
+
+ La imagen tiene demasiados puntos en total (más de {0} millones). Reduzca la imagen.
+
+
+ La etiqueta usa el juego de caracteres ^CI{0}, que este equipo no puede producir, así que no se generó ZPL. Elija otro juego de caracteres en las propiedades de la etiqueta.
+
+
+ No se pudieron leer ni guardar los contadores de "{0}" ({1}). No se perdió ni se repitió ningún número de serie: los números ya tomados se devuelven o se omiten. Compruebe que el disco tiene espacio libre y que puede escribir en la carpeta de los contadores, y vuelva a intentarlo.
+
+
+ No se envió nada: la etiqueta tiene partes que el controlador de Windows no puede imprimir igual que en la vista previa. Marque la casilla para imprimirla de todos modos o corrija la etiqueta, y vuelva a imprimir.
+
+
+ El controlador de Windows de "{0}" no aceptó la etiqueta ({1}). Las etiquetas que Windows ya había aceptado pueden imprimirse igualmente, así que revise lo que salió antes de volver a imprimir. Compruebe que la impresora esté encendida y tenga papel, y que la configuración de su controlador permita este tamaño de etiqueta.
+
+
+ Modo de impresión
+
+
+ Lenguaje de la impresora (ZPL)
+
+
+ Controlador de Windows (imagen)
+
+
+ ¿Cómo entiende las etiquetas esta impresora?
+
+
+ Rotula sugiere "{0}" por el nombre y el controlador de esta impresora; elija la otra opción si sabe que su impresora funciona de otra manera.
+
+
+ Esta impresora recibe las etiquetas en su propio lenguaje (ZPL), la forma más rápida y exacta para las impresoras Zebra y compatibles.
+
+
+ Esta impresora recibe las etiquetas como una imagen a través de su controlador de Windows, así puede imprimirlas aunque no entienda ZPL.
+
+
+ Rotula no pudo leer el tamaño de página ni la resolución del controlador de Windows de esta impresora, así que la etiqueta no se puede imprimir con él. Compruebe que la impresora esté instalada, encendida y conectada, y vuelva a intentarlo, o elija otra impresora.
+
+
+ {0} partes de esta etiqueta son comandos o funciones de la impresora que el controlador de Windows no puede imprimir y no aparecerán en la etiqueta (por ejemplo, ZPL conservado de un archivo importado, un código de barras que solo dibuja la impresora o una marca de certificación UL o CSA). Imprima de todos modos solo si la etiqueta sirve sin ellas, o imprímala en una impresora que entienda ZPL.
+
+
+ 1 parte de esta etiqueta es un comando o una función de la impresora que el controlador de Windows no puede imprimir y no aparecerá en la etiqueta (por ejemplo, ZPL conservado de un archivo importado, un código de barras que solo dibuja la impresora o una marca de certificación UL o CSA). Imprima de todos modos solo si la etiqueta sirve sin ella, o imprímala en una impresora que entienda ZPL.
+
+
+ El código de barras "{0}" tendría barras de un solo punto de ancho con la resolución de esta impresora, y los lectores no pueden leerlas con seguridad, así que no se puede imprimir de esta forma. Agrande el código de barras (un módulo más ancho) o use una impresora de mayor resolución.
+
+
+ Las barras más finas del código de barras "{0}" se imprimirían con {1} mils de ancho, por debajo de los {2} mils que necesita este tipo de código, así que podría no leerse. Agrande el código de barras o use una impresora de mayor resolución.
+
+
+ Las barras más finas del código de barras "{0}" se imprimirían ligeramente por debajo de los {2} mils que necesita este tipo de código, así que podría no leerse. Agrande un poco el código de barras o use una impresora de mayor resolución.
+
+
+ La etiqueta ({0} mm) es más grande que el área que esta impresora puede imprimir ({1} mm), así que una parte quedaría cortada. Elija un tamaño de papel mayor en la configuración de la impresora o haga la etiqueta más pequeña.
+
+
+ El controlador de Windows no aceptó el tamaño de la etiqueta ({0} mm) como tamaño de papel, así que la etiqueta se imprimirá centrada en el papel que tiene seleccionado el controlador (área imprimible {1} mm). Elija el tamaño de la etiqueta en la configuración de la impresora si la etiqueta debe ocupar toda la página.
+
+
+ La etiqueta ({0} mm) está en sentido contrario al del papel que usa esta impresora ({1} mm), así que Rotula la gira un cuarto de vuelta en sentido horario para que quepa. Revise la primera impresión. Si sale al revés, ponga la orientación de impresión de la etiqueta en boca abajo.
+
+
+ Esta impresora imprime {0} puntos por pulgada a lo ancho y {1} a lo alto, que no se dividen de forma exacta, así que las barras de los códigos pueden salir algo desiguales. Si un código no se lee, elija en la configuración de la impresora una resolución en la que ambos números sean iguales.
+
+
+ Con la resolución de esta impresora, la etiqueta sería una imagen de {0} puntos, más de los {1} que Rotula puede dibujar, así que no se puede imprimir de esta forma. Haga la etiqueta más pequeña o elija una resolución menor en la configuración de la impresora.
+
+
+ Esta impresión produciría {0} etiquetas, más de las {1} que Rotula envía a un controlador de Windows de una sola vez. Cada copia es una página propia en la cola de impresión, así que un trabajo tan grande la saturaría. Imprima en lotes más pequeños, por ejemplo de {1} etiquetas o menos cada vez.
+
+
+ El campo "{0}" lo completa la propia impresora (un contador, una fecha o los datos de otro campo), algo que una imagen no puede hacer, así que la etiqueta mostraría un valor equivocado. Use en su lugar una variable de contador o de fecha de Rotula, o imprima esta etiqueta en una impresora que entienda ZPL.
+
+
+ Un campo de esta etiqueta lo completa la propia impresora (un contador, una fecha o los datos de otro campo), algo que una imagen no puede hacer, así que la etiqueta mostraría un valor equivocado. Use en su lugar una variable de contador o de fecha de Rotula, o imprima esta etiqueta en una impresora que entienda ZPL.
+
+
+ La revisión de la etiqueta para esta impresora se detuvo antes de que respondiera el controlador, así que no se imprimió nada. Vuelva a imprimir para repetir la revisión.
+
+
+ El código de barras {0} no se puede dibujar con los datos "{1}", así que esta etiqueta no se imprimió (un código equivocado sería peor que ninguno). Corrija los datos de este código de barras y vuelva a imprimir.
+
+
+ Una imagen de esta etiqueta no se pudo leer o es demasiado grande para dibujarla, así que esta etiqueta no se imprimió. Sustituya la imagen o hágala más pequeña y vuelva a imprimir.
+
+
+ Una parte de esta etiqueta no se pudo dibujar como está diseñada, así que esta etiqueta no se imprimió. Revise los objetos de la etiqueta (por ejemplo, los datos de un código de barras o una imagen) y vuelva a imprimir.
+
+
+ Antes de imprimir con el controlador de Windows
+
+
+ Lo entiendo y quiero imprimir de todos modos
+
+
+ Todavía no se envió nada. Lea los puntos de la parte superior de esta ventana, marque "Lo entiendo y quiero imprimir de todos modos" si la etiqueta le sirve así y vuelva a pulsar Imprimir.
+
+
+ Revisando la etiqueta con el controlador de Windows de la impresora...
+
+
+ No se envió nada: Rotula todavía no sabe cómo entiende las etiquetas esta impresora. Elija una de las dos respuestas debajo del nombre de la impresora y vuelva a pulsar Imprimir.
+
+
+ No se envió nada: la impresora "{0}" no está en la lista de impresoras instaladas en este equipo, así que Rotula no puede preguntar cómo entiende las etiquetas. Actualice la lista de impresoras o elija una impresora de la lista y vuelva a pulsar Imprimir.
+
+
+ Cambiar a "Controlador de Windows (imagen)"
+
+
+ Cambiar a "Lenguaje de la impresora (ZPL)"
+
+
+ La vista previa muestra lo que imprimirá esta impresora, salvo las partes que la ventana Imprimir indica antes de imprimir: imprime esta misma imagen con su controlador de Windows, así que el texto usa fuentes de Windows en lugar de las fuentes propias de la impresora.
+
+
+ La vista previa muestra la impresión, salvo lo indicado
+
+
+ Nota: la vista previa muestra la imagen impresa, salvo las partes que la ventana Imprimir indica antes de imprimir
+
+
+ Y {0} más, en la lista de la parte superior de esta ventana.
+
+
+ Esta etiqueta no se imprimirá exactamente como se diseñó con el controlador de Windows de esta impresora:{0}Pulse Aceptar para imprimir el lote de todos modos, o Cancelar para no enviar nada.
+
+
+ No se envió nada: esta etiqueta no se puede imprimir con el controlador de Windows de esta impresora. Corrija lo que se indica abajo, o cambie la impresora a "Lenguaje de la impresora (ZPL)" si entiende ZPL, y vuelva a imprimir.
+
+
+ El registro {0} no se pudo dibujar para el controlador de Windows, así que el lote se detuvo y no se imprimió nada a partir del registro {1}. Corrija los datos del registro {0} (el motivo se indica a continuación) y vuelva a imprimir los registros a partir del registro {1}.
+
+
+ Uno de los registros del {0} al {1} no se pudo dibujar para el controlador de Windows, así que el lote se detuvo y no se imprimió nada a partir del registro {0}. Corrija los datos de ese registro (el motivo se indica a continuación) y vuelva a imprimir los registros a partir del registro {0}.
+
+
+ Las etiquetas hasta el registro {0} pueden haberse impreso; no las vuelva a imprimir.
+
+
+ Las etiquetas hasta el registro {0} pueden haberse impreso, y el registro {0} solo en parte: {1} de sus {2} copias. Su número de serie sigue usado. No vuelva a imprimir esas etiquetas y compruebe cuántas copias del registro {0} salieron.
+
+
+ La comprobación del controlador de Windows detuvo el lote en el registro {0}: la impresora ahora informa algo sobre esta etiqueta que no había cuando empezó la impresión (el motivo se indica a continuación), así que no se envió nada a partir del registro {0}. Revise la etiqueta y la impresora y vuelva a imprimir los registros a partir del registro {0}.
+
+
+ Esta etiqueta tiene partes que el controlador de Windows no puede imprimir de la misma manera. Pida a quien configuró este puesto que revise la etiqueta y vuelva a configurar el puesto.
+
+
+ Lo que el controlador de Windows no puede imprimir de la misma manera
+
+
+ Esta etiqueta no se puede imprimir en esta impresora tal como está, así que no se envió nada. Pida a quien configuró este puesto que revise la etiqueta. El motivo:
+
+
+ La vista previa muestra lo que imprimirá esta impresora, salvo las partes revisadas al configurar el puesto: imprime esta misma imagen con su controlador de Windows, así que el texto usa fuentes de Windows en lugar de las fuentes propias de la impresora.
+
+
+ Configurar puesto de impresión...
+
+
+ Configurar puesto de impresión
+
+
+ Rotula revisa cada etiqueta del puesto con la impresora que usa. Una impresora que imprime con su controlador de Windows puede no imprimir algunas partes de una etiqueta de la misma manera: léalas y, si la etiqueta está bien así, marque la casilla y guarde, para que los operarios puedan imprimirla. Hágalo en el equipo donde funciona el puesto, con la sesión iniciada en la cuenta con la que se ejecuta: la revisión usa los controladores de impresora y los modos de impresión de ese equipo.
+
+
+ Archivo de configuración: {0}
+
+
+ Volver a revisar
+
+
+ Guardar
+
+
+ Revisando las etiquetas...
+
+
+ Modo de impresión: {0}
+
+
+ Imprime en lenguaje de la impresora (ZPL). No hay nada que revisar.
+
+
+ No se eligió ningún modo de impresión para esta impresora en este equipo, así que el puesto imprime en lenguaje de la impresora (ZPL). Para imprimir con su controlador de Windows, elija ese modo para esta impresora en la ventana Imprimir y pulse Volver a revisar.
+
+
+ No hay ninguna impresora configurada para esta etiqueta. Añada una en station.json, o imprima una vez desde el diseñador en este equipo, y pulse Volver a revisar.
+
+
+ Imprime con el controlador de Windows. Nada lo impide.
+
+
+ Imprime con el controlador de Windows, que no puede imprimir de la misma manera los puntos de abajo. Los operarios solo pueden imprimir esta etiqueta después de que usted marque la casilla y guarde.
+
+
+ No se puede imprimir con el controlador de Windows. Cambie la etiqueta como se indica abajo, o ponga esta impresora en lenguaje de la impresora (ZPL) si entiende ZPL, y pulse Volver a revisar.
+
+
+ Revisé estos puntos. Permitir que el puesto imprima esta etiqueta de todos modos.
+
+
+ Se guardó en {0}. El puesto lo usa a partir de la próxima vez que se inicie.
+
+
+ No cambió nada, así que no se guardó nada.
+
+
+ Esta carpeta todavía no tiene station.json. Las etiquetas aceptadas se guardan junto al station.json del puesto, así que cree primero station.json en esta carpeta y vuelva a configurar el puesto.
+
+
+ Rotula no pudo guardar {0}. Compruebe que puede modificar archivos en esa carpeta y vuelva a pulsar Guardar.
+
+
+ Se guardó. Ya no hay ninguna etiqueta que aceptar, así que se eliminó {0}. El puesto lo tiene en cuenta a partir de la próxima vez que se inicie.
+
+
+ Se guardó en {0}, pero el archivo de etiqueta "{1}" se ofrece en dos impresoras que encuentran puntos distintos, y para un mismo archivo solo se puede aceptar uno de ellos. Los operarios solo pueden imprimirlo en {2}. Para imprimirlo en las dos, dé a cada impresora su propia copia del archivo de etiqueta y vuelva a configurar el puesto.
+
+
+ Esta impresora no puede imprimir los {2} mm exteriores de la página, así que todo lo de la etiqueta que llegue a ese borde puede quedar cortado (etiqueta de {0} mm, área imprimible de {1} mm). Revise el tamaño de papel en la configuración de la impresora o deje un margen de al menos {2} mm alrededor del contenido de la etiqueta. Si no hay nada importante tan cerca del borde, puede imprimir de todos modos.
+
+
+ Una etiqueta con errores detuvo el trabajo, así que el resto no se envió. Etiquetas ya enviadas a la impresora antes de ella: {0}. Compruebe qué etiquetas salieron, corrija los problemas indicados y luego imprima solo las que faltan.
+
diff --git a/src/Rotula.App/Resources/Strings.fr-CA.resx b/src/Rotula.App/Resources/Strings.fr-CA.resx
index c724575..0ac62be 100644
--- a/src/Rotula.App/Resources/Strings.fr-CA.resx
+++ b/src/Rotula.App/Resources/Strings.fr-CA.resx
@@ -2794,6 +2794,9 @@
Cette requête peut se lire de plusieurs façons (par exemple une barre oblique inverse devant un guillemet) ; elle n’a donc pas été exécutée. Utilisez deux apostrophes ('') au lieu de la barre oblique inverse.
+
+ Cette requête contient un élément que les bases de données lisent de façons différentes, comme un caractère caché, un mot qui commence par un chiffre (1AS) ou un nom U&"...", elle n’a donc pas été exécutée. Retirez-le, ou écrivez les noms entre guillemets et laissez une espace entre les mots.
+
fournisseur
@@ -6684,4 +6687,427 @@ Hachage à ajouter dans apiKeyHashes du fichier de paramètres : {1}
Le serveur accepte les appels d’autres ordinateurs en http non chiffré : les clés d’API et les données des étiquettes circulent sur le réseau sans chiffrement. Utilisez https.
+
+ La barre ou le module le plus étroit mesure 1 point ({1} mil, {2} mm) à {3} ppp. Beaucoup de lecteurs ne peuvent pas lire des barres aussi étroites, et un peu d’encre qui s’étale bouche les espaces. Utilisez un module de 2 points ou plus.
+
+
+ La barre la plus étroite mesure {0} point ({1} mil, {2} mm) à {3} ppp. Les barres de moins de 2 points s’impriment de façon irrégulière sur une imprimante de 203 ppp, ce qui rend la lecture du code peu fiable. Utilisez un module de 2 points ou plus.
+
+
+ Le code à barres et ses zones de silence (la marge vierge dont le lecteur a besoin) vont de {0} mm à {1} mm, mais l’étiquette fait {2} mm de large. Une marge coupée peut empêcher la lecture du code. Déplacez le code à barres, utilisez un module plus étroit ou raccourcissez les données.
+
+
+ Module : {0} mil ({1} mm) - symbole de {2} mm de large
+
+
+ Module : {0} mil ({1} mm)
+
+
+ Zone de silence : {0} mm de chaque côté ({1}X)
+
+
+ Zone de silence : {0} mm avant et {1} mm après le symbole ({2}X et {3}X)
+
+
+ Aucune zone de silence n’est nécessaire.
+
+
+ Module plus étroit (1 point de moins)
+
+
+ Module plus large (1 point de plus)
+
+
+ Point d’ancrage
+
+
+ Le point de cet objet auquel la position se rapporte. Avec un ancrage, X et Y sont la distance depuis le même point de la référence (l’étiquette ou un autre objet), de sorte que l’objet garde sa place quand l’étiquette change de taille ou que la référence se déplace. Choisir un point ne déplace jamais l’objet : seuls les nombres changent.
+
+
+ Libre
+
+
+ Sans ancrage : X et Y sont le coin supérieur gauche (ou la ligne de base) mesurés depuis l’origine de l’étiquette, comme avant.
+
+
+ Position libre (X et Y depuis l’origine de l’étiquette)
+
+
+ En haut à gauche
+
+
+ En haut au centre
+
+
+ En haut à droite
+
+
+ Au milieu à gauche
+
+
+ Centre
+
+
+ Au milieu à droite
+
+
+ En bas à gauche
+
+
+ En bas au centre
+
+
+ En bas à droite
+
+
+ Relatif à
+
+
+ Ce depuis quoi l’ancrage est mesuré : l’étiquette elle-même ou un autre objet de cette étiquette. Un objet relatif à un autre le suit quand celui-ci se déplace ou change de taille.
+
+
+ L’étiquette
+
+
+ (l’objet n’existe plus)
+
+
+ Décalage X (points)
+
+
+ Décalage Y (points)
+
+
+ Distance entre le point d’ancrage de la référence et le point d’ancrage de cet objet. Les valeurs négatives vont vers la gauche ou vers le haut.
+
+
+ {0} est déjà positionné par rapport à cet objet (directement ou par l’intermédiaire d’un autre objet) ; cet objet ne peut donc pas lui être relatif. Choisissez une autre référence.
+
+
+ Cet objet et « {0} » sont positionnés l’un par rapport à l’autre ; aucun des deux n’a donc de place et celui-ci est laissé hors de l’étiquette. Rendez l’un d’eux relatif à l’étiquette.
+
+
+ L’objet par rapport auquel celui-ci est positionné n’existe plus ; il est donc placé par rapport à l’étiquette.
+
+
+ Aligner sur l’étiquette
+
+
+ Revenir à la conception
+
+
+ Abandonne le texte tapé et affiche de nouveau le ZPL généré à partir de la conception.
+
+
+ Généré
+
+
+ Modification en cours...
+
+
+ Modifié à la main - réimporté
+
+
+ Généré à partir de la conception. Tapez ou collez du ZPL ici ; l’étiquette suit après une courte pause.
+
+
+ En attente d’une pause dans la saisie avant de réimporter...
+
+
+ Rien à importer : le texte est vide. L’étiquette est restée telle quelle.
+
+
+ Réimporté. Toutes les commandes ont été comprises.
+
+
+ Réimporté. 1 commande n’est pas prise en charge et a été conservée en ZPL brut : {0}
+
+
+ Réimporté. {0} commandes ne sont pas prises en charge et ont été conservées en ZPL brut : {1}
+
+
+ {0} de plus
+
+
+ 1 autre avis (survolez pour voir les détails)
+
+
+ {0} autres avis (survolez pour voir les détails)
+
+
+ Le texte contient {0} étiquettes ; la première est utilisée
+
+
+ {0} champ(s) de données lié(s) de nouveau
+
+
+ Placez le curseur sur une commande (^XX ou ~XX) pour voir ce qu’elle fait et ce que signifient ses paramètres.
+
+
+ {0} n’est pas une commande ZPL que Rotula connaît. Elle sera conservée en ZPL brut.
+
+
+ non modélisée par le concepteur : conservée en ZPL brut
+
+
+ Référence des commandes ZPL
+
+
+ Résultat de la dernière réimportation
+
+
+ la modification du texte ZPL
+
+
+ Le nom de format stocké {0} n’est pas valide. Utilisez une lettre de lecteur, jusqu’à 16 lettres ou chiffres et l’extension .ZPL, par exemple R:SHIP.ZPL.
+
+
+ Ce champ mélange du texte fixe et des champs de données ; le format stocké ne peut donc pas le remplir à l’impression. Il a été écrit avec sa valeur actuelle.
+
+
+ Ce texte utilise une police Windows et s’imprime comme une image ; le format stocké ne peut donc pas remplir ses champs de données à l’impression.
+
+
+ Un graphique de {0} octets n’a pas été décodé, car les graphiques de ce fichier demandent ensemble plus de mémoire qu’une importation ne peut en utiliser. Il a été conservé en ZPL brut. Divisez le fichier en fichiers plus petits pour voir tous les graphiques.
+
+
+ Le fichier des compteurs mémorise déjà {0} compteurs, soit le maximum qu’il peut contenir, donc un nouveau compteur ne peut pas être ajouté. Utilisez un compteur qui existe déjà ou déplacez le fichier des compteurs pour en commencer un nouveau (les numéros recommencent, alors ne le faites que si les anciens numéros ne sont plus sur aucune étiquette).
+
+
+ Le fichier de données compte {0} colonnes, et Rotula en lit au maximum {1}. Pour un fichier texte, vérifiez que le séparateur est le bon ; sinon, retirez les colonnes en trop. Chargez ensuite de nouveau le fichier.
+
+
+ Le fichier de données demanderait {0} cellules (colonnes fois lignes), et Rotula en lit au maximum {1}. Retirez les colonnes inutilisées ou divisez le fichier, puis chargez-le de nouveau.
+
+
+ {0} copies écrites l’une après l’autre sont trop longues à produire (la limite pour cette étiquette est de {1} copies). Utilisez moins de copies ou faites les copies avec la quantité d’impression plutôt que de répéter l’étiquette.
+
+
+ L’image compte trop de points au total (plus de {0} millions). Réduisez l’image.
+
+
+ L’étiquette utilise le jeu de caractères ^CI{0}, que cet ordinateur ne peut pas produire ; aucun ZPL n’a donc été généré. Choisissez un autre jeu de caractères dans les propriétés de l’étiquette.
+
+
+ Les compteurs de « {0} » n’ont pas pu être lus ni enregistrés ({1}). Aucun numéro de série n’a été perdu ni répété : les numéros déjà pris sont rendus ou sautés. Vérifiez que le disque a de l’espace libre et que vous pouvez écrire dans le dossier des compteurs, puis réessayez.
+
+
+ Rien n’a été envoyé : l’étiquette contient des éléments que le pilote Windows ne peut pas imprimer comme dans l’aperçu. Cochez la case pour l’imprimer quand même, ou corrigez l’étiquette, puis imprimez de nouveau.
+
+
+ Le pilote Windows de « {0} » a refusé l’étiquette ({1}). Les étiquettes que Windows avait déjà acceptées peuvent quand même s’imprimer, alors vérifiez ce qui est sorti avant d’imprimer de nouveau. Vérifiez que l’imprimante est allumée et qu’elle contient du papier, et que les paramètres de son pilote permettent ce format d’étiquette.
+
+
+ Mode d’impression
+
+
+ Langage de l’imprimante (ZPL)
+
+
+ Pilote Windows (image)
+
+
+ Comment cette imprimante comprend-elle les étiquettes ?
+
+
+ Rotula suggère « {0} » d’après le nom et le pilote de cette imprimante ; choisissez l’autre option si vous savez que votre imprimante fonctionne autrement.
+
+
+ Cette imprimante reçoit les étiquettes dans son propre langage (ZPL), la façon la plus rapide et la plus exacte pour les imprimantes Zebra et compatibles.
+
+
+ Cette imprimante reçoit les étiquettes sous forme d’image par son pilote Windows, ce qui lui permet de les imprimer même si elle ne comprend pas le ZPL.
+
+
+ Rotula n’a pas pu lire le format de page et la résolution dans le pilote Windows de cette imprimante, alors l’étiquette ne peut pas être imprimée par ce pilote. Vérifiez que l’imprimante est installée, allumée et branchée, puis réessayez, ou choisissez une autre imprimante.
+
+
+ {0} parties de cette étiquette sont des commandes ou des fonctions de l’imprimante que le pilote Windows ne peut pas imprimer et n’apparaîtront pas sur l’étiquette (par exemple du ZPL conservé d’un fichier importé, un code-barres que seule l’imprimante peut dessiner ou une marque de certification UL ou CSA). Imprimez quand même seulement si l’étiquette convient sans elles, ou imprimez-la sur une imprimante qui comprend le ZPL.
+
+
+ 1 partie de cette étiquette est une commande ou une fonction de l’imprimante que le pilote Windows ne peut pas imprimer et n’apparaîtra pas sur l’étiquette (par exemple du ZPL conservé d’un fichier importé, un code-barres que seule l’imprimante peut dessiner ou une marque de certification UL ou CSA). Imprimez quand même seulement si l’étiquette convient sans elle, ou imprimez-la sur une imprimante qui comprend le ZPL.
+
+
+ Le code-barres « {0} » aurait des barres d’un seul point de large à la résolution de cette imprimante, que les lecteurs ne lisent pas de façon fiable, alors il ne peut pas être imprimé ainsi. Agrandissez le code-barres (un module plus large) ou utilisez une imprimante de plus haute résolution.
+
+
+ Les barres les plus fines du code-barres « {0} » seraient imprimées à {1} mils de large, sous les {2} mils qu’exige ce type de code, alors il pourrait ne pas se lire. Agrandissez le code-barres ou utilisez une imprimante de plus haute résolution.
+
+
+ Les barres les plus fines du code-barres « {0} » seraient imprimées légèrement sous les {2} mils qu’exige ce type de code, alors il pourrait ne pas se lire. Agrandissez un peu le code-barres ou utilisez une imprimante de plus haute résolution.
+
+
+ L’étiquette ({0} mm) est plus grande que la zone que cette imprimante peut imprimer ({1} mm), alors une partie serait coupée. Choisissez un format de papier plus grand dans les paramètres de l’imprimante ou réduisez l’étiquette.
+
+
+ Le pilote Windows n’a pas accepté le format de l’étiquette ({0} mm) comme format de papier, alors l’étiquette sera imprimée au centre du papier sélectionné dans le pilote (zone imprimable {1} mm). Choisissez le format de l’étiquette dans les paramètres de l’imprimante si l’étiquette doit remplir la page.
+
+
+ L’étiquette ({0} mm) est dans le sens contraire de celui du papier que cette imprimante utilise ({1} mm), alors Rotula la tourne d’un quart de tour dans le sens horaire pour qu’elle tienne. Vérifiez la première impression. Si elle sort à l’envers, réglez l’orientation d’impression de l’étiquette à l’envers.
+
+
+ Cette imprimante imprime {0} points par pouce en largeur et {1} en hauteur, des nombres qui ne se divisent pas exactement, alors les barres des codes peuvent sortir légèrement inégales. Si un code ne se lit pas, choisissez dans les paramètres de l’imprimante une résolution où les deux nombres sont égaux.
+
+
+ À la résolution de cette imprimante, l’étiquette serait une image de {0} points, plus que les {1} que Rotula peut dessiner, alors elle ne peut pas être imprimée ainsi. Réduisez l’étiquette ou choisissez une résolution plus basse dans les paramètres de l’imprimante.
+
+
+ Cette impression produirait {0} étiquettes, plus que les {1} que Rotula envoie à un pilote Windows en une seule fois. Chaque copie est une page distincte dans la file d’impression, alors une tâche aussi grosse la saturerait. Imprimez par lots plus petits, par exemple {1} étiquettes ou moins à la fois.
+
+
+ Le champ « {0} » est rempli par l’imprimante elle-même (un compteur, une date ou les données d’un autre champ), ce qu’une image ne peut pas faire, alors l’étiquette afficherait une mauvaise valeur. Utilisez plutôt une variable de compteur ou de date de Rotula, ou imprimez cette étiquette sur une imprimante qui comprend le ZPL.
+
+
+ Un champ de cette étiquette est rempli par l’imprimante elle-même (un compteur, une date ou les données d’un autre champ), ce qu’une image ne peut pas faire, alors l’étiquette afficherait une mauvaise valeur. Utilisez plutôt une variable de compteur ou de date de Rotula, ou imprimez cette étiquette sur une imprimante qui comprend le ZPL.
+
+
+ La vérification de l’étiquette pour cette imprimante a été arrêtée avant la réponse du pilote, alors rien n’a été imprimé. Imprimez de nouveau pour refaire la vérification.
+
+
+ Le code-barres {0} ne peut pas être dessiné avec les données « {1} », alors cette étiquette n’a pas été imprimée (un mauvais code serait pire que pas de code). Corrigez les données de ce code-barres, puis imprimez de nouveau.
+
+
+ Une image de cette étiquette n’a pas pu être lue ou est trop grande pour être dessinée, alors cette étiquette n’a pas été imprimée. Remplacez l’image ou réduisez-la, puis imprimez de nouveau.
+
+
+ Une partie de cette étiquette n’a pas pu être dessinée comme prévu, alors cette étiquette n’a pas été imprimée. Vérifiez les objets de l’étiquette (par exemple les données d’un code-barres ou une image), puis imprimez de nouveau.
+
+
+ Avant d’imprimer par le pilote Windows
+
+
+ Je comprends et je veux imprimer quand même
+
+
+ Rien n’a encore été envoyé. Lisez les points en haut de cette fenêtre, cochez « Je comprends et je veux imprimer quand même » si l’étiquette vous convient telle quelle, puis appuyez de nouveau sur Imprimer.
+
+
+ Vérification de l’étiquette avec le pilote Windows de l’imprimante...
+
+
+ Rien n’a été envoyé : Rotula ne sait pas encore comment cette imprimante comprend les étiquettes. Choisissez l’une des deux réponses sous le nom de l’imprimante, puis appuyez de nouveau sur Imprimer.
+
+
+ Rien n’a été envoyé : l’imprimante « {0} » ne figure pas dans la liste des imprimantes installées sur cet ordinateur, alors Rotula ne peut pas demander comment elle comprend les étiquettes. Actualisez la liste des imprimantes ou choisissez une imprimante de la liste, puis appuyez de nouveau sur Imprimer.
+
+
+ Passer à « Pilote Windows (image) »
+
+
+ Passer à « Langage de l’imprimante (ZPL) »
+
+
+ L’aperçu montre ce que cette imprimante va imprimer, sauf les éléments que la fenêtre Imprimer signale avant l’impression : elle imprime cette même image par son pilote Windows, alors le texte utilise des polices Windows au lieu des polices propres à l’imprimante.
+
+
+ L’aperçu montre l’impression, sauf les éléments signalés
+
+
+ Remarque : l’aperçu montre l’image imprimée, sauf les éléments que la fenêtre Imprimer signale avant l’impression
+
+
+ Et {0} de plus, dans la liste en haut de cette fenêtre.
+
+
+ Cette étiquette ne s’imprimera pas exactement comme elle a été conçue par le pilote Windows de cette imprimante :{0}Appuyez sur OK pour imprimer le lot quand même, ou sur Annuler pour ne rien envoyer.
+
+
+ Rien n’a été envoyé : cette étiquette ne peut pas être imprimée par le pilote Windows de cette imprimante. Corrigez ce qui est indiqué ci-dessous, ou passez l’imprimante à « Langage de l’imprimante (ZPL) » si elle comprend le ZPL, puis imprimez de nouveau.
+
+
+ L’enregistrement {0} n’a pas pu être dessiné pour le pilote Windows, alors le lot s’est arrêté et rien n’a été imprimé à partir de l’enregistrement {1}. Corrigez les données de l’enregistrement {0} (la raison suit), puis imprimez de nouveau les enregistrements à partir de l’enregistrement {1}.
+
+
+ L’un des enregistrements {0} à {1} n’a pas pu être dessiné pour le pilote Windows, alors le lot s’est arrêté et rien n’a été imprimé à partir de l’enregistrement {0}. Corrigez les données de cet enregistrement (la raison suit), puis imprimez de nouveau les enregistrements à partir de l’enregistrement {0}.
+
+
+ Les étiquettes jusqu’à l’enregistrement {0} ont peut-être été imprimées ; ne les imprimez pas de nouveau.
+
+
+ Les étiquettes jusqu’à l’enregistrement {0} ont peut-être été imprimées, et l’enregistrement {0} seulement en partie : {1} de ses {2} copies. Son numéro de série reste utilisé. Ne réimprimez pas ces étiquettes, et vérifiez combien de copies de l’enregistrement {0} sont sorties.
+
+
+ La vérification du pilote Windows a arrêté le lot à l’enregistrement {0} : l’imprimante signale maintenant pour cette étiquette quelque chose qui n’était pas là au début de l’impression (la raison suit), alors rien n’a été envoyé à partir de l’enregistrement {0}. Vérifiez l’étiquette et l’imprimante, puis imprimez de nouveau les enregistrements à partir de l’enregistrement {0}.
+
+
+ Cette étiquette comporte des éléments que le pilote Windows ne peut pas imprimer de la même façon. Demandez à la personne qui a configuré ce poste de vérifier l’étiquette et de configurer le poste de nouveau.
+
+
+ Ce que le pilote Windows ne peut pas imprimer de la même façon
+
+
+ Cette étiquette ne peut pas être imprimée sur cette imprimante telle quelle, alors rien n’a été envoyé. Demandez à la personne qui a configuré ce poste de vérifier l’étiquette. La raison :
+
+
+ L’aperçu montre ce que cette imprimante va imprimer, sauf les éléments vérifiés lors de la configuration du poste : elle imprime cette même image par son pilote Windows, alors le texte utilise des polices Windows au lieu des polices propres à l’imprimante.
+
+
+ Configurer le poste d’impression...
+
+
+ Configurer le poste d’impression
+
+
+ Rotula vérifie chaque étiquette du poste avec l’imprimante qu’elle utilise. Une imprimante qui imprime par son pilote Windows peut ne pas imprimer certains éléments d’une étiquette de la même façon : lisez-les et, si l’étiquette convient telle quelle, cochez la case et enregistrez, pour que les opérateurs puissent l’imprimer. Faites-le sur l’ordinateur où le poste fonctionne, connecté avec le compte sous lequel il s’exécute : la vérification utilise les pilotes d’imprimante et les modes d’impression de cet ordinateur.
+
+
+ Fichier de réglages : {0}
+
+
+ Vérifier de nouveau
+
+
+ Enregistrer
+
+
+ Vérification des étiquettes...
+
+
+ Mode d’impression : {0}
+
+
+ Imprime en langage de l’imprimante (ZPL). Rien à vérifier.
+
+
+ Aucun mode d’impression n’est choisi pour cette imprimante sur cet ordinateur, alors le poste imprime en langage de l’imprimante (ZPL). Pour imprimer par son pilote Windows, choisissez ce mode pour cette imprimante dans la fenêtre Imprimer, puis appuyez sur Vérifier de nouveau.
+
+
+ Aucune imprimante n’est configurée pour cette étiquette. Ajoutez-en une dans station.json, ou imprimez une fois depuis le concepteur sur cet ordinateur, puis appuyez sur Vérifier de nouveau.
+
+
+ Imprime par le pilote Windows. Rien ne l’empêche.
+
+
+ Imprime par le pilote Windows, qui ne peut pas imprimer les points ci-dessous de la même façon. Les opérateurs ne peuvent imprimer cette étiquette qu’après que vous avez coché la case et enregistré.
+
+
+ Ne peut pas être imprimée par le pilote Windows. Modifiez l’étiquette comme indiqué ci-dessous, ou passez cette imprimante au langage de l’imprimante (ZPL) si elle comprend le ZPL, puis appuyez sur Vérifier de nouveau.
+
+
+ J’ai vérifié ces points. Laisser le poste imprimer cette étiquette quand même.
+
+
+ Enregistré dans {0}. Le poste l’utilise à partir de son prochain démarrage.
+
+
+ Rien n’a changé, alors rien n’a été enregistré.
+
+
+ Ce dossier n’a pas encore de station.json. Les étiquettes acceptées sont conservées à côté du station.json du poste, alors créez d’abord station.json dans ce dossier, puis configurez le poste de nouveau.
+
+
+ Rotula n’a pas pu enregistrer {0}. Vérifiez que vous pouvez modifier les fichiers de ce dossier, puis appuyez de nouveau sur Enregistrer.
+
+
+ Enregistré. Aucune étiquette n’a plus besoin d’être acceptée, alors {0} a été supprimé. Le poste en tient compte à partir de son prochain démarrage.
+
+
+ Enregistré dans {0}, mais le fichier d’étiquette « {1} » est proposé sur deux imprimantes qui trouvent des points différents, et un seul ensemble peut être accepté pour un même fichier. Les opérateurs ne peuvent l’imprimer que sur {2}. Pour l’imprimer sur les deux, donnez à chaque imprimante sa propre copie du fichier d’étiquette, puis configurez le poste de nouveau.
+
+
+ Cette imprimante ne peut pas imprimer les {2} mm extérieurs de la page, alors tout ce qui, sur l’étiquette, atteint ce bord pourrait être coupé (étiquette de {0} mm, zone imprimable de {1} mm). Vérifiez le format du papier dans les paramètres de l’imprimante, ou laissez une marge d’au moins {2} mm autour du contenu de l’étiquette. Si rien d’important n’est aussi près du bord, vous pouvez imprimer quand même.
+
+
+ Une étiquette comportant des erreurs a arrêté la tâche, alors le reste n’a pas été envoyé. Étiquettes déjà envoyées à l’imprimante avant elle : {0}. Vérifiez quelles étiquettes sont sorties, corrigez les problèmes ci-dessous, puis imprimez seulement celles qui manquent.
+
diff --git a/src/Rotula.App/Resources/Strings.pt-BR.resx b/src/Rotula.App/Resources/Strings.pt-BR.resx
index a31bf0d..d385cf4 100644
--- a/src/Rotula.App/Resources/Strings.pt-BR.resx
+++ b/src/Rotula.App/Resources/Strings.pt-BR.resx
@@ -2794,6 +2794,9 @@
Esta consulta pode ser lida de mais de uma forma (por exemplo, uma barra invertida antes de uma aspa), por isso não foi executada. Use duas aspas simples ('') em vez da barra invertida.
+
+ Esta consulta contém algo que os bancos de dados leem de formas diferentes, como um caractere oculto, uma palavra que começa com um dígito (1AS) ou um nome U&"...", por isso não foi executada. Remova-o, ou escreva os nomes entre aspas e deixe um espaço entre as palavras.
+
provedor
@@ -6684,4 +6687,427 @@ Hash a adicionar em apiKeyHashes no arquivo de configuração: {1}
O servidor aceita chamadas de outros computadores por http sem criptografia: as chaves de API e os dados das etiquetas trafegam pela rede sem criptografia. Use https.
+
+ A barra ou o módulo mais estreito tem 1 ponto ({1} mil, {2} mm) a {3} dpi. Muitos leitores não conseguem ler barras tão estreitas, e um pouco de tinta espalhada fecha os espaços. Use um módulo de 2 pontos ou mais.
+
+
+ A barra mais estreita tem {0} ponto ({1} mil, {2} mm) a {3} dpi. Barras com menos de 2 pontos saem irregulares em uma impressora de 203 dpi, o que torna a leitura do código pouco confiável. Use um módulo de 2 pontos ou mais.
+
+
+ O código de barras e suas zonas de silêncio (a margem em branco de que o leitor precisa) vão de {0} mm a {1} mm, mas a etiqueta tem {2} mm de largura. Uma margem cortada pode impedir a leitura do código. Mova o código de barras, use um módulo mais estreito ou encurte os dados.
+
+
+ Módulo: {0} mil ({1} mm) - símbolo de {2} mm de largura
+
+
+ Módulo: {0} mil ({1} mm)
+
+
+ Zona de silêncio: {0} mm de cada lado ({1}X)
+
+
+ Zona de silêncio: {0} mm antes e {1} mm depois do símbolo ({2}X e {3}X)
+
+
+ Nenhuma zona de silêncio é necessária.
+
+
+ Módulo mais estreito (1 ponto a menos)
+
+
+ Módulo mais largo (1 ponto a mais)
+
+
+ Ponto de ancoragem
+
+
+ A qual ponto deste objeto a posição se refere. Com uma ancoragem, X e Y são a distância a partir do mesmo ponto da referência (a etiqueta ou outro objeto), assim o objeto mantém seu lugar quando a etiqueta muda de tamanho ou a referência se move. Escolher um ponto nunca move o objeto: só os números mudam.
+
+
+ Livre
+
+
+ Sem ancoragem: X e Y são o canto superior esquerdo (ou a linha de base) medidos a partir da origem da etiqueta, como antes.
+
+
+ Posição livre (X e Y a partir da origem da etiqueta)
+
+
+ Superior esquerdo
+
+
+ Superior central
+
+
+ Superior direito
+
+
+ Meio à esquerda
+
+
+ Centro
+
+
+ Meio à direita
+
+
+ Inferior esquerdo
+
+
+ Inferior central
+
+
+ Inferior direito
+
+
+ Relativo a
+
+
+ A partir de onde a ancoragem é medida: a própria etiqueta ou outro objeto desta etiqueta. Um objeto relativo a outro o acompanha quando ele se move ou muda de tamanho.
+
+
+ A etiqueta
+
+
+ (o objeto não existe mais)
+
+
+ Deslocamento X (pontos)
+
+
+ Deslocamento Y (pontos)
+
+
+ Distância do ponto de ancoragem da referência até o ponto de ancoragem deste objeto. Valores negativos vão para a esquerda ou para cima.
+
+
+ {0} já está posicionado em relação a este objeto (diretamente ou por meio de outro objeto), então este objeto não pode ser relativo a ele. Escolha outra referência.
+
+
+ Este objeto e "{0}" estão posicionados um em relação ao outro, então nenhum deles tem lugar e este fica fora da etiqueta. Torne um deles relativo à etiqueta.
+
+
+ O objeto em relação ao qual este está posicionado não existe mais, então ele é colocado em relação à etiqueta.
+
+
+ Alinhar à etiqueta
+
+
+ Voltar ao design
+
+
+ Descarta o texto digitado e mostra de novo o ZPL gerado a partir do design.
+
+
+ Gerado
+
+
+ Editando...
+
+
+ Editado à mão - reimportado
+
+
+ Gerado a partir do design. Digite ou cole ZPL aqui; a etiqueta é atualizada após uma breve pausa.
+
+
+ Aguardando uma pausa na digitação antes de reimportar...
+
+
+ Nada para importar: o texto está vazio. A etiqueta ficou como estava.
+
+
+ Reimportado. Todos os comandos foram entendidos.
+
+
+ Reimportado. 1 comando não é compatível e foi mantido como ZPL bruto: {0}
+
+
+ Reimportado. {0} comandos não são compatíveis e foram mantidos como ZPL bruto: {1}
+
+
+ mais {0}
+
+
+ 1 outro aviso (passe o mouse para ver os detalhes)
+
+
+ {0} outros avisos (passe o mouse para ver os detalhes)
+
+
+ O texto contém {0} etiquetas; a primeira é usada
+
+
+ {0} campo(s) de dados vinculado(s) de novo
+
+
+ Coloque o cursor sobre um comando (^XX ou ~XX) para ver o que ele faz e o que seus parâmetros significam.
+
+
+ {0} não é um comando ZPL que o Rotula conheça. Ele será mantido como ZPL bruto.
+
+
+ não modelado pelo editor: mantido como ZPL bruto
+
+
+ Referência de comandos ZPL
+
+
+ Resultado da última reimportação
+
+
+ edição do texto ZPL
+
+
+ O nome de formato armazenado {0} não é válido. Use uma letra de unidade, até 16 letras ou dígitos e a extensão .ZPL, por exemplo R:SHIP.ZPL.
+
+
+ Este campo mistura texto fixo com campos de dados, então o formato armazenado não pode preenchê-lo na impressão. Ele foi escrito com o valor atual.
+
+
+ Este texto usa uma fonte do Windows e é impresso como imagem, então o formato armazenado não pode preencher seus campos de dados na impressão.
+
+
+ Um gráfico de {0} bytes não foi decodificado porque os gráficos deste arquivo precisam juntos de mais memória do que uma importação pode usar. Ele foi mantido como ZPL bruto. Divida o arquivo em arquivos menores para ver todos os gráficos.
+
+
+ O arquivo de contadores já lembra {0} contadores, o máximo que ele comporta, então não é possível adicionar um novo. Use um contador que já exista ou mova o arquivo de contadores para começar um novo (os números recomeçam, então só faça isso se os números antigos não estiverem mais em nenhuma etiqueta).
+
+
+ O arquivo de dados tem {0} colunas e o Rotula lê no máximo {1}. Em um arquivo de texto, verifique se o separador está correto; caso contrário, remova as colunas extras. Depois carregue o arquivo novamente.
+
+
+ O arquivo de dados precisaria de {0} células (colunas vezes linhas) e o Rotula lê no máximo {1}. Remova as colunas que você não usa ou divida o arquivo e carregue-o novamente.
+
+
+ {0} cópias escritas uma após a outra são longas demais para serem geradas (o limite desta etiqueta é de {1} cópias). Use menos cópias ou faça as cópias com a quantidade de impressão em vez de repetir a etiqueta.
+
+
+ A imagem tem pontos demais no total (mais de {0} milhões). Reduza a imagem.
+
+
+ A etiqueta usa o conjunto de caracteres ^CI{0}, que este computador não consegue produzir, então nenhum ZPL foi gerado. Escolha outro conjunto de caracteres nas propriedades da etiqueta.
+
+
+ Não foi possível ler nem gravar os contadores de "{0}" ({1}). Nenhum número de série foi perdido ou repetido: os números já usados são devolvidos ou pulados. Verifique se o disco tem espaço livre e se você pode gravar na pasta dos contadores e tente novamente.
+
+
+ Nada foi enviado: a etiqueta tem partes que o driver do Windows não consegue imprimir como na visualização. Marque a caixa para imprimir mesmo assim ou corrija a etiqueta e imprima novamente.
+
+
+ O driver do Windows de "{0}" não aceitou a etiqueta ({1}). As etiquetas que o Windows já tinha aceitado ainda podem ser impressas, então confira o que saiu antes de imprimir novamente. Verifique se a impressora está ligada e com papel, e se as configurações do driver permitem este tamanho de etiqueta.
+
+
+ Modo de impressão
+
+
+ Linguagem da impressora (ZPL)
+
+
+ Driver do Windows (imagem)
+
+
+ Como esta impressora entende as etiquetas?
+
+
+ O Rotula sugere "{0}" pelo nome e pelo driver desta impressora; escolha a outra opção se você sabe que sua impressora funciona de outro jeito.
+
+
+ Esta impressora recebe as etiquetas na própria linguagem dela (ZPL), o jeito mais rápido e exato para impressoras Zebra e compatíveis.
+
+
+ Esta impressora recebe as etiquetas como uma imagem pelo driver do Windows, assim consegue imprimi-las mesmo sem entender ZPL.
+
+
+ O Rotula não conseguiu ler o tamanho da página e a resolução no driver do Windows desta impressora, então a etiqueta não pode ser impressa por ele. Verifique se a impressora está instalada, ligada e conectada e tente novamente, ou escolha outra impressora.
+
+
+ {0} partes desta etiqueta são comandos ou recursos da impressora que o driver do Windows não consegue imprimir e não vão aparecer na etiqueta (por exemplo, ZPL mantido de um arquivo importado, um código de barras que só a impressora desenha ou uma marca de certificação UL ou CSA). Imprima mesmo assim só se a etiqueta servir sem elas, ou imprima-a em uma impressora que entenda ZPL.
+
+
+ 1 parte desta etiqueta é um comando ou recurso da impressora que o driver do Windows não consegue imprimir e não vai aparecer na etiqueta (por exemplo, ZPL mantido de um arquivo importado, um código de barras que só a impressora desenha ou uma marca de certificação UL ou CSA). Imprima mesmo assim só se a etiqueta servir sem ela, ou imprima-a em uma impressora que entenda ZPL.
+
+
+ O código de barras "{0}" teria barras de um único ponto de largura na resolução desta impressora, e os leitores não conseguem lê-las com segurança, então ele não pode ser impresso deste jeito. Aumente o código de barras (um módulo mais largo) ou use uma impressora de resolução maior.
+
+
+ As barras mais finas do código de barras "{0}" seriam impressas com {1} mils de largura, abaixo dos {2} mils de que este tipo de código precisa, então ele pode não ser lido. Aumente o código de barras ou use uma impressora de resolução maior.
+
+
+ As barras mais finas do código de barras "{0}" seriam impressas um pouco abaixo dos {2} mils de que este tipo de código precisa, então ele pode não ser lido. Aumente um pouco o código de barras ou use uma impressora de resolução maior.
+
+
+ A etiqueta ({0} mm) é maior que a área que esta impressora consegue imprimir ({1} mm), então uma parte seria cortada. Escolha um tamanho de papel maior nas configurações da impressora ou diminua a etiqueta.
+
+
+ O driver do Windows não aceitou o tamanho da etiqueta ({0} mm) como tamanho de papel, então a etiqueta será impressa no meio do papel que o driver tem selecionado (área imprimível {1} mm). Escolha o tamanho da etiqueta nas configurações da impressora se a etiqueta tiver que ocupar a página toda.
+
+
+ A etiqueta ({0} mm) está no sentido contrário ao do papel que esta impressora usa ({1} mm), então o Rotula a gira um quarto de volta no sentido horário para caber. Confira a primeira impressão. Se sair de cabeça para baixo, defina a orientação de impressão da etiqueta como de cabeça para baixo.
+
+
+ Esta impressora imprime {0} pontos por polegada na largura e {1} na altura, números que não se dividem de forma exata, então as barras dos códigos podem sair um pouco desiguais. Se um código não for lido, escolha nas configurações da impressora uma resolução em que os dois números sejam iguais.
+
+
+ Na resolução desta impressora, a etiqueta seria uma imagem de {0} pontos, mais do que os {1} que o Rotula consegue desenhar, então ela não pode ser impressa deste jeito. Diminua a etiqueta ou escolha uma resolução menor nas configurações da impressora.
+
+
+ Esta impressão geraria {0} etiquetas, mais do que as {1} que o Rotula envia a um driver do Windows de uma só vez. Cada cópia é uma página própria na fila de impressão, então um trabalho tão grande a sobrecarregaria. Imprima em lotes menores, por exemplo {1} etiquetas ou menos por vez.
+
+
+ O campo "{0}" é preenchido pela própria impressora (um contador, uma data ou os dados de outro campo), algo que uma imagem não consegue fazer, então a etiqueta mostraria um valor errado. Use no lugar uma variável de contador ou de data do Rotula, ou imprima esta etiqueta em uma impressora que entenda ZPL.
+
+
+ Um campo desta etiqueta é preenchido pela própria impressora (um contador, uma data ou os dados de outro campo), algo que uma imagem não consegue fazer, então a etiqueta mostraria um valor errado. Use no lugar uma variável de contador ou de data do Rotula, ou imprima esta etiqueta em uma impressora que entenda ZPL.
+
+
+ A verificação da etiqueta para esta impressora foi interrompida antes de o driver responder, então nada foi impresso. Imprima novamente para repetir a verificação.
+
+
+ O código de barras {0} não pode ser desenhado com os dados "{1}", então esta etiqueta não foi impressa (um código errado seria pior do que nenhum). Corrija os dados deste código de barras e imprima novamente.
+
+
+ Uma imagem desta etiqueta não pôde ser lida ou é grande demais para ser desenhada, então esta etiqueta não foi impressa. Troque a imagem ou diminua-a e imprima novamente.
+
+
+ Uma parte desta etiqueta não pôde ser desenhada como foi criada, então esta etiqueta não foi impressa. Confira os objetos da etiqueta (por exemplo, os dados de um código de barras ou uma imagem) e imprima novamente.
+
+
+ Antes de imprimir pelo driver do Windows
+
+
+ Entendo e quero imprimir mesmo assim
+
+
+ Nada foi enviado ainda. Leia os pontos no alto desta janela, marque "Entendo e quero imprimir mesmo assim" se a etiqueta servir como está e clique em Imprimir novamente.
+
+
+ Verificando a etiqueta com o driver do Windows da impressora...
+
+
+ Nada foi enviado: o Rotula ainda não sabe como esta impressora entende as etiquetas. Escolha uma das duas respostas abaixo do nome da impressora e clique em Imprimir novamente.
+
+
+ Nada foi enviado: a impressora "{0}" não está na lista de impressoras instaladas neste computador, então o Rotula não pode perguntar como ela entende as etiquetas. Atualize a lista de impressoras ou escolha uma impressora da lista e clique em Imprimir novamente.
+
+
+ Mudar para "Driver do Windows (imagem)"
+
+
+ Mudar para "Linguagem da impressora (ZPL)"
+
+
+ A visualização mostra o que esta impressora vai imprimir, exceto as partes que a janela Imprimir aponta antes de imprimir: ela imprime esta mesma imagem pelo driver do Windows, então o texto usa fontes do Windows em vez das fontes da própria impressora.
+
+
+ A visualização mostra a impressão, exceto o apontado
+
+
+ Observação: a visualização mostra a imagem impressa, exceto as partes que a janela Imprimir aponta antes de imprimir
+
+
+ E mais {0}, na lista no alto desta janela.
+
+
+ Esta etiqueta não vai sair exatamente como foi desenhada pelo driver do Windows desta impressora:{0}Clique em OK para imprimir o lote mesmo assim, ou em Cancelar para não enviar nada.
+
+
+ Nada foi enviado: esta etiqueta não pode ser impressa pelo driver do Windows desta impressora. Corrija o que está listado abaixo, ou mude a impressora para "Linguagem da impressora (ZPL)" se ela entender ZPL, e imprima novamente.
+
+
+ O registro {0} não pôde ser desenhado para o driver do Windows, então o lote parou e nada foi impresso a partir do registro {1}. Corrija os dados do registro {0} (o motivo vem a seguir) e imprima de novo os registros a partir do registro {1}.
+
+
+ Um dos registros de {0} a {1} não pôde ser desenhado para o driver do Windows, então o lote parou e nada foi impresso a partir do registro {0}. Corrija os dados desse registro (o motivo vem a seguir) e imprima de novo os registros a partir do registro {0}.
+
+
+ As etiquetas até o registro {0} podem ter sido impressas; não as imprima de novo.
+
+
+ As etiquetas até o registro {0} podem ter sido impressas, e o registro {0} só em parte: {1} de suas {2} cópias. O número de série dele continua usado. Não imprima essas etiquetas de novo e confira quantas cópias do registro {0} saíram.
+
+
+ A verificação do driver do Windows parou o lote no registro {0}: a impressora agora informa algo sobre esta etiqueta que não havia quando a impressão começou (o motivo vem a seguir), então nada foi enviado a partir do registro {0}. Confira a etiqueta e a impressora e imprima de novo os registros a partir do registro {0}.
+
+
+ Esta etiqueta tem partes que o driver do Windows não consegue imprimir da mesma forma. Peça a quem configurou este posto para conferir a etiqueta e configurar o posto de novo.
+
+
+ O que o driver do Windows não consegue imprimir da mesma forma
+
+
+ Esta etiqueta não pode ser impressa nesta impressora como está, então nada foi enviado. Peça a quem configurou este posto para conferir a etiqueta. O motivo:
+
+
+ A visualização mostra o que esta impressora vai imprimir, exceto as partes conferidas na configuração do posto: ela imprime esta mesma imagem pelo driver do Windows, então o texto usa fontes do Windows em vez das fontes da própria impressora.
+
+
+ Configurar posto de impressão...
+
+
+ Configurar posto de impressão
+
+
+ O Rotula confere cada etiqueta do posto com a impressora que ela usa. Uma impressora que imprime pelo driver do Windows pode não imprimir algumas partes de uma etiqueta da mesma forma: leia-as e, se a etiqueta estiver boa assim, marque a caixa e salve, para que os operadores possam imprimi-la. Faça isso no computador onde o posto funciona, conectado com a conta em que ele roda: a conferência usa os drivers de impressora e os modos de impressão desse computador.
+
+
+ Arquivo de configuração: {0}
+
+
+ Conferir de novo
+
+
+ Salvar
+
+
+ Conferindo as etiquetas...
+
+
+ Modo de impressão: {0}
+
+
+ Imprime em linguagem da impressora (ZPL). Nada a conferir.
+
+
+ Nenhum modo de impressão foi escolhido para esta impressora neste computador, então o posto imprime em linguagem da impressora (ZPL). Para imprimir pelo driver do Windows, escolha esse modo para esta impressora na janela Imprimir e clique em Conferir de novo.
+
+
+ Nenhuma impressora está configurada para esta etiqueta. Adicione uma no station.json, ou imprima uma vez pelo designer neste computador, e clique em Conferir de novo.
+
+
+ Imprime pelo driver do Windows. Nada impede.
+
+
+ Imprime pelo driver do Windows, que não consegue imprimir os pontos abaixo da mesma forma. Os operadores só podem imprimir esta etiqueta depois que você marcar a caixa e salvar.
+
+
+ Não pode ser impressa pelo driver do Windows. Altere a etiqueta como descrito abaixo, ou passe esta impressora para linguagem da impressora (ZPL) se ela entender ZPL, e clique em Conferir de novo.
+
+
+ Conferi estes pontos. Deixar o posto imprimir esta etiqueta mesmo assim.
+
+
+ Salvo em {0}. O posto passa a usá-lo na próxima vez que for aberto.
+
+
+ Nada mudou, então nada foi salvo.
+
+
+ Esta pasta ainda não tem station.json. As etiquetas aceitas ficam ao lado do station.json do posto, então crie primeiro o station.json nesta pasta e configure o posto de novo.
+
+
+ O Rotula não conseguiu salvar {0}. Confira se você pode alterar arquivos nessa pasta e clique em Salvar de novo.
+
+
+ Salvo. Nenhuma etiqueta precisa mais ser aceita, então {0} foi removido. O posto passa a considerar isso na próxima vez que for aberto.
+
+
+ Salvo em {0}, mas o arquivo de etiqueta "{1}" é oferecido em duas impressoras que encontram pontos diferentes, e só um conjunto pode ser aceito para um mesmo arquivo. Os operadores só podem imprimi-lo em {2}. Para imprimir nas duas, dê a cada impressora sua própria cópia do arquivo de etiqueta e configure o posto de novo.
+
+
+ Esta impressora não consegue imprimir os {2} mm externos da página, então tudo o que estiver na etiqueta e chegar a essa borda pode ser cortado (etiqueta de {0} mm, área imprimível de {1} mm). Confira o tamanho do papel nas configurações da impressora ou deixe uma margem de pelo menos {2} mm em volta do conteúdo da etiqueta. Se não houver nada importante tão perto da borda, você pode imprimir mesmo assim.
+
+
+ Uma etiqueta com erros interrompeu o trabalho, então o restante não foi enviado. Etiquetas já enviadas para a impressora antes dela: {0}. Confira quais etiquetas saíram, corrija os problemas abaixo e depois imprima só as que faltam.
+
diff --git a/src/Rotula.App/Services/AppSettings.cs b/src/Rotula.App/Services/AppSettings.cs
index b619e79..12f989c 100644
--- a/src/Rotula.App/Services/AppSettings.cs
+++ b/src/Rotula.App/Services/AppSettings.cs
@@ -57,6 +57,11 @@ public sealed class AppSettings
/// When the release page was last asked (UTC); null before the first check.
public DateTimeOffset? LastUpdateCheckUtc { get; set; }
+ ///
+ /// How often, in seconds, a recovery copy of the open label is written while it has unsaved changes (see docs/recovery.md).
+ /// 0 switches the recovery copy off. Values between 1 and 4 count as 5 and values above 3600 as 3600.
+ ///
+ public int AutosaveSeconds { get; set; } = 30;
/// The newer version whose notice the user closed, so that notice is not shown again for the same version.
public string? UpdateDismissedVersion { get; set; }
@@ -111,6 +116,64 @@ public sealed class PrintSettings
/// not a ZPL compatible printer would print the query as text.
///
public bool StatusCheckWindows { get; set; }
+
+ ///
+ /// The map as read from settings.json. Null means the file had no map (or an explicit null), which only a version before driver printing
+ /// writes: this version always writes the map, even empty, because the getter below creates it before the serializer reads it.
+ ///
+ private Dictionary? _printModes;
+
+ ///
+ /// How each Windows printer understands labels, by queue name: "PrinterLanguage" (ZPL) or "WindowsDriver" (a picture drawn
+ /// through the driver). A map rather than one value, so choosing a mode for one printer never changes the choice made for another. A
+ /// printer that is missing, or whose value is not one of the two names, has no saved mode and is asked about once. Never null when read; a
+ /// settings file saved by this version always contains it (an empty map is written as {}, not left out), which is how
+ /// tells an older file from a new one.
+ ///
+ public Dictionary PrintModes
+ {
+ get => _printModes ??= new Dictionary(StringComparer.OrdinalIgnoreCase);
+ set => _printModes = value;
+ }
+
+ ///
+ /// Makes safe to use and brings a file from an older version up to date. Call it right after loading, before
+ /// anything reads the map or saves the file (the store does; the printer view-model does it again for settings that never came from a
+ /// file). Idempotent.
+ ///
+ /// The map is rebuilt to match queue names without regard to case: Windows treats them that way, but System.Text.Json loads an ordinary
+ /// case-sensitive dictionary. The indexer is used, not the copying constructor, so a hand-edited file that spells one queue twice in
+ /// different case cannot throw.
+ /// Only when the map was absent (a file written before driver printing) is the configured printer recorded as
+ /// "PrinterLanguage": it already printed ZPL, so its user is never asked. A map that is present but empty was written by this
+ /// version for a printer nobody has answered for yet, so it is never seeded; otherwise a person who printed once from the Data window on a
+ /// non-ZPL printer would never be asked.
+ ///
+ ///
+ public void NormalizePrintModes()
+ {
+ bool writtenBeforePrintModes = _printModes is null;
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ if (_printModes is not null)
+ foreach (var (name, mode) in _printModes) map[name] = mode;
+ _printModes = map;
+ if (writtenBeforePrintModes && Configured && !string.IsNullOrWhiteSpace(PrinterName))
+ map[PrinterName] = nameof(Rotula.Printing.PrinterOutputMode.PrinterLanguage);
+ }
+
+ ///
+ /// A separate copy of these settings, for a window that must never change the designer's own printer (the print station, the station set-up
+ /// tool). The print-mode map is copied as it is, without reading it through : reading would create an empty map on
+ /// this object, and a map that is absent (an older settings file) must stay absent so that the copy goes through
+ /// exactly as the file itself would, with the same old-file-versus-new-file rule.
+ ///
+ public PrintSettings Copy()
+ {
+ var copy = (PrintSettings)MemberwiseClone();
+ // Every other field is a value or an immutable string; the map is the only part the copy could share and change.
+ copy._printModes = _printModes is null ? null : new Dictionary(_printModes, _printModes.Comparer);
+ return copy;
+ }
}
/// Loads and saves .
@@ -133,7 +196,10 @@ public sealed class JsonSettingsStore : ISettingsStore
"// Rotula settings. Edit with the program closed, or change them from the menus.\n" +
"// language: \"auto\" follows Windows, or use \"en\" / \"es\" / \"pt-BR\" / \"fr-CA\".\n" +
"// unit: \"Millimeters\", \"Inches\" or \"Dots\".\n" +
- "// updateCheckEnabled: true asks GitHub once a day whether a newer version exists (off by default; nothing is sent about you).\n";
+ "// updateCheckEnabled: true asks GitHub once a day whether a newer version exists (off by default; nothing is sent about you).\n" +
+ "// autosaveSeconds: how often (in seconds) a recovery copy of a label with unsaved changes is kept, so a crash or power cut does not lose your work.\n" +
+ "// 30 is the default, 0 switches it off. Rotula offers the copy the next time it starts. Your label file is never changed by it.\n" +
+ "// print.printModes: per Windows printer, \"PrinterLanguage\" (ZPL) or \"WindowsDriver\" (picture); a printer not listed is asked about once.\n";
private static readonly JsonSerializerOptions Options = new()
{
@@ -166,7 +232,11 @@ public AppSettings Load()
Save(fresh);
return fresh;
}
- return JsonSerializer.Deserialize(File.ReadAllText(_path), Options) ?? new AppSettings();
+ var loaded = JsonSerializer.Deserialize(File.ReadAllText(_path), Options) ?? new AppSettings();
+ // Decide here whether the file predates print modes, before any part of Rotula can save it: the first save writes the map, and
+ // the difference between "older file" and "nobody answered yet" would then be lost.
+ (loaded.Print ??= new PrintSettings()).NormalizePrintModes();
+ return loaded;
}
catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
{
@@ -178,14 +248,23 @@ public AppSettings Load()
///
public bool Save(AppSettings settings)
{
+ // Why a temp file: File.WriteAllText truncates settings.json before it writes, so a crash or power loss in between would leave an
+ // empty file and the user's settings (recent files, printer, language) would be gone. The new text is written beside the real
+ // file first and swapped in with one operation, so the real file is always either the old version or the new one.
+ // The name is unique because two Rotula windows (or the designer and the print engine) can save at the same moment.
+ string temp = _path + "." + Guid.NewGuid().ToString("N")[..8] + ".tmp";
try
{
Directory.CreateDirectory(Path.GetDirectoryName(_path)!);
- File.WriteAllText(_path, Header + JsonSerializer.Serialize(settings, Options), new System.Text.UTF8Encoding(false));
+ File.WriteAllText(temp, Header + JsonSerializer.Serialize(settings, Options), new System.Text.UTF8Encoding(false));
+ if (File.Exists(_path)) File.Replace(temp, _path, null);
+ else File.Move(temp, _path);
return true;
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
+ // Never leave a half-written copy next to the settings.
+ try { File.Delete(temp); } catch (Exception cleanup) when (cleanup is IOException or UnauthorizedAccessException) { /* nothing more can be done */ }
return false;
}
}
diff --git a/src/Rotula.App/Services/CoreZplImporter.cs b/src/Rotula.App/Services/CoreZplImporter.cs
index 36a063a..055487f 100644
--- a/src/Rotula.App/Services/CoreZplImporter.cs
+++ b/src/Rotula.App/Services/CoreZplImporter.cs
@@ -15,11 +15,13 @@ public ZplImportResult Import(byte[] zpl)
// ZPL does not say which printhead it was written for; 203 DPI is the most common and can be changed in the label
// properties afterwards (positions are in dots, so they stay as they are).
var result = ZplParser.Parse(zpl, new ZplParseOptions());
- var warnings = result.Warnings.Select(w =>
- {
- string text = Loc.FormatIssue(w.ToValidationIssue());
- return w.Line > 0 ? Loc.Get("Import.Warning.Line", w.Line, text) : text;
- }).ToList();
- return ZplImportResult.Success(result.Document, warnings);
+ return ZplImportResult.Success(result.Document, result.Warnings.Select(Describe).ToList());
+ }
+
+ /// One import notice as a sentence in the current language, with its line number when the parser knows it.
+ public static string Describe(ZplImportWarning w)
+ {
+ string text = Loc.FormatIssue(w.ToValidationIssue());
+ return w.Line > 0 ? Loc.Get("Import.Warning.Line", w.Line, text) : text;
}
}
diff --git a/src/Rotula.App/Services/DriverPreflightGate.cs b/src/Rotula.App/Services/DriverPreflightGate.cs
new file mode 100644
index 0000000..221eeb2
--- /dev/null
+++ b/src/Rotula.App/Services/DriverPreflightGate.cs
@@ -0,0 +1,129 @@
+using System.Globalization;
+using Rotula.App.Localization;
+using Rotula.Core.Validation;
+using Rotula.Printing;
+
+namespace Rotula.App.Services;
+
+///
+/// Turns the findings of the Windows-driver pre-flight () and the errors of the picture renderer
+/// () into plain sentences in the current language, one per finding. The Print dialog, batch printing and the
+/// print station all word a finding through this class, so a person reads the same sentence wherever it appears. It only builds text: no WPF
+/// types, so it can be used from any view-model.
+///
+/// The texts live in the resources under Issue.<key>, the convention already follows for every other
+/// issue (for example Issue.Driver.Preflight.RawBlocks), so a finding that reaches a generic issue list still finds its text.
+///
+///
+public static class DriverPreflightGate
+{
+ /// Prefix shared by every message key of the picture-mode path (pre-flight findings and renderer errors).
+ private const string DriverKeyPrefix = "Driver.";
+
+ /// Heading of the findings list in the current language.
+ public static string Title => Loc.Get("Print.Driver.FindingsTitle");
+
+ /// The findings as one sentence per line, in the order the library reported them; empty when there are none.
+ /// The pre-flight findings (errors and warnings).
+ /// is null.
+ public static string Describe(IReadOnlyList findings) => string.Join(Environment.NewLine, Lines(findings));
+
+ /// The findings as one sentence each, in the order the library reported them.
+ /// The pre-flight findings (errors and warnings).
+ /// is null.
+ public static IReadOnlyList Lines(IEnumerable findings)
+ {
+ ArgumentNullException.ThrowIfNull(findings);
+ return findings.Select(FormatFinding).ToList();
+ }
+
+ ///
+ /// One finding as a sentence in the current language. A key outside the picture-mode path goes through unchanged.
+ /// For a picture-mode key the arguments, written by the library in the invariant culture (so the fingerprint never depends on the
+ /// machine's language), are shown in the current language: "9,8" in Spanish, "100 × 50" for a size. Three cases pick a variant text,
+ /// because one sentence cannot read right for every argument:
+ ///
+ /// with a count of 1 ("1 part", not "1 parts").
+ /// whose actual and minimum widths print as the same one-decimal number (9.84 under
+ /// 9.85 both read "9.8"): "9.8 mils, under the 9.8 mils needed" would contradict itself, so the variant says "slightly under".
+ /// with an empty name (an unnamed text whose first line is empty): the variant names no field
+ /// instead of showing empty quotes.
+ ///
+ ///
+ /// The finding.
+ /// is null.
+ public static string FormatFinding(ValidationIssue finding)
+ {
+ ArgumentNullException.ThrowIfNull(finding);
+ if (!finding.MessageKey.StartsWith(DriverKeyPrefix, StringComparison.Ordinal)) return Loc.FormatIssue(finding);
+ string key = VariantKey(finding) ?? "Issue." + finding.MessageKey;
+ // A key this version has no text for (a newer library) still gets the generic "problem found" sentence, never a raw key.
+ if (!Loc.Instance.HasKey(key)) return Loc.FormatIssue(finding);
+ return Loc.Get(key, DisplayArgs(finding));
+ }
+
+ /// The resource key of the variant text needs, or null for its ordinary text.
+ private static string? VariantKey(ValidationIssue finding)
+ {
+ var args = finding.Args;
+ return finding.MessageKey switch
+ {
+ DriverPreflight.RawBlocks when args.Count >= 1 && args[0] == "1" => "Issue." + DriverPreflight.RawBlocks + ".One",
+ DriverPreflight.ModuleBelowMinimum when args.Count >= 3 && string.Equals(args[1], args[2], StringComparison.Ordinal)
+ => "Issue." + DriverPreflight.ModuleBelowMinimum + ".Slightly",
+ DriverPreflight.PrinterCounter when args.Count == 0 || string.IsNullOrWhiteSpace(args[0]) => "Issue." + DriverPreflight.PrinterCounter + ".NoName",
+ _ => null,
+ };
+ }
+
+ ///
+ /// The arguments of a picture-mode finding as they are shown: numbers in the current language, sizes written W × H. Arguments are
+ /// padded to three so a text never shows an unreplaced hole when the library sent fewer. An argument that does not parse is shown as sent.
+ ///
+ private static object[] DisplayArgs(ValidationIssue finding)
+ {
+ var shown = finding.Args.Select(a => (object)a).ToList();
+ switch (finding.MessageKey)
+ {
+ case DriverPreflight.ModuleBelowMinimum:
+ // Barcode name, then the actual and the minimum module width in mils (always one decimal in the library).
+ for (int i = 1; i < shown.Count && i <= 2; i++) shown[i] = Decimal(finding.Args[i], "0.0");
+ break;
+ case DriverPreflight.LabelLargerThanPaper or DriverPreflight.PaperMismatch or DriverPreflight.LabelTurned:
+ // Label size, then the printable area (for a turned label: the label as designed, then the paper it lies on), both "WxH" in
+ // millimetres with at most one decimal.
+ for (int i = 0; i < shown.Count && i <= 1; i++) shown[i] = Size(finding.Args[i]);
+ break;
+ case DriverPreflight.MarginBand:
+ // Label size and printable area as above, then the widest unprintable edge the library worked out (millimetres, one decimal).
+ // Only shown here, never computed: every value of the text travels in the finding, so any formatter can word it.
+ for (int i = 0; i < shown.Count && i <= 1; i++) shown[i] = Size(finding.Args[i]);
+ if (shown.Count >= 3) shown[2] = Decimal(finding.Args[2], "0.0");
+ break;
+ case DriverPreflight.TooLarge or DriverPreflight.TooManyLabels:
+ // Pixels of the picture, then the most Rotula draws; or the labels asked for, then the most one job may print: whole numbers,
+ // grouped for reading ("250,000" in English, "250.000" in Spanish).
+ for (int i = 0; i < shown.Count && i <= 1; i++) shown[i] = Whole(finding.Args[i]);
+ break;
+ }
+ while (shown.Count < 3) shown.Add(string.Empty);
+ return shown.ToArray();
+ }
+
+ private static string Decimal(string value, string format) =>
+ double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out double number)
+ ? number.ToString(format, Loc.Instance.Culture)
+ : value;
+
+ private static string Whole(string value) =>
+ long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long number)
+ ? number.ToString("N0", Loc.Instance.Culture)
+ : value;
+
+ private static string Size(string value)
+ {
+ string[] parts = value.Split('x');
+ if (parts.Length != 2) return value;
+ return Decimal(parts[0], "0.#") + " × " + Decimal(parts[1], "0.#");
+ }
+}
diff --git a/src/Rotula.App/Services/ServiceInterfaces.cs b/src/Rotula.App/Services/ServiceInterfaces.cs
index 53a0dfe..24d63a6 100644
--- a/src/Rotula.App/Services/ServiceInterfaces.cs
+++ b/src/Rotula.App/Services/ServiceInterfaces.cs
@@ -135,6 +135,19 @@ public sealed class StubZplImporter : IZplImporter
public ZplImportResult Import(byte[] zpl) => ZplImportResult.Failure("Import.NotAvailable");
}
+///
+/// Runs one action after a quiet period, restarting the wait on every call (typing in the ZPL source panel re-imports the label only once
+/// the user pauses). The production implementation ticks on the UI thread; tests fire it by hand.
+///
+public interface IDebouncer
+{
+ /// Forgets any pending action and arranges for to run once has passed without another call.
+ void Restart(TimeSpan delay, Action action);
+
+ /// Drops the pending action, if any.
+ void Cancel();
+}
+
/// Opens the tool windows that belong to the main window (print and data merge).
public interface IToolDialogService
{
@@ -145,7 +158,13 @@ public interface IToolDialogService
void ShowDataWindow(Rotula.App.ViewModels.DataMergeViewModel viewModel);
/// Shows a larger preview of the label as the printer will draw it.
- void ShowPreviewDialog(Rotula.App.Editing.PreviewCache preview, string title);
+ /// The preview cache of the open label.
+ /// Name of the label, shown in the title.
+ ///
+ /// The sentence under the picture saying how close it is to the print: "approximate" for a printer-language printer, the accurate note for a
+ /// printer in Windows-driver (picture) mode.
+ ///
+ void ShowPreviewDialog(Rotula.App.Editing.PreviewCache preview, string title, string note);
/// Shows the connection manager modally; returns when it is closed.
void ShowConnectionManager(Rotula.App.ViewModels.ConnectionManagerViewModel viewModel);
@@ -171,6 +190,9 @@ void ShowPrintStation(Rotula.App.ViewModels.StationViewModel viewModel) { }
/// Shows the tool that makes the exit PIN line for station.json. The default shows nothing.
void ShowStationPinTool(Rotula.App.ViewModels.StationPinViewModel viewModel) { }
+ /// Shows "Set up print station" modally (the labels checked for picture mode, the findings accepted once). The default shows nothing.
+ void ShowStationSetup(Rotula.App.ViewModels.StationSetupViewModel viewModel) { }
+
/// Shows the "Label sheets" dialog (office printer sheets as a PDF) modally. The default offers no dialog.
void ShowSheetDialog(Rotula.App.ViewModels.SheetLayoutViewModel viewModel) { }
}
diff --git a/src/Rotula.App/Services/WpfServices.cs b/src/Rotula.App/Services/WpfServices.cs
index 17b21e5..cc15754 100644
--- a/src/Rotula.App/Services/WpfServices.cs
+++ b/src/Rotula.App/Services/WpfServices.cs
@@ -187,9 +187,9 @@ public void ShowPrintDialog(Rotula.App.ViewModels.PrintViewModel viewModel)
}
///
- public void ShowPreviewDialog(Rotula.App.Editing.PreviewCache preview, string title)
+ public void ShowPreviewDialog(Rotula.App.Editing.PreviewCache preview, string title, string note)
{
- var window = new PreviewWindow(preview, title) { Owner = Application.Current?.MainWindow };
+ var window = new PreviewWindow(preview, title, note) { Owner = Application.Current?.MainWindow };
window.ShowDialog();
}
@@ -243,6 +243,13 @@ public void ShowStationPinTool(Rotula.App.ViewModels.StationPinViewModel viewMod
window.ShowDialog();
}
+ ///
+ public void ShowStationSetup(Rotula.App.ViewModels.StationSetupViewModel viewModel)
+ {
+ var window = new StationSetupWindow(viewModel) { Owner = Application.Current?.MainWindow };
+ window.ShowDialog();
+ }
+
///
public void ShowSheetDialog(Rotula.App.ViewModels.SheetLayoutViewModel viewModel)
{
@@ -319,3 +326,41 @@ public bool Open(string url)
}
}
}
+
+///
+/// on the WPF dispatcher: the action runs on the UI thread, which the editor session requires, after the
+/// quiet period. One timer is reused; restarting it simply pushes the tick back.
+///
+public sealed class DispatcherDebouncer : IDebouncer
+{
+ private readonly System.Windows.Threading.DispatcherTimer _timer = new();
+ private Action? _pending;
+
+ /// Creates the debouncer on the current thread's dispatcher.
+ public DispatcherDebouncer()
+ {
+ _timer.Tick += (_, _) =>
+ {
+ _timer.Stop();
+ var action = _pending;
+ _pending = null;
+ action?.Invoke();
+ };
+ }
+
+ ///
+ public void Restart(TimeSpan delay, Action action)
+ {
+ _timer.Stop();
+ _pending = action;
+ _timer.Interval = delay;
+ _timer.Start();
+ }
+
+ ///
+ public void Cancel()
+ {
+ _timer.Stop();
+ _pending = null;
+ }
+}
diff --git a/src/Rotula.App/ViewModels/BarcodeSizeInfo.cs b/src/Rotula.App/ViewModels/BarcodeSizeInfo.cs
new file mode 100644
index 0000000..c4879ab
--- /dev/null
+++ b/src/Rotula.App/ViewModels/BarcodeSizeInfo.cs
@@ -0,0 +1,97 @@
+using System.Globalization;
+using Rotula.App.Localization;
+using Rotula.Core.Barcodes;
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+using Rotula.Rendering.Barcodes;
+
+namespace Rotula.App.ViewModels;
+
+///
+/// Turns the sizing figures of into the plain-language lines under the module setting of a barcode:
+/// "Module: 7.5 mil (0.19 mm) - symbol 48.3 mm wide" and the quiet zone note. No arithmetic lives here; this class only chooses the
+/// wording and, where the Core rules cannot measure a symbol (Data Matrix, PDF417, ...), asks the preview encoder for its module count.
+///
+public static class BarcodeSizeInfo
+{
+ ///
+ /// The size report for at the label's resolution, or null when the symbology has no adjustable module.
+ /// is the text the symbol prints (the longest sample when the data comes from fields).
+ ///
+ public static BarcodeSizeReport? Report(BarcodeElement b, string? data, int dpi)
+ {
+ data ??= b.Data;
+ var report = BarcodeSizing.Analyze(b, data, dpi);
+ if (report is null || report.SymbolWidthDots is not null || data.Length == 0) return report;
+
+ // Core could not measure it: use the preview encoder's module count where that is true to the printer. A symbol with wide bars
+ // is drawn with the fixed ratio 3 (see LinearEncoders), so its count is only used when the element asks for the same ratio.
+ var info = BarcodeCatalog.Get(b.Symbology);
+ if (info.Supports(BarcodeOption.WideBarRatio) && Math.Abs(b.WideBarRatio - 3.0) > 1e-9) return report;
+ var matrix = BarcodeEncoder.TryEncode(b.Symbology, data, new BarcodeEncodeOptions
+ {
+ QrErrorCorrection = b.QrErrorCorrection,
+ Pdf417SecurityLevel = b.Pdf417SecurityLevel,
+ Pdf417Truncate = b.Pdf417Truncate,
+ CheckDigit = b.CheckDigit,
+ Code11CheckDigits = b.Code11CheckDigitCount,
+ MsiCheckDigit = b.MsiCheckDigit,
+ MsiCheckDigitInText = b.MsiCheckDigitInText,
+ CodabarStart = b.CodabarStart,
+ CodabarStop = b.CodabarStop,
+ DataBarType = b.DataBarType,
+ DataBarSegmentsPerRow = b.DataBarSegmentsPerRow,
+ DataBarSeparatorHeight = b.DataBarSeparatorHeight,
+ AztecErrorControl = b.AztecErrorControl,
+ Gs1 = b.DataMatrixGs1,
+ }, out _);
+ // Hexagonal and postal layouts are not a plain module grid, so their width in modules is not a width in dots.
+ return matrix is { Layout: not (BarcodeRowLayout.Hexagonal or BarcodeRowLayout.Proportional) }
+ ? BarcodeSizing.Analyze(b, data, dpi, matrix.Width)
+ : report;
+ }
+
+ ///
+ /// The rectangle (label dots) that holds the symbol and its quiet zone, for the dotted guide on the design canvas: the element's frame grown
+ /// by the quiet zone on the sides that need it. Null when the symbology has no adjustable module (nothing to say about its margin).
+ ///
+ public static Editing.RectD? QuietZoneFrame(Editing.RectD frame, BarcodeElement b, FieldRotation defaultRotation)
+ {
+ if (BarcodeSizing.ModuleDotsOf(b) is null) return null;
+ var q = BarcodeSizing.QuietZoneBox(b, b.Rotation ?? defaultRotation);
+ return new Editing.RectD(frame.X - q.Left, frame.Y - q.Top, frame.Width + q.Left + q.Right, frame.Height + q.Top + q.Bottom);
+ }
+
+ /// The "Module: ..." line. adds the printed width of the symbol when it is known.
+ public static string ReadoutLine(BarcodeSizeReport r, bool withSymbol = true)
+ {
+ string mil = r.XDimensionMils.ToString("0.#", CultureInfo.CurrentCulture);
+ string mm = r.XDimensionMm.ToString("0.##", CultureInfo.CurrentCulture);
+ return withSymbol && r.SymbolWidthMm is { } w
+ ? Loc.Get("Prop.Size.Readout", mil, mm, w.ToString("0.#", CultureInfo.CurrentCulture))
+ : Loc.Get("Prop.Size.ReadoutNoSymbol", mil, mm);
+ }
+
+ /// The quiet zone note: one value when both sides are equal, two when they differ (EAN-13), a short sentence when none is needed.
+ public static string QuietZoneLine(BarcodeSizeReport r)
+ {
+ var q = r.QuietZone;
+ if (q.LeftModules == 0 && q.RightModules == 0) return Loc.Get("Prop.Size.QuietNone");
+ string left = r.QuietLeftMm.ToString("0.#", CultureInfo.CurrentCulture), right = r.QuietRightMm.ToString("0.#", CultureInfo.CurrentCulture);
+ return q.LeftModules == q.RightModules
+ ? Loc.Get("Prop.Size.QuietBoth", left, q.LeftModules)
+ : Loc.Get("Prop.Size.QuietLeftRight", left, right, q.LeftModules, q.RightModules);
+ }
+
+ ///
+ /// The second line of the module row: readout plus quiet zone note, in the warning colour when the printer reality check complains about
+ /// the module. The wording of that complaint itself belongs to the problems list, so it is not repeated here.
+ ///
+ public static RowDetail? Describe(BarcodeElement b, string? data, LabelDocument doc, bool withSymbol = true)
+ {
+ var report = Report(b, data, (int)doc.Dpi);
+ if (report is null) return null;
+ bool warn = BarcodeSizing.Check(b, data ?? b.Data, doc).Any(i => i.MessageKey is BarcodeSizing.OneDotKey or BarcodeSizing.ThinBarKey);
+ return new RowDetail(ReadoutLine(report, withSymbol) + Environment.NewLine + QuietZoneLine(report), warn);
+ }
+}
diff --git a/src/Rotula.App/ViewModels/DataMergeViewModel.cs b/src/Rotula.App/ViewModels/DataMergeViewModel.cs
index 8fa6562..c03b3f4 100644
--- a/src/Rotula.App/ViewModels/DataMergeViewModel.cs
+++ b/src/Rotula.App/ViewModels/DataMergeViewModel.cs
@@ -299,6 +299,10 @@ private void OnDatabaseDataSelected(object? sender, DatabaseDataSelectedEventArg
///
public bool CanReload => HasSource && !IsBusy;
+ ///
+ /// The source is applied while the load is still busy, so the Reload button is re-evaluated when the busy state ends.
+ protected override void OnBusyChanged(bool busy) => OnPropertyChanged(nameof(CanReload));
+
/// True when the .rotula file named a data path that was ignored for safety.
[ObservableProperty]
private bool _sourceHintIgnored;
@@ -433,68 +437,77 @@ public async Task LoadAsync(string path)
char? detected = null;
string? error = null;
- bool finished = await RunAsync(Loc.Get("Data.Busy.Loading", fileName), ct => Task.Run(() =>
+ bool loaded = false;
+
+ bool finished = await RunAsync(Loc.Get("Data.Busy.Loading", fileName), async ct =>
{
- try
+ await Task.Run(() =>
{
- // FileShare.ReadWrite: Excel keeps a workbook open for reading; the file can still be read while it is.
- if (xlsx)
+ try
{
- using (var names = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) sheets = XlsxReader.GetSheetNames(names);
- using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
- string? useSheet = sheet is not null && sheets.Contains(sheet) ? sheet : null;
- table = XlsxReader.Read(stream, new XlsxOptions { SheetName = useSheet, HasHeader = header });
- sheet = useSheet ?? sheets.FirstOrDefault();
+ // FileShare.ReadWrite: Excel keeps a workbook open for reading; the file can still be read while it is.
+ if (xlsx)
+ {
+ using (var names = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) sheets = XlsxReader.GetSheetNames(names);
+ using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ string? useSheet = sheet is not null && sheets.Contains(sheet) ? sheet : null;
+ table = XlsxReader.Read(stream, new XlsxOptions { SheetName = useSheet, HasHeader = header });
+ sheet = useSheet ?? sheets.FirstOrDefault();
+ }
+ else
+ {
+ string text = ReadText(path);
+ detected = CsvReader.DetectDelimiter(text);
+ table = CsvReader.Parse(text, new CsvOptions { Delimiter = delimiter, HasHeader = header });
+ }
+ ct.ThrowIfCancellationRequested();
}
- else
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
- string text = ReadText(path);
- detected = CsvReader.DetectDelimiter(text);
- table = CsvReader.Parse(text, new CsvOptions { Delimiter = delimiter, HasHeader = header });
+ error = Loc.Get("Data.Error.Read", fileName);
}
- ct.ThrowIfCancellationRequested();
- }
- catch (OperationCanceledException) { throw; }
- catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ error = Loc.Get("Data.Error.Format", fileName);
+ }
+ }, ct);
+
+ // Applying the result is part of the operation: IsBusy stays true until the table, the records and the mapping are
+ // all in place. Otherwise anyone who waits for "not busy" (a test, a caller chaining a second load) can see the new
+ // columns with no records yet, because the data set is rebuilt a few statements after the table is replaced.
+ if (error is null && table is { Columns.Count: 0 }) error = Loc.Get("Data.Error.Empty", fileName);
+ if (error is not null)
{
- error = Loc.Get("Data.Error.Read", fileName);
+ SetResult(error, true);
+ return;
}
- catch (Exception ex) when (ex is not OutOfMemoryException)
+
+ // Reloading the same file is not a change to the label; only a different file or sheet is.
+ bool settingsChanged = !string.Equals(SourcePath, path, StringComparison.OrdinalIgnoreCase) || (xlsx && SelectedSheet != sheet)
+ || _databaseSettings is not null;
+ _applying = true;
+ try
{
- error = Loc.Get("Data.Error.Format", fileName);
+ SourceHintIgnored = false;
+ SourcePath = path;
+ SelectedSheet = xlsx ? sheet : null;
+ // A file replaces a database source: the label must not keep pointing at a query it no longer uses.
+ _databaseSettings = null;
+ Database.DiscardReference();
+ SourceMode = DataSourceMode.File;
+ ShowDatabasePicker = false;
}
- }, ct));
- if (!finished) return false;
- if (error is null && table is { Columns.Count: 0 }) error = Loc.Get("Data.Error.Empty", fileName);
- if (error is not null)
- {
- SetResult(error, true);
- return false;
- }
-
- // Reloading the same file is not a change to the label; only a different file or sheet is.
- bool settingsChanged = !string.Equals(SourcePath, path, StringComparison.OrdinalIgnoreCase) || (xlsx && SelectedSheet != sheet)
- || _databaseSettings is not null;
- _applying = true;
- try
- {
- SourceHintIgnored = false;
- SourcePath = path;
- SelectedSheet = xlsx ? sheet : null;
- // A file replaces a database source: the label must not keep pointing at a query it no longer uses.
- _databaseSettings = null;
- Database.DiscardReference();
- SourceMode = DataSourceMode.File;
- ShowDatabasePicker = false;
- }
- finally { _applying = false; }
- SetTable(table, sheets, delimiter ?? detected);
- CurrentIndex = 0;
- SetResult(Loc.Get("Data.Loaded", fileName, table!.RowCount), false);
- if (settingsChanged) RaiseSettingsEdited();
- Rebind();
- RaisePreviewChanged();
- return true;
+ finally { _applying = false; }
+ SetTable(table, sheets, delimiter ?? detected);
+ CurrentIndex = 0;
+ SetResult(Loc.Get("Data.Loaded", fileName, table!.RowCount), false);
+ if (settingsChanged) RaiseSettingsEdited();
+ Rebind();
+ RaisePreviewChanged();
+ loaded = true;
+ });
+ return finished && loaded;
}
/// Reads a text file as UTF-8 (or UTF-16 with a byte order mark), falling back to Windows-1252, which is what Excel writes for "CSV".
@@ -1058,21 +1071,64 @@ private async Task PrintAllAsync()
///
/// The batch print shared by this window and the Print dialog, so both go through one tested path (serial numbers reserved first and confirmed
/// only for labels that reached the printer, print-time questions, jobs, partial and cancel handling).
+ ///
+ /// The printer's mode decides what leaves. A printer whose mode question was never answered prints nothing. In printer-language (ZPL) mode
+ /// the jobs are ZPL, exactly as before. In Windows-driver (picture) mode the same jobs (, cut like the ZPL
+ /// jobs) are drawn as pictures through : the design is pre-flighted once, before the batch
+ /// question, the print-time questions and any serial number reservation; no printer commands or status checks (~HS) are sent; and
+ /// the pages the driver reports as possibly printed decide which serial numbers stay used (an error that escapes the service keeps the whole
+ /// job's numbers used).
+ ///
///
/// The host window's single-operation runner (busy state and cancel); it shows its own message when the work is cancelled or crashes.
/// Receives 0-100 while jobs go out.
/// The Print dialog's copies per record, used when the copies setting is the label's own quantity; null keeps the label's own.
+ ///
+ /// The host's own picture-mode gate (the Print dialog's findings card and tick): given the target, the exact document that will be printed and
+ /// how many labels the whole batch prints (the ticked records times their copies, which the pre-flight refuses above its limit), it returns the
+ /// target to print to (carrying the acknowledgement) or null when printing must stop, having shown why itself. Null uses this window's gate:
+ /// one question listing the findings (OK acknowledges them, Cancel sends nothing).
+ ///
/// The result line to show, or null when nothing more is to be said (declined, cancelled, or the host already showed the outcome).
- internal async Task PrintBatchAsync(Func, Task> run, Action progress, int? labelCopies)
+ internal async Task PrintBatchAsync(Func, Task> run, Action progress, int? labelCopies,
+ Func>? driverGate = null)
{
+ // Whether this printer gets ZPL or a picture is the person's answer: without it nothing leaves, not even the question about the batch.
+ if (Printer.NeedsModeQuestion) return new BatchOutcome(Printer.PendingModeText(), true);
string? error = Snapshot(out var doc, out var ds, convertToPrinter: true, quantity: labelCopies);
PrinterTarget? target = null;
PrinterProfile? profile = null;
error ??= Printer.TryBuildTarget(out target);
error ??= Printer.TryBuildProfile(out profile, doc.Encoding);
if (error is not null) return new BatchOutcome(error, true);
- // The question says exactly what will leave: the ticked records, and how many labels that is once copies are counted.
+
+ var batchTarget = target!;
+ bool pictures = IsDriverTarget(batchTarget);
+ // The whole batch: the ticked records and how many labels that is once copies are counted. It is what the question below says, and in
+ // picture mode what the pre-flight limits (every copy is a page of its own in the Windows queue), so it is worked out before the gate.
long labels = CountLabels(ds, doc.Print.Quantity);
+ if (pictures)
+ {
+ // Picture mode: the design is checked once for the whole batch (a 500-record batch is not checked 500 times), before the batch
+ // question and the print-time questions. A label the driver cannot print is reported before anything is asked, and in the Print dialog
+ // the findings and the tick come first, so the batch question is asked once, after the tick. Nothing is reserved before this check
+ // passes (the serial numbers are reserved below). The very document checked here is the one every job prints: the service works the
+ // fingerprint out again from what it receives and refuses a job whose findings were not acknowledged.
+ if (driverGate is not null)
+ {
+ var gated = await driverGate(batchTarget, doc, labels);
+ if (gated is null) return null; // the host already showed why
+ batchTarget = gated;
+ }
+ else
+ {
+ var (accepted, refusal) = await ConfirmDriverFindingsAsync(run, batchTarget, doc, labels);
+ if (accepted is null) return refusal;
+ batchTarget = accepted;
+ }
+ }
+
+ // The question says exactly what will leave: the ticked records, and how many labels that is once copies are counted.
string question = ds.BatchCount < ds.RecordCount
? Loc.Get("Data.PrintAll.ConfirmSelected", ds.BatchCount, Printer.Summary, labels, ds.RecordCount)
: labels != ds.BatchCount
@@ -1084,6 +1140,9 @@ private async Task PrintAllAsync()
var report = new MergeReport();
int jobs = 0;
PrintError? failure = null;
+ // Picture mode only: the failed job and the service's result, kept to say which records may have printed and which record stopped it.
+ MergedPictureJob? failedPictureJob = null;
+ PrintResult? failedPictureResult = null;
bool cancelled = false;
// The run owns the serial-number reservation, so only labels that really reached the printer are confirmed. MergeBatch alone would
// count every label as used the moment it is generated, and a failed send would then burn the numbers of the labels after it.
@@ -1093,25 +1152,58 @@ private async Task PrintAllAsync()
serials = VariableJob.Begin(doc, ds.CreateBatchContext(), ds.BatchCount);
ds.Options.Job = serials;
}
- int confirmedThrough = 0; // record number of the last label that was sent, or may have been (a send in doubt counts as sent)
+ // Batch position (not row number: with ticked records the two differ) of the last label that was sent, or may have been (a send in doubt
+ // counts as sent). Both print modes confirm the serial numbers with it.
+ int confirmedThrough = 0;
var closing = new List();
- // The printer set-up (darkness, speed...) goes out once, in front of the first job.
- string setup = PrinterSetup.BuildSetupZpl(profile!);
+ // Printer language only. The printer set-up (darkness, speed...) goes out once, in front of the first job; a printer that prints pictures
+ // would print these commands as text, so picture mode never sends them.
+ string setup = pictures ? string.Empty : PrinterSetup.BuildSetupZpl(profile!);
// Raw ZPL imported from bytes must reach the printer byte for byte; collected once, not per record.
var rawBlocks = ByteExactBlocks.From(doc);
var progressReporter = new Progress(progress);
// Every job is checked before it goes out (paper running out halfway stops the batch cleanly); the printer is asked whether it
- // finished only once, after the last job, instead of waiting for each job to come out.
- var batchTarget = target!;
+ // finished only once, after the last job, instead of waiting for each job to come out. (A picture-mode target has every status check
+ // off already: ~HS is a ZPL command.)
var jobTarget = batchTarget.WithStatus(batchTarget.Status with { VerifyAfterPrinting = false });
var verification = new List();
bool ok = await run(Loc.Get("Data.Busy.Printing"), async ct =>
{
- int inFlight = 0; // last record of the job being sent right now
+ int inFlight = 0; // batch position of the last label of the job being sent right now
try
{
await Task.Run(async () =>
{
+ if (pictures)
+ {
+ foreach (var job in MergeBatch.JobLabels(doc, ds, report))
+ {
+ ct.ThrowIfCancellationRequested();
+ inFlight = job.ItemsThrough;
+ // Each record's values and copies, exactly as its ZPL would carry them: page i shows the serial number of label i.
+ var resolved = job.Labels.Select(l => new ResolvedLabel(l.Values, l.Copies)).ToList();
+ var result = await _service.PrintResolvedAsync(batchTarget, doc, resolved, ct);
+ // By key and arguments: two barcodes that cannot be drawn give two findings with the same key, and both must be read.
+ foreach (var note in result.Notices)
+ if (!verification.Any(v => v.MessageKey == note.MessageKey && v.Args.SequenceEqual(note.Args))) verification.Add(note);
+ if (!result.Succeeded)
+ {
+ failure = result.Error;
+ failedPictureJob = job;
+ failedPictureResult = result;
+ // The driver reports the pages that may have printed, which makes the ZPL rule ("a send that moved bytes keeps
+ // its numbers") exact: every record with a page out keeps its number, including one whose copies were cut short
+ // (the safe side), and the records after it get theirs back. No page out: the whole job's numbers go back.
+ if (result.PagesSent > 0) confirmedThrough = LastLabelOut(job.Labels, result.PagesSent).Label.ItemNumber;
+ break;
+ }
+ confirmedThrough = inFlight;
+ inFlight = 0;
+ jobs++;
+ ((IProgress)progressReporter).Report(100.0 * report.Processed / ds.BatchCount);
+ }
+ return;
+ }
foreach (var job in MergeBatch.JobBlocks(doc, ds, report))
{
ct.ThrowIfCancellationRequested();
@@ -1139,13 +1231,22 @@ await Task.Run(async () =>
// Cancelled while a job was on its way: it may have printed, so its numbers are not given back.
if (inFlight > 0) confirmedThrough = inFlight;
}
+ catch (Exception) when (pictures && inFlight > 0)
+ {
+ // Picture mode only. The print service lets a few errors through (out of memory) instead of reporting them, possibly after earlier
+ // driver documents of the same call were accepted, and then the count of their pages never comes back. Every number of the job in
+ // flight stays used (the safe side); the error goes on to the runner, which reports it, after the finally below confirmed them.
+ confirmedThrough = inFlight;
+ throw;
+ }
finally
{
// Always confirms (also when the run failed or was cancelled), so a reservation is never left hanging.
if (serials is not null) closing.AddRange(serials.Complete(confirmedThrough));
}
- // After the numbers are confirmed: a fault reported now leaves them used, because the labels may have printed.
- if (failure is null && !cancelled && jobs > 0 && batchTarget.Status.VerifyAfterPrinting)
+ // After the numbers are confirmed: a fault reported now leaves them used, because the labels may have printed. Never in picture mode:
+ // the check is a ZPL command (~HS) the printer would print as text.
+ if (failure is null && !cancelled && jobs > 0 && !pictures && batchTarget.Status.VerifyAfterPrinting)
verification.AddRange(await _service.VerifyPrintedAsync(batchTarget, ct));
});
if (!ok) return null;
@@ -1153,8 +1254,10 @@ await Task.Run(async () =>
string notes = string.Join(" ", closing.Select(Loc.FormatIssue).Concat(verification.Select(PrintMessages.DescribeNotice)));
if (failure is not null)
{
- return new BatchOutcome(PrintMessages.Describe(failure) + " " + Loc.Get("Data.Batch.Partial", jobs) + (notes.Length > 0 ? " " + notes : string.Empty),
- failure.Kind != PrintErrorKind.Cancelled);
+ string headline = failedPictureJob is not null && failedPictureResult is not null
+ ? DescribePictureFailure(failedPictureJob, failedPictureResult, jobs)
+ : PrintMessages.Describe(failure) + " " + Loc.Get("Data.Batch.Partial", jobs);
+ return new BatchOutcome(headline + (notes.Length > 0 ? " " + notes : string.Empty), failure.Kind != PrintErrorKind.Cancelled);
}
if (cancelled)
return new BatchOutcome(Loc.Get("Data.Batch.CancelledPartial", jobs) + (notes.Length > 0 ? " " + notes : string.Empty), false);
@@ -1166,6 +1269,143 @@ await Task.Run(async () =>
/// The result line of a batch print or export, and whether it reports a problem.
internal readonly record struct BatchOutcome(string Text, bool IsError);
+ /// Prefix of the message keys of the picture renderer: a label that could not be drawn for one record's values.
+ private const string RenderKeyPrefix = "Driver.Render.";
+
+ /// True for a target printed as pictures through its Windows driver; every other target gets ZPL.
+ private static bool IsDriverTarget(PrinterTarget target) => target is { Transport: PrinterTransport.Spooler, Mode: PrinterOutputMode.WindowsDriver };
+
+ ///
+ /// This window's picture-mode gate, used when the host has none of its own: runs the driver pre-flight on (the very
+ /// document every job will print) and lets the batch through only when nothing stands in the way. An error finding never prints and is
+ /// reported; warnings are listed in one question, where OK is the acknowledgement of exactly those findings and Cancel sends nothing.
+ ///
+ /// The target to print to (carrying the acknowledgement when warnings were accepted), or no target and the result to show (null when
+ /// nothing more is to be said: the person declined, or the runner already reported a stop or a failure).
+ private async Task<(PrinterTarget? Target, BatchOutcome? Refusal)> ConfirmDriverFindingsAsync(Func, Task> run,
+ PrinterTarget target, LabelDocument doc, long labelCount)
+ {
+ DriverPreflightResult? preflight = null;
+ // Through the runner: asking a driver can take a moment, and the person can stop it like the batch itself. The count is the whole batch,
+ // so a job with too many labels is refused here, as an error, before anything is asked or drawn.
+ bool finished = await run(Loc.Get("Print.Busy.Checking"), async ct => preflight = await _service.PreflightAsync(target, doc, labelCount, ct));
+ if (!finished || preflight is null) return (null, null);
+ if (preflight.Findings.Count == 1 && preflight.Findings[0].MessageKey == DriverPreflight.Cancelled)
+ {
+ // The person stopped the check before the driver answered: a calm stop, not a list of problems to fix (as in the Print dialog).
+ return (null, new BatchOutcome(Loc.Get("Common.OperationCancelled"), false));
+ }
+ if (preflight.HasErrors)
+ {
+ var errors = preflight.Findings.Where(f => f.Severity == ValidationSeverity.Error).ToList();
+ return (null, new BatchOutcome(Loc.Get("Data.Driver.CannotPrint") + Environment.NewLine + DriverPreflightGate.Describe(errors), true));
+ }
+ if (!preflight.NeedsAcknowledgement) return (target, null);
+ // The findings stand between the sentence that introduces them and the one that says what OK and Cancel do, one finding per line.
+ string findings = Environment.NewLine + Environment.NewLine + DriverPreflightGate.Describe(preflight.Findings) + Environment.NewLine + Environment.NewLine;
+ if (!_messages.Confirm(DriverPreflightGate.Title, Loc.Get("Data.Driver.Confirm", findings))) return (null, null);
+ return (target.WithAcknowledgement(preflight.Fingerprint), null);
+ }
+
+ ///
+ /// The last label of a picture job with a page that may have printed, given (at least 1), and how many of its
+ /// copies those pages are. The service prints the labels in order, each as many pages in a row as it has copies (one page per copy), so the
+ /// label is the one that owns the last page out; fewer pages out than its copies means it printed only in part (a document boundary, or a
+ /// driver that refused a page, fell inside its copies). A page count past the end of the job, which a driver should never report, gives the
+ /// last label with all its copies, so the whole job counts as printed (the safe side).
+ ///
+ internal static (MergedLabel Label, long PagesOut) LastLabelOut(IReadOnlyList labels, long pagesSent)
+ {
+ long before = 0; // pages of the labels before this one; long, because one record may ask for up to 99,999,999 copies
+ foreach (var label in labels)
+ {
+ if (before + label.Copies >= pagesSent) return (label, pagesSent - before);
+ before += label.Copies;
+ }
+ return (labels[^1], labels[^1].Copies);
+ }
+
+ ///
+ /// The index of the first label of a picture job that the driver never received, given the pages that may have printed, or -1 when every
+ /// page went out. A label starts on the page after the copies of the labels before it.
+ ///
+ private static int FirstUnsentLabel(IReadOnlyList labels, long pagesSent)
+ {
+ long start = 0; // 0-based page on which the label starts
+ for (int i = 0; i < labels.Count; i++)
+ {
+ if (start >= pagesSent) return i;
+ start += labels[i].Copies;
+ }
+ return -1;
+ }
+
+ ///
+ /// The result line of a picture job that failed: what happened, how many jobs went out before it, and up to which record labels may have
+ /// printed (saying so when the last of them printed only in part). A record that could not be drawn (the renderer's errors, for example a
+ /// barcode value that cannot be encoded) is named when the data allows it. A job the service's own check refused (the driver now reports
+ /// other findings, or an error) gets a sentence that says so, never the Print dialog's "tick the box": in a batch, jobs may already be on
+ /// paper and the Data window has no box. The reason itself follows in the notes, worded like the Print dialog's findings.
+ ///
+ private static string DescribePictureFailure(MergedPictureJob job, PrintResult result, int jobsSent)
+ {
+ var error = result.Error!;
+ var parts = new List();
+ int firstUnsent = FirstUnsentLabel(job.Labels, result.PagesSent);
+ bool notDrawn = result.Notices.Any(n => n.MessageKey.StartsWith(RenderKeyPrefix, StringComparison.Ordinal));
+ if (error.Kind == PrintErrorKind.InvalidLabel && firstUnsent >= 0 && notDrawn)
+ {
+ // "The label has errors, so nothing was sent" would be false here: the labels before this record may be on paper.
+ var first = job.Labels[firstUnsent];
+ var culprit = FindNotDrawnRecord(job.Labels, firstUnsent, result.Notices);
+ parts.Add(culprit is not null
+ ? Loc.Get("Data.Driver.RecordNotDrawn", culprit.RecordNumber, first.RecordNumber)
+ : first.RecordNumber == job.LastRecordNumber
+ ? Loc.Get("Data.Driver.RecordNotDrawn", first.RecordNumber, first.RecordNumber)
+ : Loc.Get("Data.Driver.RecordsNotDrawn", first.RecordNumber, job.LastRecordNumber));
+ }
+ else if ((error.Kind is PrintErrorKind.NotAcknowledged or PrintErrorKind.InvalidLabel) && firstUnsent >= 0)
+ {
+ // The service checks every job again before it sends a page of it; this one was refused, so nothing from its first unsent record on
+ // went out. The texts for a single print ("Nothing was sent", "Tick the box") would be wrong here.
+ parts.Add(Loc.Get("Data.Driver.JobRefused", job.Labels[firstUnsent].RecordNumber));
+ }
+ else parts.Add(PrintMessages.Describe(result)!);
+ parts.Add(Loc.Get("Data.Batch.Partial", jobsSent));
+ // The person needs to know where to start again: the pages out of the failed job belong to the records up to this one, the last of them
+ // possibly cut short within its copies (its number stays used all the same).
+ if (result.PagesSent > 0)
+ {
+ var (last, pagesOut) = LastLabelOut(job.Labels, result.PagesSent);
+ parts.Add(pagesOut < last.Copies
+ ? Loc.Get("Data.Driver.PrintedInPart", last.RecordNumber, pagesOut, last.Copies)
+ : Loc.Get("Data.Driver.PrintedThrough", last.RecordNumber));
+ }
+ return string.Join(" ", parts);
+ }
+
+ ///
+ /// The record whose label could not be drawn, when the data says so beyond doubt: the renderer names the barcode value it could not encode,
+ /// and the record is named only when exactly one label from on holds exactly that value in one of its fields. A short
+ /// value such as "12" can also sit in another record's quantity or number column, and sending the person to correct the wrong record is
+ /// worse than naming the range. Null when the reason carries no value (an image, another part), when no label holds it (a value built from
+ /// several fields) or when several do; the caller then names the records it can be in.
+ ///
+ private static MergedLabel? FindNotDrawnRecord(IReadOnlyList labels, int from, IReadOnlyList notices)
+ {
+ var barcode = notices.FirstOrDefault(n => n.MessageKey == DevicePictureRenderer.BarcodeNotDrawn && n.Args.Count >= 2 && n.Args[1].Length > 0);
+ if (barcode is null) return null;
+ string value = barcode.Args[1];
+ MergedLabel? match = null;
+ for (int i = from; i < labels.Count; i++)
+ {
+ if (labels[i].Values is not { } values || !values.Values.Any(v => string.Equals(v, value, StringComparison.Ordinal))) continue;
+ if (match is not null) return null; // two records hold the value: which one failed cannot be told
+ match = labels[i];
+ }
+ return match;
+ }
+
private static string BatchSummary(string doneKey, MergeReport report, int units)
{
if (report.Emitted == 0) return Loc.Get("Data.Batch.NothingProduced");
diff --git a/src/Rotula.App/ViewModels/MainViewModel.cs b/src/Rotula.App/ViewModels/MainViewModel.cs
index 1ab4d95..8f625d2 100644
--- a/src/Rotula.App/ViewModels/MainViewModel.cs
+++ b/src/Rotula.App/ViewModels/MainViewModel.cs
@@ -101,7 +101,8 @@ public sealed record MainServices(
RotulaPolicy? Policy = null,
UpdateChecker? Updates = null,
IUrlLauncher? Links = null,
- TimeProvider? Clock = null);
+ TimeProvider? Clock = null,
+ IDebouncer? Debouncer = null);
///
/// View-model of the main window: the open label, its files, editing commands, view settings (zoom, unit, grid),
@@ -138,11 +139,22 @@ public MainViewModel(MainServices services, EditorSession? session = null)
Variables = services.Variables ?? new VariableServices(null, services.Dialogs);
Properties = new PropertyPanelViewModel(Session, ChooseImageFor, () => Variables.TextRasterizer);
Preview = new PreviewCache(Session);
+ // The editable ZPL panel: it re-imports typed text into the session and asks for a fresh text through RefreshZplView.
+ ZplSource = new ZplSourceViewModel(Session, services.Debouncer ?? new DispatcherDebouncer(), () => Preview.Data, RefreshZplView);
ConnectFormatting();
// Handles and selection frames are measured with the record shown on the canvas, not only the sample values.
Session.Bounds.DataProvider = () => Preview.Data;
Printer = new PrinterSettingsViewModel(services.Printing, _settings.Print, SaveSettings, () => Session.Document, applyLabelDpi: SetLabelDpi,
defaultPrinter: _policy.DefaultPrinter);
+ // The preview notice depends on the chosen printer's mode: in Windows-driver mode the picture on screen is the one that is printed. The
+ // printer view-model lives exactly as long as this one, so a plain subscription is fine.
+ Printer.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName != nameof(PrinterSettingsViewModel.IsDriverMode)) return;
+ OnPropertyChanged(nameof(ApproximateNotice));
+ OnPropertyChanged(nameof(ApproximateNoticeShort));
+ OnPropertyChanged(nameof(ApproximateNoticeName));
+ };
Data = new DataMergeViewModel(services.Printing, Printer, () => Session.Document, () => DocumentName, services.Files, services.Messages, services.Database, services.Dialogs, Variables);
Data.SettingsEdited += (_, _) => Session.MarkDirty();
Data.PreviewDataChanged += (_, _) => OnPreviewDataChanged();
@@ -229,6 +241,9 @@ private void ConnectFormatting()
/// Cache of the rendered preview.
public PreviewCache Preview { get; }
+ /// The editable ZPL source panel under the canvas.
+ public ZplSourceViewModel ZplSource { get; }
+
/// Printer and printer profile fields (shared by the Print dialog and the Data window).
public PrinterSettingsViewModel Printer { get; }
@@ -276,7 +291,7 @@ private void FillToolbox()
[ObservableProperty]
private string _title = "Rotula";
- /// The generated ZPL (read-only code view).
+ /// The ZPL generated from the design (with the shown record's values). The panel shows it through , which may hold edited text instead.
[ObservableProperty]
private string _zplText = string.Empty;
@@ -324,11 +339,20 @@ private void FillToolbox()
[ObservableProperty]
private int _errorCount;
- /// The notice that the on-screen text preview is approximate.
- public string ApproximateNotice => Loc.Get("Preview.Approximate");
+ ///
+ /// How close the on-screen preview is to the print. For a printer in printer-language (ZPL) mode the preview is approximate (the printer
+ /// draws text with its own fonts). In Windows-driver (picture) mode the preview is the picture that is printed, except the parts the Print
+ /// dialog lists before printing: raw printer commands, parts the driver cannot draw and certification marks are left out of the print, and a
+ /// label that does not match the paper is centred on it. The note says so, and still says that text uses Windows fonts instead of the
+ /// printer's own fonts.
+ ///
+ public string ApproximateNotice => Loc.Get(Printer.IsDriverMode ? "Preview.DriverNote" : "Preview.Approximate");
+
+ /// Short wording of for the status bar (the full text is its tooltip).
+ public string ApproximateNoticeShort => Loc.Get(Printer.IsDriverMode ? "Preview.DriverNote.Short" : "Preview.Approximate.Short");
- /// Short wording of the approximate-preview notice for the status bar (the full text is its tooltip).
- public string ApproximateNoticeShort => Loc.Get("Preview.Approximate.Short");
+ /// Name of the status-bar notice for screen readers.
+ public string ApproximateNoticeName => Loc.Get(Printer.IsDriverMode ? "Preview.DriverNote.Name" : "Preview.Approximate.Name");
/// Current zoom.
[ObservableProperty]
@@ -705,7 +729,7 @@ private void NotifyPresentation()
/// Opens a larger preview of the label (what the printer will draw, with the shown record's values).
[RelayCommand]
- private void ShowPreview() => _svc.Dialogs.ShowPreviewDialog(Preview, DocumentName);
+ private void ShowPreview() => _svc.Dialogs.ShowPreviewDialog(Preview, DocumentName, ApproximateNotice);
/// Reads the installed printers again (the toolbar drop-down and the dialogs share the list).
[RelayCommand]
@@ -720,6 +744,7 @@ private void RefreshZplView()
_zplResult = ZplGenerator.Generate(Session.Document, null, Variables.TextRasterizer);
var view = Preview.Data is null ? _zplResult : ZplGenerator.Generate(Session.Document, Preview.Data, Variables.TextRasterizer);
ZplText = view.Zpl;
+ ZplSource.SetGenerated(view.Zpl);
Properties.SetIssues(view.Issues);
ErrorCount = view.Issues.Count(i => i.Severity == ValidationSeverity.Error);
int warnings = view.Issues.Count - ErrorCount;
@@ -730,6 +755,7 @@ private void RefreshZplView()
// The ZPL view must never take the editor down; say what happened instead.
_zplResult = null;
ZplText = string.Empty;
+ ZplSource.SetGenerated(string.Empty);
ProblemsText = Loc.Get("Status.ZplFailed");
}
}
@@ -762,8 +788,10 @@ private void OnLanguageChanged()
FillToolbox();
OnDocumentChanged();
OnSelectionChanged();
+ ZplSource.OnLanguageChanged();
OnPropertyChanged(nameof(ApproximateNotice));
OnPropertyChanged(nameof(ApproximateNoticeShort));
+ OnPropertyChanged(nameof(ApproximateNoticeName));
OnPropertyChanged(nameof(DataRecordText));
OnPropertyChanged(nameof(LanguageSetting));
OnPropertyChanged(nameof(IsLanguageAuto));
@@ -1112,6 +1140,26 @@ private void OpenPrintStation()
[RelayCommand]
private void CreateStationPin() => _svc.Dialogs.ShowStationPinTool(new StationPinViewModel(_svc.Clipboard));
+ ///
+ /// Opens "Set up print station" for a folder you pick: every label of the station is checked with the printer it uses on this PC, and the
+ /// findings of a printer in Windows-driver (picture) mode are accepted once for the operators. The tool reads the designer's printer
+ /// settings (the printer of labels without one, and the saved print modes) and never changes them.
+ ///
+ [RelayCommand]
+ private void SetUpPrintStation()
+ {
+ string? folder = _svc.Files.PickFolder(Loc.Get("Station.PickFolder"));
+ if (folder is null) return;
+ var load = Rotula.Files.Station.StationConfigLoader.Load(folder);
+ if (!load.CanOpen)
+ {
+ _svc.Messages.ShowError(Loc.Get("Station.Open.Failed"), StationViewModel.DescribeIssues(load.Issues));
+ return;
+ }
+ // The folder, not the file: "Check again" loads the folder again, so a station.json created or changed meanwhile is read.
+ _svc.Dialogs.ShowStationSetup(new StationSetupViewModel(load.Config!, _svc.Printing, _settings.Print, stationPath: folder));
+ }
+
/// Opens the Print dialog (printer, printer profile, copies, test label, save as ZPL).
[RelayCommand]
private void Print()
@@ -1196,12 +1244,13 @@ private async Task OpenDataFileAsync()
/// Record navigator text for the toolbar ("Record 3 of 120").
public string DataRecordText => Data.RecordText;
- /// Copies the ZPL text to the clipboard.
+ /// Copies the ZPL text shown in the panel (generated, or as typed by the user) to the clipboard.
[RelayCommand]
private void CopyZpl()
{
- if (ZplText.Length == 0) return;
- _svc.Clipboard.SetText(ZplText);
+ string text = ZplSource.Text.Length > 0 ? ZplSource.Text : ZplText;
+ if (text.Length == 0) return;
+ _svc.Clipboard.SetText(text);
StatusMessage = Loc.Get("Status.ZplCopied");
}
@@ -1339,6 +1388,14 @@ private void Align(AlignKind kind)
ReportLocked();
}
+ /// Align the selection to the label itself, whatever its size (the parameter is an ).
+ [RelayCommand(CanExecute = nameof(HasSelection))]
+ private void AlignToLabel(AlignKind kind)
+ {
+ Commands.AlignToLabel(kind);
+ ReportLocked();
+ }
+
/// Distribute the selection (the parameter is a ).
[RelayCommand(CanExecute = nameof(HasThree))]
private void Distribute(DistributeKind kind)
diff --git a/src/Rotula.App/ViewModels/OperationViewModel.cs b/src/Rotula.App/ViewModels/OperationViewModel.cs
index f3786c2..dfb5dbf 100644
--- a/src/Rotula.App/ViewModels/OperationViewModel.cs
+++ b/src/Rotula.App/ViewModels/OperationViewModel.cs
@@ -21,6 +21,15 @@ public abstract partial class OperationViewModel : ObservableObject
/// The opposite of , for binding to IsEnabled.
public bool IsIdle => !IsBusy;
+ partial void OnIsBusyChanged(bool value) => OnBusyChanged(value);
+
+ ///
+ /// Called when changes, so a derived view model can refresh what it derives from it (a "can do this"
+ /// property bound to a button). The generated hook is private to this class, hence the virtual.
+ ///
+ /// The new value.
+ protected virtual void OnBusyChanged(bool busy) { }
+
/// What the running operation is doing, for example "Sending to the printer...".
[ObservableProperty]
private string _busyText = string.Empty;
diff --git a/src/Rotula.App/ViewModels/PrintMessages.cs b/src/Rotula.App/ViewModels/PrintMessages.cs
index c5afb5c..a3f51f3 100644
--- a/src/Rotula.App/ViewModels/PrintMessages.cs
+++ b/src/Rotula.App/ViewModels/PrintMessages.cs
@@ -1,4 +1,5 @@
using Rotula.App.Localization;
+using Rotula.App.Services;
using Rotula.Core.Validation;
using Rotula.Printing;
@@ -21,6 +22,15 @@ public static string Describe(PrintError error)
// The detail differs per transport (queue, code, host...), so it is joined into one argument.
args = new object[] { string.Join(", ", error.Args.Where(a => a.Length > 0)) };
break;
+ case PrintErrorKind.DriverRefused:
+ // Arguments: the printer, then the driver call that failed with its Windows error code. The text does not claim that nothing
+ // was sent: pages the driver accepted before it refused one may still print.
+ return Loc.Get(error.Key, error.Args.Count > 0 ? error.Args[0] : string.Empty, error.Args.Count > 1 ? error.Args[1] : string.Empty);
+ case PrintErrorKind.InvalidLabel or PrintErrorKind.NotAcknowledged:
+ // The arguments are message keys only (Core validation keys, or the picture-mode Driver.* finding keys), without the names and
+ // sizes those sentences need. The full findings travel as the result's notices, which the caller lists below this sentence
+ // (DescribeNotices), so the sentence itself takes no arguments.
+ return Loc.Get(error.Key);
case PrintErrorKind.HeadOpen or PrintErrorKind.PaperOut or PrintErrorKind.RibbonOut or PrintErrorKind.HeadTooHot
or PrintErrorKind.DiagnosticMode or PrintErrorKind.BufferFull or PrintErrorKind.Paused:
{
@@ -35,8 +45,21 @@ public static string Describe(PrintError error)
return Loc.Instance.HasKey(error.Key) ? Loc.Get(error.Key, args) : Loc.Get("Print.Error.Unknown", string.Join(", ", error.Args));
}
- /// Describes a print result: null on success.
- public static string? Describe(PrintResult result) => result.Error is null ? null : Describe(result.Error);
+ ///
+ /// Describes a print result: null on success. Use this rather than whenever the result is at hand: one case
+ /// needs more than the error. A label with errors that stopped a picture-mode job after the driver had already taken pages
+ /// ( above 0: a later label could not be resolved or drawn) gets
+ /// Print.Error.InvalidLabel.Partial with that count, because "nothing was sent" would be false and would invite printing those labels
+ /// twice. The printer-language path never counts pages, so its texts are unchanged.
+ ///
+ public static string? Describe(PrintResult result)
+ {
+ ArgumentNullException.ThrowIfNull(result);
+ if (result.Error is null) return null;
+ if (result.Error.Kind == PrintErrorKind.InvalidLabel && result.PagesSent > 0)
+ return Loc.Get("Print.Error.InvalidLabel.Partial", result.PagesSent);
+ return Describe(result.Error);
+ }
///
/// Lists every note a print result carries (skipped serial numbers, a counter file problem, the errors that stopped a label), one per line;
@@ -47,11 +70,13 @@ public static string DescribeNotices(PrintResult result) =>
///
/// One note of a print result. The printer status notes carry condition and reason names, which are translated here into a phrase;
- /// every other note goes through the usual issue text.
+ /// the picture-mode findings (pre-flight and renderer, Driver.* keys) are worded by exactly as in
+ /// the Print dialog's findings list; every other note goes through the usual issue text.
///
public static string DescribeNotice(ValidationIssue notice)
{
ArgumentNullException.ThrowIfNull(notice);
+ if (notice.MessageKey.StartsWith("Driver.", StringComparison.Ordinal)) return DriverPreflightGate.FormatFinding(notice);
string? arg = notice.MessageKey switch
{
PrinterStatusKeys.Warning or PrinterStatusKeys.FaultAfterJob => Conditions(notice.Args),
diff --git a/src/Rotula.App/ViewModels/PrintViewModel.cs b/src/Rotula.App/ViewModels/PrintViewModel.cs
index 52960d6..281b0d2 100644
--- a/src/Rotula.App/ViewModels/PrintViewModel.cs
+++ b/src/Rotula.App/ViewModels/PrintViewModel.cs
@@ -1,3 +1,4 @@
+using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -17,6 +18,11 @@ namespace Rotula.App.ViewModels;
/// The Print dialog: choose where to print and how the printer is set up, print the label (or a test label), or save the
/// ZPL to a file. Sending is async and cancellable, so the window never freezes. When a data file or database is loaded, this dialog is also where
/// the operator picks the records (the same ticks as in the Data merge window) and prints them as a batch.
+///
+/// For a printer in Windows-driver (picture) mode, printing first runs the driver pre-flight on the exact document that will be printed and
+/// lists its findings (). An error finding stops the job; warnings need one tick () that
+/// belongs to exactly those findings. A printer in printer-language (ZPL) mode never gets a pre-flight: it prints exactly as before.
+///
///
public sealed partial class PrintViewModel : OperationViewModel
{
@@ -28,6 +34,15 @@ public sealed partial class PrintViewModel : OperationViewModel
private readonly IVariableServices _variables;
private readonly Action? _showSheets;
+ /// Fingerprint of the findings on screen (what the person can tick for), or null when none are shown.
+ private string? _shownFingerprint;
+
+ /// The printer, transport and mode the shown findings were worked out for, or null when none are shown.
+ private PrinterChoice? _findingsFor;
+
+ /// What a pre-flight depends on besides the design: which printer, by which transport, in which mode.
+ private readonly record struct PrinterChoice(bool UseNetwork, string? PrinterName, PrinterOutputMode Mode);
+
/// Creates the dialog's view-model.
/// Printing service (a fake in tests; tests never reach a real printer).
/// Printer and profile fields, shared with the data merge window.
@@ -58,13 +73,175 @@ public PrintViewModel(IPrintService service, PrinterSettingsViewModel printer, F
RefreshLabelProblems();
// A plain subscription, removed by Detach when the dialog closes: the data view-model outlives this dialog and must not keep it alive.
if (records is not null) records.PropertyChanged += OnRecordsChanged;
+ // Same for the printer view-model, which belongs to the main window: the mode line follows it and a new printer or mode drops the findings.
+ printer.PropertyChanged += OnPrinterChanged;
PropertyChanged += (_, e) => { if (e.PropertyName == nameof(IsBusy)) OnPropertyChanged(nameof(CanPrint)); };
}
- /// Stops listening to the data view-model; called when the dialog closes.
+ /// Stops listening to the data and printer view-models (both outlive this dialog); called when the dialog closes.
public void Detach()
{
if (Records is not null) Records.PropertyChanged -= OnRecordsChanged;
+ Printer.PropertyChanged -= OnPrinterChanged;
+ }
+
+ private void OnPrinterChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(PrinterSettingsViewModel.ModeExplanation)) OnPropertyChanged(nameof(ModeExplanation));
+ // Findings belong to one printer in one mode. Two printers can report identical findings, so the tick is dropped on any change of
+ // printer, transport or mode, not only when the fingerprint differs. Comparing the choice, instead of clearing on every notification,
+ // keeps the findings through notifications that change nothing about it (a language switch, for example).
+ if (_findingsFor is { } shownFor && shownFor != CurrentChoice()) ClearPreflight();
+ }
+
+ private PrinterChoice CurrentChoice() => new(Printer.UseNetwork, Printer.SelectedPrinterName, Printer.Mode);
+
+ // ------------------------------------------------------------------------------- Windows-driver (picture) mode
+
+ ///
+ /// The findings of the last driver pre-flight, one sentence each (worded by ), errors and warnings in the
+ /// order the library reported them. Empty for a printer in printer-language mode, before the first print, and after a change of printer or mode.
+ ///
+ public ObservableCollection PreflightLines { get; } = new();
+
+ /// True when has something to show (the findings list appears only then).
+ public bool HasPreflightLines => PreflightLines.Count > 0;
+
+ private bool _needsAcknowledgement;
+
+ ///
+ /// True when the shown findings are warnings only, so the job may print once is ticked (the box appears only then).
+ /// False when any finding is an error: no tick can let such a label through.
+ ///
+ public bool NeedsAcknowledgement
+ {
+ get => _needsAcknowledgement;
+ private set => SetProperty(ref _needsAcknowledgement, value);
+ }
+
+ ///
+ /// "I understand and want to print anyway": the person accepts the shown warnings for this job. It belongs to exactly those findings: when a
+ /// later pre-flight finds anything else (the design, the printer or the mode changed) it is cleared and has to be ticked again.
+ ///
+ [ObservableProperty]
+ private bool _acknowledged;
+
+ /// One plain sentence: which print mode the chosen printer uses and why (see ).
+ public string ModeExplanation => Printer.ModeExplanation;
+
+ /// True for a target printed as a picture through its Windows driver; every other target sends ZPL and is never pre-flighted.
+ private static bool IsDriverTarget(PrinterTarget target) => target is { Transport: PrinterTransport.Spooler, Mode: PrinterOutputMode.WindowsDriver };
+
+ ///
+ /// The picture-mode gate in front of a print. For a printer-language target it returns at once and asks nothing,
+ /// so a ZPL print is exactly what it was. For a driver target it runs on
+ /// , which must be the very document that is then printed (after copies and any conversion): the service works
+ /// the fingerprint out again from what it receives, and a different document would be refused as not acknowledged.
+ /// is how many labels the whole job prints (copies included; for ticked records the whole batch): more than the pre-flight's limit is an error
+ /// finding, shown and worded like any other, and nothing is printed.
+ ///
+ ///
+ /// The target to print to (carrying the acknowledgement when warnings were ticked), or null when printing must stop; the reason is then
+ /// already shown as the result.
+ ///
+ private async Task GateDriverAsync(PrinterTarget target, LabelDocument document, long labelCount)
+ {
+ if (!IsDriverTarget(target)) return target;
+ DriverPreflightResult? preflight = null;
+ // Inside RunAsync: asking a driver can take a moment, and the person can stop it like any print.
+ bool finished = await RunAsync(Loc.Get("Print.Busy.Checking"), async ct => preflight = await _service.PreflightAsync(target, document, labelCount, ct));
+ // Not finished: RunAsync already showed "stopped" or the failure.
+ if (!finished || preflight is null) return null;
+ if (preflight.Findings.Count == 1 && preflight.Findings[0].MessageKey == DriverPreflight.Cancelled)
+ {
+ // The person stopped the check before the driver answered: a calm stop, not a list of problems to fix.
+ SetResult(Loc.Get("Common.OperationCancelled"), false);
+ return null;
+ }
+ ShowFindings(preflight);
+ if (preflight.HasErrors)
+ {
+ SetResult(ErrorResult(preflight.Findings.Where(f => f.Severity == ValidationSeverity.Error).ToList()), true);
+ return null;
+ }
+ if (!preflight.NeedsAcknowledgement) return target;
+ if (!Acknowledged)
+ {
+ SetResult(Loc.Get("Print.Driver.TickToContinue"), true);
+ return null;
+ }
+ // ShowFindings cleared the tick if these findings differ from the ones it was given for, so this is the fingerprint the person accepted.
+ return target.WithAcknowledgement(preflight.Fingerprint);
+ }
+
+ /// Most error findings written out in the result line; the findings card above lists every one.
+ private const int MaxErrorLinesInResult = 2;
+
+ ///
+ /// The result line for error findings: the first of them, then how many more the findings card lists.
+ /// The result banner is docked under the scrolled content and never scrolls, so a label with many unprintable barcodes (one sentence each)
+ /// would otherwise squeeze the scrolled part (the findings, the printer and the way back to ZPL) down to nothing at the minimum window size.
+ ///
+ private static string ErrorResult(IReadOnlyList errors)
+ {
+ var lines = DriverPreflightGate.Lines(errors.Take(MaxErrorLinesInResult)).ToList();
+ if (errors.Count > MaxErrorLinesInResult) lines.Add(Loc.Get("Print.Driver.MoreInList", errors.Count - MaxErrorLinesInResult));
+ return string.Join(Environment.NewLine, lines);
+ }
+
+ /// Shows the findings of a pre-flight, and drops the tick when they are not the findings it was given for.
+ private void ShowFindings(DriverPreflightResult preflight)
+ {
+ string fingerprint = preflight.Fingerprint;
+ if (!string.Equals(fingerprint, _shownFingerprint, StringComparison.Ordinal))
+ {
+ Acknowledged = false;
+ _shownFingerprint = fingerprint;
+ }
+ _findingsFor = CurrentChoice();
+ PreflightLines.Clear();
+ foreach (string line in DriverPreflightGate.Lines(preflight.Findings)) PreflightLines.Add(line);
+ NeedsAcknowledgement = preflight.NeedsAcknowledgement && !preflight.HasErrors;
+ OnPropertyChanged(nameof(HasPreflightLines));
+ }
+
+ /// Forgets the shown findings and the tick (another printer or mode was chosen).
+ private void ClearPreflight()
+ {
+ _shownFingerprint = null;
+ _findingsFor = null;
+ PreflightLines.Clear();
+ Acknowledged = false;
+ NeedsAcknowledgement = false;
+ OnPropertyChanged(nameof(HasPreflightLines));
+ }
+
+ ///
+ /// True when the chosen printer's mode is settled. A Windows printer that never answered "How does this printer understand labels?" does
+ /// not print in either mode (the answer decides what is sent); the result then points at the question, or, for a queue that is not
+ /// installed (no question is shown for it), says to refresh the list or choose another printer.
+ ///
+ private bool ModeSettled()
+ {
+ if (!Printer.NeedsModeQuestion) return true;
+ SetResult(Printer.PendingModeText(), true);
+ return false;
+ }
+
+ ///
+ /// The profile the test label is built from, exactly as would build it: when the profile asks
+ /// for the driver's detected settings, they are read once here and the result is passed on with the request switched off, so the service
+ /// builds the identical test label that was pre-flighted instead of reading the driver a second time. Null when the person stopped it.
+ /// The dialog's own profile does not ask for detected settings today ( leaves it off,
+ /// the resolution box already shows what the driver reported); this keeps the check on the right label if that ever changes.
+ ///
+ private async Task ResolveTestProfileAsync(PrinterTarget target, PrinterProfile profile)
+ {
+ if (!profile.UseDetectedSettings) return profile;
+ PrinterProfile? resolved = null;
+ bool finished = await RunAsync(Loc.Get("Print.Busy.Checking"), async ct =>
+ resolved = PrinterProfile.FromInfo(await _service.GetPrinterInfoAsync(target, ct), profile) with { UseDetectedSettings = false });
+ return finished ? resolved : null;
}
private void OnRecordsChanged(object? sender, PropertyChangedEventArgs e)
@@ -172,6 +349,8 @@ private bool TryPrepare(out PrinterTarget? target, out PrinterProfile? profile,
private async Task PrintAsync()
{
if (IsBusy) return;
+ // Before the records branch too: whether a printer gets ZPL or a picture is the person's answer, so nothing goes out without it.
+ if (!ModeSettled()) return;
if (HasRecords) { await PrintRecordsAsync(); return; }
if (!TryCopies(out int copies) || !TryPrepare(out var target, out var profile)) return;
RefreshLabelProblems();
@@ -188,17 +367,22 @@ private async Task PrintAsync()
if (converted is null) { SetResult(convertError!, true); return; }
doc = converted;
var data = _data();
+ // Picture mode: the pre-flight judges this final document (copies set, conversion done), the one handed to the service below, and comes
+ // before the questions so a label the driver cannot print does not ask them first. Printer language: returns the target untouched.
+ var sendTo = await GateDriverAsync(target!, doc, Math.Max(1, doc.Print.Quantity));
+ if (sendTo is null) return;
// Questions come before anything is reserved or sent; cancelling them cancels the print without a message (the operator chose it).
var answers = AskPrompts(doc, data);
if (answers is null) return;
var options = VariableServices.Options(_variables, answers);
- await SendAsync(Loc.Get("Print.Busy.Printing"), ct => _service.PrintAsync(target!, doc, data, profile, options, ct),
+ await SendAsync(Loc.Get("Print.Busy.Printing"), ct => _service.PrintAsync(sendTo, doc, data, profile, options, ct),
Loc.Get("Print.Done.Label", copies));
}
///
/// Prints the ticked records through the same path as the Data merge window's print button. The copies typed here are the copies of each
- /// record's label (unless the Data window sets copies per record itself).
+ /// record's label (unless the Data window sets copies per record itself). For a printer in picture mode the batch is checked once for the
+ /// design through this dialog's own gate (): its findings card and its one tick, never a second question on top.
///
private async Task PrintRecordsAsync()
{
@@ -211,7 +395,7 @@ private async Task PrintRecordsAsync()
SetResult(Loc.Get("Print.Error.InvalidLabel") + Environment.NewLine + LabelProblems, true);
return;
}
- var outcome = await Records.PrintBatchAsync(RunAsync, p => Progress = p, copies);
+ var outcome = await Records.PrintBatchAsync(RunAsync, p => Progress = p, copies, GateDriverAsync);
if (outcome is { } o) SetResult(o.Text, o.IsError);
}
@@ -220,8 +404,21 @@ private async Task PrintRecordsAsync()
private async Task PrintTestAsync()
{
if (IsBusy) return;
+ if (!ModeSettled()) return;
if (!TryPrepare(out var target, out var profile)) return;
- await SendAsync(Loc.Get("Print.Busy.Test"), ct => _service.PrintTestLabelAsync(target!, profile!, ct), Loc.Get("Print.Done.Test"));
+ var sendTo = target!;
+ if (IsDriverTarget(sendTo))
+ {
+ // The service builds the test label itself, so the same label is built here from the same profile and pre-flighted; anything else
+ // would give another fingerprint and the service would refuse the job as not acknowledged.
+ var testProfile = await ResolveTestProfileAsync(sendTo, profile!);
+ if (testProfile is null) return;
+ var gated = await GateDriverAsync(sendTo, TestLabel.Build(testProfile), 1);
+ if (gated is null) return;
+ sendTo = gated;
+ profile = testProfile;
+ }
+ await SendAsync(Loc.Get("Print.Busy.Test"), ct => _service.PrintTestLabelAsync(sendTo, profile!, ct), Loc.Get("Print.Done.Test"));
}
private async Task SendAsync(string busyText, Func> send, string successText)
@@ -239,7 +436,8 @@ private async Task SendAsync(string busyText, Func 0) text += Environment.NewLine + notes;
SetResult(text, result.Error!.Kind != PrintErrorKind.Cancelled);
}
diff --git a/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs b/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs
index b36e827..f399f6e 100644
--- a/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs
+++ b/src/Rotula.App/ViewModels/PrinterSettingsViewModel.cs
@@ -101,6 +101,8 @@ partial void OnUseNetworkChanged(bool value)
OnPropertyChanged(nameof(CheckStatus));
ClearStatus();
NotifyToolbar();
+ // A network printer is always ZPL, a Windows printer has its own saved mode: switching transport can switch the mode.
+ NotifyMode();
RefreshDpiMismatch();
}
@@ -111,6 +113,8 @@ partial void OnSelectedPrinterNameChanged(string? value)
{
NotifyToolbar();
ClearStatus();
+ // Each printer has its own saved mode (or none yet, which brings the question); DetectAsync refreshes the resolution note afterwards.
+ NotifyMode();
_ = DetectAsync();
}
partial void OnHostChanged(string value)
@@ -235,10 +239,18 @@ public bool CheckStatus
/// True when the status result is a problem (drives the amber note).
public bool HasStatusProblem => HasStatusText && StatusIsProblem;
+ ///
+ /// True when "Check printer status" may run: not in Windows-driver (picture) mode, where ~HS, a ZPL command, would be printed as text.
+ /// The view hides the button in that mode as well; the command is off on its own so a key binding or UI automation cannot send it either.
+ ///
+ public bool CanCheckPrinterStatus => !IsDriverMode;
+
/// Asks the chosen printer for its status (~HS) and shows the answer. A second click while waiting starts over.
- [RelayCommand]
+ [RelayCommand(CanExecute = nameof(CanCheckPrinterStatus))]
private async Task CheckPrinterStatusAsync()
{
+ // ExecuteAsync does not consult CanExecute, so the rule is enforced here too: in picture mode nothing is asked and nothing is shown.
+ if (!CanCheckPrinterStatus) return;
string? error = TryBuildTarget(out var target);
if (error is not null)
{
@@ -279,6 +291,188 @@ private void ClearStatus()
StatusIsProblem = false;
}
+ // ------------------------------------------------------------------------------- print mode (Printer language or Windows driver)
+
+ ///
+ /// How the selected printer gets the label. A network printer is always : it has no Windows driver to
+ /// draw through. A Windows printer uses the mode saved for it. A Windows printer with no saved mode also prints as
+ /// , as every printer did before driver printing existed. A suggestion never switches a printer on its
+ /// own; the person answers first (see ).
+ /// Setting it stores the choice in the map for the selected Windows printer only. Other printers keep their own choice. Persist, or any save
+ /// of the settings, writes it to settings.json. It is ignored for a network printer or when no printer is selected.
+ ///
+ public PrinterOutputMode Mode
+ {
+ get => KnownMode() ?? PrinterOutputMode.PrinterLanguage;
+ set
+ {
+ if (StoreMode(value)) RefreshDpiMismatch();
+ // Raised even when nothing was stored, so a bound control that offered a refused value snaps back to the real one.
+ NotifyMode();
+ }
+ }
+
+ /// True when the mode of the selected printer is settled: a network printer (always ZPL) or a Windows printer with a saved mode.
+ public bool ModeKnown => KnownMode() is not null;
+
+ ///
+ /// The mode to pre-select in the question, read from the names Windows reports for the selected printer (queue, driver and port, through
+ /// ). Before the printer list is read only the queue name is known. Only a suggestion: it never becomes
+ /// until the person chooses it. Always for a network printer.
+ ///
+ public PrinterOutputMode SuggestedMode
+ {
+ get
+ {
+ string? name = SelectedPrinterName;
+ if (UseNetwork || string.IsNullOrWhiteSpace(name)) return PrinterOutputMode.PrinterLanguage;
+ var info = _printers.FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
+ return info is null
+ ? PrinterOutputModeSuggestion.For(name, string.Empty, string.Empty)
+ : PrinterOutputModeSuggestion.For(info.Name, info.DriverName, info.PortName);
+ }
+ }
+
+ ///
+ /// True when must be answered before anything is printed (the Print dialog, and batch printing from the Data
+ /// window or the Print dialog): a Windows printer is selected and has no saved mode. That is a printer never used with Rotula, a new
+ /// installation, or a printer added since. It is also a printer configured in this version whose question was never answered, for example
+ /// one only chosen in a window and never printed to. A printer configured by an earlier version is recorded as ZPL when the settings are
+ /// loaded, so its user is never asked. A network printer is never asked about.
+ ///
+ public bool NeedsModeQuestion => !UseNetwork && !string.IsNullOrWhiteSpace(SelectedPrinterName) && !ModeKnown;
+
+ ///
+ /// Why nothing may be printed yet while is true, in plain words: answer the question shown under the printer
+ /// name, or, for a queue that is not installed (no question is shown for it), refresh the list or choose another printer. Empty when the
+ /// mode is settled. The Print dialog and batch printing say the same sentence, so the person reads one instruction wherever they print.
+ ///
+ public string PendingModeText()
+ {
+ if (!NeedsModeQuestion) return string.Empty;
+ return ShowModeQuestion
+ ? Loc.Get("Print.Mode.AnswerFirst")
+ : Loc.Get("Print.Mode.PrinterNotListed", SelectedPrinterName ?? string.Empty);
+ }
+
+ /// True when the label is drawn as a picture and printed through the Windows driver (never for a network printer).
+ public bool IsDriverMode => Mode == PrinterOutputMode.WindowsDriver;
+
+ /// The one-time question for a printer with no saved mode ("How does this printer understand labels?").
+ public string ModeQuestion => Loc.Get("Print.Mode.Question");
+
+ /// One sentence that names and why it is suggested, shown with the question.
+ public string ModeSuggestionText => Loc.Get("Print.Mode.Suggested", ModeName(SuggestedMode));
+
+ /// One plain sentence for the Print dialog: which mode is in use and why.
+ public string ModeExplanation => Loc.Get(IsDriverMode ? "Print.Mode.Explain.WindowsDriver" : "Print.Mode.Explain.PrinterLanguage");
+
+ ///
+ /// True when the selected Windows printer is in the list Windows reported (). False before the list was read, when
+ /// discovery failed, or for a queue remembered from another session that is no longer installed.
+ ///
+ public bool IsSelectedPrinterListed =>
+ !string.IsNullOrWhiteSpace(SelectedPrinterName)
+ && _printers.Any(p => string.Equals(p.Name, SelectedPrinterName, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// True when the Print dialog shows with its two answers: the printer has no saved mode and is really installed.
+ /// For a queue that is not in the list the question would be about a printer Rotula cannot reach, so it stays hidden and printing says
+ /// to refresh the list or choose another printer instead.
+ ///
+ public bool ShowModeQuestion => NeedsModeQuestion && IsSelectedPrinterListed;
+
+ ///
+ /// True when the Print dialog shows the mode line ( and the control that switches to the other mode): a Windows
+ /// printer whose mode is settled. Never for a network printer: it is always printer language, so there is nothing to change.
+ ///
+ public bool ShowModeLine => !UseNetwork && !string.IsNullOrWhiteSpace(SelectedPrinterName) && ModeKnown;
+
+ /// True when is the Windows driver; the question then shows that answer as the main button.
+ public bool SuggestsDriver => SuggestedMode == PrinterOutputMode.WindowsDriver;
+
+ ///
+ /// The answer to : stores for the selected Windows printer and saves settings.json at
+ /// once. Saving at once means that closing the dialog without printing does not bring the question back next time. Ignored for a network
+ /// printer. The command is typed (IRelayCommand<PrinterOutputMode>), so the view passes {x:Static} values: a plain string
+ /// parameter is refused.
+ ///
+ [RelayCommand]
+ private void ChooseMode(PrinterOutputMode mode)
+ {
+ bool stored = StoreMode(mode);
+ NotifyMode();
+ if (!stored) return;
+ RefreshDpiMismatch();
+ _save();
+ }
+
+ /// The settled mode of the selection: ZPL for a network printer, the saved mode of a Windows printer, or null when none is saved.
+ private PrinterOutputMode? KnownMode() => UseNetwork ? PrinterOutputMode.PrinterLanguage : SavedMode(SelectedPrinterName);
+
+ /// The valid mode saved for , or null when there is none.
+ private PrinterOutputMode? SavedMode(string? printerName)
+ {
+ if (string.IsNullOrWhiteSpace(printerName) || !_settings.PrintModes.TryGetValue(printerName, out string? text)) return null;
+ return ParseMode(text);
+ }
+
+ ///
+ /// Reads a stored mode. Only the two names count (any letter case, for a hand-edited file). Enum.TryParse is not used: it also accepts
+ /// digits and comma lists, so "1" would pick the Windows driver nobody chose and "7" would give no mode at all. Anything else reads as "not
+ /// answered yet" instead of crashing.
+ ///
+ private static PrinterOutputMode? ParseMode(string? text)
+ {
+ string trimmed = text?.Trim() ?? string.Empty;
+ foreach (var mode in Enum.GetValues())
+ if (string.Equals(trimmed, mode.ToString(), StringComparison.OrdinalIgnoreCase)) return mode;
+ return null;
+ }
+
+ /// Writes into the map for the selected Windows printer. Returns false when nothing changed or nothing may be stored.
+ private bool StoreMode(PrinterOutputMode mode)
+ {
+ string? name = SelectedPrinterName;
+ // A network printer has no driver to draw through, so there is no choice to remember for it.
+ if (UseNetwork || string.IsNullOrWhiteSpace(name)) return false;
+ if (SavedMode(name) == mode) return false;
+ _settings.PrintModes[name] = mode.ToString();
+ return true;
+ }
+
+ /// The name shown for a mode: "Printer language (ZPL)" or "Windows driver (picture)".
+ private static string ModeName(PrinterOutputMode mode) =>
+ Loc.Get(mode == PrinterOutputMode.WindowsDriver ? "Print.Mode.WindowsDriver" : "Print.Mode.PrinterLanguage");
+
+ ///
+ /// Makes the saved map safe to use, through the same rule the settings store applies on load (see
+ /// ). The store has normally done it already, so this changes nothing then. It matters for
+ /// settings built in memory, such as the print station's copy. A settings object whose map was never present stands for a file from before
+ /// driver printing, and its configured printer is recorded as ZPL. A present but empty map is never seeded.
+ ///
+ private void NormalizeModeMap() => _settings.NormalizePrintModes();
+
+ /// Raises the change of every mode-related property; they are all computed from the selection and the map.
+ private void NotifyMode()
+ {
+ OnPropertyChanged(nameof(Mode));
+ OnPropertyChanged(nameof(ModeKnown));
+ OnPropertyChanged(nameof(SuggestedMode));
+ OnPropertyChanged(nameof(NeedsModeQuestion));
+ OnPropertyChanged(nameof(IsDriverMode));
+ OnPropertyChanged(nameof(ModeQuestion));
+ OnPropertyChanged(nameof(ModeSuggestionText));
+ OnPropertyChanged(nameof(ModeExplanation));
+ OnPropertyChanged(nameof(IsSelectedPrinterListed));
+ OnPropertyChanged(nameof(ShowModeQuestion));
+ OnPropertyChanged(nameof(ShowModeLine));
+ OnPropertyChanged(nameof(SuggestsDriver));
+ OnPropertyChanged(nameof(CanCheckPrinterStatus));
+ // The status check is off in picture mode; a button bound to the command greys out (or back) with the mode.
+ CheckPrinterStatusCommand.NotifyCanExecuteChanged();
+ }
+
// ------------------------------------------------------------------------------- driver detection
/// True while the driver is being read (the view shows a small progress bar).
@@ -334,7 +528,8 @@ private void ClearStatus()
///
/// Returns the label to send to the printer: the same object when its resolution already equals the chosen one, otherwise a
- /// converted copy that keeps the physical size and layout. The source is never modified.
+ /// converted copy that keeps the physical size and layout. The source is never modified. In picture mode () it is
+ /// always the same object: no ZPL resolution conversion happens.
///
/// The label (or a private copy of it).
/// Notes raised by the conversion; empty when nothing was converted.
@@ -344,6 +539,9 @@ private void ClearStatus()
{
issues = Array.Empty();
error = null;
+ // Picture mode draws the label at the driver's own resolution when it prints, so the ZPL dots conversion does not apply: the label goes
+ // as designed. The ZPL path below is untouched.
+ if (IsDriverMode) return source;
if (!LabelDpiConverter.NeedsConversion(source, SelectedDpi)) return source;
try
{
@@ -431,8 +629,9 @@ private void SwitchLabelDpi()
public void RefreshDpiMismatch()
{
var doc = _document();
- // While the driver is still being read the resolution box may hold a stale value, so say nothing yet.
- if (PrinterChosen && !IsDetecting && LabelDpiConverter.NeedsConversion(doc, SelectedDpi))
+ // While the driver is still being read the resolution box may hold a stale value, so say nothing yet. In picture mode nothing is
+ // converted (see ConvertForPrinter), so a ZPL resolution note would describe something that does not happen.
+ if (PrinterChosen && !IsDetecting && !IsDriverMode && LabelDpiConverter.NeedsConversion(doc, SelectedDpi))
{
DpiMismatchText = Loc.Get("Print.DpiConvert", (int)doc.Dpi, (int)SelectedDpi);
SwitchLabelDpiText = _applyLabelDpi is null ? string.Empty : Loc.Get("Print.SwitchLabelDpi", (int)SelectedDpi);
@@ -473,6 +672,8 @@ private static string FormatMeasure(Measure w, Measure h)
private void LoadFromSettings()
{
var s = _settings;
+ // Before the first-run branch: a printer the person answers for before ever printing must find a usable map too.
+ NormalizeModeMap();
if (!s.Configured)
{
// First run: start from the open label so width and resolution are sensible without typing anything.
@@ -554,6 +755,9 @@ private void RebuildPrinterDisplay()
Printers.Add(new PrinterItem(p.Name, display));
}
SelectedPrinterName = keep;
+ // A fresh list brings the driver and port names the suggestion reads, and a language change brings new texts; the selection itself may
+ // not have changed, so its change handler would not raise these.
+ NotifyMode();
}
/// Parses a number the way people type it (current culture first, then invariant).
@@ -572,15 +776,17 @@ private static bool TryParseDouble(string text, out double value) =>
if (string.IsNullOrWhiteSpace(Host)) return Loc.Get("Print.Field.NoHost");
if (!int.TryParse(PortText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int port) || port is < 1 or > 65535)
return Loc.Get("Print.Field.Port");
- target = PrinterTarget.TcpIp(Host.Trim(), port).WithStatus(StatusOptions);
+ // WithMode keeps a TCP target on ZPL whatever is asked; Mode is ZPL for a network printer anyway.
+ target = PrinterTarget.TcpIp(Host.Trim(), port).WithStatus(StatusOptions).WithMode(Mode);
return null;
}
if (string.IsNullOrWhiteSpace(SelectedPrinterName)) return Loc.Get("Print.Field.NoPrinter");
- target = PrinterTarget.Spooler(SelectedPrinterName).WithStatus(StatusOptions);
+ target = PrinterTarget.Spooler(SelectedPrinterName).WithStatus(StatusOptions).WithMode(Mode);
return null;
}
- private PrinterStatusOptions StatusOptions => CheckStatus ? PrinterStatusOptions.Default : PrinterStatusOptions.Off;
+ // ~HS is a ZPL command. A printer in picture mode does not understand it and would print it as text, so picture mode never asks around a job.
+ private PrinterStatusOptions StatusOptions => CheckStatus && !IsDriverMode ? PrinterStatusOptions.Default : PrinterStatusOptions.Off;
/// Builds and validates the printer profile from the fields.
/// The profile when valid.
@@ -592,13 +798,15 @@ private static bool TryParseDouble(string text, out double value) =>
if (!TryParseDouble(WidthText, out double w)) return Loc.Get("Print.Field.Number", Loc.Get("Print.Profile.Width"));
if (!TryParseDouble(HeightText, out double h)) return Loc.Get("Print.Field.Number", Loc.Get("Print.Profile.Height"));
int? darkness = null, speed = null;
- if (!string.IsNullOrWhiteSpace(DarknessText))
+ // Darkness and speed are printer-language settings; in driver (picture) mode their boxes are hidden and nothing sends them, so whatever
+ // is typed there (even something invalid left from before) must neither be read nor block the print. The typed text itself is kept.
+ if (!IsDriverMode && !string.IsNullOrWhiteSpace(DarknessText))
{
if (!int.TryParse(DarknessText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int d))
return Loc.Get("Print.Field.Number", Loc.Get("Print.Profile.Darkness"));
darkness = d;
}
- if (!string.IsNullOrWhiteSpace(SpeedText))
+ if (!IsDriverMode && !string.IsNullOrWhiteSpace(SpeedText))
{
if (!int.TryParse(SpeedText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int sp))
return Loc.Get("Print.Field.Number", Loc.Get("Print.Profile.Speed"));
@@ -632,8 +840,13 @@ public void Persist()
if (TryParseDouble(WidthText, out double w) && w > 0) _settings.WidthMm = w;
if (TryParseDouble(HeightText, out double h) && h > 0) _settings.HeightMm = h;
_settings.MediaTracking = SelectedMedia.ToString();
- _settings.Darkness = int.TryParse(DarknessText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int d) ? d : null;
- _settings.SpeedIps = int.TryParse(SpeedText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int sp) ? sp : null;
+ // Not in driver mode: the boxes are hidden there, and writing them would clear a value the person set for printer-language mode
+ // (an empty or ignored box would store null). The stored values stay, so switching back restores them.
+ if (!IsDriverMode)
+ {
+ _settings.Darkness = int.TryParse(DarknessText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int d) ? d : null;
+ _settings.SpeedIps = int.TryParse(SpeedText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int sp) ? sp : null;
+ }
_settings.StatusCheckNetwork = _statusCheckNetwork;
_settings.StatusCheckWindows = _statusCheckWindows;
_save();
diff --git a/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs b/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs
index 9599442..c98dd93 100644
--- a/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs
+++ b/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs
@@ -7,6 +7,7 @@
using Rotula.Core.Barcodes;
using Rotula.Core.Barcodes.QrContent;
using Rotula.Core.Elements;
+using Rotula.Core.Layout;
using Rotula.Core.Model;
using Rotula.Core.Text;
using Rotula.Core.Validation;
@@ -109,7 +110,9 @@ private string SelectionKey()
: e is DataFieldElement ? nameof(TextElement) : e?.GetType().Name ?? string.Empty; // a legacy data field is edited as the text it is
// Whether the element has a printing rule, and whether that rule needs a value, decide which Printing rows exist.
string rule = e?.PrintCondition is { } pc ? (pc.UsesValue ? "rule+value" : "rule") : "norule";
- return sel[0] + ":" + barcodeLayout + ":" + (e is TextElement t && t.Block is not null) + ":" + rule;
+ // An anchor adds the "Relative to" row and turns the X/Y boxes into offsets (other labels, other range).
+ string anchored = e?.Anchor is null ? "abs" : "anchored";
+ return sel[0] + ":" + barcodeLayout + ":" + (e is TextElement t && t.Block is not null) + ":" + rule + ":" + anchored;
}
private void Rebuild(bool force = false)
@@ -206,8 +209,11 @@ private void BuildElementRows(LabelElement element)
if (element is TextElement textElement) BuildTextRows(textElement);
Rows.Add(new HeadingRow("Panel.Position"));
- AddInt(id, "Prop.X", e => e.X, (e, v) => e.X = v, 0, 32000);
- AddInt(id, "Prop.Y", e => e.Y, (e, v) => e.Y = v, 0, 32000);
+ BuildAnchorRows(element);
+ // With an anchor the numbers are an offset from the reference's anchor point, so they may be negative (left or up).
+ bool anchored = element.Anchor is not null;
+ AddInt(id, anchored ? "Prop.X.Offset" : "Prop.X", e => e.X, (e, v) => e.X = v, anchored ? -32000 : 0, 32000, hintKey: anchored ? "Prop.Offset.Hint" : null);
+ AddInt(id, anchored ? "Prop.Y.Offset" : "Prop.Y", e => e.Y, (e, v) => e.Y = v, anchored ? -32000 : 0, 32000, hintKey: anchored ? "Prop.Offset.Hint" : null);
Rows.Add(new ChoiceRow("Prop.OriginKind", ChoiceRow.ForEnum(),
() => El(id).OriginKind, v => Edit(id, "origin", e => e.OriginKind = (FieldOriginKind)v!), "Prop.OriginKind.Hint"));
// A barcode whose ZPL command has no orientation parameter (MaxiCode) cannot be rotated, so the control is hidden. It stays
@@ -290,6 +296,73 @@ private void BuildElementRows(LabelElement element)
BuildPrintingRows(element);
}
+ ///
+ /// The anchor picker and, once the object has an anchor, the "Relative to" drop-down (the label, or any other object). Both change the
+ /// numbers so the object stays exactly where it is: choosing an anchor is a change of meaning, not a move. A reference that would close a
+ /// circle (the other object is already relative to this one) is refused with a sentence that says so.
+ ///
+ private void BuildAnchorRows(LabelElement element)
+ {
+ string id = element.Id;
+ Rows.Add(new AnchorRow("Prop.Anchor", () => El(id).Anchor, anchor => SetAnchor(id, anchor), "Prop.Anchor.Hint") { RebuildOnChange = true });
+ if (element.Anchor is null) return;
+
+ var items = new List { new(null, Loc.Get("Prop.Reference.Label")) };
+ foreach (var other in _session.Document.Elements)
+ if (other.Id != id) items.Add(new ChoiceItem(other.Id, UniqueDisplayName(other)));
+ // A reference the label no longer has (a file edited elsewhere) is listed so the box is not blank; the layout already treats it as the label.
+ if (element.RelativeTo == AnchorReference.Element && element.ReferenceElementId is { } missing && items.All(i => !Equals(i.Value, missing)))
+ items.Add(new ChoiceItem(missing, Loc.Get("Prop.Reference.Missing")));
+ Rows.Add(new ChoiceRow("Prop.Reference", items,
+ () => El(id) is { RelativeTo: AnchorReference.Element } e ? e.ReferenceElementId : null,
+ v => SetReference(id, v as string), "Prop.Reference.Hint",
+ validate: v => v is string refId && AnchorResolver.WouldCreateCycle(_session.Document, id, refId)
+ ? Loc.Get("Anchor.Cycle", ElementDisplayName(El(refId)))
+ : null));
+ }
+
+ /// The display name of an element, numbered when another element of the label shows the same name, so a list can tell them apart.
+ private string UniqueDisplayName(LabelElement e)
+ {
+ string name = ElementDisplayName(e);
+ var twins = _session.Document.Elements.Where(x => ElementDisplayName(x) == name).ToList();
+ return twins.Count <= 1 ? name : $"{name} #{twins.IndexOf(e) + 1}";
+ }
+
+ private void SetAnchor(string id, AnchorPoint? anchor)
+ {
+ var e = El(id);
+ // Removing the anchor also forgets the reference; the numbers become the absolute origin the object already has.
+ var relativeTo = anchor is null ? AnchorReference.Label : e.RelativeTo;
+ string? reference = anchor is null ? null : e.ReferenceElementId;
+ var (x, y) = _session.Bounds.OffsetKeepingPosition(e, anchor, relativeTo, reference);
+ _session.Edit("Undo.Property", d =>
+ {
+ var target = d.Elements.First(el => el.Id == id);
+ target.Anchor = anchor;
+ target.RelativeTo = relativeTo;
+ target.ReferenceElementId = reference;
+ target.X = x;
+ target.Y = y;
+ });
+ }
+
+ private void SetReference(string id, string? referenceElementId)
+ {
+ var e = El(id);
+ if (e.Anchor is not { } anchor) return;
+ var relativeTo = referenceElementId is null ? AnchorReference.Label : AnchorReference.Element;
+ var (x, y) = _session.Bounds.OffsetKeepingPosition(e, anchor, relativeTo, referenceElementId);
+ _session.Edit("Undo.Property", d =>
+ {
+ var target = d.Elements.First(el => el.Id == id);
+ target.RelativeTo = relativeTo;
+ target.ReferenceElementId = referenceElementId;
+ target.X = x;
+ target.Y = y;
+ });
+ }
+
///
/// The "Printing" section: do not print, print only when a field has a value (the rule), and lock the position. These are the same for
/// every kind of object, so they come last. Changing whether a rule exists or whether it needs a value changes which rows exist, so
@@ -384,9 +457,11 @@ private void EditContent(string id, string labelKey, string value, string? discr
/// The text element that gets the block.
public static TextBlockOptions DefaultBlockFor(LabelDocument doc, TextElement text)
{
- // Positions count from the label home (^LH, the left and top margins), so the room is what is left of the printable area.
- int roomRight = doc.WidthDots - doc.Margins.Left.ToDots(doc.Dpi) - text.X - doc.Margins.Right.ToDots(doc.Dpi);
- int roomBelow = doc.HeightDots - doc.Margins.Top.ToDots(doc.Dpi) - text.Y - doc.Margins.Bottom.ToDots(doc.Dpi);
+ // Positions count from the label home (^LH, the left and top margins), so the room is what is left of the printable area. An anchored
+ // text's X/Y are an offset, so its resolved origin is used instead.
+ var (ox, oy) = text.Anchor is null ? (text.X, text.Y) : AnchorResolver.Resolve(doc).OriginOf(text);
+ int roomRight = doc.WidthDots - doc.Margins.Left.ToDots(doc.Dpi) - ox - doc.Margins.Right.ToDots(doc.Dpi);
+ int roomBelow = doc.HeightDots - doc.Margins.Top.ToDots(doc.Dpi) - oy - doc.Margins.Bottom.ToDots(doc.Dpi);
// A text turned a quarter runs down the label: its width comes from the room below and its lines stack across.
var rotation = text.Rotation ?? doc.DefaultRotation;
bool vertical = rotation is FieldRotation.Rotated90 or FieldRotation.Rotated270;
@@ -533,7 +608,10 @@ private void AddBarcodeOption(string id, BarcodeElement element, BarcodeSymbolog
break;
}
case BarcodeOption.ModuleWidth:
- AddInt(id, "Prop.ModuleWidth", e => e.ModuleWidth, (e, v) => e.ModuleWidth = v, 1, 10, "Barcode.ModuleWidth.Range");
+ // The module row carries a stepper and the printed size (mil, mm, symbol width, quiet zone), which follows every edit.
+ Rows.Add(new IntRow("Prop.ModuleWidth", () => El(id).ModuleWidth, v => Edit(id, "Prop.ModuleWidth", e => e.ModuleWidth = v!.Value), 1, 10,
+ rangeIssueKey: "Barcode.ModuleWidth.Range")
+ { FullWidth = true, ShowStepper = true, DetailSource = () => ModuleWidthDetail(El(id)) });
break;
case BarcodeOption.WideBarRatio:
Rows.Add(new DoubleRow("Prop.WideBarRatio", () => El(id).WideBarRatio,
@@ -584,9 +662,11 @@ private void AddBarcodeOption(string id, BarcodeElement element, BarcodeSymbolog
if (sym == BarcodeSymbology.QrCode)
Rows.Add(new IntRow("Prop.ModuleSize", () => El(id).ModuleSize, v => Edit(id, "Prop.ModuleSize", e => e.ModuleSize = v!.Value), 1, 10,
rangeIssueKey: "Barcode.ModuleSize.Range")
- { FullWidth = true, DetailSource = () => ModuleSizeDetail(El(id)) });
+ { FullWidth = true, ShowStepper = true, DetailSource = () => ModuleSizeDetail(El(id)) });
else
- AddInt(id, "Prop.ModuleSize", e => e.ModuleSize, (e, v) => e.ModuleSize = v, 1, 10, "Barcode.ModuleSize.Range");
+ Rows.Add(new IntRow("Prop.ModuleSize", () => El(id).ModuleSize, v => Edit(id, "Prop.ModuleSize", e => e.ModuleSize = v!.Value), 1, 10,
+ rangeIssueKey: "Barcode.ModuleSize.Range")
+ { FullWidth = true, ShowStepper = true, DetailSource = () => ModuleWidthDetail(El(id)) });
break;
case BarcodeOption.Columns:
if (sym == BarcodeSymbology.Codablock)
@@ -753,9 +833,16 @@ private string SpecStatus(BarcodeElement b)
var report = QrCapacityInfo.Describe(LongestPayload(b).Payload, b.QrErrorCorrection, b.QrModel);
// Model 1 has no version table and an overflowing code has no symbol: the module is still worth describing.
int version = report.Fit is { Fits: true, Version: > 0 } fit ? fit.Version : 0;
- return QrCapacityInfo.ModuleSize(version, b.ModuleSize, (int)_session.Document.Dpi);
+ var qr = QrCapacityInfo.ModuleSize(version, b.ModuleSize, (int)_session.Document.Dpi);
+ // The QR text already says the printed size and the scan limit; add the module in mil and the quiet zone (4 modules) under it.
+ var sizing = BarcodeSizeInfo.Describe(b, LongestPayload(b).Payload, _session.Document, withSymbol: false);
+ return sizing is null ? qr : new RowDetail(qr.Text + Environment.NewLine + sizing.Text, qr.IsWarning || sizing.IsWarning);
}
+ /// The "Module: 7.5 mil (0.19 mm) - symbol 48.3 mm wide" readout and the quiet zone note under a module setting.
+ private RowDetail? ModuleWidthDetail(BarcodeElement b) =>
+ BarcodeSizeInfo.Describe(b, LongestPayload(b).Payload, _session.Document);
+
///
/// Opens the QR content builder for a barcode on the label and, when the user applies it, stores the structured definition and the
/// text it builds in one undo step. Returns true when something was applied.
diff --git a/src/Rotula.App/ViewModels/PropertyRows.cs b/src/Rotula.App/ViewModels/PropertyRows.cs
index 0fb8566..aaba240 100644
--- a/src/Rotula.App/ViewModels/PropertyRows.cs
+++ b/src/Rotula.App/ViewModels/PropertyRows.cs
@@ -118,6 +118,15 @@ public IntRow(string labelKey, Func get, Action set, int min, int ma
/// True to give the row a whole line instead of sharing it with the next number (used when it carries a longer second line).
public bool FullWidth { get; init; }
+ /// True to show a compact "-" / "+" pair next to the box that changes the value by one (the module width of a barcode).
+ public bool ShowStepper { get; init; }
+
+ /// Translated tool tip of the "-" button.
+ public string StepDownHint => Loc.Get("Prop.Size.Narrower");
+
+ /// Translated tool tip of the "+" button.
+ public string StepUpHint => Loc.Get("Prop.Size.Wider");
+
/// The text in the box.
[ObservableProperty]
private string _text;
@@ -128,6 +137,35 @@ partial void OnTextChanged(string value)
Commit(value);
}
+ ///
+ /// Changes the value by , stopping at the limits. It starts from the value in the document, not from the box,
+ /// so a half-typed number does not steer the step; the box then shows the new value and the edit goes through the normal commit
+ /// (one undo step per click, merged with the typing of the same property).
+ ///
+ private void Step(int delta)
+ {
+ int current = _get() ?? _min;
+ int next = Math.Clamp(current + delta, _min, _max);
+ // Clear an old complaint: the stepped value is always valid.
+ Error = string.Empty;
+ string text = Format(next);
+ if (Text == text)
+ {
+ // Same text but the document may differ (the box showed a typed value that was rejected): write the value through.
+ if (_get() != next) _set(next);
+ return;
+ }
+ Text = text;
+ }
+
+ /// Makes the value one smaller.
+ [RelayCommand]
+ private void StepDown() => Step(-1);
+
+ /// Makes the value one larger.
+ [RelayCommand]
+ private void StepUp() => Step(1);
+
private bool _applying;
private void Commit(string value)
@@ -456,13 +494,23 @@ public partial class ChoiceRow : PropertyRow
{
private readonly Func
public sealed partial class StationViewModel : OperationViewModel
{
@@ -164,6 +168,8 @@ public StationViewModel(StationConfig config, IReadOnlyList war
WarningsText = string.Join(Environment.NewLine, notes);
RefreshVisible();
PropertyChanged += (_, e) => { if (e.PropertyName == nameof(IsBusy)) { OnPropertyChanged(nameof(CanPrint)); OnPropertyChanged(nameof(CanPrintRecords)); } };
+ // The preview note is worded in code (it depends on the print mode), so a change of language must refresh it like the {loc:T} texts.
+ Loc.Instance.SubscribeWeak(this, static self => self.OnPropertyChanged(nameof(PreviewNote)));
}
/// Lists configuration problems in plain language, one per line (errors first).
@@ -319,6 +325,13 @@ private void Read(StationLabelViewModel label)
[ObservableProperty]
private StationImage? _preview;
+ ///
+ /// The sentence under the preview: for a printer in Windows-driver (picture) mode, that the preview is the picture the printer prints (except
+ /// the parts accepted when the station was set up); otherwise that the preview is approximate. The designer's driver note points at the
+ /// Print dialog, which an operator never sees, so the station has its own wording.
+ ///
+ public string PreviewNote => Loc.Get(Printer is { IsDriverMode: true } ? "Station.Driver.PreviewNote" : "Preview.Approximate");
+
/// True when Print may run.
public bool CanPrint => IsIdle && Current is not null && Printer is not null;
@@ -361,7 +374,7 @@ public async Task OpenAsync(StationLabelViewModel label)
var doc = label.Document!;
_session = new EditorSession(doc);
_previewCache = new PreviewCache(_session);
- Printer = BuildPrinter(label, doc);
+ Printer = StationPrinting.BuildPrinter(_service, label.Entry, doc, _services.DesignerPrinter, out _);
_set = PromptSet.Build(doc, null, _services.Variables.PromptMemory);
foreach (var item in _set.Items)
{
@@ -369,13 +382,13 @@ public async Task OpenAsync(StationLabelViewModel label)
field.PropertyChanged += OnFieldChanged;
Fields.Add(field);
}
- int copies = label.Entry.DefaultCopies ?? Math.Max(1, doc.Print.Quantity);
- CopiesText = Math.Clamp(copies, 1, label.Entry.MaxCopies).ToString(CultureInfo.InvariantCulture);
+ CopiesText = StationPrinting.DefaultCopies(label.Entry, doc).ToString(CultureInfo.InvariantCulture);
Current = label;
ResultText = string.Empty;
SetStatus(StationStatusKind.None, string.Empty, string.Empty);
OnPropertyChanged(nameof(HasNoFields));
OnPropertyChanged(nameof(PrinterText));
+ OnPropertyChanged(nameof(PreviewNote));
OnPropertyChanged(nameof(MaxCopies));
OnPropertyChanged(nameof(CopiesHint));
OnPropertyChanged(nameof(CanPrint));
@@ -414,46 +427,10 @@ private void CloseLabel()
OnPropertyChanged(nameof(RecordsText));
OnPropertyChanged(nameof(CanPrintRecords));
OnPropertyChanged(nameof(HasNoFields));
+ OnPropertyChanged(nameof(PreviewNote));
SetStatus(StationStatusKind.None, string.Empty, string.Empty);
}
- private PrinterSettingsViewModel? BuildPrinter(StationLabelViewModel label, LabelDocument doc)
- {
- PrintSettings settings;
- if (label.Entry.Printer is { } p)
- {
- settings = new PrintSettings
- {
- Configured = true,
- Transport = p.Host is null ? "Spooler" : "Tcp",
- PrinterName = p.WindowsPrinter ?? string.Empty,
- Host = p.Host ?? string.Empty,
- Port = p.Port,
- Dpi = (p.Dpi is { } dpi ? (PrinterDpi)dpi : doc.Dpi).ToString(),
- MediaTracking = doc.MediaTracking.ToString(),
- Darkness = p.Darkness,
- SpeedIps = p.Speed,
- StatusCheckNetwork = p.CheckStatus,
- StatusCheckWindows = p.CheckStatus,
- };
- }
- else if (_services.DesignerPrinter is { Configured: true } d)
- {
- // A copy: the station never changes the designer's own settings.
- settings = new PrintSettings
- {
- Configured = true, Transport = d.Transport, PrinterName = d.PrinterName, Host = d.Host, Port = d.Port, Dpi = d.Dpi, WidthMm = d.WidthMm,
- HeightMm = d.HeightMm, MediaTracking = d.MediaTracking, Darkness = d.Darkness, SpeedIps = d.SpeedIps,
- StatusCheckNetwork = d.StatusCheckNetwork, StatusCheckWindows = d.StatusCheckWindows,
- };
- }
- else return null;
- // No driver detection: the station prints at the resolution it was set up with, the same on every PC.
- var vm = new PrinterSettingsViewModel(_service, settings, () => { }, () => doc, (_, _) => Task.FromResult(PrinterDetection.None));
- vm.UseDesignSize();
- return vm;
- }
-
private void OnFieldChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(PromptFieldViewModel.Value)) return;
@@ -536,7 +513,11 @@ private static string ErrorsFor(string name, PromptValidation check) =>
// ------------------------------------------------------------------------------------ printing
- /// Prints the open label with the answers and the quantity, through the print service with the printer status check.
+ ///
+ /// Prints the open label with the answers and the quantity, through the print service with the printer status check. A printer in
+ /// Windows-driver (picture) mode is pre-flighted first (see ): the operator never accepts findings; only the
+ /// acknowledgement stored when the station was set up lets a label with warnings through.
+ ///
[RelayCommand]
public async Task PrintAsync()
{
@@ -559,12 +540,10 @@ public async Task PrintAsync()
error ??= Printer.TryBuildProfile(out profile, source.Encoding);
if (error is not null) { SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), error); return; }
- // A private copy: the quantity and the resolution conversion never touch the label the station keeps.
- var doc = ElementCloner.CloneDocument(source);
- doc.Print.Quantity = copies;
- var converted = Printer.ConvertForPrinter(doc, out _, out string? convertError);
- if (converted is null) { SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), convertError!); return; }
- doc = converted;
+ // A private copy: the quantity and the resolution conversion never touch the label the station keeps. The set-up tool builds its copy
+ // with the same code, so the fingerprint it stored is worked out from the same document.
+ var doc = StationPrinting.PrepareDocument(Printer, source, copies, out string? convertError);
+ if (doc is null) { SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), convertError!); return; }
// The design is checked with the answers before anything is reserved or sent, so the operator sees the real reason.
var variables = _services.Variables;
var gen = VariableEngine.HasDefinitions(doc)
@@ -579,6 +558,13 @@ public async Task PrintAsync()
var options = VariableServices.Options(variables, answers);
_statusVersion++;
SetStatus(StationStatusKind.Printing, Loc.Get("Station.Status.Printing"), target!.Status.Enabled ? Loc.Get("Station.Status.Printing.Checked") : string.Empty);
+ if (StationPrinting.IsDriverTarget(target))
+ {
+ // On exactly the document printed below: the service works the fingerprint out again from what it receives.
+ var gated = await GateDriverAsync(target, doc, Math.Max(1, doc.Print.Quantity));
+ if (gated is null) return; // the gate showed why
+ target = gated;
+ }
PrintResult? result = null;
bool finished = await RunAsync(Loc.Get("Station.Status.Printing"), async ct => result = await _service.PrintAsync(target, doc, null, profile, options, ct));
if (!finished || result is null)
@@ -605,9 +591,13 @@ private async Task PrintRecordsAsync()
return;
}
SetStatus(StationStatusKind.Printing, Loc.Get("Station.Status.Printing"), string.Empty);
- var outcome = await records.PrintBatchAsync(RunAsync, p => Progress = p, copies);
+ // The station's own picture-mode gate: without it the batch would ask the operator to accept the findings (the Data window's question).
+ _gateShowedOutcome = false;
+ var outcome = await records.PrintBatchAsync(RunAsync, p => Progress = p, copies, GateDriverAsync);
if (outcome is not { } o)
{
+ // Refused or stopped by the picture-mode gate: it already put the reason in the status panel, which the lines below would overwrite.
+ if (_gateShowedOutcome) return;
SetStatus(ResultIsError ? StationStatusKind.Problem : StationStatusKind.None, ResultIsError ? Loc.Get("Station.Status.Problem") : string.Empty, ResultText);
return;
}
@@ -615,6 +605,69 @@ private async Task PrintRecordsAsync()
if (!o.IsError) ResetFields();
}
+ /// Set by when it stopped a print and put the reason in the status panel itself.
+ private bool _gateShowedOutcome;
+
+ ///
+ /// The station's picture-mode gate, for a single print and for the label's data (passed to the batch print as its gate). It runs the driver
+ /// pre-flight on , which must be the very document that is then printed, and decides without asking the operator:
+ ///
+ /// an error finding never prints: the status lists it and tells the operator to ask the person who set up the station;
+ /// warnings print only when their fingerprint equals the one stored for this label at set-up
+ /// (); with none stored, or another one (the design changed since), the operator reads
+ /// Station.Driver.AskSetupPerson and nothing is sent;
+ /// no findings print as they are.
+ ///
+ ///
+ /// The target to print to (carrying the stored acknowledgement when there are warnings), or null when nothing may be sent; the
+ /// reason is then in the status panel.
+ /// The printer to print to.
+ /// The document that will be printed, the very instance.
+ /// How many labels the whole job prints, copies included (for the label's data: every ticked record times its
+ /// copies). More than the pre-flight's limit is an error finding like any other.
+ private async Task GateDriverAsync(PrinterTarget target, LabelDocument doc, long labelCount)
+ {
+ if (!StationPrinting.IsDriverTarget(target)) return target;
+ DriverPreflightResult? preflight = null;
+ // Inside RunAsync: asking a driver can take a moment, and the operator can stop it like any print (the status panel shows Stop).
+ bool finished = await RunAsync(Loc.Get("Station.Status.Printing"), async ct => preflight = await _service.PreflightAsync(target, doc, labelCount, ct));
+ _gateShowedOutcome = true;
+ if (!finished || preflight is null)
+ {
+ // Stopped or crashed: RunAsync wrote the reason, shown the way a stopped print is shown.
+ SetStatus(ResultIsError ? StationStatusKind.Problem : StationStatusKind.None, ResultIsError ? Loc.Get("Station.Status.Problem") : string.Empty, ResultText);
+ return null;
+ }
+ if (preflight.Findings.Count == 1 && preflight.Findings[0].MessageKey == DriverPreflight.Cancelled)
+ {
+ // Stopped before the driver answered: a calm stop, not a problem with the label.
+ SetStatus(StationStatusKind.None, string.Empty, Loc.Get("Common.OperationCancelled"));
+ return null;
+ }
+ if (preflight.HasErrors)
+ {
+ var errors = preflight.Findings.Where(f => f.Severity == ValidationSeverity.Error).ToList();
+ // A printer that did not answer (switched off, disconnected) says nothing about the label: only its own sentence (check that the
+ // printer is on and connected) is shown, never "ask the person who set up the station to check the label".
+ string detail = errors is [{ MessageKey: DriverPreflight.DriverUnavailable }] && preflight.Findings.Count == 1
+ ? DriverPreflightGate.FormatFinding(errors[0])
+ // Nothing lets such a label through, not even an acknowledgement: whom to ask, then what cannot be printed.
+ : Loc.Get("Station.Driver.CannotPrint") + Environment.NewLine + DriverPreflightGate.Describe(errors);
+ SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), detail);
+ return null;
+ }
+ _gateShowedOutcome = false;
+ if (!preflight.NeedsAcknowledgement) return target;
+ string fingerprint = preflight.Fingerprint;
+ if (Current is { } label && _config.AcknowledgedFindings.TryGetValue(label.Entry.Path, out string? stored)
+ && string.Equals(stored, fingerprint, StringComparison.Ordinal))
+ return target.WithAcknowledgement(fingerprint);
+ // Never acknowledged, or acknowledged for other findings (the label was edited since the set-up): the operator cannot decide this.
+ _gateShowedOutcome = true;
+ SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Station.Driver.AskSetupPerson"));
+ return null;
+ }
+
/// Shows a print result in the status panel. Returns true when the labels went out (the questions are then reset for the next item).
private bool ShowResult(PrintResult result, string doneText)
{
@@ -622,7 +675,16 @@ private bool ShowResult(PrintResult result, string doneText)
if (!result.Succeeded)
{
var error = result.Error!;
- string text = PrintMessages.Describe(error) + (notes.Length > 0 ? Environment.NewLine + notes : string.Empty);
+ if (error.Kind == PrintErrorKind.NotAcknowledged)
+ {
+ // The service worked out findings that were not accepted (the driver or the label changed after the station's own check). The
+ // Print dialog's text tells the person to tick a box, which an operator does not have: the station says whom to ask, exactly as
+ // its own check does (GateDriverAsync). Nothing was sent and nothing was reserved.
+ SetStatus(StationStatusKind.Problem, Loc.Get("Station.Status.Problem"), Loc.Get("Station.Driver.AskSetupPerson"));
+ return false;
+ }
+ // The result, not only its error: a picture-mode job that stopped after pages went out says how many (see PrintMessages.Describe).
+ string text = PrintMessages.Describe(result) + (notes.Length > 0 ? Environment.NewLine + notes : string.Empty);
bool heldBack = error.Kind is PrintErrorKind.HeadOpen or PrintErrorKind.PaperOut or PrintErrorKind.RibbonOut or PrintErrorKind.HeadTooHot
or PrintErrorKind.DiagnosticMode or PrintErrorKind.BufferFull or PrintErrorKind.Paused;
SetStatus(heldBack ? StationStatusKind.HeldBack : StationStatusKind.Problem, Loc.Get(heldBack ? "Station.Status.HeldBack" : "Station.Status.Problem"), text);
diff --git a/src/Rotula.App/ViewModels/ZplSourceViewModel.cs b/src/Rotula.App/ViewModels/ZplSourceViewModel.cs
new file mode 100644
index 0000000..9e976c0
--- /dev/null
+++ b/src/Rotula.App/ViewModels/ZplSourceViewModel.cs
@@ -0,0 +1,268 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Rotula.App.Editing;
+using Rotula.App.Localization;
+using Rotula.App.Services;
+using Rotula.Core.Model;
+using Rotula.Core.Zpl.Catalog;
+using Rotula.Core.Zpl.Import;
+
+namespace Rotula.App.ViewModels;
+
+/// Where the text of the ZPL panel comes from.
+public enum ZplSourceState
+{
+ /// The text was generated from the design; the design is the source of truth.
+ Generated,
+
+ /// The user changed the text and the quiet period before it is re-imported has not passed yet.
+ Pending,
+
+ /// The design was rebuilt from the text the user typed; the text is kept as typed, not regenerated.
+ Edited,
+}
+
+///
+/// The editable ZPL source panel. The text normally mirrors the design (); when the user types, the label is
+/// re-imported through the ZPL parser once typing pauses (), as one undo step, and the text stays as typed until the
+/// design changes from somewhere else. Two guards stop 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. A hint line explains the
+/// command under the caret and a status line reports what the importer could not model.
+///
+public sealed partial class ZplSourceViewModel : ObservableObject
+{
+ /// Quiet period after the last keystroke before the text is re-imported.
+ public static readonly TimeSpan Delay = TimeSpan.FromMilliseconds(300);
+
+ /// How many unsupported command names the status line spells out before "and more".
+ public const int MaxNamesInStatus = 3;
+
+ private readonly EditorSession _session;
+ private readonly IDebouncer _debouncer;
+ private readonly Func?> _previewData;
+ private readonly Action _regenerate;
+ private bool _settingText;
+ private bool _applying;
+
+ /// Creates the panel view-model.
+ /// The editing session the text describes and edits.
+ /// Runs the re-import after the quiet period.
+ /// The record shown on the canvas (its values are in the generated text), or null for sample values.
+ /// Produces the text for the current design again (the owner calls from it).
+ public ZplSourceViewModel(EditorSession session, IDebouncer debouncer, Func?> previewData, Action regenerate)
+ {
+ _session = session;
+ _debouncer = debouncer;
+ _previewData = previewData;
+ _regenerate = regenerate;
+ _statusText = Loc.Get("Zpl.Status.Generated");
+ _hintText = Loc.Get("Zpl.Hint.Idle");
+ }
+
+ /// The ZPL text shown in the editor. Set by the user (typing) or by .
+ [ObservableProperty]
+ private string _text = string.Empty;
+
+ /// Whether the design or the text is the source of truth right now.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(StateText), nameof(IsEdited))]
+ [NotifyCanExecuteChangedFor(nameof(RevertToDesignCommand))]
+ private ZplSourceState _state;
+
+ /// One line about the last re-import: what was understood and what was kept as raw ZPL.
+ [ObservableProperty]
+ private string _statusText;
+
+ /// Every notice of the last re-import, one per line (the tooltip of the status line); empty when there were none.
+ [ObservableProperty]
+ private string _statusDetail = string.Empty;
+
+ /// One line about the command under the caret: id, parameters and name, then what the parameters mean.
+ [ObservableProperty]
+ private string _hintText;
+
+ /// The full explanation of the command under the caret (the tooltip of the hint line).
+ [ObservableProperty]
+ private string _hintDetail = string.Empty;
+
+ /// Zero-based caret position in ; the view keeps it current so the hint follows the caret.
+ [ObservableProperty]
+ private int _caretIndex;
+
+ /// The notices of the last re-import, for callers that want the structured form.
+ public IReadOnlyList LastWarnings { get; private set; } = Array.Empty();
+
+ /// What the last re-import carried over from the previous design, or null before any re-import.
+ public CarryOverReport? LastCarryOver { get; private set; }
+
+ /// True when the text is not (or not yet) the generated one, so "Revert to design" has something to do.
+ public bool IsEdited => State != ZplSourceState.Generated;
+
+ /// The state in words, for the badge next to the panel title.
+ public string StateText => Loc.Get(State switch
+ {
+ ZplSourceState.Generated => "Zpl.State.Generated",
+ ZplSourceState.Pending => "Zpl.State.Pending",
+ _ => "Zpl.State.Edited",
+ });
+
+ ///
+ /// Shows text generated from the design. Ignored while the panel is applying its own text (the design just changed because of it) and
+ /// while an edit is pending (the user is typing), so neither direction can overwrite the other.
+ ///
+ public void SetGenerated(string zpl)
+ {
+ if (_applying || State == ZplSourceState.Pending) return;
+ SetTextSilently(zpl);
+ State = ZplSourceState.Generated;
+ StatusText = Loc.Get("Zpl.Status.Generated");
+ StatusDetail = string.Empty;
+ UpdateHint();
+ }
+
+ private void SetTextSilently(string value)
+ {
+ _settingText = true;
+ try { Text = value; }
+ finally { _settingText = false; }
+ }
+
+ partial void OnTextChanged(string value)
+ {
+ if (_settingText) return;
+ State = ZplSourceState.Pending;
+ StatusText = Loc.Get("Zpl.Status.Pending");
+ StatusDetail = string.Empty;
+ _debouncer.Restart(Delay, ApplyPending);
+ UpdateHint();
+ }
+
+ partial void OnCaretIndexChanged(int value) => UpdateHint();
+
+ /// Re-imports the typed text now, without waiting for the quiet period. Does nothing unless an edit is pending.
+ public void ApplyPending()
+ {
+ if (State != ZplSourceState.Pending) return;
+ _debouncer.Cancel();
+ ApplyText(Text);
+ }
+
+ /// Throws the typed text away and shows the text generated from the design again.
+ [RelayCommand(CanExecute = nameof(IsEdited))]
+ private void RevertToDesign()
+ {
+ _debouncer.Cancel();
+ State = ZplSourceState.Generated;
+ _regenerate();
+ }
+
+ /// Rebuilds the texts that are translated (state badge, status, hint) after a language change.
+ public void OnLanguageChanged()
+ {
+ OnPropertyChanged(nameof(StateText));
+ if (State == ZplSourceState.Generated) StatusText = Loc.Get("Zpl.Status.Generated");
+ UpdateHint();
+ }
+
+ ///
+ /// Replaces the design with the label read from , as one undo step. The label keeps its resolution, identity,
+ /// stock, declared variable definitions and the designer-only settings of elements that are still there (see );
+ /// ZPL carries none of those. An empty text is not an empty label: it is reported and nothing changes.
+ ///
+ private void ApplyText(string text)
+ {
+ if (string.IsNullOrWhiteSpace(text))
+ {
+ State = ZplSourceState.Edited;
+ StatusText = Loc.Get("Zpl.Status.Empty");
+ StatusDetail = string.Empty;
+ return;
+ }
+
+ var current = _session.Document;
+ var result = ZplParser.Parse(text, new ZplParseOptions { Dpi = current.Dpi });
+ LastWarnings = result.Warnings;
+ var parsed = result.Document;
+ LastCarryOver = DesignCarryOver.Apply(current, parsed, _previewData());
+
+ _applying = true;
+ try
+ {
+ _session.Edit("Undo.ZplSource", d =>
+ {
+ string? id = d.Id, stockId = d.StockId;
+ var dpi = d.Dpi;
+ var width = d.Width;
+ var height = d.Height;
+ bool sameSize = d.WidthDots == parsed.WidthDots && d.HeightDots == parsed.HeightDots;
+ var definitions = d.Variables.Select(v => v.Clone()).ToList();
+
+ ElementCloner.CopySettings(parsed, d);
+ // Why these are restored: the text cannot say them. The size keeps its unit when it did not change, so "100 mm" does not become "800 dots".
+ d.Id = id;
+ d.Dpi = dpi;
+ d.StockId = sameSize ? stockId : null;
+ if (sameSize) { d.Width = width; d.Height = height; }
+ // The declared variables (dates, counters, prompts, computed values) stay; the text can only add plain ones from numbered fields.
+ d.Variables.Clear();
+ d.Variables.AddRange(definitions);
+ foreach (var v in parsed.Variables)
+ if (!d.Variables.Any(x => x.Name == v.Name)) d.Variables.Add(v.Clone());
+ d.Elements.Clear();
+ d.Elements.AddRange(parsed.Elements);
+ }, selectionAfter: () => Array.Empty());
+ }
+ finally { _applying = false; }
+
+ State = ZplSourceState.Edited;
+ DescribeResult(result);
+ UpdateHint();
+ }
+
+ /// Builds the status line: the count and first names of the commands kept as raw ZPL, then the other notices.
+ private void DescribeResult(ZplParseResult result)
+ {
+ var unsupported = result.Warnings.Where(w => w.KeptAsRaw).ToList();
+ var names = unsupported.Select(w => w.Command ?? w.Args.FirstOrDefault() ?? "?").Distinct(StringComparer.Ordinal).ToList();
+ string shown = string.Join(", ", names.Take(MaxNamesInStatus)) + (names.Count > MaxNamesInStatus ? ", " + Loc.Get("Zpl.Status.More", names.Count - MaxNamesInStatus) : string.Empty);
+ int other = result.Warnings.Count - unsupported.Count;
+
+ var parts = new List(3);
+ if (result.Documents.Count > 1) parts.Add(Loc.Get("Zpl.Status.MultipleLabels", result.Documents.Count));
+ parts.Add(unsupported.Count switch
+ {
+ 0 => Loc.Get("Zpl.Status.Clean"),
+ 1 => Loc.Get("Zpl.Status.Kept.One", shown),
+ _ => Loc.Get("Zpl.Status.Kept.Many", unsupported.Count, shown),
+ });
+ if (other > 0) parts.Add(Loc.Get(other == 1 ? "Zpl.Status.Notices.One" : "Zpl.Status.Notices.Many", other));
+ if (LastCarryOver is { RestoredBindings: > 0 } carry) parts.Add(Loc.Get("Zpl.Status.Rebound", carry.RestoredBindings));
+ StatusText = string.Join(" · ", parts);
+ StatusDetail = string.Join(Environment.NewLine, result.Warnings.Select(CoreZplImporter.Describe));
+ }
+
+ /// Explains the command under the caret, or says that the caret is on none / on one Rotula does not know.
+ private void UpdateHint()
+ {
+ string? token = ZplCommandReference.CommandTokenAtCaret(Text, CaretIndex);
+ if (token is null)
+ {
+ HintText = Loc.Get("Zpl.Hint.Idle");
+ HintDetail = string.Empty;
+ return;
+ }
+ var hint = ZplCommandReference.Describe(token);
+ if (hint is null)
+ {
+ HintText = Loc.Get("Zpl.Hint.Unknown", token);
+ HintDetail = string.Empty;
+ return;
+ }
+ string kept = hint.Status is ZplCommandStatus.Supported or null ? string.Empty : " · " + Loc.Get("Zpl.Hint.Kept");
+ HintText = hint.Headline + " — " + (hint.Parameters.Count == 0 ? hint.Summary : hint.ParameterText) + kept;
+ var lines = new List { hint.Summary };
+ lines.AddRange(hint.Parameters.Select(p => p.Name + ": " + p.Meaning));
+ if (hint.Note is not null) lines.Add(hint.Note);
+ HintDetail = string.Join(Environment.NewLine, lines.Where(l => l.Length > 0));
+ }
+}
diff --git a/src/Rotula.App/Views/DataWindow.xaml b/src/Rotula.App/Views/DataWindow.xaml
index d0255fa..d5e32e2 100644
--- a/src/Rotula.App/Views/DataWindow.xaml
+++ b/src/Rotula.App/Views/DataWindow.xaml
@@ -488,7 +488,8 @@
-
+
+
diff --git a/src/Rotula.App/Views/PreviewWindow.xaml b/src/Rotula.App/Views/PreviewWindow.xaml
index dc47648..3cb4d19 100644
--- a/src/Rotula.App/Views/PreviewWindow.xaml
+++ b/src/Rotula.App/Views/PreviewWindow.xaml
@@ -13,7 +13,8 @@
-
+
+
diff --git a/src/Rotula.App/Views/PreviewWindow.xaml.cs b/src/Rotula.App/Views/PreviewWindow.xaml.cs
index daffda0..e3bb2ee 100644
--- a/src/Rotula.App/Views/PreviewWindow.xaml.cs
+++ b/src/Rotula.App/Views/PreviewWindow.xaml.cs
@@ -18,10 +18,15 @@ public partial class PreviewWindow : DialogWindow
/// Creates the window and draws the label.
/// The preview cache of the open label (its current record data is used).
/// Name of the label, shown in the title.
- public PreviewWindow(PreviewCache preview, string title)
+ ///
+ /// The sentence under the picture: the "approximate" notice, or the accurate one when the chosen printer prints this very picture through its
+ /// Windows driver. A snapshot taken when the window opens, like the picture itself.
+ ///
+ public PreviewWindow(PreviewCache preview, string title, string note)
{
InitializeComponent();
Title = Loc.Get("Preview.TitleFor", title);
+ NoteText.Text = note;
_viewModel = new PreviewViewModel(preview);
DataContext = _viewModel;
_viewModel.PictureChanged += (_, _) => Draw();
diff --git a/src/Rotula.App/Views/PrintWindow.xaml b/src/Rotula.App/Views/PrintWindow.xaml
index 28c47c2..466a17d 100644
--- a/src/Rotula.App/Views/PrintWindow.xaml
+++ b/src/Rotula.App/Views/PrintWindow.xaml
@@ -68,6 +68,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -82,7 +108,8 @@
-
+
+
diff --git a/src/Rotula.App/Views/PrintWindow.xaml.cs b/src/Rotula.App/Views/PrintWindow.xaml.cs
index 66c449f..b887955 100644
--- a/src/Rotula.App/Views/PrintWindow.xaml.cs
+++ b/src/Rotula.App/Views/PrintWindow.xaml.cs
@@ -1,5 +1,6 @@
using System.ComponentModel;
using System.Windows;
+using System.Windows.Threading;
using Rotula.App.ViewModels;
namespace Rotula.App.Views;
@@ -16,6 +17,13 @@ public PrintWindow(PrintViewModel viewModel)
DataContext = viewModel;
InitializeComponent();
viewModel.CloseRequested += (_, _) => Close();
+ viewModel.PropertyChanged += (_, e) =>
+ {
+ // The findings sit at the top of the scrolled content. After pressing Print the person may have scrolled down to the copies, so the
+ // list (and its box) is brought into view; after the layout pass, because the card has only just become visible.
+ if (e.PropertyName == nameof(PrintViewModel.HasPreflightLines) && _vm.HasPreflightLines)
+ Dispatcher.BeginInvoke(() => FindingsCard.BringIntoView(), DispatcherPriority.Loaded);
+ };
// Printer discovery runs async, so the dialog appears at once and fills in when Windows answers.
Loaded += async (_, _) =>
{
diff --git a/src/Rotula.App/Views/PrinterSettingsView.xaml b/src/Rotula.App/Views/PrinterSettingsView.xaml
index 6501243..a5589c2 100644
--- a/src/Rotula.App/Views/PrinterSettingsView.xaml
+++ b/src/Rotula.App/Views/PrinterSettingsView.xaml
@@ -2,9 +2,12 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:Rotula.App.Controls"
- xmlns:loc="clr-namespace:Rotula.App.Localization">
+ xmlns:loc="clr-namespace:Rotula.App.Localization"
+ xmlns:views="clr-namespace:Rotula.App.Views"
+ xmlns:prn="clr-namespace:Rotula.Printing;assembly=Rotula.Printing">
+
@@ -51,6 +54,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -70,19 +116,22 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -122,16 +171,21 @@
-
+
+
-
+
-
+
+
diff --git a/src/Rotula.App/Views/PrinterSettingsView.xaml.cs b/src/Rotula.App/Views/PrinterSettingsView.xaml.cs
index 91d0c00..b24e53e 100644
--- a/src/Rotula.App/Views/PrinterSettingsView.xaml.cs
+++ b/src/Rotula.App/Views/PrinterSettingsView.xaml.cs
@@ -7,9 +7,24 @@ namespace Rotula.App.Views;
/// Printer choice and printer profile fields; used by the Print dialog and the Data merge window.
public partial class PrinterSettingsView : UserControl
{
+ /// Identifies the dependency property.
+ public static readonly DependencyProperty ShowModeChoiceProperty =
+ DependencyProperty.Register(nameof(ShowModeChoice), typeof(bool), typeof(PrinterSettingsView), new PropertyMetadata(false));
+
/// Creates the view.
public PrinterSettingsView() => InitializeComponent();
+ ///
+ /// True to show the print mode under the printer list: the one-time question for a printer that has no saved mode, or the mode line with
+ /// the way to switch. The Print dialog and the Data window both set it, because everything they print (a single label, the test label, and
+ /// a batch of records) follows the printer's mode, and nothing prints until the question is answered. Off by default for any other host.
+ ///
+ public bool ShowModeChoice
+ {
+ get => (bool)GetValue(ShowModeChoiceProperty);
+ set => SetValue(ShowModeChoiceProperty, value);
+ }
+
// The refresh is async in the view-model, so the window stays responsive while Windows lists its printers.
private async void OnRefreshPrinters(object sender, RoutedEventArgs e)
{
diff --git a/src/Rotula.App/Views/PropertiesPanel.xaml b/src/Rotula.App/Views/PropertiesPanel.xaml
index 503aa7f..1f7555b 100644
--- a/src/Rotula.App/Views/PropertiesPanel.xaml
+++ b/src/Rotula.App/Views/PropertiesPanel.xaml
@@ -37,8 +37,22 @@
-
+
+
+
+
+
+
+
+
+
@@ -200,6 +214,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Rotula.App/Views/StationSetupWindow.xaml b/src/Rotula.App/Views/StationSetupWindow.xaml
new file mode 100644
index 0000000..99ba13d
--- /dev/null
+++ b/src/Rotula.App/Views/StationSetupWindow.xaml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Rotula.App/Views/StationSetupWindow.xaml.cs b/src/Rotula.App/Views/StationSetupWindow.xaml.cs
new file mode 100644
index 0000000..56b4d28
--- /dev/null
+++ b/src/Rotula.App/Views/StationSetupWindow.xaml.cs
@@ -0,0 +1,31 @@
+using System.ComponentModel;
+using System.Windows;
+using Rotula.App.ViewModels;
+
+namespace Rotula.App.Views;
+
+/// "Set up print station". All behaviour is in ; this class only connects window events.
+public partial class StationSetupWindow : DialogWindow
+{
+ private readonly StationSetupViewModel _vm;
+
+ /// Creates the window.
+ public StationSetupWindow(StationSetupViewModel viewModel)
+ {
+ _vm = viewModel;
+ DataContext = viewModel;
+ InitializeComponent();
+ // The labels are checked as soon as the window is up, so the person sees each label's result without pressing anything first.
+ Loaded += async (_, _) => await _vm.CheckCommand.ExecuteAsync(null);
+ }
+
+ ///
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ base.OnClosing(e);
+ // Closing the window (Esc, X) stops a check that is still asking a driver.
+ _vm.CancelOperation();
+ }
+
+ private void OnClose(object sender, RoutedEventArgs e) => Close();
+}
diff --git a/src/Rotula.App/Views/StationWindow.xaml b/src/Rotula.App/Views/StationWindow.xaml
index 3b03a92..cefe78e 100644
--- a/src/Rotula.App/Views/StationWindow.xaml
+++ b/src/Rotula.App/Views/StationWindow.xaml
@@ -230,8 +230,9 @@
-
+
+
+/// The quiet zone (clear margin) a symbology needs around its symbol, in modules (one module is one X-dimension), and where the
+/// requirement comes from. A reader needs this margin to find the start and end of the symbol; printing something inside it, or running
+/// the label edge through it, is the most common reason a good-looking barcode does not scan.
+///
+/// Margin before the first bar, in modules, along the reading direction.
+/// Margin after the last bar, in modules, along the reading direction.
+/// True for 2D and stacked symbols, which need the margin on all four sides (then left/right are the value for each side).
+/// The standard or specification the value comes from (plain text, for tool tips and documentation).
+public sealed record BarcodeQuietZone(int LeftModules, int RightModules, bool AllSides, string Source);
+
+/// The quiet zone around a barcode in dots, for each side of the label orientation (what the design canvas draws).
+/// Dots to the left of the symbol.
+/// Dots above the symbol.
+/// Dots to the right of the symbol.
+/// Dots below the symbol.
+public readonly record struct QuietZoneDots(int Left, int Top, int Right, int Bottom);
+
+///
+/// Everything a person needs to judge the size of a barcode on paper: the X-dimension, the bar widths, the printed width of the symbol
+/// and the quiet zone, all derived from the dot-based settings and the label resolution.
+///
+/// Printer resolution the figures are for.
+/// Module (narrow bar) width in dots.
+/// X-dimension in mils (1/1000 inch).
+/// X-dimension in millimetres.
+/// Narrow bar width in dots (the same as ).
+/// Wide bar width in dots, or null when the symbology has no separate wide bar (Code 128, EAN/UPC, 2D codes).
+/// Printed width of the symbol without quiet zones in dots, or null when it cannot be known without encoding.
+/// The same in millimetres, or null.
+/// The quiet zone the symbology needs.
+/// Quiet zone before the symbol in dots.
+/// Quiet zone after the symbol in dots.
+/// Quiet zone before the symbol in millimetres.
+/// Quiet zone after the symbol in millimetres.
+public sealed record BarcodeSizeReport(
+ int Dpi,
+ int ModuleDots,
+ double XDimensionMils,
+ double XDimensionMm,
+ int NarrowBarDots,
+ int? WideBarDots,
+ int? SymbolWidthDots,
+ double? SymbolWidthMm,
+ BarcodeQuietZone QuietZone,
+ int QuietLeftDots,
+ int QuietRightDots,
+ double QuietLeftMm,
+ double QuietRightMm)
+{
+ /// Symbol plus both quiet zones in dots, or null when the symbol width is not known.
+ public int? TotalWidthDots => SymbolWidthDots is { } w ? w + QuietLeftDots + QuietRightDots : null;
+}
+
+///
+/// Whether a module (narrow bar) width is big enough to scan at a given printer resolution, as judged by
+/// . Used before printing through the Windows driver, where the label is drawn as a picture and
+/// the printer's own dot pattern is no longer under the designer's control.
+///
+public enum ModuleSafety
+{
+ /// The module is at least as wide as the symbology's minimum X-dimension.
+ Safe,
+ /// The module is two or more dots wide but narrower than the symbology's minimum X-dimension (typical on high-resolution office printers).
+ BelowMinimum,
+ /// The module is a single device dot: no tolerance for dot gain or head wear, whatever the symbology.
+ SingleDot,
+}
+
+///
+/// Pure sizing helper for barcodes: turns the dot-based module width and the label resolution into the figures printers and
+/// scanner specifications use (X-dimension in mils and mm), measures the symbol where the width follows from the symbology rules,
+/// and checks the printer reality (is the module big enough to print and scan) and the quiet zone (does the symbol fit on the label).
+/// It carries no UI text: warnings are s with message keys the app localizes.
+///
+public static class BarcodeSizing
+{
+ /// Warning key: the module is one dot at 300 dpi or more.
+ public const string OneDotKey = "Barcode.Size.OneDotModule";
+ /// Warning key: a 1D narrow bar thinner than two dots at 203 dpi.
+ public const string ThinBarKey = "Barcode.Size.ThinBar";
+ /// Warning key: the symbol with its quiet zones does not fit the label width.
+ public const string QuietZoneKey = "Barcode.Size.QuietZoneOutside";
+
+ // Scan-reliability thresholds of the printer reality check. A 203 dpi head prints a dot about 0.125 mm wide; a bar of one dot has
+ // no tolerance for dot gain or a slightly worn head, which is why bar code printer guides advise at least two dots for linear codes.
+ private const int MinNarrowDots203 = 2;
+ private const int HighDpi = 300;
+
+ ///
+ /// Quiet zone per symbology. Values are in modules (X). Sources: Code 128 ISO/IEC 15417 (10X, also GS1-128 in the GS1 General
+ /// Specifications); Code 39 ISO/IEC 16388 (10X); Interleaved 2 of 5 ISO/IEC 16390 (10X, also ITF-14 in GS1); EAN-13 11X left and 7X
+ /// right, UPC-A 9X on both sides, EAN-8 7X on both sides, UPC-E 9X left and 7X right, add-ons 7X left and 5X right (GS1 General
+ /// Specifications, ISO/IEC 15420); QR Code 4X (ISO/IEC 18004); Data Matrix 1X (ISO/IEC 16022); PDF417 2X (ISO/IEC 15438);
+ /// MicroPDF417 1X (ISO/IEC 24728); Aztec Code needs none (ISO/IEC 24778). Code 93 and Codabar use 10X (AIM uniform symbology
+ /// specifications). Symbologies without an entry fall back to a conservative value by family (see ).
+ ///
+ private static readonly Dictionary QuietZones = new()
+ {
+ [BarcodeSymbology.Code128] = new(10, 10, false, "ISO/IEC 15417"),
+ [BarcodeSymbology.Code39] = new(10, 10, false, "ISO/IEC 16388"),
+ [BarcodeSymbology.Logmars] = new(10, 10, false, "ISO/IEC 16388, MIL-STD-1189"),
+ [BarcodeSymbology.Code93] = new(10, 10, false, "AIM USS Code 93"),
+ [BarcodeSymbology.Interleaved2Of5] = new(10, 10, false, "ISO/IEC 16390"),
+ [BarcodeSymbology.Codabar] = new(10, 10, false, "AIM USS Codabar"),
+ [BarcodeSymbology.Ean13] = new(11, 7, false, "GS1 General Specifications"),
+ [BarcodeSymbology.UpcA] = new(9, 9, false, "GS1 General Specifications"),
+ [BarcodeSymbology.Ean8] = new(7, 7, false, "GS1 General Specifications"),
+ [BarcodeSymbology.UpcE] = new(9, 7, false, "GS1 General Specifications"),
+ [BarcodeSymbology.UpcEanExtension] = new(7, 5, false, "GS1 General Specifications"),
+ [BarcodeSymbology.QrCode] = new(4, 4, true, "ISO/IEC 18004"),
+ [BarcodeSymbology.DataMatrix] = new(1, 1, true, "ISO/IEC 16022"),
+ [BarcodeSymbology.Pdf417] = new(2, 2, true, "ISO/IEC 15438"),
+ [BarcodeSymbology.MicroPdf417] = new(1, 1, true, "ISO/IEC 24728"),
+ [BarcodeSymbology.Aztec] = new(0, 0, true, "ISO/IEC 24778"),
+ };
+
+ ///
+ /// The quiet zone of . A symbology with no entry in the table gets a conservative family default
+ /// (linear 10X, matrix 1X, stacked 2X, postal none); that default is a safe margin, not a citation of a standard.
+ ///
+ public static BarcodeQuietZone QuietZoneOf(BarcodeSymbology symbology)
+ {
+ if (QuietZones.TryGetValue(symbology, out var known)) return known;
+ var category = Enum.IsDefined(symbology) ? BarcodeCatalog.Get(symbology).Category : BarcodeCategory.Linear;
+ return category switch
+ {
+ BarcodeCategory.Matrix2D => new BarcodeQuietZone(1, 1, true, "default"),
+ BarcodeCategory.Stacked => new BarcodeQuietZone(2, 2, true, "default"),
+ BarcodeCategory.Postal => new BarcodeQuietZone(0, 0, false, "default"),
+ _ => new BarcodeQuietZone(10, 10, false, "default"),
+ };
+ }
+
+ /// Millimetres per mil (1/1000 inch): 0.0254.
+ private const double MmPerMil = 0.0254;
+
+ /// Converts the millimetre figure a specification states to mils, so the table keeps the standard's own number instead of a rounded inch value.
+ private static double MilsFromMm(double mm) => mm / MmPerMil;
+
+ ///
+ /// Smallest module (X-dimension) per symbology, in mils (1/1000 inch), with the document it comes from. Each figure is the hard floor the
+ /// cited specification gives for ordinary on-demand (thermal or laser) label printing, in millimetres converted exactly; going below it is
+ /// outside what the standard accepts, so a scanner can no longer be expected to read the symbol. It is a floor, not the recommended size:
+ /// the GS1 target sizes are larger. Only the symbologies that have a citable figure are listed here; every other symbology gets a labelled
+ /// family fallback from .
+ ///
+ /// Sources (GS1 General Specifications are quoted from the copies that could be read: Version 14 of Jan 2014, GSCN 16-434 effective
+ /// 20 Dec 2016 and GSCN 22-031 ratified Jul 2022, which becomes Release 23; later releases may move the figures):
+ /// - EAN-13, EAN-8, UPC-A, UPC-E, add-ons, GS1 DataBar: symbol specification table 1 (v14 Figure 5.5.2.7-2, Release 23 section 5.12.3.1)
+ /// gives a minimum X of 0.264 mm (0.0104 in, 80 percent magnification) and, in note (*), allows 0.249 mm (0.0098 in, 75 percent)
+ /// for on-demand printing (thermal, laser), which is the case here; the 0.249 mm figure is used.
+ /// - Code 128 and Interleaved 2 of 5: GS1-128 and ITF-14 tables 2 and 5 (v14 Figures 5.5.2.7-3 and 5.5.2.7-6, as revised by GSCN 16-434
+ /// in 5.5.2.7.2 note (*) and 5.5.2.7.5 note (*)): the table minimum is 0.495 mm (0.0195 in) but when the symbol is physically too small
+ /// the X-dimension "SHALL NOT be less than 0.250 mm (0.0098 in)". That absolute floor is used. ISO/IEC 15417 and ISO/IEC 16390 state no
+ /// minimum of their own, so a plain Code 128 or Interleaved 2 of 5 label gets the same floor by analogy.
+ /// - QR Code is not in this table, because its figure depends on the content (see ): a GS1 QR Code (a GS1
+ /// Digital Link URI) takes GS1 symbol specification table 1 (Release 23 section 5.12.3.1, GSCN 22-031): 0.375 mm (0.0148 in); the
+ /// addenda for retail URLs (AI 8200) use 0.396 mm. Any other QR Code has no citable figure: ISO/IEC 18004 states no minimum module size,
+ /// so it gets the matrix-2D family fallback.
+ /// - Data Matrix: GS1 symbol specification tables 6 (regulated healthcare non-retail) and 7 (direct part marking), v14 Figures 5.5.2.7-7
+ /// and 5.5.2.7-8: 0.255 mm (0.0100 in). The retail tables ask 0.375 to 0.396 mm. ISO/IEC 16022 states no minimum.
+ /// - Code 39: AIM Uniform Symbology Specification Code 39 (USS-39) states a minimum standard X-dimension of 0.0075 in (0.191 mm); ISO/IEC
+ /// 16388 itself could not be read. The clause number of USS-39 was not retrievable, only the sentence quoting it.
+ /// - LOGMARS is Code 39 with a mandatory check character (MIL-STD-1189), so it takes the Code 39 figure.
+ /// - PDF417: 0.01 in (0.254 mm) minimum narrow element, as quoted from the US DLA DLMS manual 0206.35 in a search excerpt (the document
+ /// itself could not be opened), ISO/IEC 15438 is paywalled and states no figure in the readable previews.
+ /// - POSTNET: USPS DMM archive (Aug 2017) 204, section 1.2.5: bars are 0.020 in plus or minus 0.005 in wide, so the narrowest allowed bar
+ /// is 0.015 in (0.381 mm). Applies to the bar width, which is the module of this symbology.
+ ///
+ private static readonly Dictionary MinimumModules = new()
+ {
+ [BarcodeSymbology.Code128] = (MilsFromMm(0.250), "GS1 General Specifications 5.5.2.7.2 and 5.5.2.7.5 note (*), GSCN 16-434 (GS1-128 absolute minimum X-dimension 0.250 mm); ISO/IEC 15417 states none"),
+ [BarcodeSymbology.Interleaved2Of5] = (MilsFromMm(0.250), "GS1 General Specifications 5.5.2.7.2 note (*), GSCN 16-434 (ITF-14 absolute minimum X-dimension 0.250 mm); ISO/IEC 16390 states none"),
+ [BarcodeSymbology.Ean13] = (MilsFromMm(0.249), "GS1 General Specifications symbol specification table 1 note (*): 0.249 mm (75 percent) for on-demand printing, 0.264 mm otherwise"),
+ [BarcodeSymbology.UpcA] = (MilsFromMm(0.249), "GS1 General Specifications symbol specification table 1 note (*): 0.249 mm (75 percent) for on-demand printing, 0.264 mm otherwise"),
+ [BarcodeSymbology.Ean8] = (MilsFromMm(0.249), "GS1 General Specifications symbol specification table 1 note (*): 0.249 mm (75 percent) for on-demand printing, 0.264 mm otherwise"),
+ [BarcodeSymbology.UpcE] = (MilsFromMm(0.249), "GS1 General Specifications symbol specification table 1 note (*): 0.249 mm (75 percent) for on-demand printing, 0.264 mm otherwise"),
+ [BarcodeSymbology.UpcEanExtension] = (MilsFromMm(0.249), "GS1 General Specifications symbol specification table 1 (EAN/UPC plus add-on rows) note (*): 0.249 mm (75 percent) for on-demand printing"),
+ [BarcodeSymbology.DataBar] = (MilsFromMm(0.249), "GS1 General Specifications symbol specification table 1 (GS1 DataBar rows) note (*): 0.249 mm (75 percent) for on-demand printing, 0.264 mm otherwise"),
+ [BarcodeSymbology.DataMatrix] = (MilsFromMm(0.255), "GS1 General Specifications symbol specification tables 6 and 7 (v14 5.5.2.7): GS1 DataMatrix minimum X-dimension 0.255 mm; ISO/IEC 16022 states none"),
+ [BarcodeSymbology.Code39] = (7.5, "AIM Uniform Symbology Specification Code 39 (USS-39): minimum standard X-dimension 0.0075 in (0.191 mm), clause not retrieved; ISO/IEC 16388 not readable"),
+ [BarcodeSymbology.Logmars] = (7.5, "Code 39 variant (MIL-STD-1189), takes the AIM USS-39 minimum X-dimension 0.0075 in (0.191 mm)"),
+ [BarcodeSymbology.Pdf417] = (MilsFromMm(0.254), "US DLA DLMS manual 0206.35: minimum narrow element X-dimension 0.01 in (0.254 mm), read from a search excerpt; ISO/IEC 15438 not readable"),
+ [BarcodeSymbology.Postnet] = (15.0, "USPS DMM 204 section 1.2.5 (archive Aug 2017): bar width 0.020 +/- 0.005 in, narrowest allowed bar 0.015 in"),
+ };
+
+ ///
+ /// GS1 QR Code, the form a GS1 Digital Link URI takes in a QR Code: GS1 General Specifications symbol specification table 1 (Release 23
+ /// section 5.12.3.1, GSCN 22-031), minimum X-dimension 0.375 mm. Used only for QR Codes whose content is a GS1 Digital Link
+ /// (): GS1 sets this figure for its own symbols, not for every QR Code.
+ ///
+ private static readonly (double Mils, string Source) Gs1QrMinimum =
+ (MilsFromMm(0.375), "GS1 General Specifications 5.12.3.1 symbol specification table 1 (GSCN 22-031): GS1 QR Code (GS1 Digital Link URI) minimum X-dimension 0.375 mm");
+
+ ///
+ /// Any other QR Code. ISO/IEC 18004 states no minimum module size, so it takes the matrix-2D family fallback, the GS1 DataMatrix figure of
+ /// 0.255 mm, labelled "Fallback" like every other family default. Settled before the first release of picture printing because the minimum
+ /// is an argument of the pre-flight warning, and so part of the acknowledgement fingerprint: the stricter GS1 figure made every 4-dot QR
+ /// Code at 300 dpi (13.3 mils, a common, scannable size) ask for a tick.
+ ///
+ private static readonly (double Mils, string Source) GenericQrMinimum =
+ (MilsFromMm(0.255), "Fallback (matrix 2D family): GS1 DataMatrix minimum X-dimension 0.255 mm, because ISO/IEC 18004 states no minimum module size for QR Code; GS1 Digital Link content takes the GS1 QR Code 0.375 mm");
+
+ ///
+ /// The minimum module and its source for : the table entry when the symbology has a citable figure, otherwise a
+ /// family fallback chosen by . QR Code depends on its content: selects the
+ /// GS1 QR Code figure, otherwise a generic QR gets the matrix-2D fallback with a source that names ISO/IEC 18004. For every other symbology
+ /// changes nothing: Data Matrix has one GS1 figure, and the others have no Digital Link form.
+ /// Every fallback is labelled "Fallback" in its source text and is a documented value of a related symbology, never an invented number:
+ /// linear 7.5 mils (the AIM USS-39 Code 39 figure; Code 93 and Codabar are quoted at 7.5 mils by secondary sources only, and Code 11, MSI,
+ /// Plessey and the 2 of 5 variants have no figure that could be read);
+ /// matrix 2D the GS1 DataMatrix 0.255 mm (Aztec Code has no figure that could be read, and a MaxiCode symbol has a fixed size set by ISO/IEC
+ /// 16023, so it never has an adjustable module);
+ /// stacked the PDF417 0.254 mm (MicroPDF417, Codablock, Code 49 and TLC39 have no figure that could be read);
+ /// postal the POSTNET 0.015 in (PLANET uses the same bar dimensions in USPS practice, but Publication 25 could not be read).
+ ///
+ private static (double Mils, string Source) MinimumModuleOf(BarcodeSymbology symbology, bool gs1DigitalLink)
+ {
+ if (symbology == BarcodeSymbology.QrCode) return gs1DigitalLink ? Gs1QrMinimum : GenericQrMinimum;
+ if (MinimumModules.TryGetValue(symbology, out var known)) return known;
+ var category = Enum.IsDefined(symbology) ? BarcodeCatalog.Get(symbology).Category : BarcodeCategory.Linear;
+ return category switch
+ {
+ BarcodeCategory.Matrix2D => (MilsFromMm(0.255), "Fallback (matrix 2D family): GS1 DataMatrix minimum X-dimension 0.255 mm; no figure found for this symbology"),
+ BarcodeCategory.Stacked => (MilsFromMm(0.254), "Fallback (stacked family): PDF417 minimum X-dimension 0.254 mm; no figure found for this symbology"),
+ BarcodeCategory.Postal => (15.0, "Fallback (postal family): POSTNET narrowest bar 0.015 in; no figure found for this symbology"),
+ _ => (7.5, "Fallback (linear family): AIM USS-39 Code 39 minimum X-dimension 7.5 mils; no figure found for this symbology"),
+ };
+ }
+
+ ///
+ /// The smallest module (X-dimension) in mils (1/1000 inch) at which can still be expected to scan, for generic
+ /// content (a QR Code that is not a GS1 Digital Link). A symbology without its own citable figure gets a family fallback (see
+ /// for which).
+ ///
+ public static double MinimumModuleMils(BarcodeSymbology symbology) => MinimumModuleOf(symbology, gs1DigitalLink: false).Mils;
+
+ ///
+ /// The smallest module in mils for carrying the given kind of content. Only QR Code differs: a GS1 Digital
+ /// Link takes the GS1 QR Code figure (0.375 mm), any other content the matrix-2D fallback (0.255 mm).
+ ///
+ /// The symbology.
+ /// True when the content is a GS1 Digital Link URI (see ).
+ public static double MinimumModuleMils(BarcodeSymbology symbology, bool gs1DigitalLink) => MinimumModuleOf(symbology, gs1DigitalLink).Mils;
+
+ ///
+ /// The standard or specification behind (plain text, English), or a "Fallback" note when it is
+ /// a family default.
+ ///
+ public static string MinimumModuleSourceOf(BarcodeSymbology symbology) => MinimumModuleOf(symbology, gs1DigitalLink: false).Source;
+
+ /// The source behind for the given kind of content (plain text, English).
+ /// The symbology.
+ /// True when the content is a GS1 Digital Link URI.
+ public static string MinimumModuleSourceOf(BarcodeSymbology symbology, bool gs1DigitalLink) => MinimumModuleOf(symbology, gs1DigitalLink).Source;
+
+ ///
+ /// True when carries GS1 Digital Link content: a QR Code or Data Matrix with the Digital Link option (its data is
+ /// checked as a GS1 Digital Link URI), or a QR Code made with the content builder's GS1 Digital Link kind (the builder does not set the
+ /// option, so both are read). A content definition that cannot be read counts as generic content.
+ ///
+ /// The barcode.
+ /// is null.
+ public static bool HasGs1DigitalLinkContent(BarcodeElement barcode)
+ {
+ ArgumentNullException.ThrowIfNull(barcode);
+ if (barcode.Symbology is not (BarcodeSymbology.QrCode or BarcodeSymbology.DataMatrix)) return false;
+ if (barcode.DigitalLink) return true;
+ return barcode.Symbology == BarcodeSymbology.QrCode
+ && QrContent.QrContentSpec.TryParse(barcode.QrContentSpec, out var spec)
+ && spec.Kind == QrContent.QrContentKind.GS1DigitalLink;
+ }
+
+ ///
+ /// Judges a module of device dots at for generic content (see
+ /// ).
+ ///
+ /// The symbology (its minimum comes from ).
+ /// Module (narrow bar) width in device dots.
+ /// Resolution of the printer the label will be printed on.
+ public static ModuleSafety ModuleSafetyOf(BarcodeSymbology symbology, int moduleDots, int dpi) => ModuleSafetyOf(symbology, moduleDots, dpi, gs1DigitalLink: false);
+
+ ///
+ /// Judges a module of device dots at . The check is on the physical width in mils, not on the
+ /// dot count: a 2-dot module is plenty at 203 dpi (9.9 mils) but only 1.7 mils at 1200 dpi, where no reader will resolve it.
+ /// A single dot is reported on its own because it has no tolerance for dot gain at any resolution. The minimum is the same one
+ /// returns for this content, so a caller that shows the minimum next to the verdict
+ /// shows the figure the verdict was made against.
+ ///
+ /// The symbology.
+ /// Module (narrow bar) width in device dots.
+ /// Resolution of the printer the label will be printed on.
+ /// True when the content is a GS1 Digital Link URI (only QR Code has a different minimum then).
+ public static ModuleSafety ModuleSafetyOf(BarcodeSymbology symbology, int moduleDots, int dpi, bool gs1DigitalLink)
+ {
+ // Validate first so a bad resolution is never hidden by the single-dot shortcut.
+ if (dpi <= 0) throw new ArgumentOutOfRangeException(nameof(dpi));
+ if (moduleDots <= 1) return ModuleSafety.SingleDot;
+ // The tolerance keeps a module that lands exactly on the minimum from being rejected by floating point noise.
+ return MilsOf(moduleDots, dpi) + 1e-9 < MinimumModuleMils(symbology, gs1DigitalLink) ? ModuleSafety.BelowMinimum : ModuleSafety.Safe;
+ }
+
+ ///
+ /// The module of the symbol in dots: the ^BY width for symbologies that have , the magnification for
+ /// those with , otherwise null (MaxiCode has a fixed size).
+ ///
+ public static int? ModuleDotsOf(BarcodeElement b)
+ {
+ ArgumentNullException.ThrowIfNull(b);
+ if (!Enum.IsDefined(b.Symbology)) return null;
+ var info = BarcodeCatalog.Get(b.Symbology);
+ if (info.Supports(BarcodeOption.ModuleWidth)) return b.ModuleWidth;
+ if (info.Supports(BarcodeOption.ModuleSize)) return b.ModuleSize;
+ return null;
+ }
+
+ /// X-dimension in mils (1/1000 inch) of a module of dots at .
+ public static double MilsOf(int moduleDots, int dpi)
+ {
+ if (dpi <= 0) throw new ArgumentOutOfRangeException(nameof(dpi));
+ return moduleDots * 1000.0 / dpi;
+ }
+
+ /// Length in millimetres of dots at (the X-dimension when given the module width).
+ public static double MmOf(double dots, int dpi)
+ {
+ if (dpi <= 0) throw new ArgumentOutOfRangeException(nameof(dpi));
+ return dots * 25.4 / dpi;
+ }
+
+ ///
+ /// Describes the size of printing (variables already resolved) at .
+ /// Returns null for a symbology without an adjustable module. The symbol width is measured here for the symbologies whose width follows
+ /// from their rules (Code 128, Code 39, Code 93, Interleaved 2 of 5, EAN/UPC, QR Code); for the others the caller may pass the module
+ /// count from the preview encoder as , otherwise the width stays null and nothing is claimed about it.
+ ///
+ /// The barcode.
+ /// The data it prints.
+ /// Printer resolution (203, 300 or 600).
+ /// Symbol width in modules from an encoder, used only when this class cannot measure the symbol itself.
+ public static BarcodeSizeReport? Analyze(BarcodeElement b, string data, int dpi, int? encodedModules = null)
+ {
+ ArgumentNullException.ThrowIfNull(b);
+ ArgumentNullException.ThrowIfNull(data);
+ if (dpi <= 0) throw new ArgumentOutOfRangeException(nameof(dpi));
+ if (ModuleDotsOf(b) is not { } module) return null;
+ module = Math.Max(1, module);
+
+ var info = BarcodeCatalog.Get(b.Symbology);
+ // ZPL derives the wide bar from ^BY: wide = narrow x ratio, rounded to whole dots, and never thinner than the narrow bar.
+ int? wide = info.Supports(BarcodeOption.WideBarRatio)
+ ? Math.Max(module, (int)Math.Round(module * b.WideBarRatio, MidpointRounding.AwayFromZero))
+ : null;
+
+ int? width = MeasureDots(b, data, module, wide) ?? (encodedModules is { } m and > 0 ? m * module : null);
+ var q = QuietZoneOf(b.Symbology);
+ int left = q.LeftModules * module, right = q.RightModules * module;
+ return new BarcodeSizeReport(dpi, module, MilsOf(module, dpi), MmOf(module, dpi), module, wide,
+ width, width is { } w ? MmOf(w, dpi) : null, q, left, right, MmOf(left, dpi), MmOf(right, dpi));
+ }
+
+ ///
+ /// Printed width of the symbol in dots where the symbology's own rules fix it, null otherwise. Each formula counts the elements of the
+ /// symbology (see the comments); the ones with wide bars use the wide width in dots, as ZPL does.
+ ///
+ private static int? MeasureDots(BarcodeElement b, string data, int n, int? wide)
+ {
+ int w = wide ?? n;
+ switch (b.Symbology)
+ {
+ case BarcodeSymbology.Code128:
+ {
+ // Only the automatic mode is measured: the other modes change the character sets the printer starts in. GS1-128 data
+ // (parentheses) is turned into an FNC1 sequence by the printer, so its length is not data.Length.
+ if (b.Code128Mode != Code128Mode.Automatic || data.Contains('(')) return null;
+ var values = Code128.Encode(data);
+ // Every symbol character is 11 modules wide; the stop character is 13 (it ends with the 2-module termination bar).
+ return values is null ? null : (11 * (values.Length - 1) + 13) * n;
+ }
+ case BarcodeSymbology.Code39 or BarcodeSymbology.Logmars:
+ {
+ // Start and stop '*' plus an optional mod 43 check character (LOGMARS always has it). A character is 9 elements, 3 of them wide,
+ // so 6 narrow + 3 wide; one narrow inter-character gap sits between characters.
+ int chars = data.Length + 2 + (b.CheckDigit || b.Symbology == BarcodeSymbology.Logmars ? 1 : 0);
+ return chars * (6 * n + 3 * w) + (chars - 1) * n;
+ }
+ case BarcodeSymbology.Code93:
+ {
+ // Start, data, two check characters (C and K), stop: 9 modules each, plus the 1-module termination bar.
+ return (data.Length + 4) * 9 * n + n;
+ }
+ case BarcodeSymbology.Interleaved2Of5:
+ {
+ // Digits are paired (5 bars of the first digit interleaved with 5 spaces of the second): 10 elements, 4 wide and 6 narrow.
+ // Start pattern is 4 narrow elements, stop is wide bar + 2 narrow. The printer's check digit counts as a digit.
+ int digits = data.Length + (b.CheckDigit ? 1 : 0);
+ if (digits % 2 != 0) return null;
+ return 4 * n + digits / 2 * (6 * n + 4 * w) + (w + 2 * n);
+ }
+ // The EAN/UPC family has a fixed number of modules whatever the data.
+ case BarcodeSymbology.Ean13 or BarcodeSymbology.UpcA: return 95 * n;
+ case BarcodeSymbology.Ean8: return 67 * n;
+ case BarcodeSymbology.UpcE: return 51 * n;
+ case BarcodeSymbology.QrCode:
+ {
+ if (b.QrModel != 2 || data.Length == 0) return null;
+ var fit = QrSizing.Analyze(data, b.QrErrorCorrection);
+ return fit.Fits ? fit.Modules * n : null;
+ }
+ default:
+ return null;
+ }
+ }
+
+ ///
+ /// The quiet zone around the symbol in dots, per side of the label, for a field printed with the effective rotation
+ /// . A linear symbol needs the margin only along its reading direction: a symbol rotated 90 degrees reads top
+ /// to bottom, so its margins are above and below. 2D and stacked symbols need it on all four sides.
+ ///
+ public static QuietZoneDots QuietZoneBox(BarcodeElement b, FieldRotation rotation)
+ {
+ ArgumentNullException.ThrowIfNull(b);
+ if (ModuleDotsOf(b) is not { } module) return default;
+ module = Math.Max(1, module);
+ var q = QuietZoneOf(b.Symbology);
+ int start = q.LeftModules * module, end = q.RightModules * module;
+ if (q.AllSides) return new QuietZoneDots(start, start, end, end);
+ return rotation switch
+ {
+ FieldRotation.Normal => new QuietZoneDots(start, 0, end, 0),
+ FieldRotation.Inverted180 => new QuietZoneDots(end, 0, start, 0),
+ // Rotated 90 degrees clockwise the start of the symbol is at the top; rotated 270 it is at the bottom.
+ FieldRotation.Rotated90 => new QuietZoneDots(0, start, 0, end),
+ _ => new QuietZoneDots(0, end, 0, start),
+ };
+ }
+
+ ///
+ /// Checks the printer reality and the quiet zone of one barcode. All findings are warnings: the label still prints, it may just not scan.
+ ///
+ /// The barcode.
+ /// Its data, variables resolved.
+ /// The label (resolution, width and default rotation).
+ public static List Check(BarcodeElement b, string data, LabelDocument doc)
+ {
+ ArgumentNullException.ThrowIfNull(b);
+ ArgumentNullException.ThrowIfNull(doc);
+ var issues = new List();
+ int dpi = (int)doc.Dpi;
+ // An out-of-range module is already an error from BarcodeValidator; do not pile a warning on top of it.
+ if (ModuleDotsOf(b) is not { } moduleDots || moduleDots < 1 || moduleDots > 10) return issues;
+ var report = Analyze(b, data, dpi);
+ if (report is null) return issues;
+ var info = BarcodeCatalog.Get(b.Symbology);
+
+ string dots = moduleDots.ToString(CultureInfo.InvariantCulture);
+ string mils = report.XDimensionMils.ToString("0.#", CultureInfo.InvariantCulture);
+ string mm = report.XDimensionMm.ToString("0.###", CultureInfo.InvariantCulture);
+ string dpiText = dpi.ToString(CultureInfo.InvariantCulture);
+
+ if (moduleDots == 1 && dpi >= HighDpi)
+ // At 300 dpi and above one dot is 0.085 mm or less: below what most scanners resolve, and any dot gain closes the gaps.
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, OneDotKey, b.Id, dots, mils, mm, dpiText));
+ else if (info.Category == BarcodeCategory.Linear && dpi < HighDpi && moduleDots < MinNarrowDots203)
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, ThinBarKey, b.Id, dots, mils, mm, dpiText));
+
+ // Quiet-zone fit: only along the label width and only when the symbol is printed upright (its position is then the left edge); a
+ // rotated field's extent depends on the origin rules of the rotation and is left to the canvas guide.
+ var rotation = b.Rotation ?? doc.DefaultRotation;
+ if (rotation == FieldRotation.Normal && report.SymbolWidthDots is { } symbol && b.X >= 0 && b.X <= 32000)
+ {
+ int from = b.X - report.QuietLeftDots, to = b.X + symbol + report.QuietRightDots;
+ if (from < 0 || to > doc.WidthDots)
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, QuietZoneKey, b.Id,
+ MmOf(from, dpi).ToString("0.#", CultureInfo.InvariantCulture),
+ MmOf(to, dpi).ToString("0.#", CultureInfo.InvariantCulture),
+ MmOf(doc.WidthDots, dpi).ToString("0.#", CultureInfo.InvariantCulture)));
+ }
+ return issues;
+ }
+}
diff --git a/src/Rotula.Core/Elements/LabelElement.cs b/src/Rotula.Core/Elements/LabelElement.cs
index b3b8f22..ffaf04f 100644
--- a/src/Rotula.Core/Elements/LabelElement.cs
+++ b/src/Rotula.Core/Elements/LabelElement.cs
@@ -9,10 +9,30 @@ public abstract class LabelElement
public string Id { get; set; } = Guid.NewGuid().ToString("N");
/// Optional designer-facing name.
public string? Name { get; set; }
- /// Horizontal position in dots.
+ ///
+ /// Horizontal position in dots. Without an it is the absolute origin (the ^FO/^FT value); with one it is
+ /// the horizontal distance from the reference's anchor point to this element's own anchor point (negative goes left).
+ ///
public int X { get; set; }
- /// Vertical position in dots.
+ /// Vertical position in dots; see for what it means with and without an .
public int Y { get; set; }
+
+ ///
+ /// Relative positioning: the point of this element that / place, measured from the same point of the
+ /// reference (). Null means the position is absolute (the top-left or typeset origin from the label home), which is
+ /// how every element behaved before anchors existed. The ZPL always gets absolute coordinates: works
+ /// them out when the label is generated or drawn, so a label resize or a longer text keeps an anchored object where it was asked to be.
+ ///
+ public AnchorPoint? Anchor { get; set; }
+
+ /// What the anchor is measured from: the label (default) or another element. Ignored while is null.
+ public AnchorReference RelativeTo { get; set; } = AnchorReference.Label;
+
+ /// Id of the element this one is positioned relative to when is .
+ public string? ReferenceElementId { get; set; }
+
+ /// True when the element has an anchor, so its X/Y are an offset rather than an absolute origin.
+ public bool IsAnchored => Anchor is not null;
/// ^FO (top-left) or ^FT (baseline) positioning.
public FieldOriginKind OriginKind { get; set; } = FieldOriginKind.Origin;
/// Explicit rotation, or null to inherit the document ^FW default.
@@ -38,7 +58,11 @@ public abstract class LabelElement
///
public bool Locked { get; set; }
- /// Copies the printing and locking settings (, , ) onto another element, so every copy path keeps them.
+ ///
+ /// Copies the printing, locking and anchoring settings (, , ,
+ /// , , ) onto another element, so every copy path keeps them.
+ /// The position itself (X/Y) is not copied: the copy paths scale or offset it themselves.
+ ///
/// The element that receives the settings.
public void CopyObjectSettingsTo(LabelElement target)
{
@@ -46,6 +70,9 @@ public void CopyObjectSettingsTo(LabelElement target)
target.PrintCondition = PrintCondition?.Clone();
target.NotPrintable = NotPrintable;
target.Locked = Locked;
+ target.Anchor = Anchor;
+ target.RelativeTo = RelativeTo;
+ target.ReferenceElementId = ReferenceElementId;
}
}
diff --git a/src/Rotula.Core/Imaging/ImageProcessor.cs b/src/Rotula.Core/Imaging/ImageProcessor.cs
index beb1b98..37750f3 100644
--- a/src/Rotula.Core/Imaging/ImageProcessor.cs
+++ b/src/Rotula.Core/Imaging/ImageProcessor.cs
@@ -29,11 +29,27 @@ public static (int Width, int Height) ResolveSize(ImageElement element)
return (w, h);
}
+ ///
+ /// Most pixels (width times height of the scaled image) will work on: 40 million, which holds an 8 x 12 inch label
+ /// at 600 dpi. Scaling needs four bytes per pixel plus a copy for error diffusion, so the per-axis limits alone (9999 x 32000 is 320 million
+ /// pixels) would let a small file ask for several gigabytes.
+ ///
+ public const long MaxPixels = 40_000_000;
+
+ /// True when the image, at the size gives it, is within .
+ public static bool IsWithinPixelLimit(ImageElement element)
+ {
+ var (w, h) = ResolveSize(element);
+ return (long)w * h <= MaxPixels;
+ }
+
/// Scales, dithers and rotates the source into printable dots.
+ /// The element has no source, or the scaled image is over (check first to report it nicely).
public static MonochromeBitmap ToMonochrome(ImageElement element)
{
var src = element.Source ?? throw new InvalidOperationException("Image element has no source.");
var (w, h) = ResolveSize(element);
+ if ((long)w * h > MaxPixels) throw new InvalidOperationException("Image is larger than " + MaxPixels + " pixels.");
float[] lum = Scale(src, w, h, element.Invert);
bool[] black = Reduce(lum, w, h, element.Dither, element.Threshold);
var (rw, rh, rotated) = Rotate(black, w, h, element.Rotation ?? FieldRotation.Normal);
diff --git a/src/Rotula.Core/Layout/AnchorResolver.cs b/src/Rotula.Core/Layout/AnchorResolver.cs
new file mode 100644
index 0000000..0f507a0
--- /dev/null
+++ b/src/Rotula.Core/Layout/AnchorResolver.cs
@@ -0,0 +1,245 @@
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+using Rotula.Core.Text;
+using Rotula.Core.Validation;
+
+namespace Rotula.Core.Layout;
+
+///
+/// The absolute positions of every element of a label for one record, worked out from the anchors (see ).
+/// Elements without an anchor keep their own X/Y; anchored ones are placed so that their anchor point lies at the reference's anchor point
+/// plus their X/Y offset. Resolution is lazy and memoized, so asking for one element only measures the chain it depends on.
+///
+public sealed class AnchorLayout
+{
+ private readonly LabelDocument _doc;
+ private readonly Func _sizeOf;
+ private readonly Dictionary _byId;
+ private readonly Dictionary _origins = new(StringComparer.Ordinal);
+ private readonly Dictionary _sizes = new(StringComparer.Ordinal);
+ private readonly HashSet _cycles;
+ private readonly HashSet _missing = new(StringComparer.Ordinal);
+ private readonly List _issues = new();
+
+ internal AnchorLayout(LabelDocument doc, Func sizeOf)
+ {
+ _doc = doc;
+ _sizeOf = sizeOf;
+ _byId = new Dictionary(StringComparer.Ordinal);
+ // The last element wins when two share an id; ids are unique in practice, so this only keeps the lookup from throwing.
+ foreach (var e in doc.Elements) _byId[e.Id] = e;
+ _cycles = AnchorResolver.FindCycles(doc);
+ // Every problem is found up front, so the issues are complete before any position is asked for (positions resolve lazily).
+ foreach (var e in doc.Elements)
+ {
+ if (e.Anchor is null) continue;
+ if (_cycles.Contains(e.Id))
+ {
+ string refId = e.ReferenceElementId ?? string.Empty;
+ string other = _byId.TryGetValue(refId, out var r) ? AnchorResolver.Describe(r) : refId;
+ _issues.Add(new ValidationIssue(ValidationSeverity.Error, AnchorResolver.CycleIssue, e.Id, other));
+ }
+ else if (e.RelativeTo == AnchorReference.Element && !(e.ReferenceElementId is { } id && _byId.TryGetValue(id, out var target) && !ReferenceEquals(target, e)))
+ {
+ // The object was deleted or the element came from another label: the label stands in, so the object still has a place.
+ _missing.Add(e.Id);
+ _issues.Add(new ValidationIssue(ValidationSeverity.Warning, AnchorResolver.MissingReferenceIssue, e.Id, e.ReferenceElementId ?? string.Empty));
+ }
+ }
+ }
+
+ /// The label the layout was built for.
+ public LabelDocument Document => _doc;
+
+ /// Problems found: elements that reference each other in a circle (an error) and references to elements that no longer exist (a warning).
+ public IReadOnlyList Issues => _issues;
+
+ /// True when the element is part of a reference circle and therefore has no position of its own.
+ public bool IsOnCycle(LabelElement e) => _cycles.Contains(e.Id);
+
+ /// The unrotated size of the element, from the measurer the layout was built with (memoized).
+ public (int Width, int Height) SizeOf(LabelElement e)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ if (!_sizes.TryGetValue(e.Id, out var size))
+ {
+ size = _sizeOf(e);
+ _sizes[e.Id] = size;
+ }
+ return size;
+ }
+
+ /// The absolute origin of the element: the point the ZPL ^FO/^FT gets, relative to the label home.
+ public (int X, int Y) OriginOf(LabelElement e)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ if (_origins.TryGetValue(e.Id, out var known)) return known;
+ var origin = Compute(e);
+ _origins[e.Id] = origin;
+ return origin;
+ }
+
+ /// The box the element occupies, in absolute dots relative to the label home (its estimated size placed at its resolved origin).
+ public DotRect BoundsOf(LabelElement e)
+ {
+ var (w, h) = SizeOf(e);
+ var (x, y) = OriginOf(e);
+ return ElementGeometry.BoxAtOrigin(e, _doc, w, h).Offset(x, y);
+ }
+
+ /// The anchor point of the reference of for a given anchor and reference choice (the label or an element).
+ public (double X, double Y) ReferencePoint(LabelElement e, AnchorPoint anchor, AnchorReference reference, string? referenceElementId)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ // A target on a reference circle still answers (with its raw numbers), so an element hanging off a circle keeps a place near it.
+ if (reference == AnchorReference.Element && referenceElementId is not null && _byId.TryGetValue(referenceElementId, out var target) && !ReferenceEquals(target, e))
+ return BoundsOf(target).PointAt(anchor);
+ return ElementGeometry.LabelBox(_doc).PointAt(anchor);
+ }
+
+ private (int X, int Y) Compute(LabelElement e)
+ {
+ // Absolute, or on a circle (reported in Issues): the raw numbers are the only position there is.
+ if (e.Anchor is not { } anchor || _cycles.Contains(e.Id)) return (e.X, e.Y);
+
+ // A reference that does not exist (reported in Issues) is replaced by the label.
+ var reference = _missing.Contains(e.Id) ? AnchorReference.Label : e.RelativeTo;
+ var (rx, ry) = ReferencePoint(e, anchor, reference, e.ReferenceElementId);
+ var (w, h) = SizeOf(e);
+ var (ax, ay) = ElementGeometry.BoxAtOrigin(e, _doc, w, h).PointAt(anchor);
+ // The element's own anchor point must land on the reference point plus the offset; the origin is that point minus where the anchor
+ // lies within the element's own box.
+ return (Round(rx + e.X - ax), Round(ry + e.Y - ay));
+ }
+
+ private static int Round(double v) => (int)Math.Round(v, MidpointRounding.AwayFromZero);
+}
+
+///
+/// Turns anchors into absolute coordinates. It is the single place that decides where an anchored object goes: the ZPL generator (so every
+/// record of a data merge), the preview and the designer's selection frames all call it, so what the canvas shows is what is printed.
+///
+public static class AnchorResolver
+{
+ /// Issue key (error): the element and the element it references, directly or through others, are positioned relative to each other.
+ public const string CycleIssue = "Element.Anchor.Cycle";
+
+ /// Issue key (warning): the referenced element does not exist; the label is used instead.
+ public const string MissingReferenceIssue = "Element.Anchor.MissingReference";
+
+ /// True when any element of the label has an anchor, so positions need resolving at all.
+ public static bool HasAnchors(LabelDocument doc)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ return doc.Elements.Any(e => e.Anchor is not null);
+ }
+
+ ///
+ /// Builds the layout of a label for one record. Sizes come from unless is given
+ /// (a caller that has already measured its elements passes that, so it never measures twice).
+ ///
+ /// The label.
+ /// The record's values (already resolved), or null for the sample values. Text widths depend on it.
+ /// Measures Windows fonts exactly; may be null.
+ /// Replaces the built-in size estimate.
+ public static AnchorLayout Resolve(LabelDocument doc, IReadOnlyDictionary? data = null, ITextRasterizer? rasterizer = null,
+ Func? sizeOf = null)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ return new AnchorLayout(doc, sizeOf ?? (e => ElementSizeEstimator.Estimate(doc, e, data, rasterizer)));
+ }
+
+ /// The ids of every element that sits on a reference circle (A relative to B relative to A, or an element relative to itself).
+ public static HashSet FindCycles(LabelDocument doc)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ var byId = new Dictionary(StringComparer.Ordinal);
+ foreach (var e in doc.Elements) byId[e.Id] = e;
+ var onCycle = new HashSet(StringComparer.Ordinal);
+ foreach (var start in doc.Elements)
+ {
+ // Walk the chain from each element; meeting the start again means it is on a circle.
+ var seen = new HashSet(StringComparer.Ordinal) { start.Id };
+ var current = start;
+ while (Next(current, byId) is { } next)
+ {
+ if (next.Id == start.Id) { onCycle.Add(start.Id); break; }
+ if (!seen.Add(next.Id)) break; // joined a circle further down, but the start itself is not on it
+ current = next;
+ }
+ }
+ return onCycle;
+ }
+
+ ///
+ /// True when making relative to would close a circle: the reference is the
+ /// element itself, or is (directly or through other elements) positioned relative to it.
+ ///
+ public static bool WouldCreateCycle(LabelDocument doc, string elementId, string referenceElementId)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ if (string.Equals(elementId, referenceElementId, StringComparison.Ordinal)) return true;
+ var byId = new Dictionary(StringComparer.Ordinal);
+ foreach (var e in doc.Elements) byId[e.Id] = e;
+ var seen = new HashSet(StringComparer.Ordinal);
+ var current = byId.GetValueOrDefault(referenceElementId);
+ while (current is not null && seen.Add(current.Id))
+ {
+ if (current.Id == elementId) return true;
+ current = Next(current, byId);
+ }
+ return false;
+ }
+
+ ///
+ /// True when the element follows one of : it is positioned relative to it, directly or through other elements.
+ /// Moving that set moves the element along, so a move must not add the distance to it a second time.
+ ///
+ public static bool FollowsAny(LabelDocument doc, LabelElement e, IReadOnlySet ids)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ ArgumentNullException.ThrowIfNull(e);
+ ArgumentNullException.ThrowIfNull(ids);
+ var byId = new Dictionary(StringComparer.Ordinal);
+ foreach (var el in doc.Elements) byId[el.Id] = el;
+ var seen = new HashSet(StringComparer.Ordinal) { e.Id };
+ var current = Next(e, byId);
+ while (current is not null && seen.Add(current.Id))
+ {
+ if (ids.Contains(current.Id)) return true;
+ current = Next(current, byId);
+ }
+ return false;
+ }
+
+ ///
+ /// The X/Y an element needs so that it stays exactly where it is when its anchor or reference changes to the given values (null anchor
+ /// means absolute). The designer uses it so that choosing an anchor changes the numbers, never the picture.
+ ///
+ /// The current layout of the label (where the element is now).
+ /// The element.
+ /// The new anchor, or null for an absolute position.
+ /// The new reference.
+ /// The new reference element, when is an element.
+ public static (int X, int Y) OffsetKeepingPosition(AnchorLayout layout, LabelElement e, AnchorPoint? anchor, AnchorReference reference, string? referenceElementId)
+ {
+ ArgumentNullException.ThrowIfNull(layout);
+ ArgumentNullException.ThrowIfNull(e);
+ var (ox, oy) = layout.OriginOf(e);
+ if (anchor is not { } a) return (ox, oy);
+ var (rx, ry) = layout.ReferencePoint(e, a, reference, referenceElementId);
+ var (w, h) = layout.SizeOf(e);
+ var (ax, ay) = ElementGeometry.BoxAtOrigin(e, layout.Document, w, h).PointAt(a);
+ return ((int)Math.Round(ox + ax - rx, MidpointRounding.AwayFromZero), (int)Math.Round(oy + ay - ry, MidpointRounding.AwayFromZero));
+ }
+
+ /// The element's name, or its id when it has none, for messages.
+ public static string Describe(LabelElement e)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ return string.IsNullOrWhiteSpace(e.Name) ? e.Id : e.Name;
+ }
+
+ private static LabelElement? Next(LabelElement e, Dictionary byId) =>
+ e.Anchor is not null && e.RelativeTo == AnchorReference.Element && e.ReferenceElementId is { } id ? byId.GetValueOrDefault(id) : null;
+}
diff --git a/src/Rotula.Core/Layout/DotRect.cs b/src/Rotula.Core/Layout/DotRect.cs
new file mode 100644
index 0000000..45d2e9f
--- /dev/null
+++ b/src/Rotula.Core/Layout/DotRect.cs
@@ -0,0 +1,40 @@
+using Rotula.Core.Model;
+
+namespace Rotula.Core.Layout;
+
+/// An axis-aligned rectangle in label dots (y grows downwards).
+/// Left edge.
+/// Top edge.
+/// Width in dots.
+/// Height in dots.
+public readonly record struct DotRect(double X, double Y, double Width, double Height)
+{
+ /// Right edge.
+ public double Right => X + Width;
+
+ /// Bottom edge.
+ public double Bottom => Y + Height;
+
+ /// Horizontal centre.
+ public double CenterX => X + Width / 2;
+
+ /// Vertical centre.
+ public double CenterY => Y + Height / 2;
+
+ /// The same rectangle moved by a distance.
+ public DotRect Offset(double dx, double dy) => new(X + dx, Y + dy, Width, Height);
+
+ /// One of the nine anchor points of the rectangle.
+ public (double X, double Y) PointAt(AnchorPoint anchor) => anchor switch
+ {
+ AnchorPoint.TopLeft => (X, Y),
+ AnchorPoint.TopCenter => (CenterX, Y),
+ AnchorPoint.TopRight => (Right, Y),
+ AnchorPoint.MiddleLeft => (X, CenterY),
+ AnchorPoint.Center => (CenterX, CenterY),
+ AnchorPoint.MiddleRight => (Right, CenterY),
+ AnchorPoint.BottomLeft => (X, Bottom),
+ AnchorPoint.BottomCenter => (CenterX, Bottom),
+ _ => (Right, Bottom),
+ };
+}
diff --git a/src/Rotula.Core/Layout/ElementGeometry.cs b/src/Rotula.Core/Layout/ElementGeometry.cs
new file mode 100644
index 0000000..7d90084
--- /dev/null
+++ b/src/Rotula.Core/Layout/ElementGeometry.cs
@@ -0,0 +1,70 @@
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+
+namespace Rotula.Core.Layout;
+
+///
+/// Where an element's box lies around its origin point. ZPL places a field by one point (^FO top-left, or ^FT baseline /
+/// bottom-left) and turns the field clockwise around that point, so the box an element occupies depends on its origin kind and rotation as
+/// well as its size. The same rules are used by the ZPL generator, the preview and the designer so they never disagree about where a box is.
+///
+public static class ElementGeometry
+{
+ /// The rotation an element is drawn with: its own, or the label default (^FW).
+ public static FieldRotation EffectiveRotation(LabelElement e, LabelDocument doc)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ ArgumentNullException.ThrowIfNull(doc);
+ return e switch
+ {
+ // ^GC, ^GE and ^GD have no orientation parameter, so rotation never changes their box.
+ CircleElement or EllipseElement or DiagonalLineElement => FieldRotation.Normal,
+ // The dots of an image are turned before they are sent (only the element's own rotation applies, never ^FW).
+ ImageElement img => img.Rotation ?? FieldRotation.Normal,
+ _ => e.Rotation ?? doc.DefaultRotation,
+ };
+ }
+
+ ///
+ /// The box of an element whose unrotated size is x , relative to its origin point (0, 0):
+ /// the origin kind decides which point of the box the origin is, and the rotation turns the box clockwise around it.
+ ///
+ public static DotRect BoxAtOrigin(LabelElement e, LabelDocument doc, int width, int height)
+ {
+ ArgumentNullException.ThrowIfNull(e);
+ var rotation = EffectiveRotation(e, doc);
+ int w = Math.Max(1, width), h = Math.Max(1, height);
+ bool swap = rotation is FieldRotation.Rotated90 or FieldRotation.Rotated270;
+ if (e.OriginKind == FieldOriginKind.Origin)
+ return new DotRect(0, 0, 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) };
+ double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue;
+ foreach (var (cx, cy) in corners)
+ {
+ // Clockwise quarter turns in a y-down plane: 90 -> (-y, x), 180 -> (-x, -y), 270 -> (y, -x).
+ var (rx, ry) = rotation switch
+ {
+ FieldRotation.Rotated90 => (-cy, cx),
+ FieldRotation.Inverted180 => (-cx, -cy),
+ FieldRotation.Rotated270 => (cy, -cx),
+ _ => (cx, cy),
+ };
+ minX = Math.Min(minX, rx); maxX = Math.Max(maxX, rx);
+ minY = Math.Min(minY, ry); maxY = Math.Max(maxY, ry);
+ }
+ return new DotRect(minX, minY, maxX - minX, maxY - minY);
+ }
+
+ ///
+ /// The label rectangle in element coordinates. Element positions count from the label home (^LH, the left and top margins), so the
+ /// label's own top-left corner is at minus the margins and its size is the full label, margins included.
+ ///
+ public static DotRect LabelBox(LabelDocument doc)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ int hx = doc.Margins.Left.ToDots(doc.Dpi), hy = doc.Margins.Top.ToDots(doc.Dpi);
+ return new DotRect(-hx, -hy, doc.WidthDots, doc.HeightDots);
+ }
+}
diff --git a/src/Rotula.Core/Layout/ElementSizeEstimator.cs b/src/Rotula.Core/Layout/ElementSizeEstimator.cs
new file mode 100644
index 0000000..46a8e0f
--- /dev/null
+++ b/src/Rotula.Core/Layout/ElementSizeEstimator.cs
@@ -0,0 +1,220 @@
+using Rotula.Core.Barcodes;
+using Rotula.Core.Elements;
+using Rotula.Core.Imaging;
+using Rotula.Core.Model;
+using Rotula.Core.Text;
+using Rotula.Core.Validation;
+using Rotula.Core.Zpl;
+
+namespace Rotula.Core.Layout;
+
+///
+/// The size, in dots, that an element takes on the label before rotation. Relative positioning needs it to find a corner or centre of an
+/// object, and the ZPL generator cannot draw anything, so this is the one place that answers "how big is it" from the model alone.
+///
+///
+/// Boxes, lines, circles, ellipses, diagonals, symbols and images have an exact size in the model. Text in a Windows font is measured
+/// exactly by the (the same layout the printed picture uses). Text in a printer font and barcodes are
+/// estimates: the printer's letter widths are its own (so the width comes from , the estimate behind the
+/// overflow warning), and a barcode's width is the module count of its symbology times the module width, with the human-readable band sized as
+/// the preview draws it. An anchored object can therefore sit a few dots from where the estimate puts it on paper; the canvas and the ZPL use
+/// the same estimate, so they agree with each other.
+///
+public static class ElementSizeEstimator
+{
+ // What the preview draws for a raw ZPL field: the real content is unknown.
+ private const int RawMarkerWidth = 100;
+ private const int RawMarkerHeight = 40;
+
+ /// Estimates the unrotated size of for one record.
+ /// The label (resolution, default font, variables).
+ /// The element.
+ /// The record's values, already resolved; null for the sample values.
+ /// Measures Windows fonts exactly; without one such a text is estimated like a printer font.
+ public static (int Width, int Height) Estimate(LabelDocument doc, LabelElement e, IReadOnlyDictionary? data = null, ITextRasterizer? rasterizer = null)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ ArgumentNullException.ThrowIfNull(e);
+ switch (e)
+ {
+ case BoxElement b: return (b.WidthDots, b.HeightDots);
+ case LineElement l: return l.Direction == LineDirection.Horizontal ? (l.LengthDots, l.ThicknessDots) : (l.ThicknessDots, l.LengthDots);
+ case CircleElement c: return (c.DiameterDots, c.DiameterDots);
+ case EllipseElement el: return (el.WidthDots, el.HeightDots);
+ case DiagonalLineElement d: return (d.WidthDots, d.HeightDots);
+ case SymbolElement s:
+ {
+ // ^GS with 0 takes the size of the default font; a width of 0 means "as high as it is tall".
+ int h = s.HeightDots > 0 ? s.HeightDots : doc.DefaultFont?.Height ?? 30;
+ return (s.WidthDots > 0 ? s.WidthDots : h, h);
+ }
+ case ImageElement img: return img.Source is null ? (Math.Max(1, img.WidthDots), Math.Max(1, img.HeightDots)) : ImageProcessor.ResolveSize(img);
+ case RecallPlaceholderElement recall:
+ {
+ // A graphic the same file downloads has known dots; anything else is drawn at the placeholder size the designer shows.
+ if (StoredGraphic.Find(doc.StoredGraphics, recall.Reference) is { } g && recall.Command.Equals("XG", StringComparison.OrdinalIgnoreCase))
+ {
+ var (mx, my) = recall.GetMagnification();
+ return (g.WidthDots * mx, g.HeightDots * my);
+ }
+ return (recall.WidthDots, recall.HeightDots);
+ }
+ case RawZplElement: return (RawMarkerWidth, RawMarkerHeight);
+ case TextElement t: return EstimateText(doc, t, data, rasterizer);
+ case BarcodeElement bc: return EstimateBarcode(doc, bc, data);
+ default: return (1, 1);
+ }
+ }
+
+ // ---- text ----------------------------------------------------------------------------------
+
+ private static (int Width, int Height) EstimateText(LabelDocument doc, TextElement t, IReadOnlyDictionary? data, ITextRasterizer? rasterizer)
+ {
+ var scratch = new List();
+ string text = VariableResolver.Resolve(t.GetTemplate(), data, doc.Variables, t.Id, scratch);
+ bool laidOut = t.Block is not null || t.TextBox is not null;
+ // Outside a block the printer prints one line, so line breaks are spaces (the generator and the preview do the same).
+ if (!laidOut) text = text.Replace("\r\n", " ", StringComparison.Ordinal).Replace('\n', ' ').Replace('\r', ' ');
+ // The text fit decides the size or the line count this record gets; the same decision the ZPL carries.
+ var fitted = TextFitResolver.Resolve(doc, t, text, rasterizer);
+
+ if (t.Font is { IsWindows: true } windows && rasterizer is not null && windows.Validate() is null)
+ {
+ var font = windows;
+ if (fitted?.PointSize is { } size) { font = windows.Clone(); font.PointSize = size; }
+ if (text.Length == 0) return (1, Math.Max(1, (int)Math.Round(FontChoice.PointsToDots(font.PointSize, (int)doc.Dpi))));
+ var request = new TextRasterRequest(text, font, (int)doc.Dpi, t.Block?.WidthDots ?? t.TextBox?.WidthDots ?? 0, t.TextBox?.HeightDots ?? 0,
+ fitted?.MaxLines ?? t.Block?.MaxLines ?? 0, t.Block?.LineSpacing ?? 0, t.Block?.Alignment ?? TextAlignment.Left, t.Block?.HangingIndent ?? 0);
+ var m = rasterizer.Measure(request);
+ return (Math.Max(1, m.WidthDots), Math.Max(1, m.HeightDots));
+ }
+
+ // Printer font: nominal character sizes (see the class remarks). A Windows font without a rasterizer is treated the same way, at its
+ // point size converted to dots, so the answer is at least the right order of magnitude.
+ var def = doc.DefaultFont ?? new FontSpec('0', 30, 30);
+ int height = fitted?.FontHeight ?? (t.Font is { IsWindows: true } w2 ? (int)Math.Round(FontChoice.PointsToDots(w2.PointSize, (int)doc.Dpi)) : t.FontHeight ?? def.Height);
+ int? width = fitted?.FontWidth ?? t.FontWidth;
+ double charWidth = TextFitEstimator.CharacterWidth(t, doc.DefaultFont, height, width);
+ if (t.TextBox is { } box) return (box.WidthDots, box.HeightDots);
+ if (t.Block is { } block)
+ {
+ int lines = Math.Clamp(TextFitEstimator.CountLines(t, text, doc.DefaultFont, height, width), 1, Math.Max(1, fitted?.MaxLines ?? block.MaxLines));
+ return (block.WidthDots, Math.Max(1, lines * height + (lines - 1) * block.LineSpacing));
+ }
+ return (Math.Max(1, (int)Math.Round(text.Length * charWidth)), Math.Max(1, height));
+ }
+
+ // ---- barcodes ------------------------------------------------------------------------------
+
+ private static (int Width, int Height) EstimateBarcode(LabelDocument doc, BarcodeElement b, IReadOnlyDictionary? data)
+ {
+ var scratch = new List();
+ string payload = Barcodes.QrContent.QrContentBuilder.ResolveForElement(b, data, doc.Variables, scratch)
+ ?? VariableResolver.Resolve(b.Data, data, doc.Variables, b.Id, scratch);
+ var info = BarcodeCatalog.Get(b.Symbology);
+ // Symbologies the preview cannot draw are shown as a captioned placeholder; its size is the one the designer works with.
+ if (BarcodeCatalog.GetPreviewLimitation(b) is not null)
+ return (Math.Clamp(80 + b.Data.Length * Math.Max(1, b.ModuleWidth) * 6, 200, 600), Math.Max(40, b.Height));
+
+ int n = Math.Max(1, payload.Length);
+ double r = Math.Clamp(b.WideBarRatio, 2.0, 3.0);
+ int module = Math.Max(1, info.UsesModuleSize ? b.ModuleSize : b.ModuleWidth);
+ switch (b.Symbology)
+ {
+ case BarcodeSymbology.QrCode:
+ {
+ int modules = QrSizing.Analyze(payload, b.QrErrorCorrection, b.QrModel).Modules;
+ if (modules <= 0) modules = 21;
+ return (modules * module, modules * module);
+ }
+ case BarcodeSymbology.DataMatrix:
+ {
+ var (cols, rows) = DataMatrixSize(n, b.Columns, b.Rows);
+ return (cols * module, rows * module);
+ }
+ case BarcodeSymbology.Aztec:
+ {
+ // Full-range symbols: 15 modules plus 4 per layer; one layer per dozen characters is a fair middle ground.
+ int layers = Math.Clamp((n + 11) / 12 + 1, 1, 32);
+ int modules = 15 + 4 * layers;
+ return (modules * module, modules * module);
+ }
+ case BarcodeSymbology.MaxiCode:
+ {
+ // Fixed-size symbol, about 28 x 27 mm; the preview draws modules 7 dots apart at 203 dpi.
+ int side = (int)Math.Round(28.0 / 25.4 * (int)doc.Dpi);
+ return (side, side);
+ }
+ case BarcodeSymbology.Pdf417:
+ {
+ int cols = b.Columns > 0 ? b.Columns : Math.Clamp((int)Math.Ceiling(Math.Sqrt(n / 2.0)), 1, 30);
+ int codewords = (int)Math.Ceiling(n / 1.8) + 1 + (1 << (Math.Clamp(b.Pdf417SecurityLevel, 0, 8) + 1));
+ int rows = b.Rows > 0 ? b.Rows : Math.Clamp((int)Math.Ceiling(codewords / (double)cols), 3, 90);
+ // Start, left row indicator, data columns, right row indicator and stop (the truncated form drops the right side).
+ int modules = b.Pdf417Truncate ? 17 * (cols + 2) + 1 : 17 * (cols + 4) + 1;
+ return (modules * module, rows * Math.Max(1, b.Height));
+ }
+ case BarcodeSymbology.DataBar:
+ {
+ int modules = b.DataBarType switch
+ {
+ DataBarType.Stacked or DataBarType.StackedOmnidirectional => 50,
+ DataBarType.Limited => 79,
+ DataBarType.Expanded => Math.Clamp(4 + 21 * Math.Min(Math.Max(2, b.DataBarSegmentsPerRow), Math.Max(2, (n + 1) / 2)), 50, 500),
+ _ => 96,
+ };
+ return (modules * module, LinearHeight(b, info, module));
+ }
+ }
+
+ // Where the symbology's own rules fix the width (Code 128 with its code sets, Code 39, Code 93, Interleaved 2 of 5, EAN/UPC), use the
+ // width the size readout measures: the per-character formulas below assume the widest encoding, and Code 128 packs two digits into one
+ // character, so a tracking number came out about a third too wide and a right-anchored one landed that far too left.
+ if (BarcodeSizing.Analyze(b, payload, (int)doc.Dpi)?.SymbolWidthDots is { } measured and > 0)
+ return (measured, LinearHeight(b, info, module));
+
+ // Linear codes: modules per character of the symbology, plus start/stop. The ratio r is the wide-to-narrow bar ratio (^BY).
+ double modules1D = b.Symbology switch
+ {
+ BarcodeSymbology.Code128 => 11 * (n + 3) + 2,
+ BarcodeSymbology.Code39 or BarcodeSymbology.Logmars => (3 * r + 7) * (n + 2 + (b.CheckDigit ? 1 : 0)),
+ BarcodeSymbology.Code93 => 9 * (n + 4) + 1,
+ BarcodeSymbology.Ean13 or BarcodeSymbology.UpcA => 95,
+ BarcodeSymbology.Ean8 => 67,
+ BarcodeSymbology.UpcE => 51,
+ BarcodeSymbology.UpcEanExtension => n <= 2 ? 20 : 47,
+ BarcodeSymbology.Interleaved2Of5 => ((n + (b.CheckDigit ? 1 : 0) + 1) / 2) * (4 * r + 6) + 6 + r,
+ BarcodeSymbology.Industrial2Of5 or BarcodeSymbology.Standard2Of5 => (2 * r + 8) * n + 4 * r + 12,
+ BarcodeSymbology.Code11 => (2 * r + 4) * (n + 2 + b.Code11CheckDigitCount),
+ BarcodeSymbology.Codabar => (3 * r + 5) * (n + 2),
+ BarcodeSymbology.Msi or BarcodeSymbology.Plessey => 4 * (1 + r) * (n + 2) + 2 * (1 + r),
+ // Postal codes print at a fixed pitch of about 22.5 bars per inch, 5 bars per digit plus the frame bars.
+ BarcodeSymbology.Postnet or BarcodeSymbology.Planet => (5 * n + 2) * (int)doc.Dpi / 22.5 / module,
+ _ => 11 * (n + 3) + 2,
+ };
+ return (Math.Max(1, (int)Math.Round(modules1D * module)), LinearHeight(b, info, module));
+ }
+
+ /// Bars plus the human-readable band, sized as the preview draws it (text at 8 modules, between 14 and 36 dots, plus a 4-dot gap).
+ private static int LinearHeight(BarcodeElement b, BarcodeSymbologyInfo info, int module)
+ {
+ bool text = info.SupportsHumanReadable && (info.AlwaysPrintsHumanReadable || b.HumanReadable);
+ int band = text ? (int)Math.Ceiling(Math.Clamp(module * 8.0, 14, 36)) + 4 : 0;
+ return Math.Max(1, b.Height) + band;
+ }
+
+ // ECC 200 symbol sizes (square) with their text capacity: the smallest that holds the data is the estimate.
+ private static readonly (int Side, int Capacity)[] DataMatrixSquares =
+ {
+ (10, 3), (12, 6), (14, 10), (16, 16), (18, 25), (20, 31), (22, 43), (24, 52), (26, 64), (32, 91), (36, 127), (40, 169), (44, 214), (48, 259),
+ (52, 304), (64, 418), (72, 550), (80, 682), (88, 862), (96, 1024), (104, 1222), (120, 1573), (132, 1954), (144, 2218),
+ };
+
+ private static (int Columns, int Rows) DataMatrixSize(int length, int columns, int rows)
+ {
+ if (columns > 0 && rows > 0) return (columns, rows);
+ foreach (var (side, capacity) in DataMatrixSquares)
+ if (capacity >= length) return (columns > 0 ? columns : side, rows > 0 ? rows : side);
+ return (144, 144);
+ }
+}
diff --git a/src/Rotula.Core/Merge/CsvReader.cs b/src/Rotula.Core/Merge/CsvReader.cs
index 2e32549..21f41cf 100644
--- a/src/Rotula.Core/Merge/CsvReader.cs
+++ b/src/Rotula.Core/Merge/CsvReader.cs
@@ -11,6 +11,12 @@ public sealed class CsvOptions
/// True when the first record holds column names; false generates Column1, Column2...
public bool HasHeader { get; set; } = true;
+
+ /// Most columns the file may have; a wider file is refused with a Merge.Table.TooWide issue instead of being padded.
+ public int MaxColumns { get; set; } = MergeLimits.DefaultMaxColumns;
+
+ /// Most cells (columns times data rows) the table may have; a larger file is refused with a Merge.Table.TooLarge issue.
+ public long MaxCells { get; set; } = MergeLimits.DefaultMaxCells;
}
///
@@ -67,6 +73,9 @@ public static MergeTable Read(TextReader reader, CsvOptions? options = null)
{
header = new List();
}
+ // Checked before any padding: padding every row to the widest one is what multiplies the size of a hostile file.
+ if (MergeLimits.Check(width, records.Count - start, options.MaxColumns, options.MaxCells) is { } tooBig)
+ return MergeLimits.Refuse(tooBig);
// Header shorter than the widest row: pad with blanks (named ColumnN) so no data is lost.
while (header.Count < width) header.Add(string.Empty);
diff --git a/src/Rotula.Core/Merge/MergeBatch.cs b/src/Rotula.Core/Merge/MergeBatch.cs
index aac1ab0..e3805d8 100644
--- a/src/Rotula.Core/Merge/MergeBatch.cs
+++ b/src/Rotula.Core/Merge/MergeBatch.cs
@@ -57,6 +57,18 @@ public sealed record MergedLabel(int RecordNumber, string Zpl, int ItemNumber =
/// with: serial numbers follow the position in the batch, which differs from the row number when only some rows are selected.
public sealed record MergedJob(string Zpl, int LastRecordNumber, int Labels, int ItemsThrough = 0);
+///
+/// One print job of a batch printed as pictures (Windows-driver mode): the labels of consecutive records, each with the values that fill it and
+/// its number of copies, instead of their joined ZPL text. Grouped exactly like (see ).
+///
+/// The labels of this job, in print order. Their is empty: a picture job never sends ZPL, so
+/// the text is not kept (only its length was needed, to cut the jobs like the ZPL ones).
+/// 1-based data row number of the last record in the job (what to show the user).
+/// How many records produced a label in this job (the number of entries in ).
+/// 1-based batch position of the last label in the job: the value to confirm a with once
+/// the job was really sent, exactly as .
+public sealed record MergedPictureJob(IReadOnlyList Labels, int LastRecordNumber, int LabelCount, int ItemsThrough);
+
///
/// Turns a into ZPL. Everything is lazy () so 100k rows
/// never need one giant string unless is called. A record that fails
@@ -145,7 +157,7 @@ public static IEnumerable Labels(LabelDocument doc, MergeDataSet da
// Variable warnings are the same for every record; say each of them once.
problems.AddRange(result.Issues.Where(p => job is null || p.Severity == ValidationSeverity.Error || warned.Add(p.MessageKey + "|" + string.Join("|", p.Args))));
if (!problems.Any(p => p.Severity == ValidationSeverity.Error))
- zpl = ApplyCopies(result.Zpl, copies, options.CopiesMode);
+ zpl = ApplyCopies(result.Zpl, copies, options.CopiesMode, options.MaxJobCharacters, problems);
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
@@ -192,23 +204,81 @@ public static IEnumerable Jobs(LabelDocument doc, MergeDataSet data, Mer
///
public static IEnumerable JobBlocks(LabelDocument doc, MergeDataSet data, MergeReport? report = null)
{
- int max = Math.Max(0, data.Options.MaxRecordsPerJob);
+ // An iterator, so every enumeration gets its own builder: the text of a job is joined as the labels arrive, never kept per label.
var sb = new StringBuilder();
+ foreach (var job in GroupJobs(doc, data, report,
+ label => sb.Append(label.Zpl),
+ (last, labels, itemsThrough) =>
+ {
+ var block = new MergedJob(sb.ToString(), last, labels, itemsThrough);
+ sb.Clear();
+ return block;
+ }))
+ yield return job;
+ }
+
+ ///
+ /// Like , but each job hands over its labels (values and copies per record) instead of their ZPL, for batch printing
+ /// as pictures through a Windows driver. The jobs are cut by the same code as , so both print modes get the same jobs:
+ /// the same and , the same skipped records and the
+ /// same . The ZPL of every label is still generated, because it is what validates a record and sizes a job, but it
+ /// is not kept: the labels handed over carry an empty .
+ ///
+ /// The label design.
+ /// The bound data.
+ /// Optional report filled while enumerating.
+ public static IEnumerable JobLabels(LabelDocument doc, MergeDataSet data, MergeReport? report = null)
+ {
+ // An iterator, so every enumeration collects into its own list. GroupJobs has already read the ZPL's length when a label is added, so the
+ // text itself is dropped: a job of many labels never holds their ZPL, only the values the pictures are drawn from.
+ var labels = new List();
+ foreach (var job in GroupJobs(doc, data, report,
+ label => labels.Add(label with { Zpl = string.Empty }),
+ (last, count, itemsThrough) =>
+ {
+ var pictures = new MergedPictureJob(labels.ToArray(), last, count, itemsThrough);
+ labels.Clear();
+ return pictures;
+ }))
+ yield return job;
+ }
+
+ ///
+ /// The one place that decides where a job ends, shared by and so the two print modes can
+ /// never group a batch differently. A job ends after labels (never, when it is 0), and before
+ /// the label whose ZPL would push the job past . The caller keeps the job's content:
+ /// receives each label of the job in progress, and turns that content into a job (and
+ /// starts the next one empty), from the last record number, the number of labels and the batch position of the last label.
+ ///
+ private static IEnumerable GroupJobs(LabelDocument doc, MergeDataSet data, MergeReport? report, Action add,
+ Func close)
+ {
+ int max = Math.Max(0, data.Options.MaxRecordsPerJob);
int inJob = 0, last = 0, lastItem = 0;
+ long chars = 0; // characters of ZPL in the job in progress
+ long maxChars = Math.Max(1, data.Options.MaxJobCharacters);
foreach (var label in Labels(doc, data, report))
{
- sb.Append(label.Zpl);
+ // Close the job before the label that would push it past the size limit; last/lastItem still describe the previous label here.
+ if (inJob > 0 && chars + label.Zpl.Length > maxChars)
+ {
+ yield return close(last, inJob, lastItem);
+ chars = 0;
+ inJob = 0;
+ }
+ add(label);
+ chars += label.Zpl.Length;
inJob++;
last = label.RecordNumber;
lastItem = label.ItemNumber;
if (max > 0 && inJob >= max)
{
- yield return new MergedJob(sb.ToString(), last, inJob, lastItem);
- sb.Clear();
+ yield return close(last, inJob, lastItem);
+ chars = 0;
inJob = 0;
}
}
- if (inJob > 0) yield return new MergedJob(sb.ToString(), last, inJob, lastItem);
+ if (inJob > 0) yield return close(last, inJob, lastItem);
}
/// Streams every label to without building the whole text in memory.
@@ -250,7 +320,7 @@ public static string GenerateSingleStream(LabelDocument doc, MergeDataSet data,
/// Rewrites the generator's trailing ^PQ line. The generator always ends with "\n^PQ...\n^XZ\n", so the
/// last occurrence is ours; imported raw ZPL elsewhere in the block can never be hit.
///
- private static string ApplyCopies(string zpl, int? copies, CopiesMode mode)
+ private static string? ApplyCopies(string zpl, int? copies, CopiesMode mode, long maxChars, List problems)
{
if (copies is null) return zpl;
int pq = zpl.LastIndexOf("\n^PQ", StringComparison.Ordinal);
@@ -262,7 +332,15 @@ private static string ApplyCopies(string zpl, int? copies, CopiesMode mode)
if (mode == CopiesMode.RepeatBlock)
{
string block = head + "^PQ1,0,1,N\n" + tail;
- var sb = new StringBuilder(block.Length * copies.Value);
+ // long arithmetic: the product of a block and 99,999,999 copies does not fit an int. Refused before anything is allocated.
+ long total = (long)block.Length * copies.Value;
+ if (total > maxChars)
+ {
+ problems.Add(new ValidationIssue(ValidationSeverity.Error, "Merge.Copies.TooLarge", null,
+ copies.Value.ToString(CultureInfo.InvariantCulture), (maxChars / Math.Max(1, block.Length)).ToString(CultureInfo.InvariantCulture)));
+ return null;
+ }
+ var sb = new StringBuilder((int)total);
for (int i = 0; i < copies.Value; i++) sb.Append(block);
return sb.ToString();
}
diff --git a/src/Rotula.Core/Merge/MergeLimits.cs b/src/Rotula.Core/Merge/MergeLimits.cs
new file mode 100644
index 0000000..8581d7e
--- /dev/null
+++ b/src/Rotula.Core/Merge/MergeLimits.cs
@@ -0,0 +1,49 @@
+using System.Globalization;
+using Rotula.Core.Validation;
+
+namespace Rotula.Core.Merge;
+
+///
+/// Size limits for data files. Readers pad every row to the widest one so that Rows[i][c] is always valid, which means a file can make
+/// the table many times bigger than the file itself (one very wide header plus many short rows). These limits are checked before any padding.
+///
+public static class MergeLimits
+{
+ /// Most columns a data file may have by default.
+ public const int DefaultMaxColumns = 1000;
+
+ ///
+ /// Most cells (columns times data rows, after padding) a data file may have by default. 20 million is 100,000 rows of 200 columns, far more
+ /// than a label run needs, and keeps the table near 160 MB at worst.
+ ///
+ public const long DefaultMaxCells = 20_000_000;
+
+ ///
+ /// Returns the issue that says the table is over a limit, or null when it fits.
+ ///
+ /// Width of the widest row.
+ /// Number of data rows (the header is not counted).
+ /// Column limit.
+ /// Cell limit.
+ internal static ValidationIssue? Check(int columns, long dataRows, int maxColumns, long maxCells)
+ {
+ if (columns > maxColumns)
+ return new ValidationIssue(ValidationSeverity.Error, "Merge.Table.TooWide", null,
+ columns.ToString(CultureInfo.InvariantCulture), maxColumns.ToString(CultureInfo.InvariantCulture));
+ // long arithmetic: columns and rows are each below int.MaxValue, so the product cannot overflow a long.
+ long cells = (long)columns * dataRows;
+ return cells > maxCells ? TooLarge(cells, maxCells) : null;
+ }
+
+ /// The issue for a table of cells when only are allowed.
+ internal static ValidationIssue TooLarge(long cells, long maxCells) =>
+ new(ValidationSeverity.Error, "Merge.Table.TooLarge", null, cells.ToString(CultureInfo.InvariantCulture), maxCells.ToString(CultureInfo.InvariantCulture));
+
+ /// An empty table that carries the reason the file was not read. Nothing is merged from it, so a refused file can never print by accident.
+ internal static MergeTable Refuse(ValidationIssue reason)
+ {
+ var table = new MergeTable(Array.Empty());
+ table.AddIssue(reason);
+ return table;
+ }
+}
diff --git a/src/Rotula.Core/Merge/MergeOptions.cs b/src/Rotula.Core/Merge/MergeOptions.cs
index ce84e0a..a09a24b 100644
--- a/src/Rotula.Core/Merge/MergeOptions.cs
+++ b/src/Rotula.Core/Merge/MergeOptions.cs
@@ -96,4 +96,15 @@ public sealed class MergeOptions
/// job. Counted in records, not copies, so a ^PQ of 50 still counts as one.
///
public int MaxRecordsPerJob { get; set; }
+
+ /// Default for : 64 Mi characters, which is 128 MB in memory.
+ public const long DefaultMaxJobCharacters = 64L * 1024 * 1024;
+
+ ///
+ /// Most characters of ZPL one job may hold. A job is built as one string, so without a limit a data file with many records (or one record with
+ /// a huge copy count in the repeated-block mode) could ask for gigabytes. Producing jobs starts a new job before a label that would pass the
+ /// limit, even when is 0; a single record whose repeated copies alone would pass it is refused with a
+ /// Merge.Copies.TooLarge error. Tests lower it to work with small strings.
+ ///
+ public long MaxJobCharacters { get; set; } = DefaultMaxJobCharacters;
}
diff --git a/src/Rotula.Core/Merge/XlsxReader.cs b/src/Rotula.Core/Merge/XlsxReader.cs
index af0396c..66645eb 100644
--- a/src/Rotula.Core/Merge/XlsxReader.cs
+++ b/src/Rotula.Core/Merge/XlsxReader.cs
@@ -11,6 +11,12 @@ public sealed class XlsxOptions
/// True when the first row holds column names.
public bool HasHeader { get; set; } = true;
+
+ /// Most columns the sheet may have; a wider sheet is refused with a Merge.Table.TooWide issue instead of being padded.
+ public int MaxColumns { get; set; } = MergeLimits.DefaultMaxColumns;
+
+ /// Most cells (columns times data rows) the table may have; a larger sheet is refused with a Merge.Table.TooLarge issue.
+ public long MaxCells { get; set; } = MergeLimits.DefaultMaxCells;
}
///
@@ -38,6 +44,7 @@ public static MergeTable Read(Stream stream, XlsxOptions? options = null)
// useHeaderRow:false makes MiniExcel key cells by column letter (A, B, ...), so we control header handling.
var rows = new List();
int width = 0;
+ long storedCells = 0;
foreach (var item in MiniExcel.Query(stream, useHeaderRow: false, sheetName: options.SheetName, excelType: ExcelType.XLSX))
{
var dict = (IDictionary)item;
@@ -46,20 +53,36 @@ public static MergeTable Read(Stream stream, XlsxOptions? options = null)
{
int col = ColumnIndex(kv.Key);
if (col < 0) continue;
- cells.Add((col, ToText(kv.Value)));
+ string text = ToText(kv.Value);
+ if (col >= options.MaxColumns)
+ {
+ // An empty cell out there is only formatting (the sheet's used range reaches that far), so it is ignored; a filled one
+ // makes the sheet too wide. Stopping here also keeps a lone cell at column XFD from allocating a 16,384 entry row.
+ if (text.Length == 0) continue;
+ return MergeLimits.Refuse(MergeLimits.Check(col + 1, 0, options.MaxColumns, options.MaxCells)!);
+ }
+ cells.Add((col, text));
width = Math.Max(width, col + 1);
}
var row = new string[cells.Count == 0 ? 0 : cells.Max(c => c.Col) + 1];
Array.Fill(row, string.Empty);
foreach (var (col, text) in cells) row[col] = text;
// Rows without any text are formatting leftovers, not records.
- if (row.Any(c => c.Length > 0)) rows.Add(row);
+ if (row.Any(c => c.Length > 0))
+ {
+ rows.Add(row);
+ // Rows are kept as long as their last filled cell, so this is the real memory use so far; stop before it grows without bound.
+ storedCells += row.Length;
+ if (storedCells > options.MaxCells) return MergeLimits.Refuse(MergeLimits.TooLarge(storedCells, options.MaxCells));
+ }
}
List header = new();
int start = 0;
if (options.HasHeader && rows.Count > 0) { header.AddRange(rows[0]); start = 1; }
while (header.Count < width) header.Add(string.Empty);
+ if (MergeLimits.Check(width, rows.Count - start, options.MaxColumns, options.MaxCells) is { } tooBig)
+ return MergeLimits.Refuse(tooBig);
var table = new MergeTable(header);
for (int i = start; i < rows.Count; i++) table.AddRow(rows[i]);
diff --git a/src/Rotula.Core/Model/Enums.cs b/src/Rotula.Core/Model/Enums.cs
index 275dcc2..cc42fc4 100644
--- a/src/Rotula.Core/Model/Enums.cs
+++ b/src/Rotula.Core/Model/Enums.cs
@@ -42,6 +42,41 @@ public enum MediaTracking
Mark,
}
+///
+/// One of the nine points of a box (corners, edge midpoints and the centre) used by relative positioning: the same point is taken on
+/// the object and on its reference, and the object's X/Y are the distance between the two.
+///
+public enum AnchorPoint
+{
+ /// Top-left corner.
+ TopLeft,
+ /// Middle of the top edge.
+ TopCenter,
+ /// Top-right corner.
+ TopRight,
+ /// Middle of the left edge.
+ MiddleLeft,
+ /// Centre of the box.
+ Center,
+ /// Middle of the right edge.
+ MiddleRight,
+ /// Bottom-left corner.
+ BottomLeft,
+ /// Middle of the bottom edge.
+ BottomCenter,
+ /// Bottom-right corner.
+ BottomRight,
+}
+
+/// What an anchored object is positioned relative to.
+public enum AnchorReference
+{
+ /// The label itself (its full size, so the object follows a label resize).
+ Label,
+ /// Another object of the label, named by .
+ Element,
+}
+
/// Whether a field position is the top-left corner (^FO) or the typesetting origin (^FT).
public enum FieldOriginKind
{
diff --git a/src/Rotula.Core/Model/LabelDocument.cs b/src/Rotula.Core/Model/LabelDocument.cs
index b0b37a0..c874249 100644
--- a/src/Rotula.Core/Model/LabelDocument.cs
+++ b/src/Rotula.Core/Model/LabelDocument.cs
@@ -75,6 +75,16 @@ public FontAlias(char letter, string path)
Path = path;
}
+ // Font file names: an optional device letter, a name of up to 8 characters and a font extension. No ^ ~ or comma can match, so a name that
+ // passes can never end the ^CW command it is written into.
+ private static readonly System.Text.RegularExpressions.Regex PathShape = new(@"^(?:[REBA]:)?[A-Za-z0-9_\-]{1,8}\.(?:FNT|TTF|TTE|fnt|ttf|tte)$", System.Text.RegularExpressions.RegexOptions.CultureInvariant | System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ /// True when is a stored font name the printer accepts (E:ARIAL.TTF) and nothing else.
+ public static bool IsValidPath(string? path) => path is not null && PathShape.IsMatch(path);
+
+ /// True when the letter and the path can be written as a ^CW command.
+ public static bool IsValid(char letter, string? path) => char.IsAsciiLetterOrDigit(letter) && IsValidPath(path);
+
/// The font id (A-Z or 0-9).
public char Letter { get; set; }
/// The stored font, as device:NAME.EXT (the device is optional).
diff --git a/src/Rotula.Core/Variables/Counters/CounterStore.cs b/src/Rotula.Core/Variables/Counters/CounterStore.cs
index e47879d..ca3ed7e 100644
--- a/src/Rotula.Core/Variables/Counters/CounterStore.cs
+++ b/src/Rotula.Core/Variables/Counters/CounterStore.cs
@@ -47,7 +47,9 @@ public sealed record PendingRange(string Id, long First, long Count, DateTimeOff
/// The counter settings the positions were counted under (see ).
/// Reservations that were never confirmed or cancelled.
/// When the record last changed.
-public sealed record CounterRecord(long Next, long LastUsedIndex, string? LastValue, string Fingerprint, IReadOnlyList Pending, DateTimeOffset UpdatedUtc);
+/// True for a counter the book moved out of its full records to save room (see ). It
+/// keeps only what is needed so no number is ever reissued - and - and has no last value and no pending blocks.
+public sealed record CounterRecord(long Next, long LastUsedIndex, string? LastValue, string Fingerprint, IReadOnlyList Pending, DateTimeOffset UpdatedUtc, bool Compact = false);
/// A block of positions reserved for one print job.
/// The counter key.
@@ -101,18 +103,43 @@ public interface ICounterStore
/// The counter bookkeeping shared by every store: a dictionary of records and the operations on it.
public sealed class CounterBook
{
- /// Most records kept; the least recently updated are dropped beyond this (their numbers are then not remembered).
+ ///
+ /// Most counters kept in full (last value, pending blocks). Beyond this the least recently updated ones are compacted, never dropped: a
+ /// compacted counter still remembers its next position, so the numbers it already issued are not issued again.
+ ///
public const int MaxRecords = 5000;
+ ///
+ /// Most counters the book remembers at all, compacted ones included. Beyond this a new counter is refused with
+ /// rather than forgetting an old one, because forgetting a counter would restart its numbers.
+ ///
+ public const int MaxTotalRecords = 25_000;
/// Most pending blocks kept per counter; older ones are dropped (their numbers stay skipped).
public const int MaxPending = 64;
/// Most positions one reservation may take.
public const long MaxReservation = 100_000_000;
/// Highest position the book stores.
public const long MaxIndex = long.MaxValue / 4;
- /// The newest file format version this build writes and reads.
- public const int FormatVersion = 1;
+ ///
+ /// The newest file format version this build writes and reads. Version 2 only differs from 1 by the optional compact counters;
+ /// a file without any is still written as version 1, so it stays readable by earlier builds.
+ ///
+ public const int FormatVersion = 2;
private readonly Dictionary _records = new(StringComparer.Ordinal);
+ private readonly int _maxRecords;
+ private readonly int _maxTotalRecords;
+ private int _compactCount;
+
+ /// Creates an empty book. The limits are parameters so tests can exercise the eviction with a handful of counters.
+ /// Counters kept in full (at least 1).
+ /// Counters kept at all (at least ).
+ public CounterBook(int maxRecords = MaxRecords, int maxTotalRecords = MaxTotalRecords)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(maxRecords, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(maxTotalRecords, maxRecords);
+ _maxRecords = maxRecords;
+ _maxTotalRecords = maxTotalRecords;
+ }
/// The record for , or null.
public CounterRecord? Get(string key) => _records.GetValueOrDefault(key);
@@ -128,6 +155,7 @@ public CounterReservation Reserve(string key, string fingerprint, long count, Da
string counterName = key[(key.LastIndexOf('/') + 1)..];
if (rec is not null && !string.Equals(rec.Fingerprint, fingerprint, StringComparison.Ordinal))
throw new CounterStoreException(VariableMessageKeys.CounterSettingsChanged, null, counterName, rec.LastValue ?? string.Empty);
+ if (rec is null) EnsureRoomForNewCounter();
long first = rec?.Next ?? 0;
if (first > MaxIndex - count) throw new CounterStoreException(VariableMessageKeys.CounterOverflow, null, counterName);
string id = Guid.NewGuid().ToString("N");
@@ -174,6 +202,7 @@ public bool Reclaim(string key, string pendingId, DateTimeOffset now)
public void Reset(string key, string fingerprint, long nextIndex, DateTimeOffset now)
{
if (nextIndex < 0 || nextIndex > MaxIndex) throw new ArgumentOutOfRangeException(nameof(nextIndex));
+ if (!_records.ContainsKey(key)) EnsureRoomForNewCounter();
Put(key, new CounterRecord(nextIndex, -1, null, fingerprint, Array.Empty(), now));
}
@@ -183,12 +212,44 @@ public void AcceptSettings(string key, string fingerprint, DateTimeOffset now)
if (_records.GetValueOrDefault(key) is { } rec) Put(key, rec with { Fingerprint = fingerprint, UpdatedUtc = now });
}
+ /// Refuses a counter the book has no room to remember. Forgetting an older one instead would let its numbers repeat.
+ private void EnsureRoomForNewCounter()
+ {
+ if (_records.Count >= _maxTotalRecords)
+ throw new CounterStoreException(VariableMessageKeys.CounterStoreFull, null, _maxTotalRecords.ToString(CultureInfo.InvariantCulture));
+ }
+
private void Put(string key, CounterRecord record)
{
+ if (_records.TryGetValue(key, out var previous) && previous.Compact) _compactCount--;
_records[key] = record;
- if (_records.Count <= MaxRecords) return;
- foreach (var old in _records.OrderBy(kv => kv.Value.UpdatedUtc).Take(_records.Count - MaxRecords).Select(kv => kv.Key).ToList())
- if (old != key) _records.Remove(old);
+ if (record.Compact) _compactCount++;
+
+ // Over the limit of full records: shrink the least recently updated ones to their high-water mark. Counters that still have an
+ // unconfirmed block go last, since a print that is in progress may be about to confirm it. The record just written is never touched.
+ while (_records.Count - _compactCount > _maxRecords)
+ {
+ string? victim = null;
+ CounterRecord? best = null;
+ foreach (var (k, r) in _records)
+ {
+ if (r.Compact || k == key) continue;
+ if (best is null || Older(r, k, best, victim!)) { best = r; victim = k; }
+ }
+ if (victim is null || best is null) break;
+ // Everything dropped here lies below Next, so those numbers stay used or skipped. A block of that counter still pending can no
+ // longer be confirmed or reclaimed (Complete reports false), which is the safe direction: its numbers stay skipped.
+ _records[victim] = new CounterRecord(best.Next, -1, null, best.Fingerprint, Array.Empty(), best.UpdatedUtc, Compact: true);
+ _compactCount++;
+ }
+ }
+
+ private static bool Older(CounterRecord a, string aKey, CounterRecord b, string bKey)
+ {
+ bool aPending = a.Pending.Count > 0, bPending = b.Pending.Count > 0;
+ if (aPending != bPending) return !aPending;
+ int byTime = a.UpdatedUtc.CompareTo(b.UpdatedUtc);
+ return byTime != 0 ? byTime < 0 : string.CompareOrdinal(aKey, bKey) < 0;
}
// ---- JSON -------------------------------------------------------------------------------
@@ -199,6 +260,18 @@ public string ToJson()
var counters = new JsonObject();
foreach (var (key, r) in _records.OrderBy(kv => kv.Key, StringComparer.Ordinal))
{
+ if (r.Compact)
+ {
+ // Only what keeps numbers from repeating; this is what lets tens of thousands of counters fit in a small file.
+ counters[key] = new JsonObject
+ {
+ ["next"] = r.Next,
+ ["fingerprint"] = r.Fingerprint,
+ ["updated"] = r.UpdatedUtc.ToString("o", CultureInfo.InvariantCulture),
+ ["compact"] = true,
+ };
+ continue;
+ }
var pending = new JsonArray();
foreach (var p in r.Pending)
pending.Add(new JsonObject { ["id"] = p.Id, ["first"] = p.First, ["count"] = p.Count, ["created"] = p.CreatedUtc.ToString("o", CultureInfo.InvariantCulture) });
@@ -215,7 +288,9 @@ public string ToJson()
var root = new JsonObject
{
["comment"] = "Rotula remembers the last serial number of counters that are set to remember. Do not edit by hand while Rotula is running.",
- ["version"] = FormatVersion,
+ // An earlier build ignores unknown fields and would restart a compacted counter at 0. Version 2 makes it refuse the file instead
+ // (CounterStoreNewer); a file without compacted counters stays version 1 so nothing changes for it.
+ ["version"] = _compactCount > 0 ? FormatVersion : 1,
["counters"] = counters,
};
return root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
@@ -239,10 +314,10 @@ public static CounterBook FromJson(string json, string pathForMessages)
throw new CounterStoreException(VariableMessageKeys.CounterStoreNewer, null, pathForMessages, version.ToString(CultureInfo.InvariantCulture));
if (!root.TryGetProperty("counters", out var counters) || counters.ValueKind != JsonValueKind.Object)
throw new CounterStoreException(VariableMessageKeys.CounterStoreCorrupt, null, pathForMessages);
- int n = 0;
+ int n = 0, full = 0;
foreach (var entry in counters.EnumerateObject())
{
- if (++n > MaxRecords) throw new CounterStoreException(VariableMessageKeys.CounterStoreCorrupt, null, pathForMessages);
+ if (++n > MaxTotalRecords) throw new CounterStoreException(VariableMessageKeys.CounterStoreCorrupt, null, pathForMessages);
var e = entry.Value;
long next = Num(e, "next", 0), last = Num(e, "lastUsedIndex", -1);
if (next < 0 || next > MaxIndex || last < -1 || last >= next) throw new CounterStoreException(VariableMessageKeys.CounterStoreCorrupt, null, pathForMessages);
@@ -258,7 +333,12 @@ public static CounterBook FromJson(string json, string pathForMessages)
pending.Add(new PendingRange(Str(pe, "id") ?? Guid.NewGuid().ToString("N"), first, cnt, Date(pe, "created")));
}
}
- book._records[entry.Name] = new CounterRecord(next, last, Str(e, "lastValue"), fingerprint, pending, Date(e, "updated"));
+ bool compact = e.TryGetProperty("compact", out var cp) && cp.ValueKind == JsonValueKind.True;
+ // A compacted counter carries nothing but its position; anything else in it would be a hand edit.
+ if (compact && (last != -1 || pending.Count > 0)) throw new CounterStoreException(VariableMessageKeys.CounterStoreCorrupt, null, pathForMessages);
+ if (!compact && ++full > MaxRecords) throw new CounterStoreException(VariableMessageKeys.CounterStoreCorrupt, null, pathForMessages);
+ book._records[entry.Name] = new CounterRecord(next, last, compact ? null : Str(e, "lastValue"), fingerprint, pending, Date(e, "updated"), compact);
+ if (compact) book._compactCount++;
}
return book;
}
diff --git a/src/Rotula.Core/Variables/Counters/VariableJob.cs b/src/Rotula.Core/Variables/Counters/VariableJob.cs
index 2cbcdc0..0e92231 100644
--- a/src/Rotula.Core/Variables/Counters/VariableJob.cs
+++ b/src/Rotula.Core/Variables/Counters/VariableJob.cs
@@ -31,6 +31,7 @@ public sealed class VariableJob
private readonly LabelDocument _doc;
private readonly VariableContext _baseContext;
private readonly List<(LabelVariable Variable, CounterReservation Reservation, long First)> _reservations = new();
+ private readonly Dictionary _bases = new(StringComparer.OrdinalIgnoreCase);
private readonly List _issues = new();
private bool _done;
@@ -38,7 +39,7 @@ private VariableJob(LabelDocument doc, VariableContext context, long itemCount)
{
_doc = doc;
ItemCount = itemCount;
- var bases = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var bases = _bases;
_baseContext = context.Clone();
_baseContext.CounterBase = bases;
if (context.CounterBase is { } given) foreach (var kv in given) bases[kv.Key] = kv.Value;
@@ -81,20 +82,60 @@ private VariableJob(LabelDocument doc, VariableContext context, long itemCount)
continue;
}
var reservation = store.Reserve(key, fingerprint, Math.Max(1, itemCount));
+ // Recorded before anything else can fail, so the rollback below can never miss a reservation the store already made.
+ _reservations.Add((v, reservation, reservation.First));
+ bases[v.Name] = reservation.First;
// Anything that was pending before this reservation is a run that never finished.
foreach (var stale in record?.Pending ?? Array.Empty())
_issues.Add(new ValidationIssue(ValidationSeverity.Warning, VariableMessageKeys.CounterUnconfirmed, null, v.Name,
stale.First.ToString(CultureInfo.InvariantCulture), (stale.End - 1).ToString(CultureInfo.InvariantCulture)));
- _reservations.Add((v, reservation, reservation.First));
- bases[v.Name] = reservation.First;
}
catch (CounterStoreException ex)
{
_issues.Add(new ValidationIssue(ValidationSeverity.Error, ex.MessageKey, null, ex.Args));
}
+ catch (Exception ex) when (IsStoreFailure(ex))
+ {
+ // A store other than the file store (or an unexpected error inside it) may throw IOException and friends directly. They must not
+ // escape: the caller would get no job to cancel, and the numbers taken for the counters before this one would stay reserved.
+ _issues.Add(StoreFailed(v.Name, ex, ValidationSeverity.Error));
+ }
+ }
+
+ // The run cannot start, so nothing will be printed and every number taken so far is still unused. Give them back now instead of
+ // trusting every caller to call Cancel (the print path used to leave them reserved when a later counter failed).
+ if (_issues.Any(i => i.Severity == ValidationSeverity.Error)) RollBack(store);
+ }
+
+ ///
+ /// Gives back the reservations of a run that never started, newest first (so each one is still the newest block of its counter and its
+ /// unused tail can be returned). A release that fails is reported as a warning and leaves the numbers reserved, which is the safe side: they
+ /// are skipped later and never handed out twice. The error that caused the rollback stays first in .
+ ///
+ private void RollBack(ICounterStore store)
+ {
+ _done = true; // a later Cancel or Complete has nothing left to do
+ for (int i = _reservations.Count - 1; i >= 0; i--)
+ {
+ var (variable, reservation, _) = _reservations[i];
+ try { store.Complete(reservation, 0, null); }
+ catch (Exception ex) when (IsStoreFailure(ex))
+ {
+ _issues.Add(ex is CounterStoreException cse
+ ? new ValidationIssue(ValidationSeverity.Warning, cse.MessageKey, null, cse.Args)
+ : StoreFailed(variable.Name, ex, ValidationSeverity.Warning));
+ }
+ _bases.Remove(variable.Name);
}
+ _reservations.Clear();
}
+ /// True for the failures a store can reasonably throw (anything except the runtime's own fatal errors).
+ private static bool IsStoreFailure(Exception ex) => ex is not (OutOfMemoryException or AccessViolationException);
+
+ private static ValidationIssue StoreFailed(string counterName, Exception ex, ValidationSeverity severity) =>
+ new(severity, VariableMessageKeys.CounterStoreFailed, null, counterName, ex.Message);
+
/// Number of labels this run was started for.
public long ItemCount { get; }
@@ -105,8 +146,9 @@ private VariableJob(LabelDocument doc, VariableContext context, long itemCount)
public bool CanPrint => !_issues.Any(i => i.Severity == ValidationSeverity.Error);
///
- /// Starts a run of labels and reserves their serial numbers. If is false, nothing was reserved
- /// that is not given back by .
+ /// Starts a run of labels and reserves their serial numbers. If is false, whatever was
+ /// reserved before the problem was met has already been given back (calling afterwards is harmless). Never throws for a
+ /// failing store: the failure is an error in .
///
/// The label.
/// Clock, record template, answers and the counter store.
@@ -148,6 +190,12 @@ public IReadOnlyList Complete(long labelsPrinted)
{
problems.Add(new ValidationIssue(ValidationSeverity.Error, ex.MessageKey, null, ex.Args));
}
+ catch (Exception ex) when (IsStoreFailure(ex))
+ {
+ // Labels may already be on the printer, so an exception here must not skip the remaining counters (they would stay
+ // unconfirmed) or reach the print path. The failed counter keeps its reservation: its numbers are skipped, never reissued.
+ problems.Add(StoreFailed(variable.Name, ex, ValidationSeverity.Error));
+ }
}
return problems;
}
diff --git a/src/Rotula.Core/Variables/VariableMessageKeys.cs b/src/Rotula.Core/Variables/VariableMessageKeys.cs
index 6e82797..0f445ce 100644
--- a/src/Rotula.Core/Variables/VariableMessageKeys.cs
+++ b/src/Rotula.Core/Variables/VariableMessageKeys.cs
@@ -65,6 +65,13 @@ public static class VariableMessageKeys
public const string CounterStoreNewer = "Variable.Counter.Store.NewerVersion";
/// The counters file is locked by another program or cannot be written. Args: file path.
public const string CounterStoreBusy = "Variable.Counter.Store.Busy";
+ /// The counters file already remembers as many counters as it can hold, so a new one cannot be added. Args: the limit.
+ public const string CounterStoreFull = "Variable.Counter.Store.Full";
+ ///
+ /// The counters could not be read or saved for a reason other than the file being busy, damaged or full (the disk is full, access is denied...).
+ /// Numbers already taken stay taken or are given back, never reissued. Args: variable, the system's description of the problem.
+ ///
+ public const string CounterStoreFailed = "Variable.Counter.Store.Failed";
/// A counter that remembers its value needs the label to be saved first so it has an identity. Args: variable.
public const string CounterNeedsLabelId = "Variable.Counter.NeedsLabelId";
///
diff --git a/src/Rotula.Core/Zpl/Catalog/ZplCommandReference.cs b/src/Rotula.Core/Zpl/Catalog/ZplCommandReference.cs
new file mode 100644
index 0000000..0fd2208
--- /dev/null
+++ b/src/Rotula.Core/Zpl/Catalog/ZplCommandReference.cs
@@ -0,0 +1,271 @@
+namespace Rotula.Core.Zpl.Catalog;
+
+/// One parameter of a ZPL command: the letter the guide uses for it and what it means, in Rotula's own words.
+/// The parameter as it is written in the command's signature, for example x or d:o.x.
+/// One short phrase saying what the value does and, where useful, its range or the letters it accepts.
+public sealed record ZplParameter(string Name, string Meaning);
+
+///
+/// The parameter signature of one ZPL command, kept as C# data so the in-app reference needs no file at run time and the
+/// compiler checks every entry. The name and the one-sentence summary of a command live in the JSON catalogue
+/// (); this type only adds the parameters.
+///
+public sealed class ZplCommandSignature
+{
+ internal ZplCommandSignature(string id, string? note, params ZplParameter[] parameters)
+ {
+ Id = id;
+ Note = note;
+ Parameters = parameters;
+ }
+
+ /// The command id with its primary prefix, for example ^FO or ~DG.
+ public string Id { get; }
+
+ /// The parameters in the order they are written, separated by commas in the ZPL text; empty for a command without parameters.
+ public IReadOnlyList Parameters { get; }
+
+ /// A short remark that applies to the whole command (how the data is given, a quirk), or null.
+ public string? Note { get; }
+}
+
+///
+/// What a source editor shows for the command under the caret: id, name, parameters and summary, merged from the JSON catalogue
+/// and the C# signature table. Either source may be missing for a given command; the texts then say what is known.
+///
+public sealed class ZplCommandHint
+{
+ internal ZplCommandHint(string id, string name, string summary, IReadOnlyList parameters, string? note, ZplCommandStatus? status)
+ {
+ Id = id;
+ Name = name;
+ Summary = summary;
+ Parameters = parameters;
+ Note = note;
+ Status = status;
+ }
+
+ /// The command id with the prefix as typed, for example ^FO.
+ public string Id { get; }
+
+ /// Short name of the command.
+ public string Name { get; }
+
+ /// One or two sentences saying what the command does.
+ public string Summary { get; }
+
+ /// The parameters in order; empty when the command takes none or the signature is not catalogued.
+ public IReadOnlyList Parameters { get; }
+
+ /// A remark about the whole command, or null.
+ public string? Note { get; }
+
+ /// What Rotula does with the command, or null when the JSON catalogue does not list it.
+ public ZplCommandStatus? Status { get; }
+
+ /// The parameter names joined as they are written, for example x,y,z; empty for a command without parameters.
+ public string ParameterList => string.Join(",", Parameters.Select(p => p.Name));
+
+ /// The first line of the hint: the id, the parameter list and the name, for example ^FO x,y,z Field origin.
+ public string Headline => Parameters.Count == 0 ? Id + " " + Name : Id + " " + ParameterList + " " + Name;
+
+ /// Every parameter as name: meaning, joined with semicolons; empty when there are none.
+ public string ParameterText => string.Join("; ", Parameters.Select(p => p.Name + ": " + p.Meaning));
+}
+
+///
+/// The command reference behind the hint line of the ZPL source panel: for each command Rotula supports (and the usual ones it
+/// keeps verbatim) the parameters in order and what each one means. Written in Rotula's own words. merges an
+/// entry with the name and summary of ; finds the command under a caret.
+///
+public static class ZplCommandReference
+{
+ private static ZplParameter P(string name, string meaning) => new(name, meaning);
+
+ private static readonly ZplParameter Rotation = P("o", "rotation: N upright, R turned 90 degrees clockwise, I upside down, B turned 90 degrees anticlockwise");
+ private static readonly ZplParameter BarHeight = P("h", "bar height in dots (the ^BY value when left out)");
+ private static readonly ZplParameter Interpretation = P("f", "Y prints the human-readable text, N leaves it out");
+ private static readonly ZplParameter InterpretationAbove = P("g", "Y puts the human-readable text above the bars instead of below");
+ private static readonly ZplParameter Colour = P("c", "line colour: B black, W white");
+ private static readonly ZplParameter Device = P("d:o.x", "where the object lives on the printer: drive letter, name and extension, for example R:LOGO.GRF");
+
+ private static readonly ZplCommandSignature[] Table =
+ {
+ // ---- label structure and layout
+ new("^XA", "Starts a label; everything up to ^XZ belongs to it."),
+ new("^XZ", "Ends the label and prints it."),
+ new("^LH", null, P("x", "left offset of the label home in dots"), P("y", "top offset of the label home in dots")),
+ new("^LL", null, P("y", "length of the label in dots")),
+ new("^PW", null, P("w", "print width in dots")),
+ new("^LS", null, P("a", "moves every field left by this many dots; a negative value moves right")),
+ new("^LT", null, P("y", "moves the whole label down (positive) or up (negative) by this many dot rows")),
+ new("^LR", null, P("a", "Y prints every following field reversed against its background, N stops doing so")),
+ new("^PO", null, P("a", "N prints normally, I prints the label upside down")),
+ new("^PM", null, P("a", "Y prints the whole label as a mirror image, N prints normally")),
+ new("^MU", null, P("a", "unit of the values that follow: D dots, I inches, M millimetres"), P("b", "dots per inch the format was written for"), P("c", "dots per inch to convert to")),
+ new("^PQ", null, P("q", "number of labels to print"), P("p", "pause after this many labels; 0 never pauses"), P("r", "copies of each serial number"), P("o", "Y overrides the pause count")),
+ new("^FX", "Everything after the command up to the next ^ or ~ is a comment the printer ignores.", P("c", "the comment text")),
+ new("^CC", null, P("x", "the character that replaces ^ as the command prefix")),
+ new("^CD", null, P("x", "the character that replaces the comma between parameters")),
+ new("^CT", null, P("x", "the character that replaces ~ as the prefix of immediate commands")),
+
+ // ---- fields
+ new("^FO", null, P("x", "left edge of the field in dots, from the label home"), P("y", "top edge of the field in dots"), P("z", "justification: 0 left, 1 right, 2 automatic")),
+ new("^FT", null, P("x", "horizontal position of the field's baseline origin in dots"), P("y", "vertical position of the baseline origin in dots"), P("z", "justification: 0 left, 1 right, 2 automatic")),
+ new("^FD", "The data runs up to the next ^ or ~, so neither can appear in it unless ^FH hex escapes are used.", P("data", "the text or barcode data of the field")),
+ new("^FV", "Read exactly like ^FD; the name marks data that changes between prints of a stored format.", P("data", "the data of the field")),
+ new("^FS", "Closes the field; every field needs one."),
+ new("^FR", "Inverts the dots the field overlaps, so black becomes white and white becomes black."),
+ new("^FH", null, P("a", "the character that starts a two-digit hexadecimal byte in the data (default _)")),
+ new("^FB", null, P("a", "width of the text block in dots"), P("b", "maximum number of lines"), P("c", "extra space between lines in dots; negative tightens"), P("d", "alignment: L left, C centre, R right, J justified"), P("e", "hanging indent of the second and following lines in dots")),
+ new("^TB", null, P("o", "rotation N, R, I or B"), P("w", "width of the text box in dots"), P("h", "height of the text box in dots; lines that do not fit are not printed")),
+ new("^FP", null, P("d", "direction the characters advance: H horizontal, V vertical, R reverse"), P("g", "extra gap between characters in dots")),
+ new("^FN", "Written as ^FN3\"Prompt\": the quoted prompt is optional.", P("n", "field number 0 to 9999; fields with the same number share their data"), P("\"p\"", "optional prompt shown on a keyboard display, letters, digits and spaces in quotes")),
+ new("^FE", null, P("a", "the character that marks a field-number reference inside the data (default #)")),
+ new("^FC", null, P("a", "primary clock indicator that starts a clock code in the data (default %)"), P("b", "secondary clock indicator, for the second offset clock"), P("c", "third clock indicator")),
+ new("^SN", null, P("v", "start value: letters, digits, spaces, - / . with at least one digit"), P("n", "step added after each label; negative counts down"), P("z", "Y keeps leading zeros, N drops them")),
+ new("^SF", null, P("m", "mask, one character per data character counted from the end: D decimal, H hexadecimal, O octal, A letter, N letter or digit, % unchanged"), P("i", "the step written in the same alphabet as the mask")),
+ new("^FW", null, P("r", "default rotation for every following field: N, R, I or B"), P("z", "default justification: 0 left, 1 right, 2 automatic")),
+ new("^FM", null, P("x1,y1,...", "origins of the fields of a multi-part barcode, in pairs")),
+
+ // ---- text and fonts
+ new("^A", "The font id is written right after ^A, for example ^A0N,30,30 or ^A@N,30,30,E:ARIAL.TTF.", P("f", "font id: 0 is the scalable font, A to Z and 1 to 9 are bitmap fonts, @ names a stored font file in the last parameter"), P("o", "rotation N, R, I or B"), P("h", "character height in dots"), P("w", "character width in dots; 0 keeps the proportion")),
+ new("^CF", null, P("f", "default font id for fields without ^A"), P("h", "default character height in dots"), P("w", "default character width in dots")),
+ new("^CI", null, P("a", "character set: 28 is UTF-8, 13 is code page 850, 27 is Windows-1252, 0 is the printer's default set"), P("s1,d1", "optional pairs that remap a source code to a destination code")),
+ new("^CW", null, P("a", "the font id (letter or digit) to assign"), P("d:o.x", "the stored font file, for example E:ARIAL.TTF")),
+
+ // ---- graphics
+ new("^GB", null, P("w", "width of the box in dots"), P("h", "height of the box in dots"), P("t", "border thickness in dots; a box as thick as its width or height is a solid bar"), Colour, P("r", "corner rounding 0 (square) to 8")),
+ new("^GC", null, P("d", "diameter of the circle in dots"), P("t", "border thickness in dots"), Colour),
+ new("^GD", null, P("w", "width of the box the line crosses, in dots"), P("h", "height of that box in dots"), P("t", "line thickness in dots"), Colour, P("o", "lean: R or / rises to the right, L or \\ falls to the right")),
+ new("^GE", null, P("w", "width of the ellipse in dots"), P("h", "height of the ellipse in dots"), P("t", "border thickness in dots"), Colour),
+ new("^GS", "The ^FD data holds one letter: A registered, B copyright, C trade mark, D UL, E CSA.", Rotation, P("h", "height of the symbol in dots"), P("w", "width of the symbol in dots")),
+ new("^GF", null, P("a", "how the data is written: A hexadecimal text, B binary, C compressed binary"), P("b", "total number of bytes"), P("c", "number of bytes of graphic data (normally the same as b)"), P("d", "bytes per row"), P("data", "the pixels, one bit each, 1 is black")),
+
+ // ---- barcodes
+ new("^BY", null, P("w", "module (narrow bar) width in dots, 1 to 10"), P("r", "wide-to-narrow bar ratio, 2.0 to 3.0"), P("h", "default bar height in dots for the barcodes that follow")),
+ new("^B0", null, Rotation, P("b", "magnification 1 to 10"), P("c", "Y when the data carries extended channel interpretation codes"), P("d", "error control and size: 0 default, 1 to 99 minimum correction percent, 101 to 104 compact symbol with that many layers, 201 to 232 full symbol with that many layers, 300 rune"), P("e", "Y makes a menu (reader initialisation) symbol"), P("f", "number of symbols in a structured-append set, 1 to 26"), P("g", "optional id of the structured-append set")),
+ new("^BO", null, Rotation, P("b", "magnification 1 to 10"), P("c", "Y when the data carries extended channel interpretation codes"), P("d", "error control and size: 0 default, 1 to 99 minimum correction percent, 101 to 104 compact symbol with that many layers, 201 to 232 full symbol with that many layers, 300 rune"), P("e", "Y makes a menu (reader initialisation) symbol"), P("f", "number of symbols in a structured-append set, 1 to 26"), P("g", "optional id of the structured-append set")),
+ new("^B1", null, Rotation, P("e", "Y appends one check digit, N appends two"), BarHeight, Interpretation, InterpretationAbove),
+ new("^B2", null, Rotation, BarHeight, Interpretation, InterpretationAbove, P("e", "Y appends a modulo 10 check digit")),
+ new("^B3", null, Rotation, P("e", "Y appends a modulo 43 check digit"), BarHeight, Interpretation, InterpretationAbove),
+ new("^B4", null, Rotation, BarHeight, Interpretation, P("m", "starting mode 0 to 5, or A to let the printer choose")),
+ new("^B5", null, Rotation, BarHeight, Interpretation, InterpretationAbove),
+ new("^B7", null, Rotation, P("h", "height of one row in dots"), P("s", "security (error correction) level 1 to 8"), P("c", "number of data columns, 1 to 30; 0 lets the printer choose"), P("r", "number of rows, 3 to 90; 0 lets the printer choose"), P("t", "Y prints the truncated form without the right-hand row indicators")),
+ new("^B8", null, Rotation, BarHeight, Interpretation, InterpretationAbove),
+ new("^B9", null, Rotation, BarHeight, Interpretation, InterpretationAbove, P("e", "Y prints the check digit in the human-readable text")),
+ new("^BA", null, Rotation, BarHeight, Interpretation, InterpretationAbove, P("e", "Y prints the check digit in the human-readable text")),
+ new("^BB", null, Rotation, BarHeight, P("s", "Y generates the symbol check characters"), P("c", "number of characters per row"), P("r", "number of rows"), P("m", "variant: A, E or F")),
+ new("^BC", null, Rotation, BarHeight, Interpretation, InterpretationAbove, P("e", "Y adds the UCC check digit to the human-readable text"), P("m", "mode: N none, U UCC case, A automatic subset switching, D GS1-128 with an FNC1 start")),
+ new("^BD", null, P("m", "mode 2 to 6: 2 and 3 are structured carrier messages, 4 to 6 plain data"), P("n", "number of this symbol in a structured-append set, 1 to 8"), P("t", "total number of symbols in the set, 1 to 8")),
+ new("^BE", null, Rotation, BarHeight, Interpretation, InterpretationAbove),
+ new("^BF", null, Rotation, P("h", "height of one row in dots"), P("m", "mode 0 to 33, which fixes columns, rows and error correction")),
+ new("^BI", null, Rotation, BarHeight, Interpretation, InterpretationAbove),
+ new("^BJ", null, Rotation, BarHeight, Interpretation, InterpretationAbove),
+ new("^BK", null, Rotation, P("e", "check digit switch (the printer accepts only N)"), BarHeight, Interpretation, InterpretationAbove, P("k", "start character A, B, C or D"), P("l", "stop character A, B, C or D")),
+ new("^BL", null, Rotation, BarHeight, InterpretationAbove),
+ new("^BM", null, Rotation, P("e", "check digits: A none, B one modulo 10, C two modulo 10, D modulo 10 then modulo 11"), BarHeight, Interpretation, InterpretationAbove, P("e2", "Y prints the check digit in the human-readable text")),
+ new("^BP", null, Rotation, P("e", "Y prints the check digit"), BarHeight, Interpretation, InterpretationAbove),
+ new("^BQ", "The ^FD data starts with a header such as QA, (error level, input mode) before the content.", Rotation, P("m", "model 1 or 2"), P("s", "magnification 1 to 10"), P("e", "error correction: H highest, Q, M, L lowest"), P("k", "mask pattern 0 to 7")),
+ new("^BR", null, Rotation, P("t", "DataBar variant 1 to 12 (1 omnidirectional, 7 expanded, 11 stacked omnidirectional ...)"), P("m", "magnification 1 to 10"), P("h", "height of the separator between stacked rows, 1 or 2 modules"), P("w", "bar height in dots"), P("s", "segments per row for the expanded variant, 2 to 22 in steps of two")),
+ new("^BS", null, Rotation, BarHeight, Interpretation, InterpretationAbove),
+ new("^BT", null, Rotation, P("w1", "narrow bar width of the Code 39 part in dots"), P("r1", "wide-to-narrow ratio of the Code 39 part"), P("h1", "height of the Code 39 part in dots"), P("w2", "narrow bar width of the MicroPDF417 part in dots"), P("h2", "row height of the MicroPDF417 part in dots")),
+ new("^BU", null, Rotation, BarHeight, Interpretation, InterpretationAbove, P("e", "Y prints the check digit in the human-readable text")),
+ new("^BX", null, Rotation, P("h", "size of one module in dots"), P("s", "quality level: 0, 50, 80, 100, 140 or 200 (200 is the ECC 200 standard)"), P("c", "number of columns; 0 lets the printer choose"), P("r", "number of rows; 0 lets the printer choose"), P("f", "format id of the data, 1 to 6 (only for quality levels below 200)"), P("g", "escape character used in the data, for example for FNC1 in GS1 symbols"), P("a", "shape: 1 square, 2 rectangular")),
+ new("^BZ", null, Rotation, BarHeight, Interpretation, InterpretationAbove, P("t", "type: 0 Postnet, 1 Plant Bar Code, 2 reserved, 3 USPS Intelligent Mail")),
+
+ // ---- images and stored objects
+ new("~DG", null, Device, P("t", "total number of bytes"), P("w", "bytes per row"), P("data", "the pixels as hexadecimal text, 1 is black")),
+ new("~DY", null, P("d:f", "drive letter and name of the object, without extension"), P("b", "how the data is written: A hexadecimal or ASCII, B binary, C compressed binary"), P("x", "kind of object: G bitmap graphic, P PNG picture, T TrueType font, X font"), P("t", "total number of bytes"), P("w", "bytes per row for a bitmap"), P("data", "the object itself")),
+ new("^XG", null, Device, P("mx", "horizontal magnification 1 to 10"), P("my", "vertical magnification 1 to 10")),
+ new("^IL", null, Device),
+ new("^IM", null, Device),
+ new("^IS", null, Device, P("p", "Y prints the label while saving it")),
+ new("^ID", null, Device),
+ new("^DF", "Written right after ^XA; the commands that follow are stored instead of printed.", Device),
+ new("^XF", "The ^FN fields that follow it supply the data of the stored format.", Device),
+
+ // ---- media and printer settings
+ new("^MD", null, P("d", "darkness change relative to the current setting, -30 to 30")),
+ new("~SD", null, P("d", "absolute darkness 0 to 30")),
+ new("^MM", null, P("a", "what happens after each label: T tear-off, P peel-off, R rewind, A applicator, C cutter, D delayed cutter, F RFID, K kiosk"), P("b", "Y starts peeling before the whole label is printed")),
+ new("^MN", null, P("a", "how labels are told apart: N continuous, Y or W gap or notch, M black mark, A automatic"), P("b", "offset of the black mark in dots")),
+ new("^MT", null, P("a", "T thermal transfer (ribbon), D direct thermal")),
+ new("^PR", null, P("p", "print speed in inches per second, 1 to 14 (or the letters A to E)"), P("s", "slew speed over blank areas"), P("b", "backfeed speed")),
+ new("^SO", null, P("a", "which offset clock is set: 2 secondary, 3 third"), P("b", "months to add"), P("c", "days to add"), P("d", "years to add"), P("e", "hours to add"), P("f", "minutes to add"), P("g", "seconds to add")),
+ new("^JM", null, P("a", "dot density: A normal, B half (every value is doubled)")),
+ new("^MF", null, P("p", "what the printer does with the media when switched on: F feed, C calibrate, L length, N nothing"), P("r", "the same when the head is closed")),
+ };
+
+ private static readonly Dictionary ById = Table.ToDictionary(s => s.Id, StringComparer.Ordinal);
+
+ /// Every catalogued signature, in display order.
+ public static IReadOnlyList All => Table;
+
+ /// The signature of a command by id (^FO, ~DG), or null when it is not in the table. The lookup ignores case.
+ public static ZplCommandSignature? FindSignature(string id)
+ {
+ if (string.IsNullOrWhiteSpace(id)) return null;
+ string key = id.Trim().ToUpperInvariant();
+ if (ById.TryGetValue(key, out var found)) return found;
+ // A command listed with both prefixes is stored once under its primary prefix; the other prefix means the same thing.
+ if (key.Length >= 2 && key[0] is '^' or '~')
+ {
+ string other = (key[0] == '^' ? '~' : '^') + key[1..];
+ if (ById.TryGetValue(other, out found) && ZplCommandCatalog.Find(key) is { } info && info.Prefixes.Length == 2) return found;
+ }
+ return null;
+ }
+
+ ///
+ /// Describes a command for the hint line: its name and summary from and its parameters from this table.
+ /// Returns null only when neither source knows the command.
+ ///
+ /// The command with its prefix, for example ^FO; letters may be lower case.
+ public static ZplCommandHint? Describe(string idOrCode)
+ {
+ if (string.IsNullOrWhiteSpace(idOrCode)) return null;
+ string id = idOrCode.Trim().ToUpperInvariant();
+ var info = ZplCommandCatalog.Find(id);
+ var signature = FindSignature(id);
+ if (info is null && signature is null) return null;
+ return new ZplCommandHint(id, info?.Name ?? id, info?.Summary ?? string.Empty,
+ signature?.Parameters ?? Array.Empty(), signature?.Note, info?.Status);
+ }
+
+ ///
+ /// Finds the command the caret is on and describes it. A caret is "on" a command from its prefix character up to the start of
+ /// the next command, so the hint stays while the parameters are typed. Returns null when the caret is on plain text before the
+ /// first command, outside the text, or on a command neither catalogue knows.
+ ///
+ /// The ZPL source.
+ /// Zero-based caret position; a value equal to the length means "at the end".
+ public static ZplCommandHint? AtCaret(string? text, int caretIndex) =>
+ CommandTokenAtCaret(text, caretIndex) is { } token ? Describe(token) : null;
+
+ ///
+ /// The command token (prefix and code, upper case, for example ^FO) the caret belongs to, whether or not any catalogue knows it;
+ /// null when the caret is on plain text before the first command or outside the text. Lets an editor say "unknown command" for a typo.
+ ///
+ /// The ZPL source.
+ /// Zero-based caret position; a value equal to the length means "at the end".
+ public static string? CommandTokenAtCaret(string? text, int caretIndex)
+ {
+ if (string.IsNullOrEmpty(text) || caretIndex < 0 || caretIndex > text.Length) return null;
+ // The caret sits between characters; the command it belongs to starts at the nearest prefix at or before it.
+ int start = caretIndex < text.Length && text[caretIndex] is '^' or '~' ? caretIndex : text.LastIndexOfAny(new[] { '^', '~' }, Math.Max(0, caretIndex - 1));
+ return start < 0 ? null : CommandAt(text, start);
+ }
+
+ /// The command token (prefix and code) that starts at , following the parser's own rule that ^A is one letter.
+ private static string CommandAt(string text, int start)
+ {
+ char prefix = text[start];
+ int i = start + 1;
+ if (i >= text.Length) return prefix.ToString();
+ if (prefix == '^' && char.ToUpperInvariant(text[i]) == 'A') return "^A";
+ int end = i;
+ while (end < text.Length && end - i < 2 && text[end] is not ('^' or '~')) end++;
+ return prefix + text[i..end].ToUpperInvariant();
+ }
+}
diff --git a/src/Rotula.Core/Zpl/Catalog/commands.json b/src/Rotula.Core/Zpl/Catalog/commands.json
index 7d1f6c6..359a22b 100644
--- a/src/Rotula.Core/Zpl/Catalog/commands.json
+++ b/src/Rotula.Core/Zpl/Catalog/commands.json
@@ -345,7 +345,7 @@
"preview": "NotPreviewed",
"summary": "Gives a field a number so its data can be filled in or reused later; an optional prompt can be stored with it.",
"example": "^FO50,40^A0N,30,30^FN3\"Name\"^FDSample^FS",
- "notes": "Kept on the field and written back. Filling numbered fields from a stored template is not done by Rotula yet."
+ "notes": "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."
},
{
"id": "^FO",
@@ -1260,7 +1260,7 @@
"preview": "NotPreviewed",
"summary": "Saves the commands that follow as a reusable template in the printer's memory.",
"example": "^DFR:FORM.ZPL",
- "notes": "Templates, with numbered fields filled in at print time, are planned. Today the command is kept verbatim with a warning."
+ "notes": "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."
},
{
"id": "~DG",
@@ -1410,7 +1410,7 @@
"preview": "NotPreviewed",
"summary": "Prints a stored template, filling its numbered fields.",
"example": "^XFR:FORM.ZPL",
- "notes": "Planned together with ^DF. Today the recall is kept as a stored-object placeholder with a warning."
+ "notes": "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."
},
{
"id": "^XG",
diff --git a/src/Rotula.Core/Zpl/Import/ZplImportTypes.cs b/src/Rotula.Core/Zpl/Import/ZplImportTypes.cs
index 369dfd5..8d0320a 100644
--- a/src/Rotula.Core/Zpl/Import/ZplImportTypes.cs
+++ b/src/Rotula.Core/Zpl/Import/ZplImportTypes.cs
@@ -12,6 +12,16 @@ public sealed class ZplParseOptions
/// it only matters for ^MU unit conversion and for how the document reports its size.
///
public PrinterDpi Dpi { get; set; } = PrinterDpi.Dpi203;
+
+ /// Default for : 128 MiB.
+ public const long DefaultMaxDecodedBytes = 128L * 1024 * 1024;
+
+ ///
+ /// Memory the whole import may spend on decoded graphics. A ^GF field costs eight bytes per packed byte (one luminance
+ /// byte per dot) and a ~DG download costs its packed size; both draw from this one budget. A command that would exceed it is
+ /// kept as raw ZPL with a warning instead of being decoded, so a hostile file made of many small graphic fields cannot exhaust memory.
+ ///
+ public long MaxDecodedBytes { get; set; } = DefaultMaxDecodedBytes;
}
///
@@ -56,6 +66,19 @@ internal ZplImportWarning(string messageKey, int position, int line, int column,
/// The warning as a so the existing issue list can show it.
public ValidationIssue ToValidationIssue() => new(ValidationSeverity.Warning, MessageKey, ElementId, Args.ToArray());
+ // The keys that mean "not modelled, kept word for word" rather than "read, but worth a note" (an approximated value, a missing size).
+ private static readonly HashSet RawKeys = new(StringComparer.Ordinal)
+ {
+ "Import.Command.Unknown", "Import.Setting.Unsupported", "Import.StoredFormat.Unsupported", "Import.ImageSave.Unsupported",
+ "Import.Graphic.Format.Unsupported", "Import.Field.OptionNotApplicable", "Import.Font.AliasLate",
+ };
+
+ ///
+ /// True when the warning says that a command or a whole field was kept as raw ZPL because Rotula does not model it. The other
+ /// warnings are about things that were read (a converted unit, an approximated setting, a missing size, a recall of a stored object).
+ ///
+ public bool KeptAsRaw => RawKeys.Contains(MessageKey);
+
///
public override string ToString() => $"{MessageKey}({string.Join(", ", Args)}) at {Line}:{Column}";
}
diff --git a/src/Rotula.Core/Zpl/Import/ZplImporter.cs b/src/Rotula.Core/Zpl/Import/ZplImporter.cs
index f98b2e5..5fb1425 100644
--- a/src/Rotula.Core/Zpl/Import/ZplImporter.cs
+++ b/src/Rotula.Core/Zpl/Import/ZplImporter.cs
@@ -73,6 +73,8 @@ private sealed class DocCtx
/// Offset where the label ended (its ^XZ, or the end of the input); downloads before it belong to it.
public int EndPosition = int.MaxValue;
public Dictionary Positions { get; } = new();
+ /// The variable name chosen for each field number seen so far, so fields that share a number share a placeholder.
+ public Dictionary FieldNames { get; } = new();
}
private readonly string _s;
@@ -98,6 +100,8 @@ private sealed class DocCtx
private readonly List<(int Position, StoredGraphic Graphic)> _stored = new(); // ~DG / ~DY downloads in file order
private long _storedBytes;
private const long MaxStoredBytes = 16L * 1024 * 1024;
+ // Decoded size charged so far by ~DG downloads and ^GF fields together (see ZplParseOptions.MaxDecodedBytes).
+ private long _decodedBytes;
public ZplImporter(string text, bool bytesMode, ZplParseOptions options, bool utf8Bom)
{
@@ -897,6 +901,12 @@ private bool TryStoreDownload(ZplCommand c)
Warn("Import.Graphic.Invalid", c.Start, c.Display, null, total.ToString(CultureInfo.InvariantCulture));
return false;
}
+ // The shared budget is checked from the declared size, before TryDecode allocates anything for it.
+ if (!TryCharge(total))
+ {
+ Warn("Import.Graphic.TooMuchData", c.Start, c.Display, null, total.ToString(CultureInfo.InvariantCulture));
+ return false;
+ }
if (!ZplGraphicData.TryDecode(data, total, perRow, out byte[] packed, out bool truncated) || total % perRow != 0)
{
Warn("Import.Graphic.Invalid", c.Start, c.Display, null, total.ToString(CultureInfo.InvariantCulture));
@@ -908,6 +918,18 @@ private bool TryStoreDownload(ZplCommand c)
return true;
}
+ ///
+ /// Reserves bytes of the import's decoded-size budget. Returns false (and reserves nothing) when it would not fit.
+ /// A cost that is not positive is not charged: the decoder rejects such a size itself.
+ ///
+ private bool TryCharge(long cost)
+ {
+ if (cost < 1) return true;
+ if (cost > _options.MaxDecodedBytes - _decodedBytes) return false;
+ _decodedBytes += cost;
+ return true;
+ }
+
// ---- fields ----------------------------------------------------------------------------
private void OpenField(DocCtx d, ZplCommand c, string[] p)
@@ -966,9 +988,66 @@ private void FlushField(ZplCommand? terminator, bool implicitEnd)
element.Y = f.Y;
element.OriginKind = f.Kind;
element.ReverseField = f.Reverse || f.LabelReverse;
+ // A numbered field is the printer's way of saying "this data is filled in later", which is what a placeholder is here.
+ if (element is FieldDataElement { FieldNumber: { } number, Serial: null } numbered) BindNumberedField(d, numbered, number);
AddElement(d, element, f.StartPosition);
}
+ ///
+ /// Turns a numbered field (^FN) into a {{placeholder}} and declares the variable, so the field can be merged from data
+ /// instead of being plain text. The number and prompt stay on the element, so the ZPL written back is the same as the original, and the
+ /// data the field carried becomes the variable's sample value (its printed value until data is merged). Fields that share a number share
+ /// the variable, as they share their data on the printer. A printer-side serial number (^SN) is left alone: its data is a start
+ /// value the printer counts from, not something to fill in.
+ ///
+ private static void BindNumberedField(DocCtx d, FieldDataElement element, int number)
+ {
+ string current = element is TextElement text ? text.Text : ((BarcodeElement)element).Data;
+ string name = FieldVariableName(d, number, element.FieldPrompt);
+ var variable = d.Doc.Variables.FirstOrDefault(v => v.Name == name);
+ if (variable is null) d.Doc.Variables.Add(new LabelVariable(name, current));
+ else if (variable.SampleValue.Length == 0 && current.Length > 0) variable.SampleValue = current;
+ string placeholder = "{{" + name + "}}";
+ if (element is TextElement t) t.Text = placeholder;
+ else ((BarcodeElement)element).Data = placeholder;
+ }
+
+ ///
+ /// The variable name of a field number: the prompt text written in the ^FN (Customer name becomes Customer_name) when
+ /// there is one, otherwise Field plus the number. The same number always gives the same name; a prompt already used by another
+ /// number gets a numeric suffix, because on the printer those are two different fields.
+ ///
+ private static string FieldVariableName(DocCtx d, int number, string? prompt)
+ {
+ if (d.FieldNames.TryGetValue(number, out var known)) return known;
+ string fallback = "Field" + number.ToString(CultureInfo.InvariantCulture);
+ string candidate = SanitizeVariableName(prompt) ?? fallback;
+ string unique = candidate;
+ for (int suffix = 2; d.FieldNames.ContainsValue(unique); suffix++)
+ unique = candidate + "_" + suffix.ToString(CultureInfo.InvariantCulture);
+ d.FieldNames[number] = unique;
+ return unique;
+ }
+
+ /// Makes a placeholder name out of free text (letters, digits and underscores only); null when nothing usable is left.
+ internal static string? SanitizeVariableName(string? text)
+ {
+ if (string.IsNullOrWhiteSpace(text)) return null;
+ var sb = new StringBuilder(text.Length);
+ bool lastUnderscore = true; // suppresses a leading underscore
+ foreach (char ch in text)
+ {
+ if (char.IsAsciiLetterOrDigit(ch)) { sb.Append(ch); lastUnderscore = false; }
+ else if (!lastUnderscore) { sb.Append('_'); lastUnderscore = true; }
+ }
+ string name = sb.ToString().TrimEnd('_');
+ if (name.Length == 0) return null;
+ // A name cannot start with a digit; "123" becomes "Field_123".
+ if (char.IsAsciiDigit(name[0])) name = "Field_" + name;
+ if (name.Length > Variables.VariableLimits.MaxNameLength) name = name[..Variables.VariableLimits.MaxNameLength].TrimEnd('_');
+ return Variables.VariableEngine.IsValidName(name) ? name : null;
+ }
+
///
/// Copies the options collected for the field onto the element. Returns false (after warning) when the element cannot hold
/// one of them, for example a field number on a box: the caller then keeps the whole field verbatim.
@@ -1201,6 +1280,15 @@ private LabelElement BuildImage(DocCtx d, Field f)
int total = Int(head, 2, c) ?? 0, perRow = Int(head, 3, c) ?? 0;
string data = parts.Length > 4 ? parts[4] : string.Empty;
+ // A short payload is zero-padded to the declared size, and the pixels take eight bytes per packed byte, so the declared size (not
+ // the data length) is what costs memory. It is charged before anything is allocated; once the budget is gone the field stays raw ZPL.
+ // Sizes the decoder would refuse anyway are not charged, so a bad field does not eat the budget of the good ones.
+ long cost = total >= 1 && total <= ZplGraphicData.MaxBytes && perRow >= 1 && perRow <= total ? (long)total * 8 : 0;
+ if (!TryCharge(cost))
+ {
+ Fail("Import.Graphic.TooMuchData", total.ToString(CultureInfo.InvariantCulture));
+ return BuildRawField(f, null);
+ }
if (!ZplGraphicData.TryDecode(data, total, perRow, out byte[] packed, out bool truncated))
{
Fail("Import.Graphic.Invalid", total.ToString(CultureInfo.InvariantCulture));
diff --git a/src/Rotula.Core/Zpl/RasterTextEmitter.cs b/src/Rotula.Core/Zpl/RasterTextEmitter.cs
index b721823..7b6a962 100644
--- a/src/Rotula.Core/Zpl/RasterTextEmitter.cs
+++ b/src/Rotula.Core/Zpl/RasterTextEmitter.cs
@@ -25,7 +25,8 @@ internal static class RasterTextEmitter
private const int MaxHeight = 32000;
private const int MaxBytes = 4 * 1024 * 1024;
- public static string? Emit(LabelDocument doc, TextElement t, FontChoice font, IReadOnlyDictionary? data, VariableResolution? resolution,
+ /// Writes the field. is the absolute origin (resolved from the anchor when the text has one).
+ public static string? Emit(LabelDocument doc, TextElement t, (int X, int Y) at, FontChoice font, IReadOnlyDictionary? data, VariableResolution? resolution,
List issues, ITextRasterizer? rasterizer)
{
if (font.Validate() is { } invalid)
@@ -100,7 +101,7 @@ internal static class RasterTextEmitter
// ^GF ignores rotation and, with ^FT, anchors the graphic's bottom-left corner instead of the text baseline. The picture is therefore
// always placed with ^FO: for a typeset text its top-left corner is worked out from the baseline and the turn of the field.
- int x = t.X, y = t.Y;
+ int x = at.X, y = at.Y;
if (t.OriginKind == FieldOriginKind.Typeset)
{
var (dx, dy) = TypesetCorner(rotation, m.WidthDots, m.HeightDots, m.BaselineDots);
diff --git a/src/Rotula.Core/Zpl/ZplEncoding.cs b/src/Rotula.Core/Zpl/ZplEncoding.cs
index 1b4a061..07204fa 100644
--- a/src/Rotula.Core/Zpl/ZplEncoding.cs
+++ b/src/Rotula.Core/Zpl/ZplEncoding.cs
@@ -62,6 +62,50 @@ public static bool TryFromCiCode(int ciCode, out ZplEncoding encoding, out bool
}
}
+ ///
+ /// Rebuilds an encoding from the two numbers a saved file carries. Returns false unless the pair is one Rotula itself produces: UTF-8 is
+ /// ^CI28 with no code page, and a legacy set must name exactly the code page gives that ^CI number. A
+ /// file is untrusted input, and an unknown or mismatched code page would either throw while generating ZPL or print other characters than
+ /// the label shows.
+ ///
+ /// The ^CI number.
+ /// The .NET code page, or null for UTF-8.
+ /// The encoding when the method returns true.
+ public static bool TryFromStored(int ciCode, int? codePage, out ZplEncoding encoding)
+ {
+ if (TryFromCiCode(ciCode, out var known, out _) && known.DotNetCodePage == codePage && known.IsUsable)
+ {
+ encoding = known;
+ return true;
+ }
+ encoding = Utf8;
+ return false;
+ }
+
+ ///
+ /// True when works and gives a single-byte code page (or UTF-8). An encoding made with from a number
+ /// that is not a code page of this machine is not usable, and the generator reports that instead of throwing.
+ ///
+ public bool IsUsable
+ {
+ get
+ {
+ if (DotNetCodePage is null) return true;
+ try { return GetEncoding().IsSingleByte; }
+ catch (Exception ex) when (ex is ArgumentException or NotSupportedException) { return false; }
+ }
+ }
+
+ ///
+ /// The number of bytes takes in this encoding when every character the code page cannot hold is sent as a single
+ /// ?, which is exactly what writes for it. Unlike it never throws for such characters.
+ ///
+ public int GetByteCountLenient(string text)
+ {
+ if (DotNetCodePage is null) return GetEncoding().GetByteCount(text);
+ return Encoding.GetEncoding(DotNetCodePage.Value, new EncoderReplacementFallback("?"), DecoderFallback.ExceptionFallback).GetByteCount(text);
+ }
+
/// The number sent with ^CI.
public int CiCode { get; }
diff --git a/src/Rotula.Core/Zpl/ZplGenerator.cs b/src/Rotula.Core/Zpl/ZplGenerator.cs
index 33bcf2f..e0082a6 100644
--- a/src/Rotula.Core/Zpl/ZplGenerator.cs
+++ b/src/Rotula.Core/Zpl/ZplGenerator.cs
@@ -3,6 +3,7 @@
using Rotula.Core.Barcodes;
using Rotula.Core.Elements;
using Rotula.Core.Imaging;
+using Rotula.Core.Layout;
using Rotula.Core.Model;
using Rotula.Core.Text;
using Rotula.Core.Validation;
@@ -25,13 +26,22 @@ public ZplValidationException(IReadOnlyList issues)
public IReadOnlyList Issues { get; }
}
+///
+/// A numbered field of a stored format: the ^FN number the format uses for a placeholder and the variable it stands for.
+///
+/// The field number written as ^FN, 0 to 9999.
+/// The variable name of the placeholder the field holds.
+/// Id of the element the field was written for.
+public sealed record StoredFormatField(int Number, string Name, string ElementId);
+
/// Output of .
public sealed class ZplGenerationResult
{
- internal ZplGenerationResult(string zpl, IReadOnlyList issues)
+ internal ZplGenerationResult(string zpl, IReadOnlyList issues, IReadOnlyList? formatFields = null)
{
Zpl = zpl;
Issues = issues;
+ FormatFields = formatFields ?? Array.Empty();
}
/// The ZPL program (^XA...^XZ, LF line endings, pure ASCII). Elements with errors are omitted.
@@ -40,6 +50,12 @@ internal ZplGenerationResult(string zpl, IReadOnlyList issues)
/// Errors and warnings found while generating.
public IReadOnlyList Issues { get; }
+ ///
+ /// The numbered fields written by , in label order; empty for an ordinary label.
+ /// A recall of the format () fills exactly these numbers.
+ ///
+ public IReadOnlyList FormatFields { get; }
+
/// True when at least one issue is an error (the ZPL then lacks the offending elements).
public bool HasErrors => Issues.Any(i => i.Severity == ValidationSeverity.Error);
@@ -92,21 +108,179 @@ internal static ZplGenerationResult GenerateResolved(LabelDocument doc, Variable
return GenerateCore(doc, resolution.Values, resolution, quantity, rasterizer);
}
- private static ZplGenerationResult GenerateCore(LabelDocument doc, IReadOnlyDictionary? data, VariableResolution? resolution, int? quantity, ITextRasterizer? rasterizer)
+ // ---- stored formats (^DF) and their recalls (^XF) ---------------------------------------
+
+ ///
+ /// Writes the label as a format stored on the printer: ^DF right after ^XA, and every text or barcode whose content is exactly one
+ /// {{placeholder}} written as a numbered field (^FN with the variable name as its prompt) without data, so the printer fills it
+ /// in when the format is recalled. The numbers are the elements' own field numbers where they have one, otherwise the lowest free numbers in
+ /// label order; placeholders of the same name share a number, as they share their data on the printer. Fixed text, graphics and barcodes
+ /// without a placeholder are written as usual. A field that mixes fixed text with placeholders cannot be numbered (the printer replaces the
+ /// whole data), so it is written with its sample value and reported as Format.Field.Mixed; the same goes for a Windows-font text,
+ /// which prints as a picture (Format.Field.Picture). The print quantity is left out: it belongs to the recall.
+ ///
+ /// The label.
+ /// Where the printer stores the format, for example R:SHIP.ZPL; the drive defaults to R: and the extension to .ZPL.
+ /// Draws the text of Windows fonts (see ).
+ public static ZplGenerationResult GenerateStoredFormat(LabelDocument doc, string formatPath, ITextRasterizer? rasterizer = null)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ return GenerateCore(doc, null, null, null, rasterizer, new FormatPlan(doc, NormalizeFormatPath(formatPath)));
+ }
+
+ ///
+ /// Writes the label that prints a stored format: ^XF followed by one ^FN field per placeholder with the value from
+ /// (or the sample value), then the print quantity. The numbering is the one gives
+ /// the same label, so the two always match. The recall is self-contained: it states the character set so accents are read as the format expects.
+ ///
+ /// The label the format was made from.
+ /// The stored format to print, written as for .
+ /// Values for the placeholders; names missing here use the declared sample values.
+ /// When set, replaces the label's own print quantity.
+ public static ZplGenerationResult GenerateFormatRecall(LabelDocument doc, string formatPath, IReadOnlyDictionary? data = null, int? quantity = null)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ string path = NormalizeFormatPath(formatPath);
+ // The format run decides the numbers; its issues about the path and the fields apply to the recall just as much.
+ var format = GenerateStoredFormat(doc, path);
+ var issues = new List(format.Issues.Where(i => i.MessageKey.StartsWith("Format.", StringComparison.Ordinal)));
+ var sb = new StringBuilder();
+ void Line(string s) => sb.Append(s).Append('\n');
+ Line("^XA");
+ Line("^XF" + path + "^FS");
+ Line(Invariant($"^CI{doc.Encoding.CiCode}"));
+ var written = new HashSet();
+ foreach (var field in format.FormatFields)
+ {
+ // Two elements that share a name share a number: one value is enough and the printer prints it in both.
+ if (!written.Add(field.Number)) continue;
+ var element = doc.Elements.FirstOrDefault(e => e.Id == field.ElementId);
+ string value = VariableResolver.Resolve("{{" + field.Name + "}}", data, doc.Variables, field.ElementId, issues);
+ bool lineBreaks = element is TextElement { Block: not null };
+ var esc = ZplText.Escape(value, doc.Encoding, lineBreaks);
+ foreach (var ch in esc.Unmappable) issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Text.UnmappableChar", field.ElementId, ch));
+ Line((esc.NeedsHexEscape ? "^FH_" : string.Empty) + "^FN" + field.Number.ToString(CultureInfo.InvariantCulture) + "^FD" + esc.Text + "^FS");
+ }
+ var p = doc.Print;
+ Line(Invariant($"^PQ{quantity ?? p.Quantity},{p.PauseEvery},{p.Replicates},{(p.OverridePause ? 'Y' : 'N')}"));
+ Line("^XZ");
+ return new ZplGenerationResult(sb.ToString(), issues, format.FormatFields);
+ }
+
+ // A stored format name: a drive letter, up to 16 characters and the .ZPL extension (older printers accept 8 characters).
+ private static readonly System.Text.RegularExpressions.Regex FormatPathShape = new(@"^[REBA]:[A-Z0-9_\-]{1,16}\.ZPL$",
+ System.Text.RegularExpressions.RegexOptions.CultureInvariant | System.Text.RegularExpressions.RegexOptions.Compiled);
+
+ ///
+ /// Normalises a stored format name the way the printer reads it: upper case, drive R: when none is given, extension .ZPL when none is
+ /// given. ship becomes R:SHIP.ZPL.
+ ///
+ public static string NormalizeFormatPath(string? path)
+ {
+ string p = (path ?? string.Empty).Trim().ToUpperInvariant();
+ string device = "R:";
+ int colon = p.IndexOf(':');
+ if (colon == 1) { device = p[..2]; p = p[2..]; }
+ else if (colon > 1) { p = p[(colon + 1)..]; }
+ if (!p.Contains('.')) p += ".ZPL";
+ return device + p;
+ }
+
+ /// True when (as returned by ) is a name a printer accepts for a stored format.
+ public static bool IsValidFormatPath(string? path) => FormatPathShape.IsMatch(path ?? string.Empty);
+
+ ///
+ /// The bookkeeping of one stored-format run: the format's name and the field number given to each placeholder name. Numbers already
+ /// used by elements of the label are reserved first, so an allocated number never collides with one the designer chose.
+ ///
+ private sealed class FormatPlan
+ {
+ private readonly Dictionary _numbers = new(StringComparer.Ordinal);
+ private readonly HashSet _used;
+ public FormatPlan(LabelDocument doc, string path)
+ {
+ Path = path;
+ _used = doc.Elements.OfType().Where(e => e.FieldNumber is not null).Select(e => e.FieldNumber!.Value).ToHashSet();
+ // An element that carries both a number and a placeholder fixes the number of that name; the first one in label order wins.
+ foreach (var e in doc.Elements.OfType())
+ if (e.FieldNumber is { } n && PureVariable(e) is { } name && !_numbers.ContainsKey(name)) _numbers[name] = n;
+ }
+ public string Path { get; }
+ public List Fields { get; } = new();
+
+ /// The number for a placeholder name: the one it already has, or the lowest number no element uses.
+ public int NumberFor(string name)
+ {
+ if (_numbers.TryGetValue(name, out int n)) return n;
+ n = 1;
+ while (_used.Contains(n)) n++;
+ _used.Add(n);
+ _numbers[name] = n;
+ return n;
+ }
+ }
+
+ /// The variable name when the element's content is exactly one placeholder and nothing else; otherwise null.
+ private static string? PureVariable(FieldDataElement e)
+ {
+ string template = e switch
+ {
+ TextElement t => t.GetTemplate(),
+ // A QR code built by the content builder carries structure the printer cannot fill in, so only hand-typed data counts.
+ BarcodeElement { QrContentSpec.Length: 0 } b => b.Data,
+ _ => string.Empty,
+ };
+ var names = VariableResolver.FindVariables(template);
+ if (names.Count != 1 || names[0].StartsWith('#')) return null;
+ return template.Trim() == "{{" + names[0] + "}}" ? names[0] : null;
+ }
+
+ ///
+ /// Decides how a data field is written in a stored format: the numbered field to use, or null when the field keeps its data. A field that
+ /// has placeholders but cannot be numbered is reported, because its sample value would otherwise be stored as if it were fixed text.
+ ///
+ private static (int Number, string Name)? FormatFieldFor(FormatPlan? format, FieldDataElement e, List issues)
+ {
+ if (format is null) return null;
+ string template = e is TextElement t ? t.GetTemplate() : ((BarcodeElement)e).Data;
+ if (PureVariable(e) is { } name && e.Serial is null)
+ {
+ int number = e.FieldNumber ?? format.NumberFor(name);
+ format.Fields.Add(new StoredFormatField(number, name, e.Id));
+ return (number, name);
+ }
+ if (VariableResolver.FindVariables(template).Count > 0)
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Format.Field.Mixed", e.Id));
+ return null;
+ }
+
+ private static ZplGenerationResult GenerateCore(LabelDocument doc, IReadOnlyDictionary? data, VariableResolution? resolution, int? quantity, ITextRasterizer? rasterizer, FormatPlan? format = null)
{
ArgumentNullException.ThrowIfNull(doc);
var issues = new List();
if (resolution is not null) issues.AddRange(resolution.Issues);
+ // Text and barcode data are turned into bytes with the label's encoding all through generation; one that cannot be built would throw
+ // from deep inside, so it is reported once here and nothing is generated.
+ if (!doc.Encoding.IsUsable)
+ {
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Encoding.Unsupported", null, doc.Encoding.CiCode.ToString(CultureInfo.InvariantCulture)));
+ return new ZplGenerationResult(string.Empty, issues, format?.Fields);
+ }
int widthDots = doc.WidthDots, heightDots = doc.HeightDots;
ValidateDocument(doc, widthDots, heightDots, issues);
+ if (format is not null && !IsValidFormatPath(format.Path))
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Format.Path.Invalid", null, format.Path));
var sb = new StringBuilder();
void Line(string s) => sb.Append(s).Append('\n');
// Downloaded graphics are immediate (tilde) commands: they go before the label format, not inside it.
- foreach (var g in doc.StoredGraphics) Line(StoredGraphicCommand(g));
+ // Only graphics that pass validation are written (ValidateDocument reports the others): the name goes into the command unescaped.
+ foreach (var g in doc.StoredGraphics.Where(IsWritableStoredGraphic)) Line(StoredGraphicCommand(g));
Line("^XA");
+ // The store command must come first: the printer stores everything that follows it instead of printing.
+ if (format is not null) Line("^DF" + format.Path + "^FS");
Line("^MN" + doc.MediaTracking switch { MediaTracking.Continuous => "N", MediaTracking.Mark => "M", _ => "Y" });
Line("^MT" + (doc.MediaType == MediaType.DirectThermal ? "D" : "T"));
Line(Invariant($"^CI{doc.Encoding.CiCode}"));
@@ -125,26 +299,38 @@ private static ZplGenerationResult GenerateCore(LabelDocument doc, IReadOnlyDict
if (doc.MediaDarkness is { } darkness) Line("^MD" + darkness.ToString(CultureInfo.InvariantCulture));
Line("^FW" + RotationChar(doc.DefaultRotation));
if (doc.DefaultFont is { } f) Line(Invariant($"^CF{f.FontId},{f.Height},{f.Width}"));
- foreach (var alias in doc.FontAliases) Line("^CW" + alias.Letter + "," + alias.Path);
+ foreach (var alias in doc.FontAliases.Where(a => FontAlias.IsValid(a.Letter, a.Path))) Line("^CW" + alias.Letter + "," + alias.Path);
// Offsets of the printer's secondary and third clock, defined once for the whole label before any field uses them.
if (resolution is not null)
foreach (var (clockSet, offset) in resolution.ClockOffsets.OrderBy(kv => kv.Key)) Line(PrinterClock.OffsetCommand(clockSet, offset));
+ // Anchored objects get their absolute origin here, for this record: a text that is wider on one record moves its right-anchored
+ // neighbour on that record only. Labels without anchors skip the whole step, so their output is byte-for-byte as before.
+ AnchorLayout? layout = AnchorResolver.HasAnchors(doc) ? AnchorResolver.Resolve(doc, data, rasterizer) : null;
+ if (layout is not null) issues.AddRange(layout.Issues);
+
foreach (var element in doc.Elements)
{
// An object that is switched off for this record is not emitted and not validated: a barcode that is only invalid on a record
// where it is hidden must not stop that record from printing. The same data that fills the placeholders decides the condition.
if (!ElementPrinting.IsPrinted(element, data, doc.Variables, issues)) continue;
+ // An object on a reference circle has no position (its error is already in the issues) and is left out like any other invalid field.
+ if (layout is not null && layout.IsOnCycle(element)) continue;
+ var at = layout?.OriginOf(element) ?? (element.X, element.Y);
int before = issues.Count;
- string? field = EmitElement(doc, element, data, resolution, issues, rasterizer);
+ string? field = EmitElement(doc, element, at, data, resolution, issues, rasterizer, format);
bool failed = issues.Skip(before).Any(i => i.Severity == ValidationSeverity.Error);
if (field is not null && !failed) Line(field);
}
- var p = doc.Print;
- Line(Invariant($"^PQ{quantity ?? p.Quantity},{p.PauseEvery},{p.Replicates},{(p.OverridePause ? 'Y' : 'N')}"));
+ // A stored format has no quantity of its own; the recall that prints it says how many.
+ if (format is null)
+ {
+ var p = doc.Print;
+ Line(Invariant($"^PQ{quantity ?? p.Quantity},{p.PauseEvery},{p.Replicates},{(p.OverridePause ? 'Y' : 'N')}"));
+ }
Line("^XZ");
- return new ZplGenerationResult(sb.ToString(), issues);
+ return new ZplGenerationResult(sb.ToString(), issues, format?.Fields);
}
// ---- document / element validation ----------------------------------------------------
@@ -163,17 +349,16 @@ private static void ValidateDocument(LabelDocument doc, int w, int h, List v is < 1 or > 14))
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Print.Rate.Range", null, "1", "14"));
foreach (var alias in doc.FontAliases)
- if (!(char.IsAsciiLetterOrDigit(alias.Letter) && FontPath.IsMatch(alias.Path)))
+ if (!FontAlias.IsValid(alias.Letter, alias.Path))
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Font.Alias.Invalid", null, alias.Letter.ToString(), alias.Path));
foreach (var g in doc.StoredGraphics)
- if (!StoredGraphic.IsValidPath(g.Path) || g.Data.Length > 4 * 1024 * 1024)
+ if (!IsWritableStoredGraphic(g))
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Stored.Invalid", null, g.Path));
if (doc.DefaultFont is { } f && (f.Height < 10 || f.Height > 32000 || f.Width < 0 || f.Width > 32000))
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Font.Size.Range", null, "10", "32000"));
}
- // Font file names: an optional device letter, a name of up to 8 characters and a font extension.
- private static readonly System.Text.RegularExpressions.Regex FontPath = new(@"^(?:[REBA]:)?[A-Za-z0-9_\-]{1,8}\.(?:FNT|TTF|TTE|fnt|ttf|tte)$", System.Text.RegularExpressions.RegexOptions.CultureInvariant | System.Text.RegularExpressions.RegexOptions.Compiled);
+ private static bool IsWritableStoredGraphic(StoredGraphic g) => StoredGraphic.IsValidPath(g.Path) && g.Data.Length <= 4 * 1024 * 1024;
private static string StoredGraphicCommand(StoredGraphic g) =>
Invariant($"~DG{g.Path},{g.Data.Length},{g.BytesPerRow},") + Convert.ToHexString(g.Data);
@@ -190,42 +375,44 @@ private static string StoredGraphicCommand(StoredGraphic g) =>
_ => 'T',
};
- private static void CheckPosition(LabelElement e, LabelDocument doc, int extentW, int extentH, List issues)
+ /// Checks the absolute origin (the resolved position, which for an anchored object is not its X/Y).
+ private static void CheckPosition(LabelElement e, (int X, int Y) at, LabelDocument doc, int extentW, int extentH, List issues)
{
- if (e.X < 0 || e.Y < 0 || e.X > 32000 || e.Y > 32000)
+ if (at.X < 0 || at.Y < 0 || at.X > 32000 || at.Y > 32000)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Element.Position.Range", e.Id, "0", "32000"));
- else if (e.X + extentW > doc.WidthDots || e.Y + extentH > doc.HeightDots)
+ else if (at.X + extentW > doc.WidthDots || at.Y + extentH > doc.HeightDots)
// Only a warning: printers clip silently, and the extent of text/barcodes is unknown here.
issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Element.OutsideLabel", e.Id));
}
// ---- element emission ------------------------------------------------------------------
- private static string? EmitElement(LabelDocument doc, LabelElement e, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer)
+ private static string? EmitElement(LabelDocument doc, LabelElement e, (int X, int Y) at, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer, FormatPlan? format)
{
switch (e)
{
- case TextElement t: return EmitText(doc, t, data, resolution, issues, rasterizer);
- case BarcodeElement b: return EmitBarcode(doc, b, data, issues);
- case BoxElement box: return EmitBox(doc, box, issues);
- case LineElement line: return EmitLine(doc, line, issues);
- case CircleElement c: return EmitCircle(doc, c, issues);
- case EllipseElement el: return EmitEllipse(doc, el, issues);
- case DiagonalLineElement dl: return EmitDiagonal(doc, dl, issues);
- case SymbolElement sy: return EmitSymbol(doc, sy, issues);
- case ImageElement img: return EmitImage(doc, img, issues);
+ case TextElement t: return EmitText(doc, t, at, data, resolution, issues, rasterizer, format);
+ case BarcodeElement b: return EmitBarcode(doc, b, at, data, issues, format);
+ case BoxElement box: return EmitBox(doc, box, at, issues);
+ case LineElement line: return EmitLine(doc, line, at, issues);
+ case CircleElement c: return EmitCircle(doc, c, at, issues);
+ case EllipseElement el: return EmitEllipse(doc, el, at, issues);
+ case DiagonalLineElement dl: return EmitDiagonal(doc, dl, at, issues);
+ case SymbolElement sy: return EmitSymbol(doc, sy, at, issues);
+ case ImageElement img: return EmitImage(doc, img, at, issues);
// Imported content the designer cannot model is written back unchanged.
case RawZplElement raw: return raw.Zpl.Length == 0 ? null : raw.Zpl;
case RecallPlaceholderElement recall:
- return recall.Zpl.Length == 0 ? null : recall.EmitAsField ? Origin(recall) + recall.Zpl + "^FS" : recall.Zpl;
+ return recall.Zpl.Length == 0 ? null : recall.EmitAsField ? Origin(recall, at) + recall.Zpl + "^FS" : recall.Zpl;
default:
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Element.Unsupported", e.Id, e.GetType().Name));
return null;
}
}
- internal static string Origin(LabelElement e) =>
- Invariant($"^F{(e.OriginKind == FieldOriginKind.Typeset ? 'T' : 'O')}{e.X},{e.Y}") + (e.ReverseField ? "^FR" : string.Empty);
+ /// The field origin command for an element placed at the absolute point .
+ internal static string Origin(LabelElement e, (int X, int Y) at) =>
+ Invariant($"^F{(e.OriginKind == FieldOriginKind.Typeset ? 'T' : 'O')}{at.X},{at.Y}") + (e.ReverseField ? "^FR" : string.Empty);
internal static string RotationChar(FieldRotation r) => r switch
{
@@ -247,15 +434,21 @@ private static string Params(params string[] ps)
private static string YN(bool v) => v ? "Y" : "N";
- private static string? EmitText(LabelDocument doc, TextElement t, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer)
+ private static string? EmitText(LabelDocument doc, TextElement t, (int X, int Y) at, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer, FormatPlan? format = null)
{
// A Windows font cannot exist on the printer: the text is drawn to a picture and the label carries the picture.
if (t.Font is { IsWindows: true } windowsFont)
- return RasterTextEmitter.Emit(doc, t, windowsFont, data, resolution, issues, rasterizer);
+ {
+ // A picture is fixed once drawn, so a stored format cannot fill its placeholders in later.
+ if (format is not null && VariableResolver.FindVariables(t.GetTemplate()).Count > 0)
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Format.Field.Picture", t.Id));
+ return RasterTextEmitter.Emit(doc, t, at, windowsFont, data, resolution, issues, rasterizer);
+ }
- CheckPosition(t, doc, 0, 0, issues);
+ CheckPosition(t, at, doc, 0, 0, issues);
+ var formatField = FormatFieldFor(format, t, issues);
string resolved = ResolveTextWithClock(doc, t, data, resolution, issues, out FieldClockOptions? autoClock);
- var sb = new StringBuilder(Origin(t));
+ var sb = new StringBuilder(Origin(t, at));
// Text fit: decided here, for this record's own text, so every label of a data merge gets the size its content needs. The printer only
// ever sees the computed ^A / ^FB values; the fit mode itself is Rotula metadata.
@@ -314,16 +507,31 @@ private static string Params(params string[] ps)
if (fit != TextOverflow.None) issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Text.Overflow.Estimated", t.Id));
}
- AppendDataField(sb, doc, t, resolved, allowLineBreaks: t.Block is not null, textBox: t.TextBox is not null, fdPrefix: string.Empty, issues, autoClock);
+ AppendDataField(sb, doc, t, resolved, allowLineBreaks: t.Block is not null, textBox: t.TextBox is not null, fdPrefix: string.Empty, issues, autoClock, formatField);
return sb.ToString();
}
///
/// Writes the part of a data field after its command: field options, then the data (^FD, or ^SN for a counting
- /// number) and finally ^FS. Shared by text and barcodes so both support the same options.
+ /// number) and finally ^FS. Shared by text and barcodes so both support the same options. In a stored format
+ /// ( set) the field is numbered and written without data, for the recall to fill in.
///
- private static void AppendDataField(StringBuilder sb, LabelDocument doc, FieldDataElement e, string text, bool allowLineBreaks, bool textBox, string fdPrefix, List issues, FieldClockOptions? derivedClock = null)
+ private static void AppendDataField(StringBuilder sb, LabelDocument doc, FieldDataElement e, string text, bool allowLineBreaks, bool textBox, string fdPrefix, List issues, FieldClockOptions? derivedClock = null, (int Number, string Name)? formatField = null)
{
+ if (formatField is { } numbered)
+ {
+ // The printer puts the recalled value where the data would be, so the field carries only its number and a prompt. The prompt is the
+ // variable name with underscores as spaces, which is what the importer turns back into the same name.
+ if (e.EmbedCharacter is { } embedChar) sb.Append("^FE").Append(embedChar);
+ if (e.Clock is { } fieldClock) sb.Append("^FC").Append(Params(fieldClock.Primary.ToString(), fieldClock.Secondary?.ToString() ?? string.Empty, fieldClock.Third?.ToString() ?? string.Empty));
+ sb.Append("^FN").Append(numbered.Number.ToString(CultureInfo.InvariantCulture));
+ string prompt = (e.FieldPrompt is { Length: > 0 } own ? own : numbered.Name.Replace('_', ' ')).Trim();
+ if (prompt.Length > 0 && prompt.Length <= 255 && prompt.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == ' '))
+ sb.Append('"').Append(prompt).Append('"');
+ sb.Append("^FS");
+ return;
+ }
+
var serial = e.Serial;
string? body = null; // the ^SN replacement for ^FD
@@ -452,58 +660,76 @@ private static string ResolveTextWithClock(LabelDocument doc, TextElement t, IRe
});
}
- private static string? EmitBarcode(LabelDocument doc, BarcodeElement b, IReadOnlyDictionary? data, List issues)
+ private static string? EmitBarcode(LabelDocument doc, BarcodeElement b, (int X, int Y) at, IReadOnlyDictionary? data, List issues, FormatPlan? format = null)
{
// A QR Code made by the content builder is built from its structured definition per record, so escaping is applied to the values
// after substitution; Data (the template) is only used when there is no definition.
string resolved = Barcodes.QrContent.QrContentBuilder.ResolveForElement(b, data, doc.Variables, issues)
?? VariableResolver.Resolve(b.Data, data, doc.Variables, b.Id, issues);
- var found = BarcodeValidator.Validate(b, resolved);
+ var formatField = FormatFieldFor(format, b, issues);
+ // A numbered field of a stored format carries no data, so its sample value (often empty) is not what the printer will encode.
+ IReadOnlyList found = formatField is null ? BarcodeValidator.Validate(b, resolved) : Array.Empty();
issues.AddRange(found);
- CheckPosition(b, doc, 0, 0, issues);
+ CheckPosition(b, at, doc, 0, 0, issues);
if (found.Any(i => i.Severity == ValidationSeverity.Error)) return null;
+ // Size advice (module too small for the printer, symbol plus quiet zone wider than the label). Only for data that is valid, so
+ // the symbol width is computed from something the printer can actually encode.
+ issues.AddRange(BarcodeSizing.Check(b, resolved, doc));
string payload = BarcodeValidator.NormalizeForPrinter(b, resolved);
- var sb = new StringBuilder(Origin(b));
+ var sb = new StringBuilder(Origin(b, at));
sb.Append(ZplBarcodeEmitter.Command(b, RotationOrEmpty(b.Rotation), RotationChar(doc.DefaultRotation), out string fdPrefix));
if (fdPrefix.Contains(ZplBarcodeEmitter.QrByteCountToken, StringComparison.Ordinal))
+ {
// Manual byte input states how many bytes follow, counted in the label's encoding (UTF-8 under ^CI28).
+ // Lenient count: a character the code page cannot hold goes out as one '?', exactly as ZplText.Escape writes it, so the count matches
+ // the bytes and the generator no longer throws; the substitution is reported like it is for a text field.
fdPrefix = fdPrefix.Replace(ZplBarcodeEmitter.QrByteCountToken,
- Math.Min(9999, doc.Encoding.GetEncoding().GetByteCount(payload)).ToString("0000", CultureInfo.InvariantCulture), StringComparison.Ordinal);
- AppendDataField(sb, doc, b, payload, allowLineBreaks: false, textBox: false, fdPrefix, issues);
+ Math.Min(9999, doc.Encoding.GetByteCountLenient(payload)).ToString("0000", CultureInfo.InvariantCulture), StringComparison.Ordinal);
+ foreach (var ch in ZplText.Escape(payload, doc.Encoding, allowLineBreaks: false).Unmappable)
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Text.UnmappableChar", b.Id, ch));
+ }
+ // A symbology whose data starts with a fixed header (a QR Code's "QA,") cannot be filled in by number: the recall would supply data without it.
+ if (formatField is not null && fdPrefix.Length > 0)
+ {
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Format.Field.Mixed", b.Id));
+ format!.Fields.RemoveAll(f => f.ElementId == b.Id);
+ formatField = null;
+ }
+ AppendDataField(sb, doc, b, payload, allowLineBreaks: false, textBox: false, fdPrefix, issues, null, formatField);
return sb.ToString();
}
- private static string? EmitBox(LabelDocument doc, BoxElement b, List issues)
+ private static string? EmitBox(LabelDocument doc, BoxElement b, (int X, int Y) at, List issues)
{
- CheckPosition(b, doc, b.WidthDots, b.HeightDots, issues);
+ CheckPosition(b, at, doc, b.WidthDots, b.HeightDots, issues);
if (b.WidthDots < 1 || b.HeightDots < 1 || b.WidthDots > 32000 || b.HeightDots > 32000 || b.ThicknessDots < 1 || b.ThicknessDots > 32000)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Size.Range", b.Id, "1", "32000"));
if (b.CornerRounding < 0 || b.CornerRounding > 8)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Rounding.Range", b.Id, "0", "8"));
- return Origin(b) + "^GB" + Params(b.WidthDots.ToString(CultureInfo.InvariantCulture), b.HeightDots.ToString(CultureInfo.InvariantCulture),
+ return Origin(b, at) + "^GB" + Params(b.WidthDots.ToString(CultureInfo.InvariantCulture), b.HeightDots.ToString(CultureInfo.InvariantCulture),
b.ThicknessDots.ToString(CultureInfo.InvariantCulture), ColorChar(b.Color), b.CornerRounding.ToString(CultureInfo.InvariantCulture)) + "^FS";
}
- private static string? EmitLine(LabelDocument doc, LineElement l, List issues)
+ private static string? EmitLine(LabelDocument doc, LineElement l, (int X, int Y) at, List issues)
{
bool horizontal = l.Direction == LineDirection.Horizontal;
- CheckPosition(l, doc, horizontal ? l.LengthDots : l.ThicknessDots, horizontal ? l.ThicknessDots : l.LengthDots, issues);
+ CheckPosition(l, at, doc, horizontal ? l.LengthDots : l.ThicknessDots, horizontal ? l.ThicknessDots : l.LengthDots, issues);
if (l.LengthDots < 1 || l.ThicknessDots < 1 || l.LengthDots > 32000 || l.ThicknessDots > 32000)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Size.Range", l.Id, "1", "32000"));
int w = horizontal ? l.LengthDots : l.ThicknessDots, h = horizontal ? l.ThicknessDots : l.LengthDots;
- return Origin(l) + Invariant($"^GB{w},{h},{l.ThicknessDots},{ColorChar(l.Color)}^FS");
+ return Origin(l, at) + Invariant($"^GB{w},{h},{l.ThicknessDots},{ColorChar(l.Color)}^FS");
}
- private static string? EmitCircle(LabelDocument doc, CircleElement c, List issues)
+ private static string? EmitCircle(LabelDocument doc, CircleElement c, (int X, int Y) at, List issues)
{
- CheckPosition(c, doc, c.DiameterDots, c.DiameterDots, issues);
+ CheckPosition(c, at, doc, c.DiameterDots, c.DiameterDots, issues);
if (c.DiameterDots < 3 || c.DiameterDots > 4095 || c.ThicknessDots < 1 || c.ThicknessDots > 4095)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Circle.Range", c.Id, "3", "4095"));
- return Origin(c) + Invariant($"^GC{c.DiameterDots},{c.ThicknessDots},{ColorChar(c.Color)}^FS");
+ return Origin(c, at) + Invariant($"^GC{c.DiameterDots},{c.ThicknessDots},{ColorChar(c.Color)}^FS");
}
- private static string? EmitImage(LabelDocument doc, ImageElement img, List issues)
+ private static string? EmitImage(LabelDocument doc, ImageElement img, (int X, int Y) at, List issues)
{
if (img.Source is null)
{
@@ -521,37 +747,43 @@ private static string ResolveTextWithClock(LabelDocument doc, TextElement t, IRe
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Image.Size.Range", img.Id, "9999", "32000"));
return null;
}
+ // Each axis can be within its limit while the total is not; the check comes before the scaling buffers are allocated.
+ if ((long)rw * rh > ImageProcessor.MaxPixels)
+ {
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Image.Size.Pixels", img.Id, (ImageProcessor.MaxPixels / 1_000_000).ToString(CultureInfo.InvariantCulture)));
+ return null;
+ }
var mono = ImageProcessor.ToMonochrome(img);
- CheckPosition(img, doc, mono.Width, mono.Height, issues);
+ CheckPosition(img, at, doc, mono.Width, mono.Height, issues);
int total = mono.PackedRows.Length;
// ^GF ignores ^FW and ^FO orientation, so rotation was already applied to the dots.
- return Origin(img) + Invariant($"^GFA,{total},{total},{mono.BytesPerRow},{mono.ToHex()}^FS");
+ return Origin(img, at) + Invariant($"^GFA,{total},{total},{mono.BytesPerRow},{mono.ToHex()}^FS");
}
- private static string? EmitEllipse(LabelDocument doc, EllipseElement e, List issues)
+ private static string? EmitEllipse(LabelDocument doc, EllipseElement e, (int X, int Y) at, List issues)
{
- CheckPosition(e, doc, e.WidthDots, e.HeightDots, issues);
+ CheckPosition(e, at, doc, e.WidthDots, e.HeightDots, issues);
if (e.WidthDots < 3 || e.WidthDots > 4095 || e.HeightDots < 3 || e.HeightDots > 4095 || e.ThicknessDots < 1 || e.ThicknessDots > 4095)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Ellipse.Range", e.Id, "3", "4095"));
- return Origin(e) + Invariant($"^GE{e.WidthDots},{e.HeightDots},{e.ThicknessDots},{ColorChar(e.Color)}^FS");
+ return Origin(e, at) + Invariant($"^GE{e.WidthDots},{e.HeightDots},{e.ThicknessDots},{ColorChar(e.Color)}^FS");
}
- private static string? EmitDiagonal(LabelDocument doc, DiagonalLineElement d, List issues)
+ private static string? EmitDiagonal(LabelDocument doc, DiagonalLineElement d, (int X, int Y) at, List issues)
{
- CheckPosition(d, doc, d.WidthDots, d.HeightDots, issues);
+ CheckPosition(d, at, doc, d.WidthDots, d.HeightDots, issues);
if (d.WidthDots < 3 || d.WidthDots > 32000 || d.HeightDots < 3 || d.HeightDots > 32000 || d.ThicknessDots < 1 || d.ThicknessDots > 32000)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Diagonal.Range", d.Id, "3", "32000"));
- return Origin(d) + Invariant($"^GD{d.WidthDots},{d.HeightDots},{d.ThicknessDots},{ColorChar(d.Color)},{(d.Lean == DiagonalLean.Left ? 'L' : 'R')}^FS");
+ return Origin(d, at) + Invariant($"^GD{d.WidthDots},{d.HeightDots},{d.ThicknessDots},{ColorChar(d.Color)},{(d.Lean == DiagonalLean.Left ? 'L' : 'R')}^FS");
}
- private static string? EmitSymbol(LabelDocument doc, SymbolElement s, List issues)
+ private static string? EmitSymbol(LabelDocument doc, SymbolElement s, (int X, int Y) at, List issues)
{
- CheckPosition(s, doc, s.WidthDots, s.HeightDots, issues);
+ CheckPosition(s, at, doc, s.WidthDots, s.HeightDots, issues);
if (s.HeightDots < 0 || s.HeightDots > 32000 || s.WidthDots < 0 || s.WidthDots > 32000)
issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Symbol.Range", s.Id, "0", "32000"));
char letter = (char)('A' + (int)s.Symbol);
// Height and width are always written: without them the printer would use whatever ^CF last set.
- return Origin(s) + Invariant($"^GS{RotationOrEmpty(s.Rotation)},{s.HeightDots},{s.WidthDots}^FD{letter}^FS");
+ return Origin(s, at) + Invariant($"^GS{RotationOrEmpty(s.Rotation)},{s.HeightDots},{s.WidthDots}^FD{letter}^FS");
}
private static string ColorChar(GraphicColor c) => c == GraphicColor.White ? "W" : "B";
diff --git a/src/Rotula.Data/DatabaseException.cs b/src/Rotula.Data/DatabaseException.cs
index bcfeb5e..ca5c2e9 100644
--- a/src/Rotula.Data/DatabaseException.cs
+++ b/src/Rotula.Data/DatabaseException.cs
@@ -81,6 +81,11 @@ public static class DataKeys
public const string RejectTooLong = "Data.Query.Rejected.TooLong";
/// Reason: the text could be read in more than one way (a backslash or quote the database might treat differently), so it is refused rather than guessed.
public const string RejectAmbiguous = "Data.Query.Rejected.Ambiguous";
+ ///
+ /// Reason: the text contains something different engines lex differently and that no honest query needs: a NUL or hidden/format
+ /// character outside a string, a word that starts with a digit but is not a number (1DROP), or a PostgreSQL U&"..." name.
+ ///
+ public const string RejectUnclearText = "Data.Query.Rejected.UnclearText";
/// Reason: a @parameter used by the query has no value; args[0] is the name.
public const string RejectMissingParameter = "Data.Query.Rejected.MissingParameter";
diff --git a/src/Rotula.Data/SqlDialect.cs b/src/Rotula.Data/SqlDialect.cs
index e29c44d..35b35e5 100644
--- a/src/Rotula.Data/SqlDialect.cs
+++ b/src/Rotula.Data/SqlDialect.cs
@@ -220,7 +220,9 @@ public static class SqlDialects
/// PostgreSQL (also used for Redshift, CockroachDB, YugabyteDB, Supabase, Neon, Aurora).
public static readonly SqlDialect PostgreSql = new()
{
- AmbiguityChecks = LexFeature.SingleBackslash, Id = "postgresql", DisplayName = "PostgreSQL", LexRules = new[] { SqlLexRules.PostgreSql }, RowLimit = RowLimitStyle.Limit,
+ // DoubleBackslash: PostgreSQL has no backslash escape in "..." names, but U&"..\0065.." does (guarded in the tokenizer), and a backslash
+ // inside a quoted name is how that escape is smuggled; any quoted name that contains one is refused.
+ AmbiguityChecks = LexFeature.SingleBackslash | LexFeature.DoubleBackslash, Id = "postgresql", DisplayName = "PostgreSQL", LexRules = new[] { SqlLexRules.PostgreSql }, RowLimit = RowLimitStyle.Limit,
DescribeWithKeyInfo = true,
// Functions a READ ONLY transaction does NOT block: they take session locks that outlive the rolled-back transaction, reload the
// server configuration, switch or back up the write-ahead log, reset statistics or touch replication. pg_sleep stays allowed: the
@@ -229,10 +231,13 @@ public static class SqlDialects
ForbiddenFunctions = Set("pg_reload_conf", "pg_switch_wal", "pg_rotate_logfile", "pg_create_restore_point", "pg_start_backup", "pg_stop_backup",
"pg_backup_start", "pg_backup_stop", "pg_promote", "pg_notify", "pg_logical_emit_message", "pg_import_system_collations",
"pg_wal_replay_pause", "pg_wal_replay_resume", "pg_truncate_visibility_map", "pg_replication_slot_advance", "pg_drop_replication_slot",
- "pg_file_write", "pg_file_rename", "pg_file_unlink", "pg_logdir_ls", "pg_ls_logdir", "pg_ls_waldir", "pg_ls_tmpdir", "pg_ls_archive_statusdir"),
- // pg_advisory_lock, _lock_shared, _xact_lock*, _unlock*, pg_try_advisory_*; the pg_stat_reset* family; replication origins and slots.
- ForbiddenFunctionPrefixes = Set("pg_advisory_", "pg_try_advisory_", "pg_stat_reset", "pg_replication_origin_", "pg_create_physical_replication_slot",
- "pg_create_logical_replication_slot", "pg_copy_physical_replication_slot", "pg_copy_logical_replication_slot", "pg_stat_statements_reset"),
+ "pg_logdir_ls"),
+ // Replication origins and slots, and the postgres_fdw connection functions. The advisory-lock and statistics-reset families, the
+ // query_to_xml / dblink / file-reader families and the large-object calls are in SqlGuard's shared prefix lists, so that an ODBC
+ // connection to a PostgreSQL server (no PostgreSQL dialect, the engine hint is only a claim) is guarded too.
+ ForbiddenFunctionPrefixes = Set("pg_replication_origin_", "pg_create_physical_replication_slot",
+ "pg_create_logical_replication_slot", "pg_copy_physical_replication_slot", "pg_copy_logical_replication_slot", "pg_stat_statements_reset",
+ "postgres_fdw_"),
CatalogueSql =
"SELECT table_schema, table_name, table_type FROM information_schema.tables " +
"WHERE table_type IN ('BASE TABLE','VIEW') AND table_schema NOT IN ('pg_catalog','information_schema') ORDER BY 1, 2",
@@ -312,7 +317,8 @@ public static class SqlDialects
///
public static readonly SqlDialect DuckDb = new()
{
- AmbiguityChecks = LexFeature.SingleBackslash, Id = "duckdb", DisplayName = "DuckDB",
+ // DoubleBackslash: DuckDB shares PostgreSQL's parser, including the U&"..." unicode-escaped names; see the PostgreSQL entry.
+ AmbiguityChecks = LexFeature.SingleBackslash | LexFeature.DoubleBackslash, Id = "duckdb", DisplayName = "DuckDB",
LexRules = new[] { new SqlLexRules { PostgresStrings = true, DollarParameters = true, NestedComments = true } },
ParameterStyle = ParameterStyle.DollarName, RowLimit = RowLimitStyle.Limit,
ForbiddenCallPrefixes = Set("read_", "parquet_", "iceberg_", "delta_", "json_scan", "csv_scan"),
diff --git a/src/Rotula.Data/SqlGuard.cs b/src/Rotula.Data/SqlGuard.cs
index d27b44f..c767d51 100644
--- a/src/Rotula.Data/SqlGuard.cs
+++ b/src/Rotula.Data/SqlGuard.cs
@@ -67,8 +67,29 @@ public static class SqlGuard
"PG_LS_DIR", "PG_STAT_FILE", "LO_IMPORT", "LO_EXPORT", "DBLINK", "DBLINK_EXEC", "DBLINK_CONNECT", "LOAD_EXTENSION",
"READFILE", "WRITEFILE", "PG_TERMINATE_BACKEND", "PG_CANCEL_BACKEND", "SET_CONFIG", "NEXTVAL", "SETVAL",
"SYS_EXEC", "SYS_EVAL",
+ // PostgreSQL functions that run the text they are given as SQL (ts_stat, ts_rewrite(query, 'select ...'), tablefunc's crosstab and
+ // connectby) or read server state, kept in the SHARED list on purpose: an ODBC connection to a PostgreSQL server has no PostgreSQL
+ // dialect (the engine hint is only a claim), and the inner SQL sits inside a string, so nothing else would ever see it.
+ "TS_STAT", "TS_REWRITE", "CROSSTAB", "CROSSTAB2", "CROSSTAB3", "CROSSTAB4", "CONNECTBY",
+ "PG_LOG_BACKEND_MEMORY_CONTEXTS", "TXID_CURRENT", "PG_CURRENT_XACT_ID",
};
+ // Function-name prefixes refused in every dialect, for the same reason as the PostgreSQL names above (ODBC cannot know the engine). Each
+ // family is PostgreSQL-specific and has no plausible ordinary column name: query_to_xml / cursor_to_xml / table_to_xml / schema_to_xml /
+ // database_to_xml (plus their _and_xmlschema and *_xmlschema forms) execute SQL text or dump whole tables; dblink* is the dblink extension
+ // (open, fetch, send_query, get_result, connect_u...); pg_ls_* / pg_read_* / pg_file_* read and write the server's files; pg_logical_slot_*
+ // consume replication changes; the advisory-lock and statistics-reset families outlive a rolled-back READ ONLY transaction.
+ private static readonly string[] ForbiddenFunctionPrefixes =
+ {
+ "query_to_xml", "cursor_to_xml", "table_to_xml", "schema_to_xml", "database_to_xml", "dblink", "pg_ls_", "pg_read_", "pg_file_",
+ "pg_logical_slot_", "pg_advisory_", "pg_try_advisory_", "pg_stat_reset",
+ };
+
+ // Refused only when called (followed by "("): the PostgreSQL large-object API (lo_open, lo_get, lo_creat, lo_unlink, lo_put, lo_from_bytea...;
+ // lo_import and lo_export are in the list above) and its older loread/lowrite. A column called lo_total still works.
+ private static readonly string[] ForbiddenCallPrefixes = { "lo_" };
+ private static readonly HashSet ForbiddenCalls = new(StringComparer.OrdinalIgnoreCase) { "loread", "lowrite" };
+
// Phrases that look like a plain SELECT but advance server state. NEXT and VALUE are common column names, so only the
// three-word sequence is refused (a lone NEXTVAL is already in the function list). Applies to every dialect.
private static readonly string[][] BasePhrases = { new[] { "NEXT", "VALUE", "FOR" } };
@@ -100,6 +121,9 @@ public static SqlGuardResult Check(string? sql, SqlDialect dialect)
ArgumentNullException.ThrowIfNull(dialect);
if (string.IsNullOrWhiteSpace(sql)) return Reject(DataKeys.RejectEmpty);
if (sql.Length > MaxQueryLength) return Reject(DataKeys.RejectTooLong);
+ // A NUL byte is read as "end of text" by some engines and skipped by others ("DROP"), inside a string or comment as well as
+ // outside, so there is no reading of it the guard can trust. No honest query contains one.
+ if (sql.Contains('\0')) return Reject(DataKeys.RejectUnclearText);
List? parameters = null;
List? paramTokens = null;
@@ -348,13 +372,15 @@ private static bool IsForbiddenFunction(string name, SqlDialect d)
{
if (ForbiddenFunctions.Contains(name) || d.ForbiddenFunctions.Contains(name)
|| name.StartsWith("xp_", StringComparison.OrdinalIgnoreCase) || name.StartsWith("sp_", StringComparison.OrdinalIgnoreCase)) return true;
+ if (ForbiddenFunctionPrefixes.Any(p => name.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return true;
if (d.AllowedFunctions.Contains(name)) return false;
return d.ForbiddenFunctionPrefixes.Any(p => name.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
private static bool IsForbiddenCall(string name, SqlDialect d)
{
- if (d.ForbiddenCalls.Contains(name)) return true;
+ if (ForbiddenCalls.Contains(name) || d.ForbiddenCalls.Contains(name)) return true;
+ if (ForbiddenCallPrefixes.Any(p => name.StartsWith(p, StringComparison.OrdinalIgnoreCase))) return true;
if (d.AllowedFunctions.Contains(name)) return false;
return d.ForbiddenCallPrefixes.Any(p => name.StartsWith(p, StringComparison.OrdinalIgnoreCase));
}
diff --git a/src/Rotula.Data/SqlTokenizer.cs b/src/Rotula.Data/SqlTokenizer.cs
index bab5ab0..024b073 100644
--- a/src/Rotula.Data/SqlTokenizer.cs
+++ b/src/Rotula.Data/SqlTokenizer.cs
@@ -36,11 +36,12 @@ public static SqlTokenizeResult Tokenize(string sql, SqlLexRules rules)
while (i < n)
{
char c = sql[i];
- if (char.IsWhiteSpace(c) || c == '\0') { i++; continue; }
+ if (char.IsWhiteSpace(c)) { i++; continue; }
- // Line comments.
+ // Line comments. MySQL wants real (ASCII) whitespace or a control character after "--": it does NOT treat a no-break space or
+ // another Unicode space as one, so "--; DROP ..." is live SQL there. char.IsWhiteSpace would call it a comment and hide it.
if (c == '-' && i + 1 < n && sql[i + 1] == '-' &&
- (!rules.DashNeedsSpace || i + 2 >= n || char.IsWhiteSpace(sql[i + 2]) || char.IsControl(sql[i + 2])))
+ (!rules.DashNeedsSpace || i + 2 >= n || IsAsciiSpaceOrControl(sql[i + 2])))
{
i = SkipLine(sql, i);
continue;
@@ -146,6 +147,13 @@ public static SqlTokenizeResult Tokenize(string sql, SqlLexRules rules)
}
}
+ // PostgreSQL (and DuckDB) unicode escapes: U&"pg_r\0065ad_file" is the name pg_read_file, U&'...' a string with escapes. The guard
+ // would see a quoted name that still contains the raw backslash escape and never match it against the forbidden functions.
+ // Nothing a label needs uses them, and in other engines the text is a bitwise AND on a column called u, so refuse everywhere.
+ if ((c == 'U' || c == 'u') && i + 2 < n && sql[i + 1] == '&' && (sql[i + 2] == '"' || sql[i + 2] == '\'')
+ && !(i > 0 && IsWordChar(sql[i - 1], rules)))
+ return new(tokens, DataKeys.RejectUnclearText);
+
if (IsWordStart(c, rules))
{
int e = i + 1;
@@ -168,17 +176,114 @@ public static SqlTokenizeResult Tokenize(string sql, SqlLexRules rules)
i = end;
continue;
}
- tokens.Add(new(SqlTokenKind.Word, i, e - i, sql[i..e]));
+ string word = sql[i..e];
+ // A word that starts with a digit is a number, or the lexers disagree. "1DROP" is ONE identifier in MySQL and PostgreSQL but
+ // "1" followed by DROP in SQL Server ("SELECT 1AS x" works there), so as a single word it would skip the keyword check.
+ // Only plain numeric literals are accepted; anything else must be written with a space or in quotes.
+ // One exception: where '#' is a word character (Jet/ACE, SQL Server) the closing '#' of a date literal such as #1/31/2020# is glued to its last number.
+ string number = !rules.HashComment && word.EndsWith('#') ? word[..^1] : word;
+ if (char.IsDigit(c) && !IsPlainNumber(number)) return new(tokens, DataKeys.RejectUnclearText);
+ if (!IsAscii(word))
+ {
+ if (!AddNonAsciiWord(tokens, i, word)) return new(tokens, DataKeys.RejectUnclearText);
+ }
+ else tokens.Add(new(SqlTokenKind.Word, i, e - i, word));
i = e;
continue;
}
+ // Whatever is left is punctuation or an operator. Hidden characters are not: a zero-width or control character inside a keyword
+ // ("DROP") is invisible to the reader and lexed differently by each engine, and no honest query contains one outside a
+ // string, a quoted name or a comment. Refuse instead of guessing.
+ if (IsHiddenCharacter(c)) return new(tokens, DataKeys.RejectUnclearText);
+
tokens.Add(new(SqlTokenKind.Symbol, i, 1, c.ToString()));
i++;
}
return new(tokens, null);
}
+ /// True for ASCII white space and control characters (what MySQL accepts after --); NOT for Unicode spaces such as U+00A0.
+ private static bool IsAsciiSpaceOrControl(char c) => c <= ' ' || c == '\u007F';
+
+ private static bool IsAscii(string s)
+ {
+ foreach (char ch in s) if (ch > '\u007F') return false;
+ return true;
+ }
+
+ ///
+ /// Format (zero-width, soft hyphen, bidi marks, BOM), control characters that are not white space, surrogates, private-use and
+ /// unassigned code points. Visible symbols and punctuation (including curly quotes) are not hidden and stay ordinary symbols.
+ ///
+ private static bool IsHiddenCharacter(char c) => char.GetUnicodeCategory(c) is
+ System.Globalization.UnicodeCategory.Format or System.Globalization.UnicodeCategory.Control
+ or System.Globalization.UnicodeCategory.Surrogate or System.Globalization.UnicodeCategory.PrivateUse
+ or System.Globalization.UnicodeCategory.OtherNotAssigned;
+
+ ///
+ /// A digit-led word that is a number in every engine: digits with optional _ separators, an exponent (1e10; 1e before
+ /// a sign is a word of its own), 0x/0b/0o literals, and the Oracle binary float suffixes f/d. ASCII only:
+ /// a Unicode digit is not a number to every engine.
+ ///
+ private static bool IsPlainNumber(string w)
+ {
+ int k = 0;
+ while (k < w.Length && w[k] is >= '0' and <= '9') k++;
+ if (k == 0) return false;
+ if (k == w.Length) return true;
+ char next = w[k];
+ // 1_000 (PostgreSQL 16, DuckDB, ClickHouse digit separators): only digits and underscores.
+ if (next == '_') return w.AsSpan(k).IndexOfAnyExcept("_0123456789") < 0;
+ // 1e, 1e10, 1E5
+ if (next is 'e' or 'E') return AllIn(w, k + 1, "0123456789");
+ // 1f, 1d (Oracle BINARY_FLOAT / BINARY_DOUBLE)
+ if (next is 'f' or 'F' or 'd' or 'D') return k + 1 == w.Length;
+ // 0x1F, 0b101, 0o17: the prefix must be a lone zero.
+ if (k == 1 && w[0] == '0' && next is 'x' or 'X' or 'b' or 'B' or 'o' or 'O') return w.Length > 2 && AllIn(w, 2, "0123456789abcdefABCDEF_");
+ return false;
+ }
+
+ private static bool AllIn(string s, int from, string allowed)
+ {
+ for (int k = from; k < s.Length; k++) if (!allowed.Contains(s[k])) return false;
+ return true;
+ }
+
+ ///
+ /// Adds a word that contains non-ASCII letters. Engines disagree on those: some fold full-width forms, some treat letters newer than their
+ /// Unicode tables as separators, so a keyword glued to one ("\u00F1DROP") might be a keyword to the engine. The word is therefore added
+ /// as its compatibility-normalized form when that is plain ASCII (full-width "DROP" becomes DROP), and otherwise split into its
+ /// ASCII and non-ASCII runs so each ASCII run is judged on its own. Returns false if the text cannot be normalized.
+ ///
+ private static bool AddNonAsciiWord(List tokens, int start, string word)
+ {
+ try
+ {
+ string folded = word.Normalize(NormalizationForm.FormKC);
+ if (IsAscii(folded))
+ {
+ tokens.Add(new(SqlTokenKind.Word, start, word.Length, folded));
+ return true;
+ }
+ int k = 0;
+ while (k < word.Length)
+ {
+ bool ascii = word[k] <= '\u007F';
+ int e = k + 1;
+ while (e < word.Length && (word[e] <= '\u007F') == ascii) e++;
+ string run = word[k..e];
+ tokens.Add(new(SqlTokenKind.Word, start + k, e - k, ascii ? run : run.Normalize(NormalizationForm.FormKC)));
+ k = e;
+ }
+ return true;
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+
private static bool BackslashFor(SqlLexRules r, char quote)
=> quote == '\'' ? r.SingleQuoteBackslash : quote == '"' ? r.DoubleQuoteBackslash : r.BacktickBackslash;
@@ -210,16 +315,29 @@ private static int SkipQQuoted(string s, int open)
private static string Unquote(string s, int start, int end, char closer)
{
string inner = s[(start + 1)..(end - 1)];
- return inner.Replace(new string(closer, 2), closer.ToString(), StringComparison.Ordinal);
+ string name = inner.Replace(new string(closer, 2), closer.ToString(), StringComparison.Ordinal);
+ // A full-width spelling of pg_read_file must not slip past the function lists, so non-ASCII names are compared in their compatibility form.
+ if (!IsAscii(name))
+ {
+ try { name = name.Normalize(NormalizationForm.FormKC); }
+ catch (ArgumentException) { /* ill-formed text (lone surrogate): keep it as is, it cannot equal a function name */ }
+ }
+ return name;
}
private static bool IsWordStart(char c, SqlLexRules r) => char.IsLetterOrDigit(c) || c == '_' || (c == '#' && !r.HashComment);
private static bool IsWordChar(char c, SqlLexRules r) => char.IsLetterOrDigit(c) || c == '_' || c == '$' || (c == '#' && !r.HashComment);
+ ///
+ /// Skips to the end of a line comment. Engines disagree on what ends one: LF and CR everywhere, but some also honour vertical tab, form
+ /// feed, NEL or the Unicode line/paragraph separators. Ending the comment at ANY of them is the safe side: if the engine keeps
+ /// going the guard has only looked at extra text (worst case a harmless query is refused), but a comment the guard keeps open
+ /// while the engine has closed it would hide live SQL.
+ ///
private static int SkipLine(string s, int i)
{
- while (i < s.Length && s[i] != '\n' && s[i] != '\r') i++;
+ while (i < s.Length && s[i] is not ('\n' or '\r' or '\u000B' or '\u000C' or '\u0085' or '\u2028' or '\u2029')) i++;
return i;
}
diff --git a/src/Rotula.Files/FileCounterStore.cs b/src/Rotula.Files/FileCounterStore.cs
index 5e56630..2dff53f 100644
--- a/src/Rotula.Files/FileCounterStore.cs
+++ b/src/Rotula.Files/FileCounterStore.cs
@@ -18,7 +18,8 @@ namespace Rotula.Files;
///
internal sealed class LockedTextFile
{
- private const int MaxBytes = 4 * 1024 * 1024;
+ // Room for the full counters plus the compacted ones (see CounterBook.MaxRecords and MaxTotalRecords).
+ private const int MaxBytes = 8 * 1024 * 1024;
private readonly string _path;
private readonly TimeSpan _timeout;
@@ -92,9 +93,12 @@ private void Write(string text)
string temp = _path + ".tmp";
try
{
+ var bytes = new UTF8Encoding(false).GetBytes(text);
+ // A file the reader would refuse as too large must never be written: it would lock every counter out on the next read.
+ if (bytes.Length > MaxBytes)
+ throw new CounterStoreException(VariableMessageKeys.CounterStoreFull, null, CounterBook.MaxTotalRecords.ToString(System.Globalization.CultureInfo.InvariantCulture));
using (var fs = new FileStream(temp, FileMode.Create, FileAccess.Write, FileShare.None))
{
- var bytes = new UTF8Encoding(false).GetBytes(text);
fs.Write(bytes, 0, bytes.Length);
// Flush(true) pushes the data to the disk before the swap; otherwise a power cut could leave a renamed but empty file.
fs.Flush(true);
diff --git a/src/Rotula.Files/RotulaFileDtos.cs b/src/Rotula.Files/RotulaFileDtos.cs
index 8ad9d6f..a6d7ba4 100644
--- a/src/Rotula.Files/RotulaFileDtos.cs
+++ b/src/Rotula.Files/RotulaFileDtos.cs
@@ -359,6 +359,18 @@ public abstract class ElementDto
public bool? NotPrintable { get; set; }
/// True when the object is locked against moving, resizing and deleting in the designer.
public bool? Locked { get; set; }
+
+ // ---- format version 11: omitted from the file while the element has no anchor.
+
+ /// Anchor point name (TopLeft ... BottomRight), or null for an absolute position. Format version 11.
+ public string? Anchor { get; set; }
+ ///
+ /// What the anchor is measured from: Element, or null/Label for the label. Format version 11. (Named "relativeTo" rather than
+ /// "reference" because a recall element already writes its stored-object name as "reference".)
+ ///
+ public string? RelativeTo { get; set; }
+ /// Id of the element the anchor is measured from when is Element. Format version 11.
+ public string? ReferenceElementId { get; set; }
}
/// A per-record printing rule of an object. Format version 9.
diff --git a/src/Rotula.Files/RotulaFileSerializer.cs b/src/Rotula.Files/RotulaFileSerializer.cs
index 65f3efd..f57344f 100644
--- a/src/Rotula.Files/RotulaFileSerializer.cs
+++ b/src/Rotula.Files/RotulaFileSerializer.cs
@@ -86,8 +86,18 @@ public static class RotulaFileSerializer
///
public const int ObjectConditionsFormatVersion = 10;
+ ///
+ /// Version 11: adds relative positioning: "anchor" (one of nine points), "relativeTo" (label or element) and "referenceElementId" on an element,
+ /// whose X/Y are then an offset from the reference's anchor point. An older build would read the offset as an absolute position and print the
+ /// object in the wrong place, so it must refuse the file. Labels without anchors stay at the lower versions.
+ ///
+ public const int RelativePositionFormatVersion = 11;
+
/// The newest format version this build can read. Files are written with the lowest version that can hold their content.
- public const int CurrentFormatVersion = ObjectConditionsFormatVersion;
+ public const int CurrentFormatVersion = RelativePositionFormatVersion;
+
+ /// Longest element id accepted as an anchor reference (ids are 32-character GUIDs; hand-written ones are short words).
+ private const int MaxReferenceIdLength = 64;
// Limits for untrusted values of the version 6 fields.
private const int MaxStoredGraphics = 256;
@@ -161,6 +171,9 @@ public static string Serialize(LabelDocument document, DataMergeSettings? merge
// An older build would print an object that is marked "do not print", ignore its condition and forget its lock, so it must refuse the file instead.
if (dto.Elements.Any(e => e.PrintCondition is not null || e.NotPrintable is not null || e.Locked is not null))
dto.FormatVersion = Math.Max(dto.FormatVersion, ObjectConditionsFormatVersion);
+ // An older build would take the offset of an anchored object for an absolute position and print it elsewhere, so it must refuse the file.
+ if (dto.Elements.Any(e => e.Anchor is not null))
+ dto.FormatVersion = Math.Max(dto.FormatVersion, RelativePositionFormatVersion);
return JsonSerializer.Serialize(dto, Options);
}
@@ -228,8 +241,17 @@ public static void Save(LabelDocument document, string path, DataMergeSettings?
string? folder = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
string temp = path + ".tmp";
- File.WriteAllText(temp, json, new System.Text.UTF8Encoding(false));
- File.Move(temp, path, overwrite: true);
+ try
+ {
+ File.WriteAllText(temp, json, new System.Text.UTF8Encoding(false));
+ File.Move(temp, path, overwrite: true);
+ }
+ catch
+ {
+ // A failed save must not leave a half-written copy next to the label (the name is fixed, so it would also be reused and mislead the user).
+ try { File.Delete(temp); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { /* nothing more can be done */ }
+ throw;
+ }
}
/// Reads a document from a file.
@@ -254,6 +276,10 @@ public static string Fingerprint(LabelElement e)
dto.Id = string.Empty;
dto.X = 0;
dto.Y = 0;
+ // An anchor only moves the element; it never changes its size or look.
+ dto.Anchor = null;
+ dto.RelativeTo = null;
+ dto.ReferenceElementId = null;
return JsonSerializer.Serialize(dto, FingerprintOptions);
}
@@ -544,6 +570,11 @@ public static ElementDto ToDto(LabelElement e)
: new PrintConditionDto { Field = e.PrintCondition.Field, Operator = e.PrintCondition.Operator.ToString(), Value = e.PrintCondition.Value };
dto.NotPrintable = e.NotPrintable ? true : null;
dto.Locked = e.Locked ? true : null;
+ // The reference only means something with an anchor; "Label" is the default and is left out so the file stays short.
+ dto.Anchor = e.Anchor?.ToString();
+ bool toElement = e.Anchor is not null && e.RelativeTo == AnchorReference.Element && e.ReferenceElementId is not null;
+ dto.RelativeTo = toElement ? AnchorReference.Element.ToString() : null;
+ dto.ReferenceElementId = toElement ? e.ReferenceElementId : null;
return dto;
}
@@ -622,8 +653,9 @@ public static LabelDocument FromDto(RotulaFileDto dto)
MediaTracking = Parse(l.MediaTracking),
DefaultRotation = Parse(l.DefaultRotation),
DefaultFont = l.DefaultFont is null ? null : new FontSpec(ParseChar(l.DefaultFont.FontId), l.DefaultFont.Height, l.DefaultFont.Width),
- Encoding = l.Encoding.CodePage is { } cp ? ZplEncoding.Legacy(l.Encoding.CiCode, cp)
- : l.Encoding.CiCode == 28 ? ZplEncoding.Utf8
+ // Only the pairs Rotula writes itself are accepted: a code page that does not exist on this machine would make every later ZPL
+ // generation throw, and one that does not belong to the ^CI number would print other characters than the label shows.
+ Encoding = ZplEncoding.TryFromStored(l.Encoding.CiCode, l.Encoding.CodePage, out var storedEncoding) ? storedEncoding
: throw new ArgumentException("Unsupported encoding."),
Print = new PrintOptions { Quantity = l.Print.Quantity, PauseEvery = l.Print.PauseEvery, Replicates = l.Print.Replicates, OverridePause = l.Print.OverridePause },
};
@@ -650,8 +682,11 @@ private static void ReadCatalogSettings(LabelDto l, LabelDocument doc)
doc.MediaDarkness = l.MediaDarkness;
if (l.FontAliases is not null)
foreach (var a in l.FontAliases.Take(MaxFontAliases))
+ // ^ ~ and the comma are refused because the path is written into a ^CW command unescaped and would end it. A name that merely
+ // does not look like a printer font file is kept: the generator reports it (Font.Alias.Invalid) so the user sees and fixes it,
+ // instead of the alias silently disappearing from the file.
if (a?.Letter is { Length: 1 } letter && char.IsAsciiLetterOrDigit(letter[0]) && a.Path is { Length: > 0 and <= MaxPathLength } path
- && path.All(c => c >= ' ' && c <= '~'))
+ && path.All(c => c >= ' ' && c <= '~' && c is not ('^' or '~' or ',')))
doc.FontAliases.Add(new FontAlias(letter[0], path));
if (l.StoredGraphics is not null)
{
@@ -751,6 +786,14 @@ public static LabelElement FromDto(ElementDto dto)
e.PrintCondition = dto.PrintCondition is { } pc && TryParse(pc.Operator, out var op)
? new PrintCondition { Field = Clip(pc.Field ?? string.Empty, PrintCondition.MaxTextLength), Operator = op, Value = Clip(pc.Value ?? string.Empty, PrintCondition.MaxTextLength) }
: null;
+ // An anchor name that is not known (a newer build, or a hand edit) leaves the element absolute rather than failing the file; a reference
+ // to an element needs a usable id, otherwise the label stands in.
+ e.Anchor = TryParse(dto.Anchor, out var anchor) ? anchor : null;
+ e.ReferenceElementId = dto.ReferenceElementId is { Length: > 0 and <= MaxReferenceIdLength } refId && refId.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_')
+ ? refId : null;
+ e.RelativeTo = e.Anchor is not null && TryParse(dto.RelativeTo, out var reference) && reference == AnchorReference.Element && e.ReferenceElementId is not null
+ ? AnchorReference.Element : AnchorReference.Label;
+ if (e.RelativeTo == AnchorReference.Label) e.ReferenceElementId = null;
return e;
}
diff --git a/src/Rotula.Files/Station/StationAcknowledgements.cs b/src/Rotula.Files/Station/StationAcknowledgements.cs
new file mode 100644
index 0000000..aee6bd3
--- /dev/null
+++ b/src/Rotula.Files/Station/StationAcknowledgements.cs
@@ -0,0 +1,147 @@
+using System.Text;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+
+namespace Rotula.Files.Station;
+
+///
+/// The file station.acknowledged.json, kept next to station.json by the "Set up print station" tool: per label, the fingerprint of the
+/// Windows-driver (picture mode) findings the person who set up the station accepted (see ).
+/// { "version": 1, "labels": { "pallet.rotula": "3f2a..." } }
+/// It is a file of its own, not a setting in station.json, for two reasons: station.json refuses settings it does not know, so an older Rotula
+/// would refuse a station.json that held it; and station.json is written by hand, so Rotula never has to rewrite (and risk) the administrator's
+/// file and comments. Reading never fails: a file that is missing, unreadable, damaged or of a newer version counts as "nothing accepted",
+/// which makes the station refuse those labels with a plain message, never print them unchecked.
+///
+public static class StationAcknowledgements
+{
+ /// The file name, in the folder of the station configuration.
+ public const string FileName = "station.acknowledged.json";
+
+ /// The format this version reads and writes.
+ public const int CurrentVersion = 1;
+
+ /// Hand edits (comments, a trailing comma) are tolerated, as in station.json; the file is written by Rotula, though.
+ private static readonly JsonDocumentOptions ReadOptions = new() { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true };
+
+ ///
+ /// Paths and hexadecimal text, no HTML context: there is no reason to escape letters such as "é" in a folder name, which would only make the
+ /// file harder to read.
+ ///
+ private static readonly JsonWriterOptions WriteOptions = new() { Indented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping };
+
+ /// The acknowledgements file that belongs to a station configuration file: the same folder.
+ /// The station configuration (station.json, or any configuration file name).
+ public static string PathFor(string configPath) =>
+ Path.Combine(Path.GetDirectoryName(Path.GetFullPath(configPath)) ?? string.Empty, FileName);
+
+ ///
+ /// Reads the file. The keys are resolved against exactly like a label's file in station.json, so a key
+ /// finds its label by full path (without regard to letter case). Never throws.
+ ///
+ /// The acknowledgements file.
+ /// The station's label folder.
+ /// Full label path to fingerprint; empty when the file is missing, cannot be read, is damaged, or is of a version this Rotula does
+ /// not know. An entry whose fingerprint is not a non-empty string is left out.
+ public static Dictionary Read(string path, string labelFolder)
+ {
+ var result = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ string text;
+ try
+ {
+ if (!File.Exists(path)) return result;
+ text = File.ReadAllText(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ return result;
+ }
+ try
+ {
+ using var doc = JsonDocument.Parse(text, ReadOptions);
+ var root = doc.RootElement;
+ if (root.ValueKind != JsonValueKind.Object) return result;
+ // A newer format may mean something else by the same names: accepting it could let a label through that was never accepted.
+ if (root.TryGetProperty("version", out var version) && (version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out int v) || v != CurrentVersion))
+ return result;
+ if (!root.TryGetProperty("labels", out var labels) || labels.ValueKind != JsonValueKind.Object) return result;
+ foreach (var entry in labels.EnumerateObject())
+ {
+ if (entry.Value.ValueKind != JsonValueKind.String) continue;
+ string? fingerprint = entry.Value.GetString();
+ if (string.IsNullOrWhiteSpace(entry.Name) || string.IsNullOrWhiteSpace(fingerprint)) continue;
+ result[StationConfigLoader.Resolve(labelFolder, entry.Name)] = fingerprint.Trim();
+ }
+ return result;
+ }
+ catch (JsonException)
+ {
+ // Damaged: nothing counts as accepted. The set-up tool writes a whole new file the next time it saves.
+ return new Dictionary(StringComparer.OrdinalIgnoreCase);
+ }
+ }
+
+ ///
+ /// Writes the file, or deletes it when is empty (nothing accepted means no file, so a station that prints in
+ /// printer language never gets one). The new content goes to a temporary file in the same folder first, which then replaces the old file in
+ /// one step, so a station starting at that moment reads either the old file or the new one, never half of it.
+ ///
+ /// The acknowledgements file.
+ /// The station's label folder: keys are written relative to it, so the acknowledgements follow the folder when it is
+ /// moved. A label outside it keeps its full path.
+ /// Full label path to fingerprint.
+ /// The file could not be written or deleted (the caller says so in plain words).
+ /// The folder may not be changed by this account.
+ public static void Write(string path, string labelFolder, IReadOnlyDictionary acknowledged)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+ ArgumentNullException.ThrowIfNull(labelFolder);
+ ArgumentNullException.ThrowIfNull(acknowledged);
+ if (acknowledged.Count == 0)
+ {
+ if (File.Exists(path)) File.Delete(path);
+ return;
+ }
+ string temporary = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
+ try
+ {
+ File.WriteAllBytes(temporary, Serialize(labelFolder, acknowledged));
+ File.Move(temporary, path, overwrite: true);
+ }
+ finally
+ {
+ // Only still there when the replace failed: never leave a stray file next to station.json.
+ try { if (File.Exists(temporary)) File.Delete(temporary); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { }
+ }
+ }
+
+ /// The key written for a label: its path relative to the label folder, or its full path when it lies outside that folder.
+ /// The station's label folder.
+ /// The label's full path.
+ public static string KeyFor(string labelFolder, string labelPath)
+ {
+ string relative = Path.GetRelativePath(labelFolder, labelPath);
+ bool outside = Path.IsPathRooted(relative) || relative == ".."
+ || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || relative.StartsWith("../", StringComparison.Ordinal);
+ return outside ? labelPath : relative;
+ }
+
+ /// The file content: the version, then one label per line in name order, so the file reads (and compares) the same every time.
+ private static byte[] Serialize(string labelFolder, IReadOnlyDictionary acknowledged)
+ {
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream, WriteOptions))
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("version", CurrentVersion);
+ writer.WriteStartObject("labels");
+ foreach (var (key, fingerprint) in acknowledged.Select(kv => (KeyFor(labelFolder, kv.Key), kv.Value)).OrderBy(e => e.Item1, StringComparer.OrdinalIgnoreCase))
+ writer.WriteString(key, fingerprint);
+ writer.WriteEndObject();
+ writer.WriteEndObject();
+ }
+ stream.Write(Encoding.UTF8.GetBytes(Environment.NewLine));
+ return stream.ToArray();
+ }
+}
diff --git a/src/Rotula.Files/Station/StationConfig.cs b/src/Rotula.Files/Station/StationConfig.cs
index 2352e79..dfabbb0 100644
--- a/src/Rotula.Files/Station/StationConfig.cs
+++ b/src/Rotula.Files/Station/StationConfig.cs
@@ -122,6 +122,17 @@ public sealed class StationConfig
/// The station fills the whole screen without a title bar.
public bool FullScreen { get; init; } = true;
+
+ ///
+ /// The Windows-driver (picture mode) findings that the person who set up the station accepted, per label: the label's full path (as in
+ /// , matched without regard to letter case) to the fingerprint of the findings they saw. The station prints a
+ /// label whose pre-flight has warnings only when the fingerprint worked out at print time equals the one stored here, so an operator is
+ /// never asked to accept findings, and a label edited since the set-up (other findings, another fingerprint) is refused until the station is
+ /// set up again. Read when the configuration is loaded, from station.acknowledged.json next to it (,
+ /// written by the "Set up print station" tool; its keys are relative to ). Never null: no file, an unreadable or
+ /// damaged file, or a station without a configuration file is an empty map.
+ ///
+ public Dictionary AcknowledgedFindings { get; init; } = new(StringComparer.OrdinalIgnoreCase);
}
/// Outcome of reading a station configuration: the configuration (null when an error stops it) and every problem found.
diff --git a/src/Rotula.Files/Station/StationConfigLoader.cs b/src/Rotula.Files/Station/StationConfigLoader.cs
index 2ec1026..ac26488 100644
--- a/src/Rotula.Files/Station/StationConfigLoader.cs
+++ b/src/Rotula.Files/Station/StationConfigLoader.cs
@@ -172,6 +172,12 @@ private static StationLoadResult Build(StationFileDto dto, string configPath, st
if (labels.Count == 0 && !issues.Any(x => x.Severity == ValidationSeverity.Error)) Error(StationKeys.NoLabels);
if (issues.Any(x => x.Severity == ValidationSeverity.Error)) return new StationLoadResult(null, issues);
+ // ---- picture-mode acknowledgements: their own file next to the configuration, written by the set-up tool. A station without a
+ // configuration file has none (the set-up tool never creates station.json). Missing or damaged reads as nothing accepted: the station
+ // then refuses those labels with a plain message, which is the safe side.
+ var acknowledged = fileExists
+ ? StationAcknowledgements.Read(StationAcknowledgements.PathFor(configPath), folder)
+ : new Dictionary(StringComparer.OrdinalIgnoreCase);
var config = new StationConfig
{
ConfigPath = fileExists ? Path.GetFullPath(configPath) : null,
@@ -182,6 +188,7 @@ private static StationLoadResult Build(StationFileDto dto, string configPath, st
PrintOnLastScan = dto.PrintOnLastScan ?? true,
ScanToSelect = dto.ScanToSelect ?? true,
FullScreen = dto.FullScreen ?? true,
+ AcknowledgedFindings = acknowledged,
};
return new StationLoadResult(config, issues);
}
@@ -191,7 +198,9 @@ private static bool CopiesOk(int? defaultCopies, int max) =>
private static bool IsLabelFile(string path) => path.EndsWith(".rotula", StringComparison.OrdinalIgnoreCase);
- private static string Resolve(string baseDir, string path)
+ /// A path from the configuration as a full path: relative ones start at . Also used for the keys of
+ /// , so a key finds its label exactly as the label's file does.
+ internal static string Resolve(string baseDir, string path)
{
string p = path.Trim();
try { return Path.GetFullPath(Path.IsPathRooted(p) ? p : Path.Combine(baseDir, p)); }
diff --git a/src/Rotula.Mcp/Security/PrintGate.cs b/src/Rotula.Mcp/Security/PrintGate.cs
index ae561d0..d186900 100644
--- a/src/Rotula.Mcp/Security/PrintGate.cs
+++ b/src/Rotula.Mcp/Security/PrintGate.cs
@@ -75,7 +75,13 @@ public PrintGate(McpSettings settings, Func? clock = null)
/// Number of labels.
/// A hash of everything the job consists of; see .
/// The token from an earlier dry run, or null.
- public GateResult Evaluate(string? printer, int copies, string fingerprint, string? token)
+ ///
+ /// Names of printer-changing commands the job contains (see ). When there are any, a confirmation is
+ /// only accepted if names every one of them. Null or empty means the job has none.
+ ///
+ /// The command names the caller says the user agreed to, or null.
+ public GateResult Evaluate(string? printer, int copies, string fingerprint, string? token,
+ IReadOnlyCollection? requiredAcknowledgements = null, IReadOnlyCollection? acknowledged = null)
{
var policy = CheckPolicy(printer, copies);
if (policy is not null)
@@ -109,10 +115,25 @@ public GateResult Evaluate(string? printer, int copies, string fingerprint, stri
if (!string.Equals(expectedFingerprint, fingerprint, StringComparison.Ordinal))
return Refuse("The label, printer or copies changed since the preview, so the confirmation code no longer applies. Nothing was sent. " +
"Call print_label again without a code to get a new preview.");
+ // Checked last, after the token is already spent: an assistant that forgot the acknowledgement must start over with a new
+ // preview, which is the moment the list of commands is put in front of the user again.
+ var missing = MissingAcknowledgements(requiredAcknowledgements, acknowledged);
+ if (missing.Count > 0)
+ return Refuse($"This job contains commands that change or erase things in the printer ({string.Join(", ", missing)}) and they were not acknowledged. " +
+ "Nothing was sent. Call print_label again without a code to get a new preview, tell the user exactly which commands it lists, " +
+ "and only if they agree repeat the call with the code and acknowledgedCommands naming each of them.");
return new GateResult(GateDecision.Confirmed, null);
}
}
+ /// The required command names that does not contain (names compare without regard to case).
+ private static List MissingAcknowledgements(IReadOnlyCollection? required, IReadOnlyCollection? acknowledged)
+ {
+ if (required is null || required.Count == 0) return [];
+ var given = new HashSet((acknowledged ?? []).Where(a => a is not null).Select(a => a.Trim()), StringComparer.OrdinalIgnoreCase);
+ return required.Where(r => !given.Contains(r)).ToList();
+ }
+
/// Drops any outstanding token (called when a request is refused or the label changed).
public void Forget()
{
diff --git a/src/Rotula.Mcp/Tools/PrinterTools.cs b/src/Rotula.Mcp/Tools/PrinterTools.cs
index a3480c2..fbc5147 100644
--- a/src/Rotula.Mcp/Tools/PrinterTools.cs
+++ b/src/Rotula.Mcp/Tools/PrinterTools.cs
@@ -93,12 +93,13 @@ internal static PrinterStatusResult Describe(string name, PrinterStatus status)
/// Prints the label after a dry run and a confirmation.
[McpServerTool(Name = "print_label", Destructive = true, OpenWorld = true, UseStructuredContent = true)]
- [Description("Prints the current label on a Windows printer, in two steps. Printing is switched off unless the person who installed the server allows it and lists the printer. The FIRST call never prints: it returns exactly what would be sent (printer, copies, label size, any resolution conversion, the start of the ZPL) and a one-time confirmationToken. Show that to the user and, only if they agree, call print_label again with the same printer and copies and that token to send exactly one job. The token expires quickly and stops working if the label, printer or copies change. Variables the label works out itself (dates, computed values, counters that do not remember their value) are worked out when the job is sent; a date from the printer's own clock is sent as the computer's date here. A label with a counter that remembers its last value between sessions is refused (print it from the Rotula app, which records the numbers used). A label that asks questions at print time needs answers: the refusal lists the questions; ask the user, then call again with answers.")]
+ [Description("Prints the current label on a Windows printer, in two steps. Printing is switched off unless the person who installed the server allows it and lists the printer. The FIRST call never prints: it returns exactly what would be sent (printer, copies, label size, any resolution conversion, the start of the ZPL) and a one-time confirmationToken. Show that to the user and, only if they agree, call print_label again with the same printer and copies and that token to send exactly one job. The token expires quickly and stops working if the label, printer or copies change. Variables the label works out itself (dates, computed values, counters that do not remember their value) are worked out when the job is sent; a date from the printer's own clock is sent as the computer's date here. A label with a counter that remembers its last value between sessions is refused (print it from the Rotula app, which records the numbers used). A label that asks questions at print time needs answers: the refusal lists the questions; ask the user, then call again with answers. Imported or loaded labels can carry raw printer commands (cancel all jobs, delete stored objects, factory reset, network changes...): the first call scans ALL of the text that would be sent and lists every such command by name in riskyCommands with requiresAcknowledgement set. Tell the user exactly which commands are listed. Only if they agree, repeat the call with the token AND acknowledgedCommands naming each listed command; without it the job is refused and the token is used up.")]
public async Task PrintLabel(
[Description("Exact Windows printer name from list_printers; it must be on the allowed list.")] string printer,
[Description("Number of labels. Capped by maxCopies in the settings (default 10).")] int copies = 1,
[Description("The confirmationToken from the dry run. Leave empty for the first call.")] string? confirmationToken = null,
[Description("Answers to the label's print-time questions as an object of variable name to answer. Only needed when the label has prompt variables.")] Dictionary? answers = null,
+ [Description("Second call only, and only when the dry run listed riskyCommands: the names of those commands (for example [\"~JA\", \"^ID\"]), which the user has agreed to. Never set it without showing the user the list.")] List? acknowledgedCommands = null,
CancellationToken cancellationToken = default)
{
// Rules that need no label come first so a disabled server answers instantly and never touches a printer.
@@ -177,13 +178,17 @@ public async Task PrintLabel(
}
string fingerprint = PrintGate.Fingerprint(name, copies, fingerprintData is null ? generated.Zpl : ZplGenerator.Generate(doc, fingerprintData).Zpl);
- var decision = _ctx.Gate.Evaluate(name, copies, fingerprint, confirmationToken);
+ // The scan covers every character that will be sent, so padding in front of a command or a long list of raw blocks hides nothing.
+ // It runs on the ZPL of this call; the fingerprint ties the confirmation to the same text, so what was listed is what is sent.
+ var risky = PrinterCommandScanner.Scan(generated.Zpl);
+ var decision = _ctx.Gate.Evaluate(name, copies, fingerprint, confirmationToken, risky.Select(r => r.Name).ToList(), acknowledgedCommands);
string size = $"{Units.Express(doc.Width, "mm", doc.Dpi):0.##} x {Units.Express(doc.Height, "mm", doc.Dpi):0.##} mm ({doc.WidthDots} x {doc.HeightDots} dots at {(int)doc.Dpi} DPI)";
var preview = generated.Zpl.Split('\n').Take(PreviewLines).ToList();
// Imported ZPL that Rotula does not understand is sent to the printer exactly as it was written. The dry run lists every such
- // block, because the ZPL preview above shows only the first lines and the person confirming should see them all.
- var rawBlocks = doc.Elements
+ // block in full (up to a large limit), because the ZPL preview above shows only the first lines and the person confirming
+ // should see them all. The scan for dangerous commands does not depend on what is listed here.
+ var rawTexts = doc.Elements
.Select(e => e switch
{
RawZplElement r => r.Zpl,
@@ -191,11 +196,18 @@ public async Task PrintLabel(
_ => null,
})
.Where(z => !string.IsNullOrWhiteSpace(z))
- .Select(z => Summarise(z!))
+ .Select(z => z!)
.ToList();
- string rawNote = rawBlocks.Count == 0
+ var rawBlocks = ListRawBlocks(rawTexts);
+ string rawNote = rawTexts.Count == 0
? ""
- : $" This label also contains {rawBlocks.Count} block(s) of imported raw ZPL that are sent to the printer unchanged and were not checked by Rotula: {string.Join("; ", rawBlocks.Take(10))}{(rawBlocks.Count > 10 ? "; ..." : "")}.";
+ : $" This label also contains {rawTexts.Count} block(s) of imported raw ZPL that are sent to the printer unchanged and were not checked by Rotula; they are listed in full in rawZplBlocks.";
+ string riskNote = risky.Count == 0
+ ? ""
+ : " WARNING: THIS JOB CONTAINS COMMANDS THAT CHANGE OR ERASE THINGS IN THE PRINTER, not only print a label: " +
+ string.Join("; ", risky.Select(r => $"{r.Name} ({r.Meaning}){(r.Count > 1 ? $" x{r.Count}" : "")}")) +
+ ". Tell the user exactly this. Only if they agree, send it by repeating the call with the confirmationToken AND acknowledgedCommands set to " +
+ "[" + string.Join(", ", risky.Select(r => "\"" + r.Name + "\"")) + "]. Otherwise do not print; the commands can be removed with delete_element.";
switch (decision.Decision)
{
@@ -204,8 +216,8 @@ public async Task PrintLabel(
case GateDecision.DryRun:
return new PrintResultInfo("dry-run",
- $"NOTHING WAS PRINTED. This is a preview of the job. If the user confirms, call print_label again with printer \"{name}\", copies {copies} and confirmationToken \"{decision.Token}\" before it expires." + rawNote,
- name, copies, size, caps.Dpi is { } d ? (int)d : null, conversion, preview, decision.Token, decision.ExpiresAt?.ToUniversalTime(), issues, rawBlocks);
+ $"NOTHING WAS PRINTED. This is a preview of the job. If the user confirms, call print_label again with printer \"{name}\", copies {copies} and confirmationToken \"{decision.Token}\" before it expires." + riskNote + rawNote,
+ name, copies, size, caps.Dpi is { } d ? (int)d : null, conversion, preview, decision.Token, decision.ExpiresAt?.ToUniversalTime(), issues, rawBlocks, risky, risky.Count > 0);
default:
// Exactly one job. The result is a value, never an exception; a failure is reported and nothing is retried.
@@ -215,17 +227,35 @@ public async Task PrintLabel(
if (!result.Succeeded)
throw new McpException("The printer did not accept the job: " + Messages.Describe(result.Error!));
return new PrintResultInfo("sent", $"Sent one print job of {copies} label(s) to \"{name}\" ({result.BytesSent:N0} bytes).",
- name, copies, size, caps.Dpi is { } d2 ? (int)d2 : null, conversion, preview, null, null, issues, rawBlocks);
+ name, copies, size, caps.Dpi is { } d2 ? (int)d2 : null, conversion, preview, null, null, issues, rawBlocks, risky, false);
}
}
// ---- helpers ---------------------------------------------------------------------------
- /// A short, single-line form of a raw ZPL block for the confirmation message.
- private static string Summarise(string zpl)
+ /// Longest raw block shown whole, in characters. A longer one is cut and says how much is missing.
+ internal const int MaxShownBlockChars = 4000;
+
+ /// Most raw blocks listed one by one. The rest are counted in a last entry.
+ internal const int MaxShownBlocks = 100;
+
+ ///
+ /// The raw blocks for the confirmation, each as one line and in full up to characters. Whatever is not
+ /// shown is said so; it was still scanned for printer-changing commands, so a cut never hides one.
+ ///
+ internal static List ListRawBlocks(IReadOnlyList blocks)
{
- string oneLine = string.Join(" ", zpl.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)).Trim();
- return oneLine.Length > 60 ? oneLine[..60] + "..." : oneLine;
+ var shown = new List(Math.Min(blocks.Count, MaxShownBlocks + 1));
+ foreach (var zpl in blocks.Take(MaxShownBlocks))
+ {
+ string oneLine = string.Join(" ", zpl.Split(["\r", "\n"], StringSplitOptions.RemoveEmptyEntries)).Trim();
+ shown.Add(oneLine.Length <= MaxShownBlockChars
+ ? oneLine
+ : oneLine[..MaxShownBlockChars] + $"... [{oneLine.Length - MaxShownBlockChars:N0} more characters not shown; the whole block was checked for printer-changing commands]");
+ }
+ if (blocks.Count > MaxShownBlocks)
+ shown.Add($"... [{blocks.Count - MaxShownBlocks:N0} more raw block(s) not listed here; all of them were checked for printer-changing commands]");
+ return shown;
}
private async Task> SafeListAsync(CancellationToken ct)
diff --git a/src/Rotula.Mcp/Tools/Results.cs b/src/Rotula.Mcp/Tools/Results.cs
index d299cf0..815e9b9 100644
--- a/src/Rotula.Mcp/Tools/Results.cs
+++ b/src/Rotula.Mcp/Tools/Results.cs
@@ -1,7 +1,9 @@
#pragma warning disable CS1573 // positional record parameters are documented where the meaning is not obvious from the name
using Rotula.Mcp.Hosting;
+using Rotula.Mcp.Security;
using Rotula.Mcp.Session;
+using Rotula.Printing;
namespace Rotula.Mcp.Tools;
@@ -115,4 +117,4 @@ public sealed record QueryResult(
public sealed record PrintResultInfo(
string Status, string Message, string? Printer, int Copies, string? LabelSize, int? PrinterDpi, string? DpiConversion,
IReadOnlyList ZplPreview, string? ConfirmationToken, DateTimeOffset? TokenExpiresUtc, IReadOnlyList Issues,
- IReadOnlyList RawZplBlocks);
+ IReadOnlyList RawZplBlocks, IReadOnlyList RiskyCommands, bool RequiresAcknowledgement);
diff --git a/src/Rotula.Printing/Abstractions.cs b/src/Rotula.Printing/Abstractions.cs
index b66a968..a4a293e 100644
--- a/src/Rotula.Printing/Abstractions.cs
+++ b/src/Rotula.Printing/Abstractions.cs
@@ -125,6 +125,28 @@ private PrinterTarget() { }
/// Status queries around a job; unless set with .
public PrinterStatusOptions Status { get; private init; } = PrinterStatusOptions.Off;
+ ///
+ /// How the label reaches the printer. unless set with , so every existing target
+ /// keeps sending ZPL byte for byte.
+ ///
+ public PrinterOutputMode Mode { get; private init; } = PrinterOutputMode.PrinterLanguage;
+
+ ///
+ /// Fingerprint of the print findings (unprintable raw blocks, barcodes below the safe size...) the person has accepted for this job, or null when
+ /// nothing was acknowledged. Without a matching acknowledgement a picture-mode job is not sent.
+ ///
+ public string? AcknowledgedFindings { get; private init; }
+
+ ///
+ /// A copy of this target printing in . A TCP target has no driver to draw through, so it always stays
+ /// whatever is asked.
+ ///
+ public PrinterTarget WithMode(PrinterOutputMode mode) =>
+ this with { Mode = Transport == PrinterTransport.Tcp ? PrinterOutputMode.PrinterLanguage : mode };
+
+ /// A copy of this target that carries the acknowledgement (null clears it).
+ public PrinterTarget WithAcknowledgement(string? fingerprint) => this with { AcknowledgedFindings = fingerprint };
+
/// A copy of this target that asks the printer for its status as says.
public PrinterTarget WithStatus(PrinterStatusOptions status) => this with { Status = status ?? PrinterStatusOptions.Off };
diff --git a/src/Rotula.Printing/Driver/DevicePictureRenderer.cs b/src/Rotula.Printing/Driver/DevicePictureRenderer.cs
new file mode 100644
index 0000000..c749a95
--- /dev/null
+++ b/src/Rotula.Printing/Driver/DevicePictureRenderer.cs
@@ -0,0 +1,204 @@
+using System.Runtime.InteropServices;
+using Rotula.Core.Barcodes;
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+using Rotula.Core.Validation;
+using Rotula.Rendering;
+using Rotula.Rendering.Device;
+using Rotula.Rendering.Input;
+using SkiaSharp;
+
+namespace Rotula.Printing;
+
+///
+/// Thrown by when a label cannot be drawn faithfully for this record: barcode data that cannot be
+/// encoded, or an image that cannot be read. The preview draws a red cross in such a place; on a real label that would be a silent substitution,
+/// so the label must fail before any page of its job is sent. carries what went wrong as translatable findings; the
+/// exception message is plain English for developers and logs only.
+///
+public sealed class DevicePictureException : InvalidOperationException
+{
+ /// Creates the exception.
+ /// What could not be drawn, as findings with message keys (at least one).
+ /// A plain English description for developers and logs.
+ public DevicePictureException(IReadOnlyList issues, string message)
+ : base(message)
+ {
+ ArgumentNullException.ThrowIfNull(issues);
+ Issues = issues;
+ }
+
+ ///
+ /// What could not be drawn: (symbology name, data),
+ /// (no arguments) or (no arguments), all errors.
+ ///
+ public IReadOnlyList Issues { get; }
+}
+
+///
+/// Draws one label as the picture a Windows printer driver receives in picture mode: the same renderer as the preview, rescaled to the
+/// driver's resolution with every barcode module on whole device dots, flattened to 8-bit grey.
+///
+/// Known limitation: bitmap images inside the label are drawn with the renderer's smooth (linear) sampling, because neither
+/// nor offers nearest-neighbour sampling. An image that is enlarged a lot therefore gets
+/// grey edges between its dots, which the driver then halftones. Barcodes, boxes and lines are not affected (they are drawn without smoothing).
+///
+///
+public static class DevicePictureRenderer
+{
+ ///
+ /// Largest picture drawn, in pixels (40 million: 40 MB of grey plus 160 MB of colour while it is drawn). A bigger label at a very high
+ /// resolution is refused by the pre-flight with a finding instead of running the computer out of memory.
+ ///
+ public const long MaxPixels = 40_000_000;
+
+ /// Error key carried by : a barcode's data cannot be encoded for this record (symbology name, data).
+ public const string BarcodeNotDrawn = "Driver.Render.BarcodeNotDrawn";
+ /// Error key carried by : an image cannot be read or is too large to draw (no arguments).
+ public const string ImageNotDrawn = "Driver.Render.ImageNotDrawn";
+ /// Error key carried by : any other element the renderer could not draw faithfully (no arguments).
+ public const string ElementNotDrawn = "Driver.Render.ElementNotDrawn";
+
+ ///
+ /// The resolution the picture is drawn at for : the coarser of its two axes. The picture keeps square pixels and the
+ /// device stretches it along the finer axis; with a whole ratio (600 x 300) every module still lands on whole device dots.
+ ///
+ /// What the driver reported.
+ /// is null.
+ public static int RenderDpi(DriverMedia media)
+ {
+ ArgumentNullException.ThrowIfNull(media);
+ return Math.Min(media.DpiX, media.DpiY);
+ }
+
+ ///
+ /// Draws with values that were already resolved for this label, at , on white paper.
+ /// Objects that are not printed are left out, exactly as in every other print path; so is every part the driver cannot print (see
+ /// ). Print orientation (upside down) and mirror image are applied, because no printer applies them to a picture.
+ ///
+ /// The design.
+ /// This label's resolved values (they are used as they are, never resolved again), or null for a label without variables.
+ /// Resolution of the picture in dots per inch, normally of the driver media.
+ /// The picture, one grey byte per pixel, 0 = black.
+ /// is null.
+ /// is not positive.
+ /// Something on the label could not be drawn faithfully for these values (bad barcode data, unreadable image).
+ /// The picture would be larger than .
+ public static DevicePicture Render(LabelDocument document, IReadOnlyDictionary? resolvedValues, int renderDpi)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(renderDpi);
+
+ // Second guard behind the pre-flight's TooLarge finding: checked before anything is built, so a caller that skipped the pre-flight still
+ // gets a clear refusal instead of an out-of-memory failure halfway through a job.
+ var (width, height) = PictureSize(document, renderDpi);
+ if ((long)width * height > MaxPixels)
+ throw new InvalidOperationException(
+ $"The label picture would be {width} x {height} pixels at {renderDpi} dpi, more than the {MaxPixels} pixels Rotula draws. Run the driver pre-flight first.");
+
+ // The parts the driver cannot print are left out after the layout is worked out on the whole design, so nothing anchored to them moves.
+ var designed = LabelDocumentAdapter.ToRenderLabelResolved(document, resolvedValues, e => IsLeftOutOfPicture(document, e));
+ // In printer-language mode the printer itself turns (^POI) or mirrors (^PMY) the label; in picture mode nothing does, so the picture is
+ // drawn the way the printer would put it on the media. The scaler keeps both flags.
+ var asPrinted = LabelDocumentAdapter.AsPrinted(designed, document);
+ return Draw(RenderLabelScaler.ToResolution(asPrinted, renderDpi));
+ }
+
+ ///
+ /// Draws a render label that is already at device resolution into the grey picture, and refuses it when the renderer had to put a stand-in
+ /// (a red cross) anywhere: with the parts the driver cannot print already left out, every remaining renderer issue is a real failure.
+ ///
+ /// The label at device resolution.
+ /// The renderer reported at least one element it could not draw.
+ internal static DevicePicture Draw(RenderLabel label)
+ {
+ // Scale 1: one pixel per device dot. A Windows-font text keeps the exact (unrounded) em size the scaler gave it; nothing here rounds it.
+ using var rendered = LabelRenderer.RenderBitmap(label, new RenderOptions { Scale = 1.0, Background = SKColors.White });
+ if (rendered.Issues.Count > 0)
+ throw new DevicePictureException(rendered.Issues.Select(ToFinding).ToList(),
+ "The label picture could not be drawn faithfully, so it must not be printed: " + string.Join(" ", rendered.Issues.Select(i => i.Message)));
+ return ToGray8(rendered.Bitmap);
+ }
+
+ ///
+ /// True for every element the Windows-driver picture leaves out because only the printer can produce it. The pre-flight counts exactly these
+ /// elements (as ), so what is reported and what is left out can never differ:
+ ///
+ /// every raw ZPL block, visual or not (it is a printer command);
+ /// every element the preview shows as a hatched stand-in (: a barcode only the
+ /// printer can draw, a recall of a graphic stored on the printer, an image too large to draw);
+ /// the UL and CSA certification marks (^GS D and E): the printer prints the real marks, the renderer only has look-alike letters.
+ /// The preview keeps showing the letters; only the driver picture leaves them out.
+ ///
+ /// Whether the element is printed at all (do not print, print conditions) is not looked at here.
+ ///
+ /// The design the element belongs to.
+ /// The element.
+ internal static bool IsLeftOutOfPicture(LabelDocument document, LabelElement element) =>
+ element is RawZplElement
+ || element is SymbolElement { Symbol: GraphicSymbol.UnderwritersLaboratories or GraphicSymbol.CanadianStandards }
+ || LabelDocumentAdapter.IsDrawnAsPlaceholder(document, element);
+
+ ///
+ /// Width and height in pixels of the picture draws for at , worked
+ /// out from the label size alone without allocating anything. It makes the same two roundings as the real pipeline (the label size to whole
+ /// dots at the design resolution, then 's rescale), so the pre-flight's size check judges the picture that is really drawn.
+ ///
+ /// The design.
+ /// Resolution of the picture in dots per inch.
+ internal static (int Width, int Height) PictureSize(LabelDocument document, int renderDpi)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+ int designDpi = (int)document.Dpi;
+ return (DeviceDots(document.WidthDots, designDpi, renderDpi), DeviceDots(document.HeightDots, designDpi, renderDpi));
+ }
+
+ ///
+ /// A length of design dots in device dots at , at least one. The rounding is
+ /// itself, with the same ratio the scaler uses, so the pre-flight judges exactly the module and
+ /// picture size that are drawn.
+ ///
+ /// Length in dots at the design resolution.
+ /// The design resolution (the document's).
+ /// The picture resolution.
+ internal static int DeviceDots(int dots, int designDpi, int renderDpi)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(designDpi);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(renderDpi);
+ return RenderLabelScaler.ToDeviceDots(dots, (double)renderDpi / designDpi, 1);
+ }
+
+ /// Turns a renderer issue (plain English, for the preview) into a translatable finding with stable arguments.
+ private static ValidationIssue ToFinding(RenderIssue issue) => issue.Element switch
+ {
+ RenderBarcode barcode => new ValidationIssue(ValidationSeverity.Error, BarcodeNotDrawn, null,
+ Enum.IsDefined(barcode.Symbology) ? BarcodeCatalog.Get(barcode.Symbology).DisplayName : barcode.Symbology.ToString(), barcode.Data),
+ RenderImage => new ValidationIssue(ValidationSeverity.Error, ImageNotDrawn, null),
+ _ => new ValidationIssue(ValidationSeverity.Error, ElementNotDrawn, null),
+ };
+
+ ///
+ /// Copies the rendered colour bitmap into a tightly packed grey buffer. Skia converts the pixel format while it copies (luminance), so black
+ /// stays exactly 0 and the white paper exactly 255.
+ ///
+ private static DevicePicture ToGray8(SKBitmap bitmap)
+ {
+ int width = bitmap.Width, height = bitmap.Height;
+ var gray = new byte[width * height];
+ var info = new SKImageInfo(width, height, SKColorType.Gray8, SKAlphaType.Opaque);
+ // Pinned so Skia can write straight into the managed array; row bytes = width means no padding, as DevicePicture promises.
+ var handle = GCHandle.Alloc(gray, GCHandleType.Pinned);
+ try
+ {
+ using var pixmap = bitmap.PeekPixels()
+ ?? throw new InvalidOperationException("The rendered label picture could not be read.");
+ if (!pixmap.ReadPixels(info, handle.AddrOfPinnedObject(), width, 0, 0))
+ throw new InvalidOperationException("The rendered label picture could not be converted to grey.");
+ }
+ finally
+ {
+ handle.Free();
+ }
+ return new DevicePicture(width, height, gray);
+ }
+}
diff --git a/src/Rotula.Printing/Driver/DriverModels.cs b/src/Rotula.Printing/Driver/DriverModels.cs
new file mode 100644
index 0000000..57995b0
--- /dev/null
+++ b/src/Rotula.Printing/Driver/DriverModels.cs
@@ -0,0 +1,119 @@
+namespace Rotula.Printing;
+
+///
+/// What a Windows printer driver reports about the page it will print a label on, read without printing anything (see
+/// ). The pre-flight compares it with the label, the picture renderer draws at its resolution, and the
+/// print job checks that the driver still uses this page before it starts ().
+/// All dot values are device dots: horizontal ones at , vertical ones at .
+///
+/// The physical page and the printable area are both carried because they answer different questions. When the driver accepted the label
+/// size, the label is drawn from the paper's corner, so whether it fits is a question about the physical page; the printable area inside it
+/// only tells which strip along the edges the printer cannot reach (die-cut label drivers often report about 1.5 mm there).
+///
+///
+/// Horizontal resolution of the driver in dots per inch, exactly as it reports it (any value, not only 203/300/600).
+/// Vertical resolution in dots per inch. It can differ from (for example 600 x 300).
+/// Width of the whole sheet of paper (GDI PHYSICALWIDTH), in horizontal device dots. Never smaller than the printable width on a sane driver.
+/// Height of the whole sheet of paper (GDI PHYSICALHEIGHT), in vertical device dots.
+/// Width of the area the driver can print on, in horizontal device dots.
+/// Height of the area the driver can print on, in vertical device dots.
+/// Unprintable margin on the left of the paper, in horizontal device dots.
+/// Unprintable margin at the top of the paper, in vertical device dots.
+///
+/// True when the driver accepted a paper size equal to the label size. False means it kept its own selected paper, so the label is centred on
+/// paper of another size.
+///
+public sealed record DriverMedia(int DpiX, int DpiY, int PhysicalWidthDots, int PhysicalHeightDots, int PrintableWidthDots, int PrintableHeightDots,
+ int OffsetXDots, int OffsetYDots, bool CustomSizeAccepted);
+
+///
+/// One label drawn as a picture for a printer driver: 8 bits of grey per pixel, row after row from the top, with no padding between rows.
+/// A value of 0 is black and 255 is white (paper).
+///
+/// Width in pixels.
+/// Height in pixels.
+/// The pixels, times bytes.
+public sealed record DevicePicture(int WidthPx, int HeightPx, byte[] Gray8)
+{
+ ///
+ /// The same picture turned a quarter turn clockwise: the old top row becomes the right-hand column, so the old top-left pixel ends at the
+ /// top right. A new picture of x pixels. It moves pixels and changes none, which is the reason
+ /// to turn the finished picture instead of drawing the turned label: every barcode module keeps its exact size in device dots (a module
+ /// that is 3 pixels across is 3 pixels tall afterwards), and nothing is resampled or smoothed.
+ ///
+ /// The picture's buffer is shorter than its size says.
+ public DevicePicture TurnedClockwise()
+ {
+ int width = WidthPx, height = HeightPx;
+ if (width <= 0 || height <= 0 || Gray8 is null || (long)width * height > Gray8.Length)
+ throw new ArgumentException($"The picture {width} x {height} does not match its {Gray8?.Length ?? 0} grey bytes.");
+ var turned = new byte[width * height];
+ // Source (x, y) goes to column height-1-y, row x of a picture that is `height` wide: walking the source rows in order keeps the reads
+ // sequential, and the writes jump by one turned row each, which is acceptable for a one-off picture per distinct label.
+ for (int y = 0; y < height; y++)
+ {
+ int from = y * width, column = height - 1 - y;
+ for (int x = 0; x < width; x++) turned[x * height + column] = Gray8[from + x];
+ }
+ return new DevicePicture(height, width, turned);
+ }
+}
+
+///
+/// One label of a batch whose variable values were already worked out (data merge, counters, dates), so printing draws exactly these values
+/// and never resolves them again (a second resolution would hand out a different serial number).
+///
+/// The values of this label, or null for a label without variables.
+/// How many identical copies of this label to print.
+public sealed record ResolvedLabel(IReadOnlyDictionary? Values, int Copies);
+
+///
+/// The result of one driver print job plus how many pages the driver really took. The page count is what keeps serial numbers honest
+/// after a failure or a cancel: a page the driver accepted may have printed, so its number is never issued again.
+///
+/// Success, or the failure with its stable error kind.
+/// Pages the driver accepted before the job ended (all of them on success).
+public sealed record DriverPrintOutcome(PrintResult Result, int PagesSubmitted);
+
+///
+/// The seam between the print service and the Windows print system for picture printing. The real implementation talks to GDI; tests use a fake.
+/// Printer problems come back as values, never as exceptions.
+///
+public interface IDriverPrinter
+{
+ ///
+ /// Asks the driver, without printing, which resolution and printable area it would use for a page of the label's size, and whether it
+ /// accepts that size as a custom paper size.
+ ///
+ /// The Windows printer name.
+ /// Label width in millimetres.
+ /// Label height in millimetres.
+ /// Cancels the probe.
+ /// The media, or null when the printer or its driver cannot be reached.
+ Task ProbeAsync(string printerName, double labelWidthMm, double labelHeightMm, CancellationToken ct);
+
+ ///
+ /// Prints as one print job, one picture per page, each drawn at the label's physical size.
+ ///
+ /// Before the job starts, the implementation compares the page the driver will really print on with , the media the
+ /// pre-flight judged and the pictures were drawn for. When the resolution differs, or the probe said the label size was accepted and the
+ /// page is no longer that size, the job is refused with and no page: the findings the person
+ /// accepted, and the picture itself, belong to the probed page, and printing on another one would centre, clip or stretch the label without
+ /// a word.
+ ///
+ ///
+ /// The Windows printer name.
+ /// Name of the job in the Windows print queue.
+ ///
+ /// Label width in millimetres (the size each picture is printed at). For a label turned to fit sideways stock this is the turned label's
+ /// width (the designed height): the same size the media was probed for, which is what the check before the job compares with.
+ ///
+ /// Label height in millimetres (the turned label's height when it is turned).
+ /// The media reported and the pre-flight judged; required.
+ ///
+ /// The pictures, produced lazily so a long run never holds every picture in memory at once. The implementation enumerates it exactly once.
+ ///
+ /// Cancels the job between pages; pages already accepted by the driver stay counted.
+ Task PrintAsync(string printerName, string documentName, double labelWidthMm, double labelHeightMm, DriverMedia media,
+ IEnumerable pages, CancellationToken ct);
+}
diff --git a/src/Rotula.Printing/Driver/DriverPreflight.cs b/src/Rotula.Printing/Driver/DriverPreflight.cs
new file mode 100644
index 0000000..54892ce
--- /dev/null
+++ b/src/Rotula.Printing/Driver/DriverPreflight.cs
@@ -0,0 +1,394 @@
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using Rotula.Core.Barcodes;
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+using Rotula.Core.Validation;
+using Rotula.Rendering.Input;
+using Rotula.Rendering.View;
+
+namespace Rotula.Printing;
+
+///
+/// What the driver pre-flight found for one label design on one driver. Errors block the job; warnings let it print once the person has
+/// acknowledged exactly this set of findings (see ). The findings are s whose message keys the
+/// app translates; this library holds no user-facing text.
+///
+/// Everything found, errors and warnings, in a stable order (paper and driver first, then the label's content).
+/// The driver media the findings were worked out for, or null when no usable media was reported.
+///
+/// True when the label does not fit the driver's paper as designed but fits it turned a quarter turn: the findings and
+/// are then for the turned label (the driver was asked for the turned size), and printing turns every picture the same way
+/// (). The pre-flight and the print call reach this through the same decision, so they cannot differ.
+///
+public sealed record DriverPreflightResult(IReadOnlyList Findings, DriverMedia? Media, bool LabelTurned = false)
+{
+ /// The result for a job that needs no pre-flight (printer language mode): nothing found, nothing to acknowledge.
+ public static DriverPreflightResult None { get; } = new(Array.Empty(), null);
+
+ /// True when at least one finding is an error: nothing may be sent, whatever was acknowledged.
+ public bool HasErrors => Findings.Any(f => f.Severity == ValidationSeverity.Error);
+
+ /// True when at least one finding is a warning: the job may print only after the person acknowledged these findings.
+ public bool NeedsAcknowledgement => Findings.Any(f => f.Severity == ValidationSeverity.Warning);
+
+ ///
+ /// SHA-256, as lower-case hexadecimal, over one line per finding (MessageKey|arg|arg), the lines sorted ordinally and joined with line
+ /// feeds. An acknowledgement stores this value: it stays the same for the same findings in any order and on any machine, and changes as soon
+ /// as a finding is added, removed or gets other arguments, so a changed design is asked about again. Inside an argument a backslash, a bar
+ /// and a line break are escaped, so one argument can never pass for two. Worked out on every read (it is cheap) so a copy made with
+ /// with never carries a value that belongs to other findings.
+ ///
+ public string Fingerprint
+ {
+ get
+ {
+ var lines = Findings.Select(Line).ToList();
+ lines.Sort(StringComparer.Ordinal);
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\n', lines)));
+ return Convert.ToHexStringLower(hash);
+ }
+ }
+
+ private static string Line(ValidationIssue finding) =>
+ string.Join('|', new[] { Escape(finding.MessageKey) }.Concat(finding.Args.Select(Escape)));
+
+ private static string Escape(string value) =>
+ value.Replace("\\", "\\\\").Replace("|", "\\|").Replace("\n", "\\n").Replace("\r", "\\r");
+}
+
+///
+/// Checks a label design against what a Windows printer driver reported, before anything is printed in picture mode. It looks at the design
+/// once for the whole job (a 500-record batch is checked once) and never draws or allocates the picture.
+/// Findings (message key, severity, arguments in order):
+///
+/// , error: no usable media was reported (the printer or its driver cannot be reached, or it reports no
+/// resolution or no printable area, or it accepted the label size but reports no physical page that holds its printable area). Reported alone.
+/// , error: the picture would have more than pixels (pixels, limit).
+/// , error: the label is more than 1 mm wider or taller than the paper the driver will really use: the
+/// physical page when the label size was accepted, the printable area of the driver's own paper when it was refused (label mm, printable mm).
+/// , warning: the label size was accepted and the label fits the physical page, but it is more than 1 mm wider or
+/// taller than the printable area, so anything of it in the strip along the edges the printer cannot print on may be cut off (label mm,
+/// printable mm, widest unprintable edge mm). It comes for every such label, whatever its content: the check knows the page, not where the
+/// design puts its ink.
+/// , warning: only from with a turned label (label size as
+/// designed, paper it lies on). The paper findings after it are then the turned label's.
+/// , warning: the driver refused the label size as a custom paper size, so the label is centred on its own
+/// paper (label mm, printable mm).
+/// , warning: the horizontal and vertical resolutions are not a whole ratio of each other (dpi x, dpi y).
+/// , warning: parts of the label that are printer commands or features the driver cannot print (count): every raw
+/// ZPL block kept from an imported file, every element the renderer would only show as a stand-in (a barcode only the printer can draw, a
+/// recall of a graphic stored on the printer, an image too large to draw) and every UL or CSA certification mark.
+/// leaves exactly these out of the picture (one shared rule).
+/// , error: a barcode module is a single device dot (barcode name).
+/// , warning: a barcode module is narrower than its symbology's minimum (barcode name, module mils,
+/// minimum mils).
+/// , error: a text or barcode whose data the printer fills in itself: it numbers it (^SN, ^SF), dates
+/// it (^FC) or expands embedded field references in it (^FE) (field name). The setting alone counts. Not counted with
+/// .
+///
+/// is not produced here: the print service reports it alone when the driver could not be asked because the job was cancelled.
+/// (an error) is not produced here either: it judges the size of the whole job, not the design (see ).
+/// Sizes are written as WxH in millimetres with at most one decimal (100x50, 99.5x49.9), mils and the widest edge always
+/// with one decimal (3.3, 1.5), all in the invariant culture so the fingerprint never depends on the machine's language.
+///
+public static class DriverPreflight
+{
+ /// Error key: the driver reported no usable media.
+ public const string DriverUnavailable = "Driver.Preflight.DriverUnavailable";
+ /// Warning key: the label holds raw ZPL blocks or other parts (printer-drawn barcodes, stored-graphic recalls, oversize images, UL/CSA marks) that picture mode cannot draw.
+ public const string RawBlocks = "Driver.Preflight.RawBlocks";
+ /// Error key: a barcode module is one device dot.
+ public const string ModuleOneDot = "Driver.Preflight.ModuleOneDot";
+ /// Warning key: a barcode module is below the symbology's minimum width.
+ public const string ModuleBelowMinimum = "Driver.Preflight.ModuleBelowMinimum";
+ /// Error key: the label does not fit the paper the driver will use (its page when the size was accepted, its printable area otherwise).
+ public const string LabelLargerThanPaper = "Driver.Preflight.LabelLargerThanPaper";
+ /// Warning key: the driver refused the label size as custom paper.
+ public const string PaperMismatch = "Driver.Preflight.PaperMismatch";
+ ///
+ /// Warning key: the label fits the page the driver accepted, but it is more than 1 mm larger than the area the printer can print on, so
+ /// anything of the label in the strip along the edges may be cut off. Arguments: label size, printable area, widest unprintable edge in mm.
+ ///
+ public const string MarginBand = "Driver.Preflight.MarginBand";
+ /// Warning key: the two resolutions are not a whole ratio.
+ public const string AxisRatio = "Driver.Preflight.AxisRatio";
+ /// Error key: the picture would be too large to draw.
+ public const string TooLarge = "Driver.Preflight.TooLarge";
+ ///
+ /// Error key: a text or barcode is numbered, dated or completed by the printer itself (^SN, ^SF, ^FC or ^FE embedded references), which a
+ /// picture cannot do.
+ ///
+ public const string PrinterCounter = "Driver.Preflight.PrinterCounter";
+ /// Error key: the pre-flight was cancelled before the driver answered, so nothing was judged and nothing may be printed.
+ public const string Cancelled = "Driver.Preflight.Cancelled";
+ ///
+ /// Error key: the whole job would print more pictures than . Arguments: the number of labels asked for, the limit
+ /// (both whole numbers in the invariant culture). Reported alone, before the driver is asked anything.
+ ///
+ public const string TooManyLabels = "Driver.Preflight.TooManyLabels";
+ ///
+ /// Warning key: the label does not fit the driver's paper as designed but fits it turned a quarter turn clockwise, so Rotula turns the picture.
+ /// Arguments: the label size as designed and the paper it lies on (both WxH in millimetres, the paper the other way round). It is a
+ /// warning, so it needs the usual single acknowledgement: the person should see once that the printout is turned (and, if the printer feeds
+ /// the other way, upside down), and the tick is remembered for exactly these findings like every other.
+ ///
+ public const string LabelTurned = "Driver.Preflight.LabelTurned";
+
+ ///
+ /// Most labels (so most pictures, one per copy) one picture-mode job may print. It is the limit the printer-language path already puts on
+ /// separately numbered labels (), reused on purpose: a person learns one number for
+ /// "too many labels in one go", and it is a sensible one for pictures too. In picture mode every copy is a page of its own in the Windows
+ /// queue (a driver document holds pages, 50 by default), so 100,000 labels are 2,000
+ /// documents and, at the driver's own page size, gigabytes of spool; the 99,999,999 copies the Copies box accepts would be about two million
+ /// documents and would fill the disk long before the first label came out. Nobody prints a real run that large in one click: bigger runs
+ /// are printed in batches.
+ ///
+ public const long MaxLabelsPerJob = PrintVariableOptions.MaxIndividualLabels;
+
+ ///
+ /// Judges only the size of a job: the error finding when pictures are more than
+ /// , otherwise null. It needs no driver, so it is the same for the pre-flight and for the print call that
+ /// guards a caller who skipped the pre-flight. is the total of the whole job: every record times its copies.
+ ///
+ /// How many pictures (labels with their copies) the whole job prints.
+ public static ValidationIssue? CheckJobSize(long labelCount) =>
+ labelCount <= MaxLabelsPerJob
+ ? null
+ : new ValidationIssue(ValidationSeverity.Error, TooManyLabels, null,
+ labelCount.ToString(CultureInfo.InvariantCulture), MaxLabelsPerJob.ToString(CultureInfo.InvariantCulture));
+
+ ///
+ /// Paper sizes are reported in whole dots and often rounded to whole millimetres by drivers, so a label up to this much larger than the
+ /// printable area is treated as fitting; beyond it the label would visibly be cut off.
+ ///
+ private const double PaperToleranceMm = 1.0;
+
+ /// How far from a whole number the ratio of the two resolutions may be before modules stop landing on whole device dots.
+ private const double AxisRatioTolerance = 0.01;
+
+ ///
+ /// Runs every check of the pre-flight for printed on . Objects marked "do not print" are
+ /// skipped (they never reach the printer); objects with a print condition are checked, because some record of the job may print them.
+ ///
+ /// The design.
+ /// What the driver reported, or null when it could not be asked.
+ /// is null.
+ public static DriverPreflightResult Analyze(LabelDocument document, DriverMedia? media) => Analyze(document, media, labelTurned: false);
+
+ ///
+ /// Like , for a label that is printed turned a quarter turn ():
+ /// the paper checks judge the turned label (width and height swapped) against , which must be what the driver
+ /// reported for the turned size, and the warning says what is done. Everything else (picture size in pixels, bars,
+ /// raw blocks, printer-side fields) is the same as turning moves pixels without changing any of them.
+ ///
+ /// The design, as designed (not turned).
+ /// What the driver reported, or null when it could not be asked.
+ /// True when the picture will be turned to fit the paper.
+ /// is null.
+ public static DriverPreflightResult Analyze(LabelDocument document, DriverMedia? media, bool labelTurned)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ // Without a resolution and a printable area nothing else can be judged (module sizes, picture size, fit), and the job cannot print
+ // anyway, so this stands alone. A driver reporting a zero printable area has reported nothing usable: "larger than 0x0 mm paper" would mislead.
+ // When the label size was accepted, the fit is judged against the physical page, which must at least hold the printable area drawn on it;
+ // a driver claiming otherwise (no physical page, or one smaller than its own printable area) has not reported a page either.
+ if (media is null || media.DpiX <= 0 || media.DpiY <= 0 || media.PrintableWidthDots <= 0 || media.PrintableHeightDots <= 0
+ || (media.CustomSizeAccepted && (media.PhysicalWidthDots < media.PrintableWidthDots || media.PhysicalHeightDots < media.PrintableHeightDots)))
+ return new DriverPreflightResult(new[] { new ValidationIssue(ValidationSeverity.Error, DriverUnavailable) }, null);
+
+ int renderDpi = DevicePictureRenderer.RenderDpi(media);
+ int designDpi = (int)document.Dpi;
+ var findings = new List();
+
+ CheckPictureSize(document, renderDpi, findings);
+ CheckPaper(document, media, labelTurned, findings);
+ CheckAxisRatio(media, findings);
+ CheckRawBlocks(document, findings);
+ CheckBarcodeModules(document, designDpi, renderDpi, findings);
+ CheckPrinterSideFields(document, findings);
+
+ return new DriverPreflightResult(findings, media, labelTurned);
+ }
+
+ private static void CheckPrinterSideFields(LabelDocument document, List findings)
+ {
+ // A field with Serial (^SN / ^SF), Clock (^FC) or EmbedCharacter (^FE) is written for the printer to fill in: it changes the number on
+ // every label, puts its own date into the clock codes, or replaces references such as #2# with another field's data. A picture is drawn
+ // once from values Rotula worked out and repeated for the copies, so every copy would carry the start value, the bare clock codes or the
+ // bare reference, with nothing to tell the person. That is a wrong label, not a lost part, so it is an error (no acknowledgement lets it
+ // through) and it is not counted with RawBlocks. The setting alone counts, whether or not the data holds a reference today: the rule
+ // stays simple, and the data can change per record. Rotula's own counter and date variables are worked out on the computer and print
+ // correctly in picture mode.
+ foreach (var field in document.Elements.OfType())
+ {
+ if (field.NotPrintable) continue;
+ if (field.Serial is null && field.Clock is null && field.EmbedCharacter is null) continue;
+ // A field left out of the picture entirely (a barcode only the printer can draw) prints no wrong value; it is already counted there.
+ if (DevicePictureRenderer.IsLeftOutOfPicture(document, field)) continue;
+ findings.Add(new ValidationIssue(ValidationSeverity.Error, PrinterCounter, field.Id, NameOf(field)));
+ }
+ }
+
+ private static void CheckPictureSize(LabelDocument document, int renderDpi, List findings)
+ {
+ // From the label size and the resolution only; the picture itself is never built here.
+ var (width, height) = DevicePictureRenderer.PictureSize(document, renderDpi);
+ long pixels = (long)width * height;
+ if (pixels > DevicePictureRenderer.MaxPixels)
+ findings.Add(new ValidationIssue(ValidationSeverity.Error, TooLarge, null,
+ pixels.ToString(CultureInfo.InvariantCulture), DevicePictureRenderer.MaxPixels.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ ///
+ /// Judges the label against the paper the driver will really use. Which rectangle that is depends on how the label is placed
+ /// (GdiPageLayout.Place):
+ ///
+ /// Custom size accepted: the page is the label and the picture is drawn from the paper's corner, so the label must fit the
+ /// physical page. Only a label larger than that is . A label that fits the page but is more than
+ /// 1 mm larger than the printable area on either axis covers part of the strip the printer cannot reach: , a warning,
+ /// whatever the label's content (the check knows the page, not where the design puts its ink), with the widest edge as a third value. Judging
+ /// this case against the printable area, as before, refused every full-size label on die-cut drivers (DYMO, Brother) that report a small
+ /// unprintable margin, with advice ("choose a larger paper size") that cannot help.
+ /// Custom size refused: the label is centred in the printable area of the driver's own paper, so it must fit that area
+ /// (), and is always reported.
+ ///
+ /// Every paper finding carries the label size and the printable area (both WxH in millimetres), so the sentence "larger than the area
+ /// this printer can print on" stays true in both cases and the arguments of the fingerprint do not depend on which case applied.
+ ///
+ private static void CheckPaper(LabelDocument document, DriverMedia media, bool labelTurned, List findings)
+ {
+ int designDpi = (int)document.Dpi;
+ // The label's own size as designed (not the rounded dot size), so the message shows the figures the person typed.
+ double designedW = UnitConverter.Convert(document.Width.Value, document.Width.Unit, LengthUnit.Millimeters, designDpi);
+ double designedH = UnitConverter.Convert(document.Height.Value, document.Height.Unit, LengthUnit.Millimeters, designDpi);
+ // A turned label lies on the paper the other way round: it is the turned size that must fit, and that the driver was asked for.
+ double labelW = labelTurned ? designedH : designedW;
+ double labelH = labelTurned ? designedW : designedH;
+ // Each axis at its own resolution: the printable area and the page are measured in device dots of that axis.
+ double printableW = UnitConverter.DotsToMm(media.PrintableWidthDots, media.DpiX);
+ double printableH = UnitConverter.DotsToMm(media.PrintableHeightDots, media.DpiY);
+ string label = Size(labelW, labelH), printable = Size(printableW, printableH);
+ bool beyondPrintable = labelW > printableW + PaperToleranceMm || labelH > printableH + PaperToleranceMm;
+
+ if (labelTurned)
+ {
+ // Said first among the paper findings: it explains the sizes of the others, which are the turned label's. The paper named is the
+ // page the label lies on (the whole page when the driver took the size, else the printable area of its own paper).
+ string paper = media.CustomSizeAccepted
+ ? Size(UnitConverter.DotsToMm(media.PhysicalWidthDots, media.DpiX), UnitConverter.DotsToMm(media.PhysicalHeightDots, media.DpiY))
+ : printable;
+ findings.Add(new ValidationIssue(ValidationSeverity.Warning, LabelTurned, null, Size(designedW, designedH), paper));
+ }
+
+ if (!media.CustomSizeAccepted)
+ {
+ if (beyondPrintable) findings.Add(new ValidationIssue(ValidationSeverity.Error, LabelLargerThanPaper, null, label, printable));
+ // A refused custom size is reported even when the label fits: it is then centred on paper of another size, which the person should know.
+ findings.Add(new ValidationIssue(ValidationSeverity.Warning, PaperMismatch, null, label, printable));
+ return;
+ }
+
+ double pageW = UnitConverter.DotsToMm(media.PhysicalWidthDots, media.DpiX);
+ double pageH = UnitConverter.DotsToMm(media.PhysicalHeightDots, media.DpiY);
+ if (labelW > pageW + PaperToleranceMm || labelH > pageH + PaperToleranceMm)
+ findings.Add(new ValidationIssue(ValidationSeverity.Error, LabelLargerThanPaper, null, label, printable));
+ // Only when the label fits its page: a label that does not fit is already an error, and the band would only repeat it.
+ else if (beyondPrintable)
+ findings.Add(new ValidationIssue(ValidationSeverity.Warning, MarginBand, null, label, printable,
+ WidestUnprintableEdgeMm(media).ToString("0.0", CultureInfo.InvariantCulture)));
+ }
+
+ ///
+ /// The widest of the four strips along the edges of the physical page the driver cannot print on, in millimetres: left and top are the
+ /// offsets of the printable area, right and bottom are what the page has beyond it. The widest one, not an average, because a driver can keep
+ /// its whole margin on one side (0 mm on the left and 3 mm on the right is a 3 mm edge, not 1.5 mm), and the person needs the figure that
+ /// clears every edge. A negative strip (a driver whose offsets do not add up) counts as none. Worked out here rather than by the app, so the
+ /// finding carries every value its text shows and any formatter can word it.
+ ///
+ private static double WidestUnprintableEdgeMm(DriverMedia media)
+ {
+ int right = media.PhysicalWidthDots - media.PrintableWidthDots - media.OffsetXDots;
+ int bottom = media.PhysicalHeightDots - media.PrintableHeightDots - media.OffsetYDots;
+ double horizontal = UnitConverter.DotsToMm(Math.Max(Math.Max(media.OffsetXDots, right), 0), media.DpiX);
+ double vertical = UnitConverter.DotsToMm(Math.Max(Math.Max(media.OffsetYDots, bottom), 0), media.DpiY);
+ return Math.Max(horizontal, vertical);
+ }
+
+ private static void CheckAxisRatio(DriverMedia media, List findings)
+ {
+ // The picture is drawn at the coarser resolution and stretched by the device; only a whole stretch keeps every module on whole dots.
+ double ratio = (double)Math.Max(media.DpiX, media.DpiY) / Math.Min(media.DpiX, media.DpiY);
+ if (Math.Abs(ratio - Math.Round(ratio)) > AxisRatioTolerance)
+ findings.Add(new ValidationIssue(ValidationSeverity.Warning, AxisRatio, null,
+ media.DpiX.ToString(CultureInfo.InvariantCulture), media.DpiY.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ private static void CheckRawBlocks(LabelDocument document, List findings)
+ {
+ // Every part the picture leaves out counts: raw blocks (a configuration command is just as lost as a drawing one when no ZPL is sent),
+ // elements the preview only shows as a stand-in, and the UL and CSA marks. It is the renderer's own rule, so what is reported and what is
+ // left out of the picture can never differ.
+ int count = document.Elements.Count(e => !e.NotPrintable && DevicePictureRenderer.IsLeftOutOfPicture(document, e));
+ if (count > 0)
+ findings.Add(new ValidationIssue(ValidationSeverity.Warning, RawBlocks, null, count.ToString(CultureInfo.InvariantCulture)));
+ }
+
+ private static void CheckBarcodeModules(LabelDocument document, int designDpi, int renderDpi, List findings)
+ {
+ foreach (var barcode in document.Elements.OfType())
+ {
+ if (barcode.NotPrintable) continue;
+ // A barcode only the printer can draw is not in the picture at all (it is counted with the raw blocks), so its module is not judged.
+ if (DevicePictureRenderer.IsLeftOutOfPicture(document, barcode)) continue;
+ // MaxiCode (and any symbology without an adjustable module) has a fixed size: there is no module to judge.
+ if (BarcodeSizing.ModuleDotsOf(barcode) is not { } moduleDots) continue;
+
+ // The module the picture really gets (the scaler's own rounding rule), judged at the resolution the picture is drawn at.
+ int deviceModule = DevicePictureRenderer.DeviceDots(moduleDots, designDpi, renderDpi);
+ // The minimum depends on the content for QR Code: GS1's 0.375 mm applies to a GS1 Digital Link only, any other QR takes the matrix
+ // fallback (ISO/IEC 18004 states none). The verdict and the minimum shown in the warning use the same content, so they never disagree.
+ bool gs1DigitalLink = BarcodeSizing.HasGs1DigitalLinkContent(barcode);
+ switch (BarcodeSizing.ModuleSafetyOf(barcode.Symbology, deviceModule, renderDpi, gs1DigitalLink))
+ {
+ case ModuleSafety.SingleDot:
+ findings.Add(new ValidationIssue(ValidationSeverity.Error, ModuleOneDot, barcode.Id, NameOf(barcode)));
+ break;
+ case ModuleSafety.BelowMinimum:
+ findings.Add(new ValidationIssue(ValidationSeverity.Warning, ModuleBelowMinimum, barcode.Id, NameOf(barcode),
+ BarcodeSizing.MilsOf(deviceModule, renderDpi).ToString("0.0", CultureInfo.InvariantCulture),
+ BarcodeSizing.MinimumModuleMils(barcode.Symbology, gs1DigitalLink).ToString("0.0", CultureInfo.InvariantCulture)));
+ break;
+ }
+ }
+ }
+
+ ///
+ /// The name a person recognises a field by: its own name, or else what it shows: a barcode's symbology, a text's first line (its template, so
+ /// a data field reads {{name}}). Never the internal id: it means nothing to a reader, and a ZPL import makes new ids every time, which
+ /// would change the fingerprint and ask the same question again. The id still travels as the finding's element id, so the app can point at
+ /// the object.
+ ///
+ private static string NameOf(FieldDataElement field)
+ {
+ if (!string.IsNullOrWhiteSpace(field.Name)) return field.Name.Trim();
+ return field switch
+ {
+ BarcodeElement barcode => BarcodeCatalog.Get(barcode.Symbology).DisplayName,
+ TextElement text => FirstLine(text.GetTemplate()),
+ _ => string.Empty,
+ };
+ }
+
+ /// The first line of a text, trimmed: a wrapped block or text box can hold several lines, and one is enough to recognise it.
+ private static string FirstLine(string text)
+ {
+ int end = text.AsSpan().IndexOfAny('\r', '\n');
+ return (end < 0 ? text : text[..end]).Trim();
+ }
+
+ private static string Size(double widthMm, double heightMm) =>
+ widthMm.ToString("0.#", CultureInfo.InvariantCulture) + "x" + heightMm.ToString("0.#", CultureInfo.InvariantCulture);
+}
diff --git a/src/Rotula.Printing/Driver/GdiDriverPrinter.cs b/src/Rotula.Printing/Driver/GdiDriverPrinter.cs
new file mode 100644
index 0000000..1a54e8d
--- /dev/null
+++ b/src/Rotula.Printing/Driver/GdiDriverPrinter.cs
@@ -0,0 +1,856 @@
+using System.ComponentModel;
+using System.Globalization;
+using System.Runtime.InteropServices;
+
+namespace Rotula.Printing;
+
+///
+/// The real : prints label pictures through any Windows printer driver with GDI, and asks the driver which page it
+/// would use without printing anything.
+///
+/// Paper. Every probe and every job first asks the driver for a page of exactly the label's size: a user-defined paper size in portrait,
+/// one copy, or, when the driver ignores user sizes, one of its own forms that has the label's size. The driver's answer is checked twice: the
+/// paper size it wrote back into the settings, and the physical page an information context built from those settings really reports. When both
+/// agree with the label (within 1 mm), the page is the label and the picture covers it from its corner. Otherwise the driver's own selected paper is kept (still forced to one copy) and the label is centred on its printable area;
+/// reports that as = false, so the pre-flight can warn before printing.
+/// Probe and print go through the same negotiation, and a job checks its device context against the probed media before it starts (another
+/// resolution, or an accepted page that changed, is refused), so the resolution and page the picture was drawn for are the ones the job prints on.
+///
+///
+/// Failures. A printer problem never throws: it comes back as a failed together with the number of pages the driver
+/// had already accepted (their EndPage succeeded), because such a page may have printed and its serial number must not be issued again.
+/// Any job that did not finish is aborted with AbortDoc, and every printer handle, device context and settings buffer is released.
+///
+///
+/// All calls are blocking Win32 calls (a network queue can take seconds), so the work runs on the thread pool. The device context is created and
+/// used on that one thread for the whole job, as GDI requires.
+///
+///
+public sealed class GdiDriverPrinter : IDriverPrinter
+{
+ /// Driver name passed to CreateDC/CreateIC. Learn: GDI ignores it for printers; "WINSPOOL" is the conventional value.
+ private const string SpoolerDriver = "WINSPOOL";
+
+ /// ERROR_CANCELLED (winerror.h): the person closed a dialog the driver showed (for example the Save As of a PDF printer).
+ private const int ErrorCancelled = 1223;
+
+ private readonly string? _outputFilePath;
+
+ /// Creates the printer.
+ ///
+ /// Test seam: when set, every job is written to this file instead of the printer (it becomes DOCINFO.lpszOutput), which lets
+ /// "Microsoft Print to PDF" produce a file without showing its Save As dialog. Production passes null, so the job goes to the printer.
+ ///
+ public GdiDriverPrinter(string? outputFilePath = null) =>
+ _outputFilePath = string.IsNullOrWhiteSpace(outputFilePath) ? null : outputFilePath;
+
+ ///
+ ///
+ /// Nothing is printed and no job is created: the settings are negotiated as for a real job and measured on an information context.
+ /// is true when the page the job would print on has the label's size, whether the driver took
+ /// the custom size or its selected paper already is that size. A probe whose token is cancelled ends as a cancelled task (awaiting it throws
+ /// ): null is kept for "the printer or its driver cannot be reached", and answering a cancel with
+ /// null would make the pre-flight report an unreachable driver that is not.
+ ///
+ /// A label size is not a positive finite number.
+ public Task ProbeAsync(string printerName, double labelWidthMm, double labelHeightMm, CancellationToken ct)
+ {
+ ValidateLabelSize(labelWidthMm, labelHeightMm);
+ if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(printerName)) return Task.FromResult(null);
+ if (ct.IsCancellationRequested) return Task.FromCanceled(ct);
+ return Task.Run(() =>
+ {
+ var media = ProbeCore(printerName, labelWidthMm, labelHeightMm);
+ // The driver calls cannot be interrupted; a cancel that arrived meanwhile is honoured as soon as they return.
+ ct.ThrowIfCancellationRequested();
+ return media;
+ }, ct);
+ }
+
+ ///
+ ///
+ ///
+ /// One page per picture. Each picture is drawn at the label's physical size: on an axis where the device resolution is a whole multiple of
+ /// the picture's resolution it is drawn 1:1 or stretched by that whole factor (see ), so barcode modules stay on
+ /// whole device dots; a picture clearly drawn for another resolution is stretched to the label's size in millimetres.
+ ///
+ ///
+ /// The device context is compared with before anything else happens ():
+ /// another resolution, or, when the probe said the label size was accepted, a page that is no longer the probed one or no longer the label's
+ /// size, ends the job as with the printer name and a detail naming what changed, before any
+ /// picture is requested and before StartDoc, so nothing reaches the queue and no page counts. This is what makes the pre-flight's
+ /// "accepted" true at print time: CreateDC may fall back to the driver's defaults (Learn), or the defaults may have changed since the probe.
+ ///
+ ///
+ /// is enumerated exactly once and lazily: the first picture is drawn before the job is started (so a printer that
+ /// cannot be reached or a first label that cannot be drawn leaves nothing in the queue), and the token is checked before each picture is
+ /// requested and again before it is handed to the driver. An empty sequence succeeds with zero pages and creates no job.
+ ///
+ ///
+ /// An exception thrown by itself (for example a for a label that cannot be
+ /// drawn) is not a printer problem: the job is aborted, everything is released, and the exception reaches the caller unchanged. Every picture
+ /// the sequence yielded before it threw had already been accepted by the driver, so the caller can count them as submitted. An
+ /// thrown by the sequence while is cancelled is reported as
+ /// instead.
+ ///
+ ///
+ /// Errors: the printer does not exist -> ; the user may not use it ->
+ /// ; the driver will not open or accept the job or a page (CreateDC, StartDoc, StartPage,
+ /// StretchDIBits, EndPage, EndDoc) -> with the printer name and a detail naming the call and the
+ /// Win32 error number; a cancel, or the person closing a dialog of the driver -> . Other OpenPrinter
+ /// failures map exactly as for ZPL printing ().
+ ///
+ ///
+ /// equals , and is the
+ /// number of picture bytes in those pages (capped at ), so it is above zero exactly when a page may have printed.
+ ///
+ ///
+ /// or is null.
+ /// A label size is not a positive finite number.
+ public Task PrintAsync(string printerName, string documentName, double labelWidthMm, double labelHeightMm,
+ DriverMedia media, IEnumerable pages, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(media);
+ ArgumentNullException.ThrowIfNull(pages);
+ ValidateLabelSize(labelWidthMm, labelHeightMm);
+ if (!OperatingSystem.IsWindows()) return Task.FromResult(Outcome(PrintError.Create(PrintErrorKind.Unsupported), 0, 0));
+ if (string.IsNullOrWhiteSpace(printerName))
+ return Task.FromResult(Outcome(PrintError.Create(PrintErrorKind.PrinterNotFound, printerName ?? string.Empty), 0, 0));
+ // Answered here rather than by Task.Run(…, ct): a cancelled task would throw at the caller, and a cancel is a result like any other.
+ if (ct.IsCancellationRequested) return Task.FromResult(Outcome(PrintError.Create(PrintErrorKind.Cancelled), 0, 0));
+ string jobName = string.IsNullOrEmpty(documentName) ? "Rotula label" : documentName;
+ return Task.Run(() => PrintCore(printerName, jobName, labelWidthMm, labelHeightMm, media, pages, ct), CancellationToken.None);
+ }
+
+ /// The probe on the calling thread: negotiate the page, then measure it on an information context. Null when anything fails.
+ internal static DriverMedia? ProbeCore(string printerName, double labelWidthMm, double labelHeightMm)
+ {
+ var (devMode, error) = NegotiatePage(printerName, labelWidthMm, labelHeightMm);
+ if (error is not null || devMode is null) return null;
+ using (devMode)
+ {
+ if (!TryMeasurePage(printerName, devMode, out var device)) return null;
+ return new DriverMedia(device.DpiX, device.DpiY, device.PhysicalWidth, device.PhysicalHeight, device.HorzRes, device.VertRes,
+ device.OffsetX, device.OffsetY, GdiPageLayout.PageMatchesLabel(device, labelWidthMm, labelHeightMm));
+ }
+ }
+
+ ///
+ /// The job on the calling thread: negotiate the page, create the device context, check it against the probed media, submit the pages.
+ ///
+ private DriverPrintOutcome PrintCore(string printerName, string jobName, double labelWidthMm, double labelHeightMm, DriverMedia media,
+ IEnumerable pages, CancellationToken ct)
+ {
+ var (devMode, negotiationError) = NegotiatePage(printerName, labelWidthMm, labelHeightMm);
+ if (negotiationError is not null || devMode is null)
+ return Outcome(negotiationError ?? PrintError.Create(PrintErrorKind.DriverRefused, printerName, "DocumentProperties"), 0, 0);
+
+ using (devMode)
+ using (var dc = GdiNative.CreateDC(SpoolerDriver, printerName, null, devMode.Pointer))
+ {
+ if (dc.IsInvalid) return Outcome(GdiFailure(printerName, "CreateDC", Marshal.GetLastPInvokeError()), 0, 0);
+
+ // The device context's own answers decide the geometry: Learn says CreateDC falls back to a default DEVMODE when the driver cannot use
+ // the one it was given, so the negotiated values are not trusted blindly.
+ var device = GdiDeviceGeometry.Read(dc);
+ if (!device.IsUsable) return Outcome(PrintError.Create(PrintErrorKind.DriverRefused, printerName, Detail("GetDeviceCaps", 0)), 0, 0);
+
+ // The pre-flight judged, and the pictures were drawn for, the probed media. A device context on another resolution or another page
+ // would print a label the person never saw judged (centred, clipped, or stretched off whole dots), so it is refused here, before
+ // StartDoc and before a picture is requested: nothing reaches the queue, no page counts, and the using blocks delete the DC.
+ if (GdiPageLayout.DescribeChange(device, media, labelWidthMm, labelHeightMm) is { } change)
+ return Outcome(PrintError.Create(PrintErrorKind.DriverRefused, printerName, change), 0, 0);
+
+ // COLORONCOLOR, not HALFTONE: the picture is already black and white at the device resolution and is only ever drawn 1:1 or enlarged by
+ // a whole factor, where COLORONCOLOR repeats pixels exactly. HALFTONE averages neighbouring pixels into grey, which the driver then
+ // dithers into ragged bar edges (and it needs SetBrushOrgEx). BLACKONWHITE only differs when shrinking, which never happens here.
+ // A driver may ignore the mode and scale on its own; that is a quality hint, not a failure, so the result is not checked.
+ _ = GdiNative.SetStretchBltMode(dc, GdiNative.COLORONCOLOR);
+
+ // A new device context uses MM_TEXT: one logical unit is one device dot, so the placement below is in device dots.
+ return SubmitPages(dc, device, printerName, jobName, labelWidthMm, labelHeightMm, pages, ct);
+ }
+ }
+
+ ///
+ /// The job proper: StartDoc before the first page, then StartPage / StretchDIBits / EndPage for each picture, then EndDoc. Any other way out
+ /// (failure, cancel, exception) aborts the job in the finally; the caller deletes the device context afterwards.
+ ///
+ private DriverPrintOutcome SubmitPages(SafeDcHandle dc, GdiDeviceGeometry device, string printerName, string jobName,
+ double labelWidthMm, double labelHeightMm, IEnumerable pages, CancellationToken ct)
+ {
+ int submitted = 0;
+ long bytes = 0;
+ bool started = false, ended = false;
+ byte[] bitmapInfo = GdiBitmapInfo.CreateGray8();
+ byte[]? paddedRows = null; // reused across pages of the same size, so a 500-label run allocates it once
+
+ // Disposed after the finally below, which runs the producer's own finally blocks (it may hold resources for the run).
+ using var source = pages.GetEnumerator();
+ try
+ {
+ while (true)
+ {
+ // Before asking for the next picture: drawing it is the expensive part, and it is not needed any more.
+ if (ct.IsCancellationRequested) return Outcome(PrintError.Create(PrintErrorKind.Cancelled), submitted, bytes);
+ if (!source.MoveNext()) break;
+ var picture = source.Current;
+ ValidatePicture(picture);
+ // And before handing it over: a cancel that arrived while it was being drawn stops it from reaching the driver.
+ if (ct.IsCancellationRequested) return Outcome(PrintError.Create(PrintErrorKind.Cancelled), submitted, bytes);
+
+ if (!started)
+ {
+ var doc = new GdiNative.DOCINFOW
+ {
+ cbSize = Marshal.SizeOf(),
+ lpszDocName = jobName,
+ lpszOutput = _outputFilePath,
+ };
+ if (GdiNative.StartDoc(dc, ref doc) <= 0)
+ return Outcome(GdiFailure(printerName, "StartDoc", Marshal.GetLastPInvokeError()), submitted, bytes);
+ started = true;
+ }
+
+ if (GdiNative.StartPage(dc) <= 0)
+ return Outcome(GdiFailure(printerName, "StartPage", Marshal.GetLastPInvokeError()), submitted, bytes);
+
+ var place = GdiPageLayout.Place(device, labelWidthMm, labelHeightMm, picture.WidthPx, picture.HeightPx);
+ byte[] rows = GdiBitmapInfo.DibRows(picture, ref paddedRows);
+ GdiBitmapInfo.SetSize(bitmapInfo, picture.WidthPx, picture.HeightPx);
+ int lines = GdiNative.StretchDIBits(dc, place.X, place.Y, place.Width, place.Height, 0, 0, picture.WidthPx, picture.HeightPx,
+ rows, bitmapInfo, GdiNative.DIB_RGB_COLORS, GdiNative.SRCCOPY);
+ if (lines == 0 || lines == GdiNative.GDI_ERROR)
+ return Outcome(GdiFailure(printerName, "StretchDIBits", Marshal.GetLastPInvokeError()), submitted, bytes);
+
+ if (GdiNative.EndPage(dc) <= 0)
+ return Outcome(GdiFailure(printerName, "EndPage", Marshal.GetLastPInvokeError()), submitted, bytes);
+ // Counted only now: from here on the page may print even if the job is aborted later.
+ submitted++;
+ bytes += (long)picture.WidthPx * picture.HeightPx;
+ }
+
+ // Nothing to print: no job was started, so nothing appears in the queue.
+ if (!started) return Outcome(null, 0, 0);
+
+ if (GdiNative.EndDoc(dc) <= 0)
+ {
+ // Whether the spooler prints the pages is unknown, so they stay counted as submitted (the safe side for serial numbers).
+ return Outcome(GdiFailure(printerName, "EndDoc", Marshal.GetLastPInvokeError()), submitted, bytes);
+ }
+ ended = true;
+ return Outcome(null, submitted, bytes);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // The page source honoured the same token (for example while drawing): a cancel, reported as a value like the checks above.
+ return Outcome(PrintError.Create(PrintErrorKind.Cancelled), submitted, bytes);
+ }
+ finally
+ {
+ // Every exit that is not a finished document discards it. The return value is not checked: there is nothing more to do if even
+ // AbortDoc fails, and the result already says the job did not finish.
+ if (started && !ended) _ = GdiNative.AbortDoc(dc);
+ }
+ }
+
+ ///
+ /// Builds the settings a probe or a job uses: the driver's current DEVMODE with a page of the label's size, or, when the driver does not
+ /// produce such a page, the driver's own paper. Whatever is returned carries one copy (): Rotula prints
+ /// copies as separate pages, and a driver default of several copies would multiply every label behind the serial-number count. The caller
+ /// owns (and disposes) the returned settings.
+ ///
+ /// The settings, or the error when the printer cannot be opened or the driver will not report its settings.
+ internal static (GdiDevMode? DevMode, PrintError? Error) NegotiatePage(string printerName, double labelWidthMm, double labelHeightMm)
+ {
+ if (!GdiNative.OpenPrinter(printerName, out var printer, IntPtr.Zero))
+ {
+ int code = Marshal.GetLastPInvokeError();
+ printer.Dispose();
+ // Same mapping as ZPL printing: not found, access denied, offline, or unknown with the code.
+ return (null, PrinterErrors.FromWin32(code, printerName));
+ }
+
+ using (printer)
+ {
+ // Learn's sequence: ask for the size (the driver's private data follows the public DEVMODE), allocate it, read the current settings.
+ int size = GdiNative.DocumentProperties(IntPtr.Zero, printer, printerName, IntPtr.Zero, IntPtr.Zero, 0);
+ if (size <= 0) return (null, GdiFailure(printerName, "DocumentProperties", Marshal.GetLastPInvokeError()));
+
+ GdiDevMode? current = GdiDevMode.Allocate(size);
+ try
+ {
+ if (GdiNative.DocumentProperties(IntPtr.Zero, printer, printerName, current.Pointer, IntPtr.Zero, GdiNative.DM_OUT_BUFFER) < 0)
+ return (null, GdiFailure(printerName, "DocumentProperties", Marshal.GetLastPInvokeError()));
+
+ var chosen = TryLabelSizedPage(printer, printerName, current, labelWidthMm, labelHeightMm)
+ ?? TryWithSettings(printer, printerName, current, d => d.TrySetSingleCopy());
+ if (chosen is null)
+ {
+ // The driver would not validate even "one copy": its own settings are used rather than not printing at all, but never with
+ // its copy count (WithoutExtraCopies below writes it directly).
+ chosen = current;
+ current = null;
+ }
+ return (WithoutExtraCopies(chosen), null);
+ }
+ finally
+ {
+ current?.Dispose();
+ }
+ }
+ }
+
+ ///
+ /// The last guard of the negotiation, applied to every settings it returns: when they hold a copy count other than 1, it is set to 1
+ /// (dmCopies = 1, DM_COPIES) directly, without asking the driver again. That matters for the last resort, the driver's settings as they
+ /// are, which the driver refused to change through DocumentProperties and which may carry its default of several copies; on the validated
+ /// settings it only acts if the driver changed the copy count back. Writing the field unvalidated is safe for placement: CreateDC validates
+ /// the DEVMODE itself (Learn: an unusable one is replaced by the driver's default) and the geometry is always read from the device context.
+ /// Settings whose public part ends before dmCopies have no copy count at all and are returned as they are.
+ ///
+ /// The settings to return; changed in place.
+ /// , now with at most one copy.
+ internal static GdiDevMode WithoutExtraCopies(GdiDevMode settings)
+ {
+ if (settings.Copies is { } copies && copies != 1) _ = settings.TrySetSingleCopy();
+ return settings;
+ }
+
+ ///
+ /// Settings whose page has the label's size, or null when the driver offers none. First a user-defined paper of the label's size
+ /// (DMPAPER_USER, what label printer drivers normally support); then, if the driver refuses that, one of the driver's own forms whose size is
+ /// the label's. The second step exists because drivers with a fixed form list ignore a user size without failing: "Microsoft Print to PDF"
+ /// answers a DMPAPER_USER request for 210 x 297 mm with Letter, yet prints a true A4 page when its A4 form is selected by index.
+ ///
+ private static GdiDevMode? TryLabelSizedPage(SafePrinterHandle printer, string printerName, GdiDevMode current, double labelWidthMm, double labelHeightMm)
+ {
+ int width = TenthsOfMm(labelWidthMm), length = TenthsOfMm(labelHeightMm);
+ var userSize = KeepIfLabelSized(TryWithSettings(printer, printerName, current, d => d.TryRequestPaper(width, length)),
+ printerName, width, length, labelWidthMm, labelHeightMm);
+ if (userSize is not null) return userSize;
+
+ if (FindLabelSizedForm(printerName, current, width, length) is not { } form) return null;
+ return KeepIfLabelSized(TryWithSettings(printer, printerName, current, d => d.TryRequestForm(form)),
+ printerName, width, length, labelWidthMm, labelHeightMm);
+ }
+
+ ///
+ /// Keeps validated settings only when the driver really produces a label-sized page with them, and disposes them otherwise. Two checks: the
+ /// paper size the driver wrote back must match (a different explicit size is a refusal; no explicit size means the driver describes the
+ /// paper by its form), and the physical page an information context reports must match too, because a driver can return success from
+ /// DocumentProperties and still print on its own paper.
+ ///
+ private static GdiDevMode? KeepIfLabelSized(GdiDevMode? candidate, string printerName, int widthTenthsMm, int lengthTenthsMm,
+ double labelWidthMm, double labelHeightMm)
+ {
+ if (candidate is null) return null;
+ bool accepted = (!candidate.ReportsPaperSize || candidate.PaperIs(widthTenthsMm, lengthTenthsMm))
+ && TryMeasurePage(printerName, candidate, out var device)
+ && GdiPageLayout.PageMatchesLabel(device, labelWidthMm, labelHeightMm);
+ if (accepted) return candidate;
+ candidate.Dispose();
+ return null;
+ }
+
+ ///
+ /// The index of the driver's own form whose portrait size is the label's within 1 mm (the closest one when several are), or null. Reads the
+ /// form list with DeviceCapabilities DC_PAPERS and DC_PAPERSIZE, exactly as printer detection does ():
+ /// sizes are POINTs in tenths of a millimetre "as if the paper were in DMORIENT_PORTRAIT" (Learn), which is the orientation requested.
+ /// DMPAPER_USER itself is skipped: it is the user-size request that was just refused. The port is passed as null: Windows looks the printer
+ /// up by name, and this was checked against "Microsoft Print to PDF".
+ ///
+ private static short? FindLabelSizedForm(string printerName, GdiDevMode current, int widthTenthsMm, int lengthTenthsMm)
+ {
+ const ushort DC_PAPERS = 2, DC_PAPERSIZE = 3;
+ const int MaxForms = 4096; // same sanity cap as printer detection: a larger count is a broken driver answer
+ int count = NativeMethods.DeviceCapabilities(printerName, null, DC_PAPERS, IntPtr.Zero, current.Pointer);
+ if (count <= 0 || count > MaxForms) return null;
+
+ IntPtr papers = Marshal.AllocHGlobal(count * 2), sizes = Marshal.AllocHGlobal(count * 8);
+ try
+ {
+ if (NativeMethods.DeviceCapabilities(printerName, null, DC_PAPERS, papers, current.Pointer) != count) return null;
+ if (NativeMethods.DeviceCapabilities(printerName, null, DC_PAPERSIZE, sizes, current.Pointer) != count) return null;
+ short? best = null;
+ int bestDistance = int.MaxValue;
+ for (int i = 0; i < count; i++)
+ {
+ int index = (ushort)Marshal.ReadInt16(papers, i * 2);
+ if (index <= 0 || index > short.MaxValue || index == GdiNative.DMPAPER_USER) continue;
+ int dx = Math.Abs(Marshal.ReadInt32(sizes, i * 8) - widthTenthsMm), dy = Math.Abs(Marshal.ReadInt32(sizes, i * 8 + 4) - lengthTenthsMm);
+ if (dx > GdiDevMode.PaperToleranceTenths || dy > GdiDevMode.PaperToleranceTenths || dx + dy >= bestDistance) continue;
+ best = (short)index;
+ bestDistance = dx + dy;
+ }
+ return best;
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(papers);
+ Marshal.FreeHGlobal(sizes);
+ }
+ }
+
+ ///
+ /// Copies , applies and has the driver validate the result
+ /// (DocumentProperties with DM_IN_BUFFER | DM_OUT_BUFFER on the same buffer). Null when the change cannot be made or the driver rejects it.
+ ///
+ private static GdiDevMode? TryWithSettings(SafePrinterHandle printer, string printerName, GdiDevMode current, Func change)
+ {
+ GdiDevMode? copy = current.Clone();
+ try
+ {
+ if (!change(copy)) return null;
+ if (GdiNative.DocumentProperties(IntPtr.Zero, printer, printerName, copy.Pointer, copy.Pointer,
+ GdiNative.DM_IN_BUFFER | GdiNative.DM_OUT_BUFFER) < 0)
+ return null;
+ var validated = copy;
+ copy = null;
+ return validated;
+ }
+ finally
+ {
+ copy?.Dispose();
+ }
+ }
+
+ /// Measures the page produces on an information context (nothing is printed). False when it cannot be measured.
+ private static bool TryMeasurePage(string printerName, GdiDevMode devMode, out GdiDeviceGeometry device)
+ {
+ using var ic = GdiNative.CreateIC(SpoolerDriver, printerName, null, devMode.Pointer);
+ device = ic.IsInvalid ? default : GdiDeviceGeometry.Read(ic);
+ return !ic.IsInvalid && device.IsUsable;
+ }
+
+ ///
+ /// Maps the Win32 error of a failed driver call: access denied and a vanished printer keep their own kinds, a dialog the person closed is a
+ /// cancel, anything else is (printer, detail).
+ ///
+ internal static PrintError GdiFailure(string printerName, string call, int code) => code switch
+ {
+ PrinterErrors.ErrorAccessDenied => PrintError.Create(PrintErrorKind.AccessDenied, printerName),
+ ErrorCancelled => PrintError.Create(PrintErrorKind.Cancelled),
+ PrinterErrors.ErrorInvalidPrinterName or PrinterErrors.ErrorPrinterNotFound => PrintError.Create(PrintErrorKind.PrinterNotFound, printerName),
+ _ => PrintError.Create(PrintErrorKind.DriverRefused, printerName, Detail(call, code)),
+ };
+
+ ///
+ /// The technical detail of a refusal: the call and the Win32 error number, plus Windows' own description of it (Windows words it in the
+ /// user's language). GDI does not always set an error; then the number is 0 and no description is added, because Windows would call 0
+ /// "The operation completed successfully".
+ ///
+ internal static string Detail(string call, int code)
+ {
+ string number = code.ToString(CultureInfo.InvariantCulture);
+ return code == 0 ? $"{call}, Win32 error {number}" : $"{call}, Win32 error {number}: {new Win32Exception(code).Message}";
+ }
+
+ /// The outcome of a job: success when is null, with the pages and picture bytes the driver accepted.
+ private static DriverPrintOutcome Outcome(PrintError? error, int pages, long bytes)
+ {
+ int sent = (int)Math.Min(bytes, int.MaxValue);
+ var result = error is null ? PrintResult.Ok(sent) : PrintResult.Fail(error, sent);
+ return new DriverPrintOutcome(result.WithPages(pages), pages);
+ }
+
+ /// Millimetres to the DEVMODE's tenths of a millimetre, saturated so a huge value cannot wrap (TryRequestPaper rejects it).
+ private static int TenthsOfMm(double mm) => (int)Math.Round(Math.Min(mm * 10.0, int.MaxValue), MidpointRounding.AwayFromZero);
+
+ private static void ValidateLabelSize(double widthMm, double heightMm)
+ {
+ if (!double.IsFinite(widthMm) || widthMm <= 0) throw new ArgumentOutOfRangeException(nameof(widthMm), widthMm, "The label width must be a positive number of millimetres.");
+ if (!double.IsFinite(heightMm) || heightMm <= 0) throw new ArgumentOutOfRangeException(nameof(heightMm), heightMm, "The label height must be a positive number of millimetres.");
+ }
+
+ /// A malformed picture is a programming error of the page source, not a printer problem: it throws (after the job is aborted).
+ private static void ValidatePicture(DevicePicture? picture)
+ {
+ if (picture is null) throw new ArgumentException("The page source yielded a null picture.", "pages");
+ if (picture.WidthPx <= 0 || picture.HeightPx <= 0 || picture.Gray8 is null || (long)picture.WidthPx * picture.HeightPx > picture.Gray8.Length)
+ throw new ArgumentException(
+ $"The picture {picture.WidthPx} x {picture.HeightPx} does not match its {picture.Gray8?.Length ?? 0} grey bytes.", "pages");
+ }
+}
+
+/// What a printer device or information context reports about its page, in device dots.
+/// LOGPIXELSX: horizontal dots per inch.
+/// LOGPIXELSY: vertical dots per inch.
+/// HORZRES: width of the printable area.
+/// VERTRES: height of the printable area.
+/// PHYSICALWIDTH: width of the physical page (never smaller than the printable area).
+/// PHYSICALHEIGHT: height of the physical page.
+/// PHYSICALOFFSETX: from the left edge of the physical page to the printable area.
+/// PHYSICALOFFSETY: from the top edge of the physical page to the printable area.
+internal readonly record struct GdiDeviceGeometry(int DpiX, int DpiY, int HorzRes, int VertRes, int PhysicalWidth, int PhysicalHeight, int OffsetX, int OffsetY)
+{
+ /// True when the context reported a resolution and a printable area; without them nothing can be placed.
+ public bool IsUsable => DpiX > 0 && DpiY > 0 && HorzRes > 0 && VertRes > 0;
+
+ /// Reads every value from a device or information context.
+ public static GdiDeviceGeometry Read(SafeDcHandle dc) => new(
+ GdiNative.GetDeviceCaps(dc, GdiNative.LOGPIXELSX), GdiNative.GetDeviceCaps(dc, GdiNative.LOGPIXELSY),
+ GdiNative.GetDeviceCaps(dc, GdiNative.HORZRES), GdiNative.GetDeviceCaps(dc, GdiNative.VERTRES),
+ GdiNative.GetDeviceCaps(dc, GdiNative.PHYSICALWIDTH), GdiNative.GetDeviceCaps(dc, GdiNative.PHYSICALHEIGHT),
+ GdiNative.GetDeviceCaps(dc, GdiNative.PHYSICALOFFSETX), GdiNative.GetDeviceCaps(dc, GdiNative.PHYSICALOFFSETY));
+}
+
+/// Where a picture goes on the page, in GDI device coordinates (origin at the printable area's top-left corner).
+/// Left edge; negative when the label starts in the unprintable margin.
+/// Top edge; negative when the label starts in the unprintable margin.
+/// Width of the destination rectangle in device dots.
+/// Height of the destination rectangle in device dots.
+internal readonly record struct GdiPagePlacement(int X, int Y, int Width, int Height);
+
+///
+/// The page geometry of picture printing, kept free of Win32 so it can be tested without a printer.
+///
+/// Origin. On a printer device context, GDI coordinate (0,0) is the top-left corner of the printable area, not of the paper: HORZRES/VERTRES
+/// measure the printable area, PHYSICALOFFSETX/Y measure how far it starts from the paper's edges (Learn, GetDeviceCaps), and .NET's
+/// PrintDocument.OriginAtMargins = false, which passes the device context through untranslated, is documented as "the graphics origin is
+/// at the top-left corner of the printable page". To put the label's corner on the paper's corner the picture is therefore drawn at minus the
+/// offset; the part under the unprintable margin is clipped by the device, and everything else lands where it was designed.
+///
+///
+internal static class GdiPageLayout
+{
+ ///
+ /// How far the page may differ from the label and still be "the label's size". Drivers round paper sizes to whole dots or whole millimetres;
+ /// the same 1 mm the pre-flight allows for a label against the printable area.
+ ///
+ internal const double PageToleranceMm = 1.0;
+
+ ///
+ /// How many picture pixels the picture may differ from the label's size in millimetres and still be treated as drawn for this device. The
+ /// picture's size is the label rounded twice (to design dots, then to device dots), at most about 1.25 pixels off; beyond 2 pixels the
+ /// picture was drawn for another resolution.
+ ///
+ internal const int SnapTolerancePixels = 2;
+
+ /// True when the device's physical page has the label's size on both axes, within .
+ internal static bool PageMatchesLabel(GdiDeviceGeometry device, double labelWidthMm, double labelHeightMm)
+ {
+ if (device.DpiX <= 0 || device.DpiY <= 0) return false;
+ double wantWidth = labelWidthMm / 25.4 * device.DpiX, wantHeight = labelHeightMm / 25.4 * device.DpiY;
+ double toleranceX = PageToleranceMm / 25.4 * device.DpiX, toleranceY = PageToleranceMm / 25.4 * device.DpiY;
+ return Math.Abs(device.PhysicalWidth - wantWidth) <= toleranceX && Math.Abs(device.PhysicalHeight - wantHeight) <= toleranceY;
+ }
+
+ ///
+ /// What changed between the probe the pre-flight judged () and the device context a job is about to print on, or
+ /// null when they agree. The answer is the technical detail of a (English, invariant culture,
+ /// as every refusal detail of this class), so it names the values in plain words.
+ ///
+ /// Resolution, always: both axes must be exactly the probed ones. The picture is drawn at the coarser probed axis
+ /// (), and keeps whole dots only when the device resolution is the
+ /// one it was drawn for; on any other it stretches the picture to millimetres and bars stop landing on whole dots.
+ /// Page, only when the probe said the label size was accepted: the device's physical page must be the probed page within
+ /// and still be the label's size (, the same rule uses), so the
+ /// label is drawn from the paper's corner as judged. Both are needed: a page within 1 mm of the probe can be up to 2 mm off the label, and
+ /// would then centre it without a word. When the probe said the size was refused, the label is centred on whatever paper
+ /// the driver uses and the pre-flight already warned about that, so the page is not compared.
+ ///
+ ///
+ /// What the job's device context reports.
+ /// The media the pre-flight judged.
+ /// Label width in millimetres.
+ /// Label height in millimetres.
+ internal static string? DescribeChange(GdiDeviceGeometry device, DriverMedia probed, double labelWidthMm, double labelHeightMm)
+ {
+ if (device.DpiX != probed.DpiX || device.DpiY != probed.DpiY)
+ {
+ return string.Create(CultureInfo.InvariantCulture,
+ $"the resolution changed after the check: {probed.DpiX} x {probed.DpiY} dpi then, {device.DpiX} x {device.DpiY} dpi now");
+ }
+ if (!probed.CustomSizeAccepted) return null;
+
+ // Same resolution from here on, so the probed and the device dots convert to millimetres alike.
+ double toleranceX = PageToleranceMm / 25.4 * device.DpiX, toleranceY = PageToleranceMm / 25.4 * device.DpiY;
+ bool samePage = Math.Abs(device.PhysicalWidth - probed.PhysicalWidthDots) <= toleranceX
+ && Math.Abs(device.PhysicalHeight - probed.PhysicalHeightDots) <= toleranceY;
+ if (samePage && PageMatchesLabel(device, labelWidthMm, labelHeightMm)) return null;
+
+ string now = PageMm(device.PhysicalWidth, device.PhysicalHeight, device.DpiX, device.DpiY);
+ return samePage
+ ? string.Create(CultureInfo.InvariantCulture,
+ $"the page is no longer the label's size: label {Mm(labelWidthMm)} x {Mm(labelHeightMm)} mm, page {now} mm")
+ : string.Create(CultureInfo.InvariantCulture,
+ $"the page changed after the check: {PageMm(probed.PhysicalWidthDots, probed.PhysicalHeightDots, probed.DpiX, probed.DpiY)} mm then, {now} mm now");
+ }
+
+ /// A page size in dots as W x H millimetres with at most one decimal, invariant culture.
+ private static string PageMm(int widthDots, int heightDots, int dpiX, int dpiY) =>
+ Mm(widthDots * 25.4 / dpiX) + " x " + Mm(heightDots * 25.4 / dpiY);
+
+ /// Millimetres with at most one decimal, invariant culture.
+ private static string Mm(double value) => value.ToString("0.#", CultureInfo.InvariantCulture);
+
+ ///
+ /// The destination rectangle of a picture: the label's size on the device, its corner on the paper's corner when the page is the label,
+ /// otherwise centred in the printable area (a label larger than that area overflows evenly on both sides; the pre-flight reports it).
+ ///
+ internal static GdiPagePlacement Place(GdiDeviceGeometry device, double labelWidthMm, double labelHeightMm, int pictureWidthPx, int pictureHeightPx)
+ {
+ // The picture is drawn at the coarser of the two resolutions (DevicePictureRenderer.RenderDpi), so that is the resolution to compare with.
+ int renderDpi = Math.Min(device.DpiX, device.DpiY);
+ int width = DestinationLength(pictureWidthPx, labelWidthMm, device.DpiX, renderDpi);
+ int height = DestinationLength(pictureHeightPx, labelHeightMm, device.DpiY, renderDpi);
+ if (PageMatchesLabel(device, labelWidthMm, labelHeightMm)) return new GdiPagePlacement(-device.OffsetX, -device.OffsetY, width, height);
+ return new GdiPagePlacement((device.HorzRes - width) / 2, (device.VertRes - height) / 2, width, height);
+ }
+
+ ///
+ /// The length of the destination rectangle on one axis, in device dots.
+ ///
+ /// Deliberately not always the label's millimetres converted to dots: the picture's own size can differ from that by a dot (it is rounded
+ /// twice), and stretching, say, 1181 pixels onto 1182 dots makes the device repeat one column somewhere, possibly inside a barcode, so that one
+ /// module comes out a dot wider. When the device resolution is a whole multiple of the picture's (1:1, or 600 on a 300 dpi picture) and the
+ /// picture is within of the label's size, it is drawn at exactly that whole factor: the physical size is off by
+ /// at most a couple of dots and every module keeps its width. A picture clearly drawn for another resolution, or an axis whose ratio is not
+ /// whole (already a pre-flight warning), is stretched to the label's size in millimetres.
+ ///
+ ///
+ internal static int DestinationLength(int picturePixels, double labelMm, int deviceDpi, int renderDpi)
+ {
+ int fromMillimetres = Math.Max(1, (int)Math.Round(labelMm / 25.4 * deviceDpi, MidpointRounding.AwayFromZero));
+ if (renderDpi > 0 && deviceDpi % renderDpi == 0)
+ {
+ int factor = deviceDpi / renderDpi;
+ long whole = (long)picturePixels * factor;
+ if (Math.Abs(whole - fromMillimetres) <= (long)SnapTolerancePixels * factor) return (int)whole;
+ }
+ return fromMillimetres;
+ }
+}
+
+///
+/// The BITMAPINFO and pixel rows reads for a : an 8-bit top-down DIB whose
+/// 256-entry palette maps index i to grey i, so the picture's bytes are used as palette indexes unchanged (0 black, 255 white).
+///
+internal static class GdiBitmapInfo
+{
+ /// Size of BITMAPINFOHEADER.
+ internal const int HeaderSize = 40;
+
+ /// Palette entries of an 8-bit DIB.
+ internal const int PaletteEntries = 256;
+
+ /// A BITMAPINFO (header plus grey palette) for an 8-bit picture; the size is filled in per page with .
+ internal static byte[] CreateGray8()
+ {
+ var info = new byte[HeaderSize + PaletteEntries * 4];
+ var header = new GdiNative.BITMAPINFOHEADER
+ {
+ biSize = HeaderSize,
+ biPlanes = 1,
+ biBitCount = 8,
+ biCompression = GdiNative.BI_RGB,
+ biSizeImage = 0, // allowed for BI_RGB (Learn); GDI works it out from the size
+ biClrUsed = PaletteEntries,
+ };
+ MemoryMarshal.Write(info.AsSpan(0, HeaderSize), in header);
+ for (int i = 0; i < PaletteEntries; i++)
+ {
+ // RGBQUAD order is blue, green, red, reserved.
+ int at = HeaderSize + i * 4;
+ info[at] = info[at + 1] = info[at + 2] = (byte)i;
+ info[at + 3] = 0;
+ }
+ return info;
+ }
+
+ /// Writes the picture's size into . The height is negative: DevicePicture rows run from the top down.
+ internal static void SetSize(byte[] info, int widthPx, int heightPx)
+ {
+ var header = MemoryMarshal.Read(info);
+ header.biWidth = widthPx;
+ header.biHeight = -heightPx;
+ MemoryMarshal.Write(info.AsSpan(0, HeaderSize), in header);
+ }
+
+ ///
+ /// The picture's rows as a DIB needs them: each row padded to a multiple of 4 bytes (Learn: the stride is the width rounded up to a DWORD).
+ /// A width that already is a multiple of 4 is passed through without copying; otherwise the rows are copied into ,
+ /// which is reused for the next page of the same size. Its padding bytes are never written, so they stay zero.
+ ///
+ internal static byte[] DibRows(DevicePicture picture, ref byte[]? scratch)
+ {
+ int width = picture.WidthPx, height = picture.HeightPx;
+ long stride = ((long)width + 3) & ~3L;
+ if (stride == width) return picture.Gray8;
+ long size = stride * height;
+ if (scratch is null || scratch.LongLength != size) scratch = new byte[size];
+ for (int y = 0; y < height; y++) Buffer.BlockCopy(picture.Gray8, y * width, scratch, (int)(y * stride), width);
+ return scratch;
+ }
+}
+
+///
+/// An unmanaged copy of a driver's DEVMODEW: the public part followed by the driver's private data, allocated at the size DocumentProperties
+/// reports (Learn: never sizeof(DEVMODE), the private part varies per driver). Fields are read and written at their documented offsets,
+/// and only when they lie inside both the allocation and the public part the driver declared (dmSize). Always disposed in a finally.
+///
+internal sealed class GdiDevMode : IDisposable
+{
+ // DEVMODEW offsets (Learn, DEVMODEW): WCHAR dmDeviceName[32] (64 bytes), dmSpecVersion, dmDriverVersion, dmSize, dmDriverExtra (WORDs),
+ // dmFields (DWORD), then the printer struct of the union: dmOrientation, dmPaperSize, dmPaperLength, dmPaperWidth, dmScale, dmCopies (shorts).
+ /// dmSize: size of the public part.
+ internal const int OffSize = 68;
+ /// dmFields: which members are valid.
+ internal const int OffFields = 72;
+ /// dmOrientation.
+ internal const int OffOrientation = 76;
+ /// dmPaperSize.
+ internal const int OffPaperSize = 78;
+ /// dmPaperLength, tenths of a millimetre.
+ internal const int OffPaperLength = 80;
+ /// dmPaperWidth, tenths of a millimetre.
+ internal const int OffPaperWidth = 82;
+ /// dmCopies.
+ internal const int OffCopies = 86;
+ /// dmFormName, WCHAR[32], after dmColor, dmDuplex, dmYResolution, dmTTOption and dmCollate.
+ internal const int OffFormName = 102;
+
+ /// A paper size within 1 mm (10 tenths) of the label counts as the label's size, for a driver's answer and for a form alike.
+ internal const int PaperToleranceTenths = 10;
+
+ private IntPtr _buffer;
+
+ private GdiDevMode(int length)
+ {
+ Length = length;
+ _buffer = Marshal.AllocHGlobal(length);
+ // Zeroed: a driver that fills fewer bytes than it reported must not leave garbage where fields are read.
+ Marshal.Copy(new byte[length], 0, _buffer, length);
+ }
+
+ /// Size of the allocation in bytes.
+ internal int Length { get; }
+
+ /// The buffer, for DocumentProperties, CreateDC and CreateIC.
+ internal IntPtr Pointer => _buffer != IntPtr.Zero ? _buffer : throw new ObjectDisposedException(nameof(GdiDevMode));
+
+ /// A zeroed buffer of bytes.
+ internal static GdiDevMode Allocate(int length)
+ {
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(length);
+ return new GdiDevMode(length);
+ }
+
+ /// An independent copy, so a rejected change never spoils the driver's current settings.
+ internal GdiDevMode Clone()
+ {
+ var bytes = new byte[Length];
+ Marshal.Copy(Pointer, bytes, 0, Length);
+ var copy = new GdiDevMode(Length);
+ Marshal.Copy(bytes, 0, copy._buffer, Length);
+ return copy;
+ }
+
+ ///
+ /// Asks for a user-defined paper of x in portrait, one copy: dmPaperSize
+ /// DMPAPER_USER, dmPaperWidth/Length in tenths of a millimetre, and the matching dmFields flags. Portrait because the picture is already
+ /// turned the way it prints; a landscape default would swap the page's sides. False, with nothing changed, when the size does not fit the
+ /// DEVMODE's short fields (more than 3276.7 mm) or the driver's public part is too short to hold them.
+ ///
+ internal bool TryRequestPaper(int widthTenthsMm, int lengthTenthsMm)
+ {
+ if (widthTenthsMm is < 1 or > short.MaxValue || lengthTenthsMm is < 1 or > short.MaxValue) return false;
+ if (!Holds(OffCopies, 2)) return false;
+ WriteInt16(OffOrientation, GdiNative.DMORIENT_PORTRAIT);
+ WriteInt16(OffPaperSize, GdiNative.DMPAPER_USER);
+ WriteInt16(OffPaperLength, (short)lengthTenthsMm);
+ WriteInt16(OffPaperWidth, (short)widthTenthsMm);
+ WriteInt16(OffCopies, 1);
+ WriteUInt32(OffFields, (ReadUInt32(OffFields)
+ | GdiNative.DM_ORIENTATION | GdiNative.DM_PAPERSIZE | GdiNative.DM_PAPERLENGTH | GdiNative.DM_PAPERWIDTH | GdiNative.DM_COPIES)
+ & ~GdiNative.DM_FORMNAME);
+ ClearFormName();
+ return true;
+ }
+
+ ///
+ /// Asks for the driver's own form (a DMPAPER value from its DC_PAPERS list) in portrait, one copy. The width and
+ /// length flags are cleared because Learn says those members override dmPaperSize, and the current settings may still carry the old paper's
+ /// values. False, with nothing changed, when the index is not positive or the public part is too short.
+ ///
+ internal bool TryRequestForm(short paperIndex)
+ {
+ if (paperIndex <= 0 || !Holds(OffCopies, 2)) return false;
+ WriteInt16(OffOrientation, GdiNative.DMORIENT_PORTRAIT);
+ WriteInt16(OffPaperSize, paperIndex);
+ WriteInt16(OffCopies, 1);
+ WriteUInt32(OffFields, (ReadUInt32(OffFields) | GdiNative.DM_ORIENTATION | GdiNative.DM_PAPERSIZE | GdiNative.DM_COPIES)
+ & ~(GdiNative.DM_PAPERWIDTH | GdiNative.DM_PAPERLENGTH | GdiNative.DM_FORMNAME));
+ ClearFormName();
+ return true;
+ }
+
+ /// Sets one copy (dmCopies = 1, DM_COPIES) and leaves the paper alone. False when the public part is too short.
+ internal bool TrySetSingleCopy()
+ {
+ if (!Holds(OffCopies, 2)) return false;
+ WriteInt16(OffCopies, 1);
+ WriteUInt32(OffFields, ReadUInt32(OffFields) | GdiNative.DM_COPIES);
+ return true;
+ }
+
+ ///
+ /// dmCopies, or null when the driver's public part ends before it. Read whatever dmFields says: a stray count is still forced to 1 by
+ /// , and writing 1 costs nothing.
+ ///
+ internal short? Copies => Holds(OffCopies, 2) ? ReadInt16(OffCopies) : null;
+
+ /// True when the settings carry an explicit paper width and length (both flagged in dmFields and inside the public part).
+ internal bool ReportsPaperSize =>
+ Holds(OffPaperWidth, 2)
+ && (ReadUInt32(OffFields) & (GdiNative.DM_PAPERWIDTH | GdiNative.DM_PAPERLENGTH)) == (GdiNative.DM_PAPERWIDTH | GdiNative.DM_PAPERLENGTH);
+
+ /// True when the explicit paper size equals the given one within 1 mm.
+ internal bool PaperIs(int widthTenthsMm, int lengthTenthsMm) =>
+ ReportsPaperSize
+ && Math.Abs(ReadInt16(OffPaperWidth) - widthTenthsMm) <= PaperToleranceTenths
+ && Math.Abs(ReadInt16(OffPaperLength) - lengthTenthsMm) <= PaperToleranceTenths;
+
+ /// Reads a WORD.
+ internal ushort ReadUInt16(int offset) => (ushort)Marshal.ReadInt16(At(offset, 2));
+ /// Reads a short.
+ internal short ReadInt16(int offset) => Marshal.ReadInt16(At(offset, 2));
+ /// Reads a DWORD.
+ internal uint ReadUInt32(int offset) => (uint)Marshal.ReadInt32(At(offset, 4));
+ /// Writes a WORD.
+ internal void WriteUInt16(int offset, ushort value) => Marshal.WriteInt16(At(offset, 2), unchecked((short)value));
+ /// Writes a short.
+ internal void WriteInt16(int offset, short value) => Marshal.WriteInt16(At(offset, 2), value);
+ /// Writes a DWORD.
+ internal void WriteUInt32(int offset, uint value) => Marshal.WriteInt32(At(offset, 4), unchecked((int)value));
+
+ ///
+ public void Dispose()
+ {
+ if (_buffer == IntPtr.Zero) return;
+ Marshal.FreeHGlobal(_buffer);
+ _buffer = IntPtr.Zero;
+ }
+
+ ///
+ /// Empties dmFormName (its DM_FORMNAME flag is cleared by the callers). The current settings name their paper ("Letter"), and a driver that
+ /// resolves the paper by form name before dmPaperSize would otherwise let that stale name win over the request.
+ ///
+ private void ClearFormName()
+ {
+ if (Holds(OffFormName, 2)) WriteInt16(OffFormName, 0);
+ }
+
+ /// True when a field lies inside the allocation and inside the public part the driver declared in dmSize.
+ private bool Holds(int offset, int length) =>
+ OffSize + 2 <= Length && offset + length <= Length && offset + length <= ReadUInt16(OffSize);
+
+ /// The address of a field, refusing any access outside the allocation (a programming error, never a driver answer).
+ private IntPtr At(int offset, int length)
+ {
+ if (offset < 0 || offset + length > Length)
+ throw new ArgumentOutOfRangeException(nameof(offset), offset, "The field lies outside the DEVMODE buffer.");
+ return Pointer + offset;
+ }
+}
diff --git a/src/Rotula.Printing/Driver/GdiNative.cs b/src/Rotula.Printing/Driver/GdiNative.cs
new file mode 100644
index 0000000..28ee394
--- /dev/null
+++ b/src/Rotula.Printing/Driver/GdiNative.cs
@@ -0,0 +1,236 @@
+using System.Runtime.InteropServices;
+using Microsoft.Win32.SafeHandles;
+
+namespace Rotula.Printing;
+
+///
+/// The Win32 calls the Windows-driver (picture) print path needs: winspool to read and adjust the driver's settings (DEVMODE), gdi32 to measure
+/// the page and to draw pictures into a print job. Every signature and constant was checked against Microsoft Learn (the numeric values of the
+/// constants come from wingdi.h / winerror.h, which Learn does not list).
+///
+/// (used by ZPL printing and printer detection) already declares OpenPrinter, DocumentProperties, CreateIC,
+/// GetDeviceCaps and DeleteDC with raw handles. They are declared again here on purpose: this path owns a printer handle and
+/// up to two device contexts across a whole job, possibly hundreds of pages long, and an exception thrown by the caller's lazy page source in the
+/// middle of it must never leak them. s make the release unconditional, and the ZPL path stays byte for byte unchanged.
+///
+///
+/// DllImport with explicit ...W entry points and , as in :
+/// LibraryImport would need AllowUnsafeBlocks in the project, which this library does not enable.
+///
+///
+internal static class GdiNative
+{
+ // GetDeviceCaps indexes (wingdi.h).
+ /// Width of the printable area in device dots.
+ internal const int HORZRES = 8;
+ /// Height of the printable area in device dots.
+ internal const int VERTRES = 10;
+ /// Horizontal device dots per inch.
+ internal const int LOGPIXELSX = 88;
+ /// Vertical device dots per inch.
+ internal const int LOGPIXELSY = 90;
+ /// Width of the physical page in device dots.
+ internal const int PHYSICALWIDTH = 110;
+ /// Height of the physical page in device dots.
+ internal const int PHYSICALHEIGHT = 111;
+ /// Distance from the left edge of the physical page to the printable area, in device dots.
+ internal const int PHYSICALOFFSETX = 112;
+ /// Distance from the top edge of the physical page to the printable area, in device dots.
+ internal const int PHYSICALOFFSETY = 113;
+
+ // DocumentProperties modes (winspool.h).
+ /// DM_OUT_BUFFER (DM_COPY): write the driver's current settings into the output DEVMODE.
+ internal const int DM_OUT_BUFFER = 2;
+ /// DM_IN_BUFFER (DM_MODIFY): merge the fields flagged in the input DEVMODE into the driver's settings.
+ internal const int DM_IN_BUFFER = 8;
+
+ // DEVMODE dmFields flags and values (wingdi.h).
+ /// dmOrientation is valid.
+ internal const uint DM_ORIENTATION = 0x00000001;
+ /// dmPaperSize is valid.
+ internal const uint DM_PAPERSIZE = 0x00000002;
+ /// dmPaperLength is valid.
+ internal const uint DM_PAPERLENGTH = 0x00000004;
+ /// dmPaperWidth is valid.
+ internal const uint DM_PAPERWIDTH = 0x00000008;
+ /// dmCopies is valid.
+ internal const uint DM_COPIES = 0x00000100;
+ /// dmFormName is valid.
+ internal const uint DM_FORMNAME = 0x00010000;
+ /// Portrait orientation: the page is as wide as dmPaperWidth.
+ internal const short DMORIENT_PORTRAIT = 1;
+ /// A user-defined paper size whose dimensions are dmPaperWidth and dmPaperLength.
+ internal const short DMPAPER_USER = 256;
+
+ // Drawing (wingdi.h).
+ /// Stretch mode that deletes or repeats whole rows and columns, never averaging pixels (STRETCH_DELETESCANS).
+ internal const int COLORONCOLOR = 3;
+ /// The colour table holds literal RGB values.
+ internal const uint DIB_RGB_COLORS = 0;
+ /// Raster operation: copy the source onto the destination.
+ internal const uint SRCCOPY = 0x00CC0020;
+ /// Uncompressed DIB.
+ internal const uint BI_RGB = 0;
+ /// GDI_ERROR (0xFFFFFFFF) as a signed int, a failure value of StretchDIBits.
+ internal const int GDI_ERROR = -1;
+
+ ///
+ /// DOCINFOW: the job name and the optional output file passed to . Layout per Learn: int cbSize, three
+ /// LPCWSTR and a DWORD fwType, sequential with natural alignment.
+ ///
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ internal struct DOCINFOW
+ {
+ /// Size of this structure in bytes.
+ public int cbSize;
+ /// Name of the job in the Windows print queue.
+ [MarshalAs(UnmanagedType.LPWStr)] public string? lpszDocName;
+ /// File the output goes to instead of the printer; null in production. Tests use it so "Microsoft Print to PDF" asks nothing.
+ [MarshalAs(UnmanagedType.LPWStr)] public string? lpszOutput;
+ /// Spool data type; null lets the spooler choose (Learn: the requested type might be ignored anyway).
+ [MarshalAs(UnmanagedType.LPWStr)] public string? lpszDatatype;
+ /// Must be zero (no application banding, no destination-reading raster operations).
+ public uint fwType;
+ }
+
+ ///
+ /// BITMAPINFOHEADER, 40 bytes, blittable. It is written in front of the 256-entry grey palette to form the BITMAPINFO that
+ /// reads (see ).
+ ///
+ [StructLayout(LayoutKind.Sequential)]
+ internal struct BITMAPINFOHEADER
+ {
+ /// Size of this header (40), not counting the colour table.
+ public uint biSize;
+ /// Width in pixels.
+ public int biWidth;
+ /// Height in pixels; negative means the rows are stored from the top down.
+ public int biHeight;
+ /// Always 1.
+ public ushort biPlanes;
+ /// Bits per pixel (8: one palette index per pixel).
+ public ushort biBitCount;
+ /// BI_RGB (uncompressed).
+ public uint biCompression;
+ /// Image size in bytes; Learn allows 0 for BI_RGB.
+ public uint biSizeImage;
+ /// Horizontal pixels per metre (informative, unused by StretchDIBits).
+ public int biXPelsPerMeter;
+ /// Vertical pixels per metre (informative, unused by StretchDIBits).
+ public int biYPelsPerMeter;
+ /// Number of palette entries that follow the header.
+ public uint biClrUsed;
+ /// 0: all colours are important.
+ public uint biClrImportant;
+ }
+
+ ///
+ /// Opens the printer so can read and validate its settings. Failing here is how a missing printer
+ /// (ERROR_INVALID_PRINTER_NAME) or a printer the user may not use (ERROR_ACCESS_DENIED) is told apart from a driver that refuses a page.
+ ///
+ [DllImport("winspool.drv", EntryPoint = "OpenPrinterW", SetLastError = true, CharSet = CharSet.Unicode)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool OpenPrinter(string pPrinterName, out SafePrinterHandle phPrinter, IntPtr pDefault);
+
+ /// Closes a handle from ; called only by .
+ [DllImport("winspool.drv", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool ClosePrinter(IntPtr hPrinter);
+
+ ///
+ /// Reads and validates the driver's DEVMODE without printing: mode 0 returns the buffer size (public part plus the driver's private data),
+ /// copies the current settings, and | with the same buffer
+ /// as input and output asks the driver to accept a changed paper size and returns what it really settled on (Learn, "To make changes to print
+ /// settings that are local to an application"). Returns less than zero on failure.
+ ///
+ [DllImport("winspool.drv", EntryPoint = "DocumentPropertiesW", SetLastError = true, CharSet = CharSet.Unicode)]
+ internal static extern int DocumentProperties(IntPtr hWnd, SafePrinterHandle hPrinter, string pDeviceName, IntPtr pDevModeOutput, IntPtr pDevModeInput, int fMode);
+
+ ///
+ /// Creates the printer device context the job draws into, from the DEVMODE the driver validated. Learn: GDI ignores the driver name for
+ /// printers; "WINSPOOL" is passed as Windows itself and do. The port is ignored and must be null. Null on failure.
+ ///
+ [DllImport("gdi32.dll", EntryPoint = "CreateDCW", SetLastError = true, CharSet = CharSet.Unicode)]
+ internal static extern SafeDcHandle CreateDC(string? pwszDriver, string pwszDevice, string? pszPort, IntPtr pdm);
+
+ ///
+ /// Creates an information context: the same answers as a device context from , but it cannot draw or start a job.
+ /// Used by the probe and by the paper negotiation, which must never print anything. Null on failure.
+ ///
+ [DllImport("gdi32.dll", EntryPoint = "CreateICW", SetLastError = true, CharSet = CharSet.Unicode)]
+ internal static extern SafeDcHandle CreateIC(string? pszDriver, string pszDevice, string? pszPort, IntPtr pdm);
+
+ ///
+ /// Reads one device capability. The driver's real resolution (LOGPIXELSX/Y), the physical page (PHYSICALWIDTH/HEIGHT), where the printable
+ /// area starts on it (PHYSICALOFFSETX/Y) and the printable area itself (HORZRES/VERTRES) decide the picture's resolution and its position.
+ ///
+ [DllImport("gdi32.dll")]
+ internal static extern int GetDeviceCaps(SafeDcHandle hdc, int index);
+
+ ///
+ /// Sets how resamples. The picture is drawn with so a whole-factor stretch repeats
+ /// pixels exactly and black bars keep hard edges. Returns the previous mode, or 0 on failure.
+ ///
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern int SetStretchBltMode(SafeDcHandle hdc, int mode);
+
+ /// Starts the print job. Returns the job id (greater than zero), or zero or less on failure.
+ [DllImport("gdi32.dll", EntryPoint = "StartDocW", SetLastError = true, CharSet = CharSet.Unicode)]
+ internal static extern int StartDoc(SafeDcHandle hdc, ref DOCINFOW lpdi);
+
+ /// Prepares the driver to accept one page (one label). Greater than zero on success.
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern int StartPage(SafeDcHandle hdc);
+
+ ///
+ /// Copies the label picture onto the page at its destination rectangle. Both arrays are blittable, so the marshaller pins them for the call
+ /// instead of copying: are the DWORD-padded rows, the BITMAPINFO with its palette.
+ /// Returns the number of scan lines copied; 0 or is a failure.
+ ///
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern int StretchDIBits(SafeDcHandle hdc, int xDest, int yDest, int destWidth, int destHeight, int xSrc, int ySrc,
+ int srcWidth, int srcHeight, byte[] lpBits, byte[] lpbmi, uint iUsage, uint rop);
+
+ ///
+ /// Tells the driver the page is finished. A page counts as submitted only once this succeeds (greater than zero): from then on it may print
+ /// even if the job is aborted later, so its serial number is never issued again.
+ ///
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern int EndPage(SafeDcHandle hdc);
+
+ /// Ends a finished job so the spooler prints it. Greater than zero on success.
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern int EndDoc(SafeDcHandle hdc);
+
+ ///
+ /// Stops a job that did not finish (failure, cancel, or an exception from the page source). Learn: with the spooler it erases the spool job;
+ /// without it the data may already have reached the printer, which is why pages already ended stay counted.
+ ///
+ [DllImport("gdi32.dll", SetLastError = true)]
+ internal static extern int AbortDoc(SafeDcHandle hdc);
+
+ /// Deletes a device or information context; called only by (Learn: a printer DC is deleted, never released).
+ [DllImport("gdi32.dll")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool DeleteDC(IntPtr hdc);
+}
+
+/// A printer handle from , closed with ClosePrinter when disposed (or finalized as a last resort).
+internal sealed class SafePrinterHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ /// Called by the interop marshaller.
+ public SafePrinterHandle() : base(ownsHandle: true) { }
+
+ ///
+ protected override bool ReleaseHandle() => GdiNative.ClosePrinter(handle);
+}
+
+/// A device or information context from CreateDC/CreateIC, deleted with DeleteDC when disposed (or finalized as a last resort).
+internal sealed class SafeDcHandle : SafeHandleZeroOrMinusOneIsInvalid
+{
+ /// Called by the interop marshaller.
+ public SafeDcHandle() : base(ownsHandle: true) { }
+
+ ///
+ protected override bool ReleaseHandle() => GdiNative.DeleteDC(handle);
+}
diff --git a/src/Rotula.Printing/LabelPrintRun.cs b/src/Rotula.Printing/LabelPrintRun.cs
index 910795d..a09ff18 100644
--- a/src/Rotula.Printing/LabelPrintRun.cs
+++ b/src/Rotula.Printing/LabelPrintRun.cs
@@ -143,7 +143,14 @@ public static LabelPrintRun Begin(LabelDocument doc, IReadOnlyDictionaryThe status check found the printer paused; nothing was sent. Arguments as for .
Paused,
+ /// The job needs the print findings acknowledged (see ) and they were not; nothing was sent.
+ NotAcknowledged,
+ /// The Windows driver would not open or accept the page. Arguments: printer, then the technical detail.
+ DriverRefused,
}
///
@@ -104,7 +108,16 @@ private PrintResult(int bytesSent, PrintError? error)
/// A copy of this result that also carries .
public PrintResult WithNotices(IReadOnlyList notices) =>
- notices.Count == 0 ? this : new PrintResult(BytesSent, Error) { Notices = notices };
+ notices.Count == 0 ? this : new PrintResult(BytesSent, Error) { Notices = notices, PagesSent = PagesSent };
+
+ ///
+ /// Number of pages (pictures) handed to the Windows driver; 0 for ZPL jobs and for results that never set it. Set only through
+ /// , so and behave exactly as before.
+ ///
+ public int PagesSent { get; private init; }
+
+ /// A copy of this result (error, bytes and notices kept) that reports pages sent.
+ public PrintResult WithPages(int pages) => new(BytesSent, Error) { Notices = Notices, PagesSent = pages };
/// Creates a success result.
public static PrintResult Ok(int bytesSent) => new(bytesSent, null);
diff --git a/src/Rotula.Printing/PrintService.Driver.cs b/src/Rotula.Printing/PrintService.Driver.cs
new file mode 100644
index 0000000..a24c9fa
--- /dev/null
+++ b/src/Rotula.Printing/PrintService.Driver.cs
@@ -0,0 +1,521 @@
+using System.Globalization;
+using Rotula.Core.Model;
+using Rotula.Core.Validation;
+using Rotula.Rendering.View;
+
+namespace Rotula.Printing;
+
+// Picture-mode (Windows driver) half of PrintService: the pre-flight, the acknowledgement gate and page submission that keeps serial numbers
+// honest. The printer-language half lives in PrintService.cs and is not touched by anything here.
+public sealed partial class PrintService
+{
+ /// Name of every picture-mode job in the Windows print queue; the same name the printer-language path gives its jobs.
+ private const string DriverDocumentName = "Rotula label";
+
+ ///
+ /// Message key of a resolved label with fewer than one copy. It is the key the merge engine already uses for a copies value it cannot
+ /// accept (MergeBatch), so the app already has a text for it; the argument is the value that was given.
+ ///
+ private const string CopiesInvalidKey = "Merge.Copies.Invalid";
+
+ ///
+ /// Most bytes of distinct pictures one driver document holds before it is ended early (128 MB). A document is drawn completely before any
+ /// of its pages is handed to the driver, so a label that cannot be drawn stops the job before that document starts; this caps what that
+ /// costs in memory. With the default 50 labels per document the cap only bites for large labels at high resolution (a 4 x 6 inch label at
+ /// 600 dpi is about 8.6 MB, so 14 of them per document). Copies of one label share one picture and never count twice. Ending a document
+ /// early only adds a document boundary, which is safe for serial numbers.
+ ///
+ internal const long DefaultChunkPictureBudgetBytes = 128L * 1024 * 1024;
+
+ /// Labels per driver document for , which takes no options: the default of .
+ private static readonly int DefaultLabelsPerJob = new PrintVariableOptions().LabelsPerJob;
+
+ ///
+ /// Draws one label picture: the design, the label's resolved values, the picture resolution. Production always uses
+ /// ; tests replace it to count renders, record values or make one label fail.
+ ///
+ internal Func?, int, DevicePicture> PictureRenderer { get; set; } = DevicePictureRenderer.Render;
+
+ /// Bytes of distinct pictures one driver document may hold; unless a test lowers it.
+ internal long ChunkPictureBudgetBytes { get; set; } = DefaultChunkPictureBudgetBytes;
+
+ ///
+ ///
+ /// For a driver target the driver is asked for its media with the label's size in millimetres, then judges
+ /// the design. A cancelled probe cannot be answered with "no media" (that would claim the driver is unreachable) nor with
+ /// (that would read as "all clear"), so it returns the single error finding
+ /// (no arguments): it stops the job like any error and is translated like every other pre-flight
+ /// finding. A probe that throws anything else counts as an unreachable driver.
+ ///
+ public async Task PreflightAsync(PrinterTarget target, LabelDocument document, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(document);
+ // A printer-language target is never probed: nothing about a ZPL print may change, not even a question to its driver.
+ if (!IsDriverTarget(target)) return DriverPreflightResult.None;
+ var result = await AnalyzeForDriverAsync(target, document, ct).ConfigureAwait(false);
+ return result ?? new DriverPreflightResult(new[] { new ValidationIssue(ValidationSeverity.Error, DriverPreflight.Cancelled) }, null);
+ }
+
+ ///
+ ///
+ /// The size of the job is judged first and alone: a job over labels can never print, so asking
+ /// the driver (which can take a moment, and is pointless) is skipped and the person reads the one thing to fix. It comes back with no media,
+ /// like ; being an error it is never part of an acknowledgement. A job within the limit is
+ /// pre-flighted exactly as by the overload without a count, and a printer-language target gets
+ /// whatever the count (the ZPL path has its own limit, Variable.Copies.TooMany).
+ ///
+ public async Task PreflightAsync(PrinterTarget target, LabelDocument document, long labelCount, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(document);
+ if (IsDriverTarget(target) && DriverPreflight.CheckJobSize(labelCount) is { } tooMany)
+ return new DriverPreflightResult(new[] { tooMany }, null);
+ return await PreflightAsync(target, document, ct).ConfigureAwait(false);
+ }
+
+ ///
+ ///
+ ///
+ /// Only a Windows queue in mode is printed this way; any other target fails with
+ /// (printer-language batches send ZPL). Every label is checked first: one with fewer than one copy
+ /// fails the call with before the driver is asked anything, because printing it zero times and
+ /// reporting success would hide the caller's mistake. A call whose labels add up to more than
+ /// pages fails the same way ( as a notice), also before the driver is asked anything: the
+ /// pre-flight refuses such a job as a whole, this guards a caller that skipped it. An empty list succeeds with no page and no driver call. A null argument or a null
+ /// entry in is a programming error and throws (, ),
+ /// as a null label does for .
+ ///
+ ///
+ /// Then the same gate as a single print (errors block, warnings need the matching acknowledgement), and pages in driver documents of the
+ /// default , one picture drawn per label and resubmitted for its copies. Nothing here
+ /// reserves or confirms a serial number: tells the caller how many pages may have printed (on a
+ /// failure: every page of the documents before, plus the pages the driver accepted of the failing one).
+ ///
+ ///
+ public async Task PrintResolvedAsync(PrinterTarget target, LabelDocument document, IReadOnlyList labels, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ ArgumentNullException.ThrowIfNull(document);
+ ArgumentNullException.ThrowIfNull(labels);
+ if (!IsDriverTarget(target)) return PrintResult.Fail(PrintErrorKind.Unsupported);
+ long total = 0; // long: one record may ask for 99,999,999 copies, and a list of them overflows an int
+ foreach (var label in labels)
+ {
+ if (label is null) throw new ArgumentException("The list of labels contains a null entry.", nameof(labels));
+ if (label.Copies <= 0)
+ {
+ var issue = new ValidationIssue(ValidationSeverity.Error, CopiesInvalidKey, null, label.Copies.ToString(CultureInfo.InvariantCulture));
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, CopiesInvalidKey)).WithNotices(new[] { issue });
+ }
+ total += label.Copies;
+ }
+ if (labels.Count == 0) return PrintResult.Ok(0);
+ // Defence in depth for a caller that skipped the pre-flight (which checks the same limit for the whole job, see PreflightAsync): the
+ // same refusal, before the driver is asked anything, so nothing is drawn and nothing reaches the spooler.
+ if (RefuseTooManyLabels(total) is { } tooMany) return tooMany;
+
+ var (refusal, media, turned) = await GateAsync(target, document, ct).ConfigureAwait(false);
+ if (refusal is not null) return refusal;
+
+ var progress = new DriverProgress();
+ var outcome = await SubmitPagesAsync(target, document, media!, turned, Expand(labels), DefaultLabelsPerJob, progress, ct).ConfigureAwait(false);
+ var result = outcome ?? PrintResult.Ok(Saturate(progress.Bytes));
+ return result.WithPages(Saturate(progress.Pages));
+ }
+
+ ///
+ /// The failure for a picture job with more than labels, or null when the job is within the
+ /// limit. Same shape as the other refusals of a label the print call cannot accept: naming the
+ /// message key, and the finding itself as a notice, so the app words it with the same text the pre-flight's finding gets.
+ ///
+ private static PrintResult? RefuseTooManyLabels(long labelCount)
+ {
+ if (DriverPreflight.CheckJobSize(labelCount) is not { } issue) return null;
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, issue.MessageKey)).WithNotices(new[] { issue });
+ }
+
+ /// True for the targets printed as pictures: a Windows queue whose mode is . Null-safe, so a null target keeps its old failure.
+ private static bool IsDriverTarget(PrinterTarget? target) => target is { Transport: PrinterTransport.Spooler, Mode: PrinterOutputMode.WindowsDriver };
+
+ ///
+ /// The picture-mode path of .
+ /// Steps: (1) pre-flight, any error finding fails; (2) a warning without the matching acknowledgement fails; only then (3) the run begins and
+ /// reserves serial numbers; (4) the labels are drawn and handed over in documents of ; (5) the
+ /// run is completed with the pages that may have printed, in a finally; (6) the result carries .
+ ///
+ private async Task PrintDriverAsync(PrinterTarget target, LabelDocument document, IReadOnlyDictionary? data,
+ PrintVariableOptions? variables, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(document);
+
+ // Every copy is a page of its own here, so the print quantity is the number of pictures. Checked before everything else: no driver
+ // question, no serial number reserved (Begin below), nothing drawn.
+ if (RefuseTooManyLabels(Math.Max(1, document.Print.Quantity)) is { } tooMany) return tooMany;
+
+ // (1) and (2) come before Begin on purpose: a job that is refused here must not reserve (or even read) a serial number.
+ var (refusal, media, turned) = await GateAsync(target, document, ct).ConfigureAwait(false);
+ if (refusal is not null) return refusal;
+
+ // (3) Started exactly as the printer-language path starts (PrintLanguageAsync: LabelPrintRun.Begin, then CanPrint), with the same
+ // handling of an unexpected failure and of a label with errors.
+ LabelPrintRun run;
+ try { run = LabelPrintRun.Begin(document, data, variables); }
+ catch (Exception ex) when (ex is not OutOfMemoryException and not OperationCanceledException)
+ {
+ // Begin gives back whatever it reserved before it failed.
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.Unknown, target.DisplayName, ex.GetType().Name, ex.Message));
+ }
+ if (!run.CanPrint)
+ {
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel,
+ run.Issues.Where(i => i.Severity == ValidationSeverity.Error).Select(i => i.MessageKey).ToArray())).WithNotices(run.Issues);
+ }
+
+ var progress = new DriverProgress();
+ PrintResult? outcome = null;
+ IReadOnlyList closing = Array.Empty();
+ try
+ {
+ // LabelPrintRun clamps LabelsPerJob the same way, so a document never holds more labels than a ZPL job would.
+ int perJob = Math.Max(1, variables?.LabelsPerJob ?? DefaultLabelsPerJob);
+ // (4) LabelValues resolves every label exactly as the ZPL blocks would be resolved, so page i carries the serial number of label i.
+ outcome = await SubmitPagesAsync(target, document, media!, turned, run.LabelValues(), perJob, progress, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ // (5) The printer-language rule (PrintLanguageAsync: "closing = run.Complete(printed)" in its finally): always runs, also on cancel or
+ // an unexpected exception, so a reservation is never left hanging. Complete counts from the first label, so every page that may have
+ // printed keeps its number and every later number goes back, as for the blocks the ZPL path never sent.
+ closing = run.Complete(progress.Pages);
+ }
+
+ // Warnings of the run plus problems writing the counter file (PrintLanguageAsync builds its notes the same way); no printer status here.
+ var notes = run.Issues.Where(i => i.Severity != ValidationSeverity.Error).Concat(closing).ToList();
+ int pages = Saturate(progress.Pages);
+ int bytes = Saturate(progress.Bytes);
+ if (outcome is null && run.StoppedEarly)
+ {
+ // A later label could not be resolved: as in PrintLanguageAsync, the labels before it were sent (the last document was cut short
+ // there) and the run reports the label's errors.
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel,
+ run.Issues.Where(i => i.Severity == ValidationSeverity.Error).Select(i => i.MessageKey).ToArray()), bytes)
+ .WithNotices(run.Issues.Concat(closing).ToList())
+ .WithPages(pages);
+ }
+ // (6) A failure keeps its own error and notices (a label that could not be drawn lists what); the run's notes are added after them.
+ var result = outcome ?? PrintResult.Ok(bytes);
+ return result.WithPages(pages).WithNotices(result.Notices.Concat(notes).ToList());
+ }
+
+ ///
+ /// Pre-flight and acknowledgement for a print call. Returns the refusal to report (nothing was sent and nothing reserved), or no refusal and
+ /// the media the pictures are drawn for and whether they are turned a quarter turn to fit it.
+ ///
+ private async Task<(PrintResult? Refusal, DriverMedia? Media, bool Turned)> GateAsync(PrinterTarget target, LabelDocument document, CancellationToken ct)
+ {
+ var preflight = await AnalyzeForDriverAsync(target, document, ct).ConfigureAwait(false);
+ if (preflight is null) return (PrintResult.Fail(PrintErrorKind.Cancelled), null, false);
+ if (preflight.HasErrors)
+ {
+ // Errors block whatever was acknowledged; the findings travel as notices so the app can say which part and why.
+ string[] keys = preflight.Findings.Where(f => f.Severity == ValidationSeverity.Error).Select(f => f.MessageKey).ToArray();
+ return (PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, keys)).WithNotices(preflight.Findings), null, false);
+ }
+ // Compared with the fingerprint worked out just now, never with one remembered from the dialog: a design changed since the person ticked
+ // the box has other findings, so a new fingerprint, and is asked about again.
+ if (preflight.NeedsAcknowledgement && !string.Equals(target.AcknowledgedFindings, preflight.Fingerprint, StringComparison.Ordinal))
+ return (PrintResult.Fail(PrintErrorKind.NotAcknowledged).WithNotices(preflight.Findings), null, false);
+ // Without errors Analyze always reports the media it judged (an unusable media is the DriverUnavailable error).
+ return (null, preflight.Media, preflight.LabelTurned);
+ }
+
+ ///
+ /// Asks the driver for its media at the label's size and runs . Null when
+ /// the probe was cancelled.
+ ///
+ /// Sideways stock. When the label as designed is larger than the paper the driver will use
+ /// (), the driver is asked once more for the label turned a quarter turn (width and height
+ /// swapped), and when that one fits, the result is the turned label's (, with the warning
+ /// ). The second answer is asked for rather than guessed from the first, so the judgement, the
+ /// size the driver is later asked to print at, and the check before StartDoc are all for the page the driver really gives for that size.
+ /// A label that fits as designed is never turned (the second question is not even asked), a square label has nothing to turn, and when
+ /// neither way fits the result is the first one, the ordinary refusal. Pre-flight and print both come through here, so they cannot disagree.
+ ///
+ ///
+ private async Task AnalyzeForDriverAsync(PrinterTarget target, LabelDocument document, CancellationToken ct)
+ {
+ var (widthMm, heightMm) = LabelSizeMm(document);
+ if (!IsPositive(widthMm) || !IsPositive(heightMm))
+ {
+ // The driver cannot be asked about a page with no size (the probe refuses it), and "driver unavailable" would send the person to the
+ // wrong place. These are the findings the printer-language path reports for the same label (ZplGenerator's label size checks).
+ var size = new List();
+ if (!IsPositive(widthMm)) size.Add(new ValidationIssue(ValidationSeverity.Error, "Label.Width.Range", null, "1", "9999"));
+ if (!IsPositive(heightMm)) size.Add(new ValidationIssue(ValidationSeverity.Error, "Label.Height.Range", null, "1", "32000"));
+ return new DriverPreflightResult(size, null);
+ }
+
+ var (media, cancelled) = await ProbeAsync(target, widthMm, heightMm, ct).ConfigureAwait(false);
+ if (cancelled) return null;
+ var asDesigned = DriverPreflight.Analyze(document, media);
+
+ // Only a label that does not fit as designed is looked at turned, and only when turning changes anything.
+ bool square = Math.Abs(widthMm - heightMm) <= GdiPageLayout.PageToleranceMm;
+ if (square || !asDesigned.Findings.Any(f => f.MessageKey == DriverPreflight.LabelLargerThanPaper)) return asDesigned;
+
+ var (turnedMedia, turnedCancelled) = await ProbeAsync(target, heightMm, widthMm, ct).ConfigureAwait(false);
+ if (turnedCancelled) return null;
+ var turned = DriverPreflight.Analyze(document, turnedMedia, labelTurned: true);
+ // The turned label counts only when the driver reported usable media for it (Media is null for "driver unavailable") and it fits.
+ bool fits = turned.Media is not null && !turned.Findings.Any(f => f.MessageKey == DriverPreflight.LabelLargerThanPaper);
+ return fits ? turned : asDesigned;
+ }
+
+ ///
+ /// One question to the driver. A cancel is reported as such (never as "no media", which would claim the driver is unreachable); a probe
+ /// that throws anything else counts as an unreachable driver (null media), as the contract says an unreachable one answers null.
+ ///
+ private async Task<(DriverMedia? Media, bool Cancelled)> ProbeAsync(PrinterTarget target, double widthMm, double heightMm, CancellationToken ct)
+ {
+ DriverMedia? media;
+ try { media = await _driver.ProbeAsync(target.PrinterName, widthMm, heightMm, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { return (null, true); }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // By contract the probe answers null for a printer it cannot reach; a driver binding that throws instead is just as unreachable.
+ media = null;
+ }
+ // A driver that ignores the token still cannot turn a cancel into a print.
+ return ct.IsCancellationRequested ? (null, true) : (media, false);
+ }
+
+ ///
+ /// Draws the labels and hands them to the driver in documents of at most pages, adding to
+ /// every page that may have printed. Returns null when every label was handed over and accepted, or the failure
+ /// that ended the run. Each document is drawn completely before any of its pages goes out, so a label that cannot be drawn stops the run with
+ /// nothing of its document sent.
+ ///
+ private async Task SubmitPagesAsync(PrinterTarget target, LabelDocument document, DriverMedia media, bool turned,
+ IEnumerable?> labels, int perJob, DriverProgress progress, CancellationToken ct)
+ {
+ var (designedWidthMm, designedHeightMm) = LabelSizeMm(document);
+ // A turned label lies on the paper the other way round: the driver is told the turned size (the one the pre-flight asked it about, which
+ // its check before StartDoc compares with), and every picture is turned to match. Turning the finished picture, not drawing a turned
+ // design, keeps every pixel (so every barcode module) exactly as it was drawn.
+ var (widthMm, heightMm) = turned ? (designedHeightMm, designedWidthMm) : (designedWidthMm, designedHeightMm);
+ int renderDpi = DevicePictureRenderer.RenderDpi(media);
+ int maxDistinct = MaxDistinctPictures(document, renderDpi);
+
+ // The last picture drawn and the values instance it was drawn for. The run hands out the same dictionary instance for every copy of a
+ // label, so comparing instances finds the copies without comparing contents, and the picture is drawn once and resubmitted (500 copies of a
+ // plain label: one render, 500 pages). It is kept across documents for that reason. A flag is needed because a label without variables has
+ // null values, and null must match null.
+ bool hasLast = false;
+ IReadOnlyDictionary? lastValues = null;
+ DevicePicture? lastPicture = null;
+
+ using var source = labels.GetEnumerator();
+ // True when source.Current is a label that did not fit the previous document (it needed one distinct picture too many).
+ bool carried = false;
+ while (true)
+ {
+ // Consecutive pages of one picture are stored once with a count, so a document of copies holds a single picture.
+ var runs = new List();
+ int pages = 0;
+ bool exhausted = false;
+ while (pages < perJob)
+ {
+ // Checked before every label is drawn (drawing is the slow part): a cancel stops the run with nothing of this document sent.
+ if (ct.IsCancellationRequested) return PrintResult.Fail(PrintErrorKind.Cancelled);
+ if (!carried && !source.MoveNext())
+ {
+ exhausted = true;
+ break;
+ }
+ carried = false;
+ var values = source.Current;
+
+ DevicePicture picture;
+ if (hasLast && ReferenceEquals(lastValues, values))
+ {
+ picture = lastPicture!;
+ }
+ else
+ {
+ if (runs.Count >= maxDistinct)
+ {
+ // The document already holds as many distinct pictures as the memory cap allows: this label opens the next one.
+ carried = true;
+ break;
+ }
+ try
+ {
+ picture = PictureRenderer(document, values, renderDpi);
+ if (turned) picture = picture.TurnedClockwise();
+ }
+ catch (DevicePictureException ex)
+ {
+ // The label cannot be drawn faithfully for these values (a red cross would be a silent substitution). Nothing of this
+ // document was handed over, so its labels count as not printed; the documents before it stay counted.
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, ex.Issues.Select(i => i.MessageKey).ToArray()), Saturate(progress.Bytes))
+ .WithNotices(ex.Issues);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // Any other drawing failure is reported like an unexpected failure at the start of a run, never as a crash of the caller.
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.Unknown, target.DisplayName, ex.GetType().Name, ex.Message), Saturate(progress.Bytes));
+ }
+ hasLast = true;
+ lastValues = values;
+ lastPicture = picture;
+ }
+
+ if (runs.Count > 0 && ReferenceEquals(runs[^1].Picture, picture)) runs[^1] = runs[^1] with { Count = runs[^1].Count + 1 };
+ else runs.Add(new PageRun(picture, 1));
+ pages++;
+ }
+
+ if (pages > 0)
+ {
+ var failure = await SubmitDocumentAsync(target, widthMm, heightMm, media, runs, pages, progress, ct).ConfigureAwait(false);
+ // A failed document ends the run: the documents after it are never started, so their labels' numbers go back (as the blocks
+ // after a failed send in PrintLanguageAsync, which breaks out of its loop).
+ if (failure is not null) return failure;
+ }
+ if (exhausted) return null;
+ }
+ }
+
+ ///
+ /// Hands one document of pages to the driver and adds the pages that may have printed to .
+ /// Returns null when the driver accepted the whole document, or the failure. Never throws for a driver problem.
+ ///
+ /// The Windows queue.
+ /// Label width in millimetres.
+ /// Label height in millimetres.
+ ///
+ /// The media the pre-flight judged and the pictures were drawn for. Every document carries it, because the driver checks its device context
+ /// against it before the document starts: a driver that changed its resolution or page since the check refuses the document with no page.
+ ///
+ /// The document's pictures, consecutive copies stored once with a count.
+ /// How many pages the runs hold.
+ /// Where the pages that may have printed are added.
+ /// Cancels the document between pages.
+ private async Task SubmitDocumentAsync(PrinterTarget target, double widthMm, double heightMm, DriverMedia media,
+ IReadOnlyList runs, int pages, DriverProgress progress, CancellationToken ct)
+ {
+ var feed = new PageFeed(runs);
+ bool counted = false;
+ try
+ {
+ var outcome = await _driver.PrintAsync(target.PrinterName, DriverDocumentName, widthMm, heightMm, media, feed.Pages(), ct).ConfigureAwait(false);
+ if (outcome?.Result is null)
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.Unknown, target.DisplayName, nameof(IDriverPrinter), "The driver printer returned no outcome."));
+
+ if (outcome.Result.Succeeded)
+ {
+ // The whole document was accepted. Counted in full even if a driver reports fewer pages: Complete counts from the first label, so
+ // under-counting a finished document would give back a number that may be on paper once the next document goes out.
+ progress.Pages += pages;
+ progress.Bytes += outcome.Result.BytesSent;
+ counted = true;
+ return null;
+ }
+
+ // On a failure the driver's own count is the authority: a page counts once the driver accepted it (EndPage succeeded), because from then
+ // on it may print even though the job is aborted. This is the ZPL rule "a send that moved bytes may have printed"
+ // (LabelPrintRun.MayHavePrinted) made exact: the ZPL path can only keep or release a whole block, the driver says which pages went out.
+ // The pages after the failing one were never accepted and go back. Clamped so a misbehaving count can never lower the total.
+ progress.Pages += Math.Clamp(outcome.PagesSubmitted, 0, pages);
+ progress.Bytes += outcome.Result.BytesSent;
+ counted = true;
+ // Returned untouched (same error, same arguments): the caller only adds the page count and the run's notes.
+ return outcome.Result;
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return PrintResult.Fail(PrintErrorKind.Cancelled);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // A driver binding that throws instead of reporting is still a printer problem, so it becomes a result, not an escaping exception.
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.Unknown, target.DisplayName, ex.GetType().Name, ex.Message));
+ }
+ finally
+ {
+ // Without an outcome the driver reported no count (it threw, or returned nothing). Every picture it was handed may already be on its way
+ // to paper, so every one of them counts: a gap in the series is acceptable, the same number on two labels is not.
+ if (!counted) progress.Pages += feed.Yielded;
+ }
+ }
+
+ /// The labels of a resolved batch, one entry per copy, each copy carrying the same values instance (so it is drawn once).
+ private static IEnumerable?> Expand(IReadOnlyList labels)
+ {
+ foreach (var label in labels)
+ for (int i = 0; i < label.Copies; i++)
+ yield return label.Values;
+ }
+
+ /// How many distinct pictures of fit the memory cap of one driver document; at least one.
+ private int MaxDistinctPictures(LabelDocument document, int renderDpi)
+ {
+ // Every picture of a document has the same size, worked out without drawing anything.
+ var (width, height) = DevicePictureRenderer.PictureSize(document, renderDpi);
+ long bytes = Math.Max(1L, (long)width * height);
+ return (int)Math.Clamp(ChunkPictureBudgetBytes / bytes, 1L, int.MaxValue);
+ }
+
+ ///
+ /// The label's designed size in millimetres: the figures the person typed, as the pre-flight's paper check reads them, and the size each
+ /// picture is printed at. NaN when the design resolution is unusable.
+ ///
+ private static (double Width, double Height) LabelSizeMm(LabelDocument document)
+ {
+ int dpi = (int)document.Dpi;
+ if (dpi <= 0) return (double.NaN, double.NaN);
+ return (UnitConverter.Convert(document.Width.Value, document.Width.Unit, LengthUnit.Millimeters, dpi),
+ UnitConverter.Convert(document.Height.Value, document.Height.Unit, LengthUnit.Millimeters, dpi));
+ }
+
+ private static bool IsPositive(double value) => double.IsFinite(value) && value > 0;
+
+ /// A long count as the int a carries, never negative and never wrapping.
+ private static int Saturate(long value) => (int)Math.Clamp(value, 0L, int.MaxValue);
+
+ /// Consecutive pages that print the same picture.
+ /// The picture.
+ /// How many pages in a row print it.
+ private readonly record struct PageRun(DevicePicture Picture, int Count);
+
+ /// Pages that may have printed and their picture bytes, summed over the documents of one call. A class so the finally that completes the run sees every addition.
+ private sealed class DriverProgress
+ {
+ /// Pages that may have printed, counted from the first label.
+ public long Pages;
+ /// Picture bytes the driver reported for those pages.
+ public long Bytes;
+ }
+
+ /// Hands one document's pictures to the driver lazily, one per page, and counts how many it handed over.
+ private sealed class PageFeed(IReadOnlyList runs)
+ {
+ /// Pictures handed to the driver so far.
+ public int Yielded { get; private set; }
+
+ /// The pages, in order; a run of copies yields the same picture instance again.
+ public IEnumerable Pages()
+ {
+ foreach (var run in runs)
+ {
+ for (int i = 0; i < run.Count; i++)
+ {
+ // Counted before it is handed over: if the driver throws while holding it, it may already be on its way to paper.
+ Yielded++;
+ yield return run.Picture;
+ }
+ }
+ }
+ }
+}
diff --git a/src/Rotula.Printing/PrintService.cs b/src/Rotula.Printing/PrintService.cs
index 063e8f9..acd3258 100644
--- a/src/Rotula.Printing/PrintService.cs
+++ b/src/Rotula.Printing/PrintService.cs
@@ -88,6 +88,46 @@ Task GetStatusAsync(PrinterTarget target, CancellationToken ct =
///
Task> VerifyPrintedAsync(PrinterTarget target, CancellationToken ct = default) =>
Task.FromResult>(Array.Empty());
+
+ ///
+ /// Checks against what the Windows driver of reports, before anything is printed in
+ /// picture mode (). The findings are what the person acknowledges in the Print dialog; the job prints
+ /// only when equals the worked out again at
+ /// print time. A printer-language (ZPL) target, or a TCP target, needs no pre-flight and gets .
+ /// Never throws for a printer problem. The default implementation returns so existing fakes keep compiling.
+ ///
+ /// The printer and its print mode.
+ /// The label design (checked once for a whole job or batch).
+ /// Cancellation.
+ Task PreflightAsync(PrinterTarget target, LabelDocument document, CancellationToken ct = default) =>
+ Task.FromResult(DriverPreflightResult.None);
+
+ ///
+ /// Like the plain overload, and in addition refuses a job that would print too many pictures: when is more than
+ /// the result is the single error , without the driver
+ /// being asked anything. The count is the whole job (every record times its copies, so a batch merge, the ticked records and the print station
+ /// count all of it), which the design alone does not tell. The default implementation ignores the count and answers like the plain overload, so
+ /// existing fakes keep compiling; honours it.
+ ///
+ /// The printer and its print mode.
+ /// The label design (checked once for a whole job or batch).
+ /// How many labels the whole job prints, copies included.
+ /// Cancellation.
+ Task PreflightAsync(PrinterTarget target, LabelDocument document, long labelCount, CancellationToken ct = default) =>
+ PreflightAsync(target, document, ct);
+
+ ///
+ /// Prints labels whose variable values were already worked out by the caller (batch printing), one page per copy, through the Windows
+ /// driver of . It runs the same pre-flight and acknowledgement check as
+ /// but handles no serial numbers: the caller reserved them and confirms them from . The default
+ /// implementation fails with so existing fakes keep compiling.
+ ///
+ /// A Windows queue in mode.
+ /// The label design.
+ /// The labels in print order, each with its resolved values and its number of copies (at least 1).
+ /// Cancellation; pages the driver already accepted stay counted in .
+ Task PrintResolvedAsync(PrinterTarget target, LabelDocument document, IReadOnlyList labels, CancellationToken ct = default) =>
+ Task.FromResult(PrintResult.Fail(PrintErrorKind.Unsupported));
}
/// Message keys of the notices the status check and the verification add to a .
@@ -109,13 +149,17 @@ public static class PrinterStatusKeys
public const string Stopped = "Stopped";
}
-/// Default : ZPL from Core, bytes from , transport by target.
-public sealed class PrintService : IPrintService
+///
+/// Default : ZPL from Core, bytes from , transport by target. A Windows queue in
+/// mode is printed as pictures through its driver instead (see PrintService.Driver.cs).
+///
+public sealed partial class PrintService : IPrintService
{
private readonly IRawPrinter _spooler;
private readonly INetworkPrinter _network;
private readonly IPrinterDiscovery _discovery;
private readonly IPrinterStatusReader _status;
+ private readonly IDriverPrinter _driver;
/// Creates the service with the real winspool and TCP implementations.
public PrintService() : this(new WinspoolRawPrinter(), new TcpRawPrinter(), new WinspoolRawPrinter()) { }
@@ -127,13 +171,26 @@ public sealed class PrintService : IPrintService
public PrintService(IRawPrinter spooler, INetworkPrinter network, IPrinterDiscovery discovery)
: this(spooler, network, discovery, new PrinterStatusReader()) { }
- /// Creates the service with explicit transports and status reader (tests pass fakes).
+ ///
+ /// Creates the service with explicit transports and status reader (tests pass fakes). Picture-mode printing uses the real
+ /// ; it is only reached for a target in mode.
+ ///
public PrintService(IRawPrinter spooler, INetworkPrinter network, IPrinterDiscovery discovery, IPrinterStatusReader status)
+ : this(spooler, network, discovery, status, new GdiDriverPrinter()) { }
+
+ /// Creates the service with explicit transports, status reader and Windows-driver printer (tests pass fakes).
+ /// Raw ZPL to a Windows queue.
+ /// Raw ZPL to a TCP endpoint.
+ /// Lists printers and reads their driver settings.
+ /// Asks a ZPL printer for its host status.
+ /// Prints pictures through a Windows driver (picture mode).
+ public PrintService(IRawPrinter spooler, INetworkPrinter network, IPrinterDiscovery discovery, IPrinterStatusReader status, IDriverPrinter driver)
{
_spooler = spooler ?? throw new ArgumentNullException(nameof(spooler));
_network = network ?? throw new ArgumentNullException(nameof(network));
_discovery = discovery ?? throw new ArgumentNullException(nameof(discovery));
_status = status ?? throw new ArgumentNullException(nameof(status));
+ _driver = driver ?? throw new ArgumentNullException(nameof(driver));
}
///
@@ -146,11 +203,33 @@ public Task PrintAsync(PrinterTarget target, LabelDocument document
PrintAsync(target, document, data, profile, null, cancellationToken);
///
- public async Task PrintAsync(PrinterTarget target, LabelDocument document, IReadOnlyDictionary? data,
- PrinterProfile? profile, PrintVariableOptions? variables, CancellationToken cancellationToken = default)
+ ///
+ /// A Windows queue in mode is printed as pictures through its driver: pre-flight, the acknowledgement
+ /// check, then one page per label copy (see PrintService.Driver.cs); and the target's status options are
+ /// not used there, because they are printer-language commands. Every other target takes the printer-language path, byte for byte as before.
+ ///
+ public Task PrintAsync(PrinterTarget target, LabelDocument document, IReadOnlyDictionary? data,
+ PrinterProfile? profile, PrintVariableOptions? variables, CancellationToken cancellationToken = default) =>
+ IsDriverTarget(target)
+ ? PrintDriverAsync(target, document, data, variables, cancellationToken)
+ : PrintLanguageAsync(target, document, data, profile, variables, cancellationToken);
+
+ ///
+ /// The printer-language (ZPL) path of ,
+ /// kept exactly as it was before picture mode existed: its bytes must not change.
+ ///
+ private async Task PrintLanguageAsync(PrinterTarget target, LabelDocument document, IReadOnlyDictionary? data,
+ PrinterProfile? profile, PrintVariableOptions? variables, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(document);
- var run = LabelPrintRun.Begin(document, data, variables);
+ LabelPrintRun run;
+ try { run = LabelPrintRun.Begin(document, data, variables); }
+ catch (Exception ex) when (ex is not OutOfMemoryException and not OperationCanceledException)
+ {
+ // Starting reads the counter file and generates the first label; a locked file, a full disk or a failing font engine must
+ // reach the operator as a result with the reason, not as a crash of the caller. Begin gives back what it reserved.
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.Unknown, target?.DisplayName ?? string.Empty, ex.GetType().Name, ex.Message));
+ }
if (!run.CanPrint)
{
// Begin gave any reserved numbers back; the error keys tell the operator what to fix.
@@ -221,17 +300,24 @@ public async Task PrintAsync(PrinterTarget target, LabelDocument do
}
///
+ ///
+ /// A Windows queue in mode is refused with and nothing is sent:
+ /// its driver would print the ZPL as text. Every caller in the app already keeps raw ZPL away from such a queue; this is the second guard,
+ /// for a caller that forgets.
+ ///
public Task PrintZplAsync(PrinterTarget target, string zpl, ZplEncoding encoding, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(target);
ArgumentNullException.ThrowIfNull(zpl);
ArgumentNullException.ThrowIfNull(encoding);
+ if (IsDriverTarget(target)) return Task.FromResult(PrintResult.Fail(PrintErrorKind.Unsupported));
if (!TryEncode(zpl, encoding, out var bytes))
return Task.FromResult(PrintResult.Fail(PrintErrorKind.EncodingFailed, encoding.ToString()));
return SendCheckedAsync(target, bytes, cancellationToken);
}
///
+ /// A Windows queue in mode is refused with , as by the other overload.
public Task PrintZplAsync(PrinterTarget target, string zpl, ZplEncoding encoding, IReadOnlyCollection byteExactBlocks,
CancellationToken cancellationToken = default)
{
@@ -239,15 +325,21 @@ public Task PrintZplAsync(PrinterTarget target, string zpl, ZplEnco
ArgumentNullException.ThrowIfNull(zpl);
ArgumentNullException.ThrowIfNull(encoding);
ArgumentNullException.ThrowIfNull(byteExactBlocks);
+ if (IsDriverTarget(target)) return Task.FromResult(PrintResult.Fail(PrintErrorKind.Unsupported));
if (!TryEncode(zpl, encoding, byteExactBlocks, out var bytes))
return Task.FromResult(PrintResult.Fail(PrintErrorKind.EncodingFailed, encoding.ToString()));
return SendCheckedAsync(target, bytes, cancellationToken);
}
///
+ ///
+ /// A Windows queue in mode is refused with and nothing is sent:
+ /// the set-up commands are ZPL, which its driver would print as text (darkness and speed are set in the driver's own dialog there).
+ ///
public Task ApplyProfileAsync(PrinterTarget target, PrinterProfile profile, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
+ if (IsDriverTarget(target)) return Task.FromResult(PrintResult.Fail(PrintErrorKind.Unsupported));
var bad = profile.Validate();
if (bad.Count > 0) return Task.FromResult(PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidProfile, bad.ToArray())));
ArgumentNullException.ThrowIfNull(target);
@@ -259,6 +351,11 @@ public Task ApplyProfileAsync(PrinterTarget target, PrinterProfile
}
///
+ ///
+ /// The test label is an ordinary printed through ,
+ /// so a target in mode prints it as a picture: it needs the same acknowledgement as any other label,
+ /// and the profile then only sizes the label (no set-up commands are sent to a driver).
+ ///
public async Task PrintTestLabelAsync(PrinterTarget target, PrinterProfile profile, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(profile);
@@ -283,10 +380,17 @@ public Task GetPrinterInfoAsync(PrinterTarget target, Cance
private async Task SafeInfoAsync(string name, CancellationToken ct)
{
try { return await _discovery.GetPrinterInfoAsync(name, ct).ConfigureAwait(false); }
- catch { return PrinterCapabilities.Unknown(name); }
+ // By contract detection never throws; if a custom discovery does, "unknown" is the honest answer. The caller keeps the
+ // profile's own resolution and paper size, and the print test label still works, so there is nothing the operator must act on.
+ catch (Exception ex) when (ex is not OutOfMemoryException) { return PrinterCapabilities.Unknown(name); }
}
///
+ ///
+ /// A Windows queue in mode is never asked: the query is the ZPL command ~HS, which its driver
+ /// would print as text. The answer is "status unavailable" (), the same as for a queue that
+ /// cannot read replies. goes through the same guard.
+ ///
public Task GetStatusAsync(PrinterTarget target, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(target);
@@ -363,9 +467,14 @@ private async Task SendCheckedAsync(PrinterTarget target, byte[] by
return sent.WithNotices(notes);
}
- /// Asks the reader; a reader that breaks its contract and throws is treated as "no status", never as a crash of the print.
+ ///
+ /// Asks the reader; a reader that breaks its contract and throws is treated as "no status", never as a crash of the print. Every status query
+ /// of the service passes here, so this is where a queue in mode is kept from receiving ~HS
+ /// (a ZPL command its driver would print as text): it answers "not supported" without asking.
+ ///
private async Task SafeStatusAsync(PrinterTarget target, CancellationToken ct)
{
+ if (IsDriverTarget(target)) return PrinterStatus.Unavailable(StatusUnavailableReason.NotSupported);
try { return await _status.ReadStatusAsync(target, target.Status.QueryTimeout, ct).ConfigureAwait(false); }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (OperationCanceledException) { return PrinterStatus.Unavailable(StatusUnavailableReason.NoReply); }
diff --git a/src/Rotula.Printing/PrinterCommandScanner.cs b/src/Rotula.Printing/PrinterCommandScanner.cs
new file mode 100644
index 0000000..f5dac10
--- /dev/null
+++ b/src/Rotula.Printing/PrinterCommandScanner.cs
@@ -0,0 +1,176 @@
+namespace Rotula.Printing;
+
+///
+/// What kind of harm a printer-changing command can do. The desktop app words each kind in the user's language; the English
+/// stays for the MCP server, which speaks English only.
+///
+public enum RiskCategory
+{
+ /// Cancels, pauses or restarts print jobs.
+ JobControl,
+ /// Deletes data stored in the printer's memory.
+ DeleteStored,
+ /// Writes fonts, graphics or formats into the printer's memory (flash memory wears out).
+ WriteStored,
+ /// Changes printer settings that outlast this job.
+ Settings,
+ /// Prints diagnostic or configuration output instead of the label.
+ Diagnostics,
+ /// Changes network or wireless settings, which can cut the printer off.
+ Network,
+ /// Starts the printer's built-in BASIC interpreter.
+ Programs,
+ /// Changes the command characters, so the rest of the text cannot be checked.
+ CommandCharacters,
+}
+
+/// A printer-changing ZPL command found in a job.
+/// The command as ZPL spells it, upper case: ~JA, ^ID.
+/// What it does to the printer, in plain words (English).
+/// How many times it occurs in the job.
+/// The first occurrence with its parameters, cut at the next command or line end (for example ^IDE:*.*).
+/// The kind of effect, so a user interface can describe it in the user's language.
+public sealed record RiskyCommand(string Name, string Meaning, int Count, string? Example, [property: System.Text.Json.Serialization.JsonIgnore] RiskCategory Category = RiskCategory.Settings);
+
+///
+/// Finds ZPL commands that do more than print a label: they cancel or erase things, change the printer's settings, or store objects in its
+/// memory. A job from an untrusted file (imported ZPL, a downloaded .rotula) can carry such commands, and the person who confirms a print
+/// must be told about them by name, not only see the start of the text.
+///
+/// The scan reads the entire text that will be sent. It deliberately does not try to skip binary payloads (~DY, ~DG,
+/// ^GF): a scanner that trusts a length written in the payload's own header can be told to skip the very bytes that hold the
+/// command. Reading everything can only over-report, which costs one extra acknowledgement; skipping can hide a command.
+///
+///
+/// ZPL accepts lower-case command letters, so the match ignores case. The characters that introduce commands can be changed with
+/// ^CC, ~CT and ^CD, after which the rest of the text cannot be read reliably, so those commands are flagged themselves.
+///
+///
+public static class PrinterCommandScanner
+{
+ private const int ExampleLength = 40;
+
+ // Keyed by prefix plus the two command characters, upper case. Only commands that change or erase something on the printer, or
+ // that make the rest of the text unreadable to this scan, are listed; ordinary label commands (^FO, ^FD, ^BC, ^PQ...) are not.
+ private static readonly Dictionary Table = new(StringComparer.Ordinal)
+ {
+ ["~JA"] = (RiskCategory.JobControl, "cancels every print job the printer is holding"),
+ ["~JB"] = (RiskCategory.DeleteStored, "resets the printer's optional memory (erases what is stored in it)"),
+ ["~JC"] = (RiskCategory.Settings, "starts a media sensor calibration (the printer feeds labels and changes its sensor settings)"),
+ ["~JD"] = (RiskCategory.Diagnostics, "switches on diagnostics mode (the printer prints the data it receives as text instead of labels)"),
+ ["~JL"] = (RiskCategory.Settings, "sets the label length by feeding labels"),
+ ["~JP"] = (RiskCategory.JobControl, "pauses the printer and cancels the format being printed"),
+ ["~JR"] = (RiskCategory.JobControl, "restarts the printer (power-on reset)"),
+ ["~JX"] = (RiskCategory.JobControl, "cancels the format the printer is receiving"),
+ ["~PP"] = (RiskCategory.JobControl, "pauses printing"),
+ ["~PS"] = (RiskCategory.JobControl, "starts or resumes printing"),
+ ["^JJ"] = (RiskCategory.JobControl, "changes the printer's job control (automatic cancel, pause and error handling)"),
+ ["^JU"] = (RiskCategory.Settings, "updates the printer's saved configuration, including a reset to factory settings (^JUF)"),
+ ["^ID"] = (RiskCategory.DeleteStored, "deletes stored objects (fonts, graphics, formats); a wildcard such as ^IDE:*.* deletes many at once"),
+ ["~EG"] = (RiskCategory.DeleteStored, "erases all downloaded graphics"),
+ ["^EG"] = (RiskCategory.DeleteStored, "erases all downloaded graphics"),
+ ["^TO"] = (RiskCategory.WriteStored, "copies stored objects from one memory to another"),
+ ["~DY"] = (RiskCategory.WriteStored, "downloads a file (font, graphic or firmware) into the printer's memory and can replace what is stored there"),
+ ["~DB"] = (RiskCategory.WriteStored, "downloads a bitmap font into the printer's memory"),
+ ["~DS"] = (RiskCategory.WriteStored, "downloads a scalable font into the printer's memory"),
+ ["~DT"] = (RiskCategory.WriteStored, "downloads a TrueType font into the printer's memory"),
+ ["~DU"] = (RiskCategory.WriteStored, "downloads an unbounded TrueType font into the printer's memory"),
+ ["~DE"] = (RiskCategory.WriteStored, "downloads an encoding table into the printer's memory"),
+ ["~DG"] = (RiskCategory.WriteStored, "stores a graphic in the printer's permanent (flash) memory, which wears out with every write"),
+ ["^DF"] = (RiskCategory.WriteStored, "stores a format in the printer instead of printing it, and can replace a stored format of the same name"),
+ ["^CW"] = (RiskCategory.Settings, "assigns a font letter to a stored font, changing how later text is drawn"),
+ ["~WC"] = (RiskCategory.Diagnostics, "prints a configuration label"),
+ ["~RO"] = (RiskCategory.Settings, "resets the printer's odometer and label counters"),
+ ["^MP"] = (RiskCategory.Settings, "changes which front-panel modes are protected"),
+ ["^MM"] = (RiskCategory.Settings, "changes the print mode (tear-off, peel, cutter...)"),
+ ["^MF"] = (RiskCategory.Settings, "changes what the printer does with the media at power-up and when the head is closed"),
+ ["~SD"] = (RiskCategory.Settings, "sets the print darkness, which stays set for later jobs"),
+ ["~TA"] = (RiskCategory.Settings, "changes the tear-off position, which stays set for later jobs"),
+ ["^SS"] = (RiskCategory.Settings, "changes the media sensor settings"),
+ ["^SZ"] = (RiskCategory.Settings, "switches the printer's command language, after which it may misread later jobs"),
+ ["^ST"] = (RiskCategory.Settings, "sets the printer's clock"),
+ ["^KP"] = (RiskCategory.Settings, "changes the printer's password"),
+ ["^KN"] = (RiskCategory.Settings, "changes the printer's name"),
+ ["^KL"] = (RiskCategory.Settings, "changes the language of the printer's display"),
+ ["^NI"] = (RiskCategory.Network, "changes the printer's network identity"),
+ ["^NS"] = (RiskCategory.Network, "changes the printer's network settings"),
+ ["^ND"] = (RiskCategory.Network, "changes the printer's network settings"),
+ ["^NN"] = (RiskCategory.Network, "changes the printer's SNMP network settings"),
+ ["^NP"] = (RiskCategory.Network, "changes the printer's primary network device"),
+ ["^NT"] = (RiskCategory.Network, "changes the printer's network (SMTP) settings"),
+ ["^NW"] = (RiskCategory.Network, "changes the printer's web authentication settings"),
+ ["^NB"] = (RiskCategory.Network, "changes how the printer looks for its wired network at start-up"),
+ ["~NC"] = (RiskCategory.Network, "connects the printer to a network"),
+ ["~NR"] = (RiskCategory.Network, "makes the network printers transparent to this one"),
+ ["~NT"] = (RiskCategory.Network, "makes the connected network printer transparent"),
+ ["^WA"] = (RiskCategory.Network, "changes the printer's wireless antenna settings"),
+ ["^WE"] = (RiskCategory.Network, "changes the printer's wireless encryption settings"),
+ ["^WI"] = (RiskCategory.Network, "changes the printer's wireless network settings"),
+ ["^WL"] = (RiskCategory.Network, "changes the printer's wireless authentication (LEAP) settings"),
+ ["^WP"] = (RiskCategory.Network, "changes the printer's wireless password settings"),
+ ["^WR"] = (RiskCategory.Network, "changes the printer's wireless transmit rate"),
+ ["^WS"] = (RiskCategory.Network, "changes the printer's wireless settings"),
+ ["^CM"] = (RiskCategory.Settings, "remaps the memory letters (R:, E:, B:, A:), so a later store to RAM can land in permanent memory"),
+ ["^IS"] = (RiskCategory.WriteStored, "saves an image into the printer's memory"),
+ ["^JI"] = (RiskCategory.Programs, "starts the printer's built-in BASIC interpreter (ZBI), which can run programs"),
+ ["~JI"] = (RiskCategory.Programs, "starts the printer's built-in BASIC interpreter (ZBI), which can run programs"),
+ ["^SX"] = (RiskCategory.Settings, "changes where the printer sends its alert messages"),
+ ["^SC"] = (RiskCategory.Settings, "changes the printer's serial port settings, which can cut off communication with it"),
+ ["^ZZ"] = (RiskCategory.Settings, "puts the printer to sleep or changes its idle time-out"),
+ ["~WR"] = (RiskCategory.Network, "resets the printer's wireless card"),
+ ["^XS"] = (RiskCategory.Settings, "is a printer set-up command that can change how the printer behaves for later jobs"),
+ // Changing the command characters means the text after it cannot be scanned for commands any more.
+ ["^CC"] = (RiskCategory.CommandCharacters, "changes the character that starts commands, so later commands in this job cannot be read or checked"),
+ ["~CC"] = (RiskCategory.CommandCharacters, "changes the character that starts commands, so later commands in this job cannot be read or checked"),
+ ["^CT"] = (RiskCategory.CommandCharacters, "changes the character that starts control commands, so later commands in this job cannot be read or checked"),
+ ["~CT"] = (RiskCategory.CommandCharacters, "changes the character that starts control commands, so later commands in this job cannot be read or checked"),
+ ["^CD"] = (RiskCategory.CommandCharacters, "changes the character that separates parameters, so later commands in this job cannot be read or checked"),
+ ["~CD"] = (RiskCategory.CommandCharacters, "changes the character that separates parameters, so later commands in this job cannot be read or checked"),
+ };
+
+ /// Flash devices of a Zebra printer; R: (RAM) is lost at power-off and Z: is read-only.
+ private static readonly char[] FlashDevices = ['A', 'B', 'E'];
+
+ /// Scans the whole text. The result lists each command once, in the order it first appears.
+ /// The ZPL that will be sent, or a raw block of it.
+ public static IReadOnlyList Scan(string zpl)
+ {
+ ArgumentNullException.ThrowIfNull(zpl);
+ // Printers ignore carriage returns and line feeds in the command stream, so "~J" + line break + "A" is a ~JA. Reading the text
+ // with them removed catches that; the price is a possible extra report for two commands that merely meet at a line end.
+ if (zpl.Contains('\r') || zpl.Contains('\n')) zpl = zpl.Replace("\r", "").Replace("\n", "");
+ // ^CM can make "R:" mean a flash drive, so once it appears no device letter can be trusted.
+ bool memoryRemapped = zpl.Contains("^CM", StringComparison.OrdinalIgnoreCase) || zpl.Contains("~CM", StringComparison.OrdinalIgnoreCase);
+ var found = new Dictionary(StringComparer.Ordinal);
+ var order = new List();
+ for (int i = 0; i + 2 < zpl.Length; i++)
+ {
+ char prefix = zpl[i];
+ if (prefix != '^' && prefix != '~') continue;
+ string key = string.Concat(prefix, char.ToUpperInvariant(zpl[i + 1]), char.ToUpperInvariant(zpl[i + 2]));
+ if (!Table.ContainsKey(key)) continue;
+ // A graphic stored in RAM (the default device) is routine; only the permanent memory is a concern.
+ if (key == "~DG" && !memoryRemapped && !TargetsFlash(zpl, i + 3)) continue;
+
+ if (found.TryGetValue(key, out var seen)) found[key] = (seen.Count + 1, seen.Example);
+ else
+ {
+ found[key] = (1, ExampleAt(zpl, i));
+ order.Add(key);
+ }
+ }
+ return order.Select(k => new RiskyCommand(k, Table[k].Meaning, found[k].Count, found[k].Example, Table[k].Category)).ToList();
+ }
+
+ /// True when the device letter after a ~DG names a flash memory (E:, B:, A:).
+ private static bool TargetsFlash(string zpl, int afterCommand) =>
+ afterCommand + 1 < zpl.Length && zpl[afterCommand + 1] == ':' && FlashDevices.Contains(char.ToUpperInvariant(zpl[afterCommand]));
+
+ /// The command with its parameters, cut at the next command start or line end and at a fixed length.
+ private static string ExampleAt(string zpl, int start)
+ {
+ int end = start + 3;
+ while (end < zpl.Length && end - start < ExampleLength && zpl[end] is not ('^' or '~')) end++;
+ return zpl[start..end];
+ }
+}
diff --git a/src/Rotula.Printing/PrinterInfoNative.cs b/src/Rotula.Printing/PrinterInfoNative.cs
index 8771129..2197bfa 100644
--- a/src/Rotula.Printing/PrinterInfoNative.cs
+++ b/src/Rotula.Printing/PrinterInfoNative.cs
@@ -51,13 +51,16 @@ internal sealed class Win32PrinterCapsReader : IPrinterCapsReader
try
{
(driver, port) = ReadDriverAndPort(handle);
+ // Each source below is independent and best effort: drivers misbehave in many ways (bad DEVMODE sizes, hung queries), and
+ // a failing source only means one fewer hint. The result is still shown to the operator as "detected" or "unknown" and the
+ // profile keeps its own values when nothing could be read, so swallowing here hides nothing they need to act on.
try { ReadDevMode(handle, printerName, port, out mx, out my, out paperW, out paperL); } catch { /* keep other sources */ }
}
finally { NativeMethods.ClosePrinter(handle); }
int? ix = null, iy = null;
- try { ReadInfoContext(printerName, out ix, out iy); } catch { }
- try { enumDpi = ReadSingleEnumResolution(printerName, port); } catch { }
+ try { ReadInfoContext(printerName, out ix, out iy); } catch { /* best effort, see above */ }
+ try { enumDpi = ReadSingleEnumResolution(printerName, port); } catch { /* best effort, see above */ }
return new RawPrinterCaps(driver, mx, my, ix, iy, enumDpi, paperW, paperL);
}
diff --git a/src/Rotula.Printing/PrinterOutputMode.cs b/src/Rotula.Printing/PrinterOutputMode.cs
new file mode 100644
index 0000000..b4cd7ce
--- /dev/null
+++ b/src/Rotula.Printing/PrinterOutputMode.cs
@@ -0,0 +1,47 @@
+namespace Rotula.Printing;
+
+/// How a label reaches a printer: as the printer's own language, or as a picture drawn through the Windows driver.
+public enum PrinterOutputMode
+{
+ /// The label is sent as ZPL (today's behaviour, and the only mode a raw TCP target has).
+ PrinterLanguage,
+ /// The label is drawn as a picture and handed to the installed Windows driver (non-Zebra label printers, office printers).
+ WindowsDriver,
+}
+
+///
+/// Suggests a from the names Windows reports for a printer. Only a suggestion: it pre-selects an answer the person
+/// confirms, and nothing in the library switches a printer's mode because of it (a ZPL printer with an unusual name must not be flipped silently).
+///
+public static class PrinterOutputModeSuggestion
+{
+ /// Name fragments that identify Zebra-style ZPL queues, matched case-insensitively.
+ private static readonly string[] ZplNameMarkers = ["ZDesigner", "Zebra", "ZPL"];
+
+ /// The driver Windows ships for queues that pass text through untouched, which is how ZPL printers are usually installed without a vendor driver.
+ private const string TextOnlyDriver = "Generic / Text Only";
+
+ ///
+ /// The suggested mode: when the queue or driver name contains ZDesigner, Zebra or ZPL, or the driver
+ /// is Generic / Text Only; otherwise .
+ ///
+ /// Windows queue name.
+ /// Installed driver name.
+ /// Port name; accepted for symmetry with but not used (a USB port says nothing about the language).
+ public static PrinterOutputMode For(string queueName, string driverName, string portName)
+ {
+ // Null-safe on purpose: a driver that reports nothing must not break the print dialog.
+ queueName ??= string.Empty;
+ driverName ??= string.Empty;
+
+ foreach (var marker in ZplNameMarkers)
+ {
+ if (queueName.Contains(marker, StringComparison.OrdinalIgnoreCase) || driverName.Contains(marker, StringComparison.OrdinalIgnoreCase))
+ return PrinterOutputMode.PrinterLanguage;
+ }
+
+ return driverName.Contains(TextOnlyDriver, StringComparison.OrdinalIgnoreCase)
+ ? PrinterOutputMode.PrinterLanguage
+ : PrinterOutputMode.WindowsDriver;
+ }
+}
diff --git a/src/Rotula.Printing/Rotula.Printing.csproj b/src/Rotula.Printing/Rotula.Printing.csproj
index 02d6b56..460d359 100644
--- a/src/Rotula.Printing/Rotula.Printing.csproj
+++ b/src/Rotula.Printing/Rotula.Printing.csproj
@@ -2,6 +2,8 @@
+
+
diff --git a/src/Rotula.Printing/SpoolerApi.cs b/src/Rotula.Printing/SpoolerApi.cs
new file mode 100644
index 0000000..c91f03c
--- /dev/null
+++ b/src/Rotula.Printing/SpoolerApi.cs
@@ -0,0 +1,79 @@
+using System.Runtime.InteropServices;
+
+namespace Rotula.Printing;
+
+///
+/// The few winspool calls a RAW print job needs, as a seam: the real implementation is , tests pass a fake
+/// that records the order of the calls. Kept deliberately small and internal so it does not become public API.
+///
+internal interface ISpoolerApi
+{
+ /// Opens the queue. False when it cannot be opened (read ).
+ bool Open(string printerName, out IntPtr handle);
+
+ /// True when Windows reports the open queue as offline; false when it is online or the state cannot be read.
+ bool IsOffline(IntPtr handle);
+
+ /// Starts a RAW document. False on failure.
+ bool StartDoc(IntPtr handle, string documentName);
+
+ /// Starts the page of the document. False on failure.
+ bool StartPage(IntPtr handle);
+
+ /// Writes bytes of from ; is how many were taken.
+ bool Write(IntPtr handle, byte[] data, int offset, int count, out int written);
+
+ /// Ends the page.
+ bool EndPage(IntPtr handle);
+
+ /// Ends the document, which commits it to the printer.
+ bool EndDoc(IntPtr handle);
+
+ /// Discards the document (and its spool file) instead of committing it.
+ bool Abort(IntPtr handle);
+
+ /// Closes the queue handle.
+ void Close(IntPtr handle);
+
+ /// The Win32 error code of the last failed call on this thread. Read it right after the failure, before another call.
+ int LastError();
+}
+
+/// The real winspool.drv implementation of .
+internal sealed class Win32SpoolerApi : ISpoolerApi
+{
+ /// The shared stateless instance.
+ internal static readonly Win32SpoolerApi Instance = new();
+
+ private Win32SpoolerApi() { }
+
+ public bool Open(string printerName, out IntPtr handle) => NativeMethods.OpenPrinter(printerName, out handle, IntPtr.Zero);
+
+ public bool IsOffline(IntPtr handle) => WinspoolRawPrinter.IsQueueOffline(handle);
+
+ public bool StartDoc(IntPtr handle, string documentName)
+ {
+ var doc = new NativeMethods.DOC_INFO_1 { pDocName = documentName, pOutputFile = null, pDatatype = "RAW" };
+ return NativeMethods.StartDocPrinter(handle, 1, ref doc) != 0;
+ }
+
+ public bool StartPage(IntPtr handle) => NativeMethods.StartPagePrinter(handle);
+
+ public bool Write(IntPtr handle, byte[] data, int offset, int count, out int written)
+ {
+ // Pinned only for the duration of one write, so a long job does not hold the array against the garbage collector.
+ var pin = GCHandle.Alloc(data, GCHandleType.Pinned);
+ try { return NativeMethods.WritePrinter(handle, Marshal.UnsafeAddrOfPinnedArrayElement(data, offset), count, out written); }
+ finally { pin.Free(); }
+ }
+
+ public bool EndPage(IntPtr handle) => NativeMethods.EndPagePrinter(handle);
+
+ public bool EndDoc(IntPtr handle) => NativeMethods.EndDocPrinter(handle);
+
+ public bool Abort(IntPtr handle) => NativeMethods.AbortPrinter(handle);
+
+ public void Close(IntPtr handle) => NativeMethods.ClosePrinter(handle);
+
+ public int LastError() => Marshal.GetLastWin32Error();
+}
diff --git a/src/Rotula.Printing/TcpRawPrinter.cs b/src/Rotula.Printing/TcpRawPrinter.cs
index c4e568c..022b025 100644
--- a/src/Rotula.Printing/TcpRawPrinter.cs
+++ b/src/Rotula.Printing/TcpRawPrinter.cs
@@ -13,7 +13,9 @@ public async Task SendAsync(string host, int port, byte[] data, Net
if (string.IsNullOrWhiteSpace(host) || port is < 1 or > 65535)
return PrintResult.Fail(PrintErrorKind.HostUnreachable, host ?? string.Empty, port.ToString());
- int sent = 0;
+ // Bytes that were handed to the socket or may have been: the chunk being written counts too. A cancel or a timeout in the
+ // middle of a chunk leaves part of it in the kernel buffer, and a half-sent label can still print.
+ var reached = new System.Runtime.CompilerServices.StrongBox(0);
// Why two linked sources: a timeout and a caller cancel both surface as OperationCanceledException,
// and the result must tell them apart (timeout vs. cancelled by the user).
using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
@@ -28,40 +30,55 @@ public async Task SendAsync(string host, int port, byte[] data, Net
var stream = client.GetStream();
try
{
- // Chunked so BytesSent reports real progress when the printer stops accepting data.
- const int chunk = 16 * 1024;
- while (sent < data.Length)
- {
- int n = Math.Min(chunk, data.Length - sent);
- await stream.WriteAsync(data.AsMemory(sent, n), sendCts.Token).ConfigureAwait(false);
- sent += n;
- }
- await stream.FlushAsync(sendCts.Token).ConfigureAwait(false);
+ await WriteAllAsync(stream, data, Chunk, reached, sendCts.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
- return PrintResult.Fail(PrintError.Create(PrintErrorKind.Timeout, host, port.ToString()), sent);
+ return PrintResult.Fail(PrintError.Create(PrintErrorKind.Timeout, host, port.ToString()), reached.Value);
}
// Half-close so the printer sees a clean end of stream before the socket is torn down;
// an abrupt close can make some stacks discard unsent data.
try { client.Client.Shutdown(SocketShutdown.Send); } catch (SocketException) { }
- return PrintResult.Ok(sent);
+ return PrintResult.Ok(data.Length);
}
catch (OperationCanceledException)
{
return cancellationToken.IsCancellationRequested
- ? PrintResult.Fail(PrintError.Create(PrintErrorKind.Cancelled), sent)
- : PrintResult.Fail(PrintError.Create(PrintErrorKind.Timeout, host, port.ToString()), sent);
+ ? PrintResult.Fail(PrintError.Create(PrintErrorKind.Cancelled), reached.Value)
+ : PrintResult.Fail(PrintError.Create(PrintErrorKind.Timeout, host, port.ToString()), reached.Value);
}
catch (SocketException ex)
{
- return PrintResult.Fail(Map(ex, host, port), sent);
+ return PrintResult.Fail(Map(ex, host, port), reached.Value);
}
catch (IOException ex) when (ex.InnerException is SocketException se)
{
- return PrintResult.Fail(Map(se, host, port), sent);
+ return PrintResult.Fail(Map(se, host, port), reached.Value);
+ }
+ }
+
+ private const int Chunk = 16 * 1024;
+
+ ///
+ /// Writes in chunks so the byte count reflects real progress when the printer stops accepting data.
+ /// is updated BEFORE each write with the number of bytes handed over so far including the chunk being
+ /// written: when the write is cut short, that chunk may be partly in the socket, so the caller must treat those bytes as sent
+ /// (counting only finished chunks would report 0 for a cancel during the first chunk and let serial numbers be handed out twice).
+ ///
+ internal static async Task WriteAllAsync(Stream stream, byte[] data, int chunkSize, System.Runtime.CompilerServices.StrongBox reached, CancellationToken ct)
+ {
+ int sent = 0;
+ while (sent < data.Length)
+ {
+ // Checked between chunks only: nothing is in flight here, so the count stays exact when the cancel arrives at this point.
+ ct.ThrowIfCancellationRequested();
+ int n = Math.Min(chunkSize, data.Length - sent);
+ reached.Value = sent + n;
+ await stream.WriteAsync(data.AsMemory(sent, n), ct).ConfigureAwait(false);
+ sent += n;
}
+ await stream.FlushAsync(ct).ConfigureAwait(false);
}
internal static PrintError Map(SocketException ex, string host, int port) => ex.SocketErrorCode switch
diff --git a/src/Rotula.Printing/WinspoolRawPrinter.cs b/src/Rotula.Printing/WinspoolRawPrinter.cs
index 2536502..c646632 100644
--- a/src/Rotula.Printing/WinspoolRawPrinter.cs
+++ b/src/Rotula.Printing/WinspoolRawPrinter.cs
@@ -45,51 +45,60 @@ public Task> GetPrintersAsync(CancellationToken cance
return Task.Run(() => (IReadOnlyList)Enumerate(cancellationToken), cancellationToken);
}
- private static PrintResult SendCore(string printerName, string documentName, byte[] data, CancellationToken ct)
+ private static PrintResult SendCore(string printerName, string documentName, byte[] data, CancellationToken ct) =>
+ SendCore(Win32SpoolerApi.Instance, printerName, documentName, data, ct);
+
+ ///
+ /// The whole spooler job against an , so the order of the winspool calls can be tested without a printer.
+ /// A job that did not finish is aborted, never ended: EndDocPrinter would commit the truncated bytes, and a printer that
+ /// received ^XA but not ^XZ keeps waiting for the rest of the format and swallows the next job.
+ ///
+ internal static PrintResult SendCore(ISpoolerApi api, string printerName, string documentName, byte[] data, CancellationToken ct)
{
- if (!NativeMethods.OpenPrinter(printerName, out var handle, IntPtr.Zero))
- return PrintResult.Fail(PrinterErrors.FromWin32(Marshal.GetLastWin32Error(), printerName));
+ if (!api.Open(printerName, out var handle))
+ return PrintResult.Fail(PrinterErrors.FromWin32(api.LastError(), printerName));
int written = 0;
+ bool jobStarted = false, jobEnded = false;
try
{
// Check status first: Windows happily accepts jobs for an unplugged USB printer and they then
// sit in the queue forever, which is worse for the user than an immediate "offline" message.
- if (TryGetState(handle, out _, out _, out bool offline) && offline)
+ if (api.IsOffline(handle))
return PrintResult.Fail(PrintErrorKind.Offline, printerName);
- var doc = new NativeMethods.DOC_INFO_1 { pDocName = string.IsNullOrEmpty(documentName) ? "Rotula label" : documentName, pOutputFile = null, pDatatype = "RAW" };
- if (NativeMethods.StartDocPrinter(handle, 1, ref doc) == 0)
- return PrintResult.Fail(PrinterErrors.FromWin32(Marshal.GetLastWin32Error(), printerName));
- try
+ if (!api.StartDoc(handle, string.IsNullOrEmpty(documentName) ? "Rotula label" : documentName))
+ return PrintResult.Fail(PrinterErrors.FromWin32(api.LastError(), printerName));
+ jobStarted = true;
+
+ if (!api.StartPage(handle))
+ return PrintResult.Fail(PrinterErrors.FromWin32(api.LastError(), printerName));
+
+ while (written < data.Length)
+ {
+ if (ct.IsCancellationRequested) return PrintResult.Fail(PrintError.Create(PrintErrorKind.Cancelled), written);
+ int n = Math.Min(WriteChunk, data.Length - written);
+ if (!api.Write(handle, data, written, n, out int done) || done <= 0)
+ return PrintResult.Fail(PrinterErrors.FromWin32(api.LastError(), printerName), written);
+ written += done;
+ }
+
+ api.EndPage(handle);
+ if (!api.EndDoc(handle))
{
- if (!NativeMethods.StartPagePrinter(handle))
- return PrintResult.Fail(PrinterErrors.FromWin32(Marshal.GetLastWin32Error(), printerName));
- try
- {
- var buffer = GCHandle.Alloc(data, GCHandleType.Pinned);
- try
- {
- while (written < data.Length)
- {
- if (ct.IsCancellationRequested) return PrintResult.Fail(PrintError.Create(PrintErrorKind.Cancelled), written);
- int n = Math.Min(WriteChunk, data.Length - written);
- var ptr = Marshal.UnsafeAddrOfPinnedArrayElement(data, written);
- if (!NativeMethods.WritePrinter(handle, ptr, n, out int done) || done <= 0)
- return PrintResult.Fail(PrinterErrors.FromWin32(Marshal.GetLastWin32Error(), printerName), written);
- written += done;
- }
- }
- finally { buffer.Free(); }
- }
- finally { NativeMethods.EndPagePrinter(handle); }
+ // The spooler could not close the document, so whether it prints is unknown. Reporting the written bytes makes the
+ // caller treat any serial numbers as used, which is the safe side.
+ return PrintResult.Fail(PrinterErrors.FromWin32(api.LastError(), printerName), written);
}
- finally { NativeMethods.EndDocPrinter(handle); }
+ jobEnded = true;
return PrintResult.Ok(written);
}
finally
{
- NativeMethods.ClosePrinter(handle);
+ // Any exit that is not a finished document discards the spool file. The failure result keeps the bytes already written so
+ // that serial numbers are not given back for a printer that was fed directly (no spooling) and may have printed.
+ if (jobStarted && !jobEnded) api.Abort(handle);
+ api.Close(handle);
}
}
@@ -236,6 +245,10 @@ internal struct PRINTER_INFO_2
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool EndDocPrinter(IntPtr hPrinter);
+ [DllImport("winspool.drv", SetLastError = true)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ internal static extern bool AbortPrinter(IntPtr hPrinter);
+
[DllImport("winspool.drv", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool StartPagePrinter(IntPtr hPrinter);
diff --git a/src/Rotula.Rendering/Device/RenderLabelScaler.cs b/src/Rotula.Rendering/Device/RenderLabelScaler.cs
new file mode 100644
index 0000000..c316140
--- /dev/null
+++ b/src/Rotula.Rendering/Device/RenderLabelScaler.cs
@@ -0,0 +1,192 @@
+using Rotula.Rendering.Input;
+
+namespace Rotula.Rendering.Device;
+
+///
+/// Rescales a to any printer resolution (150, 300, 600, 1200 dpi ...) so the label keeps its
+/// physical size, and so every barcode module stays a whole number of device dots (minimum 1).
+///
+/// The designer only builds render labels at the document's own resolution (203, 300 or 600 dpi). A printer driver can
+/// report any resolution, so the device renderer first calls and then draws the result with the
+/// ordinary renderer. The rules mirror LabelDpiConverter in Rotula.Core, which works on the document model and is tied
+/// to its three-value resolution enum, so it cannot be used here.
+///
+///
+public static class RenderLabelScaler
+{
+ ///
+ /// Returns redrawn at : every length, position and size is multiplied by
+ /// targetDpi / label.Dpi (one ratio for everything, rounded half away from zero), and barcode modules become whole dots
+ /// of at least 1. The input is never changed. When the resolution already matches, the same instance is returned.
+ ///
+ /// The label at its own resolution.
+ /// The resolution to draw at, in dots per inch; must be positive.
+ /// is null.
+ /// or the label's own resolution is not positive.
+ public static RenderLabel ToResolution(RenderLabel label, int targetDpi)
+ {
+ ArgumentNullException.ThrowIfNull(label);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(targetDpi);
+ ArgumentOutOfRangeException.ThrowIfNegativeOrZero(label.Dpi);
+ if (targetDpi == label.Dpi) return label;
+
+ // One ratio for every quantity, so rotated and mirrored geometry stays congruent: a rotation is an angle and is
+ // never scaled, and (X, Y) rotates with the element's own box exactly as it did at the original resolution.
+ double ratio = (double)targetDpi / label.Dpi;
+
+ var elements = new List(label.Elements.Count);
+ foreach (var element in label.Elements)
+ elements.Add(ScaleElement(element, ratio));
+
+ // 'with' keeps the whole-label flags (Inverted, Mirrored) that are not constructor parameters.
+ return label with
+ {
+ WidthDots = ToDeviceDots(label.WidthDots, ratio, 1),
+ HeightDots = ToDeviceDots(label.HeightDots, ratio, 1),
+ Dpi = targetDpi,
+ Elements = elements,
+ };
+ }
+
+ ///
+ /// Scales a dot quantity to whole device dots: times , rounded half away from zero (the rule
+ /// LabelDpiConverter uses), never below . This is the one rounding rule for the label size and for barcode modules; the
+ /// driver pre-flight calls it too, so the module and picture size it judges are exactly the ones drawn.
+ ///
+ /// The quantity in dots at the original resolution.
+ /// Target resolution divided by the original resolution.
+ /// Smallest result (1 keeps a thin bar or a tiny label from vanishing).
+ public static int ToDeviceDots(double value, double ratio, int min)
+ {
+ double scaled = Math.Round(value * ratio, MidpointRounding.AwayFromZero);
+ // Clamped before the cast so an absurd size saturates (and is then refused by a size check) instead of wrapping around.
+ return Math.Max(min, (int)Math.Clamp(scaled, int.MinValue, int.MaxValue));
+ }
+
+ ///
+ /// Scales a length that may be zero or negative. Zero stays zero because it means "none" or "unlimited" (no wrapping, no
+ /// height limit, square corners), and a positive length never collapses below so a thin rule
+ /// does not vanish at a lower resolution.
+ ///
+ private static double Length(double value, double ratio, double min = 0)
+ {
+ if (value == 0) return 0;
+ double scaled = Math.Round(value * ratio, MidpointRounding.AwayFromZero);
+ return value > 0 ? Math.Max(min, scaled) : scaled;
+ }
+
+ /// Scales a coordinate or a signed offset; there is no lower bound because 0 and negative values are valid.
+ private static double Offset(double value, double ratio) =>
+ Math.Round(value * ratio, MidpointRounding.AwayFromZero);
+
+ private static RenderElement ScaleElement(RenderElement element, double ratio)
+ {
+ switch (element)
+ {
+ case RenderText t:
+ return t with
+ {
+ X = Offset(t.X, ratio),
+ Y = Offset(t.Y, ratio),
+ // A bundled stand-in font is sized in whole dots like ZPL's ^A height. A Windows font is sized in
+ // points, which are physical; its em size in dots is kept exact so the driver's (vector) text
+ // lands at the true point size instead of a rounded one.
+ FontHeightDots = t.FamilyName is null ? Length(t.FontHeightDots, ratio, 1) : t.FontHeightDots * ratio,
+ HangingIndentDots = Offset(t.HangingIndentDots, ratio),
+ WidthDots = Length(t.WidthDots, ratio, 1),
+ HeightDots = Length(t.HeightDots, ratio, 1),
+ // May be negative (tighter lines), so it is an offset rather than a length.
+ LineSpacingDots = Offset(t.LineSpacingDots, ratio),
+ // MaxLines and Alignment are counts and enums: nothing resolution-dependent.
+ };
+
+ case RenderPlaceholder p:
+ // A preview-only stand-in box; it is scaled like any other box so its position still matches the label.
+ return p with
+ {
+ X = Offset(p.X, ratio),
+ Y = Offset(p.Y, ratio),
+ WidthDots = Length(p.WidthDots, ratio, 1),
+ HeightDots = Length(p.HeightDots, ratio, 1),
+ };
+
+ case RenderBox b:
+ return b with
+ {
+ X = Offset(b.X, ratio),
+ Y = Offset(b.Y, ratio),
+ WidthDots = Length(b.WidthDots, ratio, 1),
+ HeightDots = Length(b.HeightDots, ratio, 1),
+ ThicknessDots = Length(b.ThicknessDots, ratio, 1),
+ CornerRadiusDots = Length(b.CornerRadiusDots, ratio),
+ };
+
+ case RenderCircle c:
+ return c with
+ {
+ X = Offset(c.X, ratio),
+ Y = Offset(c.Y, ratio),
+ DiameterDots = Length(c.DiameterDots, ratio, 1),
+ ThicknessDots = Length(c.ThicknessDots, ratio, 1),
+ };
+
+ case RenderEllipse e:
+ return e with
+ {
+ X = Offset(e.X, ratio),
+ Y = Offset(e.Y, ratio),
+ WidthDots = Length(e.WidthDots, ratio, 1),
+ HeightDots = Length(e.HeightDots, ratio, 1),
+ ThicknessDots = Length(e.ThicknessDots, ratio, 1),
+ };
+
+ case RenderDiagonal d:
+ // LeansRight is a direction, not a length, and is kept by the 'with' copy.
+ return d with
+ {
+ X = Offset(d.X, ratio),
+ Y = Offset(d.Y, ratio),
+ WidthDots = Length(d.WidthDots, ratio, 1),
+ HeightDots = Length(d.HeightDots, ratio, 1),
+ ThicknessDots = Length(d.ThicknessDots, ratio, 1),
+ };
+
+ case RenderLine l:
+ return l with
+ {
+ X = Offset(l.X, ratio),
+ Y = Offset(l.Y, ratio),
+ // Signed extents: the sign is the direction of the line and must survive.
+ DeltaX = Offset(l.DeltaX, ratio),
+ DeltaY = Offset(l.DeltaY, ratio),
+ ThicknessDots = Length(l.ThicknessDots, ratio, 1),
+ };
+
+ case RenderBarcode bc:
+ // Only the module width and the bar height are dot measures. DataBarSeparatorHeight is counted in
+ // modules, so it follows the module size by itself; every other property is a symbology option.
+ return bc with
+ {
+ X = Offset(bc.X, ratio),
+ Y = Offset(bc.Y, ratio),
+ // Whole dots, never 0: a zero-width bar would erase the symbol and a fractional one blurs it.
+ ModuleDots = ToDeviceDots(bc.ModuleDots, ratio, 1),
+ HeightDots = ToDeviceDots(bc.HeightDots, ratio, 1),
+ };
+
+ case RenderImage im:
+ // The bitmap bytes are shared untouched (they are already dithered dots); only the size they are drawn at changes.
+ return im with
+ {
+ X = Offset(im.X, ratio),
+ Y = Offset(im.Y, ratio),
+ WidthDots = Length(im.WidthDots, ratio, 1),
+ HeightDots = Length(im.HeightDots, ratio, 1),
+ };
+
+ default:
+ // A new element kind must decide how it scales; silently passing it through would print it at the wrong size.
+ throw new NotSupportedException($"Element type {element.GetType().Name} is not supported by the render label scaler.");
+ }
+ }
+}
diff --git a/src/Rotula.Rendering/Input/ImageRenderCache.cs b/src/Rotula.Rendering/Input/ImageRenderCache.cs
new file mode 100644
index 0000000..9f71cc9
--- /dev/null
+++ b/src/Rotula.Rendering/Input/ImageRenderCache.cs
@@ -0,0 +1,155 @@
+using System.Runtime.CompilerServices;
+using Rotula.Core.Elements;
+using Rotula.Core.Imaging;
+using Rotula.Core.Model;
+
+namespace Rotula.Rendering.Input;
+
+///
+/// Limits and memoisation for the preview of image elements.
+///
+/// Limit. Scaling and dithering allocate several buffers of width * height pixels, so a design that asks for a huge target
+/// (a hostile file, or a typo) must be refused before anything is allocated. The sizes are checked with 64-bit arithmetic because the
+/// 32-bit product wraps around: 65536 x 65536 is 0 as an .
+///
+///
+/// Cache. Dragging an object re-renders the whole label on every mouse move. Without a cache the scale, the error-diffusion dither,
+/// the rotation and the PNG encoding of every image on the label were repeated for each frame although nothing about the picture changed.
+/// The key is the identity of the source bitmap (it is immutable, so identity is as good as content and costs nothing) plus every setting
+/// that changes the dots. Memory is bounded and least recently used entries go first.
+///
+///
+internal static class ImageRenderCache
+{
+ ///
+ /// Largest target, in pixels, that is dithered for the preview. 40 million pixels is far more than any label printed at 600 dpi
+ /// (a full 4 x 6 inch label is about 8.6 million) and keeps the transient buffers of the dither near 360 MB.
+ /// It is the same limit as the pixel cap of ImageProcessor in Rotula.Core (kept as its own constant because Rendering must
+ /// check before calling Core, so that the preview draws a placeholder instead of catching the exception Core throws above it).
+ ///
+ internal const long MaxPixels = 40_000_000;
+
+ /// Upper bound of the memory held by the cache, in bytes. Not a constant so a test can shrink it.
+ internal static long MaxBytes { get; set; } = 64L * 1024 * 1024;
+
+ /// Bytes currently held by the cache.
+ internal static long CurrentBytes { get { lock (Gate) return _bytes; } }
+
+ /// Test hook: called each time a picture is really computed (a cache miss) with the source it was computed from.
+ internal static Action? Computed { get; set; }
+
+ /// The result of preparing an image element.
+ /// The monochrome picture as a PNG; empty when .
+ /// Width of the picture after rotation, in dots.
+ /// Height of the picture after rotation, in dots.
+ /// True when the target is above and nothing was computed.
+ internal readonly record struct Prepared(byte[] EncodedBytes, int Width, int Height, bool TooLarge);
+
+ private readonly record struct Key(long Source, int Width, int Height, ImageDitherMode Dither, int Threshold, bool Invert, FieldRotation Rotation);
+
+ private sealed class Entry
+ {
+ public required Key Key { get; init; }
+ public required byte[] Png { get; init; }
+ public required int Width { get; init; }
+ public required int Height { get; init; }
+
+ /// The array plus a rough allowance for the entry and the dictionary slot.
+ public long Cost => Png.Length + 128;
+ }
+
+ private sealed class SourceId
+ {
+ public readonly long Value = Interlocked.Increment(ref _lastId);
+ }
+
+ private static long _lastId;
+ private static readonly object Gate = new();
+ // Weak association: a bitmap that is no longer used by any document is free to be collected, its entries age out of the LRU list.
+ private static readonly ConditionalWeakTable Ids = new();
+ private static readonly Dictionary> Map = new();
+ private static readonly LinkedList Lru = new(); // first = most recently used
+ private static long _bytes;
+
+ /// The target size of an element in 64-bit arithmetic, with the rule of (0 keeps the aspect ratio).
+ internal static (long Width, long Height) ResolveSize(ImageElement img)
+ {
+ var src = img.Source!;
+ long w = img.WidthDots, h = img.HeightDots;
+ if (w <= 0 && h <= 0) return (src.Width, src.Height);
+ if (w <= 0) w = Math.Max(1, (long)Math.Round((double)src.Width * h / src.Height, MidpointRounding.AwayFromZero));
+ if (h <= 0) h = Math.Max(1, (long)Math.Round((double)src.Height * w / src.Width, MidpointRounding.AwayFromZero));
+ return (w, h);
+ }
+
+ ///
+ /// True when the target size of is empty or above , so nothing may be dithered and the image is
+ /// shown as a stand-in. Only arithmetic: it can be asked (by the driver pre-flight, through the adapter) without preparing the picture.
+ /// The element must have a source.
+ ///
+ internal static bool IsTooLarge(ImageElement img)
+ {
+ var (w, h) = ResolveSize(img);
+ // Both sides are below the cap before multiplying, so the product cannot overflow a long either.
+ return w < 1 || h < 1 || w > MaxPixels || h > MaxPixels || w * h > MaxPixels;
+ }
+
+ ///
+ /// Returns the printable dots of an image element as a PNG, from the cache when the same picture was prepared before.
+ /// Never throws for a size problem: an oversize target (see ) comes back with set.
+ /// The element must have a source.
+ ///
+ internal static Prepared Prepare(ImageElement img)
+ {
+ var src = img.Source ?? throw new ArgumentException("The image element has no source.", nameof(img));
+ if (IsTooLarge(img)) return new Prepared([], 0, 0, true);
+ var (w, h) = ResolveSize(img);
+
+ var rotation = img.Rotation ?? FieldRotation.Normal;
+ var key = new Key(Ids.GetValue(src, _ => new SourceId()).Value, (int)w, (int)h, img.Dither, img.Threshold, img.Invert, rotation);
+ lock (Gate)
+ {
+ if (Map.TryGetValue(key, out var hit))
+ {
+ Lru.Remove(hit);
+ Lru.AddFirst(hit);
+ return new Prepared(hit.Value.Png, hit.Value.Width, hit.Value.Height, false);
+ }
+ }
+
+ // The expensive part runs outside the lock so two pictures can be prepared at the same time. If two threads prepare the same
+ // one, both results are identical and the second insert simply keeps the first.
+ var mono = ImageProcessor.ToMonochrome(img);
+ var entry = new Entry { Key = key, Png = LabelDocumentAdapter.EncodeMonochrome(mono), Width = mono.Width, Height = mono.Height };
+ Computed?.Invoke(src);
+ Store(entry);
+ return new Prepared(entry.Png, entry.Width, entry.Height, false);
+ }
+
+ private static void Store(Entry entry)
+ {
+ lock (Gate)
+ {
+ if (entry.Cost > MaxBytes || Map.ContainsKey(entry.Key)) return;
+ _bytes += entry.Cost;
+ Map[entry.Key] = Lru.AddFirst(entry);
+ while (_bytes > MaxBytes && Lru.Last is { } oldest)
+ {
+ Lru.RemoveLast();
+ Map.Remove(oldest.Value.Key);
+ _bytes -= oldest.Value.Cost;
+ }
+ }
+ }
+
+ /// Drops everything (tests, and a way to give the memory back).
+ internal static void Clear()
+ {
+ lock (Gate)
+ {
+ Map.Clear();
+ Lru.Clear();
+ _bytes = 0;
+ }
+ }
+}
diff --git a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
index 9da7a18..61c0ca6 100644
--- a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
+++ b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
@@ -47,7 +47,23 @@ public static RenderLabel ToRenderLabelResolved(LabelDocument doc, IReadOnlyDict
return Build(doc, values, includeNonPrinting: false);
}
- private static RenderLabel Build(LabelDocument doc, IReadOnlyDictionary? data, bool includeNonPrinting)
+ ///
+ /// Like , but also leaves out every element for which
+ /// is true (the Windows-driver picture uses it for the parts only the printer can draw). The elements are left out
+ /// of the picture, not out of the design: anchored positions are still worked out on the whole design, so an object positioned relative to
+ /// a left-out one stays exactly where the printer puts it. Removing the element from a copy of the document instead would move such objects.
+ ///
+ /// The design.
+ /// The resolved values of this label, or null for the sample values of a label without definitions.
+ /// True for an element that must not be drawn.
+ public static RenderLabel ToRenderLabelResolved(LabelDocument doc, IReadOnlyDictionary? values, Func leaveOut)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ ArgumentNullException.ThrowIfNull(leaveOut);
+ return Build(doc, values, includeNonPrinting: false, leaveOut);
+ }
+
+ private static RenderLabel Build(LabelDocument doc, IReadOnlyDictionary? data, bool includeNonPrinting, Func? leaveOut = null)
{
int dpi = (int)doc.Dpi;
int homeX = doc.Margins.Left.ToDots(doc.Dpi);
@@ -56,15 +72,21 @@ private static RenderLabel Build(LabelDocument doc, IReadOnlyDictionary();
var elements = new List(doc.Elements.Count);
+ // Anchored objects are placed by the same resolver (and the same measurements) the ZPL generator uses for this record, so the picture
+ // shows exactly where the printer puts them.
+ var layout = Core.Layout.AnchorResolver.HasAnchors(doc) ? Core.Layout.AnchorResolver.Resolve(doc, data, FitRasterizer) : null;
foreach (var e in doc.Elements)
{
// Decided on the data that fills this record's placeholders, exactly as the ZPL generator does, so the picture and the print agree.
bool printed = ElementPrinting.IsPrinted(e, data, doc.Variables);
if (!printed && !includeNonPrinting) continue;
+ // After the layout above, which saw every element: leaving one out here never moves the others.
+ if (leaveOut?.Invoke(e) == true) continue;
var mapped = Map(doc, e, data, ignored);
if (mapped is null) continue;
+ var (x, y) = layout?.OriginOf(e) ?? (e.X, e.Y);
// ^LS moves everything to the left, ^LT moves the whole format down (or up when negative).
- elements.Add(mapped with { X = e.X + homeX - doc.LabelShift, Y = e.Y + homeY + doc.LabelTop, Dimmed = !printed });
+ elements.Add(mapped with { X = x + homeX - doc.LabelShift, Y = y + homeY + doc.LabelTop, Dimmed = !printed });
}
return new RenderLabel(doc.WidthDots, doc.HeightDots, dpi, elements);
@@ -115,11 +137,95 @@ public static HashSet FindNotPrinted(LabelDocument doc, IReadOnlyDiction
return doc.Elements.Where(e => !ElementPrinting.IsPrinted(e, resolved, doc.Variables)).Select(e => e.Id).ToHashSet();
}
+ ///
+ /// True when is drawn as a labelled stand-in (a hatched box) instead of its real content, because only the printer
+ /// can draw it: a barcode the renderer has no exact encoder for, an image too large to draw, a recall of a graphic stored on the printer that
+ /// the file does not download, or a raw ZPL block that draws something. A picture-mode print (Windows driver) cannot contain such a part, so the
+ /// driver pre-flight reports it. This is the same decision the render input uses (see ); whether the element is
+ /// printed at all (do not print, print conditions) is not looked at here.
+ ///
+ /// The design the element belongs to (recalls are looked up in its stored graphics).
+ /// The element.
+ public static bool IsDrawnAsPlaceholder(LabelDocument doc, LabelElement element)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ ArgumentNullException.ThrowIfNull(element);
+ return PlaceholderFor(doc, element, typeset: false) is not null;
+ }
+
+ ///
+ /// The one place that decides which elements become a , and builds it. asks it first and
+ /// asks it for the driver pre-flight, so the preview, the exports and the pre-flight can never disagree.
+ /// Every rule depends on the design only, never on a record's values. Returns null for an element that is drawn for real (or not at all).
+ ///
+ private static RenderPlaceholder? PlaceholderFor(LabelDocument doc, LabelElement e, bool typeset)
+ {
+ switch (e)
+ {
+ // Symbologies (or options) without an exact preview are drawn as a labelled placeholder: the printer still
+ // prints the real symbol from the generated ZPL, so the designer must not pretend to show it.
+ case BarcodeElement b when BarcodeCatalog.GetPreviewLimitation(b) is not null:
+ return new RenderPlaceholder
+ {
+ WidthDots = PlaceholderWidth(b),
+ HeightDots = Math.Max(40, b.Height),
+ Caption = BarcodeCatalog.Get(b.Symbology).DisplayName + " - printer draws it",
+ Typeset = typeset,
+ };
+
+ case ImageElement { Source: not null } img when ImageRenderCache.IsTooLarge(img):
+ {
+ // A visible marker instead of an exception or an out-of-memory crash. The box is limited to the label, because
+ // nothing outside it can be seen and a 40000-dot box would only make the hatch drawing slow.
+ var (tw, th) = ImageRenderCache.ResolveSize(img);
+ bool turned = (img.Rotation ?? FieldRotation.Normal) is FieldRotation.Rotated90 or FieldRotation.Rotated270;
+ return new RenderPlaceholder
+ {
+ WidthDots = Math.Clamp(turned ? th : tw, 1, Math.Max(1, doc.WidthDots)),
+ HeightDots = Math.Clamp(turned ? tw : th, 1, Math.Max(1, doc.HeightDots)),
+ Caption = "Image too large to preview",
+ Issue = "An image is too large to draw, so a hatched box is shown in its place. Make the image smaller (width and height in dots).",
+ Typeset = typeset,
+ Reverse = img.ReverseField,
+ };
+ }
+
+ // The stored object is unknown (not downloaded by this file), so the designer's own size is used. The caption says what is recalled.
+ case RecallPlaceholderElement recall when FindStoredGraphic(doc, recall) is null:
+ return new RenderPlaceholder
+ {
+ WidthDots = Math.Max(1, recall.WidthDots),
+ HeightDots = Math.Max(1, recall.HeightDots),
+ Caption = string.IsNullOrWhiteSpace(recall.Reference)
+ ? "Stored on printer (^" + recall.Command + ")"
+ : "Stored on printer: " + recall.Reference,
+ Typeset = typeset,
+ };
+
+ // Only raw blocks that draw something get a marker; configuration commands have no place on the label.
+ // The real size of the content is unknown, so a small fixed marker is drawn at the field position.
+ case RawZplElement { IsVisual: true }:
+ return new RenderPlaceholder
+ {
+ WidthDots = RawMarkerWidthDots,
+ HeightDots = RawMarkerHeightDots,
+ Caption = "Raw ZPL",
+ Typeset = typeset,
+ };
+
+ default:
+ return null;
+ }
+ }
+
private static RenderElement? Map(LabelDocument doc, LabelElement e, IReadOnlyDictionary? data, List issues)
{
var rotation = ToRotation(e.Rotation ?? doc.DefaultRotation);
bool typeset = e.OriginKind == FieldOriginKind.Typeset;
+ // Stand-ins first: every element that only the printer can draw is decided in one place.
+ if (PlaceholderFor(doc, e, typeset) is { } placeholder) return placeholder;
+
switch (e)
{
case TextElement t:
@@ -166,18 +272,7 @@ public static HashSet FindNotPrinted(LabelDocument doc, IReadOnlyDiction
var info = BarcodeCatalog.Get(b.Symbology);
var data1 = Core.Barcodes.QrContent.QrContentBuilder.ResolveForElement(b, data, doc.Variables, issues)
?? VariableResolver.Resolve(b.Data, data, doc.Variables, b.Id, issues);
- // Symbologies (or options) without an exact preview are drawn as a labelled placeholder: the printer still
- // prints the real symbol from the generated ZPL, so the designer must not pretend to show it.
- if (BarcodeCatalog.GetPreviewLimitation(b) is not null)
- {
- return new RenderPlaceholder
- {
- WidthDots = PlaceholderWidth(b),
- HeightDots = Math.Max(40, b.Height),
- Caption = info.DisplayName + " - printer draws it",
- Typeset = typeset,
- };
- }
+ // A symbology (or option) without an exact preview never gets here: PlaceholderFor already made its stand-in.
return new RenderBarcode
{
Symbology = b.Symbology,
@@ -305,58 +400,48 @@ public static HashSet FindNotPrinted(LabelDocument doc, IReadOnlyDiction
case ImageElement img:
{
if (img.Source is null) return null;
- // Use the exact dots the printer will receive, so dithering is previewed faithfully.
- var mono = ImageProcessor.ToMonochrome(img);
+ // Use the exact dots the printer will receive, so dithering is previewed faithfully. The work is cached: it is the same
+ // for every re-render while an object is dragged. An image too large to draw never gets here: PlaceholderFor applied the
+ // same size rule (ImageRenderCache.IsTooLarge) and made its stand-in, so nothing huge is ever prepared.
+ var prepared = ImageRenderCache.Prepare(img);
return new RenderImage
{
- EncodedBytes = EncodeMonochrome(mono),
- WidthDots = mono.Width,
- HeightDots = mono.Height,
+ EncodedBytes = prepared.EncodedBytes,
+ WidthDots = prepared.Width,
+ HeightDots = prepared.Height,
// ^GF ignores field rotation (it is applied to the dots already), so none is passed on.
Typeset = typeset,
Reverse = img.ReverseField,
};
}
- case RecallPlaceholderElement recall when TryMapStoredGraphic(doc, recall, typeset) is { } stored:
- return stored;
-
+ // Only a recall whose graphic this file downloads gets here (PlaceholderFor made the stand-in for the others), so it is drawn exactly.
case RecallPlaceholderElement recall:
- // The stored object is unknown, so the designer's own size is used. The caption says what is recalled.
- return new RenderPlaceholder
- {
- WidthDots = Math.Max(1, recall.WidthDots),
- HeightDots = Math.Max(1, recall.HeightDots),
- Caption = string.IsNullOrWhiteSpace(recall.Reference)
- ? "Stored on printer (^" + recall.Command + ")"
- : "Stored on printer: " + recall.Reference,
- Typeset = typeset,
- };
-
- // Only raw blocks that draw something get a marker; configuration commands have no place on the label.
- // The real size of the content is unknown, so a small fixed marker is drawn at the field position.
- case RawZplElement { IsVisual: true }:
- return new RenderPlaceholder
- {
- WidthDots = RawMarkerWidthDots,
- HeightDots = RawMarkerHeightDots,
- Caption = "Raw ZPL",
- Typeset = typeset,
- };
+ return TryMapStoredGraphic(doc, recall, typeset);
+ // Raw blocks that draw something were turned into a marker by PlaceholderFor; configuration blocks draw nothing.
default:
return null;
}
}
+ ///
+ /// The graphic a recall prints when it can be drawn: only ^XG and ^IM recall a graphic, and only one the same file downloads
+ /// with ~DG has known dots. Null for every other recall (^IL, ^XF, or a graphic that lives only on the printer).
+ ///
+ private static StoredGraphic? FindStoredGraphic(LabelDocument doc, RecallPlaceholderElement recall)
+ {
+ if (!recall.Command.Equals("XG", StringComparison.OrdinalIgnoreCase) && !recall.Command.Equals("IM", StringComparison.OrdinalIgnoreCase)) return null;
+ return StoredGraphic.Find(doc.StoredGraphics, recall.Reference);
+ }
+
///
/// Draws a recalled graphic (^XG, ^IM) when the same file downloads it with ~DG: the dots are known,
/// so the preview is exact. The magnification is applied by repeating dots, as the printer does.
///
private static RenderElement? TryMapStoredGraphic(LabelDocument doc, RecallPlaceholderElement recall, bool typeset)
{
- if (!recall.Command.Equals("XG", StringComparison.OrdinalIgnoreCase) && !recall.Command.Equals("IM", StringComparison.OrdinalIgnoreCase)) return null;
- var graphic = StoredGraphic.Find(doc.StoredGraphics, recall.Reference);
+ var graphic = FindStoredGraphic(doc, recall);
if (graphic is null) return null;
var (mx, my) = recall.GetMagnification();
@@ -413,7 +498,7 @@ public static HashSet FindNotPrinted(LabelDocument doc, IReadOnlyDiction
};
/// Encodes packed 1-bit dots (1 = black) as a grey PNG.
- private static byte[] EncodeMonochrome(MonochromeBitmap mono)
+ internal static byte[] EncodeMonochrome(MonochromeBitmap mono)
{
using var bmp = new SKBitmap(new SKImageInfo(mono.Width, mono.Height, SKColorType.Gray8, SKAlphaType.Opaque));
var pixels = new byte[mono.Width * mono.Height];
diff --git a/src/Rotula.Rendering/Input/RenderModel.cs b/src/Rotula.Rendering/Input/RenderModel.cs
index 72e479c..64cc094 100644
--- a/src/Rotula.Rendering/Input/RenderModel.cs
+++ b/src/Rotula.Rendering/Input/RenderModel.cs
@@ -167,6 +167,12 @@ public sealed record RenderPlaceholder : RenderElement
/// Short caption drawn inside the box (for example the recalled object name). May be empty.
public string Caption { get; init; } = string.Empty;
+
+ ///
+ /// When set, the renderer reports this text as a so that an export (PNG, PDF, sheets) tells the person the
+ /// box is not the real content. Null for ordinary stand-ins such as recalled objects, which are expected.
+ ///
+ public string? Issue { get; init; }
}
/// A rectangle outline or filled box.
diff --git a/src/Rotula.Rendering/LabelRenderer.cs b/src/Rotula.Rendering/LabelRenderer.cs
index ab4d0de..64af13e 100644
--- a/src/Rotula.Rendering/LabelRenderer.cs
+++ b/src/Rotula.Rendering/LabelRenderer.cs
@@ -177,7 +177,7 @@ public virtual void Dispose() { }
{
RenderText t => new TextLayout(t),
RenderBox b => new BoxLayout(b),
- RenderPlaceholder p => new PlaceholderLayout(p),
+ RenderPlaceholder p => new PlaceholderLayout(p, issues),
RenderCircle c => new CircleLayout(c),
RenderLine l => new LineLayout(l),
RenderEllipse el => new EllipseLayout(el),
@@ -296,11 +296,12 @@ private sealed class PlaceholderLayout : Layout
{
private readonly RenderPlaceholder _p;
- public PlaceholderLayout(RenderPlaceholder p)
+ public PlaceholderLayout(RenderPlaceholder p, List issues)
{
_p = p;
Width = Math.Max(0, p.WidthDots);
Height = Math.Max(0, p.HeightDots);
+ if (!string.IsNullOrEmpty(p.Issue)) issues.Add(new RenderIssue(p, p.Issue));
}
public override void Paint(SKCanvas canvas)
@@ -498,18 +499,35 @@ public ImageLayout(RenderImage im, List issues)
_im = im;
Width = Math.Max(0, im.WidthDots);
Height = Math.Max(0, im.HeightDots);
- using var bmp = TryDecode(im.EncodedBytes);
- if (bmp is null)
+ using var bmp = TryDecode(im.EncodedBytes, out bool tooLarge);
+ if (tooLarge)
+ issues.Add(new RenderIssue(im, "The image is too large to preview. Use a smaller image file."));
+ else if (bmp is null)
issues.Add(new RenderIssue(im, "The image could not be read. Choose the image file again."));
else
_image = SKImage.FromBitmap(bmp);
}
- /// SkiaSharp throws for unrecognised data instead of returning null, so translate that into null.
- private static SKBitmap? TryDecode(byte[] bytes)
+ ///
+ /// SkiaSharp throws for unrecognised data instead of returning null, so translate that into null. The size is read from the
+ /// header first: a few bytes can claim a picture of billions of pixels, and decoding it would try to allocate that much.
+ ///
+ private static SKBitmap? TryDecode(byte[] bytes, out bool tooLarge)
{
+ tooLarge = false;
if (bytes.Length == 0) return null;
- try { return SKBitmap.Decode(bytes); }
+ try
+ {
+ using (var codec = SKCodec.Create(new SKMemoryStream(bytes)))
+ {
+ if (codec is not null && (long)codec.Info.Width * codec.Info.Height > Input.ImageRenderCache.MaxPixels)
+ {
+ tooLarge = true;
+ return null;
+ }
+ }
+ return SKBitmap.Decode(bytes);
+ }
catch (ArgumentNullException) { return null; }
}
diff --git a/tests/Rotula.Tests/AppBarcodeSizeTests.cs b/tests/Rotula.Tests/AppBarcodeSizeTests.cs
new file mode 100644
index 0000000..ef67e1d
--- /dev/null
+++ b/tests/Rotula.Tests/AppBarcodeSizeTests.cs
@@ -0,0 +1,201 @@
+using Rotula.App.Editing;
+using Rotula.App.Localization;
+using Rotula.App.ViewModels;
+using Rotula.Core.Barcodes;
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+
+namespace Rotula.Tests;
+
+/// The size readout, the module stepper and the quiet-zone guide of the barcode property panel.
+[Collection("App")]
+public class AppBarcodeSizeTests : IDisposable
+{
+ public AppBarcodeSizeTests() => Loc.Instance.SetLanguage("en");
+
+ public void Dispose() => Loc.Instance.SetLanguage("en");
+
+ private static (PropertyPanelViewModel Panel, EditorSession Session, IntRow Module) Open(BarcodeElement element, PrinterDpi dpi = PrinterDpi.Dpi203)
+ {
+ var s = AppTestSupport.Session(element);
+ s.Document.Dpi = dpi;
+ var p = new PropertyPanelViewModel(s);
+ s.Select(element);
+ var key = BarcodeCatalog.Get(element.Symbology).Supports(BarcodeOption.ModuleWidth) ? "Prop.ModuleWidth" : "Prop.ModuleSize";
+ return (p, s, p.Rows.OfType().First(r => r.LabelKey == key));
+ }
+
+ private static BarcodeElement Now(EditorSession s) => (BarcodeElement)s.Document.Elements[0];
+
+ private static BarcodeElement Code128(string data = "ABC", int module = 2) =>
+ new() { Symbology = BarcodeSymbology.Code128, Data = data, ModuleWidth = module, X = 100, Y = 50 };
+
+ [Fact]
+ public void TheModuleRow_ShowsTheReadoutAndTheQuietZoneNote()
+ {
+ var (_, _, row) = Open(Code128());
+ // 2 dots at 203 dpi is 9.9 mil = 0.25 mm; "ABC" is 68 modules = 136 dots = 17 mm; the 10X quiet zone is 20 dots = 2.5 mm.
+ Assert.Equal("Module: 9.9 mil (0.25 mm) - symbol 17 mm wide" + Environment.NewLine + "Quiet zone: 2.5 mm on each side (10X)", row.Detail);
+ Assert.False(row.DetailIsWarning);
+ Assert.True(row.ShowStepper);
+ Assert.True(row.FullWidth);
+ }
+
+ [Fact]
+ public void AnEan13Readout_NamesBothQuietZones()
+ {
+ var (_, _, row) = Open(new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "590123412345", ModuleWidth = 3, X = 100, Y = 50 }, PrinterDpi.Dpi300);
+ Assert.Equal("Module: 10 mil (0.25 mm) - symbol 24.1 mm wide" + Environment.NewLine
+ + "Quiet zone: 2.8 mm before and 1.8 mm after the symbol (11X and 7X)", row.Detail);
+ }
+
+ [Fact]
+ public void TheReadout_IsInSpanishInASpanishSession()
+ {
+ Loc.Instance.SetLanguage("es");
+ var (_, _, row) = Open(Code128());
+ Assert.StartsWith("Módulo: ", row.Detail);
+ Assert.Contains("símbolo de ", row.Detail);
+ Assert.Contains("Zona de silencio: ", row.Detail);
+ }
+
+ [Fact]
+ public void TheStepper_ChangesTheModuleAndTheReadoutLive_InOneUndoStepEach()
+ {
+ var (_, s, row) = Open(Code128());
+ row.StepUpCommand.Execute(null);
+ Assert.Equal(3, Now(s).ModuleWidth);
+ Assert.Equal("3", row.Text);
+ // 3 dots: 68 modules = 204 dots = 25.5 mm, and the module itself 14.8 mil / 0.38 mm.
+ Assert.StartsWith("Module: 14.8 mil (0.38 mm) - symbol 25.5 mm wide", row.Detail);
+ Assert.Equal(1, s.Undo.UndoCount);
+
+ row.StepDownCommand.Execute(null);
+ row.StepDownCommand.Execute(null);
+ Assert.Equal(1, Now(s).ModuleWidth);
+
+ s.Undo.Undo();
+ // Steps on the same property merge into one undo step, so undo returns to the start and the readout follows it.
+ Assert.StartsWith("Module: 9.9 mil", row.Detail);
+ }
+
+ [Fact]
+ public void TheStepper_StopsAtTheLimits()
+ {
+ var (_, s, row) = Open(Code128(module: 10));
+ row.StepUpCommand.Execute(null);
+ Assert.Equal(10, Now(s).ModuleWidth);
+ var (_, s1, row1) = Open(Code128(module: 1));
+ row1.StepDownCommand.Execute(null);
+ Assert.Equal(1, Now(s1).ModuleWidth);
+ Assert.False(row1.HasError);
+ }
+
+ [Fact]
+ public void ARejectedTypedValue_IsReplacedByTheNextStep()
+ {
+ var (_, s, row) = Open(Code128());
+ row.Text = "99";
+ Assert.True(row.HasError);
+ row.StepUpCommand.Execute(null);
+ Assert.False(row.HasError);
+ Assert.Equal(3, Now(s).ModuleWidth);
+ }
+
+ [Fact]
+ public void ATooSmallModule_ColoursTheReadoutAsAWarning_AndTheProblemsListSaysWhy()
+ {
+ var (_, _, row) = Open(Code128(module: 1), PrinterDpi.Dpi300);
+ Assert.True(row.DetailIsWarning);
+ Assert.StartsWith("Module: 3.3 mil (0.08 mm)", row.Detail);
+
+ // The wording of the complaint is in the problems list (the same ZPL issues the status bar counts), in plain language.
+ var issue = Rotula.Core.Zpl.ZplGenerator.Generate(WithDpi(PrinterDpi.Dpi300, Code128(module: 1))).Issues.First(i => i.MessageKey == BarcodeSizing.OneDotKey);
+ string text = Loc.FormatIssue(issue);
+ Assert.Contains("1 dot (3.3 mil, 0.085 mm) at 300 dpi", text);
+ Assert.DoesNotContain("Issue.", text);
+ }
+
+ private static LabelDocument WithDpi(PrinterDpi dpi, BarcodeElement b)
+ {
+ var doc = new LabelDocument { Width = Measure.Dots(800), Height = Measure.Dots(400), Dpi = dpi };
+ doc.Elements.Add(b);
+ return doc;
+ }
+
+ [Fact]
+ public void EveryNewIssueKey_IsTranslatedAndFormatsItsArguments()
+ {
+ foreach (var lang in new[] { "en", "es" })
+ {
+ Loc.Instance.SetLanguage(lang);
+ foreach (string key in new[] { BarcodeSizing.OneDotKey, BarcodeSizing.ThinBarKey, BarcodeSizing.QuietZoneKey })
+ {
+ Assert.True(Loc.Instance.HasKey("Issue." + key), $"{lang}: {key}");
+ string text = Loc.FormatIssue(new Rotula.Core.Validation.ValidationIssue(Rotula.Core.Validation.ValidationSeverity.Warning, key, null, "1", "3.3", "0.085", "300"));
+ Assert.DoesNotContain("{", text);
+ }
+ }
+ }
+
+ [Fact]
+ public void AQrRow_KeepsItsPrintedSize_AndAddsTheModuleAndTheQuietZone()
+ {
+ var qr = new BarcodeElement { Symbology = BarcodeSymbology.QrCode, Data = "HELLO", ModuleSize = 4, X = 100, Y = 50 };
+ var (_, _, row) = Open(qr);
+ Assert.Contains("Printed size", row.Detail);
+ Assert.Contains("Module: 19.7 mil (0.5 mm)", row.Detail);
+ Assert.Contains("Quiet zone: 2 mm on each side (4X)", row.Detail);
+ Assert.True(row.ShowStepper);
+ }
+
+ [Fact]
+ public void ADataMatrixRow_GetsAnEncoderMeasuredWidth()
+ {
+ var dm = new BarcodeElement { Symbology = BarcodeSymbology.DataMatrix, Data = "HELLO", ModuleSize = 4, X = 100, Y = 50 };
+ var (_, _, row) = Open(dm);
+ Assert.Contains("symbol ", row.Detail);
+ Assert.Contains("Quiet zone: 0.5 mm on each side (1X)", row.Detail);
+ }
+
+ [Fact]
+ public void ASymbologyWithoutAModule_HasNoReadout()
+ {
+ var maxi = new BarcodeElement { Symbology = BarcodeSymbology.MaxiCode, Data = "x", X = 10, Y = 10 };
+ var s = AppTestSupport.Session(maxi);
+ var p = new PropertyPanelViewModel(s);
+ s.Select(maxi);
+ Assert.DoesNotContain(p.Rows.OfType(), r => r.ShowStepper);
+ }
+
+ [Fact]
+ public void OtherNumberRows_HaveNoStepper()
+ {
+ var (p, _, _) = Open(Code128());
+ Assert.Equal(new[] { "Prop.ModuleWidth" }, p.Rows.OfType().Where(r => r.ShowStepper).Select(r => r.LabelKey));
+ }
+
+ [Fact]
+ public void TheQuietZoneGuide_GrowsTheFrameOnTheReadingAxisOnly()
+ {
+ var frame = new RectD(100, 50, 190, 100);
+ var ean = new BarcodeElement { Symbology = BarcodeSymbology.Ean13, ModuleWidth = 2 };
+ Assert.Equal(new RectD(78, 50, 190 + 22 + 14, 100), BarcodeSizeInfo.QuietZoneFrame(frame, ean, FieldRotation.Normal));
+
+ ean.Rotation = FieldRotation.Rotated90; // reads top to bottom, so the margins are above and below
+ Assert.Equal(new RectD(100, 28, 190, 100 + 22 + 14), BarcodeSizeInfo.QuietZoneFrame(frame, ean, FieldRotation.Normal));
+
+ var qr = new BarcodeElement { Symbology = BarcodeSymbology.QrCode, ModuleSize = 3 };
+ Assert.Equal(new RectD(88, 38, 190 + 24, 100 + 24), BarcodeSizeInfo.QuietZoneFrame(frame, qr, FieldRotation.Normal));
+
+ Assert.Null(BarcodeSizeInfo.QuietZoneFrame(frame, new BarcodeElement { Symbology = BarcodeSymbology.MaxiCode }, FieldRotation.Normal));
+ }
+
+ [Fact]
+ public void TheGuide_DefaultsToTheLabelRotation_WhenTheFieldHasNone()
+ {
+ var frame = new RectD(100, 50, 190, 100);
+ var code = new BarcodeElement { Symbology = BarcodeSymbology.Code128, ModuleWidth = 2 };
+ Assert.Equal(new RectD(100, 30, 190, 140), BarcodeSizeInfo.QuietZoneFrame(frame, code, FieldRotation.Rotated90));
+ }
+}
diff --git a/tests/Rotula.Tests/AppDataMergeTests.cs b/tests/Rotula.Tests/AppDataMergeTests.cs
index f6d4ecd..8e377ed 100644
--- a/tests/Rotula.Tests/AppDataMergeTests.cs
+++ b/tests/Rotula.Tests/AppDataMergeTests.cs
@@ -25,6 +25,8 @@ public AppDataMergeTests()
_fake.Settings.Current.Language = "en";
_main = new MainViewModel(_fake.Build());
_main.Printer.SelectedPrinterName = "Test Printer";
+ // A Windows printer with no saved mode does not batch print until the person answers how it understands labels; these batches are ZPL.
+ _main.Printer.ChooseModeCommand.Execute(Rotula.Printing.PrinterOutputMode.PrinterLanguage);
}
public void Dispose()
@@ -90,6 +92,25 @@ public async Task WithoutAHeaderRow_ColumnsAreNumbered()
Assert.Equal(2, Data.RecordCount);
}
+ [Fact]
+ public async Task ALoad_IsStillBusyUntilTheRecordsAreInPlace_AndReloadIsEnabledAfterwards()
+ {
+ string path = Csv("A1,5\nB2,7\n");
+ await Data.LoadAsync(path);
+ // The moment IsBusy turns false, the new records (and not just the new columns) must already be there: a caller that waits
+ // for "not busy" and then reads RecordCount used to see 0, because the data set was rebuilt after the busy flag cleared.
+ var whenNotBusy = new List<(int Records, bool CanReload)>();
+ Data.PropertyChanged += (_, e) =>
+ {
+ if (e.PropertyName == nameof(DataMergeViewModel.IsBusy) && !Data.IsBusy) whenNotBusy.Add((Data.RecordCount, Data.CanReload));
+ };
+ Data.HasHeader = false;
+ await Idle(Data);
+ Assert.Single(whenNotBusy);
+ Assert.Equal((2, true), whenNotBusy[0]);
+ Assert.True(Data.CanReload);
+ }
+
[Fact]
public async Task AWindows1252Csv_IsReadWithItsAccents()
{
@@ -370,7 +391,7 @@ public async Task AUsedCounterThatRunsOut_WithErrorOverflow_MarksTheRecordsInThe
// ------------------------------------------------------------------------------------ checking
- private void AddEanLabel() => _main.Session.Edit("Undo.Property", d => d.Elements.Add(new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "{{code}}" }));
+ private void AddEanLabel() => _main.Session.Edit("Undo.Property", d => d.Elements.Add(new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "{{code}}", X = 100 }));
[Fact]
public async Task CheckAll_ListsPerRecordProblems_InReadableSentences()
diff --git a/tests/Rotula.Tests/AppDataSelectionTests.cs b/tests/Rotula.Tests/AppDataSelectionTests.cs
index 7356782..b91c1b7 100644
--- a/tests/Rotula.Tests/AppDataSelectionTests.cs
+++ b/tests/Rotula.Tests/AppDataSelectionTests.cs
@@ -40,6 +40,8 @@ private MainViewModel Build()
{
var main = new MainViewModel(_fake.Build());
main.Printer.SelectedPrinterName = "Test Printer";
+ // A Windows printer with no saved mode does not batch print until the person answers how it understands labels; these batches are ZPL.
+ main.Printer.ChooseModeCommand.Execute(Rotula.Printing.PrinterOutputMode.PrinterLanguage);
return main;
}
@@ -673,6 +675,8 @@ public async Task ADatabaseSource_PrintsOnlyTickedRows()
_fake.Database.Store.Add(new DatabaseConnectionSettings { Name = "Warehouse", Provider = DatabaseProvider.SqlServer, Host = "h", Database = "WH" }, "pw");
var main = new MainViewModel(_fake.Build());
main.Printer.SelectedPrinterName = "Test Printer";
+ // A Windows printer with no saved mode does not batch print until the person answers how it understands labels; this batch is ZPL.
+ main.Printer.ChooseModeCommand.Execute(Rotula.Printing.PrinterOutputMode.PrinterLanguage);
main.Session.Edit("Undo.Property", d => d.Elements.Add(new TextElement { Text = "{{Name}} x{{Qty}}" }));
var data = main.Data;
data.IsDatabaseMode = true;
diff --git a/tests/Rotula.Tests/AppDatabaseTests.cs b/tests/Rotula.Tests/AppDatabaseTests.cs
index 569a63f..79b4863 100644
--- a/tests/Rotula.Tests/AppDatabaseTests.cs
+++ b/tests/Rotula.Tests/AppDatabaseTests.cs
@@ -465,7 +465,7 @@ public void TheFormatVersion_IsFourOnlyWhenADatabaseReferenceIsPresent_AndOlderF
Assert.Equal("1", FormatOf(null));
Assert.Equal("2", FormatOf(new DataMergeSettings { MaxRecordsPerJob = 5 }));
Assert.Equal("4", FormatOf(WithQuery("Warehouse", "SELECT 1")));
- Assert.Equal(RotulaFileSerializer.ObjectConditionsFormatVersion, RotulaFileSerializer.CurrentFormatVersion);
+ Assert.Equal(RotulaFileSerializer.RelativePositionFormatVersion, RotulaFileSerializer.CurrentFormatVersion);
// Raw ZPL needs version 3; together with a database reference the file must still say 4, never be lowered to 3.
doc.Elements.Add(new RawZplElement { Zpl = "^FO10,10^FDx^FS" });
diff --git a/tests/Rotula.Tests/AppDpiConversionTests.cs b/tests/Rotula.Tests/AppDpiConversionTests.cs
index 95c2f30..210423d 100644
--- a/tests/Rotula.Tests/AppDpiConversionTests.cs
+++ b/tests/Rotula.Tests/AppDpiConversionTests.cs
@@ -33,6 +33,8 @@ public AppDpiConversionTests()
private PrintViewModel Dialog(PrinterDpi printerDpi)
{
_main.Printer.SelectedPrinterName = "ZD621R-300dpi";
+ // A Windows printer with no saved mode does not print until the person answers how it understands labels; the conversion is a ZPL matter.
+ _main.Printer.ChooseModeCommand.Execute(Rotula.Printing.PrinterOutputMode.PrinterLanguage);
_main.Printer.SelectedDpi = printerDpi;
return new PrintViewModel(_fake.Printer, _main.Printer, () => _main.Session.Document, () => null, _fake.Build().Files, "x");
}
diff --git a/tests/Rotula.Tests/AppDriverBatchTests.cs b/tests/Rotula.Tests/AppDriverBatchTests.cs
new file mode 100644
index 0000000..d1b9e9a
--- /dev/null
+++ b/tests/Rotula.Tests/AppDriverBatchTests.cs
@@ -0,0 +1,919 @@
+using System.Globalization;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+using Rotula.App.Localization;
+using Rotula.App.Services;
+using Rotula.App.ViewModels;
+using Rotula.App.Views;
+using Rotula.Core.Elements;
+using Rotula.Core.Validation;
+using Rotula.Core.Variables;
+using Rotula.Core.Variables.Counters;
+using Rotula.Core.Variables.Prompts;
+using Rotula.Printing;
+
+namespace Rotula.Tests;
+
+///
+/// Batch printing (the Data merge window's print button and the Print dialog's ticked records) to a printer in Windows-driver (picture) mode:
+/// one pre-flight for the design, one acknowledgement, every job drawn as pictures through and
+/// never sent as ZPL, and the serial-number rules of the printer-language path. Every serial scenario runs in both modes, so the printer-language
+/// batch is proved unchanged next to the picture batch. Nothing here reaches a real printer: the service is a fake, or the real print service wired
+/// to a fake spooler and a fake driver.
+///
+[Collection("App")]
+public class AppDriverBatchTests : IDisposable
+{
+ private const string Queue = "Brother QL-820NWB";
+ private readonly FakeServices _fake = new();
+ private readonly string _dir = AppTestSupport.TempDir();
+ private readonly MemoryCounterStore _store = new();
+ private readonly MainViewModel _main;
+
+ /// What the fake driver reports when a test needs media: a 4 x 6 inch page at 203 dpi, printable to its edges.
+ private static readonly DriverMedia Media = new(203, 203, 812, 1218, 812, 1218, 0, 0, true);
+
+ public AppDriverBatchTests()
+ {
+ _fake.Settings.Current.Language = "en";
+ _fake.Printer.Printers = new[] { new PrinterInfo(Queue, "USB001", "Brother QL-820NWB", true, false) };
+ _fake.Variables = new SerialVariables(_store);
+ _main = new MainViewModel(_fake.Build());
+ _main.Printer.SelectedPrinterName = Queue;
+ }
+
+ public void Dispose()
+ {
+ Loc.Instance.SetLanguage("en");
+ Directory.Delete(_dir, true);
+ }
+
+ public static TheoryData BothModes => new() { PrinterOutputMode.PrinterLanguage, PrinterOutputMode.WindowsDriver };
+
+ private DataMergeViewModel Data => _main.Data;
+
+ private string CounterKeyOfLabel => CounterKey.For(_main.Session.Document.Id!, "serial");
+
+ /// How many serial numbers are used for good (the next one to hand out), or 0 when the series was never touched.
+ private long Used => _store.Get(CounterKeyOfLabel)?.Next ?? 0;
+
+ private static ValidationIssue Warning(string key, params string[] args) => new(ValidationSeverity.Warning, key, null, args);
+
+ private static ValidationIssue Error(string key, params string[] args) => new(ValidationSeverity.Error, key, null, args);
+
+ /// Makes the fake pre-flight answer these findings for every document.
+ private void Findings(params ValidationIssue[] findings) => _fake.Printer.Preflight = (_, _) => new DriverPreflightResult(findings, Media);
+
+ private static string Fingerprint(params ValidationIssue[] findings) => new DriverPreflightResult(findings, null).Fingerprint;
+
+ ///
+ /// A label with a remembered serial counter ("SN0001"...) and the record's n and code, data rows (n = 1..records,
+ /// code = "C" + n unless says otherwise, c = n % 3 + 1 unless says otherwise), jobs of
+ /// records, and the printer in (null leaves the printer's mode question unanswered).
+ ///
+ private async Task Batch(PrinterOutputMode? mode, int records, int perJob, string[]? codes = null, string[]? copies = null)
+ {
+ _main.Session.Edit("Undo.Property", d =>
+ {
+ d.EnsureId();
+ d.Variables.Add(Vars.Counter("serial", c => { c.Remember = true; c.Padding = 4; c.Prefix = "SN"; }));
+ d.Elements.Add(new TextElement { Text = "{{serial}} Item {{n}} {{code}}" });
+ });
+ var sb = new StringBuilder("n,code,c\n");
+ for (int i = 1; i <= records; i++)
+ {
+ string c = copies is null ? (i % 3 + 1).ToString(CultureInfo.InvariantCulture) : copies[i - 1];
+ sb.Append(i).Append(',').Append(codes is null ? "C" + i : codes[i - 1]).Append(',').Append(c).Append('\n');
+ }
+ string path = Path.Combine(_dir, "data.csv");
+ File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
+ Assert.True(await Data.LoadAsync(path));
+ Data.MaxRecordsText = perJob.ToString(CultureInfo.InvariantCulture);
+ if (mode is { } m) _main.Printer.ChooseModeCommand.Execute(m);
+ return Data;
+ }
+
+ /// The serial number of every record that was handed to the printer, in order (one per record, whatever its copies).
+ private string[] SentSerials(PrinterOutputMode mode) => mode == PrinterOutputMode.WindowsDriver
+ ? _fake.Printer.Resolved.SelectMany(j => j.Labels).Select(l => l.Values!["serial"]).ToArray()
+ : _fake.Printer.ZplJobs.SelectMany(j => Regex.Matches(j.Zpl, @"SN\d{4}").Select(m => m.Value)).ToArray();
+
+ /// How many print jobs reached the service in .
+ private int JobsSent(PrinterOutputMode mode) => mode == PrinterOutputMode.WindowsDriver ? _fake.Printer.Resolved.Count : _fake.Printer.ZplJobs.Count;
+
+ private void ForgetSent()
+ {
+ _fake.Printer.ZplJobs.Clear();
+ _fake.Printer.RawBlockArgs.Clear();
+ _fake.Printer.Resolved.Clear();
+ }
+
+ /// Prints again with every job succeeding and returns the first serial number that went out.
+ private async Task FirstSerialOfTheNextBatch(PrinterOutputMode mode)
+ {
+ ForgetSent();
+ _fake.Printer.Result = _ => PrintResult.Ok(5);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ return SentSerials(mode)[0];
+ }
+
+ private PrintViewModel Dialog() =>
+ new(_fake.Printer, _main.Printer, () => _main.Session.Document, () => Data.CurrentData, _fake.Build().Files, "my-label", _main.Variables, Data);
+
+ // ------------------------------------------------------------------------------------ (b) every job goes out with each record's copies
+
+ [Theory]
+ [MemberData(nameof(BothModes))]
+ public async Task EachJob_GoesOutWithTheCopiesOfEachRecord_InTheChosenMode(PrinterOutputMode mode)
+ {
+ await Batch(mode, records: 5, perJob: 2);
+ Data.CopiesSource = CopiesSource.Column;
+ Data.CopiesColumn = "c";
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.False(Data.ResultIsError, Data.ResultText);
+ Assert.Contains("5 labels", Data.ResultText);
+ Assert.Contains("3 job", Data.ResultText);
+ Assert.Equal(new[] { "SN0001", "SN0002", "SN0003", "SN0004", "SN0005" }, SentSerials(mode));
+ Assert.Equal(5, Used);
+ if (mode == PrinterOutputMode.WindowsDriver)
+ {
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.Equal(3, _fake.Printer.Resolved.Count);
+ Assert.Equal(new[] { 2, 2, 1 }, _fake.Printer.Resolved.Select(j => j.Labels.Count));
+ // c = n % 3 + 1 for n = 1..5: each record's label goes out with its own copies.
+ Assert.Equal(new[] { 2, 3, 1, 2, 3 }, _fake.Printer.Resolved.SelectMany(j => j.Labels).Select(l => l.Copies));
+ Assert.Equal(new[] { "1", "2", "3", "4", "5" }, _fake.Printer.Resolved.SelectMany(j => j.Labels).Select(l => l.Values!["n"]));
+ Assert.All(_fake.Printer.Resolved, j => Assert.Equal(PrinterOutputMode.WindowsDriver, j.Target.Mode));
+ // Checked once for the design, not per job or per record, and every job prints the very document that was checked.
+ var check = Assert.Single(_fake.Printer.Preflights);
+ Assert.All(_fake.Printer.Resolved, j => Assert.Same(check.Doc, j.Doc));
+ }
+ else
+ {
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.Empty(_fake.Printer.Preflights);
+ Assert.Equal(3, _fake.Printer.ZplJobs.Count);
+ string zpl = string.Concat(_fake.Printer.ZplJobs.Select(j => j.Zpl));
+ Assert.Equal(new[] { "2", "3", "1", "2", "3" }, Regex.Matches(zpl, @"\^PQ(\d+),").Select(m => m.Groups[1].Value));
+ }
+ }
+
+ [Theory]
+ [MemberData(nameof(BothModes))]
+ public async Task PrinterCommands_AndStatusChecks_StayInThePrinterLanguageMode(PrinterOutputMode mode)
+ {
+ await Batch(mode, records: 3, perJob: 2);
+ Data.Printer.DarknessText = "18";
+ Data.Printer.CheckStatus = true;
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.False(Data.ResultIsError, Data.ResultText);
+ if (mode == PrinterOutputMode.WindowsDriver)
+ {
+ // A printer that does not understand ZPL would print the set-up commands and ~HS as text: nothing of the kind goes out.
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.Empty(_fake.Printer.StatusQueries);
+ Assert.Empty(_fake.Printer.Verifications);
+ Assert.All(_fake.Printer.Resolved, j => Assert.False(j.Target.Status.Enabled));
+ }
+ else
+ {
+ Assert.StartsWith("~SD18", _fake.Printer.ZplJobs[0].Zpl);
+ Assert.Single(_fake.Printer.Verifications);
+ }
+ }
+
+ // ------------------------------------------------------------------------------------ (c) a failed job that may have printed part of its labels
+
+ [Theory]
+ [MemberData(nameof(BothModes))]
+ public async Task AFailedJob_ThatMayHavePrinted_KeepsItsNumbers_AndStops(PrinterOutputMode mode)
+ {
+ await Batch(mode, records: 6, perJob: 2);
+ Data.CopiesSource = CopiesSource.Fixed;
+ Data.FixedCopiesText = "2";
+ // The second job breaks after part of it may have reached the printer. ZPL: bytes went out. Picture: 3 pages went out, which is both
+ // copies of record 3 and the first copy of record 4, so record 4 counts as printed too (a record cut short is the safe side).
+ var broken = mode == PrinterOutputMode.WindowsDriver
+ ? PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "StartPage (Win32 error 1)"), 10).WithPages(3)
+ : PrintResult.Fail(PrintError.Create(PrintErrorKind.Timeout, Queue), 10);
+ _fake.Printer.Result = n => n == 2 ? broken : PrintResult.Ok(5);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Equal(2, JobsSent(mode)); // the third job is never sent
+ Assert.Equal(new[] { "SN0001", "SN0002", "SN0003", "SN0004" }, SentSerials(mode));
+ Assert.True(Data.ResultIsError);
+ Assert.Contains("1 job(s) were already sent", Data.ResultText);
+ Assert.Equal(4, Used);
+ // Record 4 had one of its two copies out: the text says it printed only in part, never that it printed.
+ if (mode == PrinterOutputMode.WindowsDriver)
+ {
+ Assert.Contains(Loc.Get("Data.Driver.PrintedInPart", 4, 1, 2), Data.ResultText);
+ Assert.DoesNotContain(Loc.Get("Data.Driver.PrintedThrough", 4), Data.ResultText);
+ }
+
+ Assert.Equal("SN0005", await FirstSerialOfTheNextBatch(mode));
+ }
+
+ [Fact]
+ public async Task APictureJobThatStopsWithinARecordsCopies_KeepsOnlyTheRecordsWithAPageOut()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 6, perJob: 2);
+ Data.CopiesSource = CopiesSource.Fixed;
+ Data.FixedCopiesText = "2";
+ // One page of the second job went out: the first copy of record 3. Record 4 never reached the driver, so its number goes back.
+ _fake.Printer.Result = n => n == 2 ? PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "EndPage (Win32 error 5)"), 10).WithPages(1) : PrintResult.Ok(5);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Equal(3, Used);
+ Assert.Contains(Loc.Get("Data.Driver.PrintedInPart", 3, 1, 2), Data.ResultText);
+ Assert.Equal("SN0004", await FirstSerialOfTheNextBatch(PrinterOutputMode.WindowsDriver));
+ }
+
+ [Theory]
+ [InlineData(4, 2, false, 2)] // pages 1-2 are record 1, 3-5 record 2: page 4 leaves record 2 with 2 of its 3 copies out
+ [InlineData(6, 3, true, 0)] // 2 + 3 + 1 pages: records 1 to 3 are out in full
+ public async Task DifferentCopiesPerRecord_WithPartOfAJobOut_KeepTheNumbersOfTheRecordsWithAPageOut(int pagesOut, int used, bool full, int copiesOut)
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 5, perJob: 0);
+ Data.CopiesSource = CopiesSource.Column;
+ Data.CopiesColumn = "c"; // c = n % 3 + 1: 2, 3, 1, 2, 3 copies
+ _fake.Printer.Result = _ => PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "StartPage (Win32 error 1)"), 10).WithPages(pagesOut);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Equal(used, Used);
+ Assert.Contains(full ? Loc.Get("Data.Driver.PrintedThrough", used) : Loc.Get("Data.Driver.PrintedInPart", used, copiesOut, 3), Data.ResultText);
+ }
+
+ [Fact]
+ public async Task ASkippedRecordInsideAPartlyPrintedPictureJob_KeepsItsGap_AndCountsWhatWentOut()
+ {
+ // Record 2 has a copies value that cannot be used, so it is left out; its batch position (and serial number) stays a gap, as in ZPL.
+ await Batch(PrinterOutputMode.WindowsDriver, records: 5, perJob: 0, copies: new[] { "1", "x", "1", "1", "1" });
+ Data.CopiesSource = CopiesSource.Column;
+ Data.CopiesColumn = "c";
+ // Two pages went out: records 1 and 3 (positions 1 and 3); records 4 and 5 never reached the driver.
+ _fake.Printer.Result = _ => PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "StartPage (Win32 error 1)"), 10).WithPages(2);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Equal(new[] { 1, 3, 4, 5 }, _fake.Printer.Resolved.Single().Labels.Select(l => int.Parse(l.Values!["n"], CultureInfo.InvariantCulture)));
+ Assert.Equal(3, Used);
+ Assert.Contains(Loc.Get("Data.Driver.PrintedThrough", 3), Data.ResultText);
+ Assert.Equal("SN0004", await FirstSerialOfTheNextBatch(PrinterOutputMode.WindowsDriver));
+ }
+
+ [Fact]
+ public async Task AnErrorThatEscapesTheService_DuringAPictureJob_KeepsThatWholeJobsNumbers()
+ {
+ // The print service lets an out-of-memory error through after earlier driver documents of the same call may have been accepted, and the
+ // page count never comes back. Every number of the job in flight stays used; none is given back.
+ await Batch(PrinterOutputMode.WindowsDriver, records: 6, perJob: 2);
+ _fake.Printer.Result = n => n == 2 ? throw new InvalidOperationException("The driver binding broke down.") : PrintResult.Ok(5);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.False(Data.IsBusy);
+ Assert.True(Data.ResultIsError);
+ Assert.Equal(2, _fake.Printer.Resolved.Count);
+ Assert.Equal(4, Used);
+ Assert.Equal("SN0005", await FirstSerialOfTheNextBatch(PrinterOutputMode.WindowsDriver));
+ }
+
+ [Theory]
+ [InlineData(PrintErrorKind.Unknown)]
+ [InlineData(PrintErrorKind.Timeout)]
+ public async Task APictureFailureInDoubt_WithNoPageOut_GivesTheJobsNumbersBack(PrintErrorKind kind)
+ {
+ // The accepted rule in picture mode: the driver's page count decides, and the print service counts every picture it handed over, even
+ // when the driver threw. No page out means nothing could have reached the printer (the ZPL rule would keep these numbers).
+ await Batch(PrinterOutputMode.WindowsDriver, records: 6, perJob: 2);
+ _fake.Printer.Result = n => n == 2 ? PrintResult.Fail(PrintError.Create(kind, Queue, "x", "y")) : PrintResult.Ok(5);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.True(Data.ResultIsError);
+ Assert.Equal(2, Used);
+ Assert.Equal("SN0003", await FirstSerialOfTheNextBatch(PrinterOutputMode.WindowsDriver));
+ }
+
+ [Theory]
+ [InlineData(PrintErrorKind.NotAcknowledged)]
+ [InlineData(PrintErrorKind.InvalidLabel)]
+ public async Task AJobRefusedByTheDriverCheckMidBatch_SaysWhatHappened_WithoutABoxToTick(PrintErrorKind kind)
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 6, perJob: 2);
+ // The service checks every job again; by the second job the driver reports something new (other paper, or the printer is gone).
+ var refused = kind == PrintErrorKind.NotAcknowledged
+ ? PrintResult.Fail(PrintErrorKind.NotAcknowledged).WithNotices(new[] { Warning(DriverPreflight.PaperMismatch, "100x50", "210x297") })
+ : PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, DriverPreflight.DriverUnavailable)).WithNotices(new[] { Error(DriverPreflight.DriverUnavailable) });
+ _fake.Printer.Result = n => n == 2 ? refused : PrintResult.Ok(5);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.True(Data.ResultIsError);
+ Assert.Contains(Loc.Get("Data.Driver.JobRefused", 3), Data.ResultText);
+ Assert.Contains("1 job(s) were already sent", Data.ResultText);
+ Assert.DoesNotContain(Loc.Get("Print.Error.NotAcknowledged"), Data.ResultText);
+ Assert.DoesNotContain(Loc.Get("Print.Error.InvalidLabel"), Data.ResultText);
+ Assert.Contains(DriverPreflightGate.FormatFinding(refused.Notices[0]), Data.ResultText);
+ Assert.Equal(2, Used);
+ }
+
+ [Fact]
+ public async Task TwoBarcodesThatCannotBeDrawn_AreBothListed()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 3, perJob: 0);
+ var first = Error(DevicePictureRenderer.BarcodeNotDrawn, "EAN-13", "12A");
+ var second = Error(DevicePictureRenderer.BarcodeNotDrawn, "Code 128", "\u0001");
+ _fake.Printer.Result = _ => PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, DevicePictureRenderer.BarcodeNotDrawn))
+ .WithNotices(new[] { first, second });
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Contains(DriverPreflightGate.FormatFinding(first), Data.ResultText);
+ Assert.Contains(DriverPreflightGate.FormatFinding(second), Data.ResultText);
+ }
+
+ [Fact]
+ public async Task WithTickedRecords_ThePagesOutAreCountedByBatchPosition_NotByRowNumber()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 6, perJob: 0);
+ Data.SelectNoneShownCommand.Execute(null);
+ foreach (int row in new[] { 1, 2, 4, 5 }) Data.VisibleRows[row].IsSelected = true; // records 2, 3, 5 and 6
+ // Three pages went out (one copy each): records 2, 3 and 5. They hold the batch positions 1 to 3, so three numbers are used, not five.
+ _fake.Printer.Result = _ => PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "StartPage (Win32 error 1)"), 10).WithPages(3);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Equal(new[] { "SN0001", "SN0002", "SN0003", "SN0004" }, SentSerials(PrinterOutputMode.WindowsDriver));
+ Assert.Equal(3, Used);
+ Assert.Contains(Loc.Get("Data.Driver.PrintedThrough", 5), Data.ResultText); // what the person reads is the record number
+ }
+
+ // ------------------------------------------------------------------------------------ (d) a failed job that printed nothing
+
+ [Theory]
+ [MemberData(nameof(BothModes))]
+ public async Task AFailedJob_ThatPrintedNothing_GivesItsNumbersBack(PrinterOutputMode mode)
+ {
+ await Batch(mode, records: 6, perJob: 2);
+ // ZPL: the printer refused the job before a byte went out. Picture: the driver refused it before it accepted a page.
+ var refused = mode == PrinterOutputMode.WindowsDriver
+ ? PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "StartDoc (Win32 error 1801)"))
+ : PrintResult.Fail(PrintErrorKind.Offline, Queue);
+ _fake.Printer.Result = n => n == 2 ? refused : PrintResult.Ok(5);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Equal(2, JobsSent(mode));
+ Assert.True(Data.ResultIsError);
+ Assert.Contains("1 job(s) were already sent", Data.ResultText);
+ Assert.Equal(2, Used); // only the first job's two labels
+ Assert.DoesNotContain(Loc.Get("Data.Driver.PrintedThrough", 4), Data.ResultText);
+
+ Assert.Equal("SN0003", await FirstSerialOfTheNextBatch(mode));
+ }
+
+ // ------------------------------------------------------------------------------------ (e) cancel mid-batch
+
+ [Theory]
+ [MemberData(nameof(BothModes))]
+ public async Task CancellingBetweenJobs_StopsLikeThePrinterLanguagePath(PrinterOutputMode mode)
+ {
+ await Batch(mode, records: 6, perJob: 2);
+ // The operator presses Stop while the first job is going out; that job completes, and the batch stops before the next one.
+ _fake.Printer.Result = n =>
+ {
+ if (n == 1) Data.CancelOperation();
+ return PrintResult.Ok(5);
+ };
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.False(Data.IsBusy);
+ Assert.Equal(1, JobsSent(mode));
+ Assert.False(Data.ResultIsError);
+ Assert.StartsWith(Loc.Get("Data.Batch.CancelledPartial", 1), Data.ResultText);
+ Assert.Equal(2, Used);
+
+ Assert.Equal("SN0003", await FirstSerialOfTheNextBatch(mode));
+ }
+
+ [Theory]
+ [MemberData(nameof(BothModes))]
+ public async Task CancellingWhileAJobIsGoingOut_GivesBackWhatCouldNotHavePrinted(PrinterOutputMode mode)
+ {
+ await Batch(mode, records: 4, perJob: 2);
+ _fake.Printer.Gate = new TaskCompletionSource(); // the first job never finishes on its own: it is "being sent"
+ var running = Data.PrintAllCommand.ExecuteAsync(null);
+ for (int i = 0; i < 300 && JobsSent(mode) == 0; i++) await Task.Delay(10);
+ Assert.True(Data.IsBusy);
+ Data.CancelOperation();
+ await running;
+
+ // The service reports the cancel as a result with nothing out, so the numbers of that job go back, as before.
+ Assert.False(Data.IsBusy);
+ Assert.Equal(1, JobsSent(mode));
+ Assert.False(Data.ResultIsError);
+ Assert.Equal(0, Used);
+ _fake.Printer.Gate = null;
+ Assert.Equal("SN0001", await FirstSerialOfTheNextBatch(mode));
+ }
+
+ // ------------------------------------------------------------------------------------ (a) the findings are acknowledged once, before anything is reserved
+
+ [Fact]
+ public async Task DataWindow_WarningsAreAskedOnce_WithTheFindings_AndOkPrintsWithTheAcknowledgement()
+ {
+ var finding = Warning(DriverPreflight.PaperMismatch, "100x50", "101.6x152.4");
+ Findings(finding);
+ await Batch(PrinterOutputMode.WindowsDriver, records: 5, perJob: 2);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ // One question with the findings (not one per job or per record), then the batch question, each asked once.
+ Assert.Equal(2, _fake.Confirms.Count);
+ Assert.Contains(DriverPreflightGate.FormatFinding(finding), _fake.Confirms[0]);
+ Assert.Contains("5 records", _fake.Confirms[1]);
+ Assert.Single(_fake.Printer.Preflights);
+ Assert.Equal(3, _fake.Printer.Resolved.Count);
+ Assert.All(_fake.Printer.Resolved, j => Assert.Equal(Fingerprint(finding), j.Target.AcknowledgedFindings));
+ Assert.False(Data.ResultIsError, Data.ResultText);
+ }
+
+ [Fact]
+ public async Task DataWindow_CancellingTheFindingsQuestion_SendsNothing_AndReservesNothing()
+ {
+ Findings(Warning(DriverPreflight.RawBlocks, "2"));
+ await Batch(PrinterOutputMode.WindowsDriver, records: 3, perJob: 0);
+ _fake.ConfirmAnswers.Enqueue(false); // no to the findings, which come first
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Single(_fake.Confirms); // the batch question is never asked
+ Assert.Contains(DriverPreflightGate.FormatFinding(Warning(DriverPreflight.RawBlocks, "2")), _fake.Confirms[0]);
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.Null(_store.Get(CounterKeyOfLabel)); // no serial number was even reserved
+
+ Assert.Equal("SN0001", await FirstSerialOfTheNextBatch(PrinterOutputMode.WindowsDriver));
+ }
+
+ [Fact]
+ public async Task DataWindow_AnErrorFinding_NeverPrints_AndSaysWhy()
+ {
+ var oneDot = Error(DriverPreflight.ModuleOneDot, "SKU");
+ Findings(oneDot, Warning(DriverPreflight.RawBlocks, "1"));
+ await Batch(PrinterOutputMode.WindowsDriver, records: 3, perJob: 0);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Empty(_fake.Confirms); // reported before any question: there is nothing to acknowledge or confirm
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.True(Data.ResultIsError);
+ Assert.Contains(Loc.Get("Data.Driver.CannotPrint"), Data.ResultText);
+ Assert.Contains(DriverPreflightGate.FormatFinding(oneDot), Data.ResultText);
+ Assert.Null(_store.Get(CounterKeyOfLabel));
+ }
+
+ [Fact]
+ public async Task DataWindow_NoFindings_PrintsWithoutAnotherQuestion()
+ {
+ Findings();
+ await Batch(PrinterOutputMode.WindowsDriver, records: 2, perJob: 0);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Single(_fake.Confirms);
+ Assert.Single(_fake.Printer.Resolved);
+ Assert.Null(_fake.Printer.Resolved[0].Target.AcknowledgedFindings);
+ }
+
+ [Fact]
+ public async Task DataWindow_AStoppedCheck_IsACalmStop()
+ {
+ Findings(Error(DriverPreflight.Cancelled));
+ await Batch(PrinterOutputMode.WindowsDriver, records: 2, perJob: 0);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.False(Data.ResultIsError);
+ Assert.Equal(Loc.Get("Common.OperationCancelled"), Data.ResultText);
+ }
+
+ [Fact]
+ public async Task DataWindow_InThePrinterLanguageMode_NeverRunsAPreflight()
+ {
+ Findings(Warning(DriverPreflight.RawBlocks, "2"));
+ await Batch(PrinterOutputMode.PrinterLanguage, records: 3, perJob: 0);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Preflights);
+ Assert.Single(_fake.Confirms);
+ Assert.Single(_fake.Printer.ZplJobs);
+ Assert.Empty(_fake.Printer.Resolved);
+ }
+
+ [Fact]
+ public async Task PrintDialogRecords_InDriverMode_UseTheFindingsCardAndTheTick_AndNeverSendZpl()
+ {
+ var finding = Warning(DriverPreflight.ModuleBelowMinimum, "SKU", "6.6", "9.8");
+ Findings(finding);
+ await Batch(PrinterOutputMode.WindowsDriver, records: 4, perJob: 2);
+ var dialog = Dialog();
+
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.True(dialog.NeedsAcknowledgement);
+ Assert.Contains("SKU", Assert.Single(dialog.PreflightLines));
+ Assert.Equal(Loc.Get("Print.Driver.TickToContinue"), dialog.ResultText);
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.Empty(_fake.Confirms); // the findings come first: the batch question waits for the tick
+ Assert.Equal(0, Used);
+
+ dialog.Acknowledged = true;
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Single(_fake.Confirms); // so the batch question is asked once
+ Assert.False(dialog.ResultIsError, dialog.ResultText);
+ Assert.Equal(2, _fake.Printer.Resolved.Count);
+ Assert.All(_fake.Printer.Resolved, j => Assert.Equal(Fingerprint(finding), j.Target.AcknowledgedFindings));
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.Empty(_fake.Printer.Printed);
+ // The dialog's own card and tick answer the findings; the Data window's findings question is never shown on top.
+ Assert.DoesNotContain(_fake.Confirms, c => c.Contains("SKU", StringComparison.Ordinal));
+ Assert.Equal(4, Used);
+ dialog.Detach();
+ }
+
+ [Fact]
+ public async Task PrintDialogRecords_ForAPrinterThatHasNotAnswered_SendNothingAtAll()
+ {
+ await _main.Printer.RefreshPrintersAsync();
+ await Batch(null, records: 3, perJob: 0); // the question was never answered for this printer
+ Assert.True(_main.Printer.NeedsModeQuestion);
+ var dialog = Dialog();
+
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.Empty(_fake.Printer.Preflights);
+ Assert.Empty(_fake.Confirms);
+ Assert.Equal(Loc.Get("Print.Mode.AnswerFirst"), dialog.ResultText);
+ dialog.Detach();
+ }
+
+ // ------------------------------------------------------------------------------------ the mode question in the Data window
+
+ [Fact]
+ public async Task DataWindow_APrinterThatHasNotAnswered_DoesNotBatchPrint_UntilItIsAnswered()
+ {
+ await _main.Printer.RefreshPrintersAsync();
+ await Batch(null, records: 3, perJob: 0);
+ Assert.True(_main.Printer.ShowModeQuestion);
+
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.Empty(_fake.Confirms); // not even the batch question
+ Assert.True(Data.ResultIsError);
+ Assert.Equal(Loc.Get("Print.Mode.AnswerFirst"), Data.ResultText);
+ Assert.Null(_store.Get(CounterKeyOfLabel));
+
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.PrinterLanguage);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Single(_fake.Printer.ZplJobs);
+ }
+
+ [Fact]
+ public async Task DataWindow_AQueueThatIsNotInstalled_SaysToChooseAnotherPrinter()
+ {
+ await _main.Printer.RefreshPrintersAsync();
+ await Batch(null, records: 2, perJob: 0);
+ _main.Printer.SelectedPrinterName = "Old Printer";
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.ZplJobs);
+ Assert.Equal(Loc.Get("Print.Mode.PrinterNotListed", "Old Printer"), Data.ResultText);
+ }
+
+ // ------------------------------------------------------------------------------------ a record that cannot be drawn
+
+ [Fact]
+ public async Task ABarcodeThatCannotBeDrawn_StopsTheBatch_NamesTheRecord_AndKeepsOnlyWhatMayHavePrinted()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 6, perJob: 0, codes: new[] { "A1", "A2", "A3", "BAD", "A5", "A6" });
+ var notDrawn = Error(DevicePictureRenderer.BarcodeNotDrawn, "Code 128", "BAD");
+ // Records 1 and 2 went out in the first driver document; the next document could not be drawn because of record 4's data.
+ _fake.Printer.Result = _ => PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, DevicePictureRenderer.BarcodeNotDrawn), 10)
+ .WithNotices(new[] { notDrawn }).WithPages(2);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.True(Data.ResultIsError);
+ Assert.Contains(Loc.Get("Data.Driver.RecordNotDrawn", 4, 3), Data.ResultText);
+ Assert.Contains(DriverPreflightGate.FormatFinding(notDrawn), Data.ResultText);
+ Assert.Contains(Loc.Get("Data.Driver.PrintedThrough", 2), Data.ResultText);
+ // The headline of a whole label with errors ("nothing was sent") would be false here: two labels may be on paper.
+ Assert.DoesNotContain(Loc.Get("Print.Error.InvalidLabel"), Data.ResultText);
+ Assert.Equal(2, Used);
+ Assert.Equal("SN0003", await FirstSerialOfTheNextBatch(PrinterOutputMode.WindowsDriver));
+ }
+
+ [Fact]
+ public async Task ABarcodeValueThatAlsoSitsInAnotherRecordsField_NamesTheRange_NotTheWrongRecord()
+ {
+ // Record 5's barcode value "2" cannot be drawn, but record 4 holds "2" too, in its copies column (c = n % 3 + 1). Naming record 4 would
+ // send the person to correct the wrong record, so the batch names the records the problem can be in instead.
+ await Batch(PrinterOutputMode.WindowsDriver, records: 6, perJob: 0, codes: new[] { "A1", "A2", "A3", "A4", "2", "A6" });
+ var notDrawn = Error(DevicePictureRenderer.BarcodeNotDrawn, "EAN-13", "2");
+ _fake.Printer.Result = _ => PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, DevicePictureRenderer.BarcodeNotDrawn), 10)
+ .WithNotices(new[] { notDrawn }).WithPages(2);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.True(Data.ResultIsError);
+ Assert.Contains(Loc.Get("Data.Driver.RecordsNotDrawn", 3, 6), Data.ResultText);
+ Assert.DoesNotContain(Loc.Get("Data.Driver.RecordNotDrawn", 4, 3), Data.ResultText);
+ Assert.Equal(2, Used);
+ }
+
+ [Fact]
+ public async Task APartThatCannotBeDrawn_WithoutAValueToMatch_NamesTheRecordsItCanBeIn()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 5, perJob: 0);
+ var notDrawn = Error(DevicePictureRenderer.ImageNotDrawn);
+ _fake.Printer.Result = _ => PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, DevicePictureRenderer.ImageNotDrawn))
+ .WithNotices(new[] { notDrawn }).WithPages(1);
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.True(Data.ResultIsError);
+ Assert.Contains(Loc.Get("Data.Driver.RecordsNotDrawn", 2, 5), Data.ResultText);
+ Assert.Contains(DriverPreflightGate.FormatFinding(notDrawn), Data.ResultText);
+ Assert.Equal(1, Used);
+ }
+
+ [Fact]
+ public void EveryNewBatchTextExists_InAllFourLanguages_WithoutHoles()
+ {
+ string[] keys =
+ {
+ "Data.Driver.Confirm", "Data.Driver.CannotPrint", "Data.Driver.RecordNotDrawn", "Data.Driver.RecordsNotDrawn", "Data.Driver.PrintedThrough",
+ "Data.Driver.PrintedInPart", "Data.Driver.JobRefused",
+ };
+ Assert.Empty(UiStrings.MissingInAnyLanguage(keys));
+ foreach (string lang in Loc.SupportedLanguages)
+ {
+ Loc.Instance.SetLanguage(lang);
+ foreach (string key in keys)
+ {
+ string text = Loc.Get(key, "7", "9", "11");
+ Assert.DoesNotMatch(new Regex(@"\{\d\}"), text);
+ Assert.True(text.Length > 30, $"{lang} {key}: {text}");
+ }
+ // The preview notes in picture mode must not promise more than the print keeps: the Print dialog lists what is left out.
+ var resx = UiStrings.Read(lang);
+ // No-break spaces (French typography) are compared as plain spaces, so the check cannot pass on a space alone.
+ foreach (var (key, old) in OldPreviewNotes[lang])
+ Assert.NotEqual(old, resx[key].Replace(' ', ' ').Replace(' ', ' '));
+ }
+ }
+
+ /// The picture-mode preview notes (full, status bar, screen reader name) before they said that the Print dialog lists the parts left out of the print.
+ private static readonly Dictionary OldPreviewNotes = new()
+ {
+ ["en"] = new[]
+ {
+ ("Preview.DriverNote", "The preview shows what this printer will print: it prints this same picture through its Windows driver, so text uses Windows fonts instead of the printer's own fonts."),
+ ("Preview.DriverNote.Short", "Preview shows the printed picture"),
+ ("Preview.DriverNote.Name", "Note: the preview shows the printed picture"),
+ },
+ ["es"] = new[]
+ {
+ ("Preview.DriverNote", "La vista previa muestra lo que imprimirá esta impresora: imprime esta misma imagen con su controlador de Windows, así que el texto usa fuentes de Windows en lugar de las fuentes propias de la impresora."),
+ ("Preview.DriverNote.Short", "La vista previa muestra la imagen impresa"),
+ ("Preview.DriverNote.Name", "Nota: la vista previa muestra la imagen impresa"),
+ },
+ ["pt-BR"] = new[]
+ {
+ ("Preview.DriverNote", "A visualização mostra o que esta impressora vai imprimir: ela imprime esta mesma imagem pelo driver do Windows, então o texto usa fontes do Windows em vez das fontes da própria impressora."),
+ ("Preview.DriverNote.Short", "A visualização mostra a imagem impressa"),
+ ("Preview.DriverNote.Name", "Observação: a visualização mostra a imagem impressa"),
+ },
+ ["fr-CA"] = new[]
+ {
+ ("Preview.DriverNote", "L’aperçu montre ce que cette imprimante va imprimer : elle imprime cette même image par son pilote Windows, alors le texte utilise des polices Windows au lieu des polices propres à l’imprimante."),
+ ("Preview.DriverNote.Short", "L’aperçu montre l’image imprimée"),
+ ("Preview.DriverNote.Name", "Remarque : l’aperçu montre l’image imprimée"),
+ },
+ };
+
+ // ------------------------------------------------------------------------------------ the real print service agrees on the fingerprint
+
+ [Fact]
+ public async Task TheRealService_PrintsTheBatchOnceTheFindingsAreAccepted()
+ {
+ // The real service works the fingerprint out again for every job, so this proves the batch checks the very document it prints.
+ var spooler = new FakeSpooler();
+ var driver = new FakeDriverPrinter { Media = Media with { CustomSizeAccepted = false } };
+ var service = new PrintService(spooler, new TcpRawPrinter(), spooler, new PrinterStatusReader(), driver);
+ var main = new MainViewModel(_fake.Build() with { Printing = service });
+ main.Printer.SelectedPrinterName = Queue;
+ main.Session.Edit("Undo.Property", d => d.Elements.Add(new TextElement { Text = "Item {{n}}" }));
+ string path = Path.Combine(_dir, "real.csv");
+ File.WriteAllText(path, "n\n1\n2\n3\n", new UTF8Encoding(false));
+ Assert.True(await main.Data.LoadAsync(path));
+ main.Data.MaxRecordsText = "2";
+ main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.WindowsDriver);
+
+ await main.Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.False(main.Data.ResultIsError, main.Data.ResultText);
+ Assert.Equal(2, _fake.Confirms.Count); // the batch, then the paper finding
+ Assert.Equal(2, driver.Jobs.Count); // two jobs of two and one records
+ Assert.Equal(3, driver.AllPages.Count);
+ Assert.Empty(spooler.Jobs); // not one byte of ZPL
+ }
+
+ // ------------------------------------------------------------------------------------ the Data window shows the mode question
+
+ [Fact]
+ public async Task TheDataWindow_ShowsThePrintModeQuestion_ForAPrinterThatHasNotAnswered()
+ {
+ var snapshot = new LanguageEventSnapshot();
+ try
+ {
+ var main = new MainViewModel(_fake.Build());
+ await main.Printer.RefreshPrintersAsync();
+ main.Printer.SelectedPrinterName = Queue;
+ main.Session.Edit("Undo.Property", d => d.Elements.Add(new TextElement { Text = "Item {{n}}" }));
+ string path = Path.Combine(_dir, "hosted.csv");
+ File.WriteAllText(path, "n\n1\n2\n", new UTF8Encoding(false));
+ Assert.True(await main.Data.LoadAsync(path));
+ Assert.True(main.Printer.ShowModeQuestion);
+
+ var facts = WpfTestHost.Run(() =>
+ {
+ var window = new DataWindow(main.Data);
+ var content = (FrameworkElement)window.Content;
+ // The printer card lives on the batch tab; only the selected tab is laid out, so that one is selected.
+ var batchTab = Logical(content).First(t => Logical(t).Any());
+ ((TabControl)batchTab.Parent).SelectedItem = batchTab;
+ var size = new Size(1100, 800);
+ content.Measure(size);
+ content.Arrange(new Rect(size));
+ content.UpdateLayout();
+ var view = Logical(content).Single();
+ var question = (FrameworkElement)view.FindName("ModeQuestionPanel");
+ var result = (view.ShowModeChoice, Shown(question, view));
+ DetachAll(window);
+ return result;
+ });
+
+ Assert.True(facts.ShowModeChoice);
+ Assert.True(facts.Item2, "The Data window hides the print mode question.");
+ }
+ finally { snapshot.Restore(); }
+ }
+
+ // ------------------------------------------------------------------------------------ helpers
+
+ /// Counter memory and no questions to ask, so a label with a remembered serial counter can print.
+ private sealed class SerialVariables(MemoryCounterStore store) : IVariableServices
+ {
+ public ICounterStore? CounterStore => store;
+ public IPromptMemory PromptMemory { get; } = new MemoryPromptMemory();
+ public IReadOnlyDictionary? Ask(PromptSet set) => new Dictionary();
+ }
+
+ private static IEnumerable Logical(DependencyObject root) where T : DependencyObject
+ {
+ foreach (var child in LogicalTreeHelper.GetChildren(root).OfType())
+ {
+ if (child is T match) yield return match;
+ foreach (var nested in Logical(child)) yield return nested;
+ }
+ }
+
+ ///
+ /// True when and every parent up to are set to show (a window measured off screen is never
+ /// shown, so cannot be used).
+ ///
+ private static bool Shown(DependencyObject element, DependencyObject root)
+ {
+ for (DependencyObject? d = element; d is not null; d = VisualTreeHelper.GetParent(d) ?? LogicalTreeHelper.GetParent(d))
+ {
+ if (d is UIElement { Visibility: not Visibility.Visible }) return false;
+ if (ReferenceEquals(d, root)) return true;
+ }
+ return false;
+ }
+
+ /// Drops every binding in the window, so the closed window never reacts to later tests that switch the language from another thread.
+ private static void DetachAll(Window window)
+ {
+ var pending = new Stack();
+ pending.Push(window);
+ while (pending.Count > 0)
+ {
+ var node = pending.Pop();
+ BindingOperations.ClearAllBindings(node);
+ if (node is Visual)
+ for (int i = 0; i < VisualTreeHelper.GetChildrenCount(node); i++) pending.Push(VisualTreeHelper.GetChild(node, i));
+ foreach (var logical in LogicalTreeHelper.GetChildren(node).OfType()) pending.Push(logical);
+ }
+ window.DataContext = null;
+ }
+
+ /// Remembers 's subscribers so a hosted test can undo what its view-models and bindings added (see AppPrintRecordsTests).
+ private sealed class LanguageEventSnapshot
+ {
+ private static readonly System.Reflection.FieldInfo[] Fields = new[] { "LanguageChanged", "PropertyChanged" }
+ .Select(n => typeof(Loc).GetField(n, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!)
+ .ToArray();
+
+ private readonly Delegate?[] _saved = Fields.Select(f => (Delegate?)f.GetValue(Loc.Instance)).ToArray();
+
+ public void Restore()
+ {
+ for (int i = 0; i < Fields.Length; i++) Fields[i].SetValue(Loc.Instance, _saved[i]);
+ }
+ }
+
+ // ------------------------------------------------------------------------------------ the limit on labels in one picture job
+
+ /// The Print dialog's text for the size finding, as the person reads it.
+ private static string TooManyText(long labels) => DriverPreflightGate.FormatFinding(Error(DriverPreflight.TooManyLabels,
+ labels.ToString(CultureInfo.InvariantCulture), DriverPreflight.MaxLabelsPerJob.ToString(CultureInfo.InvariantCulture)));
+
+ [Fact]
+ public async Task APictureBatch_IsPreflightedWithTheCopiesOfEveryRecord_AndAtTheLimitItPrints()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 3, perJob: 2, copies: new[] { "60000", "30000", "10000" });
+ Data.CopiesSource = CopiesSource.Column;
+ Data.CopiesColumn = "c";
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ // Records x copies, summed over the whole batch (not per job): exactly the limit is allowed.
+ Assert.Equal(new long[] { 100_000 }, _fake.Printer.PreflightLabelCounts);
+ Assert.False(Data.ResultIsError, Data.ResultText);
+ Assert.Equal(2, _fake.Printer.Resolved.Count);
+ Assert.Equal(3, Used);
+ }
+
+ [Fact]
+ public async Task APictureBatch_OneLabelOverTheLimit_IsRefusedBeforeAnythingIsAskedReservedOrSent()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 3, perJob: 2, copies: new[] { "60000", "30000", "10001" });
+ Data.CopiesSource = CopiesSource.Column;
+ Data.CopiesColumn = "c";
+ await Data.PrintAllCommand.ExecuteAsync(null);
+
+ Assert.Equal(new long[] { 100_001 }, _fake.Printer.PreflightLabelCounts);
+ Assert.True(Data.ResultIsError);
+ Assert.Contains(TooManyText(100_001), Data.ResultText);
+ Assert.Empty(_fake.Printer.Resolved);
+ Assert.Empty(_fake.Printer.ZplJobs);
+ // Nothing was reserved: the series was never touched.
+ Assert.Null(_store.Get(CounterKeyOfLabel));
+ Assert.Equal(0, Used);
+ }
+
+ [Fact]
+ public async Task OnlyTheTickedRecords_AreCounted_AgainstTheLimit()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 3, perJob: 50);
+ Data.CopiesSource = CopiesSource.Fixed;
+ Data.FixedCopiesText = "40000";
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.True(Data.ResultIsError); // 3 x 40,000 = 120,000
+ Assert.Equal(new long[] { 120_000 }, _fake.Printer.PreflightLabelCounts);
+ Assert.Empty(_fake.Printer.Resolved);
+
+ Data.VisibleRows[2].IsSelected = false; // 2 x 40,000 = 80,000
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Equal(new long[] { 120_000, 80_000 }, _fake.Printer.PreflightLabelCounts);
+ Assert.False(Data.ResultIsError, Data.ResultText);
+ Assert.Equal(new[] { 1, 2 }.Select(i => i.ToString(CultureInfo.InvariantCulture)), _fake.Printer.Resolved.SelectMany(j => j.Labels).Select(l => l.Values!["n"]));
+ }
+
+ [Fact]
+ public async Task ThePrintDialog_CountsTheTickedRecordsTimesItsCopies_AndShowsTheSizeErrorInItsFindingsCard()
+ {
+ await Batch(PrinterOutputMode.WindowsDriver, records: 3, perJob: 50);
+ var dialog = Dialog();
+ dialog.CopiesText = "40000";
+ await dialog.PrintCommand.ExecuteAsync(null);
+
+ Assert.Equal(new long[] { 120_000 }, _fake.Printer.PreflightLabelCounts);
+ Assert.True(dialog.ResultIsError);
+ Assert.False(dialog.NeedsAcknowledgement);
+ Assert.Equal(TooManyText(120_000), Assert.Single(dialog.PreflightLines));
+ Assert.Equal(TooManyText(120_000), dialog.ResultText);
+ Assert.Empty(_fake.Printer.Resolved);
+
+ Data.VisibleRows[2].IsSelected = false;
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Equal(new long[] { 120_000, 80_000 }, _fake.Printer.PreflightLabelCounts);
+ Assert.False(dialog.ResultIsError, dialog.ResultText);
+ Assert.NotEmpty(_fake.Printer.Resolved);
+ }
+
+ [Fact]
+ public async Task APrinterLanguageBatch_IsNotLimitedByThePictureCap_AndNeverPreflighted()
+ {
+ await Batch(PrinterOutputMode.PrinterLanguage, records: 3, perJob: 50);
+ Data.CopiesSource = CopiesSource.Fixed;
+ Data.FixedCopiesText = "40000";
+ await Data.PrintAllCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.PreflightLabelCounts);
+ Assert.NotEmpty(_fake.Printer.ZplJobs);
+ }
+}
diff --git a/tests/Rotula.Tests/AppDriverPrintTests.cs b/tests/Rotula.Tests/AppDriverPrintTests.cs
new file mode 100644
index 0000000..d58b305
--- /dev/null
+++ b/tests/Rotula.Tests/AppDriverPrintTests.cs
@@ -0,0 +1,996 @@
+using System.Globalization;
+using System.Text.RegularExpressions;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Data;
+using System.Windows.Media;
+using Rotula.App.Editing;
+using Rotula.App.Localization;
+using Rotula.App.Services;
+using Rotula.App.ViewModels;
+using Rotula.App.Views;
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+using Rotula.Core.Validation;
+using Rotula.Printing;
+
+namespace Rotula.Tests;
+
+///
+/// The Print dialog in front of the Windows-driver (picture) print mode: the mode line, the one-time question, the pre-flight findings and the
+/// single acknowledgement. A printer in printer-language (ZPL) mode must behave exactly as before: no pre-flight, no findings, no new step.
+/// Nothing here reaches a real printer: the services are fakes, or the real print service wired to a fake spooler and a fake driver.
+///
+[Collection("App")]
+public class AppDriverPrintTests : IDisposable
+{
+ private const string Queue = "Brother QL-820NWB";
+ private readonly FakeServices _fake = new();
+ private readonly MainViewModel _main;
+
+ /// What the fake driver reports when a test needs media: a 4 x 6 inch page at 203 dpi, printable to its edges.
+ private static readonly DriverMedia Media = new(203, 203, 812, 1218, 812, 1218, 0, 0, true);
+
+ public AppDriverPrintTests()
+ {
+ _fake.Settings.Current.Language = "en";
+ _fake.Printer.Printers = new[] { new PrinterInfo(Queue, "USB001", "Brother QL-820NWB", true, false) };
+ _main = new MainViewModel(_fake.Build());
+ _main.AddElementCommand.Execute(ToolboxKind.Box);
+ }
+
+ public void Dispose() => Loc.Instance.SetLanguage("en");
+
+ /// The dialog for , with the given mode answered first (null leaves the printer's question unanswered).
+ private PrintViewModel Dialog(PrinterOutputMode? mode, IPrintService? service = null)
+ {
+ _main.Printer.SelectedPrinterName = Queue;
+ if (mode is { } m) _main.Printer.ChooseModeCommand.Execute(m);
+ return new PrintViewModel(service ?? _fake.Printer, _main.Printer, () => _main.Session.Document, () => _main.Data.CurrentData, _fake.Build().Files, "my-label");
+ }
+
+ private static ValidationIssue Warning(string key, params string[] args) => new(ValidationSeverity.Warning, key, null, args);
+
+ private static ValidationIssue Error(string key, params string[] args) => new(ValidationSeverity.Error, key, null, args);
+
+ /// Makes the fake pre-flight answer these findings for every document.
+ private void Findings(params ValidationIssue[] findings) => _fake.Printer.Preflight = (_, _) => new DriverPreflightResult(findings, Media);
+
+ private static string Fingerprint(params ValidationIssue[] findings) => new DriverPreflightResult(findings, null).Fingerprint;
+
+ // ------------------------------------------------------------------------------------ printer language (ZPL) is untouched
+
+ [Fact]
+ public async Task ZplModeNeverCallsPreflight()
+ {
+ // Even a fake that would report findings is never asked: a ZPL print has no pre-flight step at all.
+ Findings(Warning(DriverPreflight.RawBlocks, "2"));
+ var dialog = Dialog(PrinterOutputMode.PrinterLanguage);
+ dialog.CopiesText = "3";
+ await dialog.PrintCommand.ExecuteAsync(null);
+ await dialog.PrintTestCommand.ExecuteAsync(null);
+
+ Assert.Empty(_fake.Printer.Preflights);
+ var call = Assert.Single(_fake.Printer.Printed);
+ Assert.Equal(PrinterOutputMode.PrinterLanguage, call.Target.Mode);
+ Assert.Null(call.Target.AcknowledgedFindings);
+ Assert.Equal(3, call.Doc.Print.Quantity);
+ var test = Assert.Single(_fake.Printer.TestLabels);
+ Assert.Null(test.Target.AcknowledgedFindings);
+ Assert.False(dialog.HasPreflightLines);
+ Assert.False(dialog.NeedsAcknowledgement);
+ Assert.False(dialog.ResultIsError);
+ Assert.Equal(Loc.Get("Print.Done.Test"), dialog.ResultText);
+ }
+
+ [Fact]
+ public async Task ZplModeOnANetworkPrinter_NeverCallsPreflightAndShowsNoQuestion()
+ {
+ Findings(Warning(DriverPreflight.RawBlocks, "2"));
+ _main.Printer.UseNetwork = true;
+ _main.Printer.Host = "192.168.1.50";
+ var dialog = new PrintViewModel(_fake.Printer, _main.Printer, () => _main.Session.Document, () => null, _fake.Build().Files);
+ Assert.False(_main.Printer.ShowModeQuestion);
+ Assert.False(_main.Printer.ShowModeLine);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Preflights);
+ Assert.Single(_fake.Printer.Printed);
+ }
+
+ // ------------------------------------------------------------------------------------ findings and the one acknowledgement
+
+ [Fact]
+ public async Task DriverModeWithWarningsStopsUntilAcknowledged()
+ {
+ var finding = Warning(DriverPreflight.ModuleBelowMinimum, "SKU", "6.6", "9.8");
+ Findings(finding);
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Single(_fake.Printer.Preflights);
+ Assert.Single(dialog.PreflightLines);
+ Assert.True(dialog.HasPreflightLines);
+ Assert.True(dialog.NeedsAcknowledgement);
+ Assert.False(dialog.Acknowledged);
+ Assert.Empty(_fake.Printer.Printed);
+ Assert.True(dialog.ResultIsError);
+ Assert.Equal(Loc.Get("Print.Driver.TickToContinue"), dialog.ResultText);
+ Assert.Contains("SKU", dialog.PreflightLines[0]);
+
+ dialog.Acknowledged = true;
+ await dialog.PrintCommand.ExecuteAsync(null);
+ var call = Assert.Single(_fake.Printer.Printed);
+ Assert.Equal(Fingerprint(finding), call.Target.AcknowledgedFindings);
+ Assert.Equal(PrinterOutputMode.WindowsDriver, call.Target.Mode);
+ // The document that was checked is the very instance that is printed, so the service works out the same fingerprint.
+ Assert.Same(_fake.Printer.Preflights[^1].Doc, call.Doc);
+ Assert.False(dialog.ResultIsError);
+ // The findings stay on screen after a successful print: they describe what the label looked like on paper.
+ Assert.True(dialog.HasPreflightLines);
+ }
+
+ [Fact]
+ public async Task DriverModeWithErrorNeverPrintsEvenWhenTicked()
+ {
+ var oneDot = Error(DriverPreflight.ModuleOneDot, "SKU");
+ Findings(oneDot, Warning(DriverPreflight.RawBlocks, "1"));
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Printed);
+ Assert.True(dialog.ResultIsError);
+ Assert.Contains(DriverPreflightGate.FormatFinding(oneDot), dialog.ResultText);
+ Assert.Equal(2, dialog.PreflightLines.Count);
+ Assert.False(dialog.NeedsAcknowledgement); // no box to tick: nothing can override an error
+
+ dialog.Acknowledged = true;
+ await dialog.PrintCommand.ExecuteAsync(null);
+ await dialog.PrintTestCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Printed);
+ Assert.Empty(_fake.Printer.TestLabels);
+ Assert.True(dialog.ResultIsError);
+ }
+
+ [Fact]
+ public async Task AcknowledgementResetsWhenTheDesignChanges()
+ {
+ // The finding depends on the design, so editing it changes the fingerprint.
+ _fake.Printer.Preflight = (_, doc) =>
+ new DriverPreflightResult(new[] { Warning(DriverPreflight.RawBlocks, doc.Elements.Count.ToString(CultureInfo.InvariantCulture)) }, Media);
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ dialog.Acknowledged = true;
+
+ _main.Session.Edit("Undo.Property", d => d.Elements.Add(new RawZplElement { Zpl = "^FO10,10^GB50,50,2^FS", IsVisual = true }));
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.False(dialog.Acknowledged);
+ Assert.Empty(_fake.Printer.Printed);
+ Assert.Equal(Loc.Get("Print.Driver.TickToContinue"), dialog.ResultText);
+ Assert.Contains("2", dialog.PreflightLines.Single());
+
+ dialog.Acknowledged = true;
+ await dialog.PrintCommand.ExecuteAsync(null);
+ var call = Assert.Single(_fake.Printer.Printed);
+ Assert.Equal(Fingerprint(Warning(DriverPreflight.RawBlocks, "2")), call.Target.AcknowledgedFindings);
+ }
+
+ [Fact]
+ public async Task AcknowledgementResetsWhenThePrinterOrTheModeChanges()
+ {
+ _fake.Printer.Printers = new[]
+ {
+ new PrinterInfo(Queue, "USB001", "Brother QL-820NWB", true, false),
+ new PrinterInfo("DYMO LabelWriter", "USB002", "DYMO LabelWriter 450", false, false),
+ };
+ await _main.Printer.RefreshPrintersAsync();
+ _main.Printer.SelectedPrinterName = "DYMO LabelWriter";
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.WindowsDriver);
+ Findings(Warning(DriverPreflight.PaperMismatch, "100x50", "101.6x152.4"));
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+
+ await dialog.PrintCommand.ExecuteAsync(null);
+ dialog.Acknowledged = true;
+ // Two printers can report identical findings; the tick still belongs to the printer it was given for.
+ _main.Printer.SelectedPrinterName = "DYMO LabelWriter";
+ Assert.False(dialog.Acknowledged);
+ Assert.False(dialog.HasPreflightLines);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Printed);
+
+ dialog.Acknowledged = true;
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.PrinterLanguage);
+ Assert.False(dialog.Acknowledged);
+ Assert.False(dialog.HasPreflightLines);
+ }
+
+ [Fact]
+ public async Task ATestLabelInDriverMode_IsCheckedAndCarriesTheAcknowledgement()
+ {
+ var finding = Warning(DriverPreflight.PaperMismatch, "100x50", "101.6x152.4");
+ Findings(finding);
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+ await dialog.PrintTestCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.TestLabels);
+ Assert.True(dialog.NeedsAcknowledgement);
+
+ dialog.Acknowledged = true;
+ await dialog.PrintTestCommand.ExecuteAsync(null);
+ var test = Assert.Single(_fake.Printer.TestLabels);
+ Assert.Equal(Fingerprint(finding), test.Target.AcknowledgedFindings);
+ // The document checked is the test label the service builds from the same profile.
+ var checkedDoc = _fake.Printer.Preflights[^1].Doc;
+ Assert.Equal(TestLabel.Build(test.Profile).Width, checkedDoc.Width);
+ Assert.Equal(TestLabel.Build(test.Profile).Elements.Count, checkedDoc.Elements.Count);
+ }
+
+ [Fact]
+ public async Task ACancelledPreflight_IsACalmStop_NotAListOfProblems()
+ {
+ Findings(Error(DriverPreflight.Cancelled));
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Printed);
+ Assert.False(dialog.ResultIsError);
+ Assert.Equal(Loc.Get("Common.OperationCancelled"), dialog.ResultText);
+ Assert.False(dialog.HasPreflightLines);
+ }
+
+ [Fact]
+ public async Task NoFindings_PrintsAtOnce_WithoutABox()
+ {
+ Findings();
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Single(_fake.Printer.Printed);
+ Assert.False(dialog.HasPreflightLines);
+ Assert.False(dialog.NeedsAcknowledgement);
+ Assert.False(dialog.ResultIsError);
+ }
+
+ // ------------------------------------------------------------------------------------ the real print service agrees on the fingerprint
+
+ /// The real print service with a fake spooler and a fake driver whose media refuses the label size (one PaperMismatch warning).
+ private static (PrintService Service, FakeDriverPrinter Driver) RealService()
+ {
+ var spooler = new FakeSpooler();
+ var driver = new FakeDriverPrinter { Media = Media with { CustomSizeAccepted = false } };
+ return (new PrintService(spooler, new TcpRawPrinter(), spooler, new PrinterStatusReader(), driver), driver);
+ }
+
+ [Fact]
+ public async Task TheRealService_PrintsTheLabelOnceTicked_SoTheDialogCheckedTheSameDocument()
+ {
+ var (service, driver) = RealService();
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver, service);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(driver.Jobs);
+ Assert.True(dialog.NeedsAcknowledgement);
+ Assert.Contains(dialog.PreflightLines, l => l.Contains("100", StringComparison.Ordinal));
+
+ dialog.Acknowledged = true;
+ await dialog.PrintCommand.ExecuteAsync(null);
+ // A different document would give another fingerprint and the service would refuse it as not acknowledged.
+ Assert.False(dialog.ResultIsError, dialog.ResultText);
+ Assert.Single(driver.Jobs);
+ }
+
+ [Fact]
+ public async Task TheRealService_PrintsTheTestLabelOnceTicked_SoTheDialogCheckedTheSameTestLabel()
+ {
+ var (service, driver) = RealService();
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver, service);
+ dialog.Printer.WidthText = "80";
+ await dialog.PrintTestCommand.ExecuteAsync(null);
+ Assert.Empty(driver.Jobs);
+ Assert.True(dialog.NeedsAcknowledgement);
+
+ dialog.Acknowledged = true;
+ await dialog.PrintTestCommand.ExecuteAsync(null);
+ Assert.False(dialog.ResultIsError, dialog.ResultText);
+ var job = Assert.Single(driver.Jobs);
+ Assert.Equal(80, job.WidthMm, 1);
+ }
+
+ // ------------------------------------------------------------------------------------ the one-time question
+
+ [Fact]
+ public async Task APrinterThatHasNotAnswered_DoesNotPrint_AndTheQuestionShows()
+ {
+ await _main.Printer.RefreshPrintersAsync();
+ var dialog = Dialog(null);
+ Assert.True(_main.Printer.NeedsModeQuestion);
+ Assert.True(_main.Printer.ShowModeQuestion);
+ Assert.False(_main.Printer.ShowModeLine);
+
+ await dialog.PrintCommand.ExecuteAsync(null);
+ await dialog.PrintTestCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Printed);
+ Assert.Empty(_fake.Printer.TestLabels);
+ Assert.Empty(_fake.Printer.Preflights);
+ Assert.True(dialog.ResultIsError);
+ Assert.Equal(Loc.Get("Print.Mode.AnswerFirst"), dialog.ResultText);
+
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.PrinterLanguage);
+ Assert.False(_main.Printer.ShowModeQuestion);
+ Assert.True(_main.Printer.ShowModeLine);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Single(_fake.Printer.Printed);
+ }
+
+ [Fact]
+ public async Task AQueueThatIsNotInstalled_GetsNoQuestion_AndPrintSaysWhatToDo()
+ {
+ await _main.Printer.RefreshPrintersAsync();
+ _main.Printer.SelectedPrinterName = "Old Printer";
+ var dialog = new PrintViewModel(_fake.Printer, _main.Printer, () => _main.Session.Document, () => null, _fake.Build().Files);
+ Assert.True(_main.Printer.NeedsModeQuestion);
+ Assert.False(_main.Printer.ShowModeQuestion);
+
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(_fake.Printer.Printed);
+ Assert.True(dialog.ResultIsError);
+ Assert.Equal(Loc.Get("Print.Mode.PrinterNotListed", "Old Printer"), dialog.ResultText);
+ }
+
+ [Fact]
+ public void TheModeLine_ExplainsTheMode_AndOffersTheOtherOne()
+ {
+ var dialog = Dialog(PrinterOutputMode.PrinterLanguage);
+ Assert.Equal(Loc.Get("Print.Mode.Explain.PrinterLanguage"), dialog.ModeExplanation);
+ var raised = new List();
+ dialog.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.WindowsDriver);
+ Assert.Equal(Loc.Get("Print.Mode.Explain.WindowsDriver"), dialog.ModeExplanation);
+ Assert.Contains(nameof(PrintViewModel.ModeExplanation), raised);
+ dialog.Detach();
+ }
+
+ [Fact]
+ public void ADetachedDialog_StopsListeningToThePrinter()
+ {
+ var dialog = Dialog(PrinterOutputMode.PrinterLanguage);
+ var raised = new List();
+ dialog.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
+ dialog.Detach();
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.WindowsDriver);
+ Assert.Empty(raised);
+ }
+
+ // ------------------------------------------------------------------------------------ results of a picture-mode print
+
+ [Fact]
+ public async Task EveryPictureModeFailure_IsAPlainSentence_InEveryLanguage()
+ {
+ Findings();
+ var notices = new[] { Warning(DriverPreflight.RawBlocks, "2"), Error(DevicePictureRenderer.BarcodeNotDrawn, "EAN-13", "12A") };
+ var results = new[]
+ {
+ PrintResult.Fail(PrintErrorKind.NotAcknowledged).WithNotices(notices),
+ PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "StartPage (Win32 error 1)")),
+ PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, DevicePictureRenderer.BarcodeNotDrawn)).WithNotices(notices),
+ PrintResult.Fail(PrintErrorKind.Cancelled),
+ };
+ foreach (var lang in Loc.SupportedLanguages)
+ {
+ Loc.Instance.SetLanguage(lang);
+ foreach (var result in results)
+ {
+ _fake.Printer.Result = _ => result;
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+ await dialog.PrintCommand.ExecuteAsync(null);
+ Assert.Empty(MissingMarker(dialog.ResultText));
+ Assert.DoesNotMatch(new Regex(@"\{\d\}"), dialog.ResultText);
+ Assert.DoesNotContain(Loc.Get("Issue.Unknown", "x").Split('(')[0], dialog.ResultText);
+ Assert.True(dialog.ResultText.Length > 20, result.Error!.Kind.ToString());
+ }
+ }
+ }
+
+ /// The part of a text that looks like a missing resource ("!Key!"), or empty.
+ private static string MissingMarker(string text) => Regex.Match(text, @"![A-Za-z]+\.[A-Za-z.]+!").Value;
+
+ [Fact]
+ public async Task ALabelThatStoppedTheJobAfterPagesWentOut_SaysHowManyWereSent_NeverThatNothingWas()
+ {
+ // Picture mode: three labels went to the driver, then a later label could not be drawn. "The label has errors, so nothing was sent"
+ // would be false and would invite printing those three again.
+ Findings();
+ var notices = new[] { Error(DevicePictureRenderer.BarcodeNotDrawn, "EAN-13", "12A") };
+ var partial = PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, DevicePictureRenderer.BarcodeNotDrawn)).WithNotices(notices).WithPages(3);
+ foreach (var lang in Loc.SupportedLanguages)
+ {
+ Loc.Instance.SetLanguage(lang);
+ _fake.Printer.Result = _ => partial;
+ var dialog = Dialog(PrinterOutputMode.WindowsDriver);
+ await dialog.PrintCommand.ExecuteAsync(null);
+
+ Assert.True(dialog.ResultIsError);
+ Assert.StartsWith(Loc.Get("Print.Error.InvalidLabel.Partial", 3), dialog.ResultText);
+ Assert.Contains("3", Loc.Get("Print.Error.InvalidLabel.Partial", 3));
+ Assert.DoesNotContain(Loc.Get("Print.Error.InvalidLabel"), dialog.ResultText);
+ // The reason still follows, worded like every other finding.
+ Assert.Contains(DriverPreflightGate.FormatFinding(notices[0]), dialog.ResultText);
+ Assert.Empty(MissingMarker(dialog.ResultText));
+ }
+ }
+
+ [Fact]
+ public void TheOrdinaryInvalidLabelText_StaysWhenNothingWasSent()
+ {
+ // A refused label (and every ZPL result, which never counts pages) keeps the sentence it always had; one page out already changes it.
+ var none = PrintResult.Fail(PrintError.Create(PrintErrorKind.InvalidLabel, "Label.Width.Range"));
+ Assert.Equal(Loc.Get("Print.Error.InvalidLabel"), PrintMessages.Describe(none));
+ Assert.Equal(Loc.Get("Print.Error.InvalidLabel.Partial", 1), PrintMessages.Describe(none.WithPages(1)));
+ Assert.DoesNotContain("nothing was sent", PrintMessages.Describe(none.WithPages(1))!, StringComparison.OrdinalIgnoreCase);
+ // Other kinds with pages keep their own texts (DriverRefused already says labels may have printed).
+ var refused = PrintResult.Fail(PrintError.Create(PrintErrorKind.DriverRefused, Queue, "StartPage")).WithPages(2);
+ Assert.Equal(PrintMessages.Describe(refused.Error!), PrintMessages.Describe(refused));
+ Assert.Null(PrintMessages.Describe(PrintResult.Ok(10)));
+ }
+
+ [Fact]
+ public void DriverRefused_NoLongerClaimsThatNothingWasSent()
+ {
+ // Pages the driver accepted before it refused one may still print, so the message must not promise the opposite.
+ foreach (var lang in UiStrings.Languages)
+ {
+ string text = UiStrings.Read(lang)["Print.Error.DriverRefused"];
+ Assert.DoesNotContain(UiStrings.Read(lang)["Print.Error.NotAcknowledged"].Split(':')[0].Trim(), text);
+ }
+ Assert.DoesNotContain("Nothing was sent", UiStrings.Read("en")["Print.Error.DriverRefused"]);
+ }
+
+ // ------------------------------------------------------------------------------------ texts
+
+ ///
+ /// Sample arguments for every finding key, in the order the library writes them. A key added to the library without an entry here fails
+ /// the test below, so its text is always checked.
+ ///
+ private static readonly Dictionary FindingSamples = new()
+ {
+ [DriverPreflight.DriverUnavailable] = new[] { Array.Empty() },
+ [DriverPreflight.RawBlocks] = new[] { new[] { "2" }, new[] { "1" } },
+ [DriverPreflight.ModuleOneDot] = new[] { new[] { "SKU" } },
+ // The same one-decimal number twice is a real case (9.84 mils under 9.85 mils), so it must read right too.
+ [DriverPreflight.ModuleBelowMinimum] = new[] { new[] { "SKU", "3.3", "9.8" }, new[] { "SKU", "9.8", "9.8" } },
+ [DriverPreflight.LabelLargerThanPaper] = new[] { new[] { "100x150", "101.6x101.6" } },
+ [DriverPreflight.MarginBand] = new[] { new[] { "101.6x152.4", "98.6x149.4", "1.5" } },
+ [DriverPreflight.PaperMismatch] = new[] { new[] { "100x50", "210x297" } },
+ [DriverPreflight.AxisRatio] = new[] { new[] { "600", "250" } },
+ [DriverPreflight.TooLarge] = new[] { new[] { "52000000", "40000000" } },
+ // An unnamed text whose first line is empty gives an empty name; the sentence must still read well.
+ [DriverPreflight.PrinterCounter] = new[] { new[] { "Lot" }, new[] { string.Empty } },
+ [DriverPreflight.Cancelled] = new[] { Array.Empty() },
+ [DriverPreflight.TooManyLabels] = new[] { new[] { "250000", "100000" } },
+ [DriverPreflight.LabelTurned] = new[] { new[] { "150x100", "100x150" } },
+ [DevicePictureRenderer.BarcodeNotDrawn] = new[] { new[] { "EAN-13", "12A" } },
+ [DevicePictureRenderer.ImageNotDrawn] = new[] { Array.Empty() },
+ [DevicePictureRenderer.ElementNotDrawn] = new[] { Array.Empty() },
+ };
+
+ /// Every finding key the printing library can hand to the app: the public message-key constants of the pre-flight and the picture renderer.
+ private static IEnumerable LibraryFindingKeys() =>
+ new[] { typeof(DriverPreflight), typeof(DevicePictureRenderer) }
+ .SelectMany(t => t.GetFields(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static))
+ .Where(f => f.IsLiteral && f.FieldType == typeof(string))
+ .Select(f => (string)f.GetRawConstantValue()!)
+ .Where(k => k.StartsWith("Driver.", StringComparison.Ordinal));
+
+ /// The dialog's own texts and the variants the formatter picks at run time (invisible to the scan of literal keys in code).
+ private static readonly string[] UiKeys =
+ {
+ "Print.Driver.AcknowledgeBox", "Print.Driver.TickToContinue", "Print.Driver.FindingsTitle", "Preview.DriverNote", "Preview.DriverNote.Short",
+ "Preview.DriverNote.Name", "Print.Mode.AnswerFirst", "Print.Mode.PrinterNotListed", "Print.Mode.Change.ToWindowsDriver",
+ "Print.Mode.Change.ToPrinterLanguage", "Print.Busy.Checking", "Print.Error.NotAcknowledged", "Print.Error.DriverRefused", "Print.Driver.MoreInList",
+ "Print.Error.InvalidLabel.Partial",
+ "Issue.Driver.Preflight.RawBlocks.One", "Issue.Driver.Preflight.ModuleBelowMinimum.Slightly", "Issue.Driver.Preflight.PrinterCounter.NoName",
+ };
+
+ [Fact]
+ public void EveryFindingKeyHasTextInAllFourLanguages()
+ {
+ var keys = LibraryFindingKeys().ToList();
+ Assert.Equal(16, keys.Count);
+ Assert.DoesNotContain(keys, k => !FindingSamples.ContainsKey(k));
+ Assert.Empty(UiStrings.MissingInAnyLanguage(keys.Select(k => "Issue." + k).Concat(UiKeys)));
+
+ foreach (var lang in UiStrings.Languages)
+ {
+ var resx = UiStrings.Read(lang);
+ Assert.DoesNotContain(keys.Select(k => "Issue." + k).Concat(UiKeys), k => string.IsNullOrWhiteSpace(resx[k]));
+ Loc.Instance.SetLanguage(lang);
+ string unknown = Loc.Get("Issue.Unknown", "§");
+ foreach (var key in keys)
+ {
+ foreach (var args in FindingSamples[key])
+ {
+ var issue = new ValidationIssue(ValidationSeverity.Warning, key, null, args);
+ foreach (string text in new[] { DriverPreflightGate.FormatFinding(issue), PrintMessages.DescribeNotice(issue) })
+ {
+ Assert.DoesNotContain("!Issue", text);
+ Assert.NotEqual(unknown.Replace("§", key, StringComparison.Ordinal), text);
+ Assert.DoesNotMatch(new Regex(@"\{\d\}"), text);
+ Assert.True(text.Length > 25, $"{lang} {key}: {text}");
+ Assert.DoesNotContain("\"\"", text);
+ Assert.DoesNotContain("« »", text.Replace(' ', ' '));
+ }
+ }
+ }
+ // The plain issue formatter used by other paths finds every finding text too (the Issue. prefix convention).
+ foreach (var key in keys)
+ Assert.NotEqual(unknown.Replace("§", key, StringComparison.Ordinal), Loc.FormatIssue(new ValidationIssue(ValidationSeverity.Warning, key, null, "1", "2", "3")));
+ }
+ }
+
+ [Fact]
+ public void TheEqualMilsCase_SaysSlightlyUnder_AndTheEmptyName_ReadsWithoutQuotes()
+ {
+ string equal = DriverPreflightGate.FormatFinding(Warning(DriverPreflight.ModuleBelowMinimum, "SKU", "9.8", "9.8"));
+ string clear = DriverPreflightGate.FormatFinding(Warning(DriverPreflight.ModuleBelowMinimum, "SKU", "3.3", "9.8"));
+ Assert.Contains("slightly", equal, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("9.8 mils wide", equal);
+ Assert.Contains("3.3", clear);
+ Assert.Contains("9.8", clear);
+
+ string named = DriverPreflightGate.FormatFinding(Error(DriverPreflight.PrinterCounter, "Lot"));
+ string unnamed = DriverPreflightGate.FormatFinding(Error(DriverPreflight.PrinterCounter, string.Empty));
+ Assert.Contains("\"Lot\"", named);
+ Assert.DoesNotContain("\"", unnamed);
+ Assert.Contains("counter", unnamed, StringComparison.OrdinalIgnoreCase);
+
+ Assert.Contains("1 part ", DriverPreflightGate.FormatFinding(Warning(DriverPreflight.RawBlocks, "1")));
+ Assert.Contains("2 parts", DriverPreflightGate.FormatFinding(Warning(DriverPreflight.RawBlocks, "2")));
+ }
+
+ [Fact]
+ public void TheMarginBand_SaysWhatThePrinterCannotPrint_NotWhereTheDesignIs()
+ {
+ // The warning comes for every label larger than the printable area, whatever its content, so it says what the printer cannot print
+ // (the widest edge, worked out by the library) and how to clear it, never that the design reaches the edge.
+ string en = DriverPreflightGate.FormatFinding(Warning(DriverPreflight.MarginBand, "101.6x152.4", "98.6x149.4", "1.5"));
+ Assert.Contains("101.6 × 152.4", en);
+ Assert.Contains("98.6 × 149.4", en);
+ Assert.Contains("outer 1.5 mm", en);
+ Assert.Contains("at least 1.5 mm", en);
+ Assert.Contains("cut off", en);
+ Assert.Contains("paper size", en);
+ Assert.DoesNotContain("Move the content", en);
+ Assert.DoesNotContain("larger paper", en);
+ Assert.DoesNotContain("Part of the label is", en);
+
+ // The edge follows the language's decimal separator, as every number of these texts does.
+ Loc.Instance.SetLanguage("es");
+ string es = DriverPreflightGate.FormatFinding(Warning(DriverPreflight.MarginBand, "101.6x152.4", "98.6x152.4", "3.0"));
+ Assert.Contains("3,0", es);
+ Assert.DoesNotContain("{", es);
+ }
+
+ [Fact]
+ public void TheMarginBand_ReadsThroughThePlainIssueFormatterToo()
+ {
+ // Every hole of the text is an argument of the finding itself, so a caller that uses the generic formatter (Loc.FormatIssue) gets a
+ // whole sentence in every language instead of a format error.
+ foreach (var lang in UiStrings.Languages)
+ {
+ Loc.Instance.SetLanguage(lang);
+ string text = Loc.FormatIssue(Warning(DriverPreflight.MarginBand, "101.6x152.4", "98.6x149.4", "1.5"));
+ Assert.Contains("1.5", text);
+ Assert.DoesNotMatch(new Regex(@"\{\d\}"), text);
+ }
+ }
+
+ [Fact]
+ public void Describe_PutsOneFindingPerLine()
+ {
+ var findings = new[] { Warning(DriverPreflight.RawBlocks, "2"), Error(DriverPreflight.ModuleOneDot, "SKU") };
+ string text = DriverPreflightGate.Describe(findings);
+ Assert.Equal(2, text.Split(Environment.NewLine).Length);
+ Assert.Equal(Loc.Get("Print.Driver.FindingsTitle"), DriverPreflightGate.Title);
+ Assert.Equal(string.Empty, DriverPreflightGate.Describe(Array.Empty()));
+ }
+
+ [Fact]
+ public void PreviewNoteChangesInDriverMode()
+ {
+ _main.Printer.SelectedPrinterName = Queue;
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.PrinterLanguage);
+ Assert.Equal(Loc.Get("Preview.Approximate"), _main.ApproximateNotice);
+ Assert.Equal(Loc.Get("Preview.Approximate.Short"), _main.ApproximateNoticeShort);
+
+ var raised = new List();
+ _main.PropertyChanged += (_, e) => raised.Add(e.PropertyName);
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.WindowsDriver);
+ Assert.Equal(Loc.Get("Preview.DriverNote"), _main.ApproximateNotice);
+ Assert.Equal(Loc.Get("Preview.DriverNote.Short"), _main.ApproximateNoticeShort);
+ Assert.Equal(Loc.Get("Preview.DriverNote.Name"), _main.ApproximateNoticeName);
+ Assert.Contains(nameof(MainViewModel.ApproximateNotice), raised);
+ Assert.Contains(nameof(MainViewModel.ApproximateNoticeShort), raised);
+
+ _main.ShowPreviewCommand.Execute(null);
+ Assert.Equal(Loc.Get("Preview.DriverNote"), _fake.ToolDialogs.LastPreviewNote);
+
+ _main.Printer.ChooseModeCommand.Execute(PrinterOutputMode.PrinterLanguage);
+ _main.ShowPreviewCommand.Execute(null);
+ Assert.Equal(Loc.Get("Preview.Approximate"), _fake.ToolDialogs.LastPreviewNote);
+ }
+
+ // ------------------------------------------------------------------------------------ hosted window
+
+ private static IEnumerable Descendants(DependencyObject root) where T : DependencyObject
+ {
+ int count = VisualTreeHelper.GetChildrenCount(root);
+ for (int i = 0; i < count; i++)
+ {
+ var child = VisualTreeHelper.GetChild(root, i);
+ if (child is T match) yield return match;
+ foreach (var nested in Descendants(child)) yield return nested;
+ }
+ }
+
+ private static T Named(DependencyObject root, string name) where T : FrameworkElement =>
+ Descendants(root).Single(e => e.Name == name);
+
+ /// Remembers 's subscribers so a hosted test can undo what its view-models and bindings added (see AppPrintRecordsTests).
+ private sealed class LanguageEventSnapshot
+ {
+ private static readonly System.Reflection.FieldInfo[] Fields = new[] { "LanguageChanged", "PropertyChanged" }
+ .Select(n => typeof(Loc).GetField(n, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!)
+ .ToArray();
+
+ private readonly Delegate?[] _saved = Fields.Select(f => (Delegate?)f.GetValue(Loc.Instance)).ToArray();
+
+ public void Restore()
+ {
+ for (int i = 0; i < Fields.Length; i++) Fields[i].SetValue(Loc.Instance, _saved[i]);
+ }
+ }
+
+ ///
+ /// True when and every parent up to are set to show. cannot
+ /// be used: a window measured off screen is never shown, so it reports false for everything inside it.
+ ///
+ private static bool Shown(DependencyObject element, DependencyObject root)
+ {
+ for (DependencyObject? d = element; d is not null; d = VisualTreeHelper.GetParent(d))
+ {
+ if (d is UIElement { Visibility: not Visibility.Visible }) return false;
+ if (ReferenceEquals(d, root)) return true;
+ }
+ return false;
+ }
+
+ ///
+ /// The content area of the Print window at its minimum size (MinWidth 560, MinHeight 520): the dialog frame takes a 40-pixel caption and a
+ /// 1-pixel border on each side (the DialogWindow style in Theme.xaml).
+ ///
+ private const double MinContentWidth = 560 - 2, MinContentHeight = 520 - 40 - 2;
+
+ /// What the hosted window showed, measured on the WPF thread.
+ ///
+ /// Height left to the scrolled content (findings, printer, mode, copies). The footer and the result banner are docked outside it and never
+ /// scroll, so a long result must not squeeze this to nothing.
+ ///
+ private sealed record WindowFacts(bool FindingsShown, int FindingRows, bool BoxShown, bool StatusButtonShown, bool StatusBoxShown,
+ bool QuestionShown, bool ModeLineShown, bool PrintButtonInside, bool FindingsFitWidth, string? DriverButtonMode, string? LanguageButtonMode,
+ double ViewportHeight);
+
+ ///
+ /// Builds the Print window for on the WPF host thread at the given size and reads what it shows. With
+ /// the visible "Windows driver" answer of the question is then executed with its own command and parameter.
+ ///
+ private static WindowFacts Host(PrintViewModel dialog, double width, double height, bool clickDriverButton = false) => WpfTestHost.Run(() =>
+ {
+ var window = new PrintWindow(dialog);
+ var content = (FrameworkElement)window.Content;
+ var size = new Size(width, height);
+ content.Measure(size);
+ content.Arrange(new Rect(size));
+ content.UpdateLayout();
+
+ var findings = Named(content, "FindingsCard");
+ var box = Named(content, "AcknowledgeBox");
+ var statusButton = Named