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 @@