diff --git a/CHANGELOG.md b/CHANGELOG.md
index c561607..208bc7b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
## [Unreleased]
### Added
+- **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. 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).
- **Settings your organization can manage**: administrators can fix the language and switch the update check on or off for everyone, suggest a default printer, keep add-ons in the user's own folder from loading, and switch printing or database access of the MCP server off, through Group Policy or Intune. Administrative templates (English and Spanish) are in `packaging/policies`. A setting fixed this way is greyed out in Rotula with "Managed by your organization".
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index cb90710..b6da1ff 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -14,7 +14,9 @@ These are product rules; changes that break them will not be merged.
address the user entered and the databases the user connects. The one exception is the **opt-in update check**: it sends
nothing unless the user (Help menu) or an administrator (policy) explicitly switches it on, and then it is at most one
anonymous `GET` of the public GitHub "latest release" API a day, with nothing about the user or the computer in it. It never
- downloads or installs anything. Do not add other network calls, and do not add anything to that request.
+ downloads or installs anything. Do not add other network calls, and do not add anything to that request. The print engine's
+ REST server (`rotula serve`) is not a call out: it listens only when someone starts it, only on the addresses in its settings
+ file (`127.0.0.1` by default), and talks only to the printers that file allows.
- **Light theme only.**
- **English, Spanish, Portuguese (Brazil) and French (Canada)** through `.resx` resources in `src/Rotula.App/Resources`
(`Strings.en.resx`, `Strings.es.resx`, `Strings.pt-BR.resx`, `Strings.fr-CA.resx`). Every new user-visible string needs all
diff --git a/README.md b/README.md
index 61fdaf3..246f217 100644
--- a/README.md
+++ b/README.md
@@ -43,6 +43,7 @@ Design it, preview it, merge your data, print it. No account, no cloud, no telem
- [Printing](#printing)
- [Print station](#print-station)
- [MCP server](#mcp-server)
+- [Print engine (command line and REST API)](#print-engine-command-line-and-rest-api)
- [Barcode support](#barcode-support)
- [Languages](#languages)
- [Privacy](#privacy)
@@ -452,6 +453,27 @@ on in `rotula-mcp.settings.json`. Printing needs a two-step confirmation. The in
---
+## Print engine (command line and REST API)
+
+`rotula.exe` prints Rotula labels with no window, for scripts, scheduled tasks and warehouse systems (WMS, ERP, shipping software).
+It uses the designer's own print path: resolution conversion, darkness and speed, variables and questions, data merge, and serial
+numbers that are reserved before a job and used up only for labels that reached the printer. Before every job it asks the printer
+for its status (ZPL `~HS`) and holds the job back while the printer is out of labels, open, paused or out of ribbon; after the last
+job it waits until the printer reports it finished. Results come back as exit codes or JSON.
+
+```bat
+rotula print carton.rotula --printer "Dock 1" --data wave-1207.csv --reference WAVE-1207 --json
+rotula status --host 10.20.0.31
+```
+
+`rotula serve` exposes the same engine as a REST API (`POST /api/v1/jobs`, printer lists and status, an OpenAPI description) that
+can run as a Windows service. It is off until you start it, listens on `127.0.0.1` unless you configure otherwise, needs an API
+key on every call, and prints only labels from the folders and to the printers its settings file lists. Messages follow the
+caller's language (English, Spanish, Portuguese (Brazil) or French (Canada)). Commands, API, settings and exit codes:
+[docs/print-engine.md](docs/print-engine.md). The installer does not include `rotula.exe` yet; build it from `src/Rotula.Cli`.
+
+---
+
## Languages
The interface is available in **English**, **Spanish**, **Portuguese (Brazil)** and **French (Canada)**. By default Rotula
@@ -471,6 +493,8 @@ one. It never downloads or installs anything. An administrator can switch the ch
([deployment](docs/deployment.md)). Your settings
(language, units, recent files, last printer) are a small JSON file in `%AppData%\Rotula`. If the program ever fails, it
writes a log to `%AppData%\Rotula\rotula.log` on your PC and sends it nowhere.
+The print engine's REST server (`rotula serve`) listens
+for calls only when you start it, only on the addresses in its settings file, and makes no outgoing calls except to printers.
---
@@ -538,6 +562,8 @@ flowchart LR
| `src/Rotula.Printing` | Windows spooler and TCP 9100 printing, printer detection |
| `src/Rotula.Data` | Database providers, presets, the read-only query guard and the connection store |
| `src/Rotula.Mcp` | The MCP server (a separate executable) |
+| `src/Rotula.Engine` | The headless print engine: print requests, printer status, settings and API keys. No UI |
+| `src/Rotula.Cli` | `rotula.exe`: the print engine's command line and REST server |
| `src/Rotula.App` | The WPF application |
| `tests/` | xUnit tests and the golden ZPL fixtures |
| `packaging/` | Installer script, MSI project, Group Policy templates and winget manifest templates |
diff --git a/Rotula.sln b/Rotula.sln
index ae37ced..2f6de85 100644
--- a/Rotula.sln
+++ b/Rotula.sln
@@ -47,6 +47,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Data.Pack.ClickHouse
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Data.Packs.Tests", "tests\Rotula.Data.Packs.Tests\Rotula.Data.Packs.Tests.csproj", "{3DC421B4-0B47-4308-8791-4225E9A0CCAD}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Engine", "src\Rotula.Engine\Rotula.Engine.csproj", "{AEEF329C-5A0F-42F6-BE98-2D444BFA3904}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Rotula.Cli", "src\Rotula.Cli\Rotula.Cli.csproj", "{1CB2C052-D923-4083-A606-C1E4B81D36C7}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -285,6 +289,30 @@ Global
{3DC421B4-0B47-4308-8791-4225E9A0CCAD}.Release|x64.Build.0 = Release|Any CPU
{3DC421B4-0B47-4308-8791-4225E9A0CCAD}.Release|x86.ActiveCfg = Release|Any CPU
{3DC421B4-0B47-4308-8791-4225E9A0CCAD}.Release|x86.Build.0 = Release|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x64.Build.0 = Debug|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Debug|x86.Build.0 = Debug|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|Any CPU.Build.0 = Release|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x64.ActiveCfg = Release|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x64.Build.0 = Release|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x86.ActiveCfg = Release|Any CPU
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904}.Release|x86.Build.0 = Release|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x64.Build.0 = Debug|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Debug|x86.Build.0 = Debug|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x64.ActiveCfg = Release|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x64.Build.0 = Release|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x86.ActiveCfg = Release|Any CPU
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -309,5 +337,7 @@ Global
{A93A2F2F-9C16-49E9-B046-5A0CC310AB2F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{12DDAC38-D696-4C2E-A2E4-7DADB7D08D28} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{3DC421B4-0B47-4308-8791-4225E9A0CCAD} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {AEEF329C-5A0F-42F6-BE98-2D444BFA3904} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {1CB2C052-D923-4083-A606-C1E4B81D36C7} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
EndGlobalSection
EndGlobal
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 578aee5..35bea7d 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -19,10 +19,17 @@ read from the project files and the license metadata in each package's `.nuspec`
| System.Configuration.ConfigurationManager (via the Microsoft.Extensions.* packages) | 9.0.18 | MIT | Rotula.Mcp | https://github.com/dotnet/runtime |
| System.Security.Cryptography.Pkcs (pinned to the patched version in Rotula.Data) | 10.0.13 | MIT | Rotula.Mcp | https://github.com/dotnet/runtime |
| System.Diagnostics.EventLog (via Microsoft.Extensions.Logging.EventLog) | 10.0.10 | MIT | Rotula.Mcp | https://github.com/dotnet/runtime |
+| ASP.NET Core shared framework (Microsoft.AspNetCore.App, part of .NET; copied into a self-contained `rotula.exe`) | 10.0 | MIT | Rotula.Cli | https://github.com/dotnet/aspnetcore |
+| Microsoft.AspNetCore.OpenApi (OpenAPI description of the print engine's REST API) | 10.0.13 | MIT | Rotula.Cli | https://github.com/dotnet/aspnetcore |
+| Microsoft.OpenApi (via Microsoft.AspNetCore.OpenApi) | 2.12.2 | MIT | Rotula.Cli | https://github.com/microsoft/OpenAPI.NET |
+| Microsoft.Extensions.Hosting.WindowsServices (runs `rotula serve` as a Windows service) | 10.0.13 | MIT | Rotula.Cli | https://github.com/dotnet/runtime |
+| System.ServiceProcess.ServiceController (via Microsoft.Extensions.Hosting.WindowsServices) | 10.0.13 | MIT | Rotula.Cli | https://github.com/dotnet/runtime |
| Liberation Sans Regular / Bold and Liberation Mono Regular (embedded stand-in fonts) | 2.x | SIL Open Font License 1.1 | Rotula.Rendering | https://github.com/liberationfonts/liberation-fonts |
The MCP server (`Rotula.Mcp`) is a separate executable from the app. It uses the packages marked `Rotula.Mcp` above; its license
expressions were read from each package's `.nuspec` in the restored NuGet cache (the SDK is Apache-2.0, used unmodified).
+The print engine (`rotula.exe`, `Rotula.Cli` and `Rotula.Engine`) is another separate executable; it uses the packages marked
+`Rotula.Cli` above besides Rotula.Rendering's and Rotula.Core's.
The Liberation fonts are embedded unmodified in `Rotula.Rendering`; their license text is in
`src/Rotula.Rendering/Fonts/LICENSE-LiberationFonts.txt` and is also embedded in the assembly. Apache-2.0 components are
diff --git a/docs/decisions.md b/docs/decisions.md
index c68c934..2a9624c 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -636,3 +636,30 @@ The design canvas stays upright because editing an upside-down label (handles, r
"Prints upside down" / "Prints mirrored" instead. PNG, JPEG and PDF exports and the MCP preview stay as designed: they are artwork for documents,
proofs and screens, not a picture of the media leaving the printer, and there is no "as printed" export option to honour. The two flips commute
(inverted and mirrored together is a top-to-bottom flip), so their order is not a question.
+
+## Print engine (Rotula.Engine, Rotula.Cli)
+
+### A library and a thin executable, on the app's print path
+`Rotula.Engine` (net10.0, no UI) turns a request into a print through `IPrintService`, `LabelPrintRun` / `VariableJob`, `PromptSet`,
+`LabelDpiConverter` and `PrinterSetup`, the same pieces the Print dialog and the data merge use, so a label prints byte for byte the same from
+the designer, the station and the engine. `Rotula.Cli` (`rotula.exe`) is only argument parsing, the REST host and the console. Results are values
+(`PrintJobResult` with an outcome, an error code and localized notices), never exceptions, so the command line and the API answer the same thing.
+Messages come from the app's own `.resx` files, embedded in the engine, so the engine needs no WPF and every string exists in all four languages.
+
+### Questions take their default, never the sample value
+An unattended job cannot ask. An unanswered question takes the default set in the designer; a required question with no default stops the job
+with every question listed. The sample value is design-time data and printing it on a real carton would be silent, wrong output.
+
+### One job at a time per printer, status before every job
+Requests for the same printer (a queue name, or host:port) wait on one `SemaphoreSlim`, so two callers never interleave labels on one roll. The
+`~HS` check runs before each job and verification once after the last, as in the app's data merge. Counters are reserved before and confirmed
+only for the labels sent, so a held-back or failed job gives its serial numbers back unless part of it may have printed.
+
+### The REST server is opt-in and allow-listed
+Offline-first stays true: the server listens only when started, on `127.0.0.1` unless configured, and calls out only to printers. Every route but
+health and the OpenAPI document needs a key; keys are 32 random bytes shown once and stored as SHA-256 hashes, compared with `FixedTimeEquals`
+(a slow hash is not needed for 256-bit random keys). Labels are named relative to configured folders (no rooted paths, `..`, drives, UNC or
+wildcards), printers must be listed, inline label content is off by default, and request bodies and label files have size limits. The settings
+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).
diff --git a/docs/deployment.md b/docs/deployment.md
index 03c08b5..70e23da 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -205,7 +205,7 @@ the logon requirement to **Whether or not a user is logged on**, and add the new
Rotula reads the policies once at start-up from `HKLM\SOFTWARE\Policies\P4Software\Rotula` (the computer) and
`HKCU\SOFTWARE\Policies\P4Software\Rotula` (the user). When both set the same value, **the computer's value wins**. A
setting fixed by a policy is shown disabled in Rotula with the note "Managed by your organization", and the user's own
-choice is kept in `settings.json` for the day the policy is removed. The MCP server (`Rotula.Mcp.exe`) reads the same key.
+choice is kept in `settings.json` for the day the policy is removed. The MCP server (`Rotula.Mcp.exe`) and the print engine (`rotula.exe`) read the same key.
| Policy (template name) | Registry value | Type | Values | Effect |
|---|---|---|---|---|
@@ -215,6 +215,7 @@ choice is kept in `settings.json` for the day the policy is removed. The MCP ser
| Allow data source add-ons from the user's folder | `UserAddOns` | REG_DWORD | `0` blocks | Add-ons in `%AppData%\Rotula\packs` are neither loaded nor offered for approval; the installed `packs` folder still works ([data-providers.md](data-providers.md)) |
| Allow the MCP server to print | `McpPrinting` | REG_DWORD | `0` blocks | The MCP server refuses every print request, whatever its settings file says |
| Allow the MCP server to read databases | `McpDatabase` | REG_DWORD | `0` blocks | The MCP server refuses every database query |
+| Allow the print engine to print | `PrintEngine` | REG_DWORD | `0` blocks | `rotula.exe` refuses to print or query printers (exit code 5) and its REST server answers 403, whatever its settings file says ([print-engine.md](print-engine.md)) |
A value that Rotula does not understand (a language other than the five values above, a number other than 0 or 1) is ignored, so
a typo never forces a wrong setting on users. The MCP policies can only take something away: setting them to `1` does not
diff --git a/docs/print-engine.md b/docs/print-engine.md
new file mode 100644
index 0000000..9343a8f
--- /dev/null
+++ b/docs/print-engine.md
@@ -0,0 +1,232 @@
+# Print engine: command line and REST API
+
+`rotula.exe` prints Rotula labels without the designer. A script, a scheduled task or a warehouse system (WMS, ERP, shipping
+software) hands it a `.rotula` label, the data and a printer; it prints exactly as the designer's Print dialog would, checks the
+printer before every job and says how it went in a form a program can act on.
+
+It has two faces over one engine:
+
+- **`rotula print`, `rotula status`, `rotula printers`**: a command line for scripts and schedulers. It runs with the rights of the
+ person or service that starts it.
+- **`rotula serve`**: a REST API for systems on this computer or the network. It prints only what its settings file allows (API
+ keys, label folders, printers) and can run as a Windows service.
+
+What it shares with the designer:
+
+- **The same print path.** Resolution conversion to the printer, darkness and speed, variables (dates, counters, questions, computed
+ values), data merge with the counters, copies and "skip or stop" settings saved with the label, and Windows fonts drawn as
+ pictures.
+- **Serial numbers are used once.** Counters that remember their value use the same counter file as the designer
+ (`%AppData%\Rotula\counters.json`, or `--counter-file` / `counterFile`). Numbers are reserved before a run and confirmed only for
+ labels that reached the printer: a printer that was offline gives the numbers back; a send that broke halfway keeps them used, so a
+ number is never printed twice.
+- **The printer status check (ZPL `~HS`).** Before every job the engine asks the printer whether it can print and holds the job back
+ while it reports it is out of labels, its printhead is open, the ribbon is out, it is paused, too hot, in diagnostics mode or its
+ buffer is full. After the last job it waits (up to 30 seconds) until the printer reports it finished. A printer that cannot answer
+ (many USB drivers) never blocks printing; the result says the status was not checked. `--no-status` and `--no-verify` (or
+ `"checkStatus": false` and `"verify": false`) switch either part off.
+- **One job at a time per printer.** Requests for the same printer wait for each other, so two callers never interleave their
+ labels. Different printers print at the same time.
+- **The messages of the app**, in English, Spanish, Portuguese (Brazil) or French (Canada).
+
+## Command line
+
+```text
+rotula print LABEL.rotula --printer NAME | --host ADDRESS [options]
+rotula status --printer NAME | --host ADDRESS [--port N]
+rotula printers
+rotula serve [--settings FILE] [--urls URL] rotula serve --init [--label-folder FOLDER]
+rotula new-key
+rotula help | version
+```
+
+| Option | Meaning |
+|---|---|
+| `--printer NAME` | An installed Windows printer (USB, shared or network queue). |
+| `--host ADDRESS`, `--port N` | A network printer reached directly over TCP (port 9100 when not given). |
+| `--copies N` | Copies of the label, or of every record. Without it, the quantity saved in the label (or the data's copies column) is used. |
+| `--set NAME=VALUE` | The value of a field (`{{NAME}}` in the label). Repeat for more fields. |
+| `--data FILE` | Records from a `.csv` / `.txt` (delimiter detected), `.xlsx` (`--sheet NAME`) or `.json` file; one label per record. A JSON object is one label's values. |
+| `--answer NAME=VALUE` | The answer to a question the label asks at print time (a prompt variable). |
+| `--dpi 203\|300\|600` | Printhead resolution. Without it, a Windows printer's driver is asked; a network printer prints at the label's own resolution. |
+| `--darkness 0-30`, `--speed 1-14` | Sent to the printer before the labels. |
+| `--no-status`, `--no-verify` | Skip the status check before every job, or the wait for "finished" after the last one. |
+| `--dry-run` | Builds the ZPL and prints it to the output (or to `--output FILE`) without sending anything and without using up serial numbers. |
+| `--reference TEXT` | Your own reference (order, wave, shipment), repeated in the result and the server log. |
+| `--counter-file FILE` | Where remembering counters keep their position. |
+| `--json` | The result as JSON, in the same shape as the REST API's answer. |
+| `--lang en\|es\|pt-BR\|fr-CA` | Language of the messages. Without it the Language policy, then the `ROTULA_LANG` environment variable, then Windows decides. |
+
+Questions a label asks at print time take their **designer default** when no `--answer` is given. A required question without
+a default and without an answer stops the job and lists every question; a sample value is never printed in place of an answer.
+
+Examples:
+
+```bat
+rotula print "C:\Labels\carton.rotula" --printer "Dock 1" --set sku=4006381333931 --set lot=L2410 --copies 2
+rotula print "C:\Labels\carton.rotula" --host 10.20.0.31 --data "C:\Exports\wave-1207.csv" --reference WAVE-1207
+rotula print "C:\Labels\carton.rotula" --printer "Dock 1" --data picks.json --dry-run --output wave.zpl
+rotula status --host 10.20.0.31 --json
+```
+
+### Exit codes
+
+| Code | Meaning |
+|---|---|
+| 0 | Printed (or the dry run worked, or the printer is ready). |
+| 1 | Sending failed (printer not found, offline, unreachable...), or the status could not be read. |
+| 2 | The command line or the request is wrong (unknown option, missing printer, unreadable data or label file). |
+| 3 | The label has errors, or the answers to its questions are missing. Nothing was sent. |
+| 4 | The printer reported a fault (out of labels, head open, paused...) and nothing was sent; for `status`, the printer cannot print now. |
+| 5 | Switched off by the organization's `PrintEngine` policy. |
+| 6 | Stopped with Ctrl+C. Labels already sent are counted; serial numbers of labels not sent go back. |
+
+## REST API
+
+### Set up
+
+```bat
+rotula serve --init --settings "C:\ProgramData\Rotula\rotula-engine.settings.json" --label-folder "D:\Labels"
+```
+
+This writes a starter settings file and prints a new API key **once**. Give the key to the calling system; the file keeps only its
+SHA-256 hash. Add the printers, then start the server:
+
+```bat
+rotula serve --settings "C:\ProgramData\Rotula\rotula-engine.settings.json"
+```
+
+```jsonc
+{
+ // Where the server listens. 127.0.0.1 accepts calls from this computer only.
+ "urls": ["http://127.0.0.1:9180"],
+ // SHA-256 hashes of the API keys ("rotula new-key" makes a key and its hash). Several keys can be listed, one per caller.
+ "apiKeyHashes": ["3f1c...e9"],
+ // A request names a label relative to one of these folders: "outbound/carton.rotula".
+ "labelFolders": ["D:\\Labels"],
+ // Windows printer names, or host / host:port of network printers. Nothing else can be printed to or queried.
+ "allowedPrinters": ["Dock 1", "10.20.0.31", "10.20.0.32:6101"],
+ // Accept the content of a .rotula file in the request (labelContent). Off: only labels from the folders print.
+ "allowInlineLabels": false,
+ "maxCopies": 1000,
+ "maxRecords": 10000,
+ "maxRequestBytes": 10485760,
+ "maxLabelBytes": 5242880,
+ // Language of the messages when a request has no Accept-Language header.
+ "language": "en",
+ // Optional: the counter file shared with the designer, for example on a server where the designer also runs.
+ "counterFile": null
+}
+```
+
+Unknown settings are an error (a misspelt `allowedPrinter` must not silently allow nothing or everything), and the server does not
+start without at least one valid key. Everything a caller can reach is listed: there is no "allow all".
+
+**HTTPS.** Listening on another address than `127.0.0.1` over plain `http` works but the server warns, because keys and label data
+would cross the network unencrypted. Use an `https://` URL with a certificate configured through the standard ASP.NET Core
+settings, for example the environment variables `ASPNETCORE_Kestrel__Certificates__Default__Path` and
+`ASPNETCORE_Kestrel__Certificates__Default__Password`, or put the server behind your reverse proxy.
+
+**As a Windows service** (elevated prompt):
+
+```bat
+sc.exe create RotulaEngine binPath= "\"C:\Program Files\Rotula\rotula.exe\" serve --settings \"C:\ProgramData\Rotula\rotula-engine.settings.json\"" start= auto
+sc.exe start RotulaEngine
+```
+
+Run the service as an account that may use the printers and read the label folders. A service account has its own
+`%AppData%`; set `counterFile` when serial numbers must continue from the designer's file.
+
+### Calls
+
+Every route except `GET /api/v1/health` and the OpenAPI document needs the key in the `X-Api-Key` header (or
+`Authorization: Bearer KEY`). The OpenAPI 3 description is at `/openapi/v1.json`. Messages follow the `Accept-Language` header
+(or `?lang=`).
+
+| Route | What it does |
+|---|---|
+| `GET /api/v1/health` | `{"status":"ok","version":"0.4.0","printingBlocked":false}`. No key needed. |
+| `GET /api/v1/printers` | The Windows printers, each with `allowed` (on `allowedPrinters`). |
+| `GET /api/v1/printers/{name}/status` | Status of an allowed Windows printer. |
+| `POST /api/v1/printers/status` | Status of an allowed printer: `{"host":"10.20.0.31"}` or `{"printer":"Dock 1"}`. |
+| `POST /api/v1/jobs` | Prints. |
+
+A print request:
+
+```json
+{
+ "label": "outbound/carton.rotula",
+ "printer": "Dock 1",
+ "reference": "WAVE-1207",
+ "copies": 1,
+ "records": [
+ { "sku": "4006381333931", "lot": "L2410", "qty": 12 },
+ { "sku": "4006381333948", "lot": "L2411", "qty": 6 }
+ ],
+ "answers": { "operator": "M. Rivera" }
+}
+```
+
+| Field | Meaning |
+|---|---|
+| `label` / `labelContent` | A label file relative to a label folder, or the content of a `.rotula` file (only with `allowInlineLabels`). Exactly one. |
+| `printer` / `host`, `port` | A Windows printer, or a network printer and its port. Exactly one, and it must be on `allowedPrinters`. |
+| `values` | The field values of one label. |
+| `records` | Several records; one label (times the copies) each. Not together with `values`. Values may be text, numbers or true/false. |
+| `copies`, `answers`, `dpi`, `darkness`, `speed` | As the command-line options. |
+| `checkStatus`, `verify` | `true` by default. |
+| `dryRun` | `true` returns the ZPL in `zpl` without printing. |
+| `reference` | Your own reference, repeated in the answer and the log. |
+
+Unknown fields are refused with 400, so a misspelt `copeis` never prints the label's own quantity by accident.
+
+The answer is the same JSON as `rotula print --json`:
+
+```json
+{
+ "outcome": "printed",
+ "message": "Sent 2 record(s), 2 label(s), to Dock 1 in 1 job(s).",
+ "printer": "Dock 1",
+ "records": 2,
+ "labels": 2,
+ "jobs": 1,
+ "bytesSent": 1874,
+ "notices": [ { "code": "PrinterStatus.Verified", "severity": "warning", "message": "The printer reported that it finished the job." } ],
+ "reference": "WAVE-1207",
+ "succeeded": true
+}
+```
+
+| `outcome` | HTTP | Meaning |
+|---|---|---|
+| `printed` | 200 | Every label was handed to the printer. Read `notices` for the verification. |
+| `dryRun` | 200 | Nothing was sent; the ZPL is in `zpl`. |
+| `invalid` | 400 | The request is wrong (copies out of range, values and records together...). |
+| `invalidLabel` | 422 | The label has errors or questions are unanswered; `notices` say which. Nothing was sent. |
+| `heldBack` | 409 | The printer reported a fault before a job; `errorCode` is `PaperOut`, `HeadOpen`, `RibbonOut`, `Paused`... Nothing more was sent. |
+| `failed` | 502 | Sending failed (`errorCode`: `Offline`, `HostUnreachable`, `Timeout`...). `jobs` says how many went out before. |
+| `cancelled` | 503 | The call was cancelled. |
+
+Refusals before the engine runs (no key 401, printer or label folder not allowed 403, label not found 404, unreadable body 400)
+answer `{"errorCode":"Engine.Error.PrinterNotAllowed","message":"..."}`.
+
+### Log
+
+The server writes one line per job to its console (and to the Windows event log when it runs as a service): the reference, an
+8-character id of the API key (a piece of its hash, useless for calling the server), the caller's address, the outcome, the printer,
+the number of labels and jobs, and the error code. It never logs label data.
+
+## Policy
+
+An administrator can switch the engine off for a computer with the **Allow the print engine to print** policy (`PrintEngine`,
+REG_DWORD `0`, in `packaging/policies`). The command line then refuses to print and to query printers (exit code 5), and the
+server answers 403 to jobs and status requests; the health check reports `"printingBlocked": true`. The policy cannot switch
+anything on; the server's own settings still decide what it allows. See [deployment.md](deployment.md).
+
+## Publishing
+
+`rotula.exe` is built from `src/Rotula.Cli` (the engine itself is `src/Rotula.Engine`, a library with no UI):
+
+```bat
+dotnet publish src\Rotula.Cli\Rotula.Cli.csproj -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:DebugType=none -o publish\win-x64-engine
+```
diff --git a/packaging/policies/Rotula.admx b/packaging/policies/Rotula.admx
index 7afbe67..59f52e7 100644
--- a/packaging/policies/Rotula.admx
+++ b/packaging/policies/Rotula.admx
@@ -88,5 +88,12 @@
+
+
+
+
+
+
diff --git a/packaging/policies/en-US/Rotula.adml b/packaging/policies/en-US/Rotula.adml
index 592b070..c122d03 100644
--- a/packaging/policies/en-US/Rotula.adml
+++ b/packaging/policies/en-US/Rotula.adml
@@ -65,6 +65,14 @@ Registry value: McpPrinting (number: 0 blocks).
Enabled or not configured: the settings file of the MCP server decides (allowDatabase). This policy cannot switch database access on by itself.
Registry value: McpDatabase (number: 0 blocks).
+ Allow the print engine to print
+ The print engine (rotula.exe) prints labels without the designer: from a command line, a script or a warehouse system calling its REST API.
+
+Disabled: the print engine never prints and never asks printers for their status, from the command line or its REST server.
+
+Enabled or not configured: the command line prints with the rights of whoever runs it, and the REST server prints only what its settings file allows (API keys, label folders, allowedPrinters). This policy cannot switch anything on by itself.
+
+Registry value: PrintEngine (number: 0 blocks).
diff --git a/packaging/policies/es-ES/Rotula.adml b/packaging/policies/es-ES/Rotula.adml
index c7af7d2..71fa4d4 100644
--- a/packaging/policies/es-ES/Rotula.adml
+++ b/packaging/policies/es-ES/Rotula.adml
@@ -65,6 +65,14 @@ Valor del registro: McpPrinting (número: 0 bloquea).
Habilitada o sin configurar: decide el archivo de configuración del servidor MCP (allowDatabase). Esta directiva no puede activar el acceso a bases de datos por sí sola.
Valor del registro: McpDatabase (número: 0 bloquea).
+ Permitir que el motor de impresión imprima
+ El motor de impresión (rotula.exe) imprime etiquetas sin el diseñador: desde la línea de comandos, un script o un sistema de almacén que llama a su API REST.
+
+Deshabilitada: el motor de impresión nunca imprime ni consulta el estado de las impresoras, ni desde la línea de comandos ni desde su servidor REST.
+
+Habilitada o no configurada: la línea de comandos imprime con los permisos de quien la ejecuta, y el servidor REST imprime solo lo que permite su archivo de configuración (claves de API, carpetas de etiquetas, allowedPrinters). Esta directiva no puede activar nada por sí sola.
+
+Valor del registro: PrintEngine (número: 0 bloquea).
diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx
index b4bf510..cc6b2d0 100644
--- a/src/Rotula.App/Resources/Strings.en.resx
+++ b/src/Rotula.App/Resources/Strings.en.resx
@@ -6498,4 +6498,190 @@ Brand and product names belong to their owners; Rotula is not affiliated with th
The label design is {0} x {1} mm, smaller than the {2} x {3} mm labels of this sheet. It is printed at its own size, centred on each label.
+
+ {0} needs NAME=VALUE, but got "{1}".
+
+
+ "{1}" is not a valid value for {0}.
+
+
+ rotula {0}: prints Rotula labels without the designer, for scripts, schedulers and warehouse systems.
+
+Commands:
+ rotula print LABEL.rotula --printer NAME | --host ADDRESS [options]
+ Prints a label. Options:
+ --port N TCP port of --host (default 9100)
+ --copies N copies of the label, or of every record
+ --data FILE records from a .csv, .txt, .xlsx or .json file (one label per record)
+ --sheet NAME the Excel sheet to read
+ --set NAME=VALUE a field value (repeat for more fields)
+ --answer NAME=VALUE an answer to a question the label asks at print time
+ --dpi 203|300|600 printhead resolution (default: what the Windows driver reports)
+ --darkness 0-30 darkness sent before the labels
+ --speed 1-14 print speed sent before the labels
+ --no-status do not ask the printer for its status before every job
+ --no-verify do not wait for the printer to report that it finished
+ --dry-run show the ZPL without printing (--output FILE writes it to a file)
+ --reference TEXT your own reference, repeated in the result
+ --counter-file FILE where serial numbers are remembered (default: the same file as the app)
+ rotula status --printer NAME | --host ADDRESS [--port N]
+ Asks the printer whether it can print right now.
+ rotula printers
+ Lists the Windows printers.
+ rotula serve [--settings FILE] [--urls URL]
+ Runs the REST API. "rotula serve --init [--label-folder FOLDER]" writes a starter settings file with a new key.
+ rotula new-key
+ Makes a new API key and the hash to put in the settings file.
+
+Every command takes --json (output for programs) and --lang en|es|pt-BR|fr-CA.
+Exit codes: 0 done, 1 printing failed, 2 wrong command line, 3 label errors, 4 printer not ready, 5 blocked by policy, 6 cancelled.
+
+
+ Created the settings file {0}. Add your printers to allowedPrinters, then run "rotula serve".
+
+
+ The settings file {0} already exists; it was not changed.
+
+
+ The option {0} needs a value.
+
+
+ New API key (shown only now; give it to the system that calls the server): {0}
+Hash to add to apiKeyHashes in the settings file: {1}
+
+
+ Name exactly one label file, for example: rotula print shipping.rotula --printer "Dock 1"
+
+
+ Record {0}:
+
+
+ Run "rotula help" for the commands and options.
+
+
+ Unknown command "{0}".
+
+
+ Unknown option {0}.
+
+
+ Warning:
+
+
+ Some answers to the label's questions are missing or not valid. The label asks: {0}. Questions marked * must be answered; the others take their default.
+
+
+ A valid API key is required. Send it in the X-Api-Key header.
+
+
+ The request could not be read: {0}
+
+
+ Copies must be a whole number from 1 to {0}.
+
+
+ The data file {0} could not be read: {1}
+
+
+ The data file {0} does not exist.{1}
+
+
+ The data in {0} must be a list of records (objects of field names and values) or one record. {1}
+
+
+ The resolution must be 203, 300 or 600 DPI.
+
+
+ This server does not accept labels sent as content (allowInlineLabels is off). Name a label file in one of its label folders.
+
+
+ Send either a label file name (label) or the label content (labelContent), not both.
+
+
+ The label {0} was not found.
+
+
+ The label {0} is not a .rotula file inside one of this server's label folders. Name it relative to a label folder, without a drive letter or "..".
+
+
+ Name a label file (label) or send the label content (labelContent).
+
+
+ The label {0} is larger than the limit of {1} KB.
+
+
+ The data has no records, so nothing was printed.
+
+
+ No record produced a label, so nothing was sent. See the problems listed with the result.
+
+
+ The file {0} could not be written: {1}
+
+
+ Name either a Windows printer or the address of a network printer, not both.
+
+
+ The printer {0} is not on this server's list of allowed printers (allowedPrinters). Nothing was sent.
+
+
+ Name a printer: a Windows printer (printer) or the address of a network printer (host).
+
+
+ Printing with the print engine is switched off by your organization's policy for this computer. Ask your IT department.
+
+
+ A request may have at most {0} records.
+
+
+ Send either the values of one label or a list of records, not both.
+
+
+ [default]
+
+
+ No printers are installed in Windows.
+
+
+ [offline]
+
+
+ Nothing was printed (dry run). This is the ZPL that would be sent to {0}.
+
+
+ Sent {0} label(s) to {1}.
+
+
+ Sent {0} record(s), {1} label(s), to {2} in {3} job(s).
+
+
+ The server could not listen on {0}: {1}
+
+
+ Rotula print engine listening on {0}. Settings: {1}. Press Ctrl+C to stop.
+
+
+ "{0}" in apiKeyHashes is not a key hash (64 hexadecimal characters). Make one with "rotula new-key".
+
+
+ The label folder {0} does not exist.
+
+
+ The settings file {0} cannot be used: {1}
+
+
+ The settings file {0} does not exist. Create one with "rotula serve --init".
+
+
+ No label folders are listed (labelFolders), and labels sent as content are switched off, so no label can be printed.
+
+
+ The settings file {0} lists no API key (apiKeyHashes). Make one with "rotula new-key".
+
+
+ No printers are allowed yet (allowedPrinters), so every print request will be refused.
+
+
+ The server accepts calls from other computers over plain http: API keys and label data cross the network unencrypted. Use https.
+
diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx
index 0d52890..1139cb1 100644
--- a/src/Rotula.App/Resources/Strings.es.resx
+++ b/src/Rotula.App/Resources/Strings.es.resx
@@ -6498,4 +6498,190 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a
El diseño de la etiqueta mide {0} x {1} mm, menos que las etiquetas de {2} x {3} mm de esta hoja. Se imprime a su propio tamaño, centrado en cada etiqueta.
+
+ {0} necesita NOMBRE=VALOR, pero recibió "{1}".
+
+
+ "{1}" no es un valor válido para {0}.
+
+
+ rotula {0}: imprime etiquetas de Rotula sin el diseñador, para scripts, programadores de tareas y sistemas de almacén.
+
+Comandos:
+ rotula print ETIQUETA.rotula --printer NOMBRE | --host DIRECCIÓN [opciones]
+ Imprime una etiqueta. Opciones:
+ --port N puerto TCP de --host (predeterminado 9100)
+ --copies N copias de la etiqueta, o de cada registro
+ --data ARCHIVO registros de un archivo .csv, .txt, .xlsx o .json (una etiqueta por registro)
+ --sheet NOMBRE la hoja de Excel que se lee
+ --set NOMBRE=VALOR el valor de un campo (repítalo para más campos)
+ --answer NOMBRE=VALOR la respuesta a una pregunta que la etiqueta hace al imprimir
+ --dpi 203|300|600 resolución del cabezal (predeterminado: la que indica el controlador de Windows)
+ --darkness 0-30 oscuridad enviada antes de las etiquetas
+ --speed 1-14 velocidad enviada antes de las etiquetas
+ --no-status no pregunta el estado a la impresora antes de cada trabajo
+ --no-verify no espera a que la impresora indique que terminó
+ --dry-run muestra el ZPL sin imprimir (--output ARCHIVO lo guarda en un archivo)
+ --reference TEXTO su propia referencia, repetida en el resultado
+ --counter-file ARCHIVO dónde se recuerdan los números de serie (predeterminado: el mismo archivo que la aplicación)
+ rotula status --printer NOMBRE | --host DIRECCIÓN [--port N]
+ Pregunta a la impresora si puede imprimir ahora.
+ rotula printers
+ Lista las impresoras de Windows.
+ rotula serve [--settings ARCHIVO] [--urls URL]
+ Ejecuta la API REST. "rotula serve --init [--label-folder CARPETA]" crea un archivo de configuración inicial con una clave nueva.
+ rotula new-key
+ Crea una clave de API nueva y el hash que va en el archivo de configuración.
+
+Todos los comandos aceptan --json (salida para programas) y --lang en|es|pt-BR|fr-CA.
+Códigos de salida: 0 hecho, 1 falló la impresión, 2 línea de comandos incorrecta, 3 errores en la etiqueta, 4 impresora no lista, 5 bloqueado por directiva, 6 cancelado.
+
+
+ Se creó el archivo de configuración {0}. Agregue sus impresoras en allowedPrinters y ejecute "rotula serve".
+
+
+ El archivo de configuración {0} ya existe; no se modificó.
+
+
+ La opción {0} necesita un valor.
+
+
+ Clave de API nueva (solo se muestra ahora; entréguela al sistema que llama al servidor): {0}
+Hash que debe agregar a apiKeyHashes en el archivo de configuración: {1}
+
+
+ Indique exactamente un archivo de etiqueta, por ejemplo: rotula print envio.rotula --printer "Muelle 1"
+
+
+ Registro {0}:
+
+
+ Ejecute "rotula help" para ver los comandos y las opciones.
+
+
+ Comando desconocido "{0}".
+
+
+ Opción desconocida {0}.
+
+
+ Advertencia:
+
+
+ Faltan respuestas a las preguntas de la etiqueta o alguna no es válida. La etiqueta pregunta: {0}. Las preguntas marcadas con * son obligatorias; las demás toman su valor predeterminado.
+
+
+ Se necesita una clave de API válida. Envíela en el encabezado X-Api-Key.
+
+
+ No se pudo leer la solicitud: {0}
+
+
+ Las copias deben ser un número entero de 1 a {0}.
+
+
+ No se pudo leer el archivo de datos {0}: {1}
+
+
+ El archivo de datos {0} no existe.{1}
+
+
+ Los datos de {0} deben ser una lista de registros (objetos con nombres de campo y valores) o un solo registro. {1}
+
+
+ La resolución debe ser 203, 300 o 600 DPI.
+
+
+ Este servidor no acepta etiquetas enviadas como contenido (allowInlineLabels está desactivado). Indique un archivo de etiqueta de una de sus carpetas de etiquetas.
+
+
+ Envíe el nombre de un archivo de etiqueta (label) o el contenido de la etiqueta (labelContent), no ambos.
+
+
+ No se encontró la etiqueta {0}.
+
+
+ La etiqueta {0} no es un archivo .rotula dentro de una de las carpetas de etiquetas de este servidor. Indíquela relativa a una carpeta de etiquetas, sin letra de unidad ni "..".
+
+
+ Indique un archivo de etiqueta (label) o envíe el contenido de la etiqueta (labelContent).
+
+
+ La etiqueta {0} supera el límite de {1} KB.
+
+
+ Los datos no tienen registros, así que no se imprimió nada.
+
+
+ Ningún registro produjo una etiqueta, así que no se envió nada. Vea los problemas indicados en el resultado.
+
+
+ No se pudo escribir el archivo {0}: {1}
+
+
+ Indique una impresora de Windows o la dirección de una impresora de red, no ambas.
+
+
+ La impresora {0} no está en la lista de impresoras permitidas de este servidor (allowedPrinters). No se envió nada.
+
+
+ Indique una impresora: una impresora de Windows (printer) o la dirección de una impresora de red (host).
+
+
+ La impresión con el motor de impresión está desactivada por la directiva de su organización para este equipo. Consulte a su departamento de TI.
+
+
+ Una solicitud puede tener como máximo {0} registros.
+
+
+ Envíe los valores de una etiqueta o una lista de registros, no ambos.
+
+
+ [predeterminada]
+
+
+ No hay impresoras instaladas en Windows.
+
+
+ [sin conexión]
+
+
+ No se imprimió nada (prueba). Este es el ZPL que se enviaría a {0}.
+
+
+ Se enviaron {0} etiqueta(s) a {1}.
+
+
+ Se enviaron {0} registro(s), {1} etiqueta(s), a {2} en {3} trabajo(s).
+
+
+ El servidor no pudo escuchar en {0}: {1}
+
+
+ Motor de impresión de Rotula escuchando en {0}. Configuración: {1}. Pulse Ctrl+C para detenerlo.
+
+
+ "{0}" en apiKeyHashes no es un hash de clave (64 caracteres hexadecimales). Cree uno con "rotula new-key".
+
+
+ La carpeta de etiquetas {0} no existe.
+
+
+ No se puede usar el archivo de configuración {0}: {1}
+
+
+ El archivo de configuración {0} no existe. Cree uno con "rotula serve --init".
+
+
+ No hay carpetas de etiquetas (labelFolders) y las etiquetas enviadas como contenido están desactivadas, así que no se puede imprimir ninguna etiqueta.
+
+
+ El archivo de configuración {0} no tiene ninguna clave de API (apiKeyHashes). Cree una con "rotula new-key".
+
+
+ Todavía no hay impresoras permitidas (allowedPrinters), así que se rechazará toda solicitud de impresión.
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.fr-CA.resx b/src/Rotula.App/Resources/Strings.fr-CA.resx
index d8c15e0..c724575 100644
--- a/src/Rotula.App/Resources/Strings.fr-CA.resx
+++ b/src/Rotula.App/Resources/Strings.fr-CA.resx
@@ -6498,4 +6498,190 @@ Les noms de marques et de produits appartiennent à leurs propriétaires ; Rot
Le dessin de l’étiquette mesure {0} x {1} mm, soit moins que les étiquettes de {2} x {3} mm de cette feuille. Il est imprimé à sa propre taille, centré sur chaque étiquette.
+
+ {0} attend NOM=VALEUR, mais a reçu « {1} ».
+
+
+ « {1} » n’est pas une valeur valide pour {0}.
+
+
+ rotula {0} : imprime des étiquettes Rotula sans le concepteur, pour les scripts, les planificateurs de tâches et les systèmes d’entrepôt.
+
+Commandes :
+ rotula print ÉTIQUETTE.rotula --printer NOM | --host ADRESSE [options]
+ Imprime une étiquette. Options :
+ --port N port TCP de --host (9100 par défaut)
+ --copies N copies de l’étiquette, ou de chaque enregistrement
+ --data FICHIER enregistrements d’un fichier .csv, .txt, .xlsx ou .json (une étiquette par enregistrement)
+ --sheet NOM la feuille Excel à lire
+ --set NOM=VALEUR la valeur d’un champ (répétez pour d’autres champs)
+ --answer NOM=VALEUR la réponse à une question que l’étiquette pose à l’impression
+ --dpi 203|300|600 résolution de la tête d’impression (par défaut : celle indiquée par le pilote Windows)
+ --darkness 0-30 noirceur envoyée avant les étiquettes
+ --speed 1-14 vitesse envoyée avant les étiquettes
+ --no-status ne demande pas l’état de l’imprimante avant chaque travail
+ --no-verify n’attend pas que l’imprimante indique qu’elle a terminé
+ --dry-run affiche le ZPL sans imprimer (--output FICHIER l’enregistre dans un fichier)
+ --reference TEXTE votre propre référence, reprise dans le résultat
+ --counter-file FICHIER où les numéros de série sont mémorisés (par défaut : le même fichier que l’application)
+ rotula status --printer NOM | --host ADRESSE [--port N]
+ Demande à l’imprimante si elle peut imprimer maintenant.
+ rotula printers
+ Liste les imprimantes Windows.
+ rotula serve [--settings FICHIER] [--urls URL]
+ Lance l’API REST. « rotula serve --init [--label-folder DOSSIER] » crée un fichier de paramètres de départ avec une nouvelle clé.
+ rotula new-key
+ Crée une nouvelle clé d’API et le hachage à mettre dans le fichier de paramètres.
+
+Toutes les commandes acceptent --json (sortie pour les programmes) et --lang en|es|pt-BR|fr-CA.
+Codes de sortie : 0 terminé, 1 échec de l’impression, 2 ligne de commande incorrecte, 3 erreurs dans l’étiquette, 4 imprimante pas prête, 5 bloqué par une stratégie, 6 annulé.
+
+
+ Le fichier de paramètres {0} a été créé. Ajoutez vos imprimantes dans allowedPrinters, puis lancez « rotula serve ».
+
+
+ Le fichier de paramètres {0} existe déjà ; il n’a pas été modifié.
+
+
+ L’option {0} doit être suivie d’une valeur.
+
+
+ Nouvelle clé d’API (affichée une seule fois ; remettez-la au système qui appelle le serveur) : {0}
+Hachage à ajouter dans apiKeyHashes du fichier de paramètres : {1}
+
+
+ Indiquez exactement un fichier d’étiquette, par exemple : rotula print expedition.rotula --printer "Quai 1"
+
+
+ Enregistrement {0} :
+
+
+ Lancez « rotula help » pour voir les commandes et les options.
+
+
+ Commande inconnue « {0} ».
+
+
+ Option inconnue {0}.
+
+
+ Avertissement :
+
+
+ Des réponses aux questions de l’étiquette manquent ou ne sont pas valides. L’étiquette demande : {0}. Les questions marquées * sont obligatoires; les autres prennent leur valeur par défaut.
+
+
+ Une clé d’API valide est requise. Envoyez-la dans l’en-tête X-Api-Key.
+
+
+ La requête n’a pas pu être lue : {0}
+
+
+ Le nombre de copies doit être un entier de 1 à {0}.
+
+
+ Le fichier de données {0} n’a pas pu être lu : {1}
+
+
+ Le fichier de données {0} n’existe pas.{1}
+
+
+ Les données de {0} doivent être une liste d’enregistrements (objets de noms de champs et de valeurs) ou un seul enregistrement. {1}
+
+
+ La résolution doit être de 203, 300 ou 600 ppp.
+
+
+ Ce serveur n’accepte pas les étiquettes envoyées comme contenu (allowInlineLabels est désactivé). Indiquez un fichier d’étiquette situé dans un de ses dossiers d’étiquettes.
+
+
+ Envoyez soit le nom d’un fichier d’étiquette (label), soit le contenu de l’étiquette (labelContent), pas les deux.
+
+
+ L’étiquette {0} est introuvable.
+
+
+ L’étiquette {0} n’est pas un fichier .rotula situé dans un des dossiers d’étiquettes de ce serveur. Indiquez-la par rapport à un dossier d’étiquettes, sans lettre de lecteur ni « .. ».
+
+
+ Indiquez un fichier d’étiquette (label) ou envoyez le contenu de l’étiquette (labelContent).
+
+
+ L’étiquette {0} dépasse la limite de {1} Ko.
+
+
+ Les données ne contiennent aucun enregistrement; rien n’a été imprimé.
+
+
+ Aucun enregistrement n’a produit d’étiquette; rien n’a été envoyé. Voyez les problèmes indiqués dans le résultat.
+
+
+ Le fichier {0} n’a pas pu être écrit : {1}
+
+
+ Indiquez soit une imprimante Windows, soit l’adresse d’une imprimante réseau, pas les deux.
+
+
+ L’imprimante {0} ne figure pas dans la liste des imprimantes autorisées de ce serveur (allowedPrinters). Rien n’a été envoyé.
+
+
+ Indiquez une imprimante : une imprimante Windows (printer) ou l’adresse d’une imprimante réseau (host).
+
+
+ L’impression avec le moteur d’impression est désactivée par la stratégie de votre organisation sur cet ordinateur. Adressez-vous à votre service informatique.
+
+
+ Une requête peut contenir au plus {0} enregistrements.
+
+
+ Envoyez soit les valeurs d’une étiquette, soit une liste d’enregistrements, pas les deux.
+
+
+ [par défaut]
+
+
+ Aucune imprimante n’est installée dans Windows.
+
+
+ [hors ligne]
+
+
+ Rien n’a été imprimé (essai). Voici le ZPL qui serait envoyé à {0}.
+
+
+ {0} étiquette(s) envoyée(s) à {1}.
+
+
+ {0} enregistrement(s), {1} étiquette(s), envoyé(s) à {2} en {3} travail(aux).
+
+
+ Le serveur n’a pas pu écouter sur {0} : {1}
+
+
+ Moteur d’impression Rotula à l’écoute sur {0}. Paramètres : {1}. Appuyez sur Ctrl+C pour l’arrêter.
+
+
+ « {0} » dans apiKeyHashes n’est pas un hachage de clé (64 caractères hexadécimaux). Créez-en un avec « rotula new-key ».
+
+
+ Le dossier d’étiquettes {0} n’existe pas.
+
+
+ Le fichier de paramètres {0} ne peut pas être utilisé : {1}
+
+
+ Le fichier de paramètres {0} n’existe pas. Créez-en un avec « rotula serve --init ».
+
+
+ Aucun dossier d’étiquettes n’est indiqué (labelFolders) et les étiquettes envoyées comme contenu sont désactivées; aucune étiquette ne peut donc être imprimée.
+
+
+ Le fichier de paramètres {0} ne contient aucune clé d’API (apiKeyHashes). Créez-en une avec « rotula new-key ».
+
+
+ Aucune imprimante n’est encore autorisée (allowedPrinters); toute demande d’impression sera donc refusée.
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.pt-BR.resx b/src/Rotula.App/Resources/Strings.pt-BR.resx
index c8eeae1..a31bf0d 100644
--- a/src/Rotula.App/Resources/Strings.pt-BR.resx
+++ b/src/Rotula.App/Resources/Strings.pt-BR.resx
@@ -6498,4 +6498,190 @@ Os nomes de marcas e produtos pertencem aos seus proprietários; o Rotula não t
O design da etiqueta tem {0} x {1} mm, menor que as etiquetas de {2} x {3} mm desta folha. Ele é impresso no próprio tamanho, centralizado em cada etiqueta.
+
+ {0} precisa de NOME=VALOR, mas recebeu "{1}".
+
+
+ "{1}" não é um valor válido para {0}.
+
+
+ rotula {0}: imprime etiquetas do Rotula sem o designer, para scripts, agendadores de tarefas e sistemas de armazém.
+
+Comandos:
+ rotula print ETIQUETA.rotula --printer NOME | --host ENDEREÇO [opções]
+ Imprime uma etiqueta. Opções:
+ --port N porta TCP de --host (padrão 9100)
+ --copies N cópias da etiqueta, ou de cada registro
+ --data ARQUIVO registros de um arquivo .csv, .txt, .xlsx ou .json (uma etiqueta por registro)
+ --sheet NOME a planilha do Excel a ler
+ --set NOME=VALOR o valor de um campo (repita para mais campos)
+ --answer NOME=VALOR a resposta a uma pergunta que a etiqueta faz na impressão
+ --dpi 203|300|600 resolução da cabeça de impressão (padrão: a informada pelo driver do Windows)
+ --darkness 0-30 escurecimento enviado antes das etiquetas
+ --speed 1-14 velocidade enviada antes das etiquetas
+ --no-status não pergunta o status à impressora antes de cada trabalho
+ --no-verify não espera a impressora informar que terminou
+ --dry-run mostra o ZPL sem imprimir (--output ARQUIVO grava em um arquivo)
+ --reference TEXTO sua própria referência, repetida no resultado
+ --counter-file ARQUIVO onde os números de série são lembrados (padrão: o mesmo arquivo do aplicativo)
+ rotula status --printer NOME | --host ENDEREÇO [--port N]
+ Pergunta à impressora se ela pode imprimir agora.
+ rotula printers
+ Lista as impressoras do Windows.
+ rotula serve [--settings ARQUIVO] [--urls URL]
+ Executa a API REST. "rotula serve --init [--label-folder PASTA]" cria um arquivo de configuração inicial com uma chave nova.
+ rotula new-key
+ Cria uma chave de API nova e o hash que vai no arquivo de configuração.
+
+Todos os comandos aceitam --json (saída para programas) e --lang en|es|pt-BR|fr-CA.
+Códigos de saída: 0 concluído, 1 a impressão falhou, 2 linha de comando incorreta, 3 erros na etiqueta, 4 impressora não está pronta, 5 bloqueado por política, 6 cancelado.
+
+
+ O arquivo de configuração {0} foi criado. Adicione suas impressoras em allowedPrinters e execute "rotula serve".
+
+
+ O arquivo de configuração {0} já existe; ele não foi alterado.
+
+
+ A opção {0} precisa de um valor.
+
+
+ Chave de API nova (mostrada só agora; entregue-a ao sistema que chama o servidor): {0}
+Hash a adicionar em apiKeyHashes no arquivo de configuração: {1}
+
+
+ Indique exatamente um arquivo de etiqueta, por exemplo: rotula print envio.rotula --printer "Doca 1"
+
+
+ Registro {0}:
+
+
+ Execute "rotula help" para ver os comandos e as opções.
+
+
+ Comando desconhecido "{0}".
+
+
+ Opção desconhecida {0}.
+
+
+ Aviso:
+
+
+ Faltam respostas às perguntas da etiqueta ou alguma não é válida. A etiqueta pergunta: {0}. As perguntas marcadas com * são obrigatórias; as demais usam o valor padrão.
+
+
+ É necessária uma chave de API válida. Envie-a no cabeçalho X-Api-Key.
+
+
+ Não foi possível ler a solicitação: {0}
+
+
+ As cópias devem ser um número inteiro de 1 a {0}.
+
+
+ Não foi possível ler o arquivo de dados {0}: {1}
+
+
+ O arquivo de dados {0} não existe.{1}
+
+
+ Os dados de {0} devem ser uma lista de registros (objetos com nomes de campo e valores) ou um único registro. {1}
+
+
+ A resolução deve ser 203, 300 ou 600 DPI.
+
+
+ Este servidor não aceita etiquetas enviadas como conteúdo (allowInlineLabels está desativado). Indique um arquivo de etiqueta de uma das suas pastas de etiquetas.
+
+
+ Envie o nome de um arquivo de etiqueta (label) ou o conteúdo da etiqueta (labelContent), não os dois.
+
+
+ A etiqueta {0} não foi encontrada.
+
+
+ A etiqueta {0} não é um arquivo .rotula dentro de uma das pastas de etiquetas deste servidor. Indique-a em relação a uma pasta de etiquetas, sem letra de unidade nem "..".
+
+
+ Indique um arquivo de etiqueta (label) ou envie o conteúdo da etiqueta (labelContent).
+
+
+ A etiqueta {0} é maior que o limite de {1} KB.
+
+
+ Os dados não têm registros, então nada foi impresso.
+
+
+ Nenhum registro gerou uma etiqueta, então nada foi enviado. Veja os problemas listados no resultado.
+
+
+ Não foi possível gravar o arquivo {0}: {1}
+
+
+ Indique uma impressora do Windows ou o endereço de uma impressora de rede, não as duas.
+
+
+ A impressora {0} não está na lista de impressoras permitidas deste servidor (allowedPrinters). Nada foi enviado.
+
+
+ Indique uma impressora: uma impressora do Windows (printer) ou o endereço de uma impressora de rede (host).
+
+
+ A impressão com o mecanismo de impressão está desativada pela política da sua organização para este computador. Fale com o seu departamento de TI.
+
+
+ Uma solicitação pode ter no máximo {0} registros.
+
+
+ Envie os valores de uma etiqueta ou uma lista de registros, não os dois.
+
+
+ [padrão]
+
+
+ Não há impressoras instaladas no Windows.
+
+
+ [offline]
+
+
+ Nada foi impresso (simulação). Este é o ZPL que seria enviado para {0}.
+
+
+ {0} etiqueta(s) enviada(s) para {1}.
+
+
+ {0} registro(s), {1} etiqueta(s), enviado(s) para {2} em {3} trabalho(s).
+
+
+ O servidor não conseguiu escutar em {0}: {1}
+
+
+ Mecanismo de impressão do Rotula escutando em {0}. Configuração: {1}. Pressione Ctrl+C para parar.
+
+
+ "{0}" em apiKeyHashes não é um hash de chave (64 caracteres hexadecimais). Crie um com "rotula new-key".
+
+
+ A pasta de etiquetas {0} não existe.
+
+
+ Não é possível usar o arquivo de configuração {0}: {1}
+
+
+ O arquivo de configuração {0} não existe. Crie um com "rotula serve --init".
+
+
+ Não há pastas de etiquetas (labelFolders) e as etiquetas enviadas como conteúdo estão desativadas, então nenhuma etiqueta pode ser impressa.
+
+
+ O arquivo de configuração {0} não tem nenhuma chave de API (apiKeyHashes). Crie uma com "rotula new-key".
+
+
+ Ainda não há impressoras permitidas (allowedPrinters), então toda solicitação de impressão será recusada.
+
+
+ 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.
+
diff --git a/src/Rotula.Cli/CliApp.cs b/src/Rotula.Cli/CliApp.cs
new file mode 100644
index 0000000..1ae8768
--- /dev/null
+++ b/src/Rotula.Cli/CliApp.cs
@@ -0,0 +1,338 @@
+using System.Globalization;
+using System.Reflection;
+using System.Text.Json;
+using Rotula.Core.Merge;
+using Rotula.Core.Variables.Counters;
+using Rotula.Engine;
+using Rotula.Files.Policy;
+using Rotula.Printing;
+
+namespace Rotula.Cli;
+
+/// Exit codes of rotula, stable so scripts and schedulers can act on them.
+internal static class ExitCodes
+{
+ /// Printed (or the dry run worked, or the printer is ready).
+ public const int Ok = 0;
+ /// Sending failed (printer not found, offline, unreachable...), or the status could not be read.
+ public const int Failed = 1;
+ /// The command line or the request is wrong.
+ public const int Usage = 2;
+ /// The label has errors or the answers to its questions are missing.
+ public const int InvalidLabel = 3;
+ /// The printer reported a fault (paper out, head open, paused...) and nothing was sent, or the status says it cannot print.
+ public const int PrinterNotReady = 4;
+ /// A policy of this computer forbids it.
+ public const int Refused = 5;
+ /// Stopped with Ctrl+C.
+ public const int Cancelled = 6;
+
+ /// The exit code of a print outcome.
+ public static int For(PrintOutcome outcome) => outcome switch
+ {
+ PrintOutcome.Printed or PrintOutcome.DryRun => Ok,
+ PrintOutcome.Invalid => Usage,
+ PrintOutcome.InvalidLabel => InvalidLabel,
+ PrintOutcome.HeldBack => PrinterNotReady,
+ PrintOutcome.Refused => Refused,
+ PrintOutcome.Cancelled => Cancelled,
+ _ => Failed,
+ };
+}
+
+/// What the commands need from outside, so tests can run them with fakes and without touching the computer.
+internal sealed class CliHost
+{
+ /// The print service.
+ public required IPrintService Print { get; init; }
+
+ /// The policy of this computer.
+ public RotulaPolicy Policy { get; init; } = RotulaPolicy.None;
+
+ /// Opens the counter store for serial numbers that remember their value (null path = the app's own file).
+ public Func Counters { get; init; } = path => new Rotula.Files.FileCounterStore(path ?? Rotula.Files.FileCounterStore.DefaultPath);
+
+ /// Draws text in Windows fonts.
+ public Rotula.Core.Text.ITextRasterizer? Rasterizer { get; init; } = new Rotula.Rendering.SkiaTextRasterizer();
+
+ /// Folder of the program (the default place of the settings file).
+ public string BaseDirectory { get; init; } = AppContext.BaseDirectory;
+
+ /// Normal output.
+ public TextWriter Out { get; init; } = Console.Out;
+
+ /// Problems.
+ public TextWriter Error { get; init; } = Console.Error;
+
+ /// Language when neither --lang nor a policy chooses one.
+ public string? DefaultLanguage { get; init; } = Environment.GetEnvironmentVariable("ROTULA_LANG") ?? CultureInfo.CurrentUICulture.Name;
+
+ /// Starts the REST server and runs it until it stops; replaced in tests.
+ public Func> Serve { get; init; } = Server.EngineServer.RunAsync;
+}
+
+/// The rotula commands: print, status, printers, new-key, serve, help, version.
+internal static class CliApp
+{
+ /// Runs one command line and returns the exit code. Never throws for a user or printer problem.
+ public static async Task RunAsync(string[] args, CliHost host, CancellationToken ct = default)
+ {
+ var (line, parseError, parseArg) = CommandLine.Parse(args);
+ // The language is chosen before anything is said: --lang, then the organization's policy, then Windows.
+ var text = EngineText.For(line.Value("lang") ?? (host.Policy.Language is { } p && p != "auto" ? p : null) ?? host.DefaultLanguage);
+ if (parseError is not null)
+ {
+ host.Error.WriteLine(text.Get(parseError, parseArg ?? string.Empty));
+ host.Error.WriteLine(text.Get("Engine.Cli.SeeHelp"));
+ return ExitCodes.Usage;
+ }
+ if (line.Has("version") || line.Command == "version")
+ {
+ host.Out.WriteLine("rotula " + Version());
+ return ExitCodes.Ok;
+ }
+ if (line.Has("help") || line.Command is "" or "help")
+ {
+ host.Out.WriteLine(text.Get("Engine.Cli.Help", Version()));
+ return ExitCodes.Ok;
+ }
+
+ try
+ {
+ return line.Command switch
+ {
+ "print" => await PrintAsync(line, host, text, ct).ConfigureAwait(false),
+ "status" => await StatusAsync(line, host, text, ct).ConfigureAwait(false),
+ "printers" => await PrintersAsync(line, host, text, ct).ConfigureAwait(false),
+ "new-key" => NewKey(line, host, text),
+ "serve" => await ServeAsync(line, host, text, args, ct).ConfigureAwait(false),
+ _ => Usage(host, text, "Engine.Cli.UnknownCommand", line.Command),
+ };
+ }
+ catch (OperationCanceledException)
+ {
+ host.Error.WriteLine(text.Get("Print.Error.Cancelled"));
+ return ExitCodes.Cancelled;
+ }
+ }
+
+ // ---- print ------------------------------------------------------------------------------------------------------------
+
+ private static async Task PrintAsync(CommandLine line, CliHost host, EngineText text, CancellationToken ct)
+ {
+ if (line.Positionals.Count != 1) return Usage(host, text, "Engine.Cli.PrintNeedsLabel", string.Empty);
+ if (host.Policy.PrintEngineBlocked) return Refused(line, host, text);
+
+ var request = new PrintJobRequest
+ {
+ Label = line.Positionals[0],
+ Printer = line.Value("printer"),
+ Host = line.Value("host"),
+ DryRun = line.Has("dry-run"),
+ CheckStatus = !line.Has("no-status"),
+ Verify = !line.Has("no-verify"),
+ Reference = line.Value("reference"),
+ };
+ foreach (var (name, apply) in new (string, Action)[]
+ {
+ ("port", v => request.Port = v), ("copies", v => request.Copies = v), ("dpi", v => request.Dpi = v),
+ ("darkness", v => request.Darkness = v), ("speed", v => request.Speed = v),
+ })
+ {
+ if (line.Value(name) is not { } raw) continue;
+ if (!int.TryParse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture, out int v)) return Usage(host, text, "Engine.Cli.BadValue", "--" + name, raw);
+ apply(v);
+ }
+ var values = CommandLine.Pairs(line.Values("set"), out var badSet);
+ if (values is null) return Usage(host, text, "Engine.Cli.BadPair", "--set", badSet!);
+ var answers = CommandLine.Pairs(line.Values("answer"), out var badAnswer);
+ if (answers is null) return Usage(host, text, "Engine.Cli.BadPair", "--answer", badAnswer!);
+ if (answers.Count > 0) request.Answers = answers;
+
+ MergeTable? table = null;
+ if (line.Value("data") is { } dataPath)
+ {
+ string? error = ReadData(dataPath, line.Value("sheet"), out table, out var jsonValues, out var detail);
+ if (error is not null) return Report(line, host, text, ExitCodes.Usage, text.Get(error, dataPath, detail ?? string.Empty));
+ if (jsonValues is not null) foreach (var kv in jsonValues) values.TryAdd(kv.Key, kv.Value);
+ }
+ if (values.Count > 0) request.Values = values;
+
+ var label = LabelFiles.Load(request.Label);
+ if (label.Content is null) return Report(line, host, text, ExitCodes.Usage, text.Get(label.ErrorKey!, label.Args));
+
+ var engine = new PrintEngine(host.Print, host.Counters(line.Value("counter-file")), host.Rasterizer);
+ var result = await engine.PrintAsync(label.Content, request, table, text, EngineLimits.Default, ct).ConfigureAwait(false);
+
+ if (result.Outcome == PrintOutcome.DryRun && result.Zpl is not null && line.Value("output") is { } output)
+ {
+ try { await File.WriteAllTextAsync(output, result.Zpl, new System.Text.UTF8Encoding(false), ct).ConfigureAwait(false); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ return Report(line, host, text, ExitCodes.Failed, text.Get("Engine.Error.OutputFile", output, ex.Message));
+ }
+ }
+
+ if (line.Has("json"))
+ {
+ host.Out.WriteLine(JsonSerializer.Serialize(result, EngineJson.Indented));
+ }
+ else
+ {
+ var writer = result.Succeeded ? host.Out : host.Error;
+ writer.WriteLine(result.Message);
+ foreach (var n in result.Notices)
+ writer.WriteLine(" - " + (n.Record is { } r ? text.Get("Engine.Cli.RecordPrefix", r) + " " : string.Empty) + n.Message);
+ // Without --output the ZPL of a dry run goes to the output, so it can be piped into a file or another tool.
+ if (result.Outcome == PrintOutcome.DryRun && result.Zpl is not null && line.Value("output") is null) host.Out.Write(result.Zpl);
+ }
+ return ExitCodes.For(result.Outcome);
+ }
+
+ /// Reads --data: JSON (records or one label's values), Excel (.xlsx) or delimited text (CSV, tab, semicolon; detected).
+ internal static string? ReadData(string path, string? sheet, out MergeTable? table, out Dictionary? values, out string? detail)
+ {
+ table = null;
+ values = null;
+ detail = null;
+ try
+ {
+ if (!File.Exists(path)) return "Engine.Error.DataMissing";
+ string ext = Path.GetExtension(path).ToLowerInvariant();
+ if (ext == ".json")
+ {
+ string? key = EngineJson.ReadData(File.ReadAllText(path), out var records, out values, out detail);
+ if (key is not null) return key;
+ if (records is not null) table = PrintEngine.RecordsTable(records);
+ return null;
+ }
+ table = ext is ".xlsx" or ".xlsm"
+ ? XlsxReader.ReadFile(path, new XlsxOptions { SheetName = sheet })
+ : CsvReader.ReadFile(path);
+ return null;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException or ArgumentException or FormatException
+ or InvalidOperationException or NotSupportedException or System.Security.SecurityException)
+ {
+ detail = ex.Message;
+ return "Engine.Error.DataFile";
+ }
+ }
+
+ // ---- status and printers ----------------------------------------------------------------------------------------------
+
+ private static async Task StatusAsync(CommandLine line, CliHost host, EngineText text, CancellationToken ct)
+ {
+ if (host.Policy.PrintEngineBlocked) return Refused(line, host, text);
+ int? port = null;
+ if (line.Value("port") is { } rawPort)
+ {
+ if (!int.TryParse(rawPort, NumberStyles.Integer, CultureInfo.InvariantCulture, out int p)) return Usage(host, text, "Engine.Cli.BadValue", "--port", rawPort);
+ port = p;
+ }
+ if (PrintEngine.TryBuildTarget(line.Value("printer") ?? line.Positionals.FirstOrDefault(), line.Value("host"), port, out var target) is { } error)
+ return Usage(host, text, error, string.Empty);
+ var engine = new PrintEngine(host.Print);
+ var report = await engine.GetStatusAsync(target!, text, ct).ConfigureAwait(false);
+ if (line.Has("json")) host.Out.WriteLine(JsonSerializer.Serialize(report, EngineJson.Indented));
+ else host.Out.WriteLine(report.Printer + ": " + report.Message);
+ return !report.Available ? ExitCodes.Failed : report.Ready == true ? ExitCodes.Ok : ExitCodes.PrinterNotReady;
+ }
+
+ private static async Task PrintersAsync(CommandLine line, CliHost host, EngineText text, CancellationToken ct)
+ {
+ var engine = new PrintEngine(host.Print);
+ var printers = (await engine.GetPrintersAsync(ct).ConfigureAwait(false))
+ .Select(p => new PrinterEntry(p.Name, p.PortName, p.DriverName, p.IsDefault, p.IsOffline, true)).ToList();
+ if (line.Has("json"))
+ {
+ host.Out.WriteLine(JsonSerializer.Serialize(printers, EngineJson.Indented));
+ return ExitCodes.Ok;
+ }
+ if (printers.Count == 0) host.Out.WriteLine(text.Get("Engine.Printers.None"));
+ foreach (var p in printers)
+ {
+ string flags = (p.IsDefault ? " " + text.Get("Engine.Printers.Default") : string.Empty) + (p.IsOffline ? " " + text.Get("Engine.Printers.Offline") : string.Empty);
+ host.Out.WriteLine(p.Name + " (" + p.Port + ", " + p.Driver + ")" + flags);
+ }
+ return ExitCodes.Ok;
+ }
+
+ // ---- keys and the server ----------------------------------------------------------------------------------------------
+
+ private static int NewKey(CommandLine line, CliHost host, EngineText text)
+ {
+ var (key, hash) = ApiKeys.Create();
+ if (line.Has("json")) host.Out.WriteLine(JsonSerializer.Serialize(new { key, hash }, EngineJson.Indented));
+ else host.Out.WriteLine(text.Get("Engine.Cli.NewKey", key, hash));
+ return ExitCodes.Ok;
+ }
+
+ private static async Task ServeAsync(CommandLine line, CliHost host, EngineText text, string[] args, CancellationToken ct)
+ {
+ if (host.Policy.PrintEngineBlocked) return Refused(line, host, text);
+ string path = Path.GetFullPath(line.Value("settings") ?? Path.Combine(host.BaseDirectory, EngineSettings.FileName));
+ if (line.Has("init"))
+ {
+ if (File.Exists(path)) return Usage(host, text, "Engine.Cli.InitExists", path);
+ var (key, hash) = ApiKeys.Create();
+ string folder = line.Value("label-folder") ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Rotula Labels");
+ try
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(path)!);
+ await File.WriteAllTextAsync(path, EngineSettingsLoader.Starter(hash, folder), new System.Text.UTF8Encoding(false), ct).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ host.Error.WriteLine(text.Get("Engine.Settings.Invalid", path, ex.Message));
+ return ExitCodes.Failed;
+ }
+ host.Out.WriteLine(text.Get("Engine.Cli.InitDone", path));
+ host.Out.WriteLine(text.Get("Engine.Cli.NewKey", key, hash));
+ return ExitCodes.Ok;
+ }
+
+ var loaded = EngineSettingsLoader.Load(path, text);
+ foreach (var warning in loaded.Warnings) host.Error.WriteLine(text.Get("Engine.Cli.WarningPrefix") + " " + warning);
+ if (loaded.Settings is null)
+ {
+ foreach (var error in loaded.Errors) host.Error.WriteLine(error);
+ return ExitCodes.Usage;
+ }
+ var settings = loaded.Settings;
+ if (line.Values("urls") is { Count: > 0 } urls) settings = settings with { Urls = urls.SelectMany(u => u.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)).ToList() };
+ var engine = new PrintEngine(host.Print, host.Counters(line.Value("counter-file") ?? settings.CounterFile), host.Rasterizer);
+ return await host.Serve(loaded with { Settings = settings }, engine, EngineText.For(settings.Language), host.Policy, args, ct).ConfigureAwait(false);
+ }
+
+ // ---- helpers ----------------------------------------------------------------------------------------------------------
+
+ private static int Refused(CommandLine line, CliHost host, EngineText text) =>
+ Report(line, host, text, ExitCodes.Refused, text.Get("Engine.Error.PrintingBlocked"), "Engine.Error.PrintingBlocked");
+
+ private static int Usage(CliHost host, EngineText text, string key, params object[] args)
+ {
+ host.Error.WriteLine(text.Get(key, args));
+ host.Error.WriteLine(text.Get("Engine.Cli.SeeHelp"));
+ return ExitCodes.Usage;
+ }
+
+ /// A problem found before the engine ran: as JSON with --json (same shape as a print result), otherwise as one line.
+ private static int Report(CommandLine line, CliHost host, EngineText text, int code, string message, string? errorCode = null)
+ {
+ if (line.Has("json"))
+ {
+ var outcome = code switch { ExitCodes.Refused => PrintOutcome.Refused, ExitCodes.Usage => PrintOutcome.Invalid, _ => PrintOutcome.Failed };
+ host.Out.WriteLine(JsonSerializer.Serialize(new PrintJobResult(outcome, message, errorCode ?? outcome.ToString(), string.Empty, 0, 0, 0, 0, [], null,
+ line.Value("reference")), EngineJson.Indented));
+ }
+ else
+ {
+ host.Error.WriteLine(message);
+ }
+ return code;
+ }
+
+ internal static string Version() =>
+ typeof(CliApp).Assembly.GetCustomAttribute()?.InformationalVersion.Split('+')[0] ?? "0.0.0";
+}
diff --git a/src/Rotula.Cli/CommandLine.cs b/src/Rotula.Cli/CommandLine.cs
new file mode 100644
index 0000000..a966eea
--- /dev/null
+++ b/src/Rotula.Cli/CommandLine.cs
@@ -0,0 +1,108 @@
+namespace Rotula.Cli;
+
+///
+/// The parsed command line: the command, its positional arguments, options with values (--printer NAME or --printer=NAME,
+/// repeatable ones such as --set keep every value) and switches (--json). Hand-written so the program needs no parser package.
+///
+internal sealed class CommandLine
+{
+ /// Options that take a value, for every command.
+ internal static readonly IReadOnlySet ValueOptions = new HashSet(StringComparer.Ordinal)
+ {
+ "printer", "host", "port", "copies", "data", "sheet", "set", "answer", "dpi", "darkness", "speed", "output", "reference", "lang",
+ "settings", "counter-file", "urls", "label-folder",
+ };
+
+ /// Switches, for every command.
+ internal static readonly IReadOnlySet Switches = new HashSet(StringComparer.Ordinal)
+ {
+ "json", "dry-run", "no-status", "no-verify", "help", "version", "init",
+ };
+
+ private readonly Dictionary> _values = new(StringComparer.Ordinal);
+ private readonly HashSet _switches = new(StringComparer.Ordinal);
+
+ /// The command (first word), lower case; empty when none was given.
+ public string Command { get; private set; } = string.Empty;
+
+ /// Words after the command that are not options.
+ public List Positionals { get; } = new();
+
+ /// The last value of an option, or null.
+ public string? Value(string name) => _values.TryGetValue(name, out var list) && list.Count > 0 ? list[^1] : null;
+
+ /// Every value of a repeatable option.
+ public IReadOnlyList Values(string name) => _values.TryGetValue(name, out var list) ? list : [];
+
+ /// True when the switch was given.
+ public bool Has(string name) => _switches.Contains(name);
+
+ ///
+ /// Parses . Returns null on success, or the message key and argument of the problem (an unknown option, an option
+ /// without its value).
+ ///
+ public static (CommandLine Line, string? ErrorKey, string? ErrorArg) Parse(IReadOnlyList args)
+ {
+ var line = new CommandLine();
+ for (int i = 0; i < args.Count; i++)
+ {
+ string a = args[i];
+ if (a.StartsWith("--", StringComparison.Ordinal) && a.Length > 2)
+ {
+ string name = a[2..], value;
+ int eq = name.IndexOf('=');
+ if (eq >= 0)
+ {
+ value = name[(eq + 1)..];
+ name = name[..eq];
+ if (!ValueOptions.Contains(name)) return (line, "Engine.Cli.UnknownOption", "--" + name);
+ }
+ else if (Switches.Contains(name))
+ {
+ line._switches.Add(name);
+ continue;
+ }
+ else if (ValueOptions.Contains(name))
+ {
+ if (i + 1 >= args.Count) return (line, "Engine.Cli.MissingValue", a);
+ value = args[++i];
+ }
+ else
+ {
+ return (line, "Engine.Cli.UnknownOption", a);
+ }
+ if (!line._values.TryGetValue(name, out var list)) line._values[name] = list = new List();
+ list.Add(value);
+ }
+ else if (a is "-h" or "/?")
+ {
+ line._switches.Add("help");
+ }
+ else if (line.Command.Length == 0)
+ {
+ line.Command = a.ToLowerInvariant();
+ }
+ else
+ {
+ line.Positionals.Add(a);
+ }
+ }
+ return (line, null, null);
+ }
+
+ ///
+ /// Reads repeatable name=value pairs (--set, --answer). Returns null and the bad pair when one has no name.
+ ///
+ public static Dictionary? Pairs(IReadOnlyList raw, out string? bad)
+ {
+ bad = null;
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var pair in raw)
+ {
+ int eq = pair.IndexOf('=');
+ if (eq <= 0) { bad = pair; return null; }
+ map[pair[..eq].Trim()] = pair[(eq + 1)..];
+ }
+ return map;
+ }
+}
diff --git a/src/Rotula.Cli/Program.cs b/src/Rotula.Cli/Program.cs
new file mode 100644
index 0000000..1314c73
--- /dev/null
+++ b/src/Rotula.Cli/Program.cs
@@ -0,0 +1,28 @@
+using Rotula.Files.Policy;
+using Rotula.Printing;
+
+namespace Rotula.Cli;
+
+/// Entry point of rotula: the print engine's command line and REST server.
+public static class Program
+{
+ /// Runs the command and returns its exit code (see docs/print-engine.md).
+ public static async Task Main(string[] args)
+ {
+ Console.OutputEncoding = System.Text.Encoding.UTF8;
+ using var cts = new CancellationTokenSource();
+ // Ctrl+C stops the work cleanly: serial numbers of labels that were not sent go back, and the server shuts down.
+ Console.CancelKeyPress += (_, e) =>
+ {
+ e.Cancel = true;
+ cts.Cancel();
+ };
+ var host = new CliHost
+ {
+ Print = new PrintService(),
+ // Group Policy / Intune can switch the print engine off for the whole computer (docs/deployment.md).
+ Policy = RotulaPolicy.Read(RegistryPolicySource.ForThisComputer()),
+ };
+ return await CliApp.RunAsync(args, host, cts.Token).ConfigureAwait(false);
+ }
+}
diff --git a/src/Rotula.Cli/Rotula.Cli.csproj b/src/Rotula.Cli/Rotula.Cli.csproj
new file mode 100644
index 0000000..54b26bd
--- /dev/null
+++ b/src/Rotula.Cli/Rotula.Cli.csproj
@@ -0,0 +1,41 @@
+
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ true
+ true
+ Rotula.Cli
+ rotula
+ ..\Rotula.Mcp\Assets\rotula-mcp.ico
+ en
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Rotula.Cli/Server/EngineServer.cs b/src/Rotula.Cli/Server/EngineServer.cs
new file mode 100644
index 0000000..f9de42c
--- /dev/null
+++ b/src/Rotula.Cli/Server/EngineServer.cs
@@ -0,0 +1,263 @@
+using System.Text.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Rotula.Engine;
+using Rotula.Files.Policy;
+using Rotula.Printing;
+
+namespace Rotula.Cli.Server;
+
+/// An error answer of the REST API: a stable code for programs and a sentence for people.
+/// Stable key, for example Engine.Error.PrinterNotAllowed.
+/// The sentence in the caller's language.
+public sealed record ApiError(string ErrorCode, string Message);
+
+/// The body of a status request for a network printer (or a Windows printer whose name is awkward in a URL).
+/// An installed Windows printer.
+/// Host or IP address of a network printer.
+/// Its TCP port (9100 when not set).
+public sealed record StatusRequest(string? Printer, string? Host, int? Port);
+
+/// The answer of the health check.
+/// Always ok when the server answers.
+/// The program version.
+/// True when the organization's policy switched printing off.
+public sealed record HealthInfo(string Status, string Version, bool PrintingBlocked);
+
+///
+/// The REST API of the print engine (rotula serve). Every route except the health check and the OpenAPI document needs an API key
+/// (X-Api-Key header, or Authorization: Bearer); printers and labels are limited to what the settings file lists.
+///
+internal static class EngineServer
+{
+ /// Starts the server, prints where it listens, and runs until Ctrl+C or the Windows service stops it.
+ public static async Task RunAsync(EngineSettingsLoad loaded, PrintEngine engine, EngineText text, RotulaPolicy policy, string[] args, CancellationToken ct)
+ {
+ var settings = loaded.Settings ?? throw new ArgumentException("The settings are not usable.", nameof(loaded));
+ await using var app = Build(settings, engine, policy);
+ try
+ {
+ await app.StartAsync(ct).ConfigureAwait(false);
+ }
+ catch (IOException ex)
+ {
+ // Typically the port is taken by another program (or another copy of this one).
+ Console.Error.WriteLine(text.Get("Engine.Server.CannotListen", string.Join(", ", settings.Urls), ex.Message));
+ return ExitCodes.Failed;
+ }
+ Console.WriteLine(text.Get("Engine.Server.Listening", string.Join(", ", settings.Urls), loaded.Path));
+ try { await app.WaitForShutdownAsync(ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { }
+ await app.StopAsync(CancellationToken.None).ConfigureAwait(false);
+ return ExitCodes.Ok;
+ }
+
+ /// Builds the web application. lets tests swap Kestrel for the in-memory test server.
+ public static WebApplication Build(EngineSettings settings, PrintEngine engine, RotulaPolicy policy, Action? configure = null)
+ {
+ ArgumentNullException.ThrowIfNull(settings);
+ ArgumentNullException.ThrowIfNull(engine);
+ ArgumentNullException.ThrowIfNull(policy);
+
+ // No command-line arguments are handed to the host: they are rotula's own, and must not reach the web host's configuration.
+ var builder = WebApplication.CreateSlimBuilder(new WebApplicationOptions { Args = [], ContentRootPath = AppContext.BaseDirectory });
+ builder.Host.UseWindowsService(o => o.ServiceName = "RotulaEngine");
+ builder.WebHost.UseUrls([.. settings.Urls]);
+ builder.WebHost.ConfigureKestrel(k =>
+ {
+ k.AddServerHeader = false;
+ k.Limits.MaxRequestBodySize = settings.MaxRequestBytes;
+ });
+ builder.Services.AddOpenApi("v1", o => o.AddDocumentTransformer((doc, _, _) =>
+ {
+ doc.Info.Title = "Rotula print engine";
+ doc.Info.Version = CliApp.Version();
+ return Task.CompletedTask;
+ }));
+ configure?.Invoke(builder);
+
+ var app = builder.Build();
+ var log = app.Services.GetRequiredService().CreateLogger("Rotula.Engine.Jobs");
+
+ app.MapOpenApi("/openapi/{documentName}.json");
+ var api = app.MapGroup("/api/v1");
+
+ api.MapGet("/health", () => Results.Json(new HealthInfo("ok", CliApp.Version(), policy.PrintEngineBlocked), EngineJson.Options))
+ .WithName("Health").WithSummary("Answers when the server runs. No API key needed.").Produces();
+
+ var secured = api.MapGroup(string.Empty).AddEndpointFilter(async (context, next) =>
+ {
+ var http = context.HttpContext;
+ string? key = http.Request.Headers[ApiKeys.Header].FirstOrDefault();
+ string auth = http.Request.Headers.Authorization.FirstOrDefault() ?? string.Empty;
+ if (string.IsNullOrEmpty(key) && auth.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) key = auth[7..].Trim();
+ if (!ApiKeys.Matches(key, settings.ApiKeyHashes))
+ {
+ log.LogWarning("Refused a call to {Path} from {Remote}: no valid API key.", LogValue(http.Request.Path.Value), http.Connection.RemoteIpAddress);
+ return Error(Text(http, settings), StatusCodes.Status401Unauthorized, "Engine.Error.ApiKey");
+ }
+ // A short, non-reversible id of the key for the log: enough to tell callers apart, useless to call the server.
+ http.Items["keyId"] = ApiKeys.Hash(key!)[..8];
+ return await next(context).ConfigureAwait(false);
+ });
+
+ secured.MapGet("/printers", async (HttpContext http) =>
+ {
+ var printers = await engine.GetPrintersAsync(http.RequestAborted).ConfigureAwait(false);
+ return Results.Json(printers.Select(p => new PrinterEntry(p.Name, p.PortName, p.DriverName, p.IsDefault, p.IsOffline,
+ settings.IsPrinterAllowed(PrinterTarget.Spooler(p.Name)))).ToList(), EngineJson.Options);
+ }).WithName("ListPrinters").WithSummary("Lists the Windows printers and which ones this server may print to.").Produces>();
+
+ secured.MapGet("/printers/{name}/status", (HttpContext http, string name) => StatusAsync(http, name, null, null))
+ .WithName("GetPrinterStatus").WithSummary("Asks a Windows printer for its status (~HS).")
+ .Produces().Produces(StatusCodes.Status403Forbidden);
+
+ secured.MapPost("/printers/status", async (HttpContext http) =>
+ {
+ var body = await ReadAsync(http, settings).ConfigureAwait(false);
+ if (body.Error is not null) return body.Error;
+ return await StatusAsync(http, body.Value!.Printer, body.Value.Host, body.Value.Port).ConfigureAwait(false);
+ }).WithName("PrinterStatus").WithSummary("Asks a Windows or network printer for its status (~HS).").Accepts("application/json")
+ .Produces().Produces(StatusCodes.Status400BadRequest).Produces(StatusCodes.Status403Forbidden);
+
+ secured.MapPost("/jobs", async (HttpContext http) =>
+ {
+ var text = Text(http, settings);
+ if (policy.PrintEngineBlocked) return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrintingBlocked");
+ var body = await ReadAsync(http, settings).ConfigureAwait(false);
+ if (body.Error is not null) return body.Error;
+ var request = body.Value!;
+
+ if (PrintEngine.TryBuildTarget(request.Printer, request.Host, request.Port, out var target) is { } targetError)
+ return Error(text, StatusCodes.Status400BadRequest, targetError);
+ if (!settings.IsPrinterAllowed(target!))
+ return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrinterNotAllowed", target!.DisplayName);
+
+ var label = LoadLabel(request, settings, text, out var labelError);
+ if (labelError is not null) return labelError;
+
+ var result = await engine.PrintAsync(label!, request, null, text, settings.Limits, http.RequestAborted).ConfigureAwait(false);
+ // The audit line: who (key id, address), what (reference, printer, labels) and how it ended. Never the label data.
+ log.LogInformation("Job {Reference} key {KeyId} from {Remote}: {Outcome} on {Printer}, {Labels} label(s) in {Jobs} job(s){Error}.",
+ LogValue(request.Reference), http.Items["keyId"], http.Connection.RemoteIpAddress, result.Outcome, LogValue(result.Printer), result.Labels, result.Jobs,
+ result.ErrorCode is null ? string.Empty : " (" + result.ErrorCode + ")");
+ return Results.Json(result, EngineJson.Options, statusCode: StatusFor(result.Outcome));
+ }).WithName("Print").WithSummary("Prints a label, once or for every record, on an allowed printer.")
+ .Accepts("application/json")
+ .Produces().Produces(StatusCodes.Status400BadRequest).Produces(StatusCodes.Status401Unauthorized)
+ .Produces(StatusCodes.Status403Forbidden).Produces(StatusCodes.Status404NotFound)
+ .Produces(StatusCodes.Status409Conflict).Produces(StatusCodes.Status422UnprocessableEntity)
+ .Produces(StatusCodes.Status502BadGateway);
+
+ return app;
+
+ async Task StatusAsync(HttpContext http, string? printer, string? host, int? port)
+ {
+ var text = Text(http, settings);
+ if (policy.PrintEngineBlocked) return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrintingBlocked");
+ if (PrintEngine.TryBuildTarget(printer, host, port, out var target) is { } error) return Error(text, StatusCodes.Status400BadRequest, error);
+ if (!settings.IsPrinterAllowed(target!)) return Error(text, StatusCodes.Status403Forbidden, "Engine.Error.PrinterNotAllowed", target!.DisplayName);
+ var report = await engine.GetStatusAsync(target!, text, http.RequestAborted).ConfigureAwait(false);
+ return Results.Json(report, EngineJson.Options);
+ }
+ }
+
+ /// The HTTP status of a print outcome. The body is the full result in every case.
+ internal static int StatusFor(PrintOutcome outcome) => outcome switch
+ {
+ PrintOutcome.Printed or PrintOutcome.DryRun => StatusCodes.Status200OK,
+ PrintOutcome.Invalid => StatusCodes.Status400BadRequest,
+ PrintOutcome.InvalidLabel => StatusCodes.Status422UnprocessableEntity,
+ PrintOutcome.Refused => StatusCodes.Status403Forbidden,
+ PrintOutcome.HeldBack => StatusCodes.Status409Conflict,
+ PrintOutcome.Cancelled => StatusCodes.Status503ServiceUnavailable,
+ _ => StatusCodes.Status502BadGateway,
+ };
+
+ /// Finds the label of a request within the server's rules: a file in a label folder, or content when the settings allow it.
+ private static Rotula.Files.RotulaFileContent? LoadLabel(PrintJobRequest request, EngineSettings settings, EngineText text, out IResult? error)
+ {
+ error = null;
+ bool hasPath = !string.IsNullOrWhiteSpace(request.Label), hasContent = !string.IsNullOrWhiteSpace(request.LabelContent);
+ if (hasPath == hasContent)
+ {
+ error = Error(text, StatusCodes.Status400BadRequest, hasPath ? "Engine.Error.LabelBoth" : "Engine.Error.LabelRequired");
+ return null;
+ }
+ LabelLoad load;
+ if (hasContent)
+ {
+ if (!settings.AllowInlineLabels)
+ {
+ error = Error(text, StatusCodes.Status403Forbidden, "Engine.Error.InlineLabelsOff");
+ return null;
+ }
+ if (request.LabelContent!.Length > settings.MaxLabelBytes)
+ {
+ error = Error(text, StatusCodes.Status400BadRequest, "Engine.Error.LabelTooLarge", "labelContent", settings.MaxLabelBytes / 1024);
+ return null;
+ }
+ load = LabelFiles.Parse(request.LabelContent);
+ }
+ else
+ {
+ string? path = LabelFiles.Resolve(request.Label, settings.LabelFolders, out var key);
+ if (path is null)
+ {
+ error = Error(text, key == "Engine.Error.LabelNotFound" ? StatusCodes.Status404NotFound : StatusCodes.Status403Forbidden, key!, request.Label!);
+ return null;
+ }
+ load = LabelFiles.Load(path, settings.MaxLabelBytes);
+ }
+ if (load.Content is null)
+ {
+ error = Error(text, StatusCodes.Status422UnprocessableEntity, load.ErrorKey!, load.Args);
+ return null;
+ }
+ return load.Content;
+ }
+
+ /// Reads a JSON body with the engine's rules (unknown fields refused), or the 400 answer that says what is wrong with it.
+ private static async Task<(T? Value, IResult? Error)> ReadAsync(HttpContext http, EngineSettings settings) where T : class
+ {
+ var text = Text(http, settings);
+ try
+ {
+ var value = await JsonSerializer.DeserializeAsync(http.Request.Body, EngineJson.Options, http.RequestAborted).ConfigureAwait(false);
+ return value is null ? (null, Error(text, StatusCodes.Status400BadRequest, "Engine.Error.BadRequest", "null")) : (value, null);
+ }
+ catch (JsonException ex)
+ {
+ return (null, Error(text, StatusCodes.Status400BadRequest, "Engine.Error.BadRequest", ex.Message));
+ }
+ catch (Microsoft.AspNetCore.Http.BadHttpRequestException ex)
+ {
+ // Body larger than maxRequestBytes, or cut off.
+ return (null, Error(text, ex.StatusCode, "Engine.Error.BadRequest", ex.Message));
+ }
+ }
+
+ /// The caller's language: ?lang=, then Accept-Language, then the server's setting.
+ /// A caller's text made safe for one log line: control characters (CR, LF...) become spaces, so a request cannot forge
+ /// log entries, and long values are cut.
+ internal static string LogValue(string? value)
+ {
+ if (string.IsNullOrEmpty(value)) return "-";
+ if (value.Length > 200) value = value[..200] + "...";
+ value = value.Replace("\r", " ", StringComparison.Ordinal).Replace("\n", " ", StringComparison.Ordinal);
+ return string.Create(value.Length, value, static (span, v) =>
+ {
+ for (int i = 0; i < v.Length; i++) span[i] = char.IsControl(v[i]) ? ' ' : v[i];
+ });
+ }
+
+ private static EngineText Text(HttpContext http, EngineSettings settings) =>
+ EngineText.For(http.Request.Query["lang"].FirstOrDefault() ?? http.Request.Headers.AcceptLanguage.FirstOrDefault() ?? settings.Language);
+
+ private static IResult Error(EngineText text, int status, string key, params object[] args) =>
+ Results.Json(new ApiError(key, text.Get(key, args)), EngineJson.Options, statusCode: status);
+}
diff --git a/src/Rotula.Engine/ApiKeys.cs b/src/Rotula.Engine/ApiKeys.cs
new file mode 100644
index 0000000..bcee2d2
--- /dev/null
+++ b/src/Rotula.Engine/ApiKeys.cs
@@ -0,0 +1,44 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Rotula.Engine;
+
+///
+/// API keys of the REST server. The settings file keeps only the SHA-256 hash of each key, so reading the file (or a backup of it) does not
+/// give anyone a working key. A key is 32 random bytes, written as 64 hexadecimal characters.
+///
+public static class ApiKeys
+{
+ /// The request header that carries the key.
+ public const string Header = "X-Api-Key";
+
+ /// Makes a new random key and its hash.
+ public static (string Key, string Hash) Create()
+ {
+ string key = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant();
+ return (key, Hash(key));
+ }
+
+ /// The hash stored in the settings file: SHA-256 of the key's UTF-8 bytes, lower-case hexadecimal.
+ public static string Hash(string key)
+ {
+ ArgumentNullException.ThrowIfNull(key);
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(key.Trim()))).ToLowerInvariant();
+ }
+
+ /// True when hashes to one of . Compared in constant time.
+ public static bool Matches(string? presented, IReadOnlyCollection hashes)
+ {
+ if (string.IsNullOrWhiteSpace(presented) || presented.Length > 256 || hashes.Count == 0) return false;
+ byte[] candidate = Encoding.ASCII.GetBytes(Hash(presented));
+ bool hit = false;
+ // Every stored hash is compared, so the time taken does not tell which one (if any) matched.
+ foreach (var hash in hashes)
+ hit |= CryptographicOperations.FixedTimeEquals(candidate, Encoding.ASCII.GetBytes(hash.Trim().ToLowerInvariant()));
+ return hit;
+ }
+
+ /// True for a value that looks like a stored hash (64 hexadecimal characters).
+ public static bool IsHash(string? value) =>
+ value is { Length: 64 } && value.All(Uri.IsHexDigit);
+}
diff --git a/src/Rotula.Engine/EngineJson.cs b/src/Rotula.Engine/EngineJson.cs
new file mode 100644
index 0000000..5b1ff83
--- /dev/null
+++ b/src/Rotula.Engine/EngineJson.cs
@@ -0,0 +1,113 @@
+using System.Buffers;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Rotula.Engine;
+
+/// The JSON shape shared by the command line (--json, --data file.json) and the REST API.
+public static class EngineJson
+{
+ ///
+ /// camelCase names, enums as text, comments allowed, unknown names refused (a misspelt field must not be ignored silently: "copeis": 500
+ /// would otherwise print the label's own quantity). Field values may be numbers or true/false as well as text.
+ ///
+ public static JsonSerializerOptions Options { get; } = Create(indented: false);
+
+ /// As , indented for people.
+ public static JsonSerializerOptions Indented { get; } = Create(indented: true);
+
+ private static JsonSerializerOptions Create(bool indented)
+ {
+ var o = new JsonSerializerOptions(JsonSerializerDefaults.Web)
+ {
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+ MaxDepth = 16,
+ WriteIndented = indented,
+ };
+ o.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
+ o.Converters.Add(new LenientStringConverter());
+ o.MakeReadOnly(populateMissingResolver: true);
+ return o;
+ }
+
+ ///
+ /// Reads data records from JSON text: an array of objects (one record each) or one object (the values of a single label). Values may be text,
+ /// numbers, true/false or null (empty). Returns the key of the problem, or null.
+ ///
+ public static string? ReadData(string json, out List>? records, out Dictionary? values, out string? detail)
+ {
+ records = null;
+ values = null;
+ detail = null;
+ try
+ {
+ using var doc = JsonDocument.Parse(json, new JsonDocumentOptions { CommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, MaxDepth = 8 });
+ switch (doc.RootElement.ValueKind)
+ {
+ case JsonValueKind.Array:
+ records = new List>();
+ foreach (var item in doc.RootElement.EnumerateArray())
+ {
+ if (item.ValueKind != JsonValueKind.Object) { detail = item.ValueKind.ToString(); return "Engine.Error.DataShape"; }
+ records.Add(Fields(item));
+ }
+ return null;
+ case JsonValueKind.Object:
+ values = Fields(doc.RootElement);
+ return null;
+ default:
+ detail = doc.RootElement.ValueKind.ToString();
+ return "Engine.Error.DataShape";
+ }
+ }
+ catch (JsonException ex)
+ {
+ detail = ex.Message;
+ return "Engine.Error.DataShape";
+ }
+ catch (InvalidOperationException ex)
+ {
+ detail = ex.Message;
+ return "Engine.Error.DataShape";
+ }
+ }
+
+ private static Dictionary Fields(JsonElement obj)
+ {
+ var map = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var p in obj.EnumerateObject()) map[p.Name] = Text(p.Value);
+ return map;
+ }
+
+ private static string Text(JsonElement e) => e.ValueKind switch
+ {
+ JsonValueKind.String => e.GetString() ?? string.Empty,
+ JsonValueKind.Number => e.GetRawText(),
+ JsonValueKind.True => "true",
+ JsonValueKind.False => "false",
+ JsonValueKind.Null => string.Empty,
+ // A nested object or list is not a field value; refusing it beats printing its JSON on a label.
+ _ => throw new InvalidOperationException("A field value must be text, a number, true/false or null."),
+ };
+
+ /// Reads a text field that a warehouse system may send as a number ("qty": 5) or true/false.
+ private sealed class LenientStringConverter : JsonConverter
+ {
+ public override bool HandleNull => false;
+
+ public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) => reader.TokenType switch
+ {
+ JsonTokenType.String => reader.GetString(),
+ // As written (1.50 stays 1.50): a number token is plain ASCII, never escaped.
+ JsonTokenType.Number => System.Text.Encoding.ASCII.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan),
+ JsonTokenType.True => "true",
+ JsonTokenType.False => "false",
+ _ => throw new JsonException("Expected text, a number or true/false."),
+ };
+
+ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) => writer.WriteStringValue(value);
+ }
+}
diff --git a/src/Rotula.Engine/EngineSettings.cs b/src/Rotula.Engine/EngineSettings.cs
new file mode 100644
index 0000000..65472e4
--- /dev/null
+++ b/src/Rotula.Engine/EngineSettings.cs
@@ -0,0 +1,160 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using Rotula.Printing;
+
+namespace Rotula.Engine;
+
+///
+/// The REST server's rules, from rotula-engine.settings.json. Everything a caller can reach is listed explicitly: the addresses the
+/// server listens on, the API keys (as hashes), the label folders and the printers. Nothing is allowed by default.
+///
+public sealed record EngineSettings
+{
+ /// The settings file name, looked for next to the program unless --settings names another.
+ public const string FileName = "rotula-engine.settings.json";
+
+ /// Default listening address: this computer only.
+ public const string DefaultUrl = "http://127.0.0.1:9180";
+
+ /// Addresses to listen on, for example http://127.0.0.1:9180 or https://0.0.0.0:9443.
+ public IReadOnlyList Urls { get; init; } = [DefaultUrl];
+
+ /// SHA-256 hashes of the API keys that may call the server (rotula new-key makes a key and its hash).
+ public IReadOnlyList ApiKeyHashes { get; init; } = [];
+
+ /// Folders labels may be printed from; a request names a label relative to one of them.
+ public IReadOnlyList LabelFolders { get; init; } = [];
+
+ /// Printers the server may print to and query: Windows printer names, or host / host:port of network printers.
+ public IReadOnlyList AllowedPrinters { get; init; } = [];
+
+ /// Accept labels sent as content in the request (a caller then can print any design). Off by default.
+ public bool AllowInlineLabels { get; init; }
+
+ /// Most copies per label or record.
+ public int MaxCopies { get; init; } = 1000;
+
+ /// Most records per request.
+ public int MaxRecords { get; init; } = 10_000;
+
+ /// Largest request body, in bytes.
+ public long MaxRequestBytes { get; init; } = 10 * 1024 * 1024;
+
+ /// Largest label file, in bytes.
+ public long MaxLabelBytes { get; init; } = 5 * 1024 * 1024;
+
+ /// Language of the messages when the request sends no Accept-Language: en, es, pt-BR or fr-CA.
+ public string Language { get; init; } = "en";
+
+ /// Counter file of serial numbers that remember their value; null = the running account's own (%AppData%\Rotula\counters.json).
+ public string? CounterFile { get; init; }
+
+ /// The request limits as the engine takes them.
+ public EngineLimits Limits => new(MaxCopies, MaxRecords);
+
+ /// True when the server may print to (or query) . Names and hosts are compared ignoring case.
+ public bool IsPrinterAllowed(PrinterTarget target)
+ {
+ ArgumentNullException.ThrowIfNull(target);
+ foreach (var entry in AllowedPrinters)
+ {
+ string e = entry.Trim();
+ if (target.Transport == PrinterTransport.Spooler)
+ {
+ if (string.Equals(e, target.PrinterName, StringComparison.OrdinalIgnoreCase)) return true;
+ continue;
+ }
+ // "host" means the default raw port; "host:port" exactly that port.
+ int colon = e.LastIndexOf(':');
+ string host = colon > 0 ? e[..colon] : e;
+ int port = colon > 0 && int.TryParse(e[(colon + 1)..], out int p) ? p : PrinterTarget.DefaultTcpPort;
+ if (string.Equals(host, target.Host, StringComparison.OrdinalIgnoreCase) && port == target.Port) return true;
+ }
+ return false;
+ }
+}
+
+/// The outcome of reading the settings: the settings, or the reasons they cannot be used.
+/// The settings, or null when is not empty.
+/// Problems that stop the server (as sentences, in the requested language).
+/// Things worth knowing that do not stop it.
+/// The file that was read.
+public sealed record EngineSettingsLoad(EngineSettings? Settings, IReadOnlyList Errors, IReadOnlyList Warnings, string Path);
+
+/// Reads rotula-engine.settings.json. Unknown names are an error, so a typo never silently leaves a rule out.
+public static class EngineSettingsLoader
+{
+ private const long MaxFileBytes = 256 * 1024;
+
+ private static readonly JsonSerializerOptions Options = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ ReadCommentHandling = JsonCommentHandling.Skip,
+ AllowTrailingCommas = true,
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ MaxDepth = 8,
+ };
+
+ /// Loads and checks the settings. Never throws.
+ public static EngineSettingsLoad Load(string path, EngineText text)
+ {
+ ArgumentNullException.ThrowIfNull(text);
+ var errors = new List();
+ var warnings = new List();
+ EngineSettings? settings;
+ try
+ {
+ var info = new FileInfo(path);
+ if (!info.Exists) return new(null, [text.Get("Engine.Settings.Missing", path)], [], path);
+ if (info.Length > MaxFileBytes) return new(null, [text.Get("Engine.Settings.Invalid", path, "> 256 KB")], [], path);
+ settings = JsonSerializer.Deserialize(File.ReadAllText(info.FullName), Options);
+ }
+ catch (JsonException ex)
+ {
+ return new(null, [text.Get("Engine.Settings.Invalid", path, ex.Message)], [], path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException)
+ {
+ return new(null, [text.Get("Engine.Settings.Invalid", path, ex.Message)], [], path);
+ }
+ if (settings is null) return new(null, [text.Get("Engine.Settings.Invalid", path, "null")], [], path);
+
+ if (settings.ApiKeyHashes.Count == 0) errors.Add(text.Get("Engine.Settings.NoKeys", path));
+ foreach (var hash in settings.ApiKeyHashes.Where(h => !ApiKeys.IsHash(h?.Trim())))
+ errors.Add(text.Get("Engine.Settings.BadHash", hash ?? string.Empty));
+ if (settings.Urls.Count == 0) errors.Add(text.Get("Engine.Settings.Invalid", path, "urls"));
+ foreach (var url in settings.Urls)
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))
+ errors.Add(text.Get("Engine.Settings.Invalid", path, "urls: " + url));
+ if (settings.MaxCopies is < 1 or > 99_999_999) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxCopies"));
+ if (settings.MaxRecords is < 1 or > 1_000_000) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxRecords"));
+ if (settings.MaxRequestBytes is < 1024 or > 256L * 1024 * 1024) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxRequestBytes"));
+ if (settings.MaxLabelBytes is < 1024 or > 64L * 1024 * 1024) errors.Add(text.Get("Engine.Settings.Invalid", path, "maxLabelBytes"));
+
+ if (settings.LabelFolders.Count == 0 && !settings.AllowInlineLabels) warnings.Add(text.Get("Engine.Settings.NoFolders"));
+ foreach (var folder in settings.LabelFolders.Where(f => !Directory.Exists(f))) warnings.Add(text.Get("Engine.Settings.FolderMissing", folder));
+ if (settings.AllowedPrinters.Count == 0) warnings.Add(text.Get("Engine.Settings.NoPrinters"));
+ if (settings.Urls.Any(u => Uri.TryCreate(u, UriKind.Absolute, out var x) && x.Scheme == "http" && !x.IsLoopback))
+ warnings.Add(text.Get("Engine.Settings.PlainHttp"));
+
+ settings = settings with { Language = EngineText.Resolve(settings.Language) };
+ return errors.Count > 0 ? new(null, errors, warnings, path) : new(settings, [], warnings, path);
+ }
+
+ /// A commented starter file, written by rotula serve --init.
+ public static string Starter(string keyHash, string labelFolder) =>
+ "{\n" +
+ " // Where the server listens. 127.0.0.1 accepts calls from this computer only. Use https with a certificate for other computers.\n" +
+ " \"urls\": [\"" + EngineSettings.DefaultUrl + "\"],\n" +
+ " // SHA-256 hashes of the API keys (rotula new-key). Callers send the key itself in the X-Api-Key header.\n" +
+ " \"apiKeyHashes\": [\"" + keyHash + "\"],\n" +
+ " // Labels are named relative to one of these folders.\n" +
+ " \"labelFolders\": [" + JsonSerializer.Serialize(labelFolder) + "],\n" +
+ " // Windows printer names, or host / host:port of network printers. Nothing else can be printed to.\n" +
+ " \"allowedPrinters\": [],\n" +
+ " \"allowInlineLabels\": false,\n" +
+ " \"maxCopies\": 1000,\n" +
+ " \"maxRecords\": 10000,\n" +
+ " \"language\": \"en\"\n" +
+ "}\n";
+}
diff --git a/src/Rotula.Engine/EngineText.cs b/src/Rotula.Engine/EngineText.cs
new file mode 100644
index 0000000..294dfba
--- /dev/null
+++ b/src/Rotula.Engine/EngineText.cs
@@ -0,0 +1,163 @@
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.Reflection;
+using System.Xml.Linq;
+using Rotula.Core.Validation;
+using Rotula.Printing;
+
+namespace Rotula.Engine;
+
+///
+/// The engine's sentences in one of the shipped languages. The texts are the app's own resource files, embedded at build time, so a message
+/// from the command line or the REST API reads exactly as the same message in the app. A key missing in a translation falls back to English.
+///
+public sealed class EngineText
+{
+ /// The languages the engine speaks, in the canonical spelling of the app (English first).
+ public static readonly IReadOnlyList Languages = new[] { "en", "es", "pt-BR", "fr-CA" };
+
+ private static readonly ConcurrentDictionary> Tables = new(StringComparer.Ordinal);
+ private static readonly ConcurrentDictionary Instances = new(StringComparer.Ordinal);
+
+ private readonly IReadOnlyDictionary _table;
+ private readonly IReadOnlyDictionary _english;
+
+ private EngineText(string language)
+ {
+ Language = language;
+ _table = Table(language);
+ _english = Table("en");
+ }
+
+ /// The language of this instance (one of ).
+ public string Language { get; }
+
+ /// The English texts.
+ public static EngineText English => For("en");
+
+ ///
+ /// The texts for a language name such as es, es-MX, pt or an HTTP Accept-Language value. Anything not shipped
+ /// gives English, so a caller can never make the engine fail by asking for a language.
+ ///
+ public static EngineText For(string? language) => Instances.GetOrAdd(Resolve(language), l => new EngineText(l));
+
+ /// Maps a requested language to a shipped one: exact match first, then the same base language, then English.
+ public static string Resolve(string? language)
+ {
+ if (string.IsNullOrWhiteSpace(language)) return "en";
+ // Accept-Language may list several ("es-MX,es;q=0.9,en;q=0.8"); the first one the engine speaks wins.
+ foreach (var part in language.Split(','))
+ {
+ string tag = part.Split(';')[0].Trim().Replace('_', '-');
+ if (tag.Length == 0) continue;
+ var exact = Languages.FirstOrDefault(l => string.Equals(l, tag, StringComparison.OrdinalIgnoreCase));
+ if (exact is not null) return exact;
+ string baseTag = tag.Split('-')[0];
+ var family = Languages.FirstOrDefault(l => string.Equals(l.Split('-')[0], baseTag, StringComparison.OrdinalIgnoreCase));
+ if (family is not null) return family;
+ }
+ return "en";
+ }
+
+ /// True when the key has a text (in this language or in English).
+ public bool Has(string key) => _table.ContainsKey(key) || _english.ContainsKey(key);
+
+ /// The text of a key formatted with ; an unknown key comes back as the key itself so nothing is lost.
+ public string Get(string key, params object[] args)
+ {
+ if (!_table.TryGetValue(key, out var text) && !_english.TryGetValue(key, out text)) return key;
+ if (args.Length == 0) return text;
+ try { return string.Format(CultureInfo.InvariantCulture, text, args); }
+ catch (FormatException) { return text; }
+ }
+
+ /// Describes a validation issue or print notice the way the app does (Issue.<key>, keyword arguments translated).
+ public string Describe(ValidationIssue issue)
+ {
+ ArgumentNullException.ThrowIfNull(issue);
+ string? arg = issue.MessageKey switch
+ {
+ PrinterStatusKeys.Warning or PrinterStatusKeys.FaultAfterJob => Conditions(issue.Args),
+ PrinterStatusKeys.NotChecked or PrinterStatusKeys.NotVerified => Reason(issue.Args.FirstOrDefault()),
+ PrinterStatusKeys.Verified => string.Empty,
+ _ => null,
+ };
+ if (arg is not null) return Get("Issue." + issue.MessageKey, arg);
+ string key = "Issue." + issue.MessageKey;
+ // Rotula.Data and the file reader raise some notes under their own key, without the Issue. prefix.
+ if (!Has(key) && Has(issue.MessageKey)) key = issue.MessageKey;
+ if (!Has(key)) return Get("Issue.Unknown", issue.MessageKey);
+ return Get(key, issue.Args.Select(a => (object)(Has("Issue.Arg." + a) ? Get("Issue.Arg." + a) : a)).ToArray());
+ }
+
+ /// Describes a print failure: what happened and what to do next (same wording as the app's print dialog).
+ public string Describe(PrintError error)
+ {
+ ArgumentNullException.ThrowIfNull(error);
+ object[] args = error.Args.Cast