diff --git a/CHANGELOG.md b/CHANGELOG.md
index 32b6f9e..c561607 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
+- **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".
- **Optional update check** (Help > Check for updates once a day): off unless you switch it on. Then Rotula asks GitHub once a day, with one anonymous request that carries nothing about you or your PC, whether a newer version exists, and shows a notice with a link to the release page. It never downloads or installs anything.
diff --git a/README.es.md b/README.es.md
index 31961b5..26f6e54 100644
--- a/README.es.md
+++ b/README.es.md
@@ -343,7 +343,8 @@ transferencia térmica) y el seguimiento.
- **Qué sabe.** Tamaño en milímetros y pulgadas, radio de las esquinas, etiquetas a lo ancho, tamaño del núcleo, etiquetas por
rollo y de dónde sale el dato. Un medio cuyo tamaño no está contrastado aparece marcado y, al aplicarlo, se muestra un aviso.
- **Sin inventar.** La separación entre etiquetas casi siempre se desconoce, así que Rotula indica que calibres la impresora en
- lugar de adivinar. En medios con varias etiquetas por fila o en hojas solo se aplica el tamaño de una etiqueta, y Rotula lo dice.
+ lugar de adivinar. En rollos con varias etiquetas por fila solo se aplica el tamaño de una etiqueta, y Rotula lo dice. Un medio en hojas abre la
+ exportación de [hojas de etiquetas](#hojas-de-etiquetas-impresoras-de-oficina) con el diseño que le corresponde.
- **Tus propios medios.** Guarda un tamaño personalizado como medio, o importa una lista de suministros (`.csv` o `.xlsx`) y revisa
las filas antes de conservarlas. Los precios nunca se leen. Tus medios se guardan en `%AppData%\Rotula\stocks`.
- **Favoritos y recientes** se recuerdan en tu PC.
@@ -400,6 +401,21 @@ Rotula envía el ZPL como datos RAW, así que el controlador lo transmite sin to
Más información en [docs/printing.md](docs/printing.md) (en inglés).
+### Hojas de etiquetas (impresoras de oficina)
+
+**Archivo > Exportar > Hojas de etiquetas (PDF)** (o **Hojas de etiquetas** en el cuadro Imprimir) coloca la etiqueta en hojas para
+una impresora láser o de inyección de tinta: Carta de 30 (2-5/8 x 1 in), 20, 14, 10 de envío (4 x 2 in), 6, 80 y media hoja (2 por hoja),
+A4 de 3 x 8 (70 x 37 mm), 21, 65, 16, 14, 8 y 2 por hoja, o un diseño personalizado (papel, márgenes, tamaño de la etiqueta, etiquetas
+a lo ancho y a lo alto, distancia entre etiquetas).
+
+- **Empezar en la posición** aprovecha una hoja ya empezada: haz clic en la primera posición libre de la vista previa.
+- **La combinación de datos y los números de serie** funcionan como al imprimir: los registros marcados con sus copias, y cada
+ número de serie se usa una sola vez.
+- **Marcas de corte** en los márgenes, o un **contorno fino** de cada etiqueta para probar en papel normal.
+- El resultado es un PDF al tamaño real de la hoja, con cada etiqueta a su propio tamaño físico. Imprímelo a **tamaño real (100 %)**.
+ El diseño de la hoja se guarda con la etiqueta. Las plantillas se describen por sus medidas; las hojas de distintos proveedores
+ pueden variar un milímetro, así que imprime primero una página de prueba en papel normal.
+
---
## Puesto de impresión
@@ -565,7 +581,6 @@ No es compatible hoy, y está abierto a discusión:
- Un mercado de plantillas de etiquetas
- Sincronización en la nube
- macOS y Linux
-- Diseños de hojas con varias etiquetas
¿Tienes un caso de uso que te importa? [Abre una solicitud de función](https://github.com/P4Software/Rotula/issues/new/choose).
diff --git a/README.md b/README.md
index 926dd28..61fdaf3 100644
--- a/README.md
+++ b/README.md
@@ -335,7 +335,8 @@ type. Pick a result and Rotula sets the label size, media type (direct thermal o
- **What it knows.** Size in millimetres and inches, die-cut corner radius, labels across, core size, labels per roll and where
the data comes from. A stock whose size is not cross-checked is marked, and applying it shows a warning.
- **Honest about gaps.** The gap between labels is usually unknown, so Rotula says to calibrate the printer instead of guessing.
- Multi-up and sheet stocks apply the label size only, and Rotula says so.
+ Multi-up roll stocks apply the label size only, and Rotula says so. A sheet stock opens the [label sheets](#label-sheets-office-printers)
+ export on the matching layout.
- **Your own stocks.** Save a custom size as a stock, or import a supplies list (`.csv` or `.xlsx`) and review the rows before
they are kept. Prices are never read. User stocks live in `%AppData%\Rotula\stocks`.
- **Favourites and recent** stocks are remembered on your PC.
@@ -389,6 +390,19 @@ Rotula sends ZPL as RAW data, so the driver passes it through untouched.
More in [docs/printing.md](docs/printing.md).
+### Label sheets (office printers)
+
+**File > Export > Label sheets (PDF)** (or **Label sheets** in the Print dialog) puts the label on sheets for a laser or inkjet
+printer: Letter 30-up (2-5/8 x 1 in), 20-up, 14-up, 10-up shipping (4 x 2 in), 6-up, 80-up, 2-up half sheets, A4 3 x 8 (70 x 37 mm),
+21-up, 65-up, 16-up, 14-up, 8-up, 2-up, or a custom layout (paper, margins, label size, labels across and down, distance between labels).
+
+- **Start at position** uses up a partly used sheet: click the first free position in the preview.
+- **Data merge and serial numbers** work as when printing: the ticked records with their copies, and every serial number used once.
+- **Crop marks** in the margins, or a **thin outline** of each label for a test on plain paper.
+- The result is a PDF at the sheet's real size with every label at its own physical size. Print it at **actual size (100 %)**. The
+ layout is saved with the label. Templates are described by their dimensions; sheets of different suppliers can differ by a
+ millimetre, so print one test page on plain paper first.
+
---
## Print station
@@ -553,7 +567,6 @@ Not supported today, and open for discussion:
- A label template marketplace
- Cloud sync
- macOS and Linux
-- Multi-label sheet layouts
Have a use case that matters to you? [Open a feature request](https://github.com/P4Software/Rotula/issues/new/choose).
diff --git a/docs/decisions.md b/docs/decisions.md
index aced028..c68c934 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -159,6 +159,14 @@ host that matches a preset offers its settings. The password stays a separate pr
placeholder name used for an unnamed form can never pick up the password of a real connection. When the store asks for the password again
the user is told and the password box takes the focus.
+### Label sheets (office printers)
+File > Export > Label sheets (PDF) and a button in the Print dialog open `SheetLayoutViewModel`. Output is a PDF only: sending to a Windows
+printer was left out because driver margins and "fit to page" scaling move the labels, while a PDF printed at actual size keeps them in place.
+The labels come from the print paths (`MergeBatch.Labels` with data, `LabelPrintRun.LabelValues` without), so copies, skipped records and serial
+numbers behave as when printing; numbers are confirmed only when the PDF is complete. Templates are generic and named from their dimensions.
+The chosen layout is saved in the optional `sheet` object of the .rotula file without a version change (an older build ignores it); changing it
+marks the label as changed but is not an undo step. Details in [printing.md](printing.md#label-sheets-for-office-printers).
+
### Tests
`tests/Rotula.Tests` targets `net10.0-windows` (it references the WPF app). All App tests share the xUnit collection "App"
because the language is process-wide state.
diff --git a/docs/printing.md b/docs/printing.md
index fa2d759..b3e8774 100644
--- a/docs/printing.md
+++ b/docs/printing.md
@@ -129,3 +129,35 @@ a resolution the user picked is never overridden.
The test label is sized from physical units (7 mm text, QR module size clamped to Core's 1-10) so it is readable and
inside the label at 203/300/600 dpi.
+
+## Label sheets for office printers
+
+Labels can also be printed on sheets with a laser or inkjet printer. This path does not send ZPL: it draws the labels into a
+PDF that is printed from any PDF reader.
+
+- **Model** (`Rotula.Core.Sheets`, no UI): `SheetLayout` is the paper (`Letter`, `A4`, `Legal` or a custom size), the top and
+ left margin, the label size, columns x rows and the pitch across and down (label size plus gap), all in millimetres.
+ `Validate()` returns message keys (`Sheet.Labels.TooWide`, `Sheet.Labels.OverlapAcross`, `Sheet.Labels.NearEdge` and the
+ others in `Issue.Sheet.*`); `ValidateStart` checks a start position and `CheckDesignSize` warns when the design is larger or
+ smaller than the positions. `SheetTemplates.All` holds generic Letter and A4 layouts described only by their dimensions;
+ `FindForStock` matches a sheet stock of the library by label size and grid, without naming a brand.
+- **Imposition**: `SheetImposition.Paginate` pulls the labels lazily and fills the positions in reading order (or down the
+ columns), sheet after sheet. The first sheet can start at a later position (a partly used sheet); later sheets start at 1.
+- **Values**: the labels come from the same iterations as printing. With data, `MergeBatch.Labels` yields each record with its
+ resolved values (`MergedLabel.Values`) and its copies; without data, `LabelPrintRun.LabelValues()` yields one value set per
+ copy (each copy of a label with a counter has its own number). The renderer draws them with
+ `LabelDocumentAdapter.ToRenderLabelResolved`, which does not resolve the variables again.
+- **Serial numbers**: reserved before the PDF is written and confirmed only when the file is complete; a failed or cancelled
+ export gives them back. A PDF will be printed, so its numbers count as used, exactly as for "Save as ZPL".
+- **Output**: `SheetExporter.SavePdf` writes one page per sheet at the paper's real size. Each label is drawn at its physical
+ size (its dots divided by its resolution), centred in its position and clipped to it; text, boxes and barcode modules stay
+ vectors. Optional crop marks (in the margins only) or grey outlines. The page size is exact for US paper and within 0.02 mm
+ for A4 (the PDF writer rounds it to whole pixels at 600 dpi).
+- **The .rotula file** keeps the chosen layout in an optional root `sheet` object (template id, paper, margins, label size,
+ grid, pitch, order, guides). It does not raise the format version: an older build ignores it. The start position is not
+ saved, because it depends on the sheet in the printer today.
+
+**Not offered: sending the sheets straight to a Windows printer.** Office printer drivers add their own margins and often scale
+to fit, which would move every label; a PDF printed at actual size (100 %, no "fit to page") keeps the positions. Sheets of
+different suppliers with the same label size can differ by about a millimetre in their margins, so print one page on plain
+paper with outlines, hold it against a sheet and adjust the margins.
diff --git a/docs/stock-catalog.md b/docs/stock-catalog.md
index d026f1b..3569151 100644
--- a/docs/stock-catalog.md
+++ b/docs/stock-catalog.md
@@ -177,8 +177,10 @@ reported; it never stops Rotula from starting.
- The gap between labels along the roll is unknown for every entry except one (black-mark sensing). Calibrate the
printer on the stock.
-- Multi-up (several labels across, or label sheets) is not supported yet. `columns`, `rows` and the gaps are in the
- data and are reported by `StockApplier`, but the label document is laid out as a single label.
+- Multi-up rolls (several labels across) are not supported yet. `columns`, `rows` and the gaps are in the data and are
+ reported by `StockApplier`, but the label document is laid out as a single label. A sheet stock whose label size and grid
+ match a built-in sheet template (`SheetTemplates.FindForStock`) opens the label sheets export on that template; the catalogue
+ states no margins for sheets, so the template's geometry is used.
- 40 entries are unverified: 15 where two sources disagree on size or series, 23 stated only by the PDF extraction (11
Zebra part numbers and the 12 generic `std-*` sizes), 2 where the export lists the part twice with different sizes. They are listed in the rebuild output.
- Roll length for long rolls comes from the stock name; the label height of those continuous rolls is the user's choice.
diff --git a/src/Rotula.App/MainWindow.xaml b/src/Rotula.App/MainWindow.xaml
index 942650e..2620f11 100644
--- a/src/Rotula.App/MainWindow.xaml
+++ b/src/Rotula.App/MainWindow.xaml
@@ -170,6 +170,7 @@
+
diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx
index d1b3b6c..c23ca80 100644
--- a/src/Rotula.App/Resources/Strings.en.resx
+++ b/src/Rotula.App/Resources/Strings.en.resx
@@ -6300,4 +6300,202 @@ Brand and product names belong to their owners; Rotula is not affiliated with th
The canvas shows the label upright so you can edit it. Open the preview to see it as printed. Change this in the label properties.
+
+ Label sheets (PDF)...
+
+
+ Label sheets (PDF)...
+
+
+ Put the labels on sheets for an office (laser or inkjet) printer and save them as a PDF.
+
+
+ Label sheets
+
+
+ Print labels on sheets with an office (laser or inkjet) printer. Rotula saves the sheets as a PDF at their real size; print it at actual size (100 %).
+
+
+ Data is loaded: the ticked records go on the sheets, with their copies and serial numbers, as when printing.
+
+
+ Sheet
+
+
+ Sheets of different suppliers with the same label size can differ by a millimetre. Print one page on plain paper, hold it against a sheet of labels and adjust the margins if needed.
+
+
+ Custom layout
+
+
+ {0}, {1} per sheet ({2} x {3}), {4}
+
+
+ {0} x {1} in
+
+
+ {0} x {1} mm
+
+
+ Paper
+
+
+ Sizes in inches
+
+
+ All sizes in millimetres
+
+
+ All sizes in inches
+
+
+ Paper width
+
+
+ Paper height
+
+
+ Label width
+
+
+ Label height
+
+
+ Labels across
+
+
+ Labels down
+
+
+ Top margin
+
+
+ Left margin
+
+
+ Across: edge to edge
+
+
+ Down: edge to edge
+
+
+ The distance from the left (or top) edge of one label to the same edge of the next one: the label size plus the gap between labels.
+
+
+ Start at position
+
+
+ On a partly used sheet, the first free position. Positions are counted along the rows from the top left. You can also click a position in the preview.
+
+
+ Fill order
+
+
+ Guides
+
+
+ Print the PDF at actual size (100 %), without "Fit to page", or the labels will not line up with the sheet.
+
+
+ Sheet preview
+
+
+ Click a position to start there. Grey positions are already used; numbers show where the first labels go.
+
+
+ Position {0}: already used
+
+
+ Position {0}: label {1}
+
+
+ Position {0}: empty
+
+
+ Save as PDF...
+
+
+ Labels: {0}. Sheets: {1} ({2} per sheet).
+
+
+ {0} per sheet. The copies come from a column of the data, so the number of sheets is known when the PDF is made.
+
+
+ {0}: type a number.
+
+
+ Copies: type a whole number from 1 to {0}.
+
+
+ Making the sheets...
+
+
+ Saved {2} (labels: {0}, sheets: {1}). Print it at actual size (100 %).
+
+
+ That is more than {0} labels, too many for one PDF. Tick fewer records or lower the copies, and save the rest in another file.
+
+
+ Letter (8.5 x 11 in)
+
+
+ A4 (210 x 297 mm)
+
+
+ Legal (8.5 x 14 in)
+
+
+ Custom size
+
+
+ Along the rows, then down
+
+
+ Down the columns, then across
+
+
+ None
+
+
+ Crop marks in the margins
+
+
+ Outline of each label (test on plain paper)
+
+
+ The paper must be between {0} and {1} mm on each side.
+
+
+ The labels must be at least {0} mm wide and high.
+
+
+ A sheet can have 1 to {0} labels across and 1 to {1} down.
+
+
+ The margins cannot be negative.
+
+
+ The labels overlap across: from one label edge to the next is {0} mm, but a label is {1} mm wide. Make the distance at least the label width.
+
+
+ The labels overlap down: from one label edge to the next is {0} mm, but a label is {1} mm high. Make the distance at least the label height.
+
+
+ The labels do not fit across the paper: the last column ends at {0} mm, the paper is {1} mm wide. Reduce the left margin, the distance or the number of labels across.
+
+
+ The labels do not fit down the paper: the last row ends at {0} mm, the paper is {1} mm high. Reduce the top margin, the distance or the number of labels down.
+
+
+ Some labels come within {0} mm of the paper edge. Most office printers cannot print there, so keep text and barcodes away from those label edges.
+
+
+ The start position must be a number from 1 to {0}.
+
+
+ The label design is {0} x {1} mm, larger than the {2} x {3} mm labels of this sheet. It is printed at its own size, centred, and cut at the label edges. Choose a sheet with larger labels or resize the design.
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx
index 9b06e42..6ae4fc3 100644
--- a/src/Rotula.App/Resources/Strings.es.resx
+++ b/src/Rotula.App/Resources/Strings.es.resx
@@ -6300,4 +6300,202 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a
El lienzo muestra la etiqueta derecha para que pueda editarla. Abra la vista previa para verla como se imprime. Puede cambiarlo en las propiedades de la etiqueta.
+
+ Hojas de etiquetas (PDF)...
+
+
+ Hojas de etiquetas (PDF)...
+
+
+ Coloca las etiquetas en hojas para una impresora de oficina (láser o de inyección de tinta) y las guarda como PDF.
+
+
+ Hojas de etiquetas
+
+
+ Imprime etiquetas en hojas con una impresora de oficina (láser o de inyección de tinta). Rotula guarda las hojas como PDF a su tamaño real; imprímelo a tamaño real (100 %).
+
+
+ Hay datos cargados: los registros marcados van a las hojas, con sus copias y números de serie, igual que al imprimir.
+
+
+ Hoja
+
+
+ Las hojas de distintos proveedores con el mismo tamaño de etiqueta pueden variar un milímetro. Imprime una página en papel normal, ponla sobre una hoja de etiquetas y ajusta los márgenes si hace falta.
+
+
+ Diseño personalizado
+
+
+ {0}, {1} por hoja ({2} x {3}), {4}
+
+
+ {0} x {1} in
+
+
+ {0} x {1} mm
+
+
+ Papel
+
+
+ Medidas en pulgadas
+
+
+ Todas las medidas en milímetros
+
+
+ Todas las medidas en pulgadas
+
+
+ Ancho del papel
+
+
+ Alto del papel
+
+
+ Ancho de la etiqueta
+
+
+ Alto de la etiqueta
+
+
+ Etiquetas a lo ancho
+
+
+ Etiquetas a lo alto
+
+
+ Margen superior
+
+
+ Margen izquierdo
+
+
+ A lo ancho: de borde a borde
+
+
+ A lo alto: de borde a borde
+
+
+ La distancia desde el borde izquierdo (o superior) de una etiqueta hasta el mismo borde de la siguiente: el tamaño de la etiqueta más el espacio entre etiquetas.
+
+
+ Empezar en la posición
+
+
+ En una hoja ya empezada, la primera posición libre. Las posiciones se cuentan por filas desde arriba a la izquierda. También puedes hacer clic en una posición de la vista previa.
+
+
+ Orden de llenado
+
+
+ Guías
+
+
+ Imprime el PDF a tamaño real (100 %), sin "Ajustar a la página"; si no, las etiquetas no coincidirán con la hoja.
+
+
+ Vista previa de la hoja
+
+
+ Haz clic en una posición para empezar ahí. Las posiciones grises ya están usadas; los números muestran dónde van las primeras etiquetas.
+
+
+ Posición {0}: ya usada
+
+
+ Posición {0}: etiqueta {1}
+
+
+ Posición {0}: vacía
+
+
+ Guardar como PDF...
+
+
+ Etiquetas: {0}. Hojas: {1} ({2} por hoja).
+
+
+ {0} por hoja. Las copias vienen de una columna de los datos, así que el número de hojas se sabe al crear el PDF.
+
+
+ {0}: escribe un número.
+
+
+ Copias: escribe un número entero del 1 al {0}.
+
+
+ Creando las hojas...
+
+
+ Se guardó {2} (etiquetas: {0}, hojas: {1}). Imprímelo a tamaño real (100 %).
+
+
+ Son más de {0} etiquetas, demasiadas para un PDF. Marca menos registros o baja las copias, y guarda el resto en otro archivo.
+
+
+ Carta (8,5 x 11 in)
+
+
+ A4 (210 x 297 mm)
+
+
+ Oficio (8,5 x 14 in)
+
+
+ Tamaño personalizado
+
+
+ Por filas, luego hacia abajo
+
+
+ Por columnas, luego a la derecha
+
+
+ Ninguna
+
+
+ Marcas de corte en los márgenes
+
+
+ Contorno de cada etiqueta (prueba en papel normal)
+
+
+ El papel debe medir entre {0} y {1} mm por lado.
+
+
+ Las etiquetas deben medir al menos {0} mm de ancho y de alto.
+
+
+ Una hoja puede tener de 1 a {0} etiquetas a lo ancho y de 1 a {1} a lo alto.
+
+
+ Los márgenes no pueden ser negativos.
+
+
+ Las etiquetas se superponen a lo ancho: de un borde al siguiente hay {0} mm, pero cada etiqueta mide {1} mm de ancho. La distancia debe ser al menos el ancho de la etiqueta.
+
+
+ Las etiquetas se superponen a lo alto: de un borde al siguiente hay {0} mm, pero cada etiqueta mide {1} mm de alto. La distancia debe ser al menos el alto de la etiqueta.
+
+
+ Las etiquetas no caben a lo ancho del papel: la última columna termina a {0} mm y el papel mide {1} mm. Reduce el margen izquierdo, la distancia o el número de etiquetas a lo ancho.
+
+
+ Las etiquetas no caben a lo alto del papel: la última fila termina a {0} mm y el papel mide {1} mm. Reduce el margen superior, la distancia o el número de etiquetas a lo alto.
+
+
+ Algunas etiquetas llegan a menos de {0} mm del borde del papel. La mayoría de las impresoras de oficina no imprimen ahí, así que aleja el texto y los códigos de barras de esos bordes.
+
+
+ La posición inicial debe ser un número del 1 al {0}.
+
+
+ El diseño de la etiqueta mide {0} x {1} mm, más que las etiquetas de {2} x {3} mm de esta hoja. Se imprime a su propio tamaño, centrado, y se recorta en los bordes de la etiqueta. Elige una hoja con etiquetas más grandes o cambia el tamaño del diseño.
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.fr-CA.resx b/src/Rotula.App/Resources/Strings.fr-CA.resx
index d380c02..d8c15e0 100644
--- a/src/Rotula.App/Resources/Strings.fr-CA.resx
+++ b/src/Rotula.App/Resources/Strings.fr-CA.resx
@@ -6300,4 +6300,202 @@ Les noms de marques et de produits appartiennent à leurs propriétaires ; Rot
La zone de conception affiche l’étiquette à l’endroit pour que vous puissiez la modifier. Ouvrez l’aperçu pour la voir telle qu’imprimée. Modifiez ce réglage dans les propriétés de l’étiquette.
+
+ Feuilles d’étiquettes (PDF)...
+
+
+ Feuilles d’étiquettes (PDF)...
+
+
+ Place les étiquettes sur des feuilles pour une imprimante de bureau (laser ou jet d’encre) et les enregistre en PDF.
+
+
+ Feuilles d’étiquettes
+
+
+ Imprimez des étiquettes sur des feuilles avec une imprimante de bureau (laser ou jet d’encre). Rotula enregistre les feuilles en PDF à leur taille réelle ; imprimez-le en taille réelle (100 %).
+
+
+ Des données sont chargées : les enregistrements cochés vont sur les feuilles, avec leurs copies et leurs numéros de série, comme à l’impression.
+
+
+ Feuille
+
+
+ Les feuilles de fournisseurs différents ayant la même taille d’étiquette peuvent varier d’un millimètre. Imprimez une page sur du papier ordinaire, superposez-la à une feuille d’étiquettes et ajustez les marges au besoin.
+
+
+ Disposition personnalisée
+
+
+ {0}, {1} par feuille ({2} x {3}), {4}
+
+
+ {0} x {1} po
+
+
+ {0} x {1} mm
+
+
+ Papier
+
+
+ Tailles en pouces
+
+
+ Toutes les tailles en millimètres
+
+
+ Toutes les tailles en pouces
+
+
+ Largeur du papier
+
+
+ Hauteur du papier
+
+
+ Largeur de l’étiquette
+
+
+ Hauteur de l’étiquette
+
+
+ Étiquettes en largeur
+
+
+ Étiquettes en hauteur
+
+
+ Marge du haut
+
+
+ Marge de gauche
+
+
+ En largeur : d’un bord à l’autre
+
+
+ En hauteur : d’un bord à l’autre
+
+
+ La distance entre le bord gauche (ou supérieur) d’une étiquette et le même bord de la suivante : la taille de l’étiquette plus l’espace entre les étiquettes.
+
+
+ Commencer à la position
+
+
+ Sur une feuille déjà entamée, la première position libre. Les positions sont comptées par rangées à partir du coin supérieur gauche. Vous pouvez aussi cliquer sur une position dans l’aperçu.
+
+
+ Ordre de remplissage
+
+
+ Repères
+
+
+ Imprimez le PDF en taille réelle (100 %), sans « Ajuster à la page », sinon les étiquettes ne seront pas alignées sur la feuille.
+
+
+ Aperçu de la feuille
+
+
+ Cliquez sur une position pour commencer à cet endroit. Les positions grises sont déjà utilisées ; les numéros indiquent où vont les premières étiquettes.
+
+
+ Position {0} : déjà utilisée
+
+
+ Position {0} : étiquette {1}
+
+
+ Position {0} : vide
+
+
+ Enregistrer en PDF...
+
+
+ Étiquettes : {0}. Feuilles : {1} ({2} par feuille).
+
+
+ {0} par feuille. Les copies proviennent d’une colonne des données ; le nombre de feuilles n’est donc connu qu’à la création du PDF.
+
+
+ {0} : tapez un nombre.
+
+
+ Copies : tapez un nombre entier de 1 à {0}.
+
+
+ Création des feuilles...
+
+
+ {2} enregistré (étiquettes : {0}, feuilles : {1}). Imprimez-le en taille réelle (100 %).
+
+
+ Cela dépasse {0} étiquettes, c’est trop pour un seul PDF. Cochez moins d’enregistrements ou réduisez les copies, et enregistrez le reste dans un autre fichier.
+
+
+ Lettre (8,5 x 11 po)
+
+
+ A4 (210 x 297 mm)
+
+
+ Légal (8,5 x 14 po)
+
+
+ Taille personnalisée
+
+
+ Par rangées, puis vers le bas
+
+
+ Par colonnes, puis vers la droite
+
+
+ Aucun
+
+
+ Traits de coupe dans les marges
+
+
+ Contour de chaque étiquette (essai sur papier ordinaire)
+
+
+ Le papier doit mesurer entre {0} et {1} mm de chaque côté.
+
+
+ Les étiquettes doivent mesurer au moins {0} mm de largeur et de hauteur.
+
+
+ Une feuille peut avoir de 1 à {0} étiquettes en largeur et de 1 à {1} en hauteur.
+
+
+ Les marges ne peuvent pas être négatives.
+
+
+ Les étiquettes se chevauchent en largeur : d’un bord d’étiquette au suivant, il y a {0} mm, mais une étiquette mesure {1} mm de largeur. La distance doit être au moins égale à la largeur de l’étiquette.
+
+
+ Les étiquettes se chevauchent en hauteur : d’un bord d’étiquette au suivant, il y a {0} mm, mais une étiquette mesure {1} mm de hauteur. La distance doit être au moins égale à la hauteur de l’étiquette.
+
+
+ Les étiquettes ne tiennent pas dans la largeur du papier : la dernière colonne se termine à {0} mm et le papier mesure {1} mm de largeur. Réduisez la marge de gauche, la distance ou le nombre d’étiquettes en largeur.
+
+
+ Les étiquettes ne tiennent pas dans la hauteur du papier : la dernière rangée se termine à {0} mm et le papier mesure {1} mm de hauteur. Réduisez la marge du haut, la distance ou le nombre d’étiquettes en hauteur.
+
+
+ Certaines étiquettes arrivent à moins de {0} mm du bord du papier. La plupart des imprimantes de bureau ne peuvent pas imprimer à cet endroit ; gardez donc le texte et les codes à barres loin de ces bords d’étiquette.
+
+
+ La position de départ doit être un nombre de 1 à {0}.
+
+
+ Le dessin de l’étiquette mesure {0} x {1} mm, soit plus que les étiquettes de {2} x {3} mm de cette feuille. Il est imprimé à sa propre taille, centré, et coupé aux bords de l’étiquette. Choisissez une feuille avec de plus grandes étiquettes ou redimensionnez le dessin.
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.pt-BR.resx b/src/Rotula.App/Resources/Strings.pt-BR.resx
index c8c85fe..c8eeae1 100644
--- a/src/Rotula.App/Resources/Strings.pt-BR.resx
+++ b/src/Rotula.App/Resources/Strings.pt-BR.resx
@@ -6300,4 +6300,202 @@ Os nomes de marcas e produtos pertencem aos seus proprietários; o Rotula não t
A área de design mostra a etiqueta na posição normal para que você possa editá-la. Abra a visualização para vê-la como é impressa. Altere isso nas propriedades da etiqueta.
+
+ Folhas de etiquetas (PDF)...
+
+
+ Folhas de etiquetas (PDF)...
+
+
+ Coloca as etiquetas em folhas para uma impressora de escritório (laser ou jato de tinta) e as salva como PDF.
+
+
+ Folhas de etiquetas
+
+
+ Imprima etiquetas em folhas com uma impressora de escritório (laser ou jato de tinta). O Rotula salva as folhas como PDF no tamanho real; imprima-o em tamanho real (100%).
+
+
+ Há dados carregados: os registros marcados vão para as folhas, com suas cópias e números de série, como na impressão.
+
+
+ Folha
+
+
+ Folhas de fornecedores diferentes com o mesmo tamanho de etiqueta podem variar um milímetro. Imprima uma página em papel comum, coloque-a contra uma folha de etiquetas e ajuste as margens se for preciso.
+
+
+ Layout personalizado
+
+
+ {0}, {1} por folha ({2} x {3}), {4}
+
+
+ {0} x {1} pol
+
+
+ {0} x {1} mm
+
+
+ Papel
+
+
+ Medidas em polegadas
+
+
+ Todas as medidas em milímetros
+
+
+ Todas as medidas em polegadas
+
+
+ Largura do papel
+
+
+ Altura do papel
+
+
+ Largura da etiqueta
+
+
+ Altura da etiqueta
+
+
+ Etiquetas na horizontal
+
+
+ Etiquetas na vertical
+
+
+ Margem superior
+
+
+ Margem esquerda
+
+
+ Na horizontal: de borda a borda
+
+
+ Na vertical: de borda a borda
+
+
+ A distância da borda esquerda (ou superior) de uma etiqueta até a mesma borda da seguinte: o tamanho da etiqueta mais o espaço entre etiquetas.
+
+
+ Começar na posição
+
+
+ Em uma folha já usada em parte, a primeira posição livre. As posições são contadas pelas linhas a partir do canto superior esquerdo. Você também pode clicar em uma posição na visualização.
+
+
+ Ordem de preenchimento
+
+
+ Guias
+
+
+ Imprima o PDF em tamanho real (100%), sem "Ajustar à página", ou as etiquetas não vão coincidir com a folha.
+
+
+ Visualização da folha
+
+
+ Clique em uma posição para começar nela. As posições cinza já estão usadas; os números mostram onde vão as primeiras etiquetas.
+
+
+ Posição {0}: já usada
+
+
+ Posição {0}: etiqueta {1}
+
+
+ Posição {0}: vazia
+
+
+ Salvar como PDF...
+
+
+ Etiquetas: {0}. Folhas: {1} ({2} por folha).
+
+
+ {0} por folha. As cópias vêm de uma coluna dos dados, por isso o número de folhas só é conhecido quando o PDF é criado.
+
+
+ {0}: digite um número.
+
+
+ Cópias: digite um número inteiro de 1 a {0}.
+
+
+ Criando as folhas...
+
+
+ {2} salvo (etiquetas: {0}, folhas: {1}). Imprima-o em tamanho real (100%).
+
+
+ São mais de {0} etiquetas, demais para um PDF. Marque menos registros ou diminua as cópias, e salve o restante em outro arquivo.
+
+
+ Carta (8,5 x 11 pol)
+
+
+ A4 (210 x 297 mm)
+
+
+ Ofício (8,5 x 14 pol)
+
+
+ Tamanho personalizado
+
+
+ Pelas linhas, depois para baixo
+
+
+ Pelas colunas, depois para a direita
+
+
+ Nenhuma
+
+
+ Marcas de corte nas margens
+
+
+ Contorno de cada etiqueta (teste em papel comum)
+
+
+ O papel deve ter entre {0} e {1} mm de cada lado.
+
+
+ As etiquetas devem ter pelo menos {0} mm de largura e de altura.
+
+
+ Uma folha pode ter de 1 a {0} etiquetas na horizontal e de 1 a {1} na vertical.
+
+
+ As margens não podem ser negativas.
+
+
+ As etiquetas se sobrepõem na horizontal: de uma borda de etiqueta à seguinte há {0} mm, mas cada etiqueta tem {1} mm de largura. A distância deve ser pelo menos a largura da etiqueta.
+
+
+ As etiquetas se sobrepõem na vertical: de uma borda de etiqueta à seguinte há {0} mm, mas cada etiqueta tem {1} mm de altura. A distância deve ser pelo menos a altura da etiqueta.
+
+
+ As etiquetas não cabem na largura do papel: a última coluna termina em {0} mm e o papel tem {1} mm de largura. Reduza a margem esquerda, a distância ou o número de etiquetas na horizontal.
+
+
+ As etiquetas não cabem na altura do papel: a última linha termina em {0} mm e o papel tem {1} mm de altura. Reduza a margem superior, a distância ou o número de etiquetas na vertical.
+
+
+ Algumas etiquetas ficam a menos de {0} mm da borda do papel. A maioria das impressoras de escritório não imprime ali, por isso mantenha textos e códigos de barras longe dessas bordas da etiqueta.
+
+
+ A posição inicial deve ser um número de 1 a {0}.
+
+
+ O design da etiqueta tem {0} x {1} mm, maior que as etiquetas de {2} x {3} mm desta folha. Ele é impresso no próprio tamanho, centralizado, e cortado nas bordas da etiqueta. Escolha uma folha com etiquetas maiores ou redimensione o design.
+
+
+ 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.
+
diff --git a/src/Rotula.App/Services/ServiceInterfaces.cs b/src/Rotula.App/Services/ServiceInterfaces.cs
index b0738c5..53a0dfe 100644
--- a/src/Rotula.App/Services/ServiceInterfaces.cs
+++ b/src/Rotula.App/Services/ServiceInterfaces.cs
@@ -170,6 +170,9 @@ void ShowPrintStation(Rotula.App.ViewModels.StationViewModel viewModel) { }
/// Shows the tool that makes the exit PIN line for station.json. The default shows nothing.
void ShowStationPinTool(Rotula.App.ViewModels.StationPinViewModel viewModel) { }
+
+ /// Shows the "Label sheets" dialog (office printer sheets as a PDF) modally. The default offers no dialog.
+ void ShowSheetDialog(Rotula.App.ViewModels.SheetLayoutViewModel viewModel) { }
}
/// Opens a web address in the user's browser (the update notice's "View release" link).
diff --git a/src/Rotula.App/Services/WpfServices.cs b/src/Rotula.App/Services/WpfServices.cs
index 0d58720..17b21e5 100644
--- a/src/Rotula.App/Services/WpfServices.cs
+++ b/src/Rotula.App/Services/WpfServices.cs
@@ -243,6 +243,15 @@ public void ShowStationPinTool(Rotula.App.ViewModels.StationPinViewModel viewMod
window.ShowDialog();
}
+ ///
+ public void ShowSheetDialog(Rotula.App.ViewModels.SheetLayoutViewModel viewModel)
+ {
+ // Opened from the main window or from the Print dialog: the active window owns it, so it stays on top of the one it came from.
+ var owner = Application.Current?.Windows.OfType().FirstOrDefault(w => w.IsActive && w.IsLoaded) ?? Application.Current?.MainWindow;
+ var window = new SheetLayoutWindow(viewModel) { Owner = owner };
+ window.ShowDialog();
+ }
+
///
public void ShowDataWindow(Rotula.App.ViewModels.DataMergeViewModel viewModel)
{
diff --git a/src/Rotula.App/ViewModels/DataMergeViewModel.Sheets.cs b/src/Rotula.App/ViewModels/DataMergeViewModel.Sheets.cs
new file mode 100644
index 0000000..5dbf011
--- /dev/null
+++ b/src/Rotula.App/ViewModels/DataMergeViewModel.Sheets.cs
@@ -0,0 +1,117 @@
+using System.Globalization;
+using Rotula.App.Localization;
+using Rotula.Core.Merge;
+using Rotula.Core.Sheets;
+using Rotula.Core.Validation;
+using Rotula.Core.Variables;
+using Rotula.Core.Variables.Counters;
+using Rotula.Rendering;
+using Rotula.Rendering.Export;
+using Rotula.Rendering.Input;
+
+namespace Rotula.App.ViewModels;
+
+/// The ticked records written onto label sheets as a PDF (the "Label sheets" dialog uses this when data is loaded).
+public sealed partial class DataMergeViewModel
+{
+ /// Most labels one sheet export writes; beyond it the PDF would be too large to print or even open.
+ internal const long MaxSheetLabels = 100_000;
+
+ ///
+ /// How many labels the ticked records make with copies each, or null when a column of the data sets the copies (then
+ /// the number is only known once the records are read).
+ ///
+ internal long? EstimateSheetLabels(int labelCopies) => CopiesSource switch
+ {
+ CopiesSource.Column => null,
+ CopiesSource.Fixed => int.TryParse(FixedCopiesText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out int n) && n >= 1
+ ? (long)SelectedCount * n
+ : SelectedCount,
+ _ => (long)SelectedCount * Math.Max(1, labelCopies),
+ };
+
+ ///
+ /// Writes the ticked records onto sheets as a PDF, through the same record iteration as printing (): the same
+ /// selection, copies, skipped invalid records and print-time questions, and the serial numbers of the batch. A PDF will be printed, so its serial
+ /// numbers count as used once the file is complete; a failed or cancelled export gives them back. Every copy of a record carries that record's
+ /// values, as the copies of a printed record do.
+ ///
+ /// The host window's single-operation runner.
+ /// Receives 0-100 while records are read.
+ /// Copies per record when the copies setting is the label's own quantity; null keeps the label's own.
+ /// The sheet (already checked).
+ /// Start position, order and guides.
+ /// The PDF file to write.
+ internal async Task ExportSheetsCoreAsync(Func, Task> run, Action progress,
+ int? labelCopies, SheetLayout layout, SheetExportOptions options, string path)
+ {
+ // The PDF keeps the design's own resolution: it is drawn, not sent to a label printer.
+ string? error = Snapshot(out var doc, out var ds, convertToPrinter: false, quantity: labelCopies);
+ if (error is not null) return new BatchOutcome(error, true);
+ if (!AskBatchPrompts(ds)) return null;
+
+ VariableJob? serials = null;
+ if (VariableEngine.HasDefinitions(doc))
+ {
+ serials = VariableJob.Begin(doc, ds.CreateBatchContext(), ds.BatchCount);
+ ds.Options.Job = serials;
+ }
+ var report = new MergeReport();
+ var closing = new List();
+ SheetExportResult? result = null;
+ string? writeError = null;
+ bool tooMany = false;
+ var progressReporter = new Progress(progress);
+ bool ok = await run(Loc.Get("Print.Sheet.Busy"), ct => Task.Run(() =>
+ {
+ int lastItem = 0, writtenThrough = 0;
+ long count = 0;
+ IEnumerable Labels()
+ {
+ foreach (var merged in MergeBatch.Labels(doc, ds, report))
+ {
+ ct.ThrowIfCancellationRequested();
+ // Converted once per record: the copies are the same picture.
+ var label = LabelDocumentAdapter.ToRenderLabelResolved(doc, merged.Values);
+ for (int c = 0; c < merged.Copies; c++)
+ {
+ if (++count > MaxSheetLabels) { tooMany = true; yield break; }
+ yield return label;
+ }
+ lastItem = merged.ItemNumber;
+ if (report.Processed % 64 == 0) ((IProgress)progressReporter).Report(100.0 * report.Processed / ds.BatchCount);
+ }
+ }
+ try
+ {
+ result = SheetExporter.SavePdf(layout, Labels(), path, options);
+ if (tooMany)
+ {
+ // Cut short: the file is not what the operator asked for, so it is removed and every number goes back.
+ if (File.Exists(path)) File.Delete(path);
+ result = null;
+ }
+ else if (result.Pages > 0) writtenThrough = lastItem;
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ writeError = Loc.Get("File.Error.Write", path);
+ }
+ finally
+ {
+ if (serials is not null) closing.AddRange(serials.Complete(writtenThrough));
+ }
+ }, ct));
+ if (!ok) return null;
+ ShowReport(report);
+ string notes = string.Join(" ", closing.Select(Loc.FormatIssue));
+ if (writeError is not null) return new BatchOutcome(writeError, true);
+ if (tooMany) return new BatchOutcome(Loc.Get("Print.Sheet.TooMany", MaxSheetLabels), true);
+ if (result is null || result.Pages == 0) return new BatchOutcome(Loc.Get("Data.Batch.NothingProduced"), true);
+ string text = Loc.Get("Print.Sheet.Done", result.Labels, result.Pages, Path.GetFileName(path));
+ if (report.SkippedRecords.Count > 0) text += " " + Loc.Get("Data.Batch.Skipped", report.SkippedRecords.Count);
+ if (report.Stopped && report.SkippedRecords.Count > 0) text += " " + Loc.Get("Data.Batch.Stopped", report.SkippedRecords[^1]);
+ if (result.Issues.Count > 0) text += " " + string.Join(" ", result.Issues.Select(i => i.Message).Take(3));
+ return new BatchOutcome(text + (notes.Length > 0 ? " " + notes : string.Empty), report.HasErrors);
+ }
+}
diff --git a/src/Rotula.App/ViewModels/MainViewModel.cs b/src/Rotula.App/ViewModels/MainViewModel.cs
index 0c7d61e..1ab4d95 100644
--- a/src/Rotula.App/ViewModels/MainViewModel.cs
+++ b/src/Rotula.App/ViewModels/MainViewModel.cs
@@ -10,6 +10,7 @@
using Rotula.App.Services;
using Rotula.Core.Elements;
using Rotula.Core.Model;
+using Rotula.Core.Sheets;
using Rotula.Core.Validation;
using Rotula.Core.Zpl;
using Rotula.Printing;
@@ -294,7 +295,7 @@ private void FillToolbox()
/// A cancelled dialog or a file that fails to load leaves it in place.
///
[ObservableProperty]
- [NotifyCanExecuteChangedFor(nameof(ShowDataCommand), nameof(OpenDataFileCommand))]
+ [NotifyCanExecuteChangedFor(nameof(ShowDataCommand), nameof(OpenDataFileCommand), nameof(ShowSheetsCommand))]
private bool _showStartScreen = true;
///
@@ -878,7 +879,7 @@ public bool LoadFile(string path)
try
{
var content = RotulaFileSerializer.LoadFile(path);
- OpenDocument(content.Document, path, merge: content.Merge);
+ OpenDocument(content.Document, path, merge: content.Merge, sheet: content.Sheet);
// The label remembers where its data came from; read it again now so the canvas shows real values, not empty fields. The
// same checks as the Reload buttons apply (local drive, read-only guard, saved connection by name).
DataReload = ReloadRememberedDataQuietlyAsync(path);
@@ -898,8 +899,10 @@ public bool LoadFile(string path)
return false;
}
- private void OpenDocument(LabelDocument doc, string? path, bool markDirty = false, DataMergeSettings? merge = null)
+ private void OpenDocument(LabelDocument doc, string? path, bool markDirty = false, DataMergeSettings? merge = null, SheetSettings? sheet = null)
{
+ // The sheet layout belongs to the label, like its data settings.
+ LabelSheet = sheet;
// Data settings are replaced first: the loaded data belonged to the previous label, and only the settings are kept per file.
Data.Apply(merge);
Session.Load(doc, markDirty);
@@ -929,7 +932,7 @@ private bool SaveTo(string path)
{
try
{
- RotulaFileSerializer.Save(Session.Document, path, Data.ToSettings());
+ RotulaFileSerializer.Save(Session.Document, path, Data.ToSettings(), LabelSheet);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
@@ -1113,10 +1116,51 @@ private void OpenPrintStation()
[RelayCommand]
private void Print()
{
- var vm = new PrintViewModel(_svc.Printing, Printer, () => Session.Document, () => Data.CurrentData, _svc.Files, DocumentName, Variables, Data);
+ var vm = new PrintViewModel(_svc.Printing, Printer, () => Session.Document, () => Data.CurrentData, _svc.Files, DocumentName, Variables, Data,
+ ShowSheets);
_svc.Dialogs.ShowPrintDialog(vm);
}
+ ///
+ /// The sheet layout saved with the label for the "Label sheets" export, or null when none was chosen yet. Changing it marks the label as changed,
+ /// so the choice is saved with it; it is not an undo step (it changes nothing on the label itself).
+ ///
+ public SheetSettings? LabelSheet { get; private set; }
+
+ /// Opens the "Label sheets" dialog: the label (or the ticked records) on sheets for an office printer, saved as a PDF.
+ [RelayCommand(CanExecute = nameof(HasLabel))]
+ private void ShowSheets()
+ {
+ var vm = new SheetLayoutViewModel(() => Session.Document, () => Data.CurrentData, _svc.Files, Variables, LabelSheet ?? SuggestSheet(Session.Document),
+ DocumentName, Data, RememberSheet);
+ _svc.Dialogs.ShowSheetDialog(vm);
+ }
+
+ private void RememberSheet(SheetSettings settings)
+ {
+ if (settings == LabelSheet) return;
+ LabelSheet = settings;
+ Session.MarkDirty();
+ }
+
+ ///
+ /// The layout to start from when the label has none: the template of the label's sheet stock, else a template with the label's size, else
+ /// the first template. The stock library is only read here, never required.
+ ///
+ internal static SheetSettings SuggestSheet(LabelDocument doc)
+ {
+ SheetTemplate? template = null;
+ if (doc.StockId is { Length: > 0 } id)
+ {
+ try { template = SheetTemplates.FindForStock(StockLibrary.Current.Catalog.Find(id)); }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException) { /* no library: fall back to the size */ }
+ }
+ double mm(int dots) => dots * SheetLayout.MmPerInch / (int)doc.Dpi;
+ template ??= SheetTemplates.FindForLabelSize(mm(doc.WidthDots), mm(doc.HeightDots));
+ template ??= SheetTemplates.All[0];
+ return new SheetSettings { TemplateId = template.Id, Layout = template.Layout };
+ }
+
/// Opens the Data merge window.
[RelayCommand(CanExecute = nameof(HasLabel))]
private void ShowData() => _svc.Dialogs.ShowDataWindow(Data);
diff --git a/src/Rotula.App/ViewModels/PrintViewModel.cs b/src/Rotula.App/ViewModels/PrintViewModel.cs
index 3207d17..52960d6 100644
--- a/src/Rotula.App/ViewModels/PrintViewModel.cs
+++ b/src/Rotula.App/ViewModels/PrintViewModel.cs
@@ -26,6 +26,7 @@ public sealed partial class PrintViewModel : OperationViewModel
private readonly IFileDialogService _files;
private readonly string _documentName;
private readonly IVariableServices _variables;
+ private readonly Action? _showSheets;
/// Creates the dialog's view-model.
/// Printing service (a fake in tests; tests never reach a real printer).
@@ -36,10 +37,12 @@ public sealed partial class PrintViewModel : OperationViewModel
/// File name stem suggested for the ZPL file.
/// Counter memory and the print-time questions; null means no counter memory and no questions (tests).
/// The data merge view-model that holds the loaded records and their ticks; null (or no records) keeps the single-label dialog.
+ /// Opens the "Label sheets" dialog (office printer sheets as a PDF); null hides the button.
public PrintViewModel(IPrintService service, PrinterSettingsViewModel printer, Func document,
Func?> data, IFileDialogService files, string documentName = "label", IVariableServices? variables = null,
- DataMergeViewModel? records = null)
+ DataMergeViewModel? records = null, Action? showSheets = null)
{
+ _showSheets = showSheets;
Records = records;
_variables = variables ?? new VariableServices(null, null);
_service = service;
@@ -320,6 +323,17 @@ private async Task SaveRecordsZplAsync()
if (!o.IsError) Printer.Persist();
}
+ /// True when the "Label sheets" button is offered.
+ public bool CanShowSheets => _showSheets is not null;
+
+ /// Opens the "Label sheets" dialog, for labels printed on sheets by an office printer instead of a label printer.
+ [RelayCommand]
+ private void ShowSheets()
+ {
+ if (IsBusy) return;
+ _showSheets?.Invoke();
+ }
+
/// Closes the dialog (stopping any running job first).
[RelayCommand]
private void Close()
diff --git a/src/Rotula.App/ViewModels/SheetLayoutViewModel.cs b/src/Rotula.App/ViewModels/SheetLayoutViewModel.cs
new file mode 100644
index 0000000..e0bc9d9
--- /dev/null
+++ b/src/Rotula.App/ViewModels/SheetLayoutViewModel.cs
@@ -0,0 +1,565 @@
+using System.Collections.ObjectModel;
+using System.Globalization;
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using Rotula.App.Editing;
+using Rotula.App.Localization;
+using Rotula.App.Services;
+using Rotula.Core.Model;
+using Rotula.Core.Sheets;
+using Rotula.Core.Validation;
+using Rotula.Printing;
+using Rotula.Rendering.Export;
+using Rotula.Rendering.Input;
+
+namespace Rotula.App.ViewModels;
+
+/// One entry of the template list: a built-in template, or null for "Custom".
+/// The template, or null for a layout the user types.
+/// The name shown, built from the dimensions (no brand names).
+public sealed record SheetTemplateChoice(SheetTemplate? Template, string Name)
+{
+ ///
+ public override string ToString() => Name;
+}
+
+/// One label position in the sheet preview, in preview pixels.
+/// 1-based position on the sheet.
+/// Left edge in the preview.
+/// Top edge in the preview.
+/// Width in the preview.
+/// Height in the preview.
+/// True for a position before the start position (already used on this sheet).
+/// True for a position that gets a label on the first sheet.
+/// The number of the label that goes there, or empty.
+/// Tooltip and accessible name.
+public sealed record SheetSlotItem(int Position, double Left, double Top, double Width, double Height, bool IsUsed, bool IsFilled, string Caption, string Tip);
+
+///
+/// The "Label sheets" dialog: choose a sheet for an office printer (a built-in template or a custom layout), see where the labels go, start at a
+/// later position on a partly used sheet, and save the labels as a PDF at the sheet's real size. With data loaded the ticked records are used,
+/// through the same path as printing; otherwise the label is repeated for the copies, each copy with its own serial number when it has a counter.
+///
+///
+/// The PDF is printed from any PDF reader at actual size. Sending the sheets straight to a Windows printer is not offered: a driver's own margins
+/// and "fit to page" scaling would move the labels, which a PDF printed at 100 % avoids.
+///
+public sealed partial class SheetLayoutViewModel : OperationViewModel
+{
+ /// Largest size of the preview page, in pixels.
+ internal const double PreviewMaxWidth = 300, PreviewMaxHeight = 380;
+
+ private readonly Func _document;
+ private readonly Func?> _data;
+ private readonly IFileDialogService _files;
+ private readonly IVariableServices _variables;
+ private readonly string _documentName;
+ private readonly Action? _remember;
+ private bool _applying;
+
+ /// Creates the dialog's view-model.
+ /// The open label.
+ /// The record shown on the canvas (used when no records are loaded), or null.
+ /// File dialogs (where to save the PDF).
+ /// Counter memory, the print-time questions and the Windows-font drawing.
+ /// The layout to start from (the one saved with the label, or a suggestion).
+ /// File name stem suggested for the PDF.
+ /// The data merge view-model with the loaded records and their ticks; null (or no records) uses the label's copies.
+ /// Receives the layout when a PDF is saved or the dialog closes, so it is saved with the label.
+ public SheetLayoutViewModel(Func document, Func?> data, IFileDialogService files,
+ IVariableServices variables, SheetSettings initial, string documentName = "label", DataMergeViewModel? records = null,
+ Action? remember = null)
+ {
+ _document = document;
+ _data = data;
+ _files = files;
+ _variables = variables;
+ _documentName = documentName;
+ _remember = remember;
+ Records = records;
+ Templates = new[] { new SheetTemplateChoice(null, Loc.Get("Print.Sheet.Custom")) }
+ .Concat(SheetTemplates.All.Select(t => new SheetTemplateChoice(t, NameOf(t)))).ToList();
+ PageKinds = Enum.GetValues().Select(k => new Choice(k, "Enum.SheetPageKind." + k)).ToList();
+ Orders = Enum.GetValues().Select(k => new Choice(k, "Enum.SheetOrder." + k)).ToList();
+ GuideChoices = Enum.GetValues().Select(k => new Choice(k, "Enum.SheetGuides." + k)).ToList();
+ _copiesText = Math.Max(1, document().Print.Quantity).ToString(CultureInfo.InvariantCulture);
+ var template = SheetTemplates.Find(initial.TemplateId);
+ _useInches = template?.Inches ?? initial.Layout.PageKind is SheetPageKind.Letter or SheetPageKind.Legal;
+ _selectedOrder = Orders.First(o => o.Value == initial.Order);
+ _selectedGuides = GuideChoices.First(g => g.Value == initial.Guides);
+ _selectedTemplate = Templates.FirstOrDefault(t => t.Template is not null && t.Template.Id == template?.Id) ?? Templates[0];
+ _selectedPage = PageKinds.First(p => p.Value == initial.Layout.PageKind);
+ ShowLayout(initial.Layout);
+ Refresh();
+ }
+
+ // ------------------------------------------------------------------------------------ choices
+
+ /// "Custom" followed by the built-in templates.
+ public IReadOnlyList Templates { get; }
+
+ /// Letter, A4, Legal, Custom.
+ public IReadOnlyList> PageKinds { get; }
+
+ /// The fill orders.
+ public IReadOnlyList> Orders { get; }
+
+ /// None, crop marks, outlines.
+ public IReadOnlyList> GuideChoices { get; }
+
+ /// The loaded records and their ticks, or null.
+ public DataMergeViewModel? Records { get; }
+
+ /// True when the ticked records are written (data is loaded).
+ public bool UsesRecords => Records is { HasRecords: true };
+
+ /// The chosen template ("Custom" when the user typed the layout).
+ [ObservableProperty]
+ private SheetTemplateChoice _selectedTemplate;
+
+ partial void OnSelectedTemplateChanged(SheetTemplateChoice value)
+ {
+ if (_applying || value.Template is null) { OnPropertyChanged(nameof(IsCustom)); return; }
+ _applying = true;
+ try
+ {
+ UseInches = value.Template.Inches;
+ SelectedPage = PageKinds.First(p => p.Value == value.Template.Layout.PageKind);
+ ShowLayout(value.Template.Layout);
+ }
+ finally { _applying = false; }
+ OnPropertyChanged(nameof(IsCustom));
+ Refresh();
+ }
+
+ /// True when the layout is the user's own (no template).
+ public bool IsCustom => SelectedTemplate.Template is null;
+
+ /// The paper size.
+ [ObservableProperty]
+ private Choice _selectedPage;
+
+ partial void OnSelectedPageChanged(Choice value)
+ {
+ OnPropertyChanged(nameof(IsCustomPage));
+ if (SheetLayout.PageSize(value.Value) is { } size)
+ {
+ bool was = _applying;
+ _applying = true;
+ PageWidthText = Format(size.WidthMm);
+ PageHeightText = Format(size.HeightMm);
+ _applying = was;
+ }
+ FieldEdited();
+ }
+
+ /// True when the paper size is typed (the width and height boxes are enabled).
+ public bool IsCustomPage => SelectedPage.Value == SheetPageKind.Custom;
+
+ /// The fill order.
+ [ObservableProperty]
+ private Choice _selectedOrder;
+
+ partial void OnSelectedOrderChanged(Choice value) => Refresh();
+
+ /// The guides drawn on the sheet.
+ [ObservableProperty]
+ private Choice _selectedGuides;
+
+ /// True when sizes are shown and typed in inches, false for millimetres.
+ [ObservableProperty]
+ private bool _useInches;
+
+ partial void OnUseInchesChanged(bool value)
+ {
+ // The numbers are shown again in the new unit; the layout itself does not change.
+ if (TryBuildLayout(!value, out var layout, out _)) ShowLayout(layout);
+ OnPropertyChanged(nameof(UnitText));
+ }
+
+ /// "mm" or "in", shown next to the size boxes.
+ public string UnitText => UseInches ? Loc.Get("Print.Sheet.Unit.In") : Loc.Get("Print.Sheet.Unit.Mm");
+
+ // ------------------------------------------------------------------------------------ the numbers as typed
+
+ /// Paper width.
+ [ObservableProperty] private string _pageWidthText = string.Empty;
+ /// Paper height.
+ [ObservableProperty] private string _pageHeightText = string.Empty;
+ /// Top margin.
+ [ObservableProperty] private string _topText = string.Empty;
+ /// Left margin.
+ [ObservableProperty] private string _leftText = string.Empty;
+ /// Label width.
+ [ObservableProperty] private string _labelWidthText = string.Empty;
+ /// Label height.
+ [ObservableProperty] private string _labelHeightText = string.Empty;
+ /// Labels across.
+ [ObservableProperty] private string _columnsText = string.Empty;
+ /// Labels down.
+ [ObservableProperty] private string _rowsText = string.Empty;
+ /// Distance between the left edges of two columns.
+ [ObservableProperty] private string _horizontalPitchText = string.Empty;
+ /// Distance between the top edges of two rows.
+ [ObservableProperty] private string _verticalPitchText = string.Empty;
+
+ partial void OnPageWidthTextChanged(string value) => FieldEdited();
+ partial void OnPageHeightTextChanged(string value) => FieldEdited();
+ partial void OnTopTextChanged(string value) => FieldEdited();
+ partial void OnLeftTextChanged(string value) => FieldEdited();
+ partial void OnLabelWidthTextChanged(string value) => FieldEdited();
+ partial void OnLabelHeightTextChanged(string value) => FieldEdited();
+ partial void OnColumnsTextChanged(string value) => FieldEdited();
+ partial void OnRowsTextChanged(string value) => FieldEdited();
+ partial void OnHorizontalPitchTextChanged(string value) => FieldEdited();
+ partial void OnVerticalPitchTextChanged(string value) => FieldEdited();
+
+ /// 1-based position on the first sheet where the first label goes.
+ [ObservableProperty]
+ private string _startPositionText = "1";
+
+ partial void OnStartPositionTextChanged(string value) => Refresh();
+
+ /// Copies of the label (or of each record when the Data window does not set them).
+ [ObservableProperty]
+ private string _copiesText;
+
+ partial void OnCopiesTextChanged(string value) => Refresh();
+
+ // ------------------------------------------------------------------------------------ what the dialog shows
+
+ /// The positions of the sheet for the preview.
+ public ObservableCollection Slots { get; } = new();
+
+ /// Width of the preview page in pixels.
+ [ObservableProperty] private double _previewWidth = PreviewMaxWidth;
+
+ /// Height of the preview page in pixels.
+ [ObservableProperty] private double _previewHeight = PreviewMaxHeight;
+
+ /// Problems and notes of the layout, one per line (empty when there is nothing to say).
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(HasProblems))]
+ private string _problemsText = string.Empty;
+
+ /// True when has an error (the export is blocked).
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(CanExport))]
+ private bool _hasErrors;
+
+ /// True when there is a problem or a note to show.
+ public bool HasProblems => ProblemsText.Length > 0;
+
+ /// "12 labels on 2 sheets" or what is known about the count.
+ [ObservableProperty]
+ private string _summaryText = string.Empty;
+
+ /// True when the PDF can be made: the layout and numbers are fine and something is selected.
+ public bool CanExport => !HasErrors;
+
+ /// The layout as it would be saved with the label, or null while the numbers have an error.
+ public SheetSettings? Settings =>
+ TryBuildLayout(UseInches, out var layout, out _) && layout.IsValid
+ ? new SheetSettings { TemplateId = SelectedTemplate.Template?.Id, Layout = layout, Order = SelectedOrder.Value, Guides = SelectedGuides.Value }
+ : null;
+
+ // ------------------------------------------------------------------------------------ editing
+
+ /// Makes the clicked position the start position.
+ [RelayCommand]
+ private void PickStart(SheetSlotItem? slot)
+ {
+ if (slot is null) return;
+ StartPositionText = slot.Position.ToString(CultureInfo.InvariantCulture);
+ }
+
+ private void FieldEdited()
+ {
+ if (_applying) return;
+ // A typed number that no longer matches the template makes the layout the user's own.
+ if (SelectedTemplate.Template is { } t && (!TryBuildLayout(UseInches, out var layout, out _) || !Same(layout, t.Layout)))
+ {
+ _applying = true;
+ SelectedTemplate = Templates[0];
+ _applying = false;
+ OnPropertyChanged(nameof(IsCustom));
+ }
+ Refresh();
+ }
+
+ private static bool Same(SheetLayout a, SheetLayout b)
+ {
+ static bool Eq(double x, double y) => Math.Abs(x - y) < 0.01;
+ return a.PageKind == b.PageKind && a.Columns == b.Columns && a.Rows == b.Rows && Eq(a.PageWidthMm, b.PageWidthMm) && Eq(a.PageHeightMm, b.PageHeightMm)
+ && Eq(a.TopMarginMm, b.TopMarginMm) && Eq(a.LeftMarginMm, b.LeftMarginMm) && Eq(a.LabelWidthMm, b.LabelWidthMm) && Eq(a.LabelHeightMm, b.LabelHeightMm)
+ && Eq(a.HorizontalPitchMm, b.HorizontalPitchMm) && Eq(a.VerticalPitchMm, b.VerticalPitchMm);
+ }
+
+ private void ShowLayout(SheetLayout layout)
+ {
+ bool was = _applying;
+ _applying = true;
+ try
+ {
+ PageWidthText = Format(layout.PageWidthMm);
+ PageHeightText = Format(layout.PageHeightMm);
+ TopText = Format(layout.TopMarginMm);
+ LeftText = Format(layout.LeftMarginMm);
+ LabelWidthText = Format(layout.LabelWidthMm);
+ LabelHeightText = Format(layout.LabelHeightMm);
+ ColumnsText = layout.Columns.ToString(CultureInfo.InvariantCulture);
+ RowsText = layout.Rows.ToString(CultureInfo.InvariantCulture);
+ HorizontalPitchText = Format(layout.HorizontalPitchMm);
+ VerticalPitchText = Format(layout.VerticalPitchMm);
+ }
+ finally { _applying = was; }
+ }
+
+ private string Format(double mm) => UseInches
+ ? Math.Round(mm / SheetLayout.MmPerInch, 4).ToString("0.####", CultureInfo.InvariantCulture)
+ : Math.Round(mm, 2).ToString("0.##", CultureInfo.InvariantCulture);
+
+ /// Reads the typed numbers (a comma is accepted as the decimal point) into a layout; false with the first problem when one cannot be read.
+ private bool TryBuildLayout(bool inches, out SheetLayout layout, out string? error)
+ {
+ layout = null!;
+ string? firstError = null;
+ double scale = inches ? SheetLayout.MmPerInch : 1;
+ bool Num(string text, string fieldKey, out double mm)
+ {
+ mm = 0;
+ if (double.TryParse(text.Trim().Replace(',', '.'), NumberStyles.Float, CultureInfo.InvariantCulture, out double v) && double.IsFinite(v))
+ {
+ mm = v * scale;
+ return true;
+ }
+ firstError ??= Loc.Get("Print.Sheet.Field.Invalid", Loc.Get(fieldKey));
+ return false;
+ }
+ bool Int(string text, string fieldKey, out int n)
+ {
+ if (int.TryParse(text.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out n)) return true;
+ firstError ??= Loc.Get("Print.Sheet.Field.Invalid", Loc.Get(fieldKey));
+ return false;
+ }
+ bool ok = Num(PageWidthText, "Print.Sheet.PageWidth", out double pw) & Num(PageHeightText, "Print.Sheet.PageHeight", out double ph)
+ & Num(TopText, "Print.Sheet.Top", out double top) & Num(LeftText, "Print.Sheet.Left", out double left)
+ & Num(LabelWidthText, "Print.Sheet.LabelWidth", out double lw) & Num(LabelHeightText, "Print.Sheet.LabelHeight", out double lh)
+ & Int(ColumnsText, "Print.Sheet.Columns", out int cols) & Int(RowsText, "Print.Sheet.Rows", out int rows)
+ & Num(HorizontalPitchText, "Print.Sheet.HorizontalPitch", out double hp) & Num(VerticalPitchText, "Print.Sheet.VerticalPitch", out double vp);
+ error = firstError;
+ if (!ok) return false;
+ layout = new SheetLayout
+ {
+ PageKind = SelectedPage.Value, PageWidthMm = pw, PageHeightMm = ph, TopMarginMm = top, LeftMarginMm = left,
+ LabelWidthMm = lw, LabelHeightMm = lh, Columns = cols, Rows = rows, HorizontalPitchMm = hp, VerticalPitchMm = vp,
+ };
+ // A named paper is its real size, whatever rounding the boxes show.
+ if (SelectedPage.Value != SheetPageKind.Custom) layout = layout.WithPage(SelectedPage.Value);
+ return true;
+ }
+
+ private bool TryStart(SheetLayout layout, out int start, out string? error)
+ {
+ error = null;
+ if (int.TryParse(StartPositionText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out start) && layout.ValidateStart(start) is null) return true;
+ error = Loc.FormatIssue(layout.ValidateStart(0)!);
+ return false;
+ }
+
+ private bool TryCopies(out int copies)
+ {
+ return int.TryParse(CopiesText.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out copies) && copies >= 1 && copies <= DataMergeViewModel.MaxSheetLabels;
+ }
+
+ /// How many labels the export makes, or null when the data decides (copies from a column).
+ private long? LabelCount(int copies) => UsesRecords ? Records!.EstimateSheetLabels(copies) : copies;
+
+ /// Checks everything and rebuilds the preview and the summary.
+ public void Refresh()
+ {
+ var lines = new List();
+ bool errors = false;
+ Slots.Clear();
+ if (!TryBuildLayout(UseInches, out var layout, out string? readError))
+ {
+ ProblemsText = readError!;
+ HasErrors = true;
+ SummaryText = string.Empty;
+ return;
+ }
+ foreach (var issue in layout.Validate())
+ {
+ lines.Add(Loc.FormatIssue(issue));
+ errors |= issue.Severity == ValidationSeverity.Error;
+ }
+ if (errors)
+ {
+ ProblemsText = string.Join(Environment.NewLine, lines);
+ HasErrors = true;
+ SummaryText = string.Empty;
+ return;
+ }
+ var doc = _document();
+ if (layout.CheckDesignSize(Mm(doc.WidthDots, doc.Dpi), Mm(doc.HeightDots, doc.Dpi)) is { } fit) lines.Add(Loc.FormatIssue(fit));
+ bool startOk = TryStart(layout, out int start, out string? startError);
+ if (!startOk) { lines.Add(startError!); errors = true; start = 1; }
+ bool copiesOk = TryCopies(out int copies);
+ if (!copiesOk) { lines.Add(Loc.Get("Print.Sheet.Copies.Invalid", DataMergeViewModel.MaxSheetLabels)); errors = true; copies = 1; }
+ if (UsesRecords && Records!.SelectedCount == 0) { lines.Add(Loc.Get("Print.Records.NoneSelected")); errors = true; }
+
+ long? count = LabelCount(copies);
+ SummaryText = count is { } n
+ ? Loc.Get("Print.Sheet.Summary", n, SheetImposition.PageCount(layout, n, start), layout.SlotCount)
+ : Loc.Get("Print.Sheet.Summary.FromData", layout.SlotCount);
+ BuildPreview(layout, start, count, SelectedOrder.Value);
+ ProblemsText = string.Join(Environment.NewLine, lines);
+ HasErrors = errors;
+ }
+
+ private static double Mm(int dots, PrinterDpi dpi) => dots * SheetLayout.MmPerInch / (int)dpi;
+
+ private void BuildPreview(SheetLayout layout, int start, long? count, SheetOrder order)
+ {
+ double scale = Math.Min(PreviewMaxWidth / layout.PageWidthMm, PreviewMaxHeight / layout.PageHeightMm);
+ PreviewWidth = layout.PageWidthMm * scale;
+ PreviewHeight = layout.PageHeightMm * scale;
+ foreach (var slot in layout.Slots(order))
+ {
+ bool used = slot.Position < start;
+ long number = slot.Position - start + 1;
+ bool filled = !used && (count is null || number <= count);
+ string caption = filled ? number.ToString(CultureInfo.InvariantCulture) : string.Empty;
+ string tip = used ? Loc.Get("Print.Sheet.Slot.Used", slot.Position)
+ : filled ? Loc.Get("Print.Sheet.Slot.Label", slot.Position, number)
+ : Loc.Get("Print.Sheet.Slot.Empty", slot.Position);
+ Slots.Add(new SheetSlotItem(slot.Position, slot.XMm * scale, slot.YMm * scale, slot.WidthMm * scale, slot.HeightMm * scale, used, filled, caption, tip));
+ }
+ }
+
+ // ------------------------------------------------------------------------------------ export
+
+ /// Asks where to save, then writes the PDF.
+ [RelayCommand]
+ private async Task ExportPdfAsync()
+ {
+ if (IsBusy) return;
+ Refresh();
+ if (HasErrors || !TryBuildLayout(UseInches, out var layout, out _) || !TryStart(layout, out int start, out _) || !TryCopies(out int copies))
+ {
+ SetResult(ProblemsText, true);
+ return;
+ }
+ if (Records is { IsBusy: true }) return;
+ var options = new SheetExportOptions { StartPosition = start, Order = SelectedOrder.Value, Guides = SelectedGuides.Value };
+ string? path = _files.PickExportFile(ExportKind.Pdf, _documentName + ".pdf");
+ if (path is null) return;
+ RememberLayout();
+ if (UsesRecords)
+ {
+ var outcome = await Records!.ExportSheetsCoreAsync(RunAsync, p => Progress = p, copies, layout, options, path);
+ if (outcome is { } o) SetResult(o.Text, o.IsError);
+ return;
+ }
+ await ExportLabelAsync(layout, options, copies, path);
+ }
+
+ ///
+ /// The label without records: its copies through , exactly as the Print dialog would send them, so every copy of a
+ /// label with a counter carries its own number and the numbers are confirmed only when the file is complete.
+ ///
+ private async Task ExportLabelAsync(SheetLayout layout, SheetExportOptions options, int copies, string path)
+ {
+ var doc = ElementCloner.CloneDocument(_document());
+ doc.Print.Quantity = copies;
+ var data = _data();
+ var answers = _variables.Ask(Rotula.Core.Variables.Prompts.PromptSet.Build(doc, data, _variables.PromptMemory));
+ if (answers is null) return;
+ var run = LabelPrintRun.Begin(doc, data, VariableServices.Options(_variables, answers));
+ if (!run.CanPrint)
+ {
+ SetResult(Loc.Get("Print.Error.InvalidLabel") + Environment.NewLine + PrintMessages.DescribeIssues(run.Issues), true);
+ return;
+ }
+ SheetExportResult? result = null;
+ string? writeError = null;
+ var closing = new List();
+ bool ok = await RunAsync(Loc.Get("Print.Sheet.Busy"), ct => Task.Run(() =>
+ {
+ long written = 0;
+ try
+ {
+ IReadOnlyDictionary? lastValues = null;
+ RenderLabel? lastLabel = null;
+ IEnumerable Labels()
+ {
+ foreach (var values in run.LabelValues())
+ {
+ ct.ThrowIfCancellationRequested();
+ // Copies without a counter share one dictionary, so the design is converted once.
+ if (lastLabel is null || !ReferenceEquals(values, lastValues))
+ {
+ lastLabel = LabelDocumentAdapter.ToRenderLabelResolved(doc, values);
+ lastValues = values;
+ }
+ yield return lastLabel;
+ }
+ }
+ result = SheetExporter.SavePdf(layout, Labels(), path, options);
+ if (result.Pages > 0 && !run.StoppedEarly) written = result.Labels;
+ else if (run.StoppedEarly && File.Exists(path)) File.Delete(path);
+ }
+ catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
+ {
+ writeError = Loc.Get("File.Error.Write", path);
+ }
+ finally
+ {
+ // Only a complete file uses up its serial numbers; anything else gives them back.
+ closing.AddRange(run.Complete(written));
+ }
+ }, ct));
+ if (!ok) return;
+ string notes = string.Join(Environment.NewLine, run.Issues.Where(i => i.Severity != ValidationSeverity.Error).Concat(closing).Select(Loc.FormatIssue));
+ if (writeError is not null) { SetResult(writeError, true); return; }
+ if (run.StoppedEarly)
+ {
+ SetResult(Loc.Get("Print.Error.InvalidLabel") + Environment.NewLine + PrintMessages.DescribeIssues(run.Issues), true);
+ return;
+ }
+ if (result is null || result.Pages == 0) { SetResult(Loc.Get("Data.Batch.NothingProduced"), true); return; }
+ string text = Loc.Get("Print.Sheet.Done", result.Labels, result.Pages, Path.GetFileName(path));
+ if (result.Issues.Count > 0) text += Environment.NewLine + string.Join(Environment.NewLine, result.Issues.Select(i => i.Message).Take(3));
+ SetResult(notes.Length == 0 ? text : text + Environment.NewLine + notes, false);
+ }
+
+ /// Hands a valid layout to the label, so it is saved with it. Called when the dialog closes, however it is closed.
+ public void RememberLayout()
+ {
+ if (Settings is { } settings) _remember?.Invoke(settings);
+ }
+
+ /// Closes the dialog.
+ [RelayCommand]
+ private void Close()
+ {
+ RememberLayout();
+ RequestClose();
+ }
+
+ // ------------------------------------------------------------------------------------ names
+
+ /// The name of a template, from its dimensions: "Letter, 30 per sheet (3 x 10), 2.625 x 1 in".
+ internal static string NameOf(SheetTemplate t)
+ {
+ var l = t.Layout;
+ string size = t.Inches
+ ? Loc.Get("Print.Sheet.Size.In", Inch(l.LabelWidthMm), Inch(l.LabelHeightMm))
+ : Loc.Get("Print.Sheet.Size.Mm", MmText(l.LabelWidthMm), MmText(l.LabelHeightMm));
+ return Loc.Get("Print.Sheet.Template.Name", Loc.Get("Enum.SheetPageKind." + l.PageKind), l.SlotCount, l.Columns, l.Rows, size);
+ }
+
+ private static string Inch(double mm) => Math.Round(mm / SheetLayout.MmPerInch, 3).ToString("0.###", CultureInfo.CurrentCulture);
+
+ private static string MmText(double mm) => Math.Round(mm, 1).ToString("0.#", CultureInfo.CurrentCulture);
+}
diff --git a/src/Rotula.App/Views/PrintWindow.xaml b/src/Rotula.App/Views/PrintWindow.xaml
index e600a8a..28c47c2 100644
--- a/src/Rotula.App/Views/PrintWindow.xaml
+++ b/src/Rotula.App/Views/PrintWindow.xaml
@@ -23,8 +23,10 @@
-
+
diff --git a/src/Rotula.App/Views/SheetLayoutWindow.xaml b/src/Rotula.App/Views/SheetLayoutWindow.xaml
new file mode 100644
index 0000000..e60c1ca
--- /dev/null
+++ b/src/Rotula.App/Views/SheetLayoutWindow.xaml
@@ -0,0 +1,233 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Rotula.App/Views/SheetLayoutWindow.xaml.cs b/src/Rotula.App/Views/SheetLayoutWindow.xaml.cs
new file mode 100644
index 0000000..8c3b439
--- /dev/null
+++ b/src/Rotula.App/Views/SheetLayoutWindow.xaml.cs
@@ -0,0 +1,28 @@
+using System.ComponentModel;
+using Rotula.App.ViewModels;
+
+namespace Rotula.App.Views;
+
+/// The "Label sheets" dialog. All behaviour is in ; this class only connects window events.
+public partial class SheetLayoutWindow : DialogWindow
+{
+ private readonly SheetLayoutViewModel _vm;
+
+ /// Creates the dialog.
+ public SheetLayoutWindow(SheetLayoutViewModel viewModel)
+ {
+ _vm = viewModel;
+ DataContext = viewModel;
+ InitializeComponent();
+ viewModel.CloseRequested += (_, _) => Close();
+ }
+
+ ///
+ protected override void OnClosing(CancelEventArgs e)
+ {
+ base.OnClosing(e);
+ // Closing the window (Esc, X) must stop an export that is still being written.
+ _vm.CancelOperation();
+ _vm.RememberLayout();
+ }
+}
diff --git a/src/Rotula.Core/Merge/MergeBatch.cs b/src/Rotula.Core/Merge/MergeBatch.cs
index efcf29d..aac1ab0 100644
--- a/src/Rotula.Core/Merge/MergeBatch.cs
+++ b/src/Rotula.Core/Merge/MergeBatch.cs
@@ -44,7 +44,10 @@ public sealed class MergeReport
/// The ZPL text, one or more ^XA..^XZ blocks each ending with a line feed.
/// 1-based position of the label in the batch (equal to unless a selection is used).
/// It is the count of serial numbers used through this label.
-public sealed record MergedLabel(int RecordNumber, string Zpl, int ItemNumber = 0);
+/// The values that filled this label: the record's columns plus the resolved dates, counters, prompts and computed values. An output
+/// that draws the label instead of sending ZPL (label sheets as PDF) uses them, so the picture carries exactly the serial number the ZPL carries.
+/// How many copies of this record's label are printed (the copies setting, or the label's own quantity).
+public sealed record MergedLabel(int RecordNumber, string Zpl, int ItemNumber = 0, IReadOnlyDictionary? Values = null, int Copies = 1);
/// One print job of a batch: the ZPL of consecutive labels and the records it covers.
/// The ZPL text of the labels of this job.
@@ -120,11 +123,25 @@ public static IEnumerable Labels(LabelDocument doc, MergeDataSet da
report.Processed++;
var problems = new List();
string? zpl = null;
+ IReadOnlyDictionary? filled = null;
+ int? copies = null;
try
{
var values = data.BuildData(i, problems, position);
- int? copies = ResolveCopies(data, i, problems);
- var result = job is null ? ZplGenerator.Generate(doc, values, options.TextRasterizer) : ZplGenerator.GenerateWithVariables(doc, job.ContextFor(position, values), null, options.TextRasterizer);
+ copies = ResolveCopies(data, i, problems);
+ ZplGenerationResult result;
+ if (job is null)
+ {
+ result = ZplGenerator.Generate(doc, values, options.TextRasterizer);
+ filled = values;
+ }
+ else
+ {
+ // Resolved once and used for both the ZPL and the label's values, so a drawn label and a printed one never differ.
+ var resolution = job.Resolve(position, values);
+ result = ZplGenerator.GenerateResolved(doc, resolution, null, options.TextRasterizer);
+ filled = resolution.Values;
+ }
// Variable warnings are the same for every record; say each of them once.
problems.AddRange(result.Issues.Where(p => job is null || p.Severity == ValidationSeverity.Error || warned.Add(p.MessageKey + "|" + string.Join("|", p.Args))));
if (!problems.Any(p => p.Severity == ValidationSeverity.Error))
@@ -149,7 +166,7 @@ public static IEnumerable Labels(LabelDocument doc, MergeDataSet da
}
report.Emitted++;
usedThrough = position + 1;
- yield return new MergedLabel(number, zpl, position + 1);
+ yield return new MergedLabel(number, zpl, position + 1, filled, copies ?? Math.Max(1, doc.Print.Quantity));
}
}
finally
diff --git a/src/Rotula.Core/Sheets/SheetImposition.cs b/src/Rotula.Core/Sheets/SheetImposition.cs
new file mode 100644
index 0000000..6134bf6
--- /dev/null
+++ b/src/Rotula.Core/Sheets/SheetImposition.cs
@@ -0,0 +1,78 @@
+namespace Rotula.Core.Sheets;
+
+/// One label placed on a sheet.
+/// What is placed (a rendered label, a record number in tests).
+/// The position on the sheet.
+/// The label.
+/// 1-based number of the label in the whole run (copies included).
+public readonly record struct SheetPlacement(SheetSlot Slot, T Item, long LabelNumber);
+
+/// One sheet of a run: its 1-based number and the labels on it, in fill order.
+/// What is placed.
+/// 1-based sheet number.
+/// The labels on this sheet; positions that are skipped or left over stay empty.
+public sealed record SheetPage(int PageNumber, IReadOnlyList> Placements);
+
+///
+/// Puts a sequence of labels onto sheets: in fill order across the positions of a sheet, then onto the next sheet. The first sheet can start at
+/// a later position so a partly used sheet is used up. The labels are pulled lazily, one sheet at a time, so a long run never sits in memory;
+/// the caller decides what a label is (one per record and copy, with its own serial number) and this class only decides where it goes.
+///
+public static class SheetImposition
+{
+ /// How many sheets labels need when the first sheet starts at .
+ /// The layout has no positions, or the start position is not on the sheet.
+ public static long PageCount(SheetLayout layout, long labelCount, int startPosition = 1)
+ {
+ Check(layout, startPosition);
+ if (labelCount <= 0) return 0;
+ long slots = layout.SlotCount;
+ // The skipped positions behave like labels that were already used.
+ return (labelCount + startPosition - 1 + slots - 1) / slots;
+ }
+
+ ///
+ /// Lays the labels out on sheets. The first sheet leaves positions 1 to - 1 empty; every later sheet starts
+ /// at position 1. A sheet is yielded as soon as it is full (or the labels run out), so an exporter can write it and move on.
+ ///
+ /// The sheet geometry. It should be valid (); only the slot count is checked here.
+ /// The labels in print order.
+ /// 1-based first position used on the first sheet.
+ /// The fill order of the positions.
+ /// The layout has no positions, or the start position is not on the sheet.
+ public static IEnumerable> Paginate(SheetLayout layout, IEnumerable labels, int startPosition = 1, SheetOrder order = SheetOrder.AcrossThenDown)
+ {
+ ArgumentNullException.ThrowIfNull(labels);
+ Check(layout, startPosition);
+ return PaginateCore(layout, labels, startPosition, order);
+ }
+
+ private static IEnumerable> PaginateCore(SheetLayout layout, IEnumerable labels, int startPosition, SheetOrder order)
+ {
+ // The slots never change, so they are worked out once.
+ var slots = layout.Slots(order).ToArray();
+ int page = 1;
+ int next = startPosition - 1;
+ long number = 0;
+ var current = new List>(slots.Length);
+ foreach (var label in labels)
+ {
+ current.Add(new SheetPlacement(slots[next], label, ++number));
+ if (++next == slots.Length)
+ {
+ yield return new SheetPage(page++, current);
+ current = new List>(slots.Length);
+ next = 0;
+ }
+ }
+ if (current.Count > 0) yield return new SheetPage(page, current);
+ }
+
+ private static void Check(SheetLayout layout, int startPosition)
+ {
+ ArgumentNullException.ThrowIfNull(layout);
+ if (layout.SlotCount <= 0) throw new ArgumentException("The sheet has no label positions.", nameof(layout));
+ if (startPosition < 1 || startPosition > layout.SlotCount)
+ throw new ArgumentOutOfRangeException(nameof(startPosition), "The start position must be on the sheet.");
+ }
+}
diff --git a/src/Rotula.Core/Sheets/SheetLayout.cs b/src/Rotula.Core/Sheets/SheetLayout.cs
new file mode 100644
index 0000000..19f17fb
--- /dev/null
+++ b/src/Rotula.Core/Sheets/SheetLayout.cs
@@ -0,0 +1,280 @@
+using System.Globalization;
+using Rotula.Core.Validation;
+
+namespace Rotula.Core.Sheets;
+
+/// Paper size of a label sheet.
+public enum SheetPageKind
+{
+ /// US Letter, 8.5 x 11 in (215.9 x 279.4 mm).
+ Letter,
+ /// ISO A4, 210 x 297 mm.
+ A4,
+ /// US Legal, 8.5 x 14 in (215.9 x 355.6 mm).
+ Legal,
+ /// Any other size, given in millimetres.
+ Custom,
+}
+
+/// The order in which labels fill the positions of a sheet.
+public enum SheetOrder
+{
+ /// Left to right along a row, then the next row down (reading order).
+ AcrossThenDown,
+ /// Top to bottom along a column, then the next column to the right.
+ DownThenAcross,
+}
+
+/// Marks drawn on a sheet to help cut or check the labels.
+public enum SheetGuides
+{
+ /// Nothing but the labels.
+ None,
+ /// Short crop marks in the page margins, in line with the label edges.
+ CropMarks,
+ /// A thin grey outline around every printed label (for a test print on plain paper).
+ Outlines,
+}
+
+/// One label position on a sheet, in millimetres from the top-left corner of the paper.
+/// 0-based position in the fill order of the sheet.
+/// 0-based column, counted from the left.
+/// 0-based row, counted from the top.
+/// Left edge of the label.
+/// Top edge of the label.
+/// Label width.
+/// Label height.
+public readonly record struct SheetSlot(int Index, int Column, int Row, double XMm, double YMm, double WidthMm, double HeightMm)
+{
+ /// 1-based position as the operator counts it (the "start at position" number).
+ public int Position => Index + 1;
+}
+
+///
+/// The geometry of a sheet of labels for an office (laser or inkjet) printer: the paper, the distance from the paper's top-left corner to the first
+/// label, the label size, how many labels across and down, and the pitch (the distance from one label's edge to the same edge of the next one,
+/// which is the label size plus the gap). Everything is in millimetres. The type knows nothing about the label design or the printer.
+///
+/// The corner radius of a die-cut sheet is irrelevant here: a label design is rectangular, and a printer prints over the corners.
+public sealed record SheetLayout
+{
+ /// Width and height of a US Letter page.
+ public const double LetterWidthMm = 215.9, LetterHeightMm = 279.4;
+
+ /// Width and height of an A4 page.
+ public const double A4WidthMm = 210, A4HeightMm = 297;
+
+ /// Width and height of a US Legal page.
+ public const double LegalWidthMm = 215.9, LegalHeightMm = 355.6;
+
+ /// Smallest and largest accepted page side.
+ public const double MinPageMm = 25, MaxPageMm = 1500;
+
+ /// Smallest accepted label side.
+ public const double MinLabelMm = 3;
+
+ /// Most columns and rows a sheet may have.
+ public const int MaxColumns = 50, MaxRows = 100;
+
+ ///
+ /// How close to the paper edge a label may come before a warning says that the printer may not reach it. Most office printers leave about
+ /// 3 to 6 mm unprinted at the edges.
+ ///
+ public const double PrinterEdgeMm = 3;
+
+ /// Rounding slack for comparisons, so a layout given in inches is not refused for a hundredth of a millimetre.
+ public const double ToleranceMm = 0.05;
+
+ /// Millimetres per inch.
+ public const double MmPerInch = 25.4;
+
+ /// The named paper size; means and are the user's.
+ public SheetPageKind PageKind { get; init; } = SheetPageKind.Letter;
+
+ /// Paper width.
+ public double PageWidthMm { get; init; } = LetterWidthMm;
+
+ /// Paper height.
+ public double PageHeightMm { get; init; } = LetterHeightMm;
+
+ /// Distance from the top of the paper to the top of the first row.
+ public double TopMarginMm { get; init; }
+
+ /// Distance from the left of the paper to the left of the first column.
+ public double LeftMarginMm { get; init; }
+
+ /// Width of one label.
+ public double LabelWidthMm { get; init; } = 50;
+
+ /// Height of one label.
+ public double LabelHeightMm { get; init; } = 25;
+
+ /// Labels across.
+ public int Columns { get; init; } = 1;
+
+ /// Labels down.
+ public int Rows { get; init; } = 1;
+
+ /// Distance from the left edge of one column to the left edge of the next (label width plus the gap).
+ public double HorizontalPitchMm { get; init; }
+
+ /// Distance from the top edge of one row to the top edge of the next (label height plus the gap).
+ public double VerticalPitchMm { get; init; }
+
+ /// Number of label positions on one sheet.
+ public int SlotCount => Math.Max(0, Columns) * Math.Max(0, Rows);
+
+ /// Gap between two columns (pitch minus label width).
+ public double HorizontalGapMm => HorizontalPitchMm - LabelWidthMm;
+
+ /// Gap between two rows (pitch minus label height).
+ public double VerticalGapMm => VerticalPitchMm - LabelHeightMm;
+
+ /// The right edge of the last column.
+ public double GridRightMm => LeftMarginMm + (Math.Max(1, Columns) - 1) * HorizontalPitchMm + LabelWidthMm;
+
+ /// The bottom edge of the last row.
+ public double GridBottomMm => TopMarginMm + (Math.Max(1, Rows) - 1) * VerticalPitchMm + LabelHeightMm;
+
+ /// The paper width and height of a named size; null for .
+ public static (double WidthMm, double HeightMm)? PageSize(SheetPageKind kind) => kind switch
+ {
+ SheetPageKind.Letter => (LetterWidthMm, LetterHeightMm),
+ SheetPageKind.A4 => (A4WidthMm, A4HeightMm),
+ SheetPageKind.Legal => (LegalWidthMm, LegalHeightMm),
+ _ => null,
+ };
+
+ ///
+ /// Builds a layout from the usual sheet description: paper, grid, label size, margins and gaps (rather than pitches).
+ ///
+ /// A named paper size (not ; use the object initializer for that).
+ /// Labels across.
+ /// Labels down.
+ /// Label width.
+ /// Label height.
+ /// Top margin.
+ /// Left margin.
+ /// Gap between columns.
+ /// Gap between rows.
+ public static SheetLayout Create(SheetPageKind page, int columns, int rows, double labelWidthMm, double labelHeightMm,
+ double topMm, double leftMm, double horizontalGapMm = 0, double verticalGapMm = 0)
+ {
+ var size = PageSize(page) ?? throw new ArgumentException("A custom page needs its size; set PageWidthMm and PageHeightMm instead.", nameof(page));
+ return new SheetLayout
+ {
+ PageKind = page, PageWidthMm = size.WidthMm, PageHeightMm = size.HeightMm,
+ Columns = columns, Rows = rows, LabelWidthMm = labelWidthMm, LabelHeightMm = labelHeightMm,
+ TopMarginMm = topMm, LeftMarginMm = leftMm,
+ HorizontalPitchMm = labelWidthMm + horizontalGapMm, VerticalPitchMm = labelHeightMm + verticalGapMm,
+ };
+ }
+
+ ///
+ /// Builds a layout whose grid is centred on the paper. Used when only the label size, the grid and the gaps are known (a sheet stock of the
+ /// library states no margins): most sheets are cut symmetrically, and the operator can still adjust the margins.
+ ///
+ public static SheetLayout Centered(SheetPageKind page, int columns, int rows, double labelWidthMm, double labelHeightMm,
+ double horizontalGapMm = 0, double verticalGapMm = 0)
+ {
+ var size = PageSize(page) ?? throw new ArgumentException("A custom page needs its size.", nameof(page));
+ double gridW = columns * labelWidthMm + (columns - 1) * horizontalGapMm;
+ double gridH = rows * labelHeightMm + (rows - 1) * verticalGapMm;
+ return Create(page, columns, rows, labelWidthMm, labelHeightMm,
+ Math.Max(0, Math.Round((size.HeightMm - gridH) / 2, 2)), Math.Max(0, Math.Round((size.WidthMm - gridW) / 2, 2)), horizontalGapMm, verticalGapMm);
+ }
+
+ /// The same grid on another paper size (the named size's dimensions replace the page width and height).
+ public SheetLayout WithPage(SheetPageKind page)
+ {
+ if (PageSize(page) is not { } size) return this with { PageKind = SheetPageKind.Custom };
+ return this with { PageKind = page, PageWidthMm = size.WidthMm, PageHeightMm = size.HeightMm };
+ }
+
+ /// The position with 0-based in the given fill order.
+ /// The index is not on the sheet.
+ public SheetSlot Slot(int index, SheetOrder order = SheetOrder.AcrossThenDown)
+ {
+ if (index < 0 || index >= SlotCount) throw new ArgumentOutOfRangeException(nameof(index));
+ int column, row;
+ if (order == SheetOrder.DownThenAcross) { column = index / Rows; row = index % Rows; }
+ else { row = index / Columns; column = index % Columns; }
+ return new SheetSlot(index, column, row, LeftMarginMm + column * HorizontalPitchMm, TopMarginMm + row * VerticalPitchMm, LabelWidthMm, LabelHeightMm);
+ }
+
+ /// Every position of the sheet in fill order.
+ public IEnumerable Slots(SheetOrder order = SheetOrder.AcrossThenDown)
+ {
+ for (int i = 0; i < SlotCount; i++) yield return Slot(i, order);
+ }
+
+ ///
+ /// Checks that the layout can be printed: sizes in range, labels that do not overlap and stay on the paper. Errors make the layout unusable;
+ /// the warning Sheet.Labels.NearEdge says that labels come so close to the paper edge that a printer may not reach them.
+ ///
+ public IReadOnlyList Validate()
+ {
+ var issues = new List();
+ if (!InRange(PageWidthMm, MinPageMm, MaxPageMm) || !InRange(PageHeightMm, MinPageMm, MaxPageMm))
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Page.Size", null, Mm(MinPageMm), Mm(MaxPageMm)));
+ if (!InRange(LabelWidthMm, MinLabelMm, MaxPageMm) || !InRange(LabelHeightMm, MinLabelMm, MaxPageMm))
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Label.Size", null, Mm(MinLabelMm)));
+ if (Columns < 1 || Columns > MaxColumns || Rows < 1 || Rows > MaxRows)
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Grid.Count", null, Num(MaxColumns), Num(MaxRows)));
+ if (!double.IsFinite(TopMarginMm) || !double.IsFinite(LeftMarginMm) || TopMarginMm < 0 || LeftMarginMm < 0)
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Margin.Negative", null));
+ if (!double.IsFinite(HorizontalPitchMm) || !double.IsFinite(VerticalPitchMm))
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Labels.OverlapAcross", null, Mm(HorizontalPitchMm), Mm(LabelWidthMm)));
+ if (issues.Count > 0) return issues; // the checks below need sane numbers
+
+ // The pitch only matters when there is a next column or row to collide with.
+ if (Columns > 1 && HorizontalPitchMm < LabelWidthMm - ToleranceMm)
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Labels.OverlapAcross", null, Mm(HorizontalPitchMm), Mm(LabelWidthMm)));
+ if (Rows > 1 && VerticalPitchMm < LabelHeightMm - ToleranceMm)
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Labels.OverlapDown", null, Mm(VerticalPitchMm), Mm(LabelHeightMm)));
+ if (GridRightMm > PageWidthMm + ToleranceMm)
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Labels.TooWide", null, Mm(GridRightMm), Mm(PageWidthMm)));
+ if (GridBottomMm > PageHeightMm + ToleranceMm)
+ issues.Add(new ValidationIssue(ValidationSeverity.Error, "Sheet.Labels.TooTall", null, Mm(GridBottomMm), Mm(PageHeightMm)));
+ if (issues.Count == 0)
+ {
+ double closest = Math.Min(Math.Min(LeftMarginMm, TopMarginMm), Math.Min(PageWidthMm - GridRightMm, PageHeightMm - GridBottomMm));
+ if (closest < PrinterEdgeMm - ToleranceMm)
+ issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Sheet.Labels.NearEdge", null, Mm(PrinterEdgeMm)));
+ }
+ return issues;
+ }
+
+ /// True when reports no error.
+ public bool IsValid => Validate().All(i => i.Severity != ValidationSeverity.Error);
+
+ /// Checks a "start at position" number (1-based); null when it is on the sheet, otherwise the error Sheet.Start.Range.
+ public ValidationIssue? ValidateStart(int startPosition) =>
+ startPosition >= 1 && startPosition <= SlotCount
+ ? null
+ : new ValidationIssue(ValidationSeverity.Error, "Sheet.Start.Range", null, Num(Math.Max(1, SlotCount)));
+
+ ///
+ /// Compares the size of the label design with the size of the positions on the sheet. The design is always printed at its own physical size,
+ /// centred in each position, so a different size is a warning: a larger design is cut off at the edges of the position
+ /// (Sheet.Label.Larger), a smaller one leaves a white border (Sheet.Label.Smaller). Null when the sizes agree within half a millimetre.
+ ///
+ /// Width of the label design.
+ /// Height of the label design.
+ public ValidationIssue? CheckDesignSize(double designWidthMm, double designHeightMm)
+ {
+ const double slack = 0.5;
+ if (Math.Abs(designWidthMm - LabelWidthMm) <= slack && Math.Abs(designHeightMm - LabelHeightMm) <= slack) return null;
+ bool larger = designWidthMm > LabelWidthMm + slack || designHeightMm > LabelHeightMm + slack;
+ return larger
+ ? new ValidationIssue(ValidationSeverity.Warning, "Sheet.Label.Larger", null, Mm(designWidthMm), Mm(designHeightMm), Mm(LabelWidthMm), Mm(LabelHeightMm))
+ : new ValidationIssue(ValidationSeverity.Warning, "Sheet.Label.Smaller", null, Mm(designWidthMm), Mm(designHeightMm), Mm(LabelWidthMm), Mm(LabelHeightMm));
+ }
+
+ private static bool InRange(double v, double min, double max) => double.IsFinite(v) && v >= min - ToleranceMm && v <= max + ToleranceMm;
+
+ /// Millimetres as message text: invariant, at most two decimals (the UI shows the argument as it is).
+ internal static string Mm(double v) => Math.Round(v, 2).ToString("0.##", CultureInfo.InvariantCulture);
+
+ private static string Num(int v) => v.ToString(CultureInfo.InvariantCulture);
+}
diff --git a/src/Rotula.Core/Sheets/SheetSettings.cs b/src/Rotula.Core/Sheets/SheetSettings.cs
new file mode 100644
index 0000000..6362008
--- /dev/null
+++ b/src/Rotula.Core/Sheets/SheetSettings.cs
@@ -0,0 +1,21 @@
+namespace Rotula.Core.Sheets;
+
+///
+/// The sheet layout chosen for a label, as it is remembered in the .rotula file: the built-in template (or none for a custom layout), the
+/// geometry, the fill order and the guides. The "start at position" number is not part of it on purpose: it depends on the sheet that is in the
+/// printer today, not on the label.
+///
+public sealed record SheetSettings
+{
+ /// Id of the built-in template the layout came from (), or null for a layout the user typed.
+ public string? TemplateId { get; init; }
+
+ /// The geometry.
+ public SheetLayout Layout { get; init; } = SheetTemplates.All[0].Layout;
+
+ /// The fill order of the positions.
+ public SheetOrder Order { get; init; } = SheetOrder.AcrossThenDown;
+
+ /// Marks drawn on the sheet.
+ public SheetGuides Guides { get; init; } = SheetGuides.None;
+}
diff --git a/src/Rotula.Core/Sheets/SheetTemplates.cs b/src/Rotula.Core/Sheets/SheetTemplates.cs
new file mode 100644
index 0000000..2498f5a
--- /dev/null
+++ b/src/Rotula.Core/Sheets/SheetTemplates.cs
@@ -0,0 +1,68 @@
+using Rotula.Core.Stocks;
+
+namespace Rotula.Core.Sheets;
+
+/// A built-in sheet layout. It is described by its dimensions only; the UI builds its name from them.
+/// Stable id, stored in the .rotula file (for example letter-30).
+/// The geometry.
+/// True when the sheet is normally described in inches (US sizes), false for millimetres.
+public sealed record SheetTemplate(string Id, SheetLayout Layout, bool Inches);
+
+///
+/// A small set of common generic sheet layouts for office printers, given by their dimensions. These are the usual industry geometries of
+/// address, shipping and file labels; sheets of different suppliers with the same label size and count can still differ by a millimetre
+/// in their margins, so the operator prints one test page on plain paper and adjusts the margins when needed.
+///
+public static class SheetTemplates
+{
+ private const double In = SheetLayout.MmPerInch;
+
+ /// All built-in templates, Letter first, then A4.
+ public static IReadOnlyList All { get; } = new[]
+ {
+ // US Letter. Margins and gaps are the common published geometry of each format.
+ new SheetTemplate("letter-30", SheetLayout.Create(SheetPageKind.Letter, 3, 10, 2.625 * In, 1 * In, 0.5 * In, 0.1875 * In, 0.125 * In), true),
+ new SheetTemplate("letter-20", SheetLayout.Create(SheetPageKind.Letter, 2, 10, 4 * In, 1 * In, 0.5 * In, 0.15625 * In, 0.1875 * In), true),
+ new SheetTemplate("letter-14", SheetLayout.Create(SheetPageKind.Letter, 2, 7, 4 * In, 4.0 / 3 * In, 5.0 / 6 * In, 0.15625 * In, 0.1875 * In), true),
+ new SheetTemplate("letter-10", SheetLayout.Create(SheetPageKind.Letter, 2, 5, 4 * In, 2 * In, 0.5 * In, 0.15625 * In, 0.1875 * In), true),
+ new SheetTemplate("letter-6", SheetLayout.Create(SheetPageKind.Letter, 2, 3, 4 * In, 10.0 / 3 * In, 0.5 * In, 0.15625 * In, 0.1875 * In), true),
+ new SheetTemplate("letter-80", SheetLayout.Create(SheetPageKind.Letter, 4, 20, 1.75 * In, 0.5 * In, 0.5 * In, 0.28125 * In, 0.3125 * In), true),
+ new SheetTemplate("letter-2", SheetLayout.Create(SheetPageKind.Letter, 1, 2, 8.5 * In, 5.5 * In, 0, 0), true),
+ new SheetTemplate("letter-1", SheetLayout.Create(SheetPageKind.Letter, 1, 1, 8.5 * In, 11 * In, 0, 0), true),
+ // A4.
+ new SheetTemplate("a4-24", SheetLayout.Create(SheetPageKind.A4, 3, 8, 70, 37, 0.5, 0), false),
+ new SheetTemplate("a4-21", SheetLayout.Create(SheetPageKind.A4, 3, 7, 63.5, 38.1, 15.15, 7.2, 2.54), false),
+ new SheetTemplate("a4-65", SheetLayout.Create(SheetPageKind.A4, 5, 13, 38.1, 21.2, 10.7, 4.75, 2.5), false),
+ new SheetTemplate("a4-16", SheetLayout.Create(SheetPageKind.A4, 2, 8, 99.1, 33.9, 12.9, 4.65, 2.5), false),
+ new SheetTemplate("a4-14", SheetLayout.Create(SheetPageKind.A4, 2, 7, 99.1, 38.1, 15.15, 4.65, 2.5), false),
+ new SheetTemplate("a4-8", SheetLayout.Create(SheetPageKind.A4, 2, 4, 99.1, 67.7, 13.1, 4.65, 2.5), false),
+ new SheetTemplate("a4-2", SheetLayout.Create(SheetPageKind.A4, 1, 2, 210, 148.5, 0, 0), false),
+ new SheetTemplate("a4-1", SheetLayout.Create(SheetPageKind.A4, 1, 1, 210, 297, 0, 0), false),
+ };
+
+ /// The template with this id (ignoring case), or null.
+ public static SheetTemplate? Find(string? id) =>
+ id is null ? null : All.FirstOrDefault(t => string.Equals(t.Id, id, StringComparison.OrdinalIgnoreCase));
+
+ ///
+ /// The template that matches a sheet stock of the library: the same label size (within half a millimetre) and the same number of labels across
+ /// and down. Null when the stock is not a sheet, states no grid, or no template has its geometry. This is how a label sized from a sheet
+ /// stock opens the sheet dialog on the right layout without the UI naming any brand.
+ ///
+ public static SheetTemplate? FindForStock(StockEntry? stock)
+ {
+ if (stock is null || stock.Format != StockFormat.Sheet && stock.Layout != StockLayout.Sheet) return null;
+ if (stock.HeightMm is not { } h || stock.Columns * stock.Rows <= 1) return null;
+ return All.FirstOrDefault(t => t.Layout.Columns == stock.Columns && t.Layout.Rows == stock.Rows
+ && Math.Abs(t.Layout.LabelWidthMm - stock.WidthMm) <= 0.5 && Math.Abs(t.Layout.LabelHeightMm - h) <= 0.5);
+ }
+
+ ///
+ /// The template whose labels have the size of a label design (within half a millimetre), preferring the given paper. Null when none fits.
+ /// Used to suggest a layout for a design that was not made from a sheet stock.
+ ///
+ public static SheetTemplate? FindForLabelSize(double widthMm, double heightMm, SheetPageKind preferredPage = SheetPageKind.Letter) =>
+ All.Where(t => Math.Abs(t.Layout.LabelWidthMm - widthMm) <= 0.5 && Math.Abs(t.Layout.LabelHeightMm - heightMm) <= 0.5)
+ .OrderBy(t => t.Layout.PageKind == preferredPage ? 0 : 1)
+ .FirstOrDefault();
+}
diff --git a/src/Rotula.Core/Zpl/ZplGenerator.cs b/src/Rotula.Core/Zpl/ZplGenerator.cs
index ce431c0..33bcf2f 100644
--- a/src/Rotula.Core/Zpl/ZplGenerator.cs
+++ b/src/Rotula.Core/Zpl/ZplGenerator.cs
@@ -81,6 +81,17 @@ public static ZplGenerationResult GenerateWithVariables(LabelDocument doc, Varia
return GenerateCore(doc, resolution.Values, resolution, quantity, rasterizer);
}
+ ///
+ /// Generates ZPL from variables that were already resolved (), so a caller that also needs the values (to draw
+ /// the same label) resolves only once.
+ ///
+ internal static ZplGenerationResult GenerateResolved(LabelDocument doc, VariableResolution resolution, int? quantity = null, ITextRasterizer? rasterizer = null)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ ArgumentNullException.ThrowIfNull(resolution);
+ return GenerateCore(doc, resolution.Values, resolution, quantity, rasterizer);
+ }
+
private static ZplGenerationResult GenerateCore(LabelDocument doc, IReadOnlyDictionary? data, VariableResolution? resolution, int? quantity, ITextRasterizer? rasterizer)
{
ArgumentNullException.ThrowIfNull(doc);
diff --git a/src/Rotula.Files/RotulaFileDtos.cs b/src/Rotula.Files/RotulaFileDtos.cs
index 2974926..8ad9d6f 100644
--- a/src/Rotula.Files/RotulaFileDtos.cs
+++ b/src/Rotula.Files/RotulaFileDtos.cs
@@ -29,6 +29,10 @@ public sealed class RotulaFileDto
/// Data-merge settings (format version 2 and later). Absent in version 1 files.
[JsonPropertyOrder(4)]
public MergeDto? Merge { get; set; }
+
+ /// The sheet layout of the label sheets export (optional, no version change: older builds ignore it).
+ [JsonPropertyOrder(5)]
+ public SheetDto? Sheet { get; set; }
}
/// A length with its unit.
diff --git a/src/Rotula.Files/RotulaFileSerializer.cs b/src/Rotula.Files/RotulaFileSerializer.cs
index 83a029d..65f3efd 100644
--- a/src/Rotula.Files/RotulaFileSerializer.cs
+++ b/src/Rotula.Files/RotulaFileSerializer.cs
@@ -31,7 +31,8 @@ public RotulaFileException(string messageKey, Exception? inner = null, params st
/// A loaded .rotula file: the label plus the saved data-merge settings (null when the file has none).
/// The label.
/// Data-merge settings, or null.
-public sealed record RotulaFileContent(LabelDocument Document, DataMergeSettings? Merge);
+/// The sheet layout chosen for the label sheets export, or null when the file has none.
+public sealed record RotulaFileContent(LabelDocument Document, DataMergeSettings? Merge, Rotula.Core.Sheets.SheetSettings? Sheet = null);
///
/// Reads and writes the versioned .rotula JSON format and converts between the Core model and the file DTOs.
@@ -127,9 +128,11 @@ public static class RotulaFileSerializer
///
/// The label.
/// Data-merge settings to store, or null/empty for none.
- public static string Serialize(LabelDocument document, DataMergeSettings? merge = null)
+ /// The sheet layout to remember, or null for none. It does not change the format version (older builds ignore it).
+ public static string Serialize(LabelDocument document, DataMergeSettings? merge = null, Rotula.Core.Sheets.SheetSettings? sheet = null)
{
var dto = ToDto(document);
+ if (sheet is not null) dto.Sheet = SheetSettingsMapper.ToDto(sheet);
if (merge is { IsEmpty: false })
{
dto.Merge = MergeSettingsMapper.ToDto(merge);
@@ -210,7 +213,7 @@ public static RotulaFileContent DeserializeFile(string json)
try
{
var merge = dto.Merge is null ? null : MergeSettingsMapper.FromDto(dto.Merge);
- return new RotulaFileContent(FromDto(dto), merge);
+ return new RotulaFileContent(FromDto(dto), merge, SheetSettingsMapper.FromDto(dto.Sheet));
}
catch (Exception ex) when (ex is ArgumentException or FormatException or InvalidOperationException or OverflowException or NullReferenceException)
{
@@ -219,9 +222,9 @@ public static RotulaFileContent DeserializeFile(string json)
}
/// Writes a document to a file (UTF-8 without BOM). A temp file is written first so a failed save never destroys the previous version.
- public static void Save(LabelDocument document, string path, DataMergeSettings? merge = null)
+ public static void Save(LabelDocument document, string path, DataMergeSettings? merge = null, Rotula.Core.Sheets.SheetSettings? sheet = null)
{
- string json = Serialize(document, merge);
+ string json = Serialize(document, merge, sheet);
string? folder = Path.GetDirectoryName(Path.GetFullPath(path));
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
string temp = path + ".tmp";
diff --git a/src/Rotula.Files/SheetSettingsMapper.cs b/src/Rotula.Files/SheetSettingsMapper.cs
new file mode 100644
index 0000000..d92c47d
--- /dev/null
+++ b/src/Rotula.Files/SheetSettingsMapper.cs
@@ -0,0 +1,76 @@
+using Rotula.Core.Sheets;
+
+namespace Rotula.Files;
+
+///
+/// The sheet layout remembered with a label (the root sheet object). It is optional and only a preference for the "Label sheets"
+/// export, so it does not raise the format version: an older build ignores the unknown property and opens the label as before.
+///
+public sealed class SheetDto
+{
+ /// Id of the built-in template, or null for a layout the user typed.
+ public string? Template { get; set; }
+ /// Paper size name: Letter, A4, Legal or Custom.
+ public string Page { get; set; } = "Letter";
+ /// Paper width in millimetres.
+ public double PageWidthMm { get; set; }
+ /// Paper height in millimetres.
+ public double PageHeightMm { get; set; }
+ /// Top margin in millimetres.
+ public double TopMm { get; set; }
+ /// Left margin in millimetres.
+ public double LeftMm { get; set; }
+ /// Label width in millimetres.
+ public double LabelWidthMm { get; set; }
+ /// Label height in millimetres.
+ public double LabelHeightMm { get; set; }
+ /// Labels across.
+ public int Columns { get; set; } = 1;
+ /// Labels down.
+ public int Rows { get; set; } = 1;
+ /// Distance between the left edges of two columns, in millimetres.
+ public double HorizontalPitchMm { get; set; }
+ /// Distance between the top edges of two rows, in millimetres.
+ public double VerticalPitchMm { get; set; }
+ /// Fill order name; null is across then down.
+ public string? Order { get; set; }
+ /// Guides name; null is none.
+ public string? Guides { get; set; }
+}
+
+/// Converts to and from the file object. The file is untrusted: a section that makes no sense is dropped, never trusted.
+internal static class SheetSettingsMapper
+{
+ public static SheetDto ToDto(SheetSettings s) => new()
+ {
+ Template = s.TemplateId, Page = s.Layout.PageKind.ToString(), PageWidthMm = s.Layout.PageWidthMm, PageHeightMm = s.Layout.PageHeightMm,
+ TopMm = s.Layout.TopMarginMm, LeftMm = s.Layout.LeftMarginMm, LabelWidthMm = s.Layout.LabelWidthMm, LabelHeightMm = s.Layout.LabelHeightMm,
+ Columns = s.Layout.Columns, Rows = s.Layout.Rows, HorizontalPitchMm = s.Layout.HorizontalPitchMm, VerticalPitchMm = s.Layout.VerticalPitchMm,
+ Order = s.Order == SheetOrder.AcrossThenDown ? null : s.Order.ToString(),
+ Guides = s.Guides == SheetGuides.None ? null : s.Guides.ToString(),
+ };
+
+ /// The settings in the file, or null when the section is missing or its layout has errors (the dialog then starts from its defaults).
+ public static SheetSettings? FromDto(SheetDto? d)
+ {
+ if (d is null) return null;
+ var page = Enum.TryParse(d.Page, ignoreCase: true, out var p) && Enum.IsDefined(p) ? p : SheetPageKind.Custom;
+ var layout = new SheetLayout
+ {
+ PageKind = page, PageWidthMm = d.PageWidthMm, PageHeightMm = d.PageHeightMm, TopMarginMm = d.TopMm, LeftMarginMm = d.LeftMm,
+ LabelWidthMm = d.LabelWidthMm, LabelHeightMm = d.LabelHeightMm, Columns = d.Columns, Rows = d.Rows,
+ HorizontalPitchMm = d.HorizontalPitchMm, VerticalPitchMm = d.VerticalPitchMm,
+ };
+ // A named paper keeps its real size even when the numbers in the file were edited by hand.
+ if (page != SheetPageKind.Custom) layout = layout.WithPage(page);
+ if (!layout.IsValid) return null;
+ string? template = d.Template is { Length: > 0 and <= 64 } t && SheetTemplates.Find(t) is { } found ? found.Id : null;
+ return new SheetSettings
+ {
+ TemplateId = template,
+ Layout = layout,
+ Order = Enum.TryParse(d.Order, ignoreCase: true, out var o) && Enum.IsDefined(o) ? o : SheetOrder.AcrossThenDown,
+ Guides = Enum.TryParse(d.Guides, ignoreCase: true, out var g) && Enum.IsDefined(g) ? g : SheetGuides.None,
+ };
+ }
+}
diff --git a/src/Rotula.Printing/LabelPrintRun.cs b/src/Rotula.Printing/LabelPrintRun.cs
index 61f9d4a..910795d 100644
--- a/src/Rotula.Printing/LabelPrintRun.cs
+++ b/src/Rotula.Printing/LabelPrintRun.cs
@@ -196,6 +196,43 @@ public IEnumerable Blocks()
if (inBlock > 0) yield return new PrintBlock(sb.ToString(), inBlock);
}
+ ///
+ /// The values that fill every label of the run, in order and copies included ( items), for an output that draws the label
+ /// instead of sending ZPL, such as label sheets saved as PDF. They are resolved exactly as resolves them, so copy i carries
+ /// the serial number the ZPL of copy i would carry; a copy that cannot be resolved ends the sequence (). Confirm
+ /// the labels that were written with , as after sending blocks. Enumerate either this or , once.
+ ///
+ /// The dictionaries are already resolved: draw them without resolving the variables again, or a counter would show its preview value.
+ public IEnumerable?> LabelValues()
+ {
+ if (!CanPrint) yield break;
+ if (_job is null)
+ {
+ // No definitions: every copy is the same record.
+ for (long i = 0; i < LabelCount; i++) yield return _data;
+ yield break;
+ }
+ if (!_expanded)
+ {
+ // No counter (or one copy): the copies are the same label, as ^PQ would print them.
+ var values = _job.Resolve(0, _data).Values;
+ for (long i = 0; i < LabelCount; i++) yield return values;
+ yield break;
+ }
+ for (long i = 0; i < LabelCount; i++)
+ {
+ var resolution = _job.Resolve(i, _data);
+ var errors = resolution.Issues.Where(x => x.Severity == ValidationSeverity.Error).ToList();
+ if (errors.Count > 0)
+ {
+ _issues.AddRange(errors);
+ StoppedEarly = true;
+ yield break;
+ }
+ yield return resolution.Values;
+ }
+ }
+
///
/// Confirms how many labels (counted from the first) may have been printed and releases the serial numbers that were not used.
/// Safe to call more than once; only the first call counts. Returns problems met while writing the counter file.
diff --git a/src/Rotula.Rendering/Export/SheetExporter.cs b/src/Rotula.Rendering/Export/SheetExporter.cs
new file mode 100644
index 0000000..8721954
--- /dev/null
+++ b/src/Rotula.Rendering/Export/SheetExporter.cs
@@ -0,0 +1,202 @@
+using Rotula.Core.Sheets;
+using Rotula.Rendering.Input;
+using SkiaSharp;
+
+namespace Rotula.Rendering.Export;
+
+/// How a run of labels is put on sheets.
+public sealed class SheetExportOptions
+{
+ /// 1-based position on the first sheet where the first label goes (earlier positions are already used).
+ public int StartPosition { get; init; } = 1;
+
+ /// The fill order of the positions.
+ public SheetOrder Order { get; init; } = SheetOrder.AcrossThenDown;
+
+ /// Crop marks or outlines drawn on every sheet.
+ public SheetGuides Guides { get; init; } = SheetGuides.None;
+}
+
+/// What a sheet export wrote.
+/// Number of sheets (PDF pages).
+/// Number of labels placed.
+/// Render notes, each once (for example a barcode that could not be drawn).
+public sealed record SheetExportResult(int Pages, long Labels, IReadOnlyList Issues);
+
+///
+/// Writes labels onto sheets for an office (laser or inkjet) printer as a multi-page PDF: one page per sheet, at the sheet's real paper size,
+/// every label at its own physical size (dots divided by its resolution), centred in its position and clipped to it so it never runs into a
+/// neighbour. Text, boxes, lines and barcode modules stay vectors, as in . Print the
+/// PDF at "actual size" (100 %, no "fit to page") or the labels will not line up.
+///
+public static class SheetExporter
+{
+ private const double PointsPerMm = 72.0 / 25.4;
+
+ ///
+ /// Resolution of the parts the PDF cannot keep as vectors (a reversed field). The PDF writer also rounds the page size to whole pixels at this
+ /// resolution, so it must be fine: at 600 dpi US paper is exact and A4 is within 0.02 mm, and no label printer has more dots.
+ ///
+ private const float RasterDpi = 600;
+
+ /// Length of a crop mark and its distance from the label edge.
+ private const double CropMarkMm = 5, CropGapMm = 1;
+
+ ///
+ /// Writes the labels as a PDF. The labels are pulled lazily one sheet at a time; pass the same instance for copies
+ /// to draw them without converting the design again. Nothing is written for an empty sequence (the result says 0 pages).
+ ///
+ /// The sheet; it must be valid ().
+ /// The labels in print order.
+ /// Where the PDF goes.
+ /// Start position, order and guides; null for the defaults.
+ /// The layout has an error or the start position is not on the sheet.
+ public static SheetExportResult SavePdf(SheetLayout layout, IEnumerable labels, Stream output, SheetExportOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(layout);
+ ArgumentNullException.ThrowIfNull(labels);
+ ArgumentNullException.ThrowIfNull(output);
+ options ??= new SheetExportOptions();
+ if (!layout.IsValid) throw new ArgumentException("The sheet layout has errors.", nameof(layout));
+
+ var issues = new List();
+ var seen = new HashSet(StringComparer.Ordinal);
+ int pages = 0;
+ long placed = 0;
+ SKManagedWStream? stream = null;
+ SKDocument? document = null;
+ try
+ {
+ foreach (var page in SheetImposition.Paginate(layout, labels, options.StartPosition, options.Order))
+ {
+ if (document is null)
+ {
+ // Created on the first sheet only, so an empty run writes no half-made file.
+ stream = new SKManagedWStream(output, false);
+ document = SKDocument.CreatePdf(stream, new SKDocumentPdfMetadata { RasterDpi = RasterDpi });
+ }
+ var canvas = document.BeginPage(Pt(layout.PageWidthMm), Pt(layout.PageHeightMm));
+ canvas.Clear(SKColors.White);
+ foreach (var placement in page.Placements)
+ {
+ foreach (var issue in DrawLabel(canvas, placement.Slot, placement.Item))
+ if (seen.Add(issue.Message)) issues.Add(issue);
+ placed++;
+ }
+ DrawGuides(canvas, layout, page, options.Guides);
+ document.EndPage();
+ pages++;
+ }
+ document?.Close();
+ }
+ finally
+ {
+ document?.Dispose();
+ stream?.Dispose();
+ }
+ return new SheetExportResult(pages, placed, issues);
+ }
+
+ /// Writes the labels as a PDF file through a temporary file, so a failed export never leaves a broken file behind.
+ public static SheetExportResult SavePdf(SheetLayout layout, IEnumerable labels, string path, SheetExportOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(path);
+ string full = Path.GetFullPath(path);
+ string? folder = Path.GetDirectoryName(full);
+ if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
+ string temp = full + ".tmp";
+ try
+ {
+ SheetExportResult result;
+ using (var file = File.Create(temp)) result = SavePdf(layout, labels, file, options);
+ if (result.Pages > 0) File.Move(temp, full, overwrite: true);
+ return result;
+ }
+ finally
+ {
+ if (File.Exists(temp)) File.Delete(temp);
+ }
+ }
+
+ /// The position of a slot and the size of a label in PDF points, as the exporter places it (exposed for tests and previews).
+ /// The slot rectangle and the rectangle the label is drawn in (centred in the slot, at the label's physical size).
+ public static (SKRect Slot, SKRect Label) Placement(SheetSlot slot, RenderLabel label)
+ {
+ ArgumentNullException.ThrowIfNull(label);
+ var box = SKRect.Create(Pt(slot.XMm), Pt(slot.YMm), Pt(slot.WidthMm), Pt(slot.HeightMm));
+ // One dot is 1/dpi inch; one point is 1/72 inch.
+ float w = (float)(label.WidthDots * 72.0 / label.Dpi), h = (float)(label.HeightDots * 72.0 / label.Dpi);
+ var drawn = SKRect.Create(box.MidX - w / 2, box.MidY - h / 2, w, h);
+ return (box, drawn);
+ }
+
+ private static IReadOnlyList DrawLabel(SKCanvas canvas, SheetSlot slot, RenderLabel label)
+ {
+ var (box, drawn) = Placement(slot, label);
+ canvas.Save();
+ try
+ {
+ // A design larger than its position is cut at the position's edge rather than printed onto the next label.
+ canvas.ClipRect(box);
+ canvas.Translate(drawn.Left, drawn.Top);
+ canvas.Scale((float)(72.0 / label.Dpi));
+ return LabelRenderer.Draw(canvas, label);
+ }
+ finally
+ {
+ canvas.Restore();
+ }
+ }
+
+ private static void DrawGuides(SKCanvas canvas, SheetLayout layout, SheetPage page, SheetGuides guides)
+ {
+ if (guides == SheetGuides.None) return;
+ using var paint = new SKPaint { IsAntialias = true, Style = SKPaintStyle.Stroke };
+ if (guides == SheetGuides.Outlines)
+ {
+ paint.Color = new SKColor(0x99, 0x99, 0x99);
+ paint.StrokeWidth = 0.5f;
+ foreach (var p in page.Placements)
+ canvas.DrawRect(SKRect.Create(Pt(p.Slot.XMm), Pt(p.Slot.YMm), Pt(p.Slot.WidthMm), Pt(p.Slot.HeightMm)), paint);
+ return;
+ }
+
+ // Crop marks sit in the paper margins only, so they never print onto a label; a margin too small for a mark gets none.
+ paint.Color = SKColors.Black;
+ paint.StrokeWidth = 0.35f;
+ var xs = new SortedSet();
+ var ys = new SortedSet();
+ for (int c = 0; c < layout.Columns; c++)
+ {
+ double left = layout.LeftMarginMm + c * layout.HorizontalPitchMm;
+ xs.Add(Math.Round(left, 3));
+ xs.Add(Math.Round(left + layout.LabelWidthMm, 3));
+ }
+ for (int r = 0; r < layout.Rows; r++)
+ {
+ double top = layout.TopMarginMm + r * layout.VerticalPitchMm;
+ ys.Add(Math.Round(top, 3));
+ ys.Add(Math.Round(top + layout.LabelHeightMm, 3));
+ }
+ double top0 = layout.TopMarginMm, bottom = layout.GridBottomMm, left0 = layout.LeftMarginMm, right = layout.GridRightMm;
+ foreach (double x in xs)
+ {
+ Mark(canvas, paint, x, top0 - CropGapMm, x, top0 - CropGapMm - Math.Min(CropMarkMm, top0 - CropGapMm), top0 - CropGapMm);
+ Mark(canvas, paint, x, bottom + CropGapMm, x, bottom + CropGapMm + Math.Min(CropMarkMm, layout.PageHeightMm - bottom - CropGapMm), layout.PageHeightMm - bottom - CropGapMm);
+ }
+ foreach (double y in ys)
+ {
+ Mark(canvas, paint, left0 - CropGapMm, y, left0 - CropGapMm - Math.Min(CropMarkMm, left0 - CropGapMm), y, left0 - CropGapMm);
+ Mark(canvas, paint, right + CropGapMm, y, right + CropGapMm + Math.Min(CropMarkMm, layout.PageWidthMm - right - CropGapMm), y, layout.PageWidthMm - right - CropGapMm);
+ }
+ }
+
+ private static void Mark(SKCanvas canvas, SKPaint paint, double x1, double y1, double x2, double y2, double room)
+ {
+ // Less than a millimetre and a half of room is not a mark anyone can use.
+ if (room < 1.5) return;
+ canvas.DrawLine(Pt(x1), Pt(y1), Pt(x2), Pt(y2), paint);
+ }
+
+ private static float Pt(double mm) => (float)(mm * PointsPerMm);
+}
diff --git a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
index 01d9a15..9da7a18 100644
--- a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
+++ b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
@@ -31,7 +31,24 @@ public static class LabelDocumentAdapter
public static RenderLabel ToRenderLabel(LabelDocument doc, IReadOnlyDictionary? data = null, bool includeNonPrinting = false)
{
ArgumentNullException.ThrowIfNull(doc);
- data = ResolvePreviewData(doc, data);
+ return Build(doc, ResolvePreviewData(doc, data), includeNonPrinting);
+ }
+
+ ///
+ /// Converts a label document into render input with values that were already resolved for one label of a run (a print run's or a merge batch's
+ /// values, see LabelPrintRun.LabelValues and MergedLabel.Values). Unlike the variables are not
+ /// resolved again, so every label keeps its own serial number instead of the preview's "next number". Objects that are not printed are left out.
+ ///
+ /// The design.
+ /// The resolved values of this label, or null for the sample values of a label without definitions.
+ public static RenderLabel ToRenderLabelResolved(LabelDocument doc, IReadOnlyDictionary? values)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ return Build(doc, values, includeNonPrinting: false);
+ }
+
+ private static RenderLabel Build(LabelDocument doc, IReadOnlyDictionary? data, bool includeNonPrinting)
+ {
int dpi = (int)doc.Dpi;
int homeX = doc.Margins.Left.ToDots(doc.Dpi);
int homeY = doc.Margins.Top.ToDots(doc.Dpi);
diff --git a/tests/Rotula.Tests/AppLocalizationTests.cs b/tests/Rotula.Tests/AppLocalizationTests.cs
index 0619df0..6621165 100644
--- a/tests/Rotula.Tests/AppLocalizationTests.cs
+++ b/tests/Rotula.Tests/AppLocalizationTests.cs
@@ -231,6 +231,7 @@ void Check() where T : struct, Enum
Check(); Check(); Check();
Check(); Check();
Check(); Check();
+ Check(); Check(); Check();
Assert.Empty(UiStrings.MissingInAnyLanguage(keys));
}
diff --git a/tests/Rotula.Tests/AppSheetLayoutTests.cs b/tests/Rotula.Tests/AppSheetLayoutTests.cs
new file mode 100644
index 0000000..7d0acad
--- /dev/null
+++ b/tests/Rotula.Tests/AppSheetLayoutTests.cs
@@ -0,0 +1,255 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using Rotula.App.Localization;
+using Rotula.App.Services;
+using Rotula.App.ViewModels;
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+using Rotula.Core.Sheets;
+using Rotula.Core.Variables.Counters;
+using Rotula.Core.Variables.Prompts;
+using Rotula.Files;
+
+namespace Rotula.Tests;
+
+/// The "Label sheets" dialog: choosing a sheet, the start position, the PDF and what is remembered with the label.
+[Collection("App")]
+public class AppSheetLayoutTests : IDisposable
+{
+ private readonly FakeServices _fake = new();
+ private readonly string _dir = AppTestSupport.TempDir();
+
+ public AppSheetLayoutTests()
+ {
+ _fake.Settings.Current.Language = "en";
+ Loc.Instance.SetLanguage("en");
+ }
+
+ public void Dispose()
+ {
+ Loc.Instance.SetLanguage("en");
+ Directory.Delete(_dir, true);
+ }
+
+ private sealed class SerialVariables(MemoryCounterStore store) : IVariableServices
+ {
+ public ICounterStore? CounterStore => store;
+ public IPromptMemory PromptMemory { get; } = new MemoryPromptMemory();
+ public IReadOnlyDictionary? Ask(PromptSet set) => new Dictionary();
+ }
+
+ /// A main window with a new label of the given size (in inches) opened, and the sheet dialog shown for it.
+ private (MainViewModel Main, SheetLayoutViewModel Sheet) Open(double widthIn, double heightIn, Action? design = null)
+ {
+ var doc = new LabelDocument { Width = Measure.Inches(widthIn), Height = Measure.Inches(heightIn) };
+ doc.EnsureId();
+ doc.Elements.Add(new TextElement { Id = "t", X = 10, Y = 10, Text = "Hello" });
+ design?.Invoke(doc);
+ _fake.NewLabel = doc;
+ var main = new MainViewModel(_fake.Build());
+ main.NewCommand.Execute(null);
+ Assert.True(main.ShowSheetsCommand.CanExecute(null));
+ main.ShowSheetsCommand.Execute(null);
+ return (main, _fake.ToolDialogs.LastSheet!);
+ }
+
+ private static int PdfPages(string path) =>
+ Regex.Matches(Encoding.Latin1.GetString(File.ReadAllBytes(path)), @"/Type /Page\b").Count;
+
+ [Fact]
+ public void TheDialog_StartsOnTheTemplateWithTheLabelsSize_InInches()
+ {
+ var (_, sheet) = Open(2.625, 1);
+ Assert.Equal("letter-30", sheet.SelectedTemplate.Template?.Id);
+ Assert.True(sheet.UseInches);
+ Assert.Equal("2.625", sheet.LabelWidthText);
+ Assert.Equal("3", sheet.ColumnsText);
+ Assert.Equal(30, sheet.Slots.Count);
+ Assert.False(sheet.HasErrors);
+ Assert.Contains("Labels: 1. Sheets: 1 (30 per sheet).", sheet.SummaryText);
+ // Names come from the dimensions only.
+ Assert.All(sheet.Templates.Skip(1), t => Assert.Matches(@"^(Letter|A4)", t.Name));
+ Assert.DoesNotContain(sheet.Templates, t => t.Name.Contains("Avery", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public void EditingANumber_MakesTheLayoutCustom_AndAProblemBlocksTheExport()
+ {
+ var (_, sheet) = Open(2.625, 1);
+ sheet.TopText = "0.6";
+ Assert.True(sheet.IsCustom);
+ Assert.False(sheet.HasErrors);
+ Assert.Equal(0.6 * 25.4, sheet.Settings!.Layout.TopMarginMm, 3);
+
+ sheet.ColumnsText = "4";
+ Assert.True(sheet.HasErrors);
+ Assert.False(sheet.CanExport);
+ Assert.Contains("do not fit across the paper", sheet.ProblemsText);
+ Assert.Null(sheet.Settings);
+
+ sheet.ColumnsText = "x";
+ Assert.Contains("Labels across: type a number.", sheet.ProblemsText);
+ }
+
+ [Fact]
+ public void ChoosingATemplate_FillsTheNumbers_AndMillimetresForA4()
+ {
+ var (_, sheet) = Open(2.625, 1);
+ sheet.SelectedTemplate = sheet.Templates.First(t => t.Template?.Id == "a4-21");
+ Assert.False(sheet.UseInches);
+ Assert.Equal("63.5", sheet.LabelWidthText);
+ Assert.Equal("15.15", sheet.TopText);
+ Assert.Equal(SheetPageKind.A4, sheet.SelectedPage.Value);
+ Assert.False(sheet.IsCustom);
+ Assert.Equal(21, sheet.Slots.Count);
+ // The design is larger than these labels, which is a note, not an error.
+ Assert.False(sheet.HasErrors);
+ Assert.Contains("larger than", sheet.ProblemsText);
+
+ // Switching the unit shows the same layout in inches.
+ sheet.UseInches = true;
+ Assert.Equal("2.5", sheet.LabelWidthText);
+ Assert.False(sheet.IsCustom);
+ }
+
+ [Fact]
+ public void ClickingAPosition_MakesItTheStart_AndThePreviewShowsTheUsedOnes()
+ {
+ var (_, sheet) = Open(4, 2);
+ Assert.Equal("letter-10", sheet.SelectedTemplate.Template?.Id);
+ sheet.CopiesText = "8";
+ sheet.PickStartCommand.Execute(sheet.Slots[4]);
+ Assert.Equal("5", sheet.StartPositionText);
+ Assert.All(sheet.Slots.Take(4), s => Assert.True(s.IsUsed));
+ Assert.Equal("1", sheet.Slots[4].Caption);
+ Assert.Equal("6", sheet.Slots[9].Caption);
+ Assert.Contains("Sheets: 2", sheet.SummaryText);
+
+ sheet.StartPositionText = "11";
+ Assert.True(sheet.HasErrors);
+ Assert.Contains("from 1 to 10", sheet.ProblemsText);
+ }
+
+ [Fact]
+ public async Task Export_WritesThePdf_AndEveryCopyGetsItsOwnSerial_UsedOnlyOnce()
+ {
+ var store = new MemoryCounterStore();
+ _fake.Variables = new SerialVariables(store);
+ var (main, sheet) = Open(4, 2, d =>
+ {
+ d.Variables.Add(Vars.Counter("serial", c => { c.Remember = true; c.Padding = 4; c.Prefix = "SN"; }));
+ d.Elements.Add(new TextElement { Id = "sn", X = 10, Y = 60, Text = "{{serial}}" });
+ });
+ _fake.ExportPath = Path.Combine(_dir, "sheets.pdf");
+ sheet.CopiesText = "12";
+ sheet.StartPositionText = "5";
+ await sheet.ExportPdfCommand.ExecuteAsync(null);
+
+ Assert.False(sheet.ResultIsError, sheet.ResultText);
+ Assert.Contains("labels: 12, sheets: 2", sheet.ResultText);
+ Assert.Equal(2, PdfPages(_fake.ExportPath));
+ Assert.Equal("SN0012", CounterAdmin.Peek(main.Session.Document, "serial", store).LastValue);
+ // The layout is remembered with the label, which now has a change to save.
+ Assert.Equal("letter-10", main.LabelSheet?.TemplateId);
+ Assert.True(main.Session.IsDirty);
+ }
+
+ [Fact]
+ public async Task Export_WithData_WritesTheTickedRecordsWithTheirCopies()
+ {
+ var (main, sheet) = Open(4, 2, d => d.Elements.Add(new TextElement { Id = "n", X = 10, Y = 60, Text = "Item {{n}}" }));
+ string csv = Path.Combine(_dir, "data.csv");
+ File.WriteAllText(csv, "n\n1\n2\n3\n4\n5\n6\n", new UTF8Encoding(false));
+ Assert.True(await main.Data.LoadAsync(csv));
+ main.Data.SelectNoneShownCommand.Execute(null);
+ foreach (int i in new[] { 0, 2, 4 }) main.Data.VisibleRows[i].IsSelected = true;
+
+ main.ShowSheetsCommand.Execute(null);
+ sheet = _fake.ToolDialogs.LastSheet!;
+ Assert.True(sheet.UsesRecords);
+ sheet.CopiesText = "4"; // 3 records x 4 copies = 12 labels on a 10-up sheet
+ Assert.Contains("Labels: 12. Sheets: 2", sheet.SummaryText);
+ _fake.ExportPath = Path.Combine(_dir, "records.pdf");
+ await sheet.ExportPdfCommand.ExecuteAsync(null);
+
+ Assert.False(sheet.ResultIsError, sheet.ResultText);
+ Assert.Contains("labels: 12, sheets: 2", sheet.ResultText);
+ Assert.Equal(2, PdfPages(_fake.ExportPath));
+ }
+
+ [Fact]
+ public async Task Export_WhenNothingIsTicked_IsBlocked()
+ {
+ var (main, _) = Open(4, 2);
+ string csv = Path.Combine(_dir, "data.csv");
+ File.WriteAllText(csv, "n\n1\n2\n", new UTF8Encoding(false));
+ Assert.True(await main.Data.LoadAsync(csv));
+ main.Data.SelectNoneShownCommand.Execute(null);
+ main.ShowSheetsCommand.Execute(null);
+ var sheet = _fake.ToolDialogs.LastSheet!;
+ Assert.True(sheet.HasErrors);
+ _fake.ExportPath = Path.Combine(_dir, "none.pdf");
+ await sheet.ExportPdfCommand.ExecuteAsync(null);
+ Assert.True(sheet.ResultIsError);
+ Assert.False(File.Exists(_fake.ExportPath));
+ }
+
+ [Fact]
+ public void TheLayout_IsSavedWithTheLabel_AndComesBackWhenItIsOpened()
+ {
+ var (main, sheet) = Open(2.625, 1);
+ sheet.SelectedGuides = sheet.GuideChoices.First(g => g.Value == SheetGuides.CropMarks);
+ sheet.LeftText = "0.2";
+ sheet.CloseCommand.Execute(null);
+ Assert.NotNull(main.LabelSheet);
+ Assert.Null(main.LabelSheet!.TemplateId);
+ Assert.True(main.Session.IsDirty);
+
+ _fake.SavePath = Path.Combine(_dir, "sheet.rotula");
+ main.SaveCommand.Execute(null);
+ Assert.Equal(SheetGuides.CropMarks, RotulaFileSerializer.LoadFile(_fake.SavePath).Sheet?.Guides);
+
+ var reopened = new MainViewModel(_fake.Build());
+ Assert.True(reopened.LoadFile(_fake.SavePath));
+ Assert.Equal(main.LabelSheet, reopened.LabelSheet);
+ reopened.ShowSheetsCommand.Execute(null);
+ var again = _fake.ToolDialogs.LastSheet!;
+ Assert.Equal("0.2", again.LeftText);
+ Assert.Equal(SheetGuides.CropMarks, again.SelectedGuides.Value);
+ Assert.True(again.IsCustom);
+ }
+
+ [Fact]
+ public void ThePrintDialog_OffersLabelSheets()
+ {
+ var (main, _) = Open(4, 2);
+ _fake.ToolDialogs.LastSheet = null;
+ main.PrintCommand.Execute(null);
+ var print = _fake.ToolDialogs.LastPrint!;
+ Assert.True(print.CanShowSheets);
+ print.ShowSheetsCommand.Execute(null);
+ Assert.NotNull(_fake.ToolDialogs.LastSheet);
+ print.Detach();
+ }
+
+ [Fact]
+ public void SuggestSheet_UsesTheLabelsSheetStock()
+ {
+ var doc = new LabelDocument { Width = Measure.Millimeters(63.5), Height = Measure.Millimeters(38.1), StockId = "avery-L7160" };
+ Assert.Equal("a4-21", MainViewModel.SuggestSheet(doc).TemplateId);
+ var odd = new LabelDocument { Width = Measure.Millimeters(12), Height = Measure.Millimeters(12) };
+ Assert.Equal(SheetTemplates.All[0].Id, MainViewModel.SuggestSheet(odd).TemplateId);
+ }
+
+ [Fact]
+ public void TheDialog_ReadsWellInSpanish()
+ {
+ // The main window applies the saved language when it opens, so the setting (not only Loc) must say Spanish.
+ _fake.Settings.Current.Language = "es";
+ Loc.Instance.SetLanguage("es");
+ var (_, sheet) = Open(2.625, 1);
+ Assert.StartsWith("Carta", sheet.SelectedTemplate.Name);
+ Assert.Contains("por hoja", sheet.SelectedTemplate.Name);
+ Assert.Contains("Etiquetas: 1. Hojas: 1", sheet.SummaryText);
+ }
+}
diff --git a/tests/Rotula.Tests/AppTestSupport.cs b/tests/Rotula.Tests/AppTestSupport.cs
index ff50c6d..b494fbf 100644
--- a/tests/Rotula.Tests/AppTestSupport.cs
+++ b/tests/Rotula.Tests/AppTestSupport.cs
@@ -200,6 +200,8 @@ internal sealed class FakeToolDialogs : IToolDialogService
public Rotula.App.ViewModels.DataMergeViewModel? LastData;
public void ShowPrintDialog(Rotula.App.ViewModels.PrintViewModel viewModel) => LastPrint = viewModel;
public void ShowDataWindow(Rotula.App.ViewModels.DataMergeViewModel viewModel) => LastData = viewModel;
+ public Rotula.App.ViewModels.SheetLayoutViewModel? LastSheet;
+ public void ShowSheetDialog(Rotula.App.ViewModels.SheetLayoutViewModel viewModel) => LastSheet = viewModel;
public readonly List Managers = new();
/// What the "user" does inside the manager before it is closed (the fake dialog returns immediately).
public Func? OnManager;
diff --git a/tests/Rotula.Tests/SheetLayoutTests.cs b/tests/Rotula.Tests/SheetLayoutTests.cs
new file mode 100644
index 0000000..1c5b526
--- /dev/null
+++ b/tests/Rotula.Tests/SheetLayoutTests.cs
@@ -0,0 +1,524 @@
+using System.Text;
+using System.Text.RegularExpressions;
+using Rotula.Core.Elements;
+using Rotula.Core.Merge;
+using Rotula.Core.Model;
+using Rotula.Core.Sheets;
+using Rotula.Core.Stocks;
+using Rotula.Core.Validation;
+using Rotula.Core.Variables.Counters;
+using Rotula.Files;
+using Rotula.Printing;
+using Rotula.Rendering.Export;
+using Rotula.Rendering.Input;
+
+namespace Rotula.Tests;
+
+/// Sheet layouts for office printers: the geometry, its checks, the built-in templates and putting labels on sheets.
+public class SheetLayoutTests
+{
+ private const double In = 25.4;
+ private const double MmToPt = 72.0 / 25.4;
+
+ private static SheetLayout Template(string id) => SheetTemplates.Find(id)!.Layout;
+
+ private static void Near(double expected, double actual, double tolerance = 0.001) =>
+ Assert.True(Math.Abs(expected - actual) <= tolerance, $"expected {expected}, got {actual}");
+
+ // ------------------------------------------------------------------------------------ positions
+
+ [Fact]
+ public void Letter30_PositionsFollowTheSheetGeometry_InMillimetresAndPoints()
+ {
+ var layout = Template("letter-30");
+ Assert.Equal(30, layout.SlotCount);
+ Assert.Equal(SheetPageKind.Letter, layout.PageKind);
+ Near(215.9, layout.PageWidthMm);
+ Near(279.4, layout.PageHeightMm);
+
+ var first = layout.Slot(0);
+ Near(0.1875 * In, first.XMm);
+ Near(0.5 * In, first.YMm);
+ Near(13.5, first.XMm * MmToPt); // 3/16 in = 13.5 pt
+ Near(36, first.YMm * MmToPt); // 1/2 in = 36 pt
+ Near(2.625 * In, first.WidthMm);
+ Near(1 * In, first.HeightMm);
+
+ // Next column: one pitch (2-5/8 in label + 1/8 in gap = 2-3/4 in) to the right.
+ Near((0.1875 + 2.75) * In, layout.Slot(1).XMm);
+ Assert.Equal(1, layout.Slot(1).Column);
+ // Position 4 starts the second row.
+ var fourth = layout.Slot(3);
+ Assert.Equal((0, 1), (fourth.Column, fourth.Row));
+ Near(1.5 * In, fourth.YMm);
+ var last = layout.Slot(29);
+ Assert.Equal(30, last.Position);
+ Near((0.1875 + 5.5) * In, last.XMm);
+ Near(9.5 * In, last.YMm);
+ }
+
+ [Fact]
+ public void A4_24_And_A4_21_PositionsAreExact()
+ {
+ var a24 = Template("a4-24");
+ Assert.Equal((3, 8), (a24.Columns, a24.Rows));
+ var last = a24.Slot(23);
+ Near(140, last.XMm);
+ Near(0.5 + 7 * 37, last.YMm);
+ Near(70, last.WidthMm);
+ Near(37, last.HeightMm);
+
+ var a21 = Template("a4-21");
+ var s = a21.Slot(20);
+ Assert.Equal((2, 6), (s.Column, s.Row));
+ Near(7.2 + 2 * 66.04, s.XMm);
+ Near(15.15 + 6 * 38.1, s.YMm);
+ Near(2.54, a21.HorizontalGapMm);
+ Near(0, a21.VerticalGapMm);
+ }
+
+ [Fact]
+ public void DownThenAcross_FillsAColumnFirst()
+ {
+ var layout = Template("letter-30");
+ var second = layout.Slot(1, SheetOrder.DownThenAcross);
+ Assert.Equal((0, 1), (second.Column, second.Row));
+ var eleventh = layout.Slot(10, SheetOrder.DownThenAcross);
+ Assert.Equal((1, 0), (eleventh.Column, eleventh.Row));
+ Assert.Equal(30, layout.Slots(SheetOrder.DownThenAcross).Select(x => (x.Column, x.Row)).Distinct().Count());
+ Assert.Throws(() => layout.Slot(30));
+ }
+
+ [Fact]
+ public void Centered_PutsTheGridInTheMiddleOfThePaper()
+ {
+ var layout = SheetLayout.Centered(SheetPageKind.A4, 3, 7, 63.5, 38.1, 2.54);
+ Near(7.2, layout.LeftMarginMm, 0.01);
+ Near(15.15, layout.TopMarginMm, 0.01);
+ Near(layout.LeftMarginMm, layout.PageWidthMm - layout.GridRightMm, 0.01);
+ }
+
+ [Fact]
+ public void WithPage_KeepsTheGridAndTakesTheNamedSize()
+ {
+ var legal = Template("letter-30").WithPage(SheetPageKind.Legal);
+ Assert.Equal(SheetPageKind.Legal, legal.PageKind);
+ Near(355.6, legal.PageHeightMm);
+ Assert.Equal(30, legal.SlotCount);
+ Assert.Equal(SheetPageKind.Custom, legal.WithPage(SheetPageKind.Custom).PageKind);
+ }
+
+ // ------------------------------------------------------------------------------------ templates
+
+ [Fact]
+ public void EveryTemplate_IsValid_AndHasAUniqueId()
+ {
+ Assert.True(SheetTemplates.All.Count >= 10);
+ Assert.Equal(SheetTemplates.All.Count, SheetTemplates.All.Select(t => t.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count());
+ foreach (var t in SheetTemplates.All)
+ Assert.True(t.Layout.Validate().All(i => i.Severity != ValidationSeverity.Error), t.Id + ": " + string.Join(", ", t.Layout.Validate()));
+ // The common formats are there.
+ foreach (var (id, count) in new[] { ("letter-30", 30), ("letter-10", 10), ("letter-2", 2), ("a4-24", 24), ("a4-21", 21) })
+ Assert.Equal(count, Template(id).SlotCount);
+ Assert.Same(SheetTemplates.All[0], SheetTemplates.Find("LETTER-30"));
+ Assert.Null(SheetTemplates.Find("nope"));
+ }
+
+ [Fact]
+ public void SheetStocksOfTheLibrary_FindTheirTemplate()
+ {
+ var catalog = StockCatalog.LoadBundled();
+ Assert.Equal("letter-30", SheetTemplates.FindForStock(catalog.Find("avery-5160"))?.Id);
+ Assert.Equal("letter-10", SheetTemplates.FindForStock(catalog.Find("avery-5163"))?.Id);
+ Assert.Equal("a4-21", SheetTemplates.FindForStock(catalog.Find("avery-L7160"))?.Id);
+ // A roll stock is never a sheet.
+ Assert.Null(SheetTemplates.FindForStock(catalog.Entries.First(e => e.Format == StockFormat.Roll)));
+ Assert.Null(SheetTemplates.FindForStock(null));
+ }
+
+ [Fact]
+ public void FindForLabelSize_PrefersThePaperAsked()
+ {
+ Assert.Equal("a4-24", SheetTemplates.FindForLabelSize(70, 37, SheetPageKind.A4)?.Id);
+ Assert.Equal("letter-30", SheetTemplates.FindForLabelSize(66.7, 25.4)?.Id);
+ Assert.Null(SheetTemplates.FindForLabelSize(12, 12));
+ }
+
+ // ------------------------------------------------------------------------------------ validation
+
+ private static SheetLayout Grid(Func? change = null)
+ {
+ var layout = SheetLayout.Create(SheetPageKind.A4, 2, 2, 90, 130, 10, 10, 10, 10);
+ return change is null ? layout : change(layout);
+ }
+
+ private static string[] Errors(SheetLayout layout) =>
+ layout.Validate().Where(i => i.Severity == ValidationSeverity.Error).Select(i => i.MessageKey).ToArray();
+
+ [Fact]
+ public void Validate_AcceptsAGridThatFits()
+ {
+ Assert.Empty(Grid().Validate());
+ Assert.True(Grid().IsValid);
+ }
+
+ [Fact]
+ public void Validate_RefusesLabelsThatDoNotFitThePage()
+ {
+ Assert.Equal(new[] { "Sheet.Labels.TooWide" }, Errors(Grid(l => l with { LeftMarginMm = 25 })));
+ Assert.Equal(new[] { "Sheet.Labels.TooTall" }, Errors(Grid(l => l with { Rows = 3 })));
+ var tooWide = Grid(l => l with { LeftMarginMm = 25 }).Validate().Single();
+ Assert.Equal(new[] { "215", "210" }, tooWide.Args);
+ }
+
+ [Fact]
+ public void Validate_RefusesOverlappingLabels()
+ {
+ Assert.Contains("Sheet.Labels.OverlapAcross", Errors(Grid(l => l with { HorizontalPitchMm = 80 })));
+ Assert.Contains("Sheet.Labels.OverlapDown", Errors(Grid(l => l with { VerticalPitchMm = 100 })));
+ // One column cannot overlap anything, whatever its pitch says.
+ Assert.Empty(Errors(Grid(l => l with { Columns = 1, HorizontalPitchMm = 0 })));
+ }
+
+ [Fact]
+ public void Validate_RefusesNonsense()
+ {
+ Assert.Contains("Sheet.Margin.Negative", Errors(Grid(l => l with { TopMarginMm = -1 })));
+ Assert.Contains("Sheet.Grid.Count", Errors(Grid(l => l with { Columns = 0 })));
+ Assert.Contains("Sheet.Grid.Count", Errors(Grid(l => l with { Rows = SheetLayout.MaxRows + 1 })));
+ Assert.Contains("Sheet.Label.Size", Errors(Grid(l => l with { LabelWidthMm = 0 })));
+ Assert.Contains("Sheet.Page.Size", Errors(Grid(l => l with { PageKind = SheetPageKind.Custom, PageWidthMm = 10 })));
+ Assert.Contains("Sheet.Page.Size", Errors(Grid(l => l with { PageHeightMm = double.NaN })));
+ }
+
+ [Fact]
+ public void Validate_WarnsWhenLabelsReachThePaperEdge()
+ {
+ // The 70 x 37 mm sheet is cut edge to edge: valid, with a warning about what a printer can reach.
+ var issues = Template("a4-24").Validate();
+ var warning = Assert.Single(issues);
+ Assert.Equal(ValidationSeverity.Warning, warning.Severity);
+ Assert.Equal("Sheet.Labels.NearEdge", warning.MessageKey);
+ Assert.True(Template("a4-24").IsValid);
+ }
+
+ [Fact]
+ public void ValidateStart_AcceptsOnlyPositionsOnTheSheet()
+ {
+ var layout = Template("letter-10");
+ Assert.Null(layout.ValidateStart(1));
+ Assert.Null(layout.ValidateStart(10));
+ Assert.Equal("Sheet.Start.Range", layout.ValidateStart(0)?.MessageKey);
+ Assert.Equal(new[] { "10" }, layout.ValidateStart(11)!.Args);
+ }
+
+ [Fact]
+ public void CheckDesignSize_SaysWhenTheDesignIsLargerOrSmaller()
+ {
+ var layout = Template("letter-30");
+ Assert.Null(layout.CheckDesignSize(66.8, 25.3));
+ Assert.Equal("Sheet.Label.Larger", layout.CheckDesignSize(100, 150)?.MessageKey);
+ var smaller = Template("letter-10").CheckDesignSize(50, 20);
+ Assert.Equal("Sheet.Label.Smaller", smaller?.MessageKey);
+ Assert.Equal(new[] { "50", "20", "101.6", "50.8" }, smaller!.Args);
+ }
+
+ // ------------------------------------------------------------------------------------ imposition
+
+ [Fact]
+ public void Paginate_FillsSheetsInOrder_AndBreaksPages()
+ {
+ var layout = Template("letter-10");
+ var pages = SheetImposition.Paginate(layout, Enumerable.Range(1, 23)).ToList();
+ Assert.Equal(3, pages.Count);
+ Assert.Equal(new[] { 1, 2, 3 }, pages.Select(p => p.PageNumber));
+ Assert.Equal(new[] { 10, 10, 3 }, pages.Select(p => p.Placements.Count));
+ Assert.Equal(Enumerable.Range(1, 10), pages[0].Placements.Select(p => p.Item));
+ Assert.Equal(Enumerable.Range(1, 10), pages[1].Placements.Select(p => p.Slot.Position));
+ Assert.Equal(new long[] { 21, 22, 23 }, pages[2].Placements.Select(p => p.LabelNumber));
+ Assert.Equal(3, SheetImposition.PageCount(layout, 23));
+ Assert.Equal(2, SheetImposition.PageCount(layout, 20));
+ Assert.Equal(0, SheetImposition.PageCount(layout, 0));
+ Assert.Empty(SheetImposition.Paginate(layout, Array.Empty()));
+ }
+
+ [Fact]
+ public void Paginate_StartPosition_SkipsUsedPositionsOnTheFirstSheetOnly()
+ {
+ var layout = Template("letter-10");
+ var pages = SheetImposition.Paginate(layout, Enumerable.Range(1, 10), startPosition: 5).ToList();
+ Assert.Equal(2, pages.Count);
+ Assert.Equal(new[] { 5, 6, 7, 8, 9, 10 }, pages[0].Placements.Select(p => p.Slot.Position));
+ Assert.Equal(new[] { 1, 2, 3, 4, 5, 6 }, pages[0].Placements.Select(p => p.Item));
+ Assert.Equal(new[] { 1, 2, 3, 4 }, pages[1].Placements.Select(p => p.Slot.Position));
+ Assert.Equal(2, SheetImposition.PageCount(layout, 10, 5));
+ Assert.Equal(1, SheetImposition.PageCount(layout, 6, 5));
+ // The last position of a sheet works as a start, the one after does not.
+ Assert.Single(SheetImposition.Paginate(layout, new[] { 1 }, 10).Single().Placements);
+ Assert.Throws(() => SheetImposition.Paginate(layout, new[] { 1 }, 11));
+ Assert.Throws(() => SheetImposition.Paginate(layout, new[] { 1 }, 0));
+ }
+
+ [Fact]
+ public void Paginate_IsLazy_OneSheetAtATime()
+ {
+ int pulled = 0;
+ IEnumerable Endless() { while (true) yield return ++pulled; }
+ var first = SheetImposition.Paginate(Template("letter-30"), Endless()).First();
+ Assert.Equal(30, first.Placements.Count);
+ Assert.Equal(30, pulled);
+ }
+
+ [Fact]
+ public void Paginate_DownThenAcross_UsesColumnOrder()
+ {
+ var page = SheetImposition.Paginate(Template("letter-10"), new[] { "a", "b", "c" }, 1, SheetOrder.DownThenAcross).Single();
+ Assert.Equal(new[] { (0, 0), (0, 1), (0, 2) }, page.Placements.Select(p => (p.Slot.Column, p.Slot.Row)));
+ }
+
+ // ------------------------------------------------------------------------------------ merge records and serial numbers
+
+ private static LabelDocument CounterDoc(int quantity = 1, bool remember = false)
+ {
+ var doc = new LabelDocument { Width = Measure.Millimeters(101.6), Height = Measure.Millimeters(50.8) };
+ doc.EnsureId();
+ doc.Variables.Add(Vars.Counter("serial", c => { c.Remember = remember; c.Padding = 4; c.Prefix = "SN"; }));
+ doc.Elements.Add(new TextElement { Id = "name", X = 10, Y = 10, Text = "{{Name}}" });
+ doc.Elements.Add(new TextElement { Id = "sn", X = 10, Y = 60, Text = "{{serial}}" });
+ doc.Print.Quantity = quantity;
+ return doc;
+ }
+
+ [Fact]
+ public void MergedLabels_CarryTheirResolvedValuesAndCopies_AndBreakAcrossSheets()
+ {
+ var doc = CounterDoc();
+ var options = new MergeOptions { CopiesPerRecord = 2 };
+ var set = new MergeDataSet(doc, CsvReader.Parse("Name\nAlpha\nBeta\nGamma\n"), options);
+ var labels = MergeBatch.Labels(doc, set).ToList();
+ Assert.Equal(new[] { "SN0001", "SN0002", "SN0003" }, labels.Select(l => l.Values!["serial"]));
+ Assert.Equal(new[] { "Alpha", "Beta", "Gamma" }, labels.Select(l => l.Values!["Name"]));
+ Assert.All(labels, l => Assert.Equal(2, l.Copies));
+ // The ZPL of a record carries the same number its values carry.
+ Assert.All(labels, l => Assert.Contains(l.Values!["serial"], l.Zpl));
+
+ // 3 records x 2 copies on a 2 x 2 sheet that starts at position 3: two labels on the first sheet, four on the second.
+ var sheet = SheetLayout.Create(SheetPageKind.Letter, 2, 2, 101.6, 50.8, 20, 5, 3, 3);
+ var flat = labels.SelectMany(l => Enumerable.Repeat(l.Values!["serial"], l.Copies));
+ var pages = SheetImposition.Paginate(sheet, flat, startPosition: 3).ToList();
+ Assert.Equal(2, pages.Count);
+ Assert.Equal(new[] { "SN0001", "SN0001" }, pages[0].Placements.Select(p => p.Item));
+ Assert.Equal(new[] { 3, 4 }, pages[0].Placements.Select(p => p.Slot.Position));
+ Assert.Equal(new[] { "SN0002", "SN0002", "SN0003", "SN0003" }, pages[1].Placements.Select(p => p.Item));
+ }
+
+ [Fact]
+ public void MergedLabels_WithoutDefinitions_UseTheRecordAndTheLabelQuantity()
+ {
+ var doc = new LabelDocument { Width = Measure.Millimeters(50), Height = Measure.Millimeters(25) };
+ doc.Elements.Add(new TextElement { Id = "t", X = 10, Y = 10, Text = "{{Name}}" });
+ doc.Print.Quantity = 3;
+ var set = new MergeDataSet(doc, CsvReader.Parse("Name\nAlpha\n"), new MergeOptions());
+ var label = Assert.Single(MergeBatch.Labels(doc, set));
+ Assert.Equal("Alpha", label.Values!["Name"]);
+ Assert.Equal(3, label.Copies);
+ }
+
+ [Fact]
+ public void LabelPrintRun_LabelValues_GiveEveryCopyItsOwnSerial_AndConfirmOnlyWhatWasWritten()
+ {
+ var store = new MemoryCounterStore();
+ var doc = CounterDoc(quantity: 5, remember: true);
+ var run = LabelPrintRun.Begin(doc, null, new PrintVariableOptions { CounterStore = store });
+ Assert.True(run.CanPrint, string.Join(", ", run.Issues));
+ var values = run.LabelValues().ToList();
+ Assert.Equal(new[] { "SN0001", "SN0002", "SN0003", "SN0004", "SN0005" }, values.Select(v => v!["serial"]));
+ run.Complete(values.Count);
+ Assert.Equal("SN0005", CounterAdmin.Peek(doc, "serial", store).LastValue);
+
+ // The next run continues after the last confirmed number: a serial is never written twice.
+ var next = LabelPrintRun.Begin(doc, null, new PrintVariableOptions { CounterStore = store });
+ Assert.Equal("SN0006", next.LabelValues().First()!["serial"]);
+ next.Cancel();
+ Assert.Equal("SN0005", CounterAdmin.Peek(doc, "serial", store).LastValue);
+ }
+
+ [Fact]
+ public void LabelPrintRun_LabelValues_RepeatAPlainLabel()
+ {
+ var doc = new LabelDocument { Width = Measure.Millimeters(50), Height = Measure.Millimeters(25) };
+ doc.Elements.Add(new TextElement { Id = "t", X = 10, Y = 10, Text = "{{Name}}" });
+ doc.Print.Quantity = 3;
+ var record = new Dictionary { ["Name"] = "Alpha" };
+ var run = LabelPrintRun.Begin(doc, record);
+ var values = run.LabelValues().ToList();
+ Assert.Equal(3, values.Count);
+ Assert.All(values, v => Assert.Same(record, v));
+ }
+
+ [Fact]
+ public void ToRenderLabelResolved_KeepsTheGivenSerial()
+ {
+ var doc = CounterDoc();
+ var values = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Name"] = "Alpha", ["serial"] = "SN0042" };
+ var resolved = LabelDocumentAdapter.ToRenderLabelResolved(doc, values);
+ Assert.Contains(resolved.Elements.OfType(), t => t.Text == "SN0042");
+ // The preview path resolves the counter again and shows its own value instead, which is why sheets use the resolved path.
+ var preview = LabelDocumentAdapter.ToRenderLabel(doc, values);
+ Assert.DoesNotContain(preview.Elements.OfType(), t => t.Text == "SN0042");
+ }
+
+ // ------------------------------------------------------------------------------------ PDF
+
+ private static RenderLabel SampleLabel(double widthMm, double heightMm, int dpi = 203) =>
+ new((int)Math.Round(widthMm / 25.4 * dpi), (int)Math.Round(heightMm / 25.4 * dpi), dpi,
+ new RenderElement[] { new RenderText { Text = "Sheet", FontHeightDots = 30, X = 10, Y = 10 } });
+
+ private static string Pdf(SheetLayout layout, int labels, SheetExportOptions? options, out SheetExportResult result)
+ {
+ var label = SampleLabel(layout.LabelWidthMm, layout.LabelHeightMm);
+ using var ms = new MemoryStream();
+ result = SheetExporter.SavePdf(layout, Enumerable.Repeat(label, labels), ms, options);
+ return Encoding.Latin1.GetString(ms.ToArray());
+ }
+
+ [Fact]
+ public void Pdf_HasOnePagePerSheet_AtLetterSize()
+ {
+ string text = Pdf(Template("letter-30"), 31, null, out var result);
+ Assert.StartsWith("%PDF-", text);
+ Assert.Equal(2, result.Pages);
+ Assert.Equal(31, result.Labels);
+ Assert.Equal(2, Regex.Matches(text, @"/Type /Page\b").Count);
+ // 8.5 x 11 in = 612 x 792 pt.
+ Assert.Equal(2, Regex.Matches(text, @"/MediaBox \[\s*0 0 612 792\s*\]").Count);
+ }
+
+ [Fact]
+ public void Pdf_StartPosition_AddsAPageWhenTheFirstSheetIsPartlyUsed()
+ {
+ Pdf(Template("letter-10"), 10, new SheetExportOptions { StartPosition = 1 }, out var full);
+ Assert.Equal(1, full.Pages);
+ string text = Pdf(Template("letter-10"), 10, new SheetExportOptions { StartPosition = 4, Guides = SheetGuides.CropMarks }, out var partial);
+ Assert.Equal(2, partial.Pages);
+ Assert.Equal(2, Regex.Matches(text, @"/Type /Page\b").Count);
+ }
+
+ [Fact]
+ public void Pdf_A4_PageIsA4()
+ {
+ string text = Pdf(Template("a4-21"), 5, new SheetExportOptions { Guides = SheetGuides.Outlines }, out var result);
+ Assert.Equal(1, result.Pages);
+ // 210 x 297 mm = 595.28 x 841.89 pt.
+ var box = Regex.Match(text, @"/MediaBox \[\s*0 0 ([\d.]+) ([\d.]+)\s*\]");
+ Assert.True(box.Success);
+ Near(595.28, double.Parse(box.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture), 0.1);
+ Near(841.89, double.Parse(box.Groups[2].Value, System.Globalization.CultureInfo.InvariantCulture), 0.1);
+ }
+
+ [Fact]
+ public void Pdf_EmptyRunWritesNothing_AndAnInvalidLayoutIsRefused()
+ {
+ using var ms = new MemoryStream();
+ var result = SheetExporter.SavePdf(Template("letter-30"), Array.Empty(), ms);
+ Assert.Equal(0, result.Pages);
+ Assert.Equal(0, ms.Length);
+ var bad = Template("letter-30") with { Columns = 4 };
+ Assert.Throws(() => SheetExporter.SavePdf(bad, new[] { SampleLabel(66.68, 25.4) }, new MemoryStream()));
+ }
+
+ [Fact]
+ public void Placement_DrawsTheLabelAtItsPhysicalSize_CentredInItsPosition()
+ {
+ var layout = Template("letter-30");
+ // 2-5/8 x 1 in at 300 dpi: 787.5 rounds to 788 dots, so the drawn label is 189.12 pt for a 189 pt position.
+ var label = SampleLabel(66.675, 25.4, 300);
+ var (slot, drawn) = SheetExporter.Placement(layout.Slot(0), label);
+ Near(13.5, slot.Left, 0.01);
+ Near(36, slot.Top, 0.01);
+ Near(189, slot.Width, 0.01);
+ Near(72, slot.Height, 0.01);
+ Near(label.WidthDots * 72.0 / 300, drawn.Width, 0.01);
+ Near(slot.MidX, drawn.MidX, 0.01);
+ Near(slot.MidY, drawn.MidY, 0.01);
+
+ // A design half the size keeps its size: it is not stretched to the position.
+ var small = SampleLabel(33.3375, 12.7, 203);
+ var (_, smallDrawn) = SheetExporter.Placement(layout.Slot(0), small);
+ Near(small.WidthDots * 72.0 / 203, smallDrawn.Width, 0.01);
+ Near(slot.MidX, smallDrawn.MidX, 0.01);
+ }
+
+ [Fact]
+ public void Pdf_FileIsWrittenThroughATempFile()
+ {
+ string dir = Path.Combine(Path.GetTempPath(), "rotula-sheet-" + Guid.NewGuid().ToString("N"));
+ try
+ {
+ string path = Path.Combine(dir, "sheets.pdf");
+ var result = SheetExporter.SavePdf(Template("a4-24"), Enumerable.Repeat(SampleLabel(70, 37), 25), path);
+ Assert.Equal(2, result.Pages);
+ Assert.True(File.Exists(path));
+ Assert.False(File.Exists(path + ".tmp"));
+ // Nothing to write leaves no file behind.
+ string empty = Path.Combine(dir, "empty.pdf");
+ SheetExporter.SavePdf(Template("a4-24"), Array.Empty(), empty);
+ Assert.False(File.Exists(empty));
+ }
+ finally
+ {
+ if (Directory.Exists(dir)) Directory.Delete(dir, true);
+ }
+ }
+
+ // ------------------------------------------------------------------------------------ .rotula file
+
+ [Fact]
+ public void Serializer_RoundTripsTheSheetSettings_WithoutChangingTheFormatVersion()
+ {
+ var doc = new LabelDocument { Width = Measure.Millimeters(66.68), Height = Measure.Millimeters(25.4) };
+ var settings = new SheetSettings
+ {
+ TemplateId = "letter-30",
+ Layout = Template("letter-30") with { TopMarginMm = 13.2 },
+ Order = SheetOrder.DownThenAcross,
+ Guides = SheetGuides.CropMarks,
+ };
+ string json = RotulaFileSerializer.Serialize(doc, null, settings);
+ Assert.Contains("\"sheet\"", json);
+ Assert.Contains("\"formatVersion\": 1", json);
+
+ var back = RotulaFileSerializer.DeserializeFile(json).Sheet;
+ Assert.NotNull(back);
+ Assert.Equal(settings, back);
+
+ // A label without a sheet layout writes no sheet section and reads back none.
+ string plain = RotulaFileSerializer.Serialize(doc);
+ Assert.DoesNotContain("\"sheet\"", plain);
+ Assert.Null(RotulaFileSerializer.DeserializeFile(plain).Sheet);
+ }
+
+ [Fact]
+ public void Serializer_DropsASheetSectionThatMakesNoSense()
+ {
+ var doc = new LabelDocument();
+ var settings = new SheetSettings { TemplateId = "letter-30", Layout = Template("letter-30") };
+ string json = RotulaFileSerializer.Serialize(doc, null, settings);
+
+ // Labels that no longer fit the page: the section is ignored, the label still opens.
+ string broken = json.Replace("\"columns\": 3", "\"columns\": 9");
+ var content = RotulaFileSerializer.DeserializeFile(broken);
+ Assert.NotNull(content.Document);
+ Assert.Null(content.Sheet);
+
+ // An unknown template id is forgotten, the layout is kept.
+ var unknown = RotulaFileSerializer.DeserializeFile(json.Replace("\"letter-30\"", "\"made-up\"")).Sheet;
+ Assert.NotNull(unknown);
+ Assert.Null(unknown!.TemplateId);
+ Assert.Equal(30, unknown.Layout.SlotCount);
+
+ // A named paper keeps its real size even if the numbers were edited.
+ var edited = RotulaFileSerializer.DeserializeFile(json.Replace("\"pageHeightMm\": 279.4", "\"pageHeightMm\": 300")).Sheet;
+ Assert.Equal(279.4, edited!.Layout.PageHeightMm);
+ }
+}