diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64307b3..32b6f9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **A gallery of eight showcase labels** in the README (GS1-128 pallet, parcel, food, wine, lab specimen, asset tag, shelf tags with data merge, print conditions). Their `.rotula` files and the CSV data are in `docs/showcase`, and the pictures are rendered with Rotula's own PNG export by a test that only runs when `ROTULA_RENDER_SHOWCASE=1` is set.
- **Text fit for wrapped text** (Properties panel, Text fit): choose what a text block does when its text is longer than the box. "Shrink to fit" uses the largest font size, between a smallest and a largest size you set, at which all the text fits; "Grow height" adds lines, up to a limit you set, so all the text shows. With data merge every record gets the size or the lines its own text needs, the canvas shows the same size that prints, and "Do not warn" hides the does-not-fit warning when cutting the text off is what you want. Labels that do not use it are saved exactly as before; a label that does cannot be opened by older versions of Rotula.
- **Print an object only when you want it**: in the new Printing section of the properties, mark an object "Do not print this object" (a note or guide that never reaches the printer or an export), set "Print only when" a field equals, does not equal, contains, is empty or is not empty a value (so a warning can appear on some labels only), and "Lock position" so nothing moves it by accident. The Objects list and the canvas show small No print, Hidden and Locked badges.
+- **The Preview window shows a label the way it prints when it is set to print upside down (`^POI`) or mirrored (`^PMY`).** It opens on "As printed", says in one sentence why the picture is turned, and has an "As designed" switch to see it upright. The canvas stays upright for editing and the status bar says "Prints upside down" (or "Prints mirrored"). Imported ZPL with `^POI` / `^PON` and `^PMY` sets the label's setting, so the preview follows it. PNG, JPEG and PDF exports stay as designed.
- **Portuguese (Brazil) and French (Canada)**: the whole interface is now also available in Brazilian Portuguese and Canadian French (View > Language, where each language is listed in its own name), and the installer offers both. On first run Rotula follows Windows: a Portuguese desktop gets Portuguese (Brazil), a French one French (Canada), Spanish stays Spanish and anything else English. Numbers and dates follow the chosen language. A language you picked before stays as it was. The "Language of Rotula" policy (Group Policy / Intune) also accepts `pt-BR` and `fr-CA`.
### Fixed
diff --git a/README.es.md b/README.es.md
index 927e541..31961b5 100644
--- a/README.es.md
+++ b/README.es.md
@@ -139,7 +139,10 @@ QR, cuadros, líneas, círculos e imágenes monocromáticas (con umbral o tramad
### Vista previa e impresión
-La vista previa muestra la etiqueta más grande, tal como se dibujará, y dice con claridad que es aproximada. El cuadro de
+La vista previa muestra la etiqueta más grande, tal como se dibujará, y dice con claridad que es aproximada. Una etiqueta
+configurada para imprimirse al revés (`^POI`) o en espejo (`^PMY`) se muestra "Como se imprime", con una frase que explica
+por qué; cambia a "Como se diseñó" para verla derecha. El lienzo siempre queda derecho para editar, y la barra de estado
+indica "Se imprime al revés". El cuadro de
impresión lee la resolución y el tamaño de papel desde el controlador de la impresora, puede enviar una etiqueta de prueba
e imprime en una impresora de Windows o en una impresora de red.
@@ -577,8 +580,9 @@ Léelas antes de depender de Rotula para etiquetas de producción, e **imprime s
impresa. La aplicación lo avisa.
- **Fuentes de mapa de bits y `FontWidth`.** Las fuentes de mapa de bits integradas de la impresora no se reproducen; el
escalado de ancho solo se aproxima en la vista previa.
-- **`^PO` (orientación de impresión) no se previsualiza.** Se escribe en el ZPL, pero la vista previa siempre muestra la
- orientación normal.
+- **La orientación de impresión (`^PO`) y el espejo (`^PM`) solo se muestran en la ventana Vista previa.** El lienzo y las
+ exportaciones PNG, JPEG y PDF muestran la etiqueta como se diseñó (derecha); las exportaciones son el diseño, no una imagen
+ de lo que sale de la impresora.
- **Las variables no sobreviven a un viaje de ida y vuelta por ZPL.** ZPL no puede expresar `{{nombre}}`, así que el ZPL
importado es texto simple hasta que lo edites.
- **El ZPL sin procesar no se escala al convertir el DPI.** Los comandos desconocidos conservados como ZPL sin procesar
diff --git a/README.md b/README.md
index 34393ef..926dd28 100644
--- a/README.md
+++ b/README.md
@@ -136,7 +136,9 @@ dithering).
### Preview and print
-A live preview shows the label larger, as it will be drawn, and says plainly that it is approximate. The print dialog
+A live preview shows the label larger, as it will be drawn, and says plainly that it is approximate. A label set to print
+upside down (`^POI`) or as a mirror image (`^PMY`) is shown "As printed" with a sentence saying why; switch to "As designed"
+to see it upright. The canvas always stays upright for editing, and the status bar says "Prints upside down". The print dialog
reads the resolution and paper size from the printer driver, can send a test label, and prints to a Windows printer or a
network printer.
@@ -565,7 +567,8 @@ Read these before relying on Rotula for production labels, and **always print a
shapes and line breaks can differ slightly from the printed label. The app says so.
- **Bitmap fonts and `FontWidth`.** The printer's built-in bitmap fonts are not reproduced; width scaling is only approximated
in the preview.
-- **`^PO` (print orientation) is not previewed.** It is written to the ZPL, but the preview always shows the normal orientation.
+- **Print orientation (`^PO`) and mirror (`^PM`) are shown only in the Preview window.** The canvas and the PNG, JPEG and PDF
+ exports show the label as designed (upright); the exports are artwork, not a picture of the printer's output.
- **Variables do not survive a ZPL round trip.** ZPL cannot express `{{name}}`, so imported ZPL is plain text until you edit it.
- **Raw ZPL is not scaled on DPI conversion.** Unknown commands kept as raw ZPL stay in their original dots when a label is
converted to another resolution, and Rotula warns you.
diff --git a/docs/decisions.md b/docs/decisions.md
index afb13e8..aced028 100644
--- a/docs/decisions.md
+++ b/docs/decisions.md
@@ -618,3 +618,13 @@ leaving by accident; it is not a security boundary, because anyone who can edit
Keyboard-wedge scanners end a code with Enter or Tab, which `ScanSequencer` turns into "stay", "next field", "focus Print" or "print". Timing
heuristics (telling a scan from typing by speed) were left out: they misfire on slow PCs and remote sessions, and the terminator already works for
both people and scanners.
+## Print orientation preview (Rotula.Rendering)
+
+### ^PO and ^PM are shown "as printed" only in the Preview window
+`RenderLabel.Inverted` (^POI) and `RenderLabel.Mirrored` (^PMY) flip the whole drawing about the label's centre inside `LabelRenderer.Draw`, so
+bitmaps and PDF pages get the same transform. `LabelDocumentAdapter.ToRenderLabel` never sets them; only `LabelDocumentAdapter.AsPrinted` does,
+and only the Preview window asks for it (it opens on "As printed" with an "As designed" switch and a sentence saying why the picture is turned).
+The design canvas stays upright because editing an upside-down label (handles, rulers, snapping, typing) would be confusing; the status bar says
+"Prints upside down" / "Prints mirrored" instead. PNG, JPEG and PDF exports and the MCP preview stay as designed: they are artwork for documents,
+proofs and screens, not a picture of the media leaving the printer, and there is no "as printed" export option to honour. The two flips commute
+(inverted and mirrored together is a top-to-bottom flip), so their order is not a question.
diff --git a/docs/zpl-coverage.md b/docs/zpl-coverage.md
index c8c1036..365ad1d 100644
--- a/docs/zpl-coverage.md
+++ b/docs/zpl-coverage.md
@@ -82,8 +82,8 @@ Nothing is silently dropped.
| `^LS` | Label shift | Supported | Yes | Yes | Exact | Shifts every field to the left by a number of dots; a negative value shifts to the right. Written only when it is not zero. |
| `^LT` | Label top | Supported | Yes | Yes | Exact | Moves the whole format down (or up for a negative value) by a few dot rows to fine-tune where it lands on the label. Rotula accepts -240 to 240; most printers only accept -120 to 120. |
| `^MU` | Units of measure | Supported | No | Yes | n/a | Switches the units used by later numbers to dots, inches or millimetres. Values are converted to dots on import and a notice is shown. |
-| `^PM` | Mirror the label | Supported | Yes | Yes | No | Prints the whole label as a mirror image, left to right. The on-screen preview does not mirror. |
-| `^PO` | Print orientation | Supported | Yes | Yes | No | Prints the whole label rotated by 180 degrees. The on-screen preview always shows the normal orientation. |
+| `^PM` | Mirror the label | Supported | Yes | Yes | Exact | Prints the whole label as a mirror image, left to right. The Preview window shows it mirrored ("As printed"); the canvas and the image and PDF exports stay as designed. |
+| `^PO` | Print orientation | Supported | Yes | Yes | Exact | Prints the whole label rotated by 180 degrees. The Preview window shows it upside down ("As printed", with a switch to "As designed"); the canvas and the image and PDF exports stay upright. |
| `^PQ` | Print quantity | Supported | Yes | Yes | n/a | Sets how many labels print, how many between pauses, and how many copies of each serial number. |
| `^PW` | Print width | Supported | Yes | Yes | Exact | Sets the width of the printable area in dots. |
| `^XA` | Start of label | Supported | Yes | Yes | n/a | Opens a label format. Everything up to the matching end-of-label command belongs to one label. |
diff --git a/src/Rotula.App/Editing/PreviewCache.cs b/src/Rotula.App/Editing/PreviewCache.cs
index f3d5e8d..19acf5d 100644
--- a/src/Rotula.App/Editing/PreviewCache.cs
+++ b/src/Rotula.App/Editing/PreviewCache.cs
@@ -27,6 +27,9 @@ public sealed class PreviewCache : IDisposable
/// Creates a cache for a session.
public PreviewCache(EditorSession session) => _session = session;
+ /// The label this cache draws (the session's current document).
+ public Core.Model.LabelDocument Document => _session.Document;
+
///
/// Values of the data-merge record shown on the canvas, or null to draw the sample values. Changing it must be followed
/// by so the cached render is rebuilt.
@@ -70,10 +73,16 @@ public RenderLabel GetDesignLabel()
/// Draws the label exactly as it prints (objects that are not printed for the shown record are left out). The caller owns and must dispose the
/// result. Returns null and sets when the label cannot be drawn.
///
- public RenderResult? RenderPrinted(double scale, out string? errorKey)
+ /// Output pixels per printer dot.
+ /// Message key of the reason the label could not be drawn, or null.
+ ///
+ /// True to turn (^POI) or mirror (^PMY) the picture the way the printer puts it on the media; false shows it as designed.
+ ///
+ public RenderResult? RenderPrinted(double scale, out string? errorKey, bool asPrinted = false)
{
errorKey = null;
var label = GetLabel();
+ if (asPrinted) label = LabelDocumentAdapter.AsPrinted(label, _session.Document);
if (label.WidthDots <= 0 || label.HeightDots <= 0) { errorKey = "Preview.InvalidSize"; return null; }
try { return LabelRenderer.RenderBitmap(label, new RenderOptions { Scale = scale }); }
catch (ArgumentOutOfRangeException) { errorKey = "Preview.TooLarge"; return null; }
diff --git a/src/Rotula.App/MainWindow.xaml b/src/Rotula.App/MainWindow.xaml
index 810f837..942650e 100644
--- a/src/Rotula.App/MainWindow.xaml
+++ b/src/Rotula.App/MainWindow.xaml
@@ -751,6 +751,19 @@
+
+
+
+
+
+
+
+
diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx
index e0c3c33..d1b3b6c 100644
--- a/src/Rotula.App/Resources/Strings.en.resx
+++ b/src/Rotula.App/Resources/Strings.en.resx
@@ -6258,4 +6258,46 @@ Brand and product names belong to their owners; Rotula is not affiliated with th
The browser could not be opened. The release page is {0}
+
+ Show the label as printed or as designed
+
+
+ As printed
+
+
+ Show the label the way it comes out of the printer, turned or mirrored by the label's print settings.
+
+
+ As designed
+
+
+ Show the label upright, the way you edit it on the canvas.
+
+
+ Shown as printed: this label's print orientation is set to inverted, so the printer prints it upside down.
+
+
+ Shown as printed: this label is set to print as a mirror image, so left and right are swapped.
+
+
+ Shown as printed: this label is set to print upside down and as a mirror image.
+
+
+ Shown as designed, the way you edit it. Choose "As printed" to see how it comes out of the printer.
+
+
+ Prints upside down
+
+
+ Prints mirrored
+
+
+ Prints upside down, mirrored
+
+
+ Print orientation
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx
index ad3dd70..9b06e42 100644
--- a/src/Rotula.App/Resources/Strings.es.resx
+++ b/src/Rotula.App/Resources/Strings.es.resx
@@ -6258,4 +6258,46 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a
No se pudo abrir el navegador. La página de la versión es {0}
+
+ Mostrar la etiqueta como se imprime o como se diseñó
+
+
+ Como se imprime
+
+
+ Muestra la etiqueta tal como sale de la impresora, girada o reflejada según la configuración de impresión de la etiqueta.
+
+
+ Como se diseñó
+
+
+ Muestra la etiqueta derecha, tal como la edita en el lienzo.
+
+
+ Vista como se imprime: la orientación de impresión de esta etiqueta es invertida, así que la impresora la imprime al revés.
+
+
+ Vista como se imprime: esta etiqueta está configurada para imprimirse en espejo, así que la izquierda y la derecha se intercambian.
+
+
+ Vista como se imprime: esta etiqueta está configurada para imprimirse al revés y en espejo.
+
+
+ Vista como se diseñó, tal como la edita. Elija "Como se imprime" para ver cómo sale de la impresora.
+
+
+ Se imprime al revés
+
+
+ Se imprime en espejo
+
+
+ Se imprime al revés y en espejo
+
+
+ Orientación de impresión
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.fr-CA.resx b/src/Rotula.App/Resources/Strings.fr-CA.resx
index bdad7cb..d380c02 100644
--- a/src/Rotula.App/Resources/Strings.fr-CA.resx
+++ b/src/Rotula.App/Resources/Strings.fr-CA.resx
@@ -6258,4 +6258,46 @@ Les noms de marques et de produits appartiennent à leurs propriétaires ; Rot
Le navigateur n’a pas pu être ouvert. La page de la version est {0}
+
+ Afficher l’étiquette telle qu’imprimée ou telle que conçue
+
+
+ Telle qu’imprimée
+
+
+ Affiche l’étiquette comme elle sort de l’imprimante, tournée ou inversée en miroir selon les réglages d’impression de l’étiquette.
+
+
+ Telle que conçue
+
+
+ Affiche l’étiquette à l’endroit, comme vous la modifiez dans la zone de conception.
+
+
+ Affichée telle qu’imprimée : l’orientation d’impression de cette étiquette est inversée ; l’imprimante l’imprime donc à l’envers.
+
+
+ Affichée telle qu’imprimée : cette étiquette est réglée pour s’imprimer en miroir ; la gauche et la droite sont donc inversées.
+
+
+ Affichée telle qu’imprimée : cette étiquette est réglée pour s’imprimer à l’envers et en miroir.
+
+
+ Affichée telle que conçue, comme vous la modifiez. Choisissez « Telle qu’imprimée » pour voir comment elle sort de l’imprimante.
+
+
+ S’imprime à l’envers
+
+
+ S’imprime en miroir
+
+
+ S’imprime à l’envers, en miroir
+
+
+ Orientation d’impression
+
+
+ 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.
+
diff --git a/src/Rotula.App/Resources/Strings.pt-BR.resx b/src/Rotula.App/Resources/Strings.pt-BR.resx
index 66e4fd5..c8c85fe 100644
--- a/src/Rotula.App/Resources/Strings.pt-BR.resx
+++ b/src/Rotula.App/Resources/Strings.pt-BR.resx
@@ -6258,4 +6258,46 @@ Os nomes de marcas e produtos pertencem aos seus proprietários; o Rotula não t
Não foi possível abrir o navegador. A página da versão é {0}
+
+ Mostrar a etiqueta como é impressa ou como foi criada
+
+
+ Como é impressa
+
+
+ Mostra a etiqueta do jeito que ela sai da impressora, girada ou espelhada de acordo com as configurações de impressão da etiqueta.
+
+
+ Como foi criada
+
+
+ Mostra a etiqueta na posição normal, do jeito que você a edita na área de design.
+
+
+ Exibida como é impressa: a orientação de impressão desta etiqueta está invertida, por isso a impressora a imprime de cabeça para baixo.
+
+
+ Exibida como é impressa: esta etiqueta está configurada para imprimir espelhada, por isso a esquerda e a direita ficam trocadas.
+
+
+ Exibida como é impressa: esta etiqueta está configurada para imprimir de cabeça para baixo e espelhada.
+
+
+ Exibida como foi criada, do jeito que você a edita. Escolha "Como é impressa" para ver como ela sai da impressora.
+
+
+ Imprime de cabeça para baixo
+
+
+ Imprime espelhada
+
+
+ Imprime de cabeça para baixo e espelhada
+
+
+ Orientação de impressão
+
+
+ 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.
+
diff --git a/src/Rotula.App/ViewModels/MainViewModel.cs b/src/Rotula.App/ViewModels/MainViewModel.cs
index 8ff7964..0c7d61e 100644
--- a/src/Rotula.App/ViewModels/MainViewModel.cs
+++ b/src/Rotula.App/ViewModels/MainViewModel.cs
@@ -581,6 +581,21 @@ private string SizeText()
/// Right side of the status bar, for example "4.00 x 3.00 in · 203 dpi · ZPL".
public string StatusLabelText => LabelSummaryText + " \u00B7 ZPL";
+ ///
+ /// Status bar badge for a label the printer turns (^POI) or mirrors (^PMY), for example "Prints upside down", or empty for a normal label.
+ /// The canvas always shows the label upright for editing; this badge is how the operator knows the print differs.
+ ///
+ public string PrintOrientationText
+ {
+ get
+ {
+ var doc = Session.Document;
+ bool inverted = doc.Orientation == LabelOrientation.Inverted;
+ if (!inverted && !doc.MirrorImage) return string.Empty;
+ return Loc.Get(inverted && doc.MirrorImage ? "Status.PrintsInvertedMirrored" : inverted ? "Status.PrintsInverted" : "Status.PrintsMirrored");
+ }
+ }
+
/// Left side of the status bar, for example "5 objects · 1 selected".
public string ObjectsText
{
@@ -681,6 +696,7 @@ private void NotifyPresentation()
OnPropertyChanged(nameof(TitleBarText));
OnPropertyChanged(nameof(LabelSummaryText));
OnPropertyChanged(nameof(StatusLabelText));
+ OnPropertyChanged(nameof(PrintOrientationText));
OnPropertyChanged(nameof(ObjectsText));
OnPropertyChanged(nameof(SelectionTypeTag));
OnPropertyChanged(nameof(BarcodePreviewNote));
diff --git a/src/Rotula.App/ViewModels/PreviewViewModel.cs b/src/Rotula.App/ViewModels/PreviewViewModel.cs
new file mode 100644
index 0000000..a5aaa8f
--- /dev/null
+++ b/src/Rotula.App/ViewModels/PreviewViewModel.cs
@@ -0,0 +1,78 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using Rotula.App.Editing;
+using Rotula.App.Localization;
+using Rotula.Core.Model;
+using Rotula.Rendering;
+
+namespace Rotula.App.ViewModels;
+
+///
+/// The preview window: draws the label as the printer puts it on the media ("as printed", turned for ^POI and mirrored for ^PMY) or
+/// the way it is edited on the canvas ("as designed"), and says in words which one is shown so an upside-down picture is never a surprise.
+///
+public sealed partial class PreviewViewModel : ObservableObject
+{
+ private readonly PreviewCache _preview;
+
+ /// Creates the view model for the label held by .
+ /// The preview cache of the open label (its current record data is used; its print orientation (^PO) and mirror (^PM) settings decide the view).
+ public PreviewViewModel(PreviewCache preview)
+ {
+ ArgumentNullException.ThrowIfNull(preview);
+ _preview = preview;
+ // The preview is a snapshot of the label when the window opens, so the settings are read once.
+ _inverted = preview.Document.Orientation == LabelOrientation.Inverted;
+ _mirrored = preview.Document.MirrorImage;
+ ChangesWhenPrinted = _inverted || _mirrored;
+ // The preview's job is to show what the printer draws, so it opens on "as printed".
+ _asPrinted = true;
+ }
+
+ private readonly bool _inverted;
+ private readonly bool _mirrored;
+
+ /// Raised when the picture must be drawn again (the view switched between "as printed" and "as designed").
+ public event EventHandler? PictureChanged;
+
+ ///
+ /// True when the printer turns or mirrors this label, so "as printed" and "as designed" differ and the switch is shown.
+ /// False for a normal label, where both views are the same picture.
+ ///
+ public bool ChangesWhenPrinted { get; }
+
+ /// True to show the label as it comes out of the printer; false to show it as designed.
+ [ObservableProperty]
+ [NotifyPropertyChangedFor(nameof(AsDesigned), nameof(ModeNote))]
+ private bool _asPrinted;
+
+ ///
+ /// The opposite of , for the second option of the switch. Only a write of true acts: a radio button that
+ /// is unchecked by its sibling writes false back, and reacting to that would flip the choice again.
+ ///
+ public bool AsDesigned
+ {
+ get => !AsPrinted;
+ set { if (value) AsPrinted = false; }
+ }
+
+ ///
+ /// One sentence on which view is shown and why it looks the way it does, or empty for a label the printer does not turn or mirror.
+ ///
+ public string ModeNote
+ {
+ get
+ {
+ if (!ChangesWhenPrinted) return string.Empty;
+ if (!AsPrinted) return Loc.Get("Preview.Note.Designed");
+ return Loc.Get(_inverted && _mirrored ? "Preview.Note.PrintedBoth" : _inverted ? "Preview.Note.PrintedInverted" : "Preview.Note.PrintedMirrored");
+ }
+ }
+
+ ///
+ /// Draws the label at one pixel per dot in the chosen view. The caller owns and must dispose the result. Returns null and sets
+ /// when the label cannot be drawn.
+ ///
+ public RenderResult? Render(out string? errorKey) => _preview.RenderPrinted(1.0, out errorKey, asPrinted: AsPrinted);
+
+ partial void OnAsPrintedChanged(bool value) => PictureChanged?.Invoke(this, EventArgs.Empty);
+}
diff --git a/src/Rotula.App/Views/PreviewWindow.xaml b/src/Rotula.App/Views/PreviewWindow.xaml
index fc8dafa..dc47648 100644
--- a/src/Rotula.App/Views/PreviewWindow.xaml
+++ b/src/Rotula.App/Views/PreviewWindow.xaml
@@ -6,11 +6,29 @@
Title="{loc:T Preview.Title}" Width="760" Height="820" MinWidth="420" MinHeight="360"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
AutomationProperties.Name="{loc:T Preview.Title}">
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Rotula.App/Views/PreviewWindow.xaml.cs b/src/Rotula.App/Views/PreviewWindow.xaml.cs
index 8e5c077..daffda0 100644
--- a/src/Rotula.App/Views/PreviewWindow.xaml.cs
+++ b/src/Rotula.App/Views/PreviewWindow.xaml.cs
@@ -3,22 +3,38 @@
using System.Windows.Media.Imaging;
using Rotula.App.Editing;
using Rotula.App.Localization;
+using Rotula.App.ViewModels;
namespace Rotula.App.Views;
-/// A larger preview of the label, drawn by Rotula.Rendering at one pixel per printer dot.
+///
+/// A larger preview of the label, drawn by Rotula.Rendering at one pixel per printer dot. When the printer turns (^POI) or mirrors (^PMY)
+/// the label, a switch shows it "as printed" (the default) or "as designed", with a sentence saying which one is on screen.
+///
public partial class PreviewWindow : DialogWindow
{
- /// Creates the window and draws the label once.
+ private readonly PreviewViewModel _viewModel;
+
+ /// Creates the window and draws the label.
/// The preview cache of the open label (its current record data is used).
/// Name of the label, shown in the title.
public PreviewWindow(PreviewCache preview, string title)
{
InitializeComponent();
Title = Loc.Get("Preview.TitleFor", title);
- // The preview is a snapshot: it shows the label as it is when the window opens.
- // This window shows what the printer will draw, so objects that are switched off for the shown record are absent (the canvas only dims them).
- using var result = preview.RenderPrinted(1.0, out string? errorKey);
+ _viewModel = new PreviewViewModel(preview);
+ DataContext = _viewModel;
+ _viewModel.PictureChanged += (_, _) => Draw();
+ Draw();
+ }
+
+ ///
+ /// Draws the picture for the chosen view. The preview is a snapshot: it shows the label as it is when the window opens. This window
+ /// shows what the printer will draw, so objects that are switched off for the shown record are absent (the canvas only dims them).
+ ///
+ private void Draw()
+ {
+ using var result = _viewModel.Render(out string? errorKey);
if (result is null)
{
Content = new System.Windows.Controls.TextBlock
diff --git a/src/Rotula.Core/Zpl/Catalog/commands.json b/src/Rotula.Core/Zpl/Catalog/commands.json
index 31c7993..7d1f6c6 100644
--- a/src/Rotula.Core/Zpl/Catalog/commands.json
+++ b/src/Rotula.Core/Zpl/Catalog/commands.json
@@ -162,10 +162,10 @@
"reason": null,
"generator": true,
"parser": true,
- "preview": "NotPreviewed",
+ "preview": "Exact",
"summary": "Prints the whole label as a mirror image, left to right.",
"example": "^PMY",
- "notes": "The on-screen preview does not mirror."
+ "notes": "The Preview window shows it mirrored (\"As printed\"); the canvas and the image and PDF exports stay as designed."
},
{
"id": "^PO",
@@ -177,10 +177,10 @@
"reason": null,
"generator": true,
"parser": true,
- "preview": "NotPreviewed",
+ "preview": "Exact",
"summary": "Prints the whole label rotated by 180 degrees.",
"example": "^POI",
- "notes": "The on-screen preview always shows the normal orientation."
+ "notes": "The Preview window shows it upside down (\"As printed\", with a switch to \"As designed\"); the canvas and the image and PDF exports stay upright."
},
{
"id": "^PQ",
diff --git a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
index 9145bcf..01d9a15 100644
--- a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
+++ b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs
@@ -13,7 +13,9 @@ namespace Rotula.Rendering.Input;
/// variables are substituted, defaults (^CF, ^FW) are applied and margins (^LH) are added to every position,
/// so the preview lays elements out exactly where tells the printer to put them.
///
-/// Not previewed: whole-label inversion (^PO) and mirroring (^PM) are ignored, text direction (^FP) is ignored, and bitmap
+/// Whole-label inversion (^PO) and mirroring (^PM) are left out of so the canvas and the exports show the
+/// label as designed; adds them for the "as printed" preview.
+/// Not previewed: text direction (^FP) is ignored, and bitmap
/// font ids other than '0' are all drawn with the monospaced stand-in. Label shift (^LS) and label top (^LT) are applied. Text preview is approximate in general (see ).
///
///
@@ -51,6 +53,26 @@ public static RenderLabel ToRenderLabel(LabelDocument doc, IReadOnlyDictionaryTrue when the printer turns (^POI) or mirrors (^PMY) the whole label, so "as printed" differs from "as designed".
+ public static bool ChangesWhenPrinted(LabelDocument doc)
+ {
+ ArgumentNullException.ThrowIfNull(doc);
+ return doc.Orientation == LabelOrientation.Inverted || doc.MirrorImage;
+ }
+
+ ///
+ /// Returns set to draw the way the printer puts it on the media: turned by 180 degrees for ^POI and
+ /// mirrored for ^PMY. The same instance comes back when the document does neither.
+ ///
+ /// Render input made from by .
+ /// The design whose print orientation and mirror settings apply.
+ public static RenderLabel AsPrinted(RenderLabel label, LabelDocument doc)
+ {
+ ArgumentNullException.ThrowIfNull(label);
+ if (!ChangesWhenPrinted(doc)) return label;
+ return label with { Inverted = doc.Orientation == LabelOrientation.Inverted, Mirrored = doc.MirrorImage };
+ }
+
// Stateless and thread safe; shared so the preview does not build one per element.
private static readonly SkiaTextRasterizer FitRasterizer = new();
diff --git a/src/Rotula.Rendering/Input/RenderModel.cs b/src/Rotula.Rendering/Input/RenderModel.cs
index d37303f..72e479c 100644
--- a/src/Rotula.Rendering/Input/RenderModel.cs
+++ b/src/Rotula.Rendering/Input/RenderModel.cs
@@ -58,6 +58,18 @@ public sealed record RenderLabel(int WidthDots, int HeightDots, int Dpi, IReadOn
/// Label height in millimetres.
public double HeightMm => HeightDots * 25.4 / Dpi;
+ ///
+ /// True to draw the whole label turned by 180 degrees, as the printer does with ZPL ^POI. False (the default) draws it
+ /// the way it was designed. Only the "as printed" preview sets it; the design canvas and the exports keep it false.
+ ///
+ public bool Inverted { get; init; }
+
+ ///
+ /// True to draw the whole label as a left-to-right mirror image, as the printer does with ZPL ^PMY. False (the default)
+ /// draws it the way it was designed. Applied together with when both are set.
+ ///
+ public bool Mirrored { get; init; }
+
/// Builds a label from a physical size in millimetres, rounding to whole dots.
public static RenderLabel FromMillimetres(double widthMm, double heightMm, int dpi, IReadOnlyList elements) =>
new((int)Math.Round(widthMm / 25.4 * dpi), (int)Math.Round(heightMm / 25.4 * dpi), dpi, elements);
diff --git a/src/Rotula.Rendering/LabelRenderer.cs b/src/Rotula.Rendering/LabelRenderer.cs
index 848bc3b..ab4d0de 100644
--- a/src/Rotula.Rendering/LabelRenderer.cs
+++ b/src/Rotula.Rendering/LabelRenderer.cs
@@ -80,10 +80,32 @@ public static RenderResult RenderBitmap(RenderLabel label, RenderOptions? option
///
/// Draws all elements onto , whose coordinate system must already be in printer dots
- /// with the origin at the label's top-left corner. The background is NOT painted here.
+ /// with the origin at the label's top-left corner. The background is NOT painted here. When the label is
+ /// or the whole drawing is turned or flipped inside the label's own
+ /// area, so the caller's canvas setup stays the same.
///
/// Elements that could not be drawn faithfully; empty when everything was fine.
public static IReadOnlyList Draw(SKCanvas canvas, RenderLabel label)
+ {
+ if (!label.Inverted && !label.Mirrored) return DrawElements(canvas, label);
+ canvas.Save();
+ try
+ {
+ // Both are flips about the label's centre: ^POI flips both axes (a 180 degree turn), ^PM flips the horizontal axis.
+ // Together they leave only the vertical flip; the order does not matter because the two flips commute.
+ float sx = label.Inverted != label.Mirrored ? -1 : 1;
+ float sy = label.Inverted ? -1 : 1;
+ canvas.Translate(sx < 0 ? label.WidthDots : 0, sy < 0 ? label.HeightDots : 0);
+ canvas.Scale(sx, sy);
+ return DrawElements(canvas, label);
+ }
+ finally
+ {
+ canvas.Restore();
+ }
+ }
+
+ private static IReadOnlyList DrawElements(SKCanvas canvas, RenderLabel label)
{
const byte DimmedAlpha = 70;
var issues = new List();
diff --git a/tests/Rotula.Tests/PrintOrientationPreviewTests.cs b/tests/Rotula.Tests/PrintOrientationPreviewTests.cs
new file mode 100644
index 0000000..2730af6
--- /dev/null
+++ b/tests/Rotula.Tests/PrintOrientationPreviewTests.cs
@@ -0,0 +1,269 @@
+using System.ComponentModel;
+using Rotula.App.Editing;
+using Rotula.App.Localization;
+using Rotula.App.ViewModels;
+using Rotula.Core.Elements;
+using Rotula.Core.Model;
+using Rotula.Core.Zpl;
+using Rotula.Core.Zpl.Import;
+using Rotula.Rendering;
+using Rotula.Rendering.Export;
+using Rotula.Rendering.Input;
+using SkiaSharp;
+using static Rotula.Tests.RenderingTestSupport;
+
+namespace Rotula.Tests;
+
+///
+/// Print orientation (^PO) and mirror (^PM) in the preview: the "as printed" picture is turned or flipped exactly as the printer does,
+/// the canvas and the exports stay as designed, and imported ^POI / ^PON set the document setting and survive a round trip.
+///
+public class PrintOrientationPreviewTests
+{
+ // An asymmetric picture: a solid block in the top-left corner and a thin bar along the bottom edge, so every flip is told apart.
+ private static RenderLabel Asymmetric() => Label(200, 100,
+ new RenderBox { X = 10, Y = 5, WidthDots = 40, HeightDots = 20, ThicknessDots = 100 },
+ new RenderBox { X = 120, Y = 90, WidthDots = 70, HeightDots = 4, ThicknessDots = 100 });
+
+ private static bool[,] Dark(RenderLabel label)
+ {
+ using var r = LabelRenderer.RenderBitmap(label);
+ var dark = new bool[r.Bitmap.Width, r.Bitmap.Height];
+ for (int y = 0; y < r.Bitmap.Height; y++)
+ for (int x = 0; x < r.Bitmap.Width; x++)
+ dark[x, y] = IsDark(r.Bitmap, x, y);
+ return dark;
+ }
+
+ /// Asserts that every pixel of is the pixel of that names.
+ private static void AssertMapped(bool[,] designed, bool[,] actual, Func map)
+ {
+ int w = designed.GetLength(0), h = designed.GetLength(1);
+ Assert.Equal(w, actual.GetLength(0));
+ Assert.Equal(h, actual.GetLength(1));
+ for (int y = 0; y < h; y++)
+ for (int x = 0; x < w; x++)
+ {
+ var (sx, sy) = map(x, y);
+ Assert.True(designed[sx, sy] == actual[x, y], $"pixel ({x},{y}) should match designed ({sx},{sy})");
+ }
+ }
+
+ // ------------------------------------------------------------------------------- renderer
+
+ [Fact]
+ public void Inverted_label_is_the_designed_picture_turned_by_180_degrees_pixel_for_pixel()
+ {
+ var designed = Dark(Asymmetric());
+ var printed = Dark(Asymmetric() with { Inverted = true });
+ AssertMapped(designed, printed, (x, y) => (199 - x, 99 - y));
+
+ // The top-left block now sits in the bottom-right corner.
+ using var r = LabelRenderer.RenderBitmap(Asymmetric() with { Inverted = true, Elements = [Asymmetric().Elements[0]] });
+ Assert.Equal(new SKRectI(150, 75, 190, 95), DarkBounds(r.Bitmap));
+ }
+
+ [Fact]
+ public void Mirrored_label_swaps_left_and_right_only()
+ {
+ var designed = Dark(Asymmetric());
+ AssertMapped(designed, Dark(Asymmetric() with { Mirrored = true }), (x, y) => (199 - x, y));
+ }
+
+ [Fact]
+ public void Inverted_and_mirrored_label_is_flipped_top_to_bottom()
+ {
+ var designed = Dark(Asymmetric());
+ AssertMapped(designed, Dark(Asymmetric() with { Inverted = true, Mirrored = true }), (x, y) => (x, 99 - y));
+ }
+
+ [Fact]
+ public void Inversion_also_holds_at_a_zoomed_scale()
+ {
+ var label = Asymmetric() with { Elements = [Asymmetric().Elements[0]] };
+ using var r = LabelRenderer.RenderBitmap(label with { Inverted = true }, new RenderOptions { Scale = 2 });
+ Assert.Equal(new SKRectI(300, 150, 380, 190), DarkBounds(r.Bitmap));
+ }
+
+ // ------------------------------------------------------------------------------- adapter, canvas and exports
+
+ private static LabelDocument InvertedDoc()
+ {
+ var doc = CoreTestSupport.NewDoc(new BoxElement { X = 10, Y = 10, WidthDots = 40, HeightDots = 20, ThicknessDots = 20 });
+ doc.Width = Measure.Dots(200);
+ doc.Height = Measure.Dots(100);
+ doc.Orientation = LabelOrientation.Inverted;
+ return doc;
+ }
+
+ [Fact]
+ public void Adapter_keeps_the_design_upright_and_only_AsPrinted_turns_it()
+ {
+ var doc = InvertedDoc();
+ var designed = LabelDocumentAdapter.ToRenderLabel(doc);
+ Assert.False(designed.Inverted);
+ Assert.False(designed.Mirrored);
+
+ var printed = LabelDocumentAdapter.AsPrinted(designed, doc);
+ Assert.True(printed.Inverted);
+ Assert.False(printed.Mirrored);
+ Assert.True(LabelDocumentAdapter.ChangesWhenPrinted(doc));
+
+ doc.MirrorImage = true;
+ Assert.True(LabelDocumentAdapter.AsPrinted(designed, doc).Mirrored);
+
+ var normal = CoreTestSupport.NewDoc();
+ var label = LabelDocumentAdapter.ToRenderLabel(normal);
+ Assert.False(LabelDocumentAdapter.ChangesWhenPrinted(normal));
+ Assert.Same(label, LabelDocumentAdapter.AsPrinted(label, normal));
+ }
+
+ [Fact]
+ public void Png_export_stays_as_designed_for_an_inverted_label()
+ {
+ // Exports have no "as printed" notion: they are the artwork, so they keep the design upright (see docs/zpl-coverage.md).
+ var doc = InvertedDoc();
+ using var ms = new MemoryStream();
+ LabelExporter.SavePng(LabelDocumentAdapter.ToRenderLabel(doc), ms);
+ ms.Position = 0;
+ using var bmp = SKBitmap.Decode(ms);
+ Assert.Equal(new SKRectI(10, 10, 50, 30), DarkBounds(bmp));
+ }
+
+ // ------------------------------------------------------------------------------- ZPL import and round trip
+
+ [Theory]
+ [InlineData("^XA^POI^PW400^LL200^XZ", LabelOrientation.Inverted)]
+ [InlineData("^XA^POi^PW400^LL200^XZ", LabelOrientation.Inverted)]
+ [InlineData("^XA^PON^PW400^LL200^XZ", LabelOrientation.Normal)]
+ [InlineData("^XA^PW400^LL200^XZ", LabelOrientation.Normal)]
+ public void Imported_print_orientation_sets_the_document_setting(string zpl, LabelOrientation expected)
+ {
+ var r = ZplParser.Parse(zpl);
+ Assert.Empty(r.Warnings);
+ Assert.Equal(expected, r.Document.Orientation);
+ }
+
+ [Fact]
+ public void Inverted_and_mirrored_label_round_trips_through_zpl()
+ {
+ var doc = InvertedDoc();
+ doc.MirrorImage = true;
+ string zpl = CoreTestSupport.Zpl(doc);
+ Assert.Contains("\n^POI\n", zpl);
+ Assert.Contains("\n^PMY\n", zpl);
+
+ var back = ZplParser.Parse(zpl);
+ Assert.Empty(back.Warnings);
+ Assert.Equal(LabelOrientation.Inverted, back.Document.Orientation);
+ Assert.True(back.Document.MirrorImage);
+ Assert.Equal(zpl, CoreTestSupport.Zpl(back.Document));
+
+ // The imported label previews the same way as the one it came from.
+ Assert.True(LabelDocumentAdapter.AsPrinted(LabelDocumentAdapter.ToRenderLabel(back.Document), back.Document).Inverted);
+ }
+
+ [Fact]
+ public void Golden_inverted_label() =>
+ CoreTestSupport.AssertGolden("label_print_inverted", CoreTestSupport.Zpl(InvertedDoc()));
+}
+
+/// The preview window's "as printed" / "as designed" switch.
+[Collection("App")]
+public class PreviewViewModelTests
+{
+ public PreviewViewModelTests() => Loc.Instance.SetLanguage("en");
+
+ private static PreviewCache Cache(LabelOrientation orientation, bool mirror = false)
+ {
+ var session = AppTestSupport.Session(AppTestSupport.Box(10, 10, 40, 20));
+ session.Document.Width = Measure.Dots(200);
+ session.Document.Height = Measure.Dots(100);
+ session.Document.Orientation = orientation;
+ session.Document.MirrorImage = mirror;
+ session.Touch();
+ return new PreviewCache(session);
+ }
+
+ private static SKRectI? Bounds(PreviewViewModel vm)
+ {
+ using var r = vm.Render(out string? error);
+ Assert.Null(error);
+ return DarkBounds(r!.Bitmap);
+ }
+
+ [Fact]
+ public void Inverted_label_opens_as_printed_and_switches_to_as_designed()
+ {
+ using var cache = Cache(LabelOrientation.Inverted);
+ var vm = new PreviewViewModel(cache);
+ Assert.True(vm.ChangesWhenPrinted);
+ Assert.True(vm.AsPrinted);
+ Assert.False(vm.AsDesigned);
+ Assert.Equal(Loc.Get("Preview.Note.PrintedInverted"), vm.ModeNote);
+ Assert.Equal(new SKRectI(150, 70, 190, 90), Bounds(vm));
+
+ int redraws = 0;
+ var changed = new List();
+ vm.PictureChanged += (_, _) => redraws++;
+ ((INotifyPropertyChanged)vm).PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+
+ vm.AsDesigned = true;
+ Assert.False(vm.AsPrinted);
+ Assert.Equal(1, redraws);
+ Assert.Contains(nameof(PreviewViewModel.ModeNote), changed);
+ Assert.Contains(nameof(PreviewViewModel.AsDesigned), changed);
+ Assert.Equal(Loc.Get("Preview.Note.Designed"), vm.ModeNote);
+ Assert.Equal(new SKRectI(10, 10, 50, 30), Bounds(vm));
+
+ // A radio unchecked by its sibling writes false back; that must not flip the choice again.
+ vm.AsDesigned = false;
+ Assert.False(vm.AsPrinted);
+ Assert.Equal(1, redraws);
+
+ vm.AsPrinted = true;
+ Assert.Equal(2, redraws);
+ Assert.Equal(new SKRectI(150, 70, 190, 90), Bounds(vm));
+ }
+
+ [Fact]
+ public void Mirrored_and_combined_labels_say_why_the_picture_differs()
+ {
+ using var mirrored = Cache(LabelOrientation.Normal, mirror: true);
+ var vm = new PreviewViewModel(mirrored);
+ Assert.Equal(Loc.Get("Preview.Note.PrintedMirrored"), vm.ModeNote);
+ Assert.Equal(new SKRectI(150, 10, 190, 30), Bounds(vm));
+
+ using var both = Cache(LabelOrientation.Inverted, mirror: true);
+ Assert.Equal(Loc.Get("Preview.Note.PrintedBoth"), new PreviewViewModel(both).ModeNote);
+ }
+
+ [Fact]
+ public void Normal_label_has_no_switch_and_no_note()
+ {
+ using var cache = Cache(LabelOrientation.Normal);
+ var vm = new PreviewViewModel(cache);
+ Assert.False(vm.ChangesWhenPrinted);
+ Assert.Equal(string.Empty, vm.ModeNote);
+ Assert.Equal(new SKRectI(10, 10, 50, 30), Bounds(vm));
+ }
+
+ [Fact]
+ public void Status_bar_names_a_label_that_prints_upside_down()
+ {
+ var vm = new MainViewModel(new FakeServices().Build());
+ Assert.Equal(string.Empty, vm.PrintOrientationText);
+
+ var changed = new List();
+ vm.PropertyChanged += (_, e) => changed.Add(e.PropertyName);
+ vm.Session.Edit("Undo.LabelSize", d => d.Orientation = LabelOrientation.Inverted);
+ Assert.Contains(nameof(MainViewModel.PrintOrientationText), changed);
+ Assert.Equal(Loc.Get("Status.PrintsInverted"), vm.PrintOrientationText);
+
+ vm.Session.Edit("Undo.LabelSize", d => d.MirrorImage = true);
+ Assert.Equal(Loc.Get("Status.PrintsInvertedMirrored"), vm.PrintOrientationText);
+
+ // The canvas keeps drawing the design upright.
+ Assert.False(vm.Preview.GetDesignLabel().Inverted);
+ }
+}
diff --git a/tests/fixtures/label_print_inverted.zpl b/tests/fixtures/label_print_inverted.zpl
new file mode 100644
index 0000000..23f88f5
--- /dev/null
+++ b/tests/fixtures/label_print_inverted.zpl
@@ -0,0 +1,13 @@
+^XA
+^MNY
+^MTT
+^CI28
+^PW200
+^LL100
+^POI
+^LH0,0
+^FWN
+^CF0,30,30
+^FO10,10^GB40,20,20,B,0^FS
+^PQ1,0,1,N
+^XZ