From 245b045cccaa8c901702833e600be1c4945491ed Mon Sep 17 00:00:00 2001 From: glenntosco Date: Mon, 12 Oct 2026 14:35:03 -0500 Subject: [PATCH 01/25] Make barcode sizing understandable and printer-aware Show the module as mil and mm with the printed symbol width and the quiet zone under the module setting, add a - / + stepper, draw the quiet zone of the selected barcode on the canvas, and warn in the Problems list about one-dot modules, thin linear bars at 203 dpi and a symbol plus quiet zones wider than the label. --- CHANGELOG.md | 1 + docs/decisions.md | 20 ++ src/Rotula.App/Controls/DesignCanvas.cs | 8 + src/Rotula.App/Resources/Strings.en.resx | 30 ++ src/Rotula.App/Resources/Strings.es.resx | 30 ++ src/Rotula.App/ViewModels/BarcodeSizeInfo.cs | 97 ++++++ .../ViewModels/PropertyPanelViewModel.cs | 20 +- src/Rotula.App/ViewModels/PropertyRows.cs | 38 +++ src/Rotula.App/Views/PropertiesPanel.xaml | 18 +- src/Rotula.Core/Barcodes/BarcodeSizing.cs | 305 ++++++++++++++++++ src/Rotula.Core/Zpl/ZplGenerator.cs | 3 + tests/Rotula.Tests/AppBarcodeSizeTests.cs | 201 ++++++++++++ tests/Rotula.Tests/AppDataMergeTests.cs | 2 +- tests/Rotula.Tests/AppPropertyPanelTests.cs | 2 +- tests/Rotula.Tests/BarcodeSizingTests.cs | 213 ++++++++++++ 15 files changed, 980 insertions(+), 8 deletions(-) create mode 100644 src/Rotula.App/ViewModels/BarcodeSizeInfo.cs create mode 100644 src/Rotula.Core/Barcodes/BarcodeSizing.cs create mode 100644 tests/Rotula.Tests/AppBarcodeSizeTests.cs create mode 100644 tests/Rotula.Tests/BarcodeSizingTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4083e..ca63673 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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. +- **Barcode size you can understand, and checked against the printer.** Under the module width of a barcode (and the module size of QR Code, Data Matrix and Aztec) the Properties panel now says what the setting means on paper: "Module: 9.9 mil (0.25 mm) - symbol 17 mm wide", plus the quiet zone the symbol needs (for example 10X on each side for Code 128, 11X before and 7X after for EAN-13, 4X for QR Code), and a small - / + pair changes the module one dot at a time with the readout following live. While one barcode is selected the design canvas shows its quiet zone as a dotted rectangle. The Problems list now warns, in plain words, when the module is a single dot at 300 dpi or more, when a linear code has bars thinner than 2 dots at 203 dpi, and when the symbol plus its quiet zones does not fit the label width. Nothing about the file format changed. ### Fixed - **Windows fonts that are named after a style now draw in that style.** "Arial Black", "Segoe UI Semibold", "Segoe UI Black", "Bahnschrift Condensed" and similar names used to be replaced by the default font in the preview, PNG/PDF export and print picture; they now resolve to their parent family at the weight and width the name says. diff --git a/docs/decisions.md b/docs/decisions.md index 1beea9e..521ba24 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -530,3 +530,23 @@ emitted or validated, so a barcode invalid only on a record where it is hidden d prints and PNG/JPEG/PDF; only the design canvas draws them dimmed (`includeNonPrinting`), and the Preview window shows the printed result. Lock is enforced in `EditorCommands` (move, nudge, resize, rotate, align, distribute, delete, cut) and never touches ZPL; property edits stay allowed. Condition fields are listed as merge placeholders so they can be mapped to columns. ZPL import never creates conditions or locks. + +## Barcode sizing (Rotula.Core/Barcodes/BarcodeSizing) + +### Dots stay the stored unit; mil and mm are derived +The file keeps `moduleWidth` / `moduleSize` in dots (what ^BY and ^BQ take), so the format did not change. `BarcodeSizing` derives the X-dimension in +mils and mm from the dots and the label dpi, and the panel shows it. The module is edited in dots, with a - / + stepper, because that is what the +printer can do: a mil value that is not a whole number of dots would silently be rounded. + +### The symbol width is only claimed where the symbology's rules fix it +Core measures Code 128 (automatic mode, no GS1 parentheses), Code 39/LOGMARS, Code 93, Interleaved 2 of 5, EAN/UPC and QR Code model 2 from their element +counts (wide bar = narrow x ratio rounded to whole dots, as ZPL does). Core cannot call the preview encoders (they live in Rotula.Rendering), so for +the other symbologies the panel passes the encoder's module count (`BarcodeSizeInfo`), and the generator's fit warning simply stays silent: it never +guesses a width. The preview draws wide bars at a fixed 3:1, so the encoder count is used only when the element's ratio is 3.0. + +### Quiet zones follow the standards, not one number +Values are per symbology (Code 128/39/93, I2of5, Codabar 10X; EAN-13 11X/7X; UPC-A 9X/9X; EAN-8 7X/7X; UPC-E 9X/7X; QR 4X; Data Matrix 1X; +PDF417 2X; Aztec none) with the source named next to each in the code. A symbology without an entry gets a conservative family default, which is a +safe margin and not a claim about a standard. The fit check (symbol plus quiet zones against the label width) runs only for upright fields; the canvas +guide handles every rotation because it grows the real frame along the reading axis. All three new findings are warnings, never errors: the label still +prints. Thresholds: a 1-dot module at 300 dpi or more; a linear narrow bar under 2 dots at 203 dpi. diff --git a/src/Rotula.App/Controls/DesignCanvas.cs b/src/Rotula.App/Controls/DesignCanvas.cs index aae497e..28fc112 100644 --- a/src/Rotula.App/Controls/DesignCanvas.cs +++ b/src/Rotula.App/Controls/DesignCanvas.cs @@ -36,6 +36,9 @@ private enum Mode { None, Move, Resize, Rotate, Marquee } private static readonly Brush GridDotBrush = new SolidColorBrush(Color.FromArgb(120, 110, 100, 90)); private static readonly Pen MarginPen = CreatePen(Color.FromArgb(150, 200, 120, 0), 1, new DashStyle(new double[] { 3, 3 }, 0)); + // Subtle blue dots: quieter than the orange selection frame, readable on white paper and on a dark barcode edge. + private static readonly Pen QuietZonePen = CreatePen(Color.FromArgb(230, 40, 120, 190), 1, new DashStyle(new double[] { 2, 3 }, 0)); + private MainViewModel? _vm; private WriteableBitmap? _bitmap; private long _bitmapRevision = -1; @@ -282,6 +285,11 @@ private void DrawSelection(DrawingContext dc) foreach (var e in selected) dc.DrawRectangle(null, pen, ToPixelRect(_vm.Session.Bounds.GetFrame(e))); + // The blank margin a scanner needs around a selected barcode, as a dotted guide; it is drawn only while that one barcode is selected, + // so it never clutters the label or reaches a print or export (this canvas is the only place that draws it). + if (selected is [BarcodeElement single] && BarcodeSizeInfo.QuietZoneFrame(_vm.Session.Bounds.GetFrame(single), single, Doc.DefaultRotation) is { } quiet) + dc.DrawRectangle(null, QuietZonePen, ToPixelRect(quiet)); + var union = RectD.UnionAll(selected.Select(_vm.Session.Bounds.GetFrame)); if (selected.Count > 1) dc.DrawRectangle(null, new Pen(AccentBrush, 1), ToPixelRect(union)); diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx index 6e0d2c7..a3b1ee3 100644 --- a/src/Rotula.App/Resources/Strings.en.resx +++ b/src/Rotula.App/Resources/Strings.en.resx @@ -3043,6 +3043,36 @@ The printer always prints the text under a LOGMARS barcode, so switching it off has no effect. Turn the text on to match what will print. + + The narrowest bar or module is 1 dot ({1} mil, {2} mm) at {3} dpi. Many scanners cannot read bars this narrow, and a little ink spread closes the gaps. Use a module of 2 dots or more. + + + The narrowest bar is {0} dot ({1} mil, {2} mm) at {3} dpi. Bars thinner than 2 dots print unevenly on a 203 dpi printer, which makes the code unreliable to scan. Use a module of 2 dots or more. + + + The barcode and its quiet zones (the blank margin a scanner needs) span {0} mm to {1} mm, but the label is {2} mm wide. A cut-off margin can stop the code from scanning. Move the barcode, make the module narrower or shorten the data. + + + Module: {0} mil ({1} mm) - symbol {2} mm wide + + + Module: {0} mil ({1} mm) + + + Quiet zone: {0} mm on each side ({1}X) + + + Quiet zone: {0} mm before and {1} mm after the symbol ({2}X and {3}X) + + + No quiet zone is needed. + + + Narrower module (1 dot less) + + + Wider module (1 dot more) + Codabar allows only digits and - $ : / . + in the data. The start and stop letters A to D are chosen in the options, not typed in the data. diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx index 653f841..ffb3f5d 100644 --- a/src/Rotula.App/Resources/Strings.es.resx +++ b/src/Rotula.App/Resources/Strings.es.resx @@ -3043,6 +3043,36 @@ La impresora siempre imprime el texto debajo de un código LOGMARS, así que desactivarlo no tiene efecto. Active el texto para que coincida con lo que se imprimirá. + + La barra o módulo más estrecho mide 1 punto ({1} mil, {2} mm) a {3} dpi. Muchos lectores no pueden leer barras tan estrechas y un poco de tinta extendida cierra los espacios. Use un módulo de 2 puntos o más. + + + La barra más estrecha mide {0} punto ({1} mil, {2} mm) a {3} dpi. Las barras de menos de 2 puntos se imprimen de forma irregular en una impresora de 203 dpi, y el código se lee con poca fiabilidad. Use un módulo de 2 puntos o más. + + + El código de barras y sus zonas de silencio (el margen en blanco que necesita el lector) ocupan de {0} mm a {1} mm, pero la etiqueta mide {2} mm de ancho. Un margen cortado puede impedir la lectura. Mueva el código, use un módulo más estrecho o acorte los datos. + + + Módulo: {0} mil ({1} mm) - símbolo de {2} mm de ancho + + + Módulo: {0} mil ({1} mm) + + + Zona de silencio: {0} mm a cada lado ({1}X) + + + Zona de silencio: {0} mm antes y {1} mm después del símbolo ({2}X y {3}X) + + + No se necesita zona de silencio. + + + Módulo más estrecho (1 punto menos) + + + Módulo más ancho (1 punto más) + Codabar solo admite dígitos y - $ : / . + en los datos. Las letras de inicio y fin (A a D) se eligen en las opciones, no se escriben en los datos. diff --git a/src/Rotula.App/ViewModels/BarcodeSizeInfo.cs b/src/Rotula.App/ViewModels/BarcodeSizeInfo.cs new file mode 100644 index 0000000..c4879ab --- /dev/null +++ b/src/Rotula.App/ViewModels/BarcodeSizeInfo.cs @@ -0,0 +1,97 @@ +using System.Globalization; +using Rotula.App.Localization; +using Rotula.Core.Barcodes; +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Rendering.Barcodes; + +namespace Rotula.App.ViewModels; + +/// +/// Turns the sizing figures of into the plain-language lines under the module setting of a barcode: +/// "Module: 7.5 mil (0.19 mm) - symbol 48.3 mm wide" and the quiet zone note. No arithmetic lives here; this class only chooses the +/// wording and, where the Core rules cannot measure a symbol (Data Matrix, PDF417, ...), asks the preview encoder for its module count. +/// +public static class BarcodeSizeInfo +{ + /// + /// The size report for at the label's resolution, or null when the symbology has no adjustable module. + /// is the text the symbol prints (the longest sample when the data comes from fields). + /// + public static BarcodeSizeReport? Report(BarcodeElement b, string? data, int dpi) + { + data ??= b.Data; + var report = BarcodeSizing.Analyze(b, data, dpi); + if (report is null || report.SymbolWidthDots is not null || data.Length == 0) return report; + + // Core could not measure it: use the preview encoder's module count where that is true to the printer. A symbol with wide bars + // is drawn with the fixed ratio 3 (see LinearEncoders), so its count is only used when the element asks for the same ratio. + var info = BarcodeCatalog.Get(b.Symbology); + if (info.Supports(BarcodeOption.WideBarRatio) && Math.Abs(b.WideBarRatio - 3.0) > 1e-9) return report; + var matrix = BarcodeEncoder.TryEncode(b.Symbology, data, new BarcodeEncodeOptions + { + QrErrorCorrection = b.QrErrorCorrection, + Pdf417SecurityLevel = b.Pdf417SecurityLevel, + Pdf417Truncate = b.Pdf417Truncate, + CheckDigit = b.CheckDigit, + Code11CheckDigits = b.Code11CheckDigitCount, + MsiCheckDigit = b.MsiCheckDigit, + MsiCheckDigitInText = b.MsiCheckDigitInText, + CodabarStart = b.CodabarStart, + CodabarStop = b.CodabarStop, + DataBarType = b.DataBarType, + DataBarSegmentsPerRow = b.DataBarSegmentsPerRow, + DataBarSeparatorHeight = b.DataBarSeparatorHeight, + AztecErrorControl = b.AztecErrorControl, + Gs1 = b.DataMatrixGs1, + }, out _); + // Hexagonal and postal layouts are not a plain module grid, so their width in modules is not a width in dots. + return matrix is { Layout: not (BarcodeRowLayout.Hexagonal or BarcodeRowLayout.Proportional) } + ? BarcodeSizing.Analyze(b, data, dpi, matrix.Width) + : report; + } + + /// + /// The rectangle (label dots) that holds the symbol and its quiet zone, for the dotted guide on the design canvas: the element's frame grown + /// by the quiet zone on the sides that need it. Null when the symbology has no adjustable module (nothing to say about its margin). + /// + public static Editing.RectD? QuietZoneFrame(Editing.RectD frame, BarcodeElement b, FieldRotation defaultRotation) + { + if (BarcodeSizing.ModuleDotsOf(b) is null) return null; + var q = BarcodeSizing.QuietZoneBox(b, b.Rotation ?? defaultRotation); + return new Editing.RectD(frame.X - q.Left, frame.Y - q.Top, frame.Width + q.Left + q.Right, frame.Height + q.Top + q.Bottom); + } + + /// The "Module: ..." line. adds the printed width of the symbol when it is known. + public static string ReadoutLine(BarcodeSizeReport r, bool withSymbol = true) + { + string mil = r.XDimensionMils.ToString("0.#", CultureInfo.CurrentCulture); + string mm = r.XDimensionMm.ToString("0.##", CultureInfo.CurrentCulture); + return withSymbol && r.SymbolWidthMm is { } w + ? Loc.Get("Prop.Size.Readout", mil, mm, w.ToString("0.#", CultureInfo.CurrentCulture)) + : Loc.Get("Prop.Size.ReadoutNoSymbol", mil, mm); + } + + /// The quiet zone note: one value when both sides are equal, two when they differ (EAN-13), a short sentence when none is needed. + public static string QuietZoneLine(BarcodeSizeReport r) + { + var q = r.QuietZone; + if (q.LeftModules == 0 && q.RightModules == 0) return Loc.Get("Prop.Size.QuietNone"); + string left = r.QuietLeftMm.ToString("0.#", CultureInfo.CurrentCulture), right = r.QuietRightMm.ToString("0.#", CultureInfo.CurrentCulture); + return q.LeftModules == q.RightModules + ? Loc.Get("Prop.Size.QuietBoth", left, q.LeftModules) + : Loc.Get("Prop.Size.QuietLeftRight", left, right, q.LeftModules, q.RightModules); + } + + /// + /// The second line of the module row: readout plus quiet zone note, in the warning colour when the printer reality check complains about + /// the module. The wording of that complaint itself belongs to the problems list, so it is not repeated here. + /// + public static RowDetail? Describe(BarcodeElement b, string? data, LabelDocument doc, bool withSymbol = true) + { + var report = Report(b, data, (int)doc.Dpi); + if (report is null) return null; + bool warn = BarcodeSizing.Check(b, data ?? b.Data, doc).Any(i => i.MessageKey is BarcodeSizing.OneDotKey or BarcodeSizing.ThinBarKey); + return new RowDetail(ReadoutLine(report, withSymbol) + Environment.NewLine + QuietZoneLine(report), warn); + } +} diff --git a/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs b/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs index 9599442..220b196 100644 --- a/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs +++ b/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs @@ -533,7 +533,10 @@ private void AddBarcodeOption(string id, BarcodeElement element, BarcodeSymbolog break; } case BarcodeOption.ModuleWidth: - AddInt(id, "Prop.ModuleWidth", e => e.ModuleWidth, (e, v) => e.ModuleWidth = v, 1, 10, "Barcode.ModuleWidth.Range"); + // The module row carries a stepper and the printed size (mil, mm, symbol width, quiet zone), which follows every edit. + Rows.Add(new IntRow("Prop.ModuleWidth", () => El(id).ModuleWidth, v => Edit(id, "Prop.ModuleWidth", e => e.ModuleWidth = v!.Value), 1, 10, + rangeIssueKey: "Barcode.ModuleWidth.Range") + { FullWidth = true, ShowStepper = true, DetailSource = () => ModuleWidthDetail(El(id)) }); break; case BarcodeOption.WideBarRatio: Rows.Add(new DoubleRow("Prop.WideBarRatio", () => El(id).WideBarRatio, @@ -584,9 +587,11 @@ private void AddBarcodeOption(string id, BarcodeElement element, BarcodeSymbolog if (sym == BarcodeSymbology.QrCode) Rows.Add(new IntRow("Prop.ModuleSize", () => El(id).ModuleSize, v => Edit(id, "Prop.ModuleSize", e => e.ModuleSize = v!.Value), 1, 10, rangeIssueKey: "Barcode.ModuleSize.Range") - { FullWidth = true, DetailSource = () => ModuleSizeDetail(El(id)) }); + { FullWidth = true, ShowStepper = true, DetailSource = () => ModuleSizeDetail(El(id)) }); else - AddInt(id, "Prop.ModuleSize", e => e.ModuleSize, (e, v) => e.ModuleSize = v, 1, 10, "Barcode.ModuleSize.Range"); + Rows.Add(new IntRow("Prop.ModuleSize", () => El(id).ModuleSize, v => Edit(id, "Prop.ModuleSize", e => e.ModuleSize = v!.Value), 1, 10, + rangeIssueKey: "Barcode.ModuleSize.Range") + { FullWidth = true, ShowStepper = true, DetailSource = () => ModuleWidthDetail(El(id)) }); break; case BarcodeOption.Columns: if (sym == BarcodeSymbology.Codablock) @@ -753,9 +758,16 @@ private string SpecStatus(BarcodeElement b) var report = QrCapacityInfo.Describe(LongestPayload(b).Payload, b.QrErrorCorrection, b.QrModel); // Model 1 has no version table and an overflowing code has no symbol: the module is still worth describing. int version = report.Fit is { Fits: true, Version: > 0 } fit ? fit.Version : 0; - return QrCapacityInfo.ModuleSize(version, b.ModuleSize, (int)_session.Document.Dpi); + var qr = QrCapacityInfo.ModuleSize(version, b.ModuleSize, (int)_session.Document.Dpi); + // The QR text already says the printed size and the scan limit; add the module in mil and the quiet zone (4 modules) under it. + var sizing = BarcodeSizeInfo.Describe(b, LongestPayload(b).Payload, _session.Document, withSymbol: false); + return sizing is null ? qr : new RowDetail(qr.Text + Environment.NewLine + sizing.Text, qr.IsWarning || sizing.IsWarning); } + /// The "Module: 7.5 mil (0.19 mm) - symbol 48.3 mm wide" readout and the quiet zone note under a module setting. + private RowDetail? ModuleWidthDetail(BarcodeElement b) => + BarcodeSizeInfo.Describe(b, LongestPayload(b).Payload, _session.Document); + /// /// Opens the QR content builder for a barcode on the label and, when the user applies it, stores the structured definition and the /// text it builds in one undo step. Returns true when something was applied. diff --git a/src/Rotula.App/ViewModels/PropertyRows.cs b/src/Rotula.App/ViewModels/PropertyRows.cs index 0fb8566..1f9db3c 100644 --- a/src/Rotula.App/ViewModels/PropertyRows.cs +++ b/src/Rotula.App/ViewModels/PropertyRows.cs @@ -118,6 +118,15 @@ public IntRow(string labelKey, Func get, Action set, int min, int ma /// True to give the row a whole line instead of sharing it with the next number (used when it carries a longer second line). public bool FullWidth { get; init; } + /// True to show a compact "-" / "+" pair next to the box that changes the value by one (the module width of a barcode). + public bool ShowStepper { get; init; } + + /// Translated tool tip of the "-" button. + public string StepDownHint => Loc.Get("Prop.Size.Narrower"); + + /// Translated tool tip of the "+" button. + public string StepUpHint => Loc.Get("Prop.Size.Wider"); + /// The text in the box. [ObservableProperty] private string _text; @@ -128,6 +137,35 @@ partial void OnTextChanged(string value) Commit(value); } + /// + /// Changes the value by , stopping at the limits. It starts from the value in the document, not from the box, + /// so a half-typed number does not steer the step; the box then shows the new value and the edit goes through the normal commit + /// (one undo step per click, merged with the typing of the same property). + /// + private void Step(int delta) + { + int current = _get() ?? _min; + int next = Math.Clamp(current + delta, _min, _max); + // Clear an old complaint: the stepped value is always valid. + Error = string.Empty; + string text = Format(next); + if (Text == text) + { + // Same text but the document may differ (the box showed a typed value that was rejected): write the value through. + if (_get() != next) _set(next); + return; + } + Text = text; + } + + /// Makes the value one smaller. + [RelayCommand] + private void StepDown() => Step(-1); + + /// Makes the value one larger. + [RelayCommand] + private void StepUp() => Step(1); + private bool _applying; private void Commit(string value) diff --git a/src/Rotula.App/Views/PropertiesPanel.xaml b/src/Rotula.App/Views/PropertiesPanel.xaml index 503aa7f..c2863d5 100644 --- a/src/Rotula.App/Views/PropertiesPanel.xaml +++ b/src/Rotula.App/Views/PropertiesPanel.xaml @@ -37,8 +37,22 @@ - + + + + + + + + + diff --git a/src/Rotula.Core/Barcodes/BarcodeSizing.cs b/src/Rotula.Core/Barcodes/BarcodeSizing.cs new file mode 100644 index 0000000..3bdd33f --- /dev/null +++ b/src/Rotula.Core/Barcodes/BarcodeSizing.cs @@ -0,0 +1,305 @@ +using System.Globalization; +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Core.Validation; + +namespace Rotula.Core.Barcodes; + +/// +/// The quiet zone (clear margin) a symbology needs around its symbol, in modules (one module is one X-dimension), and where the +/// requirement comes from. A reader needs this margin to find the start and end of the symbol; printing something inside it, or running +/// the label edge through it, is the most common reason a good-looking barcode does not scan. +/// +/// Margin before the first bar, in modules, along the reading direction. +/// Margin after the last bar, in modules, along the reading direction. +/// True for 2D and stacked symbols, which need the margin on all four sides (then left/right are the value for each side). +/// The standard or specification the value comes from (plain text, for tool tips and documentation). +public sealed record BarcodeQuietZone(int LeftModules, int RightModules, bool AllSides, string Source); + +/// The quiet zone around a barcode in dots, for each side of the label orientation (what the design canvas draws). +/// Dots to the left of the symbol. +/// Dots above the symbol. +/// Dots to the right of the symbol. +/// Dots below the symbol. +public readonly record struct QuietZoneDots(int Left, int Top, int Right, int Bottom); + +/// +/// Everything a person needs to judge the size of a barcode on paper: the X-dimension, the bar widths, the printed width of the symbol +/// and the quiet zone, all derived from the dot-based settings and the label resolution. +/// +/// Printer resolution the figures are for. +/// Module (narrow bar) width in dots. +/// X-dimension in mils (1/1000 inch). +/// X-dimension in millimetres. +/// Narrow bar width in dots (the same as ). +/// Wide bar width in dots, or null when the symbology has no separate wide bar (Code 128, EAN/UPC, 2D codes). +/// Printed width of the symbol without quiet zones in dots, or null when it cannot be known without encoding. +/// The same in millimetres, or null. +/// The quiet zone the symbology needs. +/// Quiet zone before the symbol in dots. +/// Quiet zone after the symbol in dots. +/// Quiet zone before the symbol in millimetres. +/// Quiet zone after the symbol in millimetres. +public sealed record BarcodeSizeReport( + int Dpi, + int ModuleDots, + double XDimensionMils, + double XDimensionMm, + int NarrowBarDots, + int? WideBarDots, + int? SymbolWidthDots, + double? SymbolWidthMm, + BarcodeQuietZone QuietZone, + int QuietLeftDots, + int QuietRightDots, + double QuietLeftMm, + double QuietRightMm) +{ + /// Symbol plus both quiet zones in dots, or null when the symbol width is not known. + public int? TotalWidthDots => SymbolWidthDots is { } w ? w + QuietLeftDots + QuietRightDots : null; +} + +/// +/// Pure sizing helper for barcodes: turns the dot-based module width and the label resolution into the figures printers and +/// scanner specifications use (X-dimension in mils and mm), measures the symbol where the width follows from the symbology rules, +/// and checks the printer reality (is the module big enough to print and scan) and the quiet zone (does the symbol fit on the label). +/// It carries no UI text: warnings are s with message keys the app localizes. +/// +public static class BarcodeSizing +{ + /// Warning key: the module is one dot at 300 dpi or more. + public const string OneDotKey = "Barcode.Size.OneDotModule"; + /// Warning key: a 1D narrow bar thinner than two dots at 203 dpi. + public const string ThinBarKey = "Barcode.Size.ThinBar"; + /// Warning key: the symbol with its quiet zones does not fit the label width. + public const string QuietZoneKey = "Barcode.Size.QuietZoneOutside"; + + // Scan-reliability thresholds of the printer reality check. A 203 dpi head prints a dot about 0.125 mm wide; a bar of one dot has + // no tolerance for dot gain or a slightly worn head, which is why bar code printer guides advise at least two dots for linear codes. + private const int MinNarrowDots203 = 2; + private const int HighDpi = 300; + + /// + /// Quiet zone per symbology. Values are in modules (X). Sources: Code 128 ISO/IEC 15417 (10X, also GS1-128 in the GS1 General + /// Specifications); Code 39 ISO/IEC 16388 (10X); Interleaved 2 of 5 ISO/IEC 16390 (10X, also ITF-14 in GS1); EAN-13 11X left and 7X + /// right, UPC-A 9X on both sides, EAN-8 7X on both sides, UPC-E 9X left and 7X right, add-ons 7X left and 5X right (GS1 General + /// Specifications, ISO/IEC 15420); QR Code 4X (ISO/IEC 18004); Data Matrix 1X (ISO/IEC 16022); PDF417 2X (ISO/IEC 15438); + /// MicroPDF417 1X (ISO/IEC 24728); Aztec Code needs none (ISO/IEC 24778). Code 93 and Codabar use 10X (AIM uniform symbology + /// specifications). Symbologies without an entry fall back to a conservative value by family (see ). + /// + private static readonly Dictionary QuietZones = new() + { + [BarcodeSymbology.Code128] = new(10, 10, false, "ISO/IEC 15417"), + [BarcodeSymbology.Code39] = new(10, 10, false, "ISO/IEC 16388"), + [BarcodeSymbology.Logmars] = new(10, 10, false, "ISO/IEC 16388, MIL-STD-1189"), + [BarcodeSymbology.Code93] = new(10, 10, false, "AIM USS Code 93"), + [BarcodeSymbology.Interleaved2Of5] = new(10, 10, false, "ISO/IEC 16390"), + [BarcodeSymbology.Codabar] = new(10, 10, false, "AIM USS Codabar"), + [BarcodeSymbology.Ean13] = new(11, 7, false, "GS1 General Specifications"), + [BarcodeSymbology.UpcA] = new(9, 9, false, "GS1 General Specifications"), + [BarcodeSymbology.Ean8] = new(7, 7, false, "GS1 General Specifications"), + [BarcodeSymbology.UpcE] = new(9, 7, false, "GS1 General Specifications"), + [BarcodeSymbology.UpcEanExtension] = new(7, 5, false, "GS1 General Specifications"), + [BarcodeSymbology.QrCode] = new(4, 4, true, "ISO/IEC 18004"), + [BarcodeSymbology.DataMatrix] = new(1, 1, true, "ISO/IEC 16022"), + [BarcodeSymbology.Pdf417] = new(2, 2, true, "ISO/IEC 15438"), + [BarcodeSymbology.MicroPdf417] = new(1, 1, true, "ISO/IEC 24728"), + [BarcodeSymbology.Aztec] = new(0, 0, true, "ISO/IEC 24778"), + }; + + /// + /// The quiet zone of . A symbology with no entry in the table gets a conservative family default + /// (linear 10X, matrix 1X, stacked 2X, postal none); that default is a safe margin, not a citation of a standard. + /// + public static BarcodeQuietZone QuietZoneOf(BarcodeSymbology symbology) + { + if (QuietZones.TryGetValue(symbology, out var known)) return known; + var category = Enum.IsDefined(symbology) ? BarcodeCatalog.Get(symbology).Category : BarcodeCategory.Linear; + return category switch + { + BarcodeCategory.Matrix2D => new BarcodeQuietZone(1, 1, true, "default"), + BarcodeCategory.Stacked => new BarcodeQuietZone(2, 2, true, "default"), + BarcodeCategory.Postal => new BarcodeQuietZone(0, 0, false, "default"), + _ => new BarcodeQuietZone(10, 10, false, "default"), + }; + } + + /// + /// The module of the symbol in dots: the ^BY width for symbologies that have , the magnification for + /// those with , otherwise null (MaxiCode has a fixed size). + /// + public static int? ModuleDotsOf(BarcodeElement b) + { + ArgumentNullException.ThrowIfNull(b); + if (!Enum.IsDefined(b.Symbology)) return null; + var info = BarcodeCatalog.Get(b.Symbology); + if (info.Supports(BarcodeOption.ModuleWidth)) return b.ModuleWidth; + if (info.Supports(BarcodeOption.ModuleSize)) return b.ModuleSize; + return null; + } + + /// X-dimension in mils (1/1000 inch) of a module of dots at . + public static double MilsOf(int moduleDots, int dpi) + { + if (dpi <= 0) throw new ArgumentOutOfRangeException(nameof(dpi)); + return moduleDots * 1000.0 / dpi; + } + + /// Length in millimetres of dots at (the X-dimension when given the module width). + public static double MmOf(double dots, int dpi) + { + if (dpi <= 0) throw new ArgumentOutOfRangeException(nameof(dpi)); + return dots * 25.4 / dpi; + } + + /// + /// Describes the size of printing (variables already resolved) at . + /// Returns null for a symbology without an adjustable module. The symbol width is measured here for the symbologies whose width follows + /// from their rules (Code 128, Code 39, Code 93, Interleaved 2 of 5, EAN/UPC, QR Code); for the others the caller may pass the module + /// count from the preview encoder as , otherwise the width stays null and nothing is claimed about it. + /// + /// The barcode. + /// The data it prints. + /// Printer resolution (203, 300 or 600). + /// Symbol width in modules from an encoder, used only when this class cannot measure the symbol itself. + public static BarcodeSizeReport? Analyze(BarcodeElement b, string data, int dpi, int? encodedModules = null) + { + ArgumentNullException.ThrowIfNull(b); + ArgumentNullException.ThrowIfNull(data); + if (dpi <= 0) throw new ArgumentOutOfRangeException(nameof(dpi)); + if (ModuleDotsOf(b) is not { } module) return null; + module = Math.Max(1, module); + + var info = BarcodeCatalog.Get(b.Symbology); + // ZPL derives the wide bar from ^BY: wide = narrow x ratio, rounded to whole dots, and never thinner than the narrow bar. + int? wide = info.Supports(BarcodeOption.WideBarRatio) + ? Math.Max(module, (int)Math.Round(module * b.WideBarRatio, MidpointRounding.AwayFromZero)) + : null; + + int? width = MeasureDots(b, data, module, wide) ?? (encodedModules is { } m and > 0 ? m * module : null); + var q = QuietZoneOf(b.Symbology); + int left = q.LeftModules * module, right = q.RightModules * module; + return new BarcodeSizeReport(dpi, module, MilsOf(module, dpi), MmOf(module, dpi), module, wide, + width, width is { } w ? MmOf(w, dpi) : null, q, left, right, MmOf(left, dpi), MmOf(right, dpi)); + } + + /// + /// Printed width of the symbol in dots where the symbology's own rules fix it, null otherwise. Each formula counts the elements of the + /// symbology (see the comments); the ones with wide bars use the wide width in dots, as ZPL does. + /// + private static int? MeasureDots(BarcodeElement b, string data, int n, int? wide) + { + int w = wide ?? n; + switch (b.Symbology) + { + case BarcodeSymbology.Code128: + { + // Only the automatic mode is measured: the other modes change the character sets the printer starts in. GS1-128 data + // (parentheses) is turned into an FNC1 sequence by the printer, so its length is not data.Length. + if (b.Code128Mode != Code128Mode.Automatic || data.Contains('(')) return null; + var values = Code128.Encode(data); + // Every symbol character is 11 modules wide; the stop character is 13 (it ends with the 2-module termination bar). + return values is null ? null : (11 * (values.Length - 1) + 13) * n; + } + case BarcodeSymbology.Code39 or BarcodeSymbology.Logmars: + { + // Start and stop '*' plus an optional mod 43 check character (LOGMARS always has it). A character is 9 elements, 3 of them wide, + // so 6 narrow + 3 wide; one narrow inter-character gap sits between characters. + int chars = data.Length + 2 + (b.CheckDigit || b.Symbology == BarcodeSymbology.Logmars ? 1 : 0); + return chars * (6 * n + 3 * w) + (chars - 1) * n; + } + case BarcodeSymbology.Code93: + { + // Start, data, two check characters (C and K), stop: 9 modules each, plus the 1-module termination bar. + return (data.Length + 4) * 9 * n + n; + } + case BarcodeSymbology.Interleaved2Of5: + { + // Digits are paired (5 bars of the first digit interleaved with 5 spaces of the second): 10 elements, 4 wide and 6 narrow. + // Start pattern is 4 narrow elements, stop is wide bar + 2 narrow. The printer's check digit counts as a digit. + int digits = data.Length + (b.CheckDigit ? 1 : 0); + if (digits % 2 != 0) return null; + return 4 * n + digits / 2 * (6 * n + 4 * w) + (w + 2 * n); + } + // The EAN/UPC family has a fixed number of modules whatever the data. + case BarcodeSymbology.Ean13 or BarcodeSymbology.UpcA: return 95 * n; + case BarcodeSymbology.Ean8: return 67 * n; + case BarcodeSymbology.UpcE: return 51 * n; + case BarcodeSymbology.QrCode: + { + if (b.QrModel != 2 || data.Length == 0) return null; + var fit = QrSizing.Analyze(data, b.QrErrorCorrection); + return fit.Fits ? fit.Modules * n : null; + } + default: + return null; + } + } + + /// + /// The quiet zone around the symbol in dots, per side of the label, for a field printed with the effective rotation + /// . A linear symbol needs the margin only along its reading direction: a symbol rotated 90 degrees reads top + /// to bottom, so its margins are above and below. 2D and stacked symbols need it on all four sides. + /// + public static QuietZoneDots QuietZoneBox(BarcodeElement b, FieldRotation rotation) + { + ArgumentNullException.ThrowIfNull(b); + if (ModuleDotsOf(b) is not { } module) return default; + module = Math.Max(1, module); + var q = QuietZoneOf(b.Symbology); + int start = q.LeftModules * module, end = q.RightModules * module; + if (q.AllSides) return new QuietZoneDots(start, start, end, end); + return rotation switch + { + FieldRotation.Normal => new QuietZoneDots(start, 0, end, 0), + FieldRotation.Inverted180 => new QuietZoneDots(end, 0, start, 0), + // Rotated 90 degrees clockwise the start of the symbol is at the top; rotated 270 it is at the bottom. + FieldRotation.Rotated90 => new QuietZoneDots(0, start, 0, end), + _ => new QuietZoneDots(0, end, 0, start), + }; + } + + /// + /// Checks the printer reality and the quiet zone of one barcode. All findings are warnings: the label still prints, it may just not scan. + /// + /// The barcode. + /// Its data, variables resolved. + /// The label (resolution, width and default rotation). + public static List Check(BarcodeElement b, string data, LabelDocument doc) + { + ArgumentNullException.ThrowIfNull(b); + ArgumentNullException.ThrowIfNull(doc); + var issues = new List(); + int dpi = (int)doc.Dpi; + // An out-of-range module is already an error from BarcodeValidator; do not pile a warning on top of it. + if (ModuleDotsOf(b) is not { } moduleDots || moduleDots < 1 || moduleDots > 10) return issues; + var report = Analyze(b, data, dpi); + if (report is null) return issues; + var info = BarcodeCatalog.Get(b.Symbology); + + string dots = moduleDots.ToString(CultureInfo.InvariantCulture); + string mils = report.XDimensionMils.ToString("0.#", CultureInfo.InvariantCulture); + string mm = report.XDimensionMm.ToString("0.###", CultureInfo.InvariantCulture); + string dpiText = dpi.ToString(CultureInfo.InvariantCulture); + + if (moduleDots == 1 && dpi >= HighDpi) + // At 300 dpi and above one dot is 0.085 mm or less: below what most scanners resolve, and any dot gain closes the gaps. + issues.Add(new ValidationIssue(ValidationSeverity.Warning, OneDotKey, b.Id, dots, mils, mm, dpiText)); + else if (info.Category == BarcodeCategory.Linear && dpi < HighDpi && moduleDots < MinNarrowDots203) + issues.Add(new ValidationIssue(ValidationSeverity.Warning, ThinBarKey, b.Id, dots, mils, mm, dpiText)); + + // Quiet-zone fit: only along the label width and only when the symbol is printed upright (its position is then the left edge); a + // rotated field's extent depends on the origin rules of the rotation and is left to the canvas guide. + var rotation = b.Rotation ?? doc.DefaultRotation; + if (rotation == FieldRotation.Normal && report.SymbolWidthDots is { } symbol && b.X >= 0 && b.X <= 32000) + { + int from = b.X - report.QuietLeftDots, to = b.X + symbol + report.QuietRightDots; + if (from < 0 || to > doc.WidthDots) + issues.Add(new ValidationIssue(ValidationSeverity.Warning, QuietZoneKey, b.Id, + MmOf(from, dpi).ToString("0.#", CultureInfo.InvariantCulture), + MmOf(to, dpi).ToString("0.#", CultureInfo.InvariantCulture), + MmOf(doc.WidthDots, dpi).ToString("0.#", CultureInfo.InvariantCulture))); + } + return issues; + } +} diff --git a/src/Rotula.Core/Zpl/ZplGenerator.cs b/src/Rotula.Core/Zpl/ZplGenerator.cs index ce431c0..e6a1388 100644 --- a/src/Rotula.Core/Zpl/ZplGenerator.cs +++ b/src/Rotula.Core/Zpl/ZplGenerator.cs @@ -451,6 +451,9 @@ private static string ResolveTextWithClock(LabelDocument doc, TextElement t, IRe issues.AddRange(found); CheckPosition(b, doc, 0, 0, issues); if (found.Any(i => i.Severity == ValidationSeverity.Error)) return null; + // Size advice (module too small for the printer, symbol plus quiet zone wider than the label). Only for data that is valid, so + // the symbol width is computed from something the printer can actually encode. + issues.AddRange(BarcodeSizing.Check(b, resolved, doc)); string payload = BarcodeValidator.NormalizeForPrinter(b, resolved); var sb = new StringBuilder(Origin(b)); diff --git a/tests/Rotula.Tests/AppBarcodeSizeTests.cs b/tests/Rotula.Tests/AppBarcodeSizeTests.cs new file mode 100644 index 0000000..ef67e1d --- /dev/null +++ b/tests/Rotula.Tests/AppBarcodeSizeTests.cs @@ -0,0 +1,201 @@ +using Rotula.App.Editing; +using Rotula.App.Localization; +using Rotula.App.ViewModels; +using Rotula.Core.Barcodes; +using Rotula.Core.Elements; +using Rotula.Core.Model; + +namespace Rotula.Tests; + +/// The size readout, the module stepper and the quiet-zone guide of the barcode property panel. +[Collection("App")] +public class AppBarcodeSizeTests : IDisposable +{ + public AppBarcodeSizeTests() => Loc.Instance.SetLanguage("en"); + + public void Dispose() => Loc.Instance.SetLanguage("en"); + + private static (PropertyPanelViewModel Panel, EditorSession Session, IntRow Module) Open(BarcodeElement element, PrinterDpi dpi = PrinterDpi.Dpi203) + { + var s = AppTestSupport.Session(element); + s.Document.Dpi = dpi; + var p = new PropertyPanelViewModel(s); + s.Select(element); + var key = BarcodeCatalog.Get(element.Symbology).Supports(BarcodeOption.ModuleWidth) ? "Prop.ModuleWidth" : "Prop.ModuleSize"; + return (p, s, p.Rows.OfType().First(r => r.LabelKey == key)); + } + + private static BarcodeElement Now(EditorSession s) => (BarcodeElement)s.Document.Elements[0]; + + private static BarcodeElement Code128(string data = "ABC", int module = 2) => + new() { Symbology = BarcodeSymbology.Code128, Data = data, ModuleWidth = module, X = 100, Y = 50 }; + + [Fact] + public void TheModuleRow_ShowsTheReadoutAndTheQuietZoneNote() + { + var (_, _, row) = Open(Code128()); + // 2 dots at 203 dpi is 9.9 mil = 0.25 mm; "ABC" is 68 modules = 136 dots = 17 mm; the 10X quiet zone is 20 dots = 2.5 mm. + Assert.Equal("Module: 9.9 mil (0.25 mm) - symbol 17 mm wide" + Environment.NewLine + "Quiet zone: 2.5 mm on each side (10X)", row.Detail); + Assert.False(row.DetailIsWarning); + Assert.True(row.ShowStepper); + Assert.True(row.FullWidth); + } + + [Fact] + public void AnEan13Readout_NamesBothQuietZones() + { + var (_, _, row) = Open(new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "590123412345", ModuleWidth = 3, X = 100, Y = 50 }, PrinterDpi.Dpi300); + Assert.Equal("Module: 10 mil (0.25 mm) - symbol 24.1 mm wide" + Environment.NewLine + + "Quiet zone: 2.8 mm before and 1.8 mm after the symbol (11X and 7X)", row.Detail); + } + + [Fact] + public void TheReadout_IsInSpanishInASpanishSession() + { + Loc.Instance.SetLanguage("es"); + var (_, _, row) = Open(Code128()); + Assert.StartsWith("Módulo: ", row.Detail); + Assert.Contains("símbolo de ", row.Detail); + Assert.Contains("Zona de silencio: ", row.Detail); + } + + [Fact] + public void TheStepper_ChangesTheModuleAndTheReadoutLive_InOneUndoStepEach() + { + var (_, s, row) = Open(Code128()); + row.StepUpCommand.Execute(null); + Assert.Equal(3, Now(s).ModuleWidth); + Assert.Equal("3", row.Text); + // 3 dots: 68 modules = 204 dots = 25.5 mm, and the module itself 14.8 mil / 0.38 mm. + Assert.StartsWith("Module: 14.8 mil (0.38 mm) - symbol 25.5 mm wide", row.Detail); + Assert.Equal(1, s.Undo.UndoCount); + + row.StepDownCommand.Execute(null); + row.StepDownCommand.Execute(null); + Assert.Equal(1, Now(s).ModuleWidth); + + s.Undo.Undo(); + // Steps on the same property merge into one undo step, so undo returns to the start and the readout follows it. + Assert.StartsWith("Module: 9.9 mil", row.Detail); + } + + [Fact] + public void TheStepper_StopsAtTheLimits() + { + var (_, s, row) = Open(Code128(module: 10)); + row.StepUpCommand.Execute(null); + Assert.Equal(10, Now(s).ModuleWidth); + var (_, s1, row1) = Open(Code128(module: 1)); + row1.StepDownCommand.Execute(null); + Assert.Equal(1, Now(s1).ModuleWidth); + Assert.False(row1.HasError); + } + + [Fact] + public void ARejectedTypedValue_IsReplacedByTheNextStep() + { + var (_, s, row) = Open(Code128()); + row.Text = "99"; + Assert.True(row.HasError); + row.StepUpCommand.Execute(null); + Assert.False(row.HasError); + Assert.Equal(3, Now(s).ModuleWidth); + } + + [Fact] + public void ATooSmallModule_ColoursTheReadoutAsAWarning_AndTheProblemsListSaysWhy() + { + var (_, _, row) = Open(Code128(module: 1), PrinterDpi.Dpi300); + Assert.True(row.DetailIsWarning); + Assert.StartsWith("Module: 3.3 mil (0.08 mm)", row.Detail); + + // The wording of the complaint is in the problems list (the same ZPL issues the status bar counts), in plain language. + var issue = Rotula.Core.Zpl.ZplGenerator.Generate(WithDpi(PrinterDpi.Dpi300, Code128(module: 1))).Issues.First(i => i.MessageKey == BarcodeSizing.OneDotKey); + string text = Loc.FormatIssue(issue); + Assert.Contains("1 dot (3.3 mil, 0.085 mm) at 300 dpi", text); + Assert.DoesNotContain("Issue.", text); + } + + private static LabelDocument WithDpi(PrinterDpi dpi, BarcodeElement b) + { + var doc = new LabelDocument { Width = Measure.Dots(800), Height = Measure.Dots(400), Dpi = dpi }; + doc.Elements.Add(b); + return doc; + } + + [Fact] + public void EveryNewIssueKey_IsTranslatedAndFormatsItsArguments() + { + foreach (var lang in new[] { "en", "es" }) + { + Loc.Instance.SetLanguage(lang); + foreach (string key in new[] { BarcodeSizing.OneDotKey, BarcodeSizing.ThinBarKey, BarcodeSizing.QuietZoneKey }) + { + Assert.True(Loc.Instance.HasKey("Issue." + key), $"{lang}: {key}"); + string text = Loc.FormatIssue(new Rotula.Core.Validation.ValidationIssue(Rotula.Core.Validation.ValidationSeverity.Warning, key, null, "1", "3.3", "0.085", "300")); + Assert.DoesNotContain("{", text); + } + } + } + + [Fact] + public void AQrRow_KeepsItsPrintedSize_AndAddsTheModuleAndTheQuietZone() + { + var qr = new BarcodeElement { Symbology = BarcodeSymbology.QrCode, Data = "HELLO", ModuleSize = 4, X = 100, Y = 50 }; + var (_, _, row) = Open(qr); + Assert.Contains("Printed size", row.Detail); + Assert.Contains("Module: 19.7 mil (0.5 mm)", row.Detail); + Assert.Contains("Quiet zone: 2 mm on each side (4X)", row.Detail); + Assert.True(row.ShowStepper); + } + + [Fact] + public void ADataMatrixRow_GetsAnEncoderMeasuredWidth() + { + var dm = new BarcodeElement { Symbology = BarcodeSymbology.DataMatrix, Data = "HELLO", ModuleSize = 4, X = 100, Y = 50 }; + var (_, _, row) = Open(dm); + Assert.Contains("symbol ", row.Detail); + Assert.Contains("Quiet zone: 0.5 mm on each side (1X)", row.Detail); + } + + [Fact] + public void ASymbologyWithoutAModule_HasNoReadout() + { + var maxi = new BarcodeElement { Symbology = BarcodeSymbology.MaxiCode, Data = "x", X = 10, Y = 10 }; + var s = AppTestSupport.Session(maxi); + var p = new PropertyPanelViewModel(s); + s.Select(maxi); + Assert.DoesNotContain(p.Rows.OfType(), r => r.ShowStepper); + } + + [Fact] + public void OtherNumberRows_HaveNoStepper() + { + var (p, _, _) = Open(Code128()); + Assert.Equal(new[] { "Prop.ModuleWidth" }, p.Rows.OfType().Where(r => r.ShowStepper).Select(r => r.LabelKey)); + } + + [Fact] + public void TheQuietZoneGuide_GrowsTheFrameOnTheReadingAxisOnly() + { + var frame = new RectD(100, 50, 190, 100); + var ean = new BarcodeElement { Symbology = BarcodeSymbology.Ean13, ModuleWidth = 2 }; + Assert.Equal(new RectD(78, 50, 190 + 22 + 14, 100), BarcodeSizeInfo.QuietZoneFrame(frame, ean, FieldRotation.Normal)); + + ean.Rotation = FieldRotation.Rotated90; // reads top to bottom, so the margins are above and below + Assert.Equal(new RectD(100, 28, 190, 100 + 22 + 14), BarcodeSizeInfo.QuietZoneFrame(frame, ean, FieldRotation.Normal)); + + var qr = new BarcodeElement { Symbology = BarcodeSymbology.QrCode, ModuleSize = 3 }; + Assert.Equal(new RectD(88, 38, 190 + 24, 100 + 24), BarcodeSizeInfo.QuietZoneFrame(frame, qr, FieldRotation.Normal)); + + Assert.Null(BarcodeSizeInfo.QuietZoneFrame(frame, new BarcodeElement { Symbology = BarcodeSymbology.MaxiCode }, FieldRotation.Normal)); + } + + [Fact] + public void TheGuide_DefaultsToTheLabelRotation_WhenTheFieldHasNone() + { + var frame = new RectD(100, 50, 190, 100); + var code = new BarcodeElement { Symbology = BarcodeSymbology.Code128, ModuleWidth = 2 }; + Assert.Equal(new RectD(100, 30, 190, 140), BarcodeSizeInfo.QuietZoneFrame(frame, code, FieldRotation.Rotated90)); + } +} diff --git a/tests/Rotula.Tests/AppDataMergeTests.cs b/tests/Rotula.Tests/AppDataMergeTests.cs index f6d4ecd..3b9b67a 100644 --- a/tests/Rotula.Tests/AppDataMergeTests.cs +++ b/tests/Rotula.Tests/AppDataMergeTests.cs @@ -370,7 +370,7 @@ public async Task AUsedCounterThatRunsOut_WithErrorOverflow_MarksTheRecordsInThe // ------------------------------------------------------------------------------------ checking - private void AddEanLabel() => _main.Session.Edit("Undo.Property", d => d.Elements.Add(new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "{{code}}" })); + private void AddEanLabel() => _main.Session.Edit("Undo.Property", d => d.Elements.Add(new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "{{code}}", X = 100 })); [Fact] public async Task CheckAll_ListsPerRecordProblems_InReadableSentences() diff --git a/tests/Rotula.Tests/AppPropertyPanelTests.cs b/tests/Rotula.Tests/AppPropertyPanelTests.cs index db3e8e9..5c4c1ca 100644 --- a/tests/Rotula.Tests/AppPropertyPanelTests.cs +++ b/tests/Rotula.Tests/AppPropertyPanelTests.cs @@ -166,7 +166,7 @@ public void BarcodeRows_DependOnTheSymbology() [Fact] public void BarcodeData_IsValidatedByCore_AndShownAsLocalizedMessages() { - var bc = new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "5901234123457" }; + var bc = new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "5901234123457", X = 100 }; // clear of the label edge, so the quiet-zone warning stays out of this test var vm = new MainViewModel(new FakeServices().Build()); vm.Session.Load(new LabelDocument()); vm.Commands.Add(bc); diff --git a/tests/Rotula.Tests/BarcodeSizingTests.cs b/tests/Rotula.Tests/BarcodeSizingTests.cs new file mode 100644 index 0000000..011dff2 --- /dev/null +++ b/tests/Rotula.Tests/BarcodeSizingTests.cs @@ -0,0 +1,213 @@ +using Rotula.Core.Barcodes; +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Core.Validation; +using Rotula.Core.Zpl; + +namespace Rotula.Tests; + +/// The barcode sizing helper: X-dimension, bar widths, symbol width, quiet zones and the printer-reality / fit warnings. +public class BarcodeSizingTests +{ + private static BarcodeElement Bar(BarcodeSymbology s, string data, int module = 2, int x = 100) => + new() { Symbology = s, Data = data, ModuleWidth = module, ModuleSize = module, X = x, Y = 20 }; + + private static LabelDocument Doc(PrinterDpi dpi, int widthDots = 800, params LabelElement[] elements) + { + var d = new LabelDocument { Width = Measure.Dots(widthDots), Height = Measure.Dots(400), Dpi = dpi }; + d.Elements.AddRange(elements); + return d; + } + + [Theory] + [InlineData(203, 2, 9.852, 0.250)] + [InlineData(300, 2, 6.667, 0.169)] + [InlineData(600, 3, 5.0, 0.127)] + public void XDimension_IsDerivedFromDotsAndDpi(int dpi, int module, double mils, double mm) + { + var report = BarcodeSizing.Analyze(Bar(BarcodeSymbology.Ean13, "590123412345", module), "590123412345", dpi)!; + Assert.Equal(mils, report.XDimensionMils, 3); + Assert.Equal(mm, report.XDimensionMm, 3); + Assert.Equal(module, report.NarrowBarDots); + } + + [Theory] + // Symbol widths follow from each symbology's rules: Code 128 "ABC" = start + 3 + check + stop = 11x5 + 13 modules. + [InlineData(BarcodeSymbology.Code128, "ABC", 68 * 2)] + [InlineData(BarcodeSymbology.Ean13, "590123412345", 95 * 2)] + [InlineData(BarcodeSymbology.UpcA, "01234567890", 95 * 2)] + [InlineData(BarcodeSymbology.Ean8, "1234567", 67 * 2)] + // Code 39 "ABC": 5 characters of 6 narrow + 3 wide (6 dots) and 4 gaps; wide = 3 x 2. + [InlineData(BarcodeSymbology.Code39, "ABC", 5 * (12 + 18) + 4 * 2)] + // Interleaved 2 of 5 "1234": start 4n, two pairs of 6n + 4w, stop w + 2n. + [InlineData(BarcodeSymbology.Interleaved2Of5, "1234", 8 + 2 * (12 + 24) + (6 + 4))] + [InlineData(BarcodeSymbology.Code93, "ABC", 7 * 9 * 2 + 2)] + // QR "HELLO" at level Q is version 1: 21 modules of 2 dots. + [InlineData(BarcodeSymbology.QrCode, "HELLO", 21 * 2)] + public void SymbolWidth_IsMeasuredWhereTheRulesFixIt(BarcodeSymbology s, string data, int expectedDots) + { + var report = BarcodeSizing.Analyze(Bar(s, data), data, 203)!; + Assert.Equal(expectedDots, report.SymbolWidthDots); + Assert.Equal(expectedDots * 25.4 / 203, report.SymbolWidthMm!.Value, 6); + } + + [Fact] + public void SymbolWidth_IsNotClaimed_WhenItCannotBeKnown_AndAnEncoderCountIsUsedInstead() + { + var dm = Bar(BarcodeSymbology.DataMatrix, "HELLO", 3); + Assert.Null(BarcodeSizing.Analyze(dm, "HELLO", 300)!.SymbolWidthDots); + Assert.Equal(14 * 3, BarcodeSizing.Analyze(dm, "HELLO", 300, encodedModules: 14)!.SymbolWidthDots); + // MaxiCode has a fixed size and no module setting at all. + Assert.Null(BarcodeSizing.Analyze(Bar(BarcodeSymbology.MaxiCode, "x"), "x", 203)); + } + + [Fact] + public void WideBar_FollowsTheRatio_OnlyForSymbologiesWithWideBars() + { + var c39 = Bar(BarcodeSymbology.Code39, "A", 3); + c39.WideBarRatio = 2.5; + var r = BarcodeSizing.Analyze(c39, "A", 203)!; + Assert.Equal(3, r.NarrowBarDots); + Assert.Equal(8, r.WideBarDots); // 3 x 2.5 = 7.5, rounded to whole dots + Assert.Null(BarcodeSizing.Analyze(Bar(BarcodeSymbology.Code128, "A"), "A", 203)!.WideBarDots); + } + + [Theory] + [InlineData(BarcodeSymbology.Code128, 10, 10)] + [InlineData(BarcodeSymbology.Code39, 10, 10)] + [InlineData(BarcodeSymbology.Ean13, 11, 7)] + [InlineData(BarcodeSymbology.UpcA, 9, 9)] + [InlineData(BarcodeSymbology.Ean8, 7, 7)] + [InlineData(BarcodeSymbology.QrCode, 4, 4)] + [InlineData(BarcodeSymbology.DataMatrix, 1, 1)] + [InlineData(BarcodeSymbology.Pdf417, 2, 2)] + [InlineData(BarcodeSymbology.Postnet, 0, 0)] // family default: postal + [InlineData(BarcodeSymbology.Code49, 2, 2)] // family default: stacked + public void QuietZone_UsesTheStandardValue(BarcodeSymbology s, int left, int right) + { + var q = BarcodeSizing.QuietZoneOf(s); + Assert.Equal((left, right), (q.LeftModules, q.RightModules)); + Assert.False(string.IsNullOrWhiteSpace(q.Source)); + } + + [Fact] + public void QuietZone_IsScaledByTheModuleInDotsAndMm() + { + var r = BarcodeSizing.Analyze(Bar(BarcodeSymbology.Ean13, "590123412345", 3), "590123412345", 300)!; + Assert.Equal((33, 21), (r.QuietLeftDots, r.QuietRightDots)); + Assert.Equal(33 * 25.4 / 300, r.QuietLeftMm, 6); + Assert.Equal(95 * 3 + 33 + 21, r.TotalWidthDots); + } + + [Theory] + [InlineData(FieldRotation.Normal, 22, 0, 14, 0)] + [InlineData(FieldRotation.Inverted180, 14, 0, 22, 0)] + [InlineData(FieldRotation.Rotated90, 0, 22, 0, 14)] + [InlineData(FieldRotation.Rotated270, 0, 14, 0, 22)] + public void QuietZoneBox_FollowsTheReadingDirection(FieldRotation rotation, int l, int t, int r, int b) + { + var box = BarcodeSizing.QuietZoneBox(Bar(BarcodeSymbology.Ean13, "590123412345"), rotation); + Assert.Equal(new QuietZoneDots(l, t, r, b), box); + } + + [Fact] + public void QuietZoneBox_OfA2DCode_IsOnAllSides() + { + var box = BarcodeSizing.QuietZoneBox(Bar(BarcodeSymbology.QrCode, "HELLO", 3), FieldRotation.Rotated90); + Assert.Equal(new QuietZoneDots(12, 12, 12, 12), box); + } + + // ---- printer reality check ------------------------------------------------------------ + + [Theory] + [InlineData(PrinterDpi.Dpi300, 1, BarcodeSymbology.Code128, true)] + [InlineData(PrinterDpi.Dpi600, 1, BarcodeSymbology.QrCode, true)] + [InlineData(PrinterDpi.Dpi300, 2, BarcodeSymbology.Code128, false)] + [InlineData(PrinterDpi.Dpi203, 2, BarcodeSymbology.Code128, false)] + public void OneDotModule_WarnsAtHighResolution(PrinterDpi dpi, int module, BarcodeSymbology s, bool warns) + { + var b = Bar(s, s == BarcodeSymbology.QrCode ? "HELLO" : "ABC", module); + var issues = BarcodeSizing.Check(b, b.Data, Doc(dpi, 1200, b)); + Assert.Equal(warns, issues.Any(i => i.MessageKey == BarcodeSizing.OneDotKey)); + Assert.DoesNotContain(issues, i => i.MessageKey == BarcodeSizing.ThinBarKey); + Assert.All(issues, i => Assert.Equal(ValidationSeverity.Warning, i.Severity)); + } + + [Fact] + public void ThinBar_WarnsOnlyFor1DCodesAt203Dpi() + { + var linear = Bar(BarcodeSymbology.Code128, "ABC", 1); + var issue = Assert.Single(BarcodeSizing.Check(linear, "ABC", Doc(PrinterDpi.Dpi203, 800, linear)), i => i.MessageKey == BarcodeSizing.ThinBarKey); + Assert.Equal(new[] { "1", "4.9", "0.125", "203" }, issue.Args); + + // A 2D module of 1 dot at 203 dpi is not a "narrow bar" and is not flagged by this rule. + var qr = Bar(BarcodeSymbology.QrCode, "HELLO", 1); + Assert.DoesNotContain(BarcodeSizing.Check(qr, "HELLO", Doc(PrinterDpi.Dpi203, 800, qr)), i => i.MessageKey == BarcodeSizing.ThinBarKey); + } + + // ---- quiet zone fit --------------------------------------------------------------------- + + [Fact] + public void SymbolPlusQuietZones_MustFitTheLabelWidth() + { + // Code 128 "ABC" is 136 dots; quiet zones 20 + 20 dots at module 2. + var fits = Bar(BarcodeSymbology.Code128, "ABC", 2, x: 20); + Assert.Empty(BarcodeSizing.Check(fits, "ABC", Doc(PrinterDpi.Dpi203, 176, fits))); + + var left = Bar(BarcodeSymbology.Code128, "ABC", 2, x: 19); // quiet zone would start left of the label edge + var l = Assert.Single(BarcodeSizing.Check(left, "ABC", Doc(PrinterDpi.Dpi203, 400, left))); + Assert.Equal(BarcodeSizing.QuietZoneKey, l.MessageKey); + + var right = Bar(BarcodeSymbology.Code128, "ABC", 2, x: 20); + var r = Assert.Single(BarcodeSizing.Check(right, "ABC", Doc(PrinterDpi.Dpi203, 175, right))); + Assert.Equal(BarcodeSizing.QuietZoneKey, r.MessageKey); + // From 0 mm to 22 mm needed (176 dots), on a label that is 21.9 mm (175 dots) wide. + Assert.Equal(new[] { "0", "22", "21.9" }, r.Args); + } + + [Fact] + public void QuietZoneFit_IsSkippedForRotatedFields_AndForSymbolsWhoseWidthIsUnknown() + { + var rotated = Bar(BarcodeSymbology.Code128, "ABC", 2, x: 700); + rotated.Rotation = FieldRotation.Rotated90; + Assert.Empty(BarcodeSizing.Check(rotated, "ABC", Doc(PrinterDpi.Dpi203, 800, rotated))); + + var dm = Bar(BarcodeSymbology.DataMatrix, "HELLO", 4, x: 790); + Assert.Empty(BarcodeSizing.Check(dm, "HELLO", Doc(PrinterDpi.Dpi203, 800, dm))); + } + + [Fact] + public void TheDocumentDefaultRotation_DecidesWhenTheFieldHasNone() + { + var b = Bar(BarcodeSymbology.Code128, "ABC", 2, x: 5); + var doc = Doc(PrinterDpi.Dpi203, 800, b); + doc.DefaultRotation = FieldRotation.Rotated90; + Assert.Empty(BarcodeSizing.Check(b, "ABC", doc)); + } + + [Fact] + public void AnOutOfRangeModule_IsLeftToTheValidator() + { + var b = Bar(BarcodeSymbology.Code128, "ABC", 0); + Assert.Empty(BarcodeSizing.Check(b, "ABC", Doc(PrinterDpi.Dpi203, 800, b))); + } + + [Fact] + public void TheZplGenerator_ReportsTheSizeWarnings_AndStillPrints() + { + var b = Bar(BarcodeSymbology.Code128, "ABC", 1, x: 0); + var result = ZplGenerator.Generate(Doc(PrinterDpi.Dpi203, 800, b)); + Assert.False(result.HasErrors); + Assert.Contains(result.Issues, i => i.MessageKey == BarcodeSizing.ThinBarKey); + Assert.Contains(result.Issues, i => i.MessageKey == BarcodeSizing.QuietZoneKey); // x = 0 leaves no room for the 10X margin + Assert.Contains("^BC", result.Zpl); + } + + [Fact] + public void ADefaultBarcodeOnADefaultLabel_HasNoSizeWarning() + { + var b = Bar(BarcodeSymbology.Code128, "ABC123", 2, x: 50); + var result = ZplGenerator.Generate(Doc(PrinterDpi.Dpi203, 800, b)); + Assert.DoesNotContain(result.Issues, i => i.MessageKey.StartsWith("Barcode.Size.", StringComparison.Ordinal)); + } +} From c369350460dd9758917d2a0e0a21a3131f1b2457 Mon Sep 17 00:00:00 2001 From: glenntosco Date: Mon, 12 Oct 2026 14:39:19 -0500 Subject: [PATCH 02/25] fix: a data load stays busy until its records are in place RunAsync cleared IsBusy as soon as the file was parsed, but LoadAsync applied the table, the records and the mapping afterwards. Anyone waiting for "not busy" could see the new columns with no records yet (the WithoutAHeaderRow test failed under load for this reason). The whole apply now runs inside the operation, and the Reload button is re-evaluated when the busy state ends. --- .../ViewModels/DataMergeViewModel.cs | 117 ++++++++++-------- .../ViewModels/OperationViewModel.cs | 9 ++ tests/Rotula.Tests/AppDataMergeTests.cs | 19 +++ 3 files changed, 93 insertions(+), 52 deletions(-) diff --git a/src/Rotula.App/ViewModels/DataMergeViewModel.cs b/src/Rotula.App/ViewModels/DataMergeViewModel.cs index b99f267..a0f870e 100644 --- a/src/Rotula.App/ViewModels/DataMergeViewModel.cs +++ b/src/Rotula.App/ViewModels/DataMergeViewModel.cs @@ -299,6 +299,10 @@ private void OnDatabaseDataSelected(object? sender, DatabaseDataSelectedEventArg /// public bool CanReload => HasSource && !IsBusy; + /// + /// The source is applied while the load is still busy, so the Reload button is re-evaluated when the busy state ends. + protected override void OnBusyChanged(bool busy) => OnPropertyChanged(nameof(CanReload)); + /// True when the .rotula file named a data path that was ignored for safety. [ObservableProperty] private bool _sourceHintIgnored; @@ -433,68 +437,77 @@ public async Task LoadAsync(string path) char? detected = null; string? error = null; - bool finished = await RunAsync(Loc.Get("Data.Busy.Loading", fileName), ct => Task.Run(() => + bool loaded = false; + + bool finished = await RunAsync(Loc.Get("Data.Busy.Loading", fileName), async ct => { - try + await Task.Run(() => { - // FileShare.ReadWrite: Excel keeps a workbook open for reading; the file can still be read while it is. - if (xlsx) + try { - using (var names = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) sheets = XlsxReader.GetSheetNames(names); - using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); - string? useSheet = sheet is not null && sheets.Contains(sheet) ? sheet : null; - table = XlsxReader.Read(stream, new XlsxOptions { SheetName = useSheet, HasHeader = header }); - sheet = useSheet ?? sheets.FirstOrDefault(); + // FileShare.ReadWrite: Excel keeps a workbook open for reading; the file can still be read while it is. + if (xlsx) + { + using (var names = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) sheets = XlsxReader.GetSheetNames(names); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + string? useSheet = sheet is not null && sheets.Contains(sheet) ? sheet : null; + table = XlsxReader.Read(stream, new XlsxOptions { SheetName = useSheet, HasHeader = header }); + sheet = useSheet ?? sheets.FirstOrDefault(); + } + else + { + string text = ReadText(path); + detected = CsvReader.DetectDelimiter(text); + table = CsvReader.Parse(text, new CsvOptions { Delimiter = delimiter, HasHeader = header }); + } + ct.ThrowIfCancellationRequested(); } - else + catch (OperationCanceledException) { throw; } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - string text = ReadText(path); - detected = CsvReader.DetectDelimiter(text); - table = CsvReader.Parse(text, new CsvOptions { Delimiter = delimiter, HasHeader = header }); + error = Loc.Get("Data.Error.Read", fileName); } - ct.ThrowIfCancellationRequested(); - } - catch (OperationCanceledException) { throw; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ex is not OutOfMemoryException) + { + error = Loc.Get("Data.Error.Format", fileName); + } + }, ct); + + // Applying the result is part of the operation: IsBusy stays true until the table, the records and the mapping are + // all in place. Otherwise anyone who waits for "not busy" (a test, a caller chaining a second load) can see the new + // columns with no records yet, because the data set is rebuilt a few statements after the table is replaced. + if (error is null && table is { Columns.Count: 0 }) error = Loc.Get("Data.Error.Empty", fileName); + if (error is not null) { - error = Loc.Get("Data.Error.Read", fileName); + SetResult(error, true); + return; } - catch (Exception ex) when (ex is not OutOfMemoryException) + + // Reloading the same file is not a change to the label; only a different file or sheet is. + bool settingsChanged = !string.Equals(SourcePath, path, StringComparison.OrdinalIgnoreCase) || (xlsx && SelectedSheet != sheet) + || _databaseSettings is not null; + _applying = true; + try { - error = Loc.Get("Data.Error.Format", fileName); + SourceHintIgnored = false; + SourcePath = path; + SelectedSheet = xlsx ? sheet : null; + // A file replaces a database source: the label must not keep pointing at a query it no longer uses. + _databaseSettings = null; + Database.DiscardReference(); + SourceMode = DataSourceMode.File; + ShowDatabasePicker = false; } - }, ct)); - if (!finished) return false; - if (error is null && table is { Columns.Count: 0 }) error = Loc.Get("Data.Error.Empty", fileName); - if (error is not null) - { - SetResult(error, true); - return false; - } - - // Reloading the same file is not a change to the label; only a different file or sheet is. - bool settingsChanged = !string.Equals(SourcePath, path, StringComparison.OrdinalIgnoreCase) || (xlsx && SelectedSheet != sheet) - || _databaseSettings is not null; - _applying = true; - try - { - SourceHintIgnored = false; - SourcePath = path; - SelectedSheet = xlsx ? sheet : null; - // A file replaces a database source: the label must not keep pointing at a query it no longer uses. - _databaseSettings = null; - Database.DiscardReference(); - SourceMode = DataSourceMode.File; - ShowDatabasePicker = false; - } - finally { _applying = false; } - SetTable(table, sheets, delimiter ?? detected); - CurrentIndex = 0; - SetResult(Loc.Get("Data.Loaded", fileName, table!.RowCount), false); - if (settingsChanged) RaiseSettingsEdited(); - Rebind(); - RaisePreviewChanged(); - return true; + finally { _applying = false; } + SetTable(table, sheets, delimiter ?? detected); + CurrentIndex = 0; + SetResult(Loc.Get("Data.Loaded", fileName, table!.RowCount), false); + if (settingsChanged) RaiseSettingsEdited(); + Rebind(); + RaisePreviewChanged(); + loaded = true; + }); + return finished && loaded; } /// Reads a text file as UTF-8 (or UTF-16 with a byte order mark), falling back to Windows-1252, which is what Excel writes for "CSV". diff --git a/src/Rotula.App/ViewModels/OperationViewModel.cs b/src/Rotula.App/ViewModels/OperationViewModel.cs index f3786c2..dfb5dbf 100644 --- a/src/Rotula.App/ViewModels/OperationViewModel.cs +++ b/src/Rotula.App/ViewModels/OperationViewModel.cs @@ -21,6 +21,15 @@ public abstract partial class OperationViewModel : ObservableObject /// The opposite of , for binding to IsEnabled. public bool IsIdle => !IsBusy; + partial void OnIsBusyChanged(bool value) => OnBusyChanged(value); + + /// + /// Called when changes, so a derived view model can refresh what it derives from it (a "can do this" + /// property bound to a button). The generated hook is private to this class, hence the virtual. + /// + /// The new value. + protected virtual void OnBusyChanged(bool busy) { } + /// What the running operation is doing, for example "Sending to the printer...". [ObservableProperty] private string _busyText = string.Empty; diff --git a/tests/Rotula.Tests/AppDataMergeTests.cs b/tests/Rotula.Tests/AppDataMergeTests.cs index f6d4ecd..7d42ea2 100644 --- a/tests/Rotula.Tests/AppDataMergeTests.cs +++ b/tests/Rotula.Tests/AppDataMergeTests.cs @@ -90,6 +90,25 @@ public async Task WithoutAHeaderRow_ColumnsAreNumbered() Assert.Equal(2, Data.RecordCount); } + [Fact] + public async Task ALoad_IsStillBusyUntilTheRecordsAreInPlace_AndReloadIsEnabledAfterwards() + { + string path = Csv("A1,5\nB2,7\n"); + await Data.LoadAsync(path); + // The moment IsBusy turns false, the new records (and not just the new columns) must already be there: a caller that waits + // for "not busy" and then reads RecordCount used to see 0, because the data set was rebuilt after the busy flag cleared. + var whenNotBusy = new List<(int Records, bool CanReload)>(); + Data.PropertyChanged += (_, e) => + { + if (e.PropertyName == nameof(DataMergeViewModel.IsBusy) && !Data.IsBusy) whenNotBusy.Add((Data.RecordCount, Data.CanReload)); + }; + Data.HasHeader = false; + await Idle(Data); + Assert.Single(whenNotBusy); + Assert.Equal((2, true), whenNotBusy[0]); + Assert.True(Data.CanReload); + } + [Fact] public async Task AWindows1252Csv_IsReadWithItsAccents() { From aaa31f30cafb01c5b65eca408160ff9993809d93 Mon Sep 17 00:00:00 2001 From: glenntosco Date: Mon, 12 Oct 2026 15:06:33 -0500 Subject: [PATCH 03/25] feat: relative position with a nine-point anchor An object can be anchored by one of nine points (corners, edge middles, centre) to the label or to another object; X and Y then mean the distance between the two anchor points. A bottom-right object stays in the corner when the label is resized, and an object relative to another follows it when it moves or grows, per record when a text width depends on the data. - Core: AnchorPoint/AnchorReference enums, Anchor/RelativeTo/ ReferenceElementId on LabelElement, Layout/AnchorResolver (absolute origins, cycle detection, missing references, keep-position offsets), ElementGeometry and ElementSizeEstimator (exact for shapes, images and Windows fonts; documented estimate for printer fonts and barcodes). - ZPL generator and raster text emitter write resolved absolute ^FO/^FT; labels without anchors are byte-for-byte unchanged. - Files: anchor, relativeTo and referenceElementId on elements, format version 11 stamped only when an anchor is used. - Rendering: the preview adapter places anchored objects with the same resolver. - App: 3x3 anchor picker and "Relative to" drop-down in the Position section (choosing one keeps the object where it is; a reference cycle is refused with a message), X/Y as offsets, anchor marker on the canvas, drag/nudge/align move a follower once, deleting a reference rebinds its followers to the label without moving them, duplicate/paste rewire references, and new "Align to label" commands. - Tests: Core maths for all nine anchors, label and element references, cycles, serializer stamping and backward compatibility, golden fixture, PNG export before and after a label resize, editing and panel view-models, resx parity. Docs: CHANGELOG and decisions. --- CHANGELOG.md | 1 + docs/decisions.md | 35 ++ src/Rotula.App/Controls/DesignCanvas.cs | 33 +- src/Rotula.App/Editing/EditorCommands.cs | 77 ++- .../Editing/ElementBoundsCalculator.cs | 66 +- src/Rotula.App/MainWindow.xaml | 9 + src/Rotula.App/Resources/Strings.en.resx | 75 +++ src/Rotula.App/Resources/Strings.es.resx | 75 +++ src/Rotula.App/ViewModels/MainViewModel.cs | 8 + .../ViewModels/PropertyPanelViewModel.cs | 87 ++- src/Rotula.App/ViewModels/PropertyRows.cs | 137 ++++- src/Rotula.App/Views/PropertiesPanel.xaml | 67 ++ src/Rotula.Core/Elements/LabelElement.cs | 33 +- src/Rotula.Core/Layout/AnchorResolver.cs | 245 ++++++++ src/Rotula.Core/Layout/DotRect.cs | 40 ++ src/Rotula.Core/Layout/ElementGeometry.cs | 70 +++ .../Layout/ElementSizeEstimator.cs | 214 +++++++ src/Rotula.Core/Model/Enums.cs | 35 ++ src/Rotula.Core/Zpl/RasterTextEmitter.cs | 5 +- src/Rotula.Core/Zpl/ZplGenerator.cs | 101 +-- src/Rotula.Files/RotulaFileDtos.cs | 12 + src/Rotula.Files/RotulaFileSerializer.cs | 32 +- .../Input/LabelDocumentAdapter.cs | 6 +- tests/Rotula.Tests/AppDatabaseTests.cs | 2 +- tests/Rotula.Tests/ObjectConditionsTests.cs | 7 +- tests/Rotula.Tests/RelativePositionTests.cs | 576 ++++++++++++++++++ tests/fixtures/anchor_relative.zpl | 16 + 27 files changed, 1987 insertions(+), 77 deletions(-) create mode 100644 src/Rotula.Core/Layout/AnchorResolver.cs create mode 100644 src/Rotula.Core/Layout/DotRect.cs create mode 100644 src/Rotula.Core/Layout/ElementGeometry.cs create mode 100644 src/Rotula.Core/Layout/ElementSizeEstimator.cs create mode 100644 tests/Rotula.Tests/RelativePositionTests.cs create mode 100644 tests/fixtures/anchor_relative.zpl diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4083e..855b8f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,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. +- **Relative position with an anchor point** (Properties panel, Position): pick one of nine anchor points (corners, edge middles, centre) and what the object is relative to, the label or another object. X and Y then mean the distance between the two anchor points, so an object anchored bottom-right stays in the bottom-right corner when the label is resized, and an object relative to another one follows it when it moves or grows (a longer text on one record moves its right-anchored neighbour on that record only). Choosing an anchor never moves the object: only the numbers change. The canvas marks the anchor point of the selected object, deleting a reference keeps its followers where they are, and a reference that would make two objects relative to each other is refused with an explanation. The printer always receives absolute `^FO`/`^FT` positions. Objects without an anchor behave exactly as before and such labels are saved exactly as before; a label that uses anchors is saved as format version 11, which older versions of Rotula refuse to open. Also new: **Align to label** (Object menu) lines several selected objects up with the label's edges or centre instead of with each other. ### Fixed - **Windows fonts that are named after a style now draw in that style.** "Arial Black", "Segoe UI Semibold", "Segoe UI Black", "Bahnschrift Condensed" and similar names used to be replaced by the default font in the preview, PNG/PDF export and print picture; they now resolve to their parent family at the weight and width the name says. diff --git a/docs/decisions.md b/docs/decisions.md index 1beea9e..b3a6175 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -530,3 +530,38 @@ emitted or validated, so a barcode invalid only on a record where it is hidden d prints and PNG/JPEG/PDF; only the design canvas draws them dimmed (`includeNonPrinting`), and the Preview window shows the printed result. Lock is enforced in `EditorCommands` (move, nudge, resize, rotate, align, distribute, delete, cut) and never touches ZPL; property edits stay allowed. Condition fields are listed as merge placeholders so they can be mapped to columns. ZPL import never creates conditions or locks. + +## Relative position (Rotula.Core/Layout) + +### One anchor point, taken on both the object and its reference; X/Y are the vector between them +An element may carry an `Anchor` (one of nine points: corners, edge middles, centre) and a reference (`RelativeTo`: the label, or another element +by id). The same point is taken on the object's box and on the reference's box, and X/Y are the signed distance from the reference's point to the +object's point (so a bottom-right object 10 dots inside the corner has X = Y = -10). One point rather than two keeps the panel a single 3 x 3 +grid, which is what people know from other designers; the signed offset is the plain reading of "X/Y are an offset" and needs no inward/outward +convention. An element without an anchor is untouched: its X/Y stay the absolute origin and its ZPL is byte-for-byte as before. The label +reference is the full label (margins included, at minus the home), the same box the Align commands use. + +### The ZPL stays absolute; one resolver places things, for every record, and the canvas uses it too +`AnchorResolver` turns anchors into absolute origins; the ZPL generator (so every record of a data merge), the preview adapter and the designer's +bounds calculator all call it, so what the canvas shows is where the printer puts the object. It runs per record because text widths change per +record. Sizes come from `ElementSizeEstimator`: exact for shapes and images, exact for Windows-font text (the rasterizer the Text Fit feature uses, +so the fitted size is honoured), and an **estimate** for printer-font text (`TextFitEstimator`, the same nominal character width behind the +overflow warning) and barcodes (module count of the symbology times the module width, text band as the preview draws it). An anchored +printer-font text can therefore sit a few dots off on paper, as the overflow warning can be a few dots off; the canvas and the ZPL always agree +with each other, and an object relative to a Windows-font text is exact. Core gets no drawing code and the generator gets no new parameter. + +### Circles are refused in the panel and are an error in the file; a missing reference falls back to the label +A reference that would make two objects relative to each other (directly or through others) is refused in the "Relative to" drop-down with a +sentence naming the other object, and nothing is written. A file that carries a circle anyway (hand edited) gets an error per object on the +circle and those objects are left out of the ZPL, like any other invalid field; the preview draws them at their raw numbers. A reference to an +element that no longer exists (a file edited elsewhere, a paste from another label) is a warning and the label stands in, so the object keeps a +place. Deleting a reference in the designer never produces that case: its followers are made relative to the label with the numbers that keep +them exactly where they are, in the same undo step. + +### Choosing an anchor changes the numbers, never the picture; moving a reference moves its followers once +Picking an anchor point or a reference in the panel recomputes X/Y so the object stays put (`OffsetKeepingPosition`). Dragging, nudging and +aligning a selection skip the elements that follow another selected element, because those move with their reference already. Resizing works on +bounds, so an anchored object keeps its anchored edge where the handle rule says. Duplicating or pasting a pair rewires the copy's reference to the +copied reference. The .rotula file stores `anchor`, `relativeTo` and `referenceElementId` (version 11, stamped only when an anchor is used; the +field is `relativeTo` rather than `reference` because a recall element already writes its stored-object name as `reference`). ZPL import never +creates anchors; the MCP server reads and writes X/Y as they are (offsets for an anchored object) and does not expose anchors yet. diff --git a/src/Rotula.App/Controls/DesignCanvas.cs b/src/Rotula.App/Controls/DesignCanvas.cs index aae497e..c0678ae 100644 --- a/src/Rotula.App/Controls/DesignCanvas.cs +++ b/src/Rotula.App/Controls/DesignCanvas.cs @@ -285,6 +285,8 @@ private void DrawSelection(DrawingContext dc) var union = RectD.UnionAll(selected.Select(_vm.Session.Bounds.GetFrame)); if (selected.Count > 1) dc.DrawRectangle(null, new Pen(AccentBrush, 1), ToPixelRect(union)); + if (selected.Count == 1) DrawAnchorMarker(dc, selected[0]); + // No handles on a locked object (the frame alone shows it is selected). if (selected.Count != 1 || selected[0].Locked) return; var r = ToPixelRect(union); @@ -301,6 +303,32 @@ private void DrawSelection(DrawingContext dc) dc.DrawEllipse(Brushes.White, new Pen(AccentBrush, 1.5), rot, HandleSize / 2 + 1, HandleSize / 2 + 1); } + private static readonly Pen AnchorLinkPen = CreatePen(Color.FromArgb(170, 0xC8, 0x42, 0x0F), 1, new DashStyle(new double[] { 4, 3 }, 0)); + + /// + /// Marks the anchor point of a selected anchored object: a filled dot on the corner, edge or centre of its frame, and a dashed line to the + /// same point of the object it is relative to (nothing for the label, whose edges speak for themselves). Drawn by the canvas only; it never + /// reaches a print or an export. + /// + private void DrawAnchorMarker(DrawingContext dc, LabelElement e) + { + if (e.Anchor is not { } anchor) return; + var session = _vm!.Session; + var frame = session.Bounds.GetFrame(e); + var (ax, ay) = new Rotula.Core.Layout.DotRect(frame.X, frame.Y, frame.Width, frame.Height).PointAt(anchor); + var own = ToPixel(ax, ay); + if (e.RelativeTo == Rotula.Core.Model.AnchorReference.Element && session.Document.Elements.FirstOrDefault(x => x.Id == e.ReferenceElementId) is { } reference + && !ReferenceEquals(reference, e)) + { + var rf = session.Bounds.GetFrame(reference); + var (rx, ry) = new Rotula.Core.Layout.DotRect(rf.X, rf.Y, rf.Width, rf.Height).PointAt(anchor); + var target = ToPixel(rx, ry); + dc.DrawLine(AnchorLinkPen, own, target); + dc.DrawEllipse(Brushes.White, new Pen(AccentBrush, 1.5), target, 4, 4); + } + dc.DrawEllipse(AccentBrush, new Pen(Brushes.White, 1.5), own, 5, 5); + } + private void DrawGuides(DrawingContext dc, Rect labelRect) { var pen = new Pen(GuideBrush, 1); @@ -363,7 +391,10 @@ protected override void OnMouseLeftButtonDown(MouseButtonEventArgs e) else if (!session.IsSelected(hit)) session.Select(hit); _mode = Mode.Move; // Locked objects stay where they are even when they are part of a dragged selection; with only locked ones there is nothing to drag. - _origins = session.SelectedElements.Where(el => !el.Locked).ToDictionary(el => el.Id, el => (el.X, el.Y)); + // An object positioned relative to another dragged one follows it by itself, so only the independent ones get the distance. + var unlocked = session.SelectedElements.Where(el => !el.Locked).ToList(); + var independent = EditorCommands.IndependentlyMovable(Doc, unlocked); + _origins = unlocked.Where(el => independent.Contains(el.Id)).ToDictionary(el => el.Id, el => (el.X, el.Y)); _gesture = _origins.Count > 0 ? session.BeginGesture() : null; _startBounds = session.Bounds.GetSelectionBounds(session.SelectedElements.Where(el => !el.Locked).ToList()); CaptureMouse(); diff --git a/src/Rotula.App/Editing/EditorCommands.cs b/src/Rotula.App/Editing/EditorCommands.cs index b876ce9..32c20e1 100644 --- a/src/Rotula.App/Editing/EditorCommands.cs +++ b/src/Rotula.App/Editing/EditorCommands.cs @@ -1,4 +1,5 @@ using Rotula.Core.Elements; +using Rotula.Core.Layout; using Rotula.Core.Model; namespace Rotula.App.Editing; @@ -96,12 +97,30 @@ public void Add(LabelElement element, Action? alsoChange = null) }, selectionAfter: () => new[] { element.Id }); } - /// Deletes the selected elements. + /// + /// Deletes the selected elements. An object that was positioned relative to a deleted one becomes relative to the label instead, with the + /// numbers that keep it exactly where it is, so deleting a reference never moves anything else. + /// public void DeleteSelected() { var ids = Unlocked(_s.SelectedElements).Select(e => e.Id).ToHashSet(); if (ids.Count == 0) return; - _s.Edit("Undo.Delete", d => d.Elements.RemoveAll(e => ids.Contains(e.Id)), selectionAfter: () => Array.Empty()); + // Worked out before the delete, while the references still exist to measure against. + var rebound = new Dictionary(StringComparer.Ordinal); + foreach (var e in _s.Document.Elements) + if (!ids.Contains(e.Id) && e.Anchor is not null && e.RelativeTo == AnchorReference.Element && e.ReferenceElementId is { } refId && ids.Contains(refId)) + rebound[e.Id] = _s.Bounds.OffsetKeepingPosition(e, e.Anchor, AnchorReference.Label, null); + _s.Edit("Undo.Delete", d => + { + d.Elements.RemoveAll(e => ids.Contains(e.Id)); + foreach (var e in d.Elements) + if (rebound.TryGetValue(e.Id, out var offset)) + { + e.RelativeTo = AnchorReference.Label; + e.ReferenceElementId = null; + (e.X, e.Y) = offset; + } + }, selectionAfter: () => Array.Empty()); } /// Copies the selection with fresh ids, offset a little so the copy is visible, and selects the copies. @@ -109,6 +128,7 @@ public void DuplicateSelected() { var copies = _s.SelectedElements.Select(e => ElementCloner.Clone(e, newId: true)).ToList(); if (copies.Count == 0) return; + RemapReferences(_s.SelectedElements, copies, _s.Document); foreach (var c in copies) { c.X += PasteOffsetDots; @@ -117,6 +137,28 @@ public void DuplicateSelected() _s.Edit("Undo.Duplicate", d => d.Elements.AddRange(copies), selectionAfter: () => copies.Select(c => c.Id).ToList()); } + /// + /// Points the copies' references at the copies: when A and B are copied together and B is relative to A, B' becomes relative to A'. A + /// reference to something that is neither copied nor on this label (a paste from another label) falls back to the label, so the copy + /// still has a place. + /// + private static void RemapReferences(IReadOnlyList originals, IReadOnlyList copies, LabelDocument doc) + { + var map = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < originals.Count && i < copies.Count; i++) map[originals[i].Id] = copies[i].Id; + var present = doc.Elements.Select(e => e.Id).ToHashSet(StringComparer.Ordinal); + foreach (var c in copies) + { + if (c.Anchor is null || c.RelativeTo != AnchorReference.Element || c.ReferenceElementId is not { } refId) continue; + if (map.TryGetValue(refId, out var copied)) c.ReferenceElementId = copied; + else if (!present.Contains(refId)) + { + c.RelativeTo = AnchorReference.Label; + c.ReferenceElementId = null; + } + } + } + /// The clipboard text for the current selection, or null when nothing is selected. public string? CopySelected() { @@ -140,6 +182,7 @@ public bool Paste(string? clipboardText) c.Y += PasteOffsetDots * _pasteCount; fresh.Add(c); } + RemapReferences(items, fresh, _s.Document); _s.Edit("Undo.Paste", d => d.Elements.AddRange(fresh), selectionAfter: () => fresh.Select(c => c.Id).ToList()); return true; } @@ -211,6 +254,17 @@ public void MoveLayer(LabelElement element, int newIndex) // ------------------------------------------------------------------------------- move / nudge / rotate + /// + /// The ids of the elements a move of must shift itself: an element positioned relative to another one of the + /// selection (directly or through others) moves along with it, so adding the distance to it as well would move it twice. + /// + public static HashSet IndependentlyMovable(LabelDocument doc, IReadOnlyList selection) + { + var ids = selection.Select(e => e.Id).ToHashSet(StringComparer.Ordinal); + if (!AnchorResolver.HasAnchors(doc)) return ids; + return selection.Where(e => !AnchorResolver.FollowsAny(doc, e, ids)).Select(e => e.Id).ToHashSet(StringComparer.Ordinal); + } + /// /// Moves the selection. Repeated nudges within a second merge into one undo step. /// @@ -218,7 +272,7 @@ public void Nudge(int dx, int dy) { var sel = Unlocked(_s.SelectedElements); if (sel.Count == 0 || (dx == 0 && dy == 0)) return; - var movable = sel.Select(e => e.Id).ToHashSet(); + var movable = IndependentlyMovable(_s.Document, sel); _s.Edit("Undo.Move", d => { foreach (var e in d.Elements.Where(e => movable.Contains(e.Id))) @@ -264,8 +318,21 @@ public void Align(AlignKind kind) var sel = _s.SelectedElements; if (sel.Count == 0) return; // A locked object still counts for the reference box (it is a fine anchor to line the others up with) but it is never moved. - RectD reference = sel.Count == 1 ? LabelRect(_s.Document) : _s.Bounds.GetSelectionBounds(sel); - var movable = Unlocked(sel).Select(e => e.Id).ToHashSet(); + AlignTo(kind, sel.Count == 1 ? LabelRect(_s.Document) : _s.Bounds.GetSelectionBounds(sel)); + } + + /// Aligns every selected element to the label's edges or centre, however many are selected. + public void AlignToLabel(AlignKind kind) + { + if (_s.SelectedElements.Count == 0) return; + AlignTo(kind, LabelRect(_s.Document)); + } + + private void AlignTo(AlignKind kind, RectD reference) + { + var sel = _s.SelectedElements; + // An element that follows another selected one lands where its reference lands; moving it as well would double the shift. + var movable = IndependentlyMovable(_s.Document, Unlocked(sel)); if (movable.Count == 0) return; _s.Edit("Undo.Align", d => { diff --git a/src/Rotula.App/Editing/ElementBoundsCalculator.cs b/src/Rotula.App/Editing/ElementBoundsCalculator.cs index e58e6ca..ee61985 100644 --- a/src/Rotula.App/Editing/ElementBoundsCalculator.cs +++ b/src/Rotula.App/Editing/ElementBoundsCalculator.cs @@ -1,5 +1,6 @@ using Rotula.Core.Elements; using Rotula.Core.Imaging; +using Rotula.Core.Layout; using Rotula.Core.Text; using Rotula.Core.Model; using Rotula.Rendering; @@ -135,6 +136,52 @@ private string DataKey(IReadOnlyDictionary? data) /// The rotation an element is drawn with (its own, or the label default). public static FieldRotation EffectiveRotation(LabelElement e, LabelDocument doc) => e.Rotation ?? doc.DefaultRotation; + // Measures Windows fonts for the anchor layout; stateless and thread safe, like the one the preview uses. + private static readonly SkiaTextRasterizer AnchorRasterizer = new(); + + /// + /// The anchor layout of the current document: the same resolver, with the same size estimates, that the ZPL generator and the preview use, + /// so the frame the designer draws around an anchored object is where the object prints. Sizes of text and barcodes are cached by their + /// content, so dragging never re-estimates them. + /// + public AnchorLayout GetLayout() + { + var doc = _document(); + var data = DataProvider?.Invoke(); + return AnchorResolver.Resolve(doc, data, AnchorRasterizer, e => + { + // Only text and barcodes are worth caching (the others are read straight from the model). + if (e is not (TextElement or BarcodeElement)) return ElementSizeEstimator.Estimate(doc, e, data, AnchorRasterizer); + string key = DataKey(data) + "" + MeasureKey(e, doc); + if (!_sizeCache.TryGetValue(key, out var size)) + { + size = ElementSizeEstimator.Estimate(doc, e, data, AnchorRasterizer); + if (_sizeCache.Count > 2000) _sizeCache.Clear(); + _sizeCache[key] = size; + } + return size; + }); + } + + private readonly Dictionary _sizeCache = new(); + + /// + /// The absolute origin of the element (the point its ^FO/^FT gets): its own X/Y, or the resolved point when it is anchored. + /// Everything the designer draws or hit-tests starts from this point, never from the raw X/Y of an anchored object. + /// + public (int X, int Y) GetOrigin(LabelElement e) + { + var doc = _document(); + return !AnchorResolver.HasAnchors(doc) ? (e.X, e.Y) : GetLayout().OriginOf(e); + } + + /// + /// The X/Y that keep where it is when its anchor or reference becomes the given values (null anchor = absolute), so + /// choosing an anchor in the panel changes the numbers and not the picture. + /// + public (int X, int Y) OffsetKeepingPosition(LabelElement e, AnchorPoint? anchor, AnchorReference relativeTo, string? referenceElementId) => + AnchorResolver.OffsetKeepingPosition(GetLayout(), e, anchor, relativeTo, referenceElementId); + /// Bounding box of the element in label dots (relative to the label home, like element coordinates). public RectD GetBounds(LabelElement e) { @@ -179,7 +226,7 @@ public RectD GetFrame(LabelElement e) if (!HasTextBoxFrame(e)) return bounds; var block = ((TextElement)e).Block!; // The ink may start right of the origin (centred or right-aligned lines), but the box always starts at the origin. - return new RectD(e.X, bounds.Y, Math.Max(1, block.WidthDots), bounds.Height); + return new RectD(GetOrigin(e).X, bounds.Y, Math.Max(1, block.WidthDots), bounds.Height); } /// True when is a text block's box rather than the element's ink (the frame can then be resized by width). @@ -189,13 +236,15 @@ public bool HasTextBoxFrame(LabelElement e) => /// Bounds of every element, same order as the document. public RectD GetSelectionBounds(IEnumerable elements) => RectD.UnionAll(elements.Select(GetBounds)); - private static RectD FromSize(LabelElement e, LabelDocument doc, int w, int h, FieldRotation rotation) + private RectD FromSize(LabelElement e, LabelDocument doc, int w, int h, FieldRotation rotation) { w = Math.Max(1, w); h = Math.Max(1, h); + // The resolved origin, not the raw X/Y: for an anchored object those are an offset from its reference. + var (ox, oy) = GetOrigin(e); bool swap = rotation is FieldRotation.Rotated90 or FieldRotation.Rotated270; if (e.OriginKind == FieldOriginKind.Origin) - return new RectD(e.X, e.Y, swap ? h : w, swap ? w : h); + return new RectD(ox, oy, swap ? h : w, swap ? w : h); // ^FT: the origin is the bottom-left corner of the unrotated box and the box rotates clockwise around it. var corners = new (double X, double Y)[] { (0, -h), (w, -h), (w, 0), (0, 0) }; @@ -212,19 +261,20 @@ private static RectD FromSize(LabelElement e, LabelDocument doc, int w, int h, F minX = Math.Min(minX, rx); maxX = Math.Max(maxX, rx); minY = Math.Min(minY, ry); maxY = Math.Max(maxY, ry); } - return new RectD(e.X + minX, e.Y + minY, maxX - minX, maxY - minY); + return new RectD(ox + minX, oy + minY, maxX - minX, maxY - minY); } private RectD MeasureInk(LabelElement e, LabelDocument doc) { var data = DataProvider?.Invoke(); + var (ox, oy) = GetOrigin(e); // A record's values change the text width, so they are part of the cache key (empty when no record is shown). string key = DataKey(data) + "" + MeasureKey(e, doc); if (!_cache.TryGetValue(key, out var m)) { bool failed = false; m = Probe(e, doc, data, ref failed); - if (failed && _lastGood.TryGetValue(e.Id, out var known)) return new RectD(e.X + known.OffX, e.Y + known.OffY, known.W, known.H); + if (failed && _lastGood.TryGetValue(e.Id, out var known)) return new RectD(ox + known.OffX, oy + known.OffY, known.W, known.H); if (!failed) { if (_cache.Count > 2000) _cache.Clear(); @@ -232,7 +282,7 @@ private RectD MeasureInk(LabelElement e, LabelDocument doc) _lastGood[e.Id] = m; } } - return new RectD(e.X + m.OffX, e.Y + m.OffY, m.W, m.H); + return new RectD(ox + m.OffX, oy + m.OffY, m.W, m.H); } /// Everything that influences how large a text/barcode is, except its position. @@ -261,6 +311,10 @@ private static string MeasureKey(LabelElement e, LabelDocument doc) var clone = ElementCloner.Clone(e, newId: false); clone.X = ProbePad; clone.Y = ProbePad; + // The probe measures the ink alone; an anchor would move it (and would point at an element the probe label does not have). + clone.Anchor = null; + clone.RelativeTo = AnchorReference.Label; + clone.ReferenceElementId = null; // Reverse printing inverts what is underneath; on a blank probe that would make the whole area ink. clone.ReverseField = false; // An object that is switched off for the shown record still has a size and must stay selectable; the renderer would otherwise skip it and diff --git a/src/Rotula.App/MainWindow.xaml b/src/Rotula.App/MainWindow.xaml index 02c60f5..4acc6ed 100644 --- a/src/Rotula.App/MainWindow.xaml +++ b/src/Rotula.App/MainWindow.xaml @@ -234,6 +234,15 @@ + + + + + + + + + diff --git a/src/Rotula.App/Resources/Strings.en.resx b/src/Rotula.App/Resources/Strings.en.resx index 6e0d2c7..840ff55 100644 --- a/src/Rotula.App/Resources/Strings.en.resx +++ b/src/Rotula.App/Resources/Strings.en.resx @@ -5846,4 +5846,79 @@ Brand and product names belong to their owners; Rotula is not affiliated with th Locked: this object cannot be moved, resized or deleted until you unlock it. + + Anchor point + + + Which point of this object the position refers to. With an anchor, X and Y are the distance from the same point of the reference (the label, or another object), so the object keeps its place when the label is resized or the reference moves. Choosing a point never moves the object: only the numbers change. + + + Free + + + No anchor: X and Y are the top-left corner (or the baseline) measured from the label home, as before. + + + Free position (X and Y from the label home) + + + Top left + + + Top centre + + + Top right + + + Middle left + + + Centre + + + Middle right + + + Bottom left + + + Bottom centre + + + Bottom right + + + Relative to + + + What the anchor is measured from: the label itself, or another object of this label. An object that is relative to another one follows it when it moves or changes size. + + + The label + + + (object no longer exists) + + + X offset (dots) + + + Y offset (dots) + + + Distance from the anchor point of the reference to the anchor point of this object. Negative values go left or up. + + + {0} is already positioned relative to this object (directly or through another object), so this object cannot be relative to it. Choose another reference. + + + This object and "{0}" are positioned relative to each other, so neither has a place and this one is left out of the label. Make one of them relative to the label. + + + The object this one is positioned relative to no longer exists, so it is placed relative to the label instead. + + + Align to label + diff --git a/src/Rotula.App/Resources/Strings.es.resx b/src/Rotula.App/Resources/Strings.es.resx index 653f841..d7226d9 100644 --- a/src/Rotula.App/Resources/Strings.es.resx +++ b/src/Rotula.App/Resources/Strings.es.resx @@ -5846,4 +5846,79 @@ Las marcas y los nombres de producto pertenecen a sus dueños; Rotula no está a Bloqueado: este objeto no se puede mover, cambiar de tamaño ni borrar hasta que lo desbloquee. + + Punto de anclaje + + + A qué punto de este objeto se refiere la posición. Con un anclaje, X e Y son la distancia desde el mismo punto de la referencia (la etiqueta u otro objeto), así que el objeto conserva su lugar cuando la etiqueta cambia de tamaño o la referencia se mueve. Elegir un punto nunca mueve el objeto: solo cambian los números. + + + Libre + + + Sin anclaje: X e Y son la esquina superior izquierda (o la línea base) medidas desde el origen de la etiqueta, como antes. + + + Posición libre (X e Y desde el origen de la etiqueta) + + + Superior izquierda + + + Superior centro + + + Superior derecha + + + Centro izquierda + + + Centro + + + Centro derecha + + + Inferior izquierda + + + Inferior centro + + + Inferior derecha + + + Relativo a + + + Desde dónde se mide el anclaje: la propia etiqueta u otro objeto de esta etiqueta. Un objeto relativo a otro lo sigue cuando este se mueve o cambia de tamaño. + + + La etiqueta + + + (el objeto ya no existe) + + + Desplazamiento X (puntos) + + + Desplazamiento Y (puntos) + + + Distancia desde el punto de anclaje de la referencia hasta el punto de anclaje de este objeto. Los valores negativos van hacia la izquierda o hacia arriba. + + + {0} ya está posicionado relativo a este objeto (directamente o a través de otro objeto), así que este objeto no puede ser relativo a él. Elija otra referencia. + + + Este objeto y "{0}" están posicionados uno relativo al otro, así que ninguno tiene lugar y este se deja fuera de la etiqueta. Haga que uno de ellos sea relativo a la etiqueta. + + + El objeto respecto al cual está posicionado este ya no existe, así que se coloca relativo a la etiqueta. + + + Alinear con la etiqueta + diff --git a/src/Rotula.App/ViewModels/MainViewModel.cs b/src/Rotula.App/ViewModels/MainViewModel.cs index 250b697..483b8c6 100644 --- a/src/Rotula.App/ViewModels/MainViewModel.cs +++ b/src/Rotula.App/ViewModels/MainViewModel.cs @@ -1175,6 +1175,14 @@ private void Align(AlignKind kind) ReportLocked(); } + /// Align the selection to the label itself, whatever its size (the parameter is an ). + [RelayCommand(CanExecute = nameof(HasSelection))] + private void AlignToLabel(AlignKind kind) + { + Commands.AlignToLabel(kind); + ReportLocked(); + } + /// Distribute the selection (the parameter is a ). [RelayCommand(CanExecute = nameof(HasThree))] private void Distribute(DistributeKind kind) diff --git a/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs b/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs index 9599442..5d8684b 100644 --- a/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs +++ b/src/Rotula.App/ViewModels/PropertyPanelViewModel.cs @@ -7,6 +7,7 @@ using Rotula.Core.Barcodes; using Rotula.Core.Barcodes.QrContent; using Rotula.Core.Elements; +using Rotula.Core.Layout; using Rotula.Core.Model; using Rotula.Core.Text; using Rotula.Core.Validation; @@ -109,7 +110,9 @@ private string SelectionKey() : e is DataFieldElement ? nameof(TextElement) : e?.GetType().Name ?? string.Empty; // a legacy data field is edited as the text it is // Whether the element has a printing rule, and whether that rule needs a value, decide which Printing rows exist. string rule = e?.PrintCondition is { } pc ? (pc.UsesValue ? "rule+value" : "rule") : "norule"; - return sel[0] + ":" + barcodeLayout + ":" + (e is TextElement t && t.Block is not null) + ":" + rule; + // An anchor adds the "Relative to" row and turns the X/Y boxes into offsets (other labels, other range). + string anchored = e?.Anchor is null ? "abs" : "anchored"; + return sel[0] + ":" + barcodeLayout + ":" + (e is TextElement t && t.Block is not null) + ":" + rule + ":" + anchored; } private void Rebuild(bool force = false) @@ -206,8 +209,11 @@ private void BuildElementRows(LabelElement element) if (element is TextElement textElement) BuildTextRows(textElement); Rows.Add(new HeadingRow("Panel.Position")); - AddInt(id, "Prop.X", e => e.X, (e, v) => e.X = v, 0, 32000); - AddInt(id, "Prop.Y", e => e.Y, (e, v) => e.Y = v, 0, 32000); + BuildAnchorRows(element); + // With an anchor the numbers are an offset from the reference's anchor point, so they may be negative (left or up). + bool anchored = element.Anchor is not null; + AddInt(id, anchored ? "Prop.X.Offset" : "Prop.X", e => e.X, (e, v) => e.X = v, anchored ? -32000 : 0, 32000, hintKey: anchored ? "Prop.Offset.Hint" : null); + AddInt(id, anchored ? "Prop.Y.Offset" : "Prop.Y", e => e.Y, (e, v) => e.Y = v, anchored ? -32000 : 0, 32000, hintKey: anchored ? "Prop.Offset.Hint" : null); Rows.Add(new ChoiceRow("Prop.OriginKind", ChoiceRow.ForEnum(), () => El(id).OriginKind, v => Edit(id, "origin", e => e.OriginKind = (FieldOriginKind)v!), "Prop.OriginKind.Hint")); // A barcode whose ZPL command has no orientation parameter (MaxiCode) cannot be rotated, so the control is hidden. It stays @@ -290,6 +296,73 @@ private void BuildElementRows(LabelElement element) BuildPrintingRows(element); } + /// + /// The anchor picker and, once the object has an anchor, the "Relative to" drop-down (the label, or any other object). Both change the + /// numbers so the object stays exactly where it is: choosing an anchor is a change of meaning, not a move. A reference that would close a + /// circle (the other object is already relative to this one) is refused with a sentence that says so. + /// + private void BuildAnchorRows(LabelElement element) + { + string id = element.Id; + Rows.Add(new AnchorRow("Prop.Anchor", () => El(id).Anchor, anchor => SetAnchor(id, anchor), "Prop.Anchor.Hint") { RebuildOnChange = true }); + if (element.Anchor is null) return; + + var items = new List { new(null, Loc.Get("Prop.Reference.Label")) }; + foreach (var other in _session.Document.Elements) + if (other.Id != id) items.Add(new ChoiceItem(other.Id, UniqueDisplayName(other))); + // A reference the label no longer has (a file edited elsewhere) is listed so the box is not blank; the layout already treats it as the label. + if (element.RelativeTo == AnchorReference.Element && element.ReferenceElementId is { } missing && items.All(i => !Equals(i.Value, missing))) + items.Add(new ChoiceItem(missing, Loc.Get("Prop.Reference.Missing"))); + Rows.Add(new ChoiceRow("Prop.Reference", items, + () => El(id) is { RelativeTo: AnchorReference.Element } e ? e.ReferenceElementId : null, + v => SetReference(id, v as string), "Prop.Reference.Hint", + validate: v => v is string refId && AnchorResolver.WouldCreateCycle(_session.Document, id, refId) + ? Loc.Get("Anchor.Cycle", ElementDisplayName(El(refId))) + : null)); + } + + /// The display name of an element, numbered when another element of the label shows the same name, so a list can tell them apart. + private string UniqueDisplayName(LabelElement e) + { + string name = ElementDisplayName(e); + var twins = _session.Document.Elements.Where(x => ElementDisplayName(x) == name).ToList(); + return twins.Count <= 1 ? name : $"{name} #{twins.IndexOf(e) + 1}"; + } + + private void SetAnchor(string id, AnchorPoint? anchor) + { + var e = El(id); + // Removing the anchor also forgets the reference; the numbers become the absolute origin the object already has. + var relativeTo = anchor is null ? AnchorReference.Label : e.RelativeTo; + string? reference = anchor is null ? null : e.ReferenceElementId; + var (x, y) = _session.Bounds.OffsetKeepingPosition(e, anchor, relativeTo, reference); + _session.Edit("Undo.Property", d => + { + var target = d.Elements.First(el => el.Id == id); + target.Anchor = anchor; + target.RelativeTo = relativeTo; + target.ReferenceElementId = reference; + target.X = x; + target.Y = y; + }); + } + + private void SetReference(string id, string? referenceElementId) + { + var e = El(id); + if (e.Anchor is not { } anchor) return; + var relativeTo = referenceElementId is null ? AnchorReference.Label : AnchorReference.Element; + var (x, y) = _session.Bounds.OffsetKeepingPosition(e, anchor, relativeTo, referenceElementId); + _session.Edit("Undo.Property", d => + { + var target = d.Elements.First(el => el.Id == id); + target.RelativeTo = relativeTo; + target.ReferenceElementId = referenceElementId; + target.X = x; + target.Y = y; + }); + } + /// /// The "Printing" section: do not print, print only when a field has a value (the rule), and lock the position. These are the same for /// every kind of object, so they come last. Changing whether a rule exists or whether it needs a value changes which rows exist, so @@ -384,9 +457,11 @@ private void EditContent(string id, string labelKey, string value, string? discr /// The text element that gets the block. public static TextBlockOptions DefaultBlockFor(LabelDocument doc, TextElement text) { - // Positions count from the label home (^LH, the left and top margins), so the room is what is left of the printable area. - int roomRight = doc.WidthDots - doc.Margins.Left.ToDots(doc.Dpi) - text.X - doc.Margins.Right.ToDots(doc.Dpi); - int roomBelow = doc.HeightDots - doc.Margins.Top.ToDots(doc.Dpi) - text.Y - doc.Margins.Bottom.ToDots(doc.Dpi); + // Positions count from the label home (^LH, the left and top margins), so the room is what is left of the printable area. An anchored + // text's X/Y are an offset, so its resolved origin is used instead. + var (ox, oy) = text.Anchor is null ? (text.X, text.Y) : AnchorResolver.Resolve(doc).OriginOf(text); + int roomRight = doc.WidthDots - doc.Margins.Left.ToDots(doc.Dpi) - ox - doc.Margins.Right.ToDots(doc.Dpi); + int roomBelow = doc.HeightDots - doc.Margins.Top.ToDots(doc.Dpi) - oy - doc.Margins.Bottom.ToDots(doc.Dpi); // A text turned a quarter runs down the label: its width comes from the room below and its lines stack across. var rotation = text.Rotation ?? doc.DefaultRotation; bool vertical = rotation is FieldRotation.Rotated90 or FieldRotation.Rotated270; diff --git a/src/Rotula.App/ViewModels/PropertyRows.cs b/src/Rotula.App/ViewModels/PropertyRows.cs index 0fb8566..2f8448f 100644 --- a/src/Rotula.App/ViewModels/PropertyRows.cs +++ b/src/Rotula.App/ViewModels/PropertyRows.cs @@ -456,13 +456,23 @@ public partial class ChoiceRow : PropertyRow { private readonly Func _get; private readonly Action _set; + private readonly Func? _validate; private bool _applying; /// Creates a row. - public ChoiceRow(string labelKey, IReadOnlyList items, Func get, Action set, string? hintKey = null) + /// Resource key of the label. + /// The options. + /// Reads the current value. + /// Writes a chosen value. + /// Resource key of the tool tip. + /// + /// Returns a plain-language reason why a value cannot be chosen, or null when it can. A refused choice is shown as the row's error and the + /// drop-down goes back to the current value; nothing is written. + /// + public ChoiceRow(string labelKey, IReadOnlyList items, Func get, Action set, string? hintKey = null, Func? validate = null) : base(labelKey, hintKey) { - Items = items; _get = get; _set = set; + Items = items; _get = get; _set = set; _validate = validate; _selected = Find(get()); // The drop-down binds to a view so that items with a group (the barcode families) are listed under group headings. View = System.Windows.Data.CollectionViewSource.GetDefaultView(items); @@ -485,7 +495,18 @@ public ChoiceRow(string labelKey, IReadOnlyList items, Func partial void OnSelectedChanged(ChoiceItem? value) { if (_applying || value is null) return; - if (!Equals(_get(), value.Value)) _set(value.Value); + if (Equals(_get(), value.Value)) return; + if (_validate?.Invoke(value.Value) is { } problem) + { + // Refused: say why and show the value that is still in force. + Error = problem; + _applying = true; + Selected = Find(_get()); + _applying = false; + return; + } + Error = string.Empty; + _set(value.Value); } /// @@ -504,6 +525,116 @@ public static List ForEnum(IEnumerable? values = null) where T (values ?? Enum.GetValues()).Select(v => new ChoiceItem(v, Loc.EnumName(v))).ToList(); } +/// One of the nine cells of the anchor picker. +public sealed partial class AnchorCell : ObservableObject +{ + internal AnchorCell(AnchorRow owner, Rotula.Core.Model.AnchorPoint point) + { + Owner = owner; + Point = point; + Tip = Loc.EnumName(point); + // Where the dot sits inside the cell: the picker is a picture of the object's box, so each cell shows its own corner, edge or centre. + Horizontal = point switch + { + Rotula.Core.Model.AnchorPoint.TopLeft or Rotula.Core.Model.AnchorPoint.MiddleLeft or Rotula.Core.Model.AnchorPoint.BottomLeft => System.Windows.HorizontalAlignment.Left, + Rotula.Core.Model.AnchorPoint.TopRight or Rotula.Core.Model.AnchorPoint.MiddleRight or Rotula.Core.Model.AnchorPoint.BottomRight => System.Windows.HorizontalAlignment.Right, + _ => System.Windows.HorizontalAlignment.Center, + }; + Vertical = point switch + { + Rotula.Core.Model.AnchorPoint.TopLeft or Rotula.Core.Model.AnchorPoint.TopCenter or Rotula.Core.Model.AnchorPoint.TopRight => System.Windows.VerticalAlignment.Top, + Rotula.Core.Model.AnchorPoint.BottomLeft or Rotula.Core.Model.AnchorPoint.BottomCenter or Rotula.Core.Model.AnchorPoint.BottomRight => System.Windows.VerticalAlignment.Bottom, + _ => System.Windows.VerticalAlignment.Center, + }; + } + + /// The row the cell belongs to (the cell's button runs its command). + public AnchorRow Owner { get; } + + /// The anchor point the cell stands for. + public Rotula.Core.Model.AnchorPoint Point { get; } + + /// Translated name, shown as tool tip and read by screen readers. + public string Tip { get; } + + /// Where the dot sits horizontally inside the cell. + public System.Windows.HorizontalAlignment Horizontal { get; } + + /// Where the dot sits vertically inside the cell. + public System.Windows.VerticalAlignment Vertical { get; } + + /// True when this is the chosen anchor. + [ObservableProperty] + private bool _isSelected; +} + +/// +/// The anchor picker of the Position section: a 3 x 3 grid of the nine anchor points plus a "Free" button for an absolute position. Picking a +/// point never moves the object: the panel converts its X/Y so it stays put and only the meaning of the numbers changes. +/// +public sealed partial class AnchorRow : PropertyRow +{ + private readonly Func _get; + private readonly Action _set; + + /// Creates the row. + /// Resource key of the label. + /// Reads the current anchor (null = free). + /// Writes the chosen anchor (null = free). + /// Resource key of the tool tip. + public AnchorRow(string labelKey, Func get, Action set, string? hintKey = null) : base(labelKey, hintKey) + { + _get = get; _set = set; + Cells = Enum.GetValues().Select(p => new AnchorCell(this, p)).ToList(); + FreeCaption = Loc.Get("Prop.Anchor.Free"); + FreeTip = Loc.Get("Prop.Anchor.Free.Tip"); + Apply(get()); + } + + /// The nine cells, in reading order (top-left first), which is the order a 3 x 3 grid lays them out. + public IReadOnlyList Cells { get; } + + /// Caption of the "no anchor" button. + public string FreeCaption { get; } + + /// Tool tip of the "no anchor" button. + public string FreeTip { get; } + + /// The chosen anchor, or null for a free (absolute) position. + public Rotula.Core.Model.AnchorPoint? Selected { get; private set; } + + /// True while the position is absolute. + public bool IsFree => Selected is null; + + /// What is chosen, in words, for the line under the grid and for screen readers. + public string SelectedText => Selected is { } p ? Loc.EnumName(p) : Loc.Get("Prop.Anchor.Free.Status"); + + /// Chooses an anchor point, or null for free. + [RelayCommand] + private void Pick(Rotula.Core.Model.AnchorPoint? point) + { + if (point == _get()) return; + _set(point); + Apply(_get()); + } + + private void Apply(Rotula.Core.Model.AnchorPoint? value) + { + Selected = value; + foreach (var c in Cells) c.IsSelected = c.Point == value; + OnPropertyChanged(nameof(Selected)); + OnPropertyChanged(nameof(IsFree)); + OnPropertyChanged(nameof(SelectedText)); + } + + /// + public override void Refresh() + { + var current = _get(); + if (current != Selected) Apply(current); + } +} + /// A button in the property panel (for example "Choose image"). public sealed partial class ActionRow : PropertyRow { diff --git a/src/Rotula.App/Views/PropertiesPanel.xaml b/src/Rotula.App/Views/PropertiesPanel.xaml index 503aa7f..50990c2 100644 --- a/src/Rotula.App/Views/PropertiesPanel.xaml +++ b/src/Rotula.App/Views/PropertiesPanel.xaml @@ -200,6 +200,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Rotula.Core/Elements/LabelElement.cs b/src/Rotula.Core/Elements/LabelElement.cs index b3b8f22..ffaf04f 100644 --- a/src/Rotula.Core/Elements/LabelElement.cs +++ b/src/Rotula.Core/Elements/LabelElement.cs @@ -9,10 +9,30 @@ public abstract class LabelElement public string Id { get; set; } = Guid.NewGuid().ToString("N"); /// Optional designer-facing name. public string? Name { get; set; } - /// Horizontal position in dots. + /// + /// Horizontal position in dots. Without an it is the absolute origin (the ^FO/^FT value); with one it is + /// the horizontal distance from the reference's anchor point to this element's own anchor point (negative goes left). + /// public int X { get; set; } - /// Vertical position in dots. + /// Vertical position in dots; see for what it means with and without an . public int Y { get; set; } + + /// + /// Relative positioning: the point of this element that / place, measured from the same point of the + /// reference (). Null means the position is absolute (the top-left or typeset origin from the label home), which is + /// how every element behaved before anchors existed. The ZPL always gets absolute coordinates: works + /// them out when the label is generated or drawn, so a label resize or a longer text keeps an anchored object where it was asked to be. + /// + public AnchorPoint? Anchor { get; set; } + + /// What the anchor is measured from: the label (default) or another element. Ignored while is null. + public AnchorReference RelativeTo { get; set; } = AnchorReference.Label; + + /// Id of the element this one is positioned relative to when is . + public string? ReferenceElementId { get; set; } + + /// True when the element has an anchor, so its X/Y are an offset rather than an absolute origin. + public bool IsAnchored => Anchor is not null; /// ^FO (top-left) or ^FT (baseline) positioning. public FieldOriginKind OriginKind { get; set; } = FieldOriginKind.Origin; /// Explicit rotation, or null to inherit the document ^FW default. @@ -38,7 +58,11 @@ public abstract class LabelElement /// public bool Locked { get; set; } - /// Copies the printing and locking settings (, , ) onto another element, so every copy path keeps them. + /// + /// Copies the printing, locking and anchoring settings (, , , + /// , , ) onto another element, so every copy path keeps them. + /// The position itself (X/Y) is not copied: the copy paths scale or offset it themselves. + /// /// The element that receives the settings. public void CopyObjectSettingsTo(LabelElement target) { @@ -46,6 +70,9 @@ public void CopyObjectSettingsTo(LabelElement target) target.PrintCondition = PrintCondition?.Clone(); target.NotPrintable = NotPrintable; target.Locked = Locked; + target.Anchor = Anchor; + target.RelativeTo = RelativeTo; + target.ReferenceElementId = ReferenceElementId; } } diff --git a/src/Rotula.Core/Layout/AnchorResolver.cs b/src/Rotula.Core/Layout/AnchorResolver.cs new file mode 100644 index 0000000..0f507a0 --- /dev/null +++ b/src/Rotula.Core/Layout/AnchorResolver.cs @@ -0,0 +1,245 @@ +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Core.Text; +using Rotula.Core.Validation; + +namespace Rotula.Core.Layout; + +/// +/// The absolute positions of every element of a label for one record, worked out from the anchors (see ). +/// Elements without an anchor keep their own X/Y; anchored ones are placed so that their anchor point lies at the reference's anchor point +/// plus their X/Y offset. Resolution is lazy and memoized, so asking for one element only measures the chain it depends on. +/// +public sealed class AnchorLayout +{ + private readonly LabelDocument _doc; + private readonly Func _sizeOf; + private readonly Dictionary _byId; + private readonly Dictionary _origins = new(StringComparer.Ordinal); + private readonly Dictionary _sizes = new(StringComparer.Ordinal); + private readonly HashSet _cycles; + private readonly HashSet _missing = new(StringComparer.Ordinal); + private readonly List _issues = new(); + + internal AnchorLayout(LabelDocument doc, Func sizeOf) + { + _doc = doc; + _sizeOf = sizeOf; + _byId = new Dictionary(StringComparer.Ordinal); + // The last element wins when two share an id; ids are unique in practice, so this only keeps the lookup from throwing. + foreach (var e in doc.Elements) _byId[e.Id] = e; + _cycles = AnchorResolver.FindCycles(doc); + // Every problem is found up front, so the issues are complete before any position is asked for (positions resolve lazily). + foreach (var e in doc.Elements) + { + if (e.Anchor is null) continue; + if (_cycles.Contains(e.Id)) + { + string refId = e.ReferenceElementId ?? string.Empty; + string other = _byId.TryGetValue(refId, out var r) ? AnchorResolver.Describe(r) : refId; + _issues.Add(new ValidationIssue(ValidationSeverity.Error, AnchorResolver.CycleIssue, e.Id, other)); + } + else if (e.RelativeTo == AnchorReference.Element && !(e.ReferenceElementId is { } id && _byId.TryGetValue(id, out var target) && !ReferenceEquals(target, e))) + { + // The object was deleted or the element came from another label: the label stands in, so the object still has a place. + _missing.Add(e.Id); + _issues.Add(new ValidationIssue(ValidationSeverity.Warning, AnchorResolver.MissingReferenceIssue, e.Id, e.ReferenceElementId ?? string.Empty)); + } + } + } + + /// The label the layout was built for. + public LabelDocument Document => _doc; + + /// Problems found: elements that reference each other in a circle (an error) and references to elements that no longer exist (a warning). + public IReadOnlyList Issues => _issues; + + /// True when the element is part of a reference circle and therefore has no position of its own. + public bool IsOnCycle(LabelElement e) => _cycles.Contains(e.Id); + + /// The unrotated size of the element, from the measurer the layout was built with (memoized). + public (int Width, int Height) SizeOf(LabelElement e) + { + ArgumentNullException.ThrowIfNull(e); + if (!_sizes.TryGetValue(e.Id, out var size)) + { + size = _sizeOf(e); + _sizes[e.Id] = size; + } + return size; + } + + /// The absolute origin of the element: the point the ZPL ^FO/^FT gets, relative to the label home. + public (int X, int Y) OriginOf(LabelElement e) + { + ArgumentNullException.ThrowIfNull(e); + if (_origins.TryGetValue(e.Id, out var known)) return known; + var origin = Compute(e); + _origins[e.Id] = origin; + return origin; + } + + /// The box the element occupies, in absolute dots relative to the label home (its estimated size placed at its resolved origin). + public DotRect BoundsOf(LabelElement e) + { + var (w, h) = SizeOf(e); + var (x, y) = OriginOf(e); + return ElementGeometry.BoxAtOrigin(e, _doc, w, h).Offset(x, y); + } + + /// The anchor point of the reference of for a given anchor and reference choice (the label or an element). + public (double X, double Y) ReferencePoint(LabelElement e, AnchorPoint anchor, AnchorReference reference, string? referenceElementId) + { + ArgumentNullException.ThrowIfNull(e); + // A target on a reference circle still answers (with its raw numbers), so an element hanging off a circle keeps a place near it. + if (reference == AnchorReference.Element && referenceElementId is not null && _byId.TryGetValue(referenceElementId, out var target) && !ReferenceEquals(target, e)) + return BoundsOf(target).PointAt(anchor); + return ElementGeometry.LabelBox(_doc).PointAt(anchor); + } + + private (int X, int Y) Compute(LabelElement e) + { + // Absolute, or on a circle (reported in Issues): the raw numbers are the only position there is. + if (e.Anchor is not { } anchor || _cycles.Contains(e.Id)) return (e.X, e.Y); + + // A reference that does not exist (reported in Issues) is replaced by the label. + var reference = _missing.Contains(e.Id) ? AnchorReference.Label : e.RelativeTo; + var (rx, ry) = ReferencePoint(e, anchor, reference, e.ReferenceElementId); + var (w, h) = SizeOf(e); + var (ax, ay) = ElementGeometry.BoxAtOrigin(e, _doc, w, h).PointAt(anchor); + // The element's own anchor point must land on the reference point plus the offset; the origin is that point minus where the anchor + // lies within the element's own box. + return (Round(rx + e.X - ax), Round(ry + e.Y - ay)); + } + + private static int Round(double v) => (int)Math.Round(v, MidpointRounding.AwayFromZero); +} + +/// +/// Turns anchors into absolute coordinates. It is the single place that decides where an anchored object goes: the ZPL generator (so every +/// record of a data merge), the preview and the designer's selection frames all call it, so what the canvas shows is what is printed. +/// +public static class AnchorResolver +{ + /// Issue key (error): the element and the element it references, directly or through others, are positioned relative to each other. + public const string CycleIssue = "Element.Anchor.Cycle"; + + /// Issue key (warning): the referenced element does not exist; the label is used instead. + public const string MissingReferenceIssue = "Element.Anchor.MissingReference"; + + /// True when any element of the label has an anchor, so positions need resolving at all. + public static bool HasAnchors(LabelDocument doc) + { + ArgumentNullException.ThrowIfNull(doc); + return doc.Elements.Any(e => e.Anchor is not null); + } + + /// + /// Builds the layout of a label for one record. Sizes come from unless is given + /// (a caller that has already measured its elements passes that, so it never measures twice). + /// + /// The label. + /// The record's values (already resolved), or null for the sample values. Text widths depend on it. + /// Measures Windows fonts exactly; may be null. + /// Replaces the built-in size estimate. + public static AnchorLayout Resolve(LabelDocument doc, IReadOnlyDictionary? data = null, ITextRasterizer? rasterizer = null, + Func? sizeOf = null) + { + ArgumentNullException.ThrowIfNull(doc); + return new AnchorLayout(doc, sizeOf ?? (e => ElementSizeEstimator.Estimate(doc, e, data, rasterizer))); + } + + /// The ids of every element that sits on a reference circle (A relative to B relative to A, or an element relative to itself). + public static HashSet FindCycles(LabelDocument doc) + { + ArgumentNullException.ThrowIfNull(doc); + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var e in doc.Elements) byId[e.Id] = e; + var onCycle = new HashSet(StringComparer.Ordinal); + foreach (var start in doc.Elements) + { + // Walk the chain from each element; meeting the start again means it is on a circle. + var seen = new HashSet(StringComparer.Ordinal) { start.Id }; + var current = start; + while (Next(current, byId) is { } next) + { + if (next.Id == start.Id) { onCycle.Add(start.Id); break; } + if (!seen.Add(next.Id)) break; // joined a circle further down, but the start itself is not on it + current = next; + } + } + return onCycle; + } + + /// + /// True when making relative to would close a circle: the reference is the + /// element itself, or is (directly or through other elements) positioned relative to it. + /// + public static bool WouldCreateCycle(LabelDocument doc, string elementId, string referenceElementId) + { + ArgumentNullException.ThrowIfNull(doc); + if (string.Equals(elementId, referenceElementId, StringComparison.Ordinal)) return true; + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var e in doc.Elements) byId[e.Id] = e; + var seen = new HashSet(StringComparer.Ordinal); + var current = byId.GetValueOrDefault(referenceElementId); + while (current is not null && seen.Add(current.Id)) + { + if (current.Id == elementId) return true; + current = Next(current, byId); + } + return false; + } + + /// + /// True when the element follows one of : it is positioned relative to it, directly or through other elements. + /// Moving that set moves the element along, so a move must not add the distance to it a second time. + /// + public static bool FollowsAny(LabelDocument doc, LabelElement e, IReadOnlySet ids) + { + ArgumentNullException.ThrowIfNull(doc); + ArgumentNullException.ThrowIfNull(e); + ArgumentNullException.ThrowIfNull(ids); + var byId = new Dictionary(StringComparer.Ordinal); + foreach (var el in doc.Elements) byId[el.Id] = el; + var seen = new HashSet(StringComparer.Ordinal) { e.Id }; + var current = Next(e, byId); + while (current is not null && seen.Add(current.Id)) + { + if (ids.Contains(current.Id)) return true; + current = Next(current, byId); + } + return false; + } + + /// + /// The X/Y an element needs so that it stays exactly where it is when its anchor or reference changes to the given values (null anchor + /// means absolute). The designer uses it so that choosing an anchor changes the numbers, never the picture. + /// + /// The current layout of the label (where the element is now). + /// The element. + /// The new anchor, or null for an absolute position. + /// The new reference. + /// The new reference element, when is an element. + public static (int X, int Y) OffsetKeepingPosition(AnchorLayout layout, LabelElement e, AnchorPoint? anchor, AnchorReference reference, string? referenceElementId) + { + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(e); + var (ox, oy) = layout.OriginOf(e); + if (anchor is not { } a) return (ox, oy); + var (rx, ry) = layout.ReferencePoint(e, a, reference, referenceElementId); + var (w, h) = layout.SizeOf(e); + var (ax, ay) = ElementGeometry.BoxAtOrigin(e, layout.Document, w, h).PointAt(a); + return ((int)Math.Round(ox + ax - rx, MidpointRounding.AwayFromZero), (int)Math.Round(oy + ay - ry, MidpointRounding.AwayFromZero)); + } + + /// The element's name, or its id when it has none, for messages. + public static string Describe(LabelElement e) + { + ArgumentNullException.ThrowIfNull(e); + return string.IsNullOrWhiteSpace(e.Name) ? e.Id : e.Name; + } + + private static LabelElement? Next(LabelElement e, Dictionary byId) => + e.Anchor is not null && e.RelativeTo == AnchorReference.Element && e.ReferenceElementId is { } id ? byId.GetValueOrDefault(id) : null; +} diff --git a/src/Rotula.Core/Layout/DotRect.cs b/src/Rotula.Core/Layout/DotRect.cs new file mode 100644 index 0000000..45d2e9f --- /dev/null +++ b/src/Rotula.Core/Layout/DotRect.cs @@ -0,0 +1,40 @@ +using Rotula.Core.Model; + +namespace Rotula.Core.Layout; + +/// An axis-aligned rectangle in label dots (y grows downwards). +/// Left edge. +/// Top edge. +/// Width in dots. +/// Height in dots. +public readonly record struct DotRect(double X, double Y, double Width, double Height) +{ + /// Right edge. + public double Right => X + Width; + + /// Bottom edge. + public double Bottom => Y + Height; + + /// Horizontal centre. + public double CenterX => X + Width / 2; + + /// Vertical centre. + public double CenterY => Y + Height / 2; + + /// The same rectangle moved by a distance. + public DotRect Offset(double dx, double dy) => new(X + dx, Y + dy, Width, Height); + + /// One of the nine anchor points of the rectangle. + public (double X, double Y) PointAt(AnchorPoint anchor) => anchor switch + { + AnchorPoint.TopLeft => (X, Y), + AnchorPoint.TopCenter => (CenterX, Y), + AnchorPoint.TopRight => (Right, Y), + AnchorPoint.MiddleLeft => (X, CenterY), + AnchorPoint.Center => (CenterX, CenterY), + AnchorPoint.MiddleRight => (Right, CenterY), + AnchorPoint.BottomLeft => (X, Bottom), + AnchorPoint.BottomCenter => (CenterX, Bottom), + _ => (Right, Bottom), + }; +} diff --git a/src/Rotula.Core/Layout/ElementGeometry.cs b/src/Rotula.Core/Layout/ElementGeometry.cs new file mode 100644 index 0000000..7d90084 --- /dev/null +++ b/src/Rotula.Core/Layout/ElementGeometry.cs @@ -0,0 +1,70 @@ +using Rotula.Core.Elements; +using Rotula.Core.Model; + +namespace Rotula.Core.Layout; + +/// +/// Where an element's box lies around its origin point. ZPL places a field by one point (^FO top-left, or ^FT baseline / +/// bottom-left) and turns the field clockwise around that point, so the box an element occupies depends on its origin kind and rotation as +/// well as its size. The same rules are used by the ZPL generator, the preview and the designer so they never disagree about where a box is. +/// +public static class ElementGeometry +{ + /// The rotation an element is drawn with: its own, or the label default (^FW). + public static FieldRotation EffectiveRotation(LabelElement e, LabelDocument doc) + { + ArgumentNullException.ThrowIfNull(e); + ArgumentNullException.ThrowIfNull(doc); + return e switch + { + // ^GC, ^GE and ^GD have no orientation parameter, so rotation never changes their box. + CircleElement or EllipseElement or DiagonalLineElement => FieldRotation.Normal, + // The dots of an image are turned before they are sent (only the element's own rotation applies, never ^FW). + ImageElement img => img.Rotation ?? FieldRotation.Normal, + _ => e.Rotation ?? doc.DefaultRotation, + }; + } + + /// + /// The box of an element whose unrotated size is x , relative to its origin point (0, 0): + /// the origin kind decides which point of the box the origin is, and the rotation turns the box clockwise around it. + /// + public static DotRect BoxAtOrigin(LabelElement e, LabelDocument doc, int width, int height) + { + ArgumentNullException.ThrowIfNull(e); + var rotation = EffectiveRotation(e, doc); + int w = Math.Max(1, width), h = Math.Max(1, height); + bool swap = rotation is FieldRotation.Rotated90 or FieldRotation.Rotated270; + if (e.OriginKind == FieldOriginKind.Origin) + return new DotRect(0, 0, swap ? h : w, swap ? w : h); + + // ^FT: the origin is the bottom-left corner of the unrotated box and the box rotates clockwise around it. + var corners = new (double X, double Y)[] { (0, -h), (w, -h), (w, 0), (0, 0) }; + double minX = double.MaxValue, minY = double.MaxValue, maxX = double.MinValue, maxY = double.MinValue; + foreach (var (cx, cy) in corners) + { + // Clockwise quarter turns in a y-down plane: 90 -> (-y, x), 180 -> (-x, -y), 270 -> (y, -x). + var (rx, ry) = rotation switch + { + FieldRotation.Rotated90 => (-cy, cx), + FieldRotation.Inverted180 => (-cx, -cy), + FieldRotation.Rotated270 => (cy, -cx), + _ => (cx, cy), + }; + minX = Math.Min(minX, rx); maxX = Math.Max(maxX, rx); + minY = Math.Min(minY, ry); maxY = Math.Max(maxY, ry); + } + return new DotRect(minX, minY, maxX - minX, maxY - minY); + } + + /// + /// The label rectangle in element coordinates. Element positions count from the label home (^LH, the left and top margins), so the + /// label's own top-left corner is at minus the margins and its size is the full label, margins included. + /// + public static DotRect LabelBox(LabelDocument doc) + { + ArgumentNullException.ThrowIfNull(doc); + int hx = doc.Margins.Left.ToDots(doc.Dpi), hy = doc.Margins.Top.ToDots(doc.Dpi); + return new DotRect(-hx, -hy, doc.WidthDots, doc.HeightDots); + } +} diff --git a/src/Rotula.Core/Layout/ElementSizeEstimator.cs b/src/Rotula.Core/Layout/ElementSizeEstimator.cs new file mode 100644 index 0000000..cfdaee4 --- /dev/null +++ b/src/Rotula.Core/Layout/ElementSizeEstimator.cs @@ -0,0 +1,214 @@ +using Rotula.Core.Barcodes; +using Rotula.Core.Elements; +using Rotula.Core.Imaging; +using Rotula.Core.Model; +using Rotula.Core.Text; +using Rotula.Core.Validation; +using Rotula.Core.Zpl; + +namespace Rotula.Core.Layout; + +/// +/// The size, in dots, that an element takes on the label before rotation. Relative positioning needs it to find a corner or centre of an +/// object, and the ZPL generator cannot draw anything, so this is the one place that answers "how big is it" from the model alone. +/// +/// +/// Boxes, lines, circles, ellipses, diagonals, symbols and images have an exact size in the model. Text in a Windows font is measured +/// exactly by the (the same layout the printed picture uses). Text in a printer font and barcodes are +/// estimates: the printer's letter widths are its own (so the width comes from , the estimate behind the +/// overflow warning), and a barcode's width is the module count of its symbology times the module width, with the human-readable band sized as +/// the preview draws it. An anchored object can therefore sit a few dots from where the estimate puts it on paper; the canvas and the ZPL use +/// the same estimate, so they agree with each other. +/// +public static class ElementSizeEstimator +{ + // What the preview draws for a raw ZPL field: the real content is unknown. + private const int RawMarkerWidth = 100; + private const int RawMarkerHeight = 40; + + /// Estimates the unrotated size of for one record. + /// The label (resolution, default font, variables). + /// The element. + /// The record's values, already resolved; null for the sample values. + /// Measures Windows fonts exactly; without one such a text is estimated like a printer font. + public static (int Width, int Height) Estimate(LabelDocument doc, LabelElement e, IReadOnlyDictionary? data = null, ITextRasterizer? rasterizer = null) + { + ArgumentNullException.ThrowIfNull(doc); + ArgumentNullException.ThrowIfNull(e); + switch (e) + { + case BoxElement b: return (b.WidthDots, b.HeightDots); + case LineElement l: return l.Direction == LineDirection.Horizontal ? (l.LengthDots, l.ThicknessDots) : (l.ThicknessDots, l.LengthDots); + case CircleElement c: return (c.DiameterDots, c.DiameterDots); + case EllipseElement el: return (el.WidthDots, el.HeightDots); + case DiagonalLineElement d: return (d.WidthDots, d.HeightDots); + case SymbolElement s: + { + // ^GS with 0 takes the size of the default font; a width of 0 means "as high as it is tall". + int h = s.HeightDots > 0 ? s.HeightDots : doc.DefaultFont?.Height ?? 30; + return (s.WidthDots > 0 ? s.WidthDots : h, h); + } + case ImageElement img: return img.Source is null ? (Math.Max(1, img.WidthDots), Math.Max(1, img.HeightDots)) : ImageProcessor.ResolveSize(img); + case RecallPlaceholderElement recall: + { + // A graphic the same file downloads has known dots; anything else is drawn at the placeholder size the designer shows. + if (StoredGraphic.Find(doc.StoredGraphics, recall.Reference) is { } g && recall.Command.Equals("XG", StringComparison.OrdinalIgnoreCase)) + { + var (mx, my) = recall.GetMagnification(); + return (g.WidthDots * mx, g.HeightDots * my); + } + return (recall.WidthDots, recall.HeightDots); + } + case RawZplElement: return (RawMarkerWidth, RawMarkerHeight); + case TextElement t: return EstimateText(doc, t, data, rasterizer); + case BarcodeElement bc: return EstimateBarcode(doc, bc, data); + default: return (1, 1); + } + } + + // ---- text ---------------------------------------------------------------------------------- + + private static (int Width, int Height) EstimateText(LabelDocument doc, TextElement t, IReadOnlyDictionary? data, ITextRasterizer? rasterizer) + { + var scratch = new List(); + string text = VariableResolver.Resolve(t.GetTemplate(), data, doc.Variables, t.Id, scratch); + bool laidOut = t.Block is not null || t.TextBox is not null; + // Outside a block the printer prints one line, so line breaks are spaces (the generator and the preview do the same). + if (!laidOut) text = text.Replace("\r\n", " ", StringComparison.Ordinal).Replace('\n', ' ').Replace('\r', ' '); + // The text fit decides the size or the line count this record gets; the same decision the ZPL carries. + var fitted = TextFitResolver.Resolve(doc, t, text, rasterizer); + + if (t.Font is { IsWindows: true } windows && rasterizer is not null && windows.Validate() is null) + { + var font = windows; + if (fitted?.PointSize is { } size) { font = windows.Clone(); font.PointSize = size; } + if (text.Length == 0) return (1, Math.Max(1, (int)Math.Round(FontChoice.PointsToDots(font.PointSize, (int)doc.Dpi)))); + var request = new TextRasterRequest(text, font, (int)doc.Dpi, t.Block?.WidthDots ?? t.TextBox?.WidthDots ?? 0, t.TextBox?.HeightDots ?? 0, + fitted?.MaxLines ?? t.Block?.MaxLines ?? 0, t.Block?.LineSpacing ?? 0, t.Block?.Alignment ?? TextAlignment.Left, t.Block?.HangingIndent ?? 0); + var m = rasterizer.Measure(request); + return (Math.Max(1, m.WidthDots), Math.Max(1, m.HeightDots)); + } + + // Printer font: nominal character sizes (see the class remarks). A Windows font without a rasterizer is treated the same way, at its + // point size converted to dots, so the answer is at least the right order of magnitude. + var def = doc.DefaultFont ?? new FontSpec('0', 30, 30); + int height = fitted?.FontHeight ?? (t.Font is { IsWindows: true } w2 ? (int)Math.Round(FontChoice.PointsToDots(w2.PointSize, (int)doc.Dpi)) : t.FontHeight ?? def.Height); + int? width = fitted?.FontWidth ?? t.FontWidth; + double charWidth = TextFitEstimator.CharacterWidth(t, doc.DefaultFont, height, width); + if (t.TextBox is { } box) return (box.WidthDots, box.HeightDots); + if (t.Block is { } block) + { + int lines = Math.Clamp(TextFitEstimator.CountLines(t, text, doc.DefaultFont, height, width), 1, Math.Max(1, fitted?.MaxLines ?? block.MaxLines)); + return (block.WidthDots, Math.Max(1, lines * height + (lines - 1) * block.LineSpacing)); + } + return (Math.Max(1, (int)Math.Round(text.Length * charWidth)), Math.Max(1, height)); + } + + // ---- barcodes ------------------------------------------------------------------------------ + + private static (int Width, int Height) EstimateBarcode(LabelDocument doc, BarcodeElement b, IReadOnlyDictionary? data) + { + var scratch = new List(); + string payload = Barcodes.QrContent.QrContentBuilder.ResolveForElement(b, data, doc.Variables, scratch) + ?? VariableResolver.Resolve(b.Data, data, doc.Variables, b.Id, scratch); + var info = BarcodeCatalog.Get(b.Symbology); + // Symbologies the preview cannot draw are shown as a captioned placeholder; its size is the one the designer works with. + if (BarcodeCatalog.GetPreviewLimitation(b) is not null) + return (Math.Clamp(80 + b.Data.Length * Math.Max(1, b.ModuleWidth) * 6, 200, 600), Math.Max(40, b.Height)); + + int n = Math.Max(1, payload.Length); + double r = Math.Clamp(b.WideBarRatio, 2.0, 3.0); + int module = Math.Max(1, info.UsesModuleSize ? b.ModuleSize : b.ModuleWidth); + switch (b.Symbology) + { + case BarcodeSymbology.QrCode: + { + int modules = QrSizing.Analyze(payload, b.QrErrorCorrection, b.QrModel).Modules; + if (modules <= 0) modules = 21; + return (modules * module, modules * module); + } + case BarcodeSymbology.DataMatrix: + { + var (cols, rows) = DataMatrixSize(n, b.Columns, b.Rows); + return (cols * module, rows * module); + } + case BarcodeSymbology.Aztec: + { + // Full-range symbols: 15 modules plus 4 per layer; one layer per dozen characters is a fair middle ground. + int layers = Math.Clamp((n + 11) / 12 + 1, 1, 32); + int modules = 15 + 4 * layers; + return (modules * module, modules * module); + } + case BarcodeSymbology.MaxiCode: + { + // Fixed-size symbol, about 28 x 27 mm; the preview draws modules 7 dots apart at 203 dpi. + int side = (int)Math.Round(28.0 / 25.4 * (int)doc.Dpi); + return (side, side); + } + case BarcodeSymbology.Pdf417: + { + int cols = b.Columns > 0 ? b.Columns : Math.Clamp((int)Math.Ceiling(Math.Sqrt(n / 2.0)), 1, 30); + int codewords = (int)Math.Ceiling(n / 1.8) + 1 + (1 << (Math.Clamp(b.Pdf417SecurityLevel, 0, 8) + 1)); + int rows = b.Rows > 0 ? b.Rows : Math.Clamp((int)Math.Ceiling(codewords / (double)cols), 3, 90); + // Start, left row indicator, data columns, right row indicator and stop (the truncated form drops the right side). + int modules = b.Pdf417Truncate ? 17 * (cols + 2) + 1 : 17 * (cols + 4) + 1; + return (modules * module, rows * Math.Max(1, b.Height)); + } + case BarcodeSymbology.DataBar: + { + int modules = b.DataBarType switch + { + DataBarType.Stacked or DataBarType.StackedOmnidirectional => 50, + DataBarType.Limited => 79, + DataBarType.Expanded => Math.Clamp(4 + 21 * Math.Min(Math.Max(2, b.DataBarSegmentsPerRow), Math.Max(2, (n + 1) / 2)), 50, 500), + _ => 96, + }; + return (modules * module, LinearHeight(b, info, module)); + } + } + + // Linear codes: modules per character of the symbology, plus start/stop. The ratio r is the wide-to-narrow bar ratio (^BY). + double modules1D = b.Symbology switch + { + BarcodeSymbology.Code128 => 11 * (n + 3) + 2, + BarcodeSymbology.Code39 or BarcodeSymbology.Logmars => (3 * r + 7) * (n + 2 + (b.CheckDigit ? 1 : 0)), + BarcodeSymbology.Code93 => 9 * (n + 4) + 1, + BarcodeSymbology.Ean13 or BarcodeSymbology.UpcA => 95, + BarcodeSymbology.Ean8 => 67, + BarcodeSymbology.UpcE => 51, + BarcodeSymbology.UpcEanExtension => n <= 2 ? 20 : 47, + BarcodeSymbology.Interleaved2Of5 => ((n + (b.CheckDigit ? 1 : 0) + 1) / 2) * (4 * r + 6) + 6 + r, + BarcodeSymbology.Industrial2Of5 or BarcodeSymbology.Standard2Of5 => (2 * r + 8) * n + 4 * r + 12, + BarcodeSymbology.Code11 => (2 * r + 4) * (n + 2 + b.Code11CheckDigitCount), + BarcodeSymbology.Codabar => (3 * r + 5) * (n + 2), + BarcodeSymbology.Msi or BarcodeSymbology.Plessey => 4 * (1 + r) * (n + 2) + 2 * (1 + r), + // Postal codes print at a fixed pitch of about 22.5 bars per inch, 5 bars per digit plus the frame bars. + BarcodeSymbology.Postnet or BarcodeSymbology.Planet => (5 * n + 2) * (int)doc.Dpi / 22.5 / module, + _ => 11 * (n + 3) + 2, + }; + return (Math.Max(1, (int)Math.Round(modules1D * module)), LinearHeight(b, info, module)); + } + + /// Bars plus the human-readable band, sized as the preview draws it (text at 8 modules, between 14 and 36 dots, plus a 4-dot gap). + private static int LinearHeight(BarcodeElement b, BarcodeSymbologyInfo info, int module) + { + bool text = info.SupportsHumanReadable && (info.AlwaysPrintsHumanReadable || b.HumanReadable); + int band = text ? (int)Math.Ceiling(Math.Clamp(module * 8.0, 14, 36)) + 4 : 0; + return Math.Max(1, b.Height) + band; + } + + // ECC 200 symbol sizes (square) with their text capacity: the smallest that holds the data is the estimate. + private static readonly (int Side, int Capacity)[] DataMatrixSquares = + { + (10, 3), (12, 6), (14, 10), (16, 16), (18, 25), (20, 31), (22, 43), (24, 52), (26, 64), (32, 91), (36, 127), (40, 169), (44, 214), (48, 259), + (52, 304), (64, 418), (72, 550), (80, 682), (88, 862), (96, 1024), (104, 1222), (120, 1573), (132, 1954), (144, 2218), + }; + + private static (int Columns, int Rows) DataMatrixSize(int length, int columns, int rows) + { + if (columns > 0 && rows > 0) return (columns, rows); + foreach (var (side, capacity) in DataMatrixSquares) + if (capacity >= length) return (columns > 0 ? columns : side, rows > 0 ? rows : side); + return (144, 144); + } +} diff --git a/src/Rotula.Core/Model/Enums.cs b/src/Rotula.Core/Model/Enums.cs index 275dcc2..cc42fc4 100644 --- a/src/Rotula.Core/Model/Enums.cs +++ b/src/Rotula.Core/Model/Enums.cs @@ -42,6 +42,41 @@ public enum MediaTracking Mark, } +/// +/// One of the nine points of a box (corners, edge midpoints and the centre) used by relative positioning: the same point is taken on +/// the object and on its reference, and the object's X/Y are the distance between the two. +/// +public enum AnchorPoint +{ + /// Top-left corner. + TopLeft, + /// Middle of the top edge. + TopCenter, + /// Top-right corner. + TopRight, + /// Middle of the left edge. + MiddleLeft, + /// Centre of the box. + Center, + /// Middle of the right edge. + MiddleRight, + /// Bottom-left corner. + BottomLeft, + /// Middle of the bottom edge. + BottomCenter, + /// Bottom-right corner. + BottomRight, +} + +/// What an anchored object is positioned relative to. +public enum AnchorReference +{ + /// The label itself (its full size, so the object follows a label resize). + Label, + /// Another object of the label, named by . + Element, +} + /// Whether a field position is the top-left corner (^FO) or the typesetting origin (^FT). public enum FieldOriginKind { diff --git a/src/Rotula.Core/Zpl/RasterTextEmitter.cs b/src/Rotula.Core/Zpl/RasterTextEmitter.cs index b721823..7b6a962 100644 --- a/src/Rotula.Core/Zpl/RasterTextEmitter.cs +++ b/src/Rotula.Core/Zpl/RasterTextEmitter.cs @@ -25,7 +25,8 @@ internal static class RasterTextEmitter private const int MaxHeight = 32000; private const int MaxBytes = 4 * 1024 * 1024; - public static string? Emit(LabelDocument doc, TextElement t, FontChoice font, IReadOnlyDictionary? data, VariableResolution? resolution, + /// Writes the field. is the absolute origin (resolved from the anchor when the text has one). + public static string? Emit(LabelDocument doc, TextElement t, (int X, int Y) at, FontChoice font, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer) { if (font.Validate() is { } invalid) @@ -100,7 +101,7 @@ internal static class RasterTextEmitter // ^GF ignores rotation and, with ^FT, anchors the graphic's bottom-left corner instead of the text baseline. The picture is therefore // always placed with ^FO: for a typeset text its top-left corner is worked out from the baseline and the turn of the field. - int x = t.X, y = t.Y; + int x = at.X, y = at.Y; if (t.OriginKind == FieldOriginKind.Typeset) { var (dx, dy) = TypesetCorner(rotation, m.WidthDots, m.HeightDots, m.BaselineDots); diff --git a/src/Rotula.Core/Zpl/ZplGenerator.cs b/src/Rotula.Core/Zpl/ZplGenerator.cs index ce431c0..cc0c2bf 100644 --- a/src/Rotula.Core/Zpl/ZplGenerator.cs +++ b/src/Rotula.Core/Zpl/ZplGenerator.cs @@ -3,6 +3,7 @@ using Rotula.Core.Barcodes; using Rotula.Core.Elements; using Rotula.Core.Imaging; +using Rotula.Core.Layout; using Rotula.Core.Model; using Rotula.Core.Text; using Rotula.Core.Validation; @@ -119,13 +120,21 @@ private static ZplGenerationResult GenerateCore(LabelDocument doc, IReadOnlyDict if (resolution is not null) foreach (var (clockSet, offset) in resolution.ClockOffsets.OrderBy(kv => kv.Key)) Line(PrinterClock.OffsetCommand(clockSet, offset)); + // Anchored objects get their absolute origin here, for this record: a text that is wider on one record moves its right-anchored + // neighbour on that record only. Labels without anchors skip the whole step, so their output is byte-for-byte as before. + AnchorLayout? layout = AnchorResolver.HasAnchors(doc) ? AnchorResolver.Resolve(doc, data, rasterizer) : null; + if (layout is not null) issues.AddRange(layout.Issues); + foreach (var element in doc.Elements) { // An object that is switched off for this record is not emitted and not validated: a barcode that is only invalid on a record // where it is hidden must not stop that record from printing. The same data that fills the placeholders decides the condition. if (!ElementPrinting.IsPrinted(element, data, doc.Variables, issues)) continue; + // An object on a reference circle has no position (its error is already in the issues) and is left out like any other invalid field. + if (layout is not null && layout.IsOnCycle(element)) continue; + var at = layout?.OriginOf(element) ?? (element.X, element.Y); int before = issues.Count; - string? field = EmitElement(doc, element, data, resolution, issues, rasterizer); + string? field = EmitElement(doc, element, at, data, resolution, issues, rasterizer); bool failed = issues.Skip(before).Any(i => i.Severity == ValidationSeverity.Error); if (field is not null && !failed) Line(field); } @@ -179,42 +188,44 @@ private static string StoredGraphicCommand(StoredGraphic g) => _ => 'T', }; - private static void CheckPosition(LabelElement e, LabelDocument doc, int extentW, int extentH, List issues) + /// Checks the absolute origin (the resolved position, which for an anchored object is not its X/Y). + private static void CheckPosition(LabelElement e, (int X, int Y) at, LabelDocument doc, int extentW, int extentH, List issues) { - if (e.X < 0 || e.Y < 0 || e.X > 32000 || e.Y > 32000) + if (at.X < 0 || at.Y < 0 || at.X > 32000 || at.Y > 32000) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Element.Position.Range", e.Id, "0", "32000")); - else if (e.X + extentW > doc.WidthDots || e.Y + extentH > doc.HeightDots) + else if (at.X + extentW > doc.WidthDots || at.Y + extentH > doc.HeightDots) // Only a warning: printers clip silently, and the extent of text/barcodes is unknown here. issues.Add(new ValidationIssue(ValidationSeverity.Warning, "Element.OutsideLabel", e.Id)); } // ---- element emission ------------------------------------------------------------------ - private static string? EmitElement(LabelDocument doc, LabelElement e, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer) + private static string? EmitElement(LabelDocument doc, LabelElement e, (int X, int Y) at, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer) { switch (e) { - case TextElement t: return EmitText(doc, t, data, resolution, issues, rasterizer); - case BarcodeElement b: return EmitBarcode(doc, b, data, issues); - case BoxElement box: return EmitBox(doc, box, issues); - case LineElement line: return EmitLine(doc, line, issues); - case CircleElement c: return EmitCircle(doc, c, issues); - case EllipseElement el: return EmitEllipse(doc, el, issues); - case DiagonalLineElement dl: return EmitDiagonal(doc, dl, issues); - case SymbolElement sy: return EmitSymbol(doc, sy, issues); - case ImageElement img: return EmitImage(doc, img, issues); + case TextElement t: return EmitText(doc, t, at, data, resolution, issues, rasterizer); + case BarcodeElement b: return EmitBarcode(doc, b, at, data, issues); + case BoxElement box: return EmitBox(doc, box, at, issues); + case LineElement line: return EmitLine(doc, line, at, issues); + case CircleElement c: return EmitCircle(doc, c, at, issues); + case EllipseElement el: return EmitEllipse(doc, el, at, issues); + case DiagonalLineElement dl: return EmitDiagonal(doc, dl, at, issues); + case SymbolElement sy: return EmitSymbol(doc, sy, at, issues); + case ImageElement img: return EmitImage(doc, img, at, issues); // Imported content the designer cannot model is written back unchanged. case RawZplElement raw: return raw.Zpl.Length == 0 ? null : raw.Zpl; case RecallPlaceholderElement recall: - return recall.Zpl.Length == 0 ? null : recall.EmitAsField ? Origin(recall) + recall.Zpl + "^FS" : recall.Zpl; + return recall.Zpl.Length == 0 ? null : recall.EmitAsField ? Origin(recall, at) + recall.Zpl + "^FS" : recall.Zpl; default: issues.Add(new ValidationIssue(ValidationSeverity.Error, "Element.Unsupported", e.Id, e.GetType().Name)); return null; } } - internal static string Origin(LabelElement e) => - Invariant($"^F{(e.OriginKind == FieldOriginKind.Typeset ? 'T' : 'O')}{e.X},{e.Y}") + (e.ReverseField ? "^FR" : string.Empty); + /// The field origin command for an element placed at the absolute point . + internal static string Origin(LabelElement e, (int X, int Y) at) => + Invariant($"^F{(e.OriginKind == FieldOriginKind.Typeset ? 'T' : 'O')}{at.X},{at.Y}") + (e.ReverseField ? "^FR" : string.Empty); internal static string RotationChar(FieldRotation r) => r switch { @@ -236,15 +247,15 @@ private static string Params(params string[] ps) private static string YN(bool v) => v ? "Y" : "N"; - private static string? EmitText(LabelDocument doc, TextElement t, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer) + private static string? EmitText(LabelDocument doc, TextElement t, (int X, int Y) at, IReadOnlyDictionary? data, VariableResolution? resolution, List issues, ITextRasterizer? rasterizer) { // A Windows font cannot exist on the printer: the text is drawn to a picture and the label carries the picture. if (t.Font is { IsWindows: true } windowsFont) - return RasterTextEmitter.Emit(doc, t, windowsFont, data, resolution, issues, rasterizer); + return RasterTextEmitter.Emit(doc, t, at, windowsFont, data, resolution, issues, rasterizer); - CheckPosition(t, doc, 0, 0, issues); + CheckPosition(t, at, doc, 0, 0, issues); string resolved = ResolveTextWithClock(doc, t, data, resolution, issues, out FieldClockOptions? autoClock); - var sb = new StringBuilder(Origin(t)); + var sb = new StringBuilder(Origin(t, at)); // Text fit: decided here, for this record's own text, so every label of a data merge gets the size its content needs. The printer only // ever sees the computed ^A / ^FB values; the fit mode itself is Rotula metadata. @@ -441,7 +452,7 @@ private static string ResolveTextWithClock(LabelDocument doc, TextElement t, IRe }); } - private static string? EmitBarcode(LabelDocument doc, BarcodeElement b, IReadOnlyDictionary? data, List issues) + private static string? EmitBarcode(LabelDocument doc, BarcodeElement b, (int X, int Y) at, IReadOnlyDictionary? data, List issues) { // A QR Code made by the content builder is built from its structured definition per record, so escaping is applied to the values // after substitution; Data (the template) is only used when there is no definition. @@ -449,11 +460,11 @@ private static string ResolveTextWithClock(LabelDocument doc, TextElement t, IRe ?? VariableResolver.Resolve(b.Data, data, doc.Variables, b.Id, issues); var found = BarcodeValidator.Validate(b, resolved); issues.AddRange(found); - CheckPosition(b, doc, 0, 0, issues); + CheckPosition(b, at, doc, 0, 0, issues); if (found.Any(i => i.Severity == ValidationSeverity.Error)) return null; string payload = BarcodeValidator.NormalizeForPrinter(b, resolved); - var sb = new StringBuilder(Origin(b)); + var sb = new StringBuilder(Origin(b, at)); sb.Append(ZplBarcodeEmitter.Command(b, RotationOrEmpty(b.Rotation), RotationChar(doc.DefaultRotation), out string fdPrefix)); if (fdPrefix.Contains(ZplBarcodeEmitter.QrByteCountToken, StringComparison.Ordinal)) // Manual byte input states how many bytes follow, counted in the label's encoding (UTF-8 under ^CI28). @@ -463,36 +474,36 @@ private static string ResolveTextWithClock(LabelDocument doc, TextElement t, IRe return sb.ToString(); } - private static string? EmitBox(LabelDocument doc, BoxElement b, List issues) + private static string? EmitBox(LabelDocument doc, BoxElement b, (int X, int Y) at, List issues) { - CheckPosition(b, doc, b.WidthDots, b.HeightDots, issues); + CheckPosition(b, at, doc, b.WidthDots, b.HeightDots, issues); if (b.WidthDots < 1 || b.HeightDots < 1 || b.WidthDots > 32000 || b.HeightDots > 32000 || b.ThicknessDots < 1 || b.ThicknessDots > 32000) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Size.Range", b.Id, "1", "32000")); if (b.CornerRounding < 0 || b.CornerRounding > 8) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Rounding.Range", b.Id, "0", "8")); - return Origin(b) + "^GB" + Params(b.WidthDots.ToString(CultureInfo.InvariantCulture), b.HeightDots.ToString(CultureInfo.InvariantCulture), + return Origin(b, at) + "^GB" + Params(b.WidthDots.ToString(CultureInfo.InvariantCulture), b.HeightDots.ToString(CultureInfo.InvariantCulture), b.ThicknessDots.ToString(CultureInfo.InvariantCulture), ColorChar(b.Color), b.CornerRounding.ToString(CultureInfo.InvariantCulture)) + "^FS"; } - private static string? EmitLine(LabelDocument doc, LineElement l, List issues) + private static string? EmitLine(LabelDocument doc, LineElement l, (int X, int Y) at, List issues) { bool horizontal = l.Direction == LineDirection.Horizontal; - CheckPosition(l, doc, horizontal ? l.LengthDots : l.ThicknessDots, horizontal ? l.ThicknessDots : l.LengthDots, issues); + CheckPosition(l, at, doc, horizontal ? l.LengthDots : l.ThicknessDots, horizontal ? l.ThicknessDots : l.LengthDots, issues); if (l.LengthDots < 1 || l.ThicknessDots < 1 || l.LengthDots > 32000 || l.ThicknessDots > 32000) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Size.Range", l.Id, "1", "32000")); int w = horizontal ? l.LengthDots : l.ThicknessDots, h = horizontal ? l.ThicknessDots : l.LengthDots; - return Origin(l) + Invariant($"^GB{w},{h},{l.ThicknessDots},{ColorChar(l.Color)}^FS"); + return Origin(l, at) + Invariant($"^GB{w},{h},{l.ThicknessDots},{ColorChar(l.Color)}^FS"); } - private static string? EmitCircle(LabelDocument doc, CircleElement c, List issues) + private static string? EmitCircle(LabelDocument doc, CircleElement c, (int X, int Y) at, List issues) { - CheckPosition(c, doc, c.DiameterDots, c.DiameterDots, issues); + CheckPosition(c, at, doc, c.DiameterDots, c.DiameterDots, issues); if (c.DiameterDots < 3 || c.DiameterDots > 4095 || c.ThicknessDots < 1 || c.ThicknessDots > 4095) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Circle.Range", c.Id, "3", "4095")); - return Origin(c) + Invariant($"^GC{c.DiameterDots},{c.ThicknessDots},{ColorChar(c.Color)}^FS"); + return Origin(c, at) + Invariant($"^GC{c.DiameterDots},{c.ThicknessDots},{ColorChar(c.Color)}^FS"); } - private static string? EmitImage(LabelDocument doc, ImageElement img, List issues) + private static string? EmitImage(LabelDocument doc, ImageElement img, (int X, int Y) at, List issues) { if (img.Source is null) { @@ -511,36 +522,36 @@ private static string ResolveTextWithClock(LabelDocument doc, TextElement t, IRe return null; } var mono = ImageProcessor.ToMonochrome(img); - CheckPosition(img, doc, mono.Width, mono.Height, issues); + CheckPosition(img, at, doc, mono.Width, mono.Height, issues); int total = mono.PackedRows.Length; // ^GF ignores ^FW and ^FO orientation, so rotation was already applied to the dots. - return Origin(img) + Invariant($"^GFA,{total},{total},{mono.BytesPerRow},{mono.ToHex()}^FS"); + return Origin(img, at) + Invariant($"^GFA,{total},{total},{mono.BytesPerRow},{mono.ToHex()}^FS"); } - private static string? EmitEllipse(LabelDocument doc, EllipseElement e, List issues) + private static string? EmitEllipse(LabelDocument doc, EllipseElement e, (int X, int Y) at, List issues) { - CheckPosition(e, doc, e.WidthDots, e.HeightDots, issues); + CheckPosition(e, at, doc, e.WidthDots, e.HeightDots, issues); if (e.WidthDots < 3 || e.WidthDots > 4095 || e.HeightDots < 3 || e.HeightDots > 4095 || e.ThicknessDots < 1 || e.ThicknessDots > 4095) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Ellipse.Range", e.Id, "3", "4095")); - return Origin(e) + Invariant($"^GE{e.WidthDots},{e.HeightDots},{e.ThicknessDots},{ColorChar(e.Color)}^FS"); + return Origin(e, at) + Invariant($"^GE{e.WidthDots},{e.HeightDots},{e.ThicknessDots},{ColorChar(e.Color)}^FS"); } - private static string? EmitDiagonal(LabelDocument doc, DiagonalLineElement d, List issues) + private static string? EmitDiagonal(LabelDocument doc, DiagonalLineElement d, (int X, int Y) at, List issues) { - CheckPosition(d, doc, d.WidthDots, d.HeightDots, issues); + CheckPosition(d, at, doc, d.WidthDots, d.HeightDots, issues); if (d.WidthDots < 3 || d.WidthDots > 32000 || d.HeightDots < 3 || d.HeightDots > 32000 || d.ThicknessDots < 1 || d.ThicknessDots > 32000) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Diagonal.Range", d.Id, "3", "32000")); - return Origin(d) + Invariant($"^GD{d.WidthDots},{d.HeightDots},{d.ThicknessDots},{ColorChar(d.Color)},{(d.Lean == DiagonalLean.Left ? 'L' : 'R')}^FS"); + return Origin(d, at) + Invariant($"^GD{d.WidthDots},{d.HeightDots},{d.ThicknessDots},{ColorChar(d.Color)},{(d.Lean == DiagonalLean.Left ? 'L' : 'R')}^FS"); } - private static string? EmitSymbol(LabelDocument doc, SymbolElement s, List issues) + private static string? EmitSymbol(LabelDocument doc, SymbolElement s, (int X, int Y) at, List issues) { - CheckPosition(s, doc, s.WidthDots, s.HeightDots, issues); + CheckPosition(s, at, doc, s.WidthDots, s.HeightDots, issues); if (s.HeightDots < 0 || s.HeightDots > 32000 || s.WidthDots < 0 || s.WidthDots > 32000) issues.Add(new ValidationIssue(ValidationSeverity.Error, "Graphic.Symbol.Range", s.Id, "0", "32000")); char letter = (char)('A' + (int)s.Symbol); // Height and width are always written: without them the printer would use whatever ^CF last set. - return Origin(s) + Invariant($"^GS{RotationOrEmpty(s.Rotation)},{s.HeightDots},{s.WidthDots}^FD{letter}^FS"); + return Origin(s, at) + Invariant($"^GS{RotationOrEmpty(s.Rotation)},{s.HeightDots},{s.WidthDots}^FD{letter}^FS"); } private static string ColorChar(GraphicColor c) => c == GraphicColor.White ? "W" : "B"; diff --git a/src/Rotula.Files/RotulaFileDtos.cs b/src/Rotula.Files/RotulaFileDtos.cs index 2974926..93c975d 100644 --- a/src/Rotula.Files/RotulaFileDtos.cs +++ b/src/Rotula.Files/RotulaFileDtos.cs @@ -355,6 +355,18 @@ public abstract class ElementDto public bool? NotPrintable { get; set; } /// True when the object is locked against moving, resizing and deleting in the designer. public bool? Locked { get; set; } + + // ---- format version 11: omitted from the file while the element has no anchor. + + /// Anchor point name (TopLeft ... BottomRight), or null for an absolute position. Format version 11. + public string? Anchor { get; set; } + /// + /// What the anchor is measured from: Element, or null/Label for the label. Format version 11. (Named "relativeTo" rather than + /// "reference" because a recall element already writes its stored-object name as "reference".) + /// + public string? RelativeTo { get; set; } + /// Id of the element the anchor is measured from when is Element. Format version 11. + public string? ReferenceElementId { get; set; } } /// A per-record printing rule of an object. Format version 9. diff --git a/src/Rotula.Files/RotulaFileSerializer.cs b/src/Rotula.Files/RotulaFileSerializer.cs index 83a029d..6c83ba5 100644 --- a/src/Rotula.Files/RotulaFileSerializer.cs +++ b/src/Rotula.Files/RotulaFileSerializer.cs @@ -85,8 +85,18 @@ public static class RotulaFileSerializer /// public const int ObjectConditionsFormatVersion = 10; + /// + /// Version 11: adds relative positioning: "anchor" (one of nine points), "relativeTo" (label or element) and "referenceElementId" on an element, + /// whose X/Y are then an offset from the reference's anchor point. An older build would read the offset as an absolute position and print the + /// object in the wrong place, so it must refuse the file. Labels without anchors stay at the lower versions. + /// + public const int RelativePositionFormatVersion = 11; + /// The newest format version this build can read. Files are written with the lowest version that can hold their content. - public const int CurrentFormatVersion = ObjectConditionsFormatVersion; + public const int CurrentFormatVersion = RelativePositionFormatVersion; + + /// Longest element id accepted as an anchor reference (ids are 32-character GUIDs; hand-written ones are short words). + private const int MaxReferenceIdLength = 64; // Limits for untrusted values of the version 6 fields. private const int MaxStoredGraphics = 256; @@ -158,6 +168,9 @@ public static string Serialize(LabelDocument document, DataMergeSettings? merge // An older build would print an object that is marked "do not print", ignore its condition and forget its lock, so it must refuse the file instead. if (dto.Elements.Any(e => e.PrintCondition is not null || e.NotPrintable is not null || e.Locked is not null)) dto.FormatVersion = Math.Max(dto.FormatVersion, ObjectConditionsFormatVersion); + // An older build would take the offset of an anchored object for an absolute position and print it elsewhere, so it must refuse the file. + if (dto.Elements.Any(e => e.Anchor is not null)) + dto.FormatVersion = Math.Max(dto.FormatVersion, RelativePositionFormatVersion); return JsonSerializer.Serialize(dto, Options); } @@ -251,6 +264,10 @@ public static string Fingerprint(LabelElement e) dto.Id = string.Empty; dto.X = 0; dto.Y = 0; + // An anchor only moves the element; it never changes its size or look. + dto.Anchor = null; + dto.RelativeTo = null; + dto.ReferenceElementId = null; return JsonSerializer.Serialize(dto, FingerprintOptions); } @@ -541,6 +558,11 @@ public static ElementDto ToDto(LabelElement e) : new PrintConditionDto { Field = e.PrintCondition.Field, Operator = e.PrintCondition.Operator.ToString(), Value = e.PrintCondition.Value }; dto.NotPrintable = e.NotPrintable ? true : null; dto.Locked = e.Locked ? true : null; + // The reference only means something with an anchor; "Label" is the default and is left out so the file stays short. + dto.Anchor = e.Anchor?.ToString(); + bool toElement = e.Anchor is not null && e.RelativeTo == AnchorReference.Element && e.ReferenceElementId is not null; + dto.RelativeTo = toElement ? AnchorReference.Element.ToString() : null; + dto.ReferenceElementId = toElement ? e.ReferenceElementId : null; return dto; } @@ -748,6 +770,14 @@ public static LabelElement FromDto(ElementDto dto) e.PrintCondition = dto.PrintCondition is { } pc && TryParse(pc.Operator, out var op) ? new PrintCondition { Field = Clip(pc.Field ?? string.Empty, PrintCondition.MaxTextLength), Operator = op, Value = Clip(pc.Value ?? string.Empty, PrintCondition.MaxTextLength) } : null; + // An anchor name that is not known (a newer build, or a hand edit) leaves the element absolute rather than failing the file; a reference + // to an element needs a usable id, otherwise the label stands in. + e.Anchor = TryParse(dto.Anchor, out var anchor) ? anchor : null; + e.ReferenceElementId = dto.ReferenceElementId is { Length: > 0 and <= MaxReferenceIdLength } refId && refId.All(c => char.IsAsciiLetterOrDigit(c) || c is '-' or '_') + ? refId : null; + e.RelativeTo = e.Anchor is not null && TryParse(dto.RelativeTo, out var reference) && reference == AnchorReference.Element && e.ReferenceElementId is not null + ? AnchorReference.Element : AnchorReference.Label; + if (e.RelativeTo == AnchorReference.Label) e.ReferenceElementId = null; return e; } diff --git a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs index 9145bcf..fc9230a 100644 --- a/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs +++ b/src/Rotula.Rendering/Input/LabelDocumentAdapter.cs @@ -37,6 +37,9 @@ public static RenderLabel ToRenderLabel(LabelDocument doc, IReadOnlyDictionary(); var elements = new List(doc.Elements.Count); + // Anchored objects are placed by the same resolver (and the same measurements) the ZPL generator uses for this record, so the picture + // shows exactly where the printer puts them. + var layout = Core.Layout.AnchorResolver.HasAnchors(doc) ? Core.Layout.AnchorResolver.Resolve(doc, data, FitRasterizer) : null; foreach (var e in doc.Elements) { // Decided on the data that fills this record's placeholders, exactly as the ZPL generator does, so the picture and the print agree. @@ -44,8 +47,9 @@ public static RenderLabel ToRenderLabel(LabelDocument doc, IReadOnlyDictionary(() => RotulaFileSerializer.Deserialize("{\"formatVersion\":11}")); + // Version 10 is read (relative positioning, version 11, came later); anything beyond the newest version is refused. + Assert.True(RotulaFileSerializer.CurrentFormatVersion >= RotulaFileSerializer.ObjectConditionsFormatVersion); + Assert.Throws(() => RotulaFileSerializer.Deserialize("{\"formatVersion\":" + (RotulaFileSerializer.CurrentFormatVersion + 1) + "}")); } [Fact] diff --git a/tests/Rotula.Tests/RelativePositionTests.cs b/tests/Rotula.Tests/RelativePositionTests.cs new file mode 100644 index 0000000..8f38cca --- /dev/null +++ b/tests/Rotula.Tests/RelativePositionTests.cs @@ -0,0 +1,576 @@ +using System.Text.Json; +using Rotula.App.Editing; +using Rotula.App.Localization; +using Rotula.App.ViewModels; +using Rotula.Core.Elements; +using Rotula.Core.Layout; +using Rotula.Core.Model; +using Rotula.Core.Validation; +using Rotula.Core.Zpl; +using Rotula.Files; +using Rotula.Rendering.Export; +using Rotula.Rendering.Input; +using SkiaSharp; +using static Rotula.Tests.CoreTestSupport; + +namespace Rotula.Tests; + +/// +/// Relative positioning with a nine-point anchor: the Core maths for every anchor against the label and against another element, cycle +/// detection, the ZPL generator (absolute ^FO per record), the .rotula file (format version 11), the preview picture, the editing commands and +/// the property panel. +/// +[Collection("App")] +public class RelativePositionTests : IDisposable +{ + public RelativePositionTests() => Loc.Instance.SetLanguage("en"); + + public void Dispose() => Loc.Instance.SetLanguage("en"); + + /// A 100 x 50 box anchored to the label with the given offset. + private static BoxElement Anchored(AnchorPoint anchor, int x = 0, int y = 0, string id = "box") => + new() { Id = id, X = x, Y = y, WidthDots = 100, HeightDots = 50, ThicknessDots = 3, Anchor = anchor }; + + private static (int X, int Y) OriginOf(LabelDocument doc, LabelElement e) => AnchorResolver.Resolve(doc).OriginOf(e); + + // ------------------------------------------------------------------------------------ the nine anchors against the label + + [Theory] + [InlineData(AnchorPoint.TopLeft, 0, 0)] + [InlineData(AnchorPoint.TopCenter, 350, 0)] + [InlineData(AnchorPoint.TopRight, 700, 0)] + [InlineData(AnchorPoint.MiddleLeft, 0, 175)] + [InlineData(AnchorPoint.Center, 350, 175)] + [InlineData(AnchorPoint.MiddleRight, 700, 175)] + [InlineData(AnchorPoint.BottomLeft, 0, 350)] + [InlineData(AnchorPoint.BottomCenter, 350, 350)] + [InlineData(AnchorPoint.BottomRight, 700, 350)] + public void EachAnchor_PutsTheSamePointOfTheBoxOnTheSamePointOfTheLabel(AnchorPoint anchor, int expectedX, int expectedY) + { + // An 800 x 400 label and a 100 x 50 box: with a zero offset the box's corner/edge/centre sits exactly on the label's. + var doc = NewDoc(Anchored(anchor)); + Assert.Equal((expectedX, expectedY), OriginOf(doc, doc.Elements[0])); + Assert.Contains($"^FO{expectedX},{expectedY}^GB100,50,3,B,0^FS", ZplGenerator.Generate(doc).Zpl); + } + + [Fact] + public void TheOffset_IsADistanceFromTheReferencePoint_SoNegativeValuesMoveInwardFromTheFarEdges() + { + var doc = NewDoc(Anchored(AnchorPoint.BottomRight, -10, -20)); + Assert.Equal((690, 330), OriginOf(doc, doc.Elements[0])); + var centred = NewDoc(Anchored(AnchorPoint.Center, 25, -5)); + Assert.Equal((375, 170), OriginOf(centred, centred.Elements[0])); + } + + [Fact] + public void TheLabelBox_IncludesTheMargins_BecausePositionsCountFromTheLabelHome() + { + var doc = NewDoc(Anchored(AnchorPoint.TopLeft), Anchored(AnchorPoint.BottomRight, 0, 0, "br")); + doc.Margins.Left = Measure.Dots(30); + doc.Margins.Top = Measure.Dots(20); + // The label's own corner is 30/20 dots left/above the home, so the top-left anchor lands at (-30, -20) ... + Assert.Equal((-30, -20), OriginOf(doc, doc.Elements[0])); + // ... and the bottom-right one at the label's size minus the home and the box. + Assert.Equal((800 - 30 - 100, 400 - 20 - 50), OriginOf(doc, doc.Elements[1])); + } + + [Fact] + public void ResizingTheLabel_KeepsAnchoredObjectsAttached_AndLeavesFreeOnesWhereTheyWere() + { + var doc = NewDoc( + Anchored(AnchorPoint.BottomRight, -10, -10, "corner"), + new BoxElement { Id = "free", X = 10, Y = 10, WidthDots = 100, HeightDots = 50, ThicknessDots = 3 }); + string before = ZplGenerator.Generate(doc).Zpl; + Assert.Contains("^FO690,340^GB100,50", before); + Assert.Contains("^FO10,10^GB100,50", before); + + doc.Width = Measure.Dots(1200); + doc.Height = Measure.Dots(600); + string after = ZplGenerator.Generate(doc).Zpl; + Assert.Contains("^FO1090,540^GB100,50", after); + Assert.Contains("^FO10,10^GB100,50", after); + } + + // ------------------------------------------------------------------------------------ typeset origin and rotation + + [Fact] + public void ATypesetOrRotatedObject_IsAnchoredByItsVisibleBox_NotByItsOriginPoint() + { + // ^FT: the origin is the bottom-left corner, so a bottom-right anchor at the label corner puts the origin at (700, 400). + var typeset = NewDoc(new BoxElement { Id = "t", WidthDots = 100, HeightDots = 50, OriginKind = FieldOriginKind.Typeset, Anchor = AnchorPoint.BottomRight }); + Assert.Equal((700, 400), OriginOf(typeset, typeset.Elements[0])); + + // Rotated a quarter turn the box is 50 wide and 100 tall; the anchor works on that box. + var rotated = NewDoc(new BoxElement { Id = "r", WidthDots = 100, HeightDots = 50, Rotation = FieldRotation.Rotated90, Anchor = AnchorPoint.BottomRight }); + Assert.Equal((750, 300), OriginOf(rotated, rotated.Elements[0])); + } + + // ------------------------------------------------------------------------------------ relative to another element + + [Fact] + public void AnElementRelativeToAnother_FollowsThatElement_AndTheChainResolvesInAnyOrder() + { + var a = new BoxElement { Id = "a", X = 100, Y = 100, WidthDots = 200, HeightDots = 80, ThicknessDots = 3 }; + // b: the same point (top-right) on both boxes; b's top-right corner is 60 dots right of a's, so b starts 10 dots right of a's edge. + var b = new BoxElement { Id = "b", X = 60, Y = 0, WidthDots = 50, HeightDots = 50, ThicknessDots = 3, Anchor = AnchorPoint.TopRight, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }; + // c: centred under b (its top-centre 55 dots under b's top-centre, which is 5 dots below b), listed first to prove order does not matter. + var c = new BoxElement { Id = "c", X = 0, Y = 55, WidthDots = 30, HeightDots = 30, ThicknessDots = 3, Anchor = AnchorPoint.TopCenter, RelativeTo = AnchorReference.Element, ReferenceElementId = "b" }; + var doc = NewDoc(c, a, b); + + Assert.Equal((310, 100), OriginOf(doc, b)); + Assert.Equal((310 + 25 - 15, 155), OriginOf(doc, c)); + + // Moving a moves both followers by the same distance. + a.X += 40; + a.Y -= 20; + Assert.Equal((350, 80), OriginOf(doc, b)); + Assert.Equal((360, 135), OriginOf(doc, c)); + // Growing a moves b (anchored to its right edge); c, anchored to b's top-centre, follows b. + a.WidthDots = 300; + Assert.Equal((450, 80), OriginOf(doc, b)); + Assert.Equal((460, 135), OriginOf(doc, c)); + } + + [Fact] + public void TextWidth_DecidesWhereARightAnchoredTextStarts_PerRecord() + { + // The width of a printer-font text is an estimate from the nominal character size (see ElementSizeEstimator), so a longer value on one + // record moves that record's text to the left and nothing else. + var text = new TextElement { Id = "t", Text = "{{Name}}", FontId = '0', FontHeight = 30, FontWidth = 30, X = 0, Y = 0, Anchor = AnchorPoint.TopRight }; + var doc = NewDoc(text); + doc.Variables.Add(new LabelVariable("Name", "AB")); + string shortName = ZplGenerator.Generate(doc, new Dictionary { ["Name"] = "AB" }).Zpl; + string longName = ZplGenerator.Generate(doc, new Dictionary { ["Name"] = "ABCDEFGH" }).Zpl; + int Fo(string zpl) => int.Parse(System.Text.RegularExpressions.Regex.Match(zpl, @"\^FO(\d+),0").Groups[1].Value); + int shortX = Fo(shortName), longX = Fo(longName); + Assert.True(longX < shortX, $"{longX} should be left of {shortX}"); + Assert.True(shortX < 800 && longX > 0); + // Twice the letters, half the distance from the edge: the estimate is linear in the character count. + Assert.Equal((800 - shortX) * 4, 800 - longX); + } + + [Fact] + public void TheSizeEstimate_IsExactForShapesAndImages_AndBelievableForBarcodes() + { + var doc = NewDoc(); + Assert.Equal((100, 50), ElementSizeEstimator.Estimate(doc, new BoxElement { WidthDots = 100, HeightDots = 50 })); + Assert.Equal((4, 120), ElementSizeEstimator.Estimate(doc, new LineElement { LengthDots = 120, ThicknessDots = 4, Direction = LineDirection.Vertical })); + Assert.Equal((90, 90), ElementSizeEstimator.Estimate(doc, new CircleElement { DiameterDots = 90 })); + Assert.Equal((64, 32), ElementSizeEstimator.Estimate(doc, new ImageElement { Source = AppTestSupport.SmallBitmap(), WidthDots = 64, HeightDots = 32 })); + // EAN-13 is always 95 modules wide; with the text band it is taller than the bars. + var ean = new BarcodeElement { Symbology = BarcodeSymbology.Ean13, Data = "4006381333931", ModuleWidth = 2, Height = 100 }; + var (w, h) = ElementSizeEstimator.Estimate(doc, ean); + Assert.Equal(190, w); + Assert.True(h > 100); + // A QR code is square and grows with its data. + var qr = new BarcodeElement { Symbology = BarcodeSymbology.QrCode, Data = "short", ModuleSize = 4 }; + var (qw, qh) = ElementSizeEstimator.Estimate(doc, qr); + Assert.Equal(qw, qh); + qr.Data = new string('x', 300); + Assert.True(ElementSizeEstimator.Estimate(doc, qr).Width > qw); + } + + // ------------------------------------------------------------------------------------ cycles and missing references + + [Fact] + public void ACycle_IsDetected_ReportedAsAnError_AndTheObjectsAreLeftOutOfTheZpl() + { + var a = new BoxElement { Id = "a", Name = "First", X = 10, Y = 10, Anchor = AnchorPoint.TopLeft, RelativeTo = AnchorReference.Element, ReferenceElementId = "b" }; + var b = new BoxElement { Id = "b", Name = "Second", X = 10, Y = 10, Anchor = AnchorPoint.TopLeft, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }; + var c = new BoxElement { Id = "c", X = 5, Y = 5, Anchor = AnchorPoint.TopLeft, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }; + var free = new BoxElement { Id = "free", X = 300, Y = 300 }; + var doc = NewDoc(a, b, c, free); + + Assert.Equal(new[] { "a", "b" }, AnchorResolver.FindCycles(doc).OrderBy(x => x)); + var result = ZplGenerator.Generate(doc); + Assert.True(result.HasErrors); + var errors = result.Issues.Where(i => i.Severity == ValidationSeverity.Error).ToList(); + Assert.Equal(2, errors.Count); + Assert.All(errors, i => Assert.Equal(AnchorResolver.CycleIssue, i.MessageKey)); + Assert.Contains(errors, i => i.ElementId == "a" && i.Args[0] == "Second"); + Assert.DoesNotContain("^FO10,10", result.Zpl); + // c hangs off the circle: it keeps a place (the raw numbers of a stand in, so c lands at 10 + 5) and the free box prints as always. + Assert.Contains("^FO15,15", result.Zpl); + Assert.Contains("^FO300,300", result.Zpl); + Assert.Equal(2, System.Text.RegularExpressions.Regex.Matches(result.Zpl, @"\^GB").Count); + } + + [Theory] + [InlineData("a", "b", true)] // b is relative to a, so a cannot be relative to b + [InlineData("a", "a", true)] // never to itself + [InlineData("c", "a", true)] // a -> ... no: a is free, but c -> a is fine + [InlineData("a", "c", true)] // c is (through b) relative to a + [InlineData("b", "c", true)] // c is relative to b + [InlineData("free", "c", false)] + public void WouldCreateCycle_LooksThroughTheWholeChain(string element, string reference, bool expected) + { + var a = new BoxElement { Id = "a" }; + var b = new BoxElement { Id = "b", Anchor = AnchorPoint.Center, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }; + var c = new BoxElement { Id = "c", Anchor = AnchorPoint.Center, RelativeTo = AnchorReference.Element, ReferenceElementId = "b" }; + var doc = NewDoc(a, b, c, new BoxElement { Id = "free" }); + // "c relative to a" is allowed: a is the root of c's own chain. + if (element == "c" && reference == "a") expected = false; + Assert.Equal(expected, AnchorResolver.WouldCreateCycle(doc, element, reference)); + } + + [Fact] + public void AMissingReference_FallsBackToTheLabel_WithAWarning() + { + var doc = NewDoc(new BoxElement { Id = "b", X = -10, Y = -10, WidthDots = 100, HeightDots = 50, ThicknessDots = 3, Anchor = AnchorPoint.BottomRight, RelativeTo = AnchorReference.Element, ReferenceElementId = "gone" }); + var result = ZplGenerator.Generate(doc); + Assert.False(result.HasErrors); + var warning = Assert.Single(result.Issues); + Assert.Equal(AnchorResolver.MissingReferenceIssue, warning.MessageKey); + Assert.Contains("^FO690,340^GB100,50", result.Zpl); + } + + // ------------------------------------------------------------------------------------ golden ZPL + + [Fact] + public void AnchoredLabel_MatchesTheGoldenFixture() + { + var frame = new BoxElement { Id = "frame", Name = "Frame", X = 0, Y = 0, WidthDots = 780, HeightDots = 380, ThicknessDots = 3, Anchor = AnchorPoint.Center }; + var title = new TextElement { Id = "title", X = 20, Y = 20, FontHeight = 40, FontWidth = 30, Text = "Rotula", Anchor = AnchorPoint.TopLeft, RelativeTo = AnchorReference.Element, ReferenceElementId = "frame" }; + var code = new BarcodeElement { Id = "code", X = -20, Y = -20, Data = "{{Sku}}", Height = 90, ModuleWidth = 2, Anchor = AnchorPoint.BottomRight, RelativeTo = AnchorReference.Element, ReferenceElementId = "frame" }; + var stamp = new TextElement { Id = "stamp", X = 0, Y = -4, FontHeight = 24, FontWidth = 18, Text = "{{Sku}}", Anchor = AnchorPoint.BottomCenter }; + var doc = NewDoc(frame, title, code, stamp); + doc.Variables.Add(new LabelVariable("Sku", "SKU-0042")); + AssertGolden("anchor_relative", Zpl(doc)); + } + + // ------------------------------------------------------------------------------------ file format + + private static string FormatOf(LabelDocument doc) => JsonDocument.Parse(RotulaFileSerializer.Serialize(doc)).RootElement.GetProperty("formatVersion").ToString(); + + [Fact] + public void Anchors_RoundTripThroughTheFile_AndMarkItVersionEleven() + { + var doc = NewDoc( + new BoxElement { Id = "a", X = -5, Y = 7, Anchor = AnchorPoint.BottomRight }, + new TextElement { Id = "b", Text = "x", X = 3, Y = 4, Anchor = AnchorPoint.Center, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }, + new LineElement { Id = "plain", X = 1, Y = 2 }); + + string json = RotulaFileSerializer.Serialize(doc); + Assert.Equal("11", FormatOf(doc)); + Assert.Equal(11, RotulaFileSerializer.RelativePositionFormatVersion); + Assert.Equal(RotulaFileSerializer.RelativePositionFormatVersion, RotulaFileSerializer.CurrentFormatVersion); + Assert.Contains("\"anchor\": \"BottomRight\"", json); + Assert.Contains("\"relativeTo\": \"Element\"", json); + Assert.Contains("\"referenceElementId\": \"a\"", json); + + var back = RotulaFileSerializer.Deserialize(json); + Assert.Equal(AnchorPoint.BottomRight, back.Elements[0].Anchor); + Assert.Equal(AnchorReference.Label, back.Elements[0].RelativeTo); + Assert.Equal((-5, 7), (back.Elements[0].X, back.Elements[0].Y)); + Assert.Equal(AnchorPoint.Center, back.Elements[1].Anchor); + Assert.Equal(AnchorReference.Element, back.Elements[1].RelativeTo); + Assert.Equal("a", back.Elements[1].ReferenceElementId); + Assert.Null(back.Elements[2].Anchor); + // A free object writes none of the three, so an unchanged element looks exactly as before. + var plainJson = JsonDocument.Parse(json).RootElement.GetProperty("elements")[2]; + Assert.False(plainJson.TryGetProperty("anchor", out _)); + Assert.False(plainJson.TryGetProperty("relativeTo", out _)); + Assert.False(plainJson.TryGetProperty("referenceElementId", out _)); + } + + [Fact] + public void ALabelWithoutAnchors_KeepsItsOldFormatVersion_AndOldFilesOpenUnchanged() + { + Assert.Equal("1", FormatOf(NewDoc(new TextElement { Text = "x" }))); + Assert.Equal("10", FormatOf(NewDoc(new TextElement { Text = "x", Locked = true }))); + + const string v10 = """ + {"formatVersion":10,"label":{"width":{"value":80,"unit":"Millimeters"},"height":{"value":40,"unit":"Millimeters"},"dpi":203}, + "elements":[{"type":"text","id":"a","x":10,"y":20,"text":"Old file","locked":true},{"type":"recall","id":"r","command":"XG","reference":"R:LOGO.GRF","zpl":"^XGR:LOGO.GRF,1,1"}]} + """; + var doc = RotulaFileSerializer.Deserialize(v10); + Assert.Null(doc.Elements[0].Anchor); + Assert.Equal((10, 20), (doc.Elements[0].X, doc.Elements[0].Y)); + // A recall element's own "reference" (the stored object) is untouched by the new field, which is why the new one is "relativeTo". + Assert.Equal("R:LOGO.GRF", ((RecallPlaceholderElement)doc.Elements[1]).Reference); + Assert.Contains("^FO10,20", ZplGenerator.Generate(doc).Zpl); + } + + [Fact] + public void UnreadableAnchorData_LeavesTheObjectFree_OrRelativeToTheLabel_NeverFailingTheFile() + { + static LabelElement Load(string extra) => + RotulaFileSerializer.Deserialize("{\"formatVersion\":11,\"label\":{},\"elements\":[{\"type\":\"box\",\"id\":\"a\",\"x\":5,\"y\":6" + extra + "}]}").Elements[0]; + + Assert.Null(Load(",\"anchor\":\"Somewhere\"").Anchor); + Assert.Null(Load(",\"anchor\":\"3\"").Anchor); + var noId = Load(",\"anchor\":\"Center\",\"relativeTo\":\"Element\""); + Assert.Equal(AnchorReference.Label, noId.RelativeTo); + var badId = Load(",\"anchor\":\"Center\",\"relativeTo\":\"Element\",\"referenceElementId\":\"" + new string('x', 200) + "\""); + Assert.Equal(AnchorReference.Label, badId.RelativeTo); + Assert.Null(badId.ReferenceElementId); + var fine = Load(",\"anchor\":\"Center\",\"relativeTo\":\"Element\",\"referenceElementId\":\"other-1\""); + Assert.Equal(AnchorReference.Element, fine.RelativeTo); + Assert.Equal("other-1", fine.ReferenceElementId); + } + + [Fact] + public void TheClipboard_TheDuplicate_AndTheDpiConversion_KeepTheAnchor() + { + var box = new BoxElement { Id = "b", X = -20, Y = 10, Anchor = AnchorPoint.MiddleRight, RelativeTo = AnchorReference.Element, ReferenceElementId = "other" }; + var pasted = RotulaFileSerializer.DeserializeElements(RotulaFileSerializer.SerializeElements(new[] { box })); + Assert.Equal(AnchorPoint.MiddleRight, pasted[0].Anchor); + Assert.Equal("other", pasted[0].ReferenceElementId); + + var copy = ElementCloner.Clone(box, newId: true); + Assert.Equal(AnchorPoint.MiddleRight, copy.Anchor); + Assert.Equal(AnchorReference.Element, copy.RelativeTo); + + var converted = LabelDpiConverter.Convert(NewDoc(box), PrinterDpi.Dpi300).Document.Elements[0]; + Assert.Equal(AnchorPoint.MiddleRight, converted.Anchor); + Assert.Equal("other", converted.ReferenceElementId); + // The offset is a dot distance, so it scales with the resolution like every other dot value. + Assert.Equal((-30, 15), (converted.X, converted.Y)); + } + + // ------------------------------------------------------------------------------------ pictures + + private static (int Width, int Height) SizeOf(SKBitmap bmp) => (bmp.Width, bmp.Height); + + [Fact] + public void ThePreview_PlacesAnchoredObjectsWhereTheZplDoes_BeforeAndAfterALabelResize() + { + // A solid 60 x 40 box anchored to the bottom-right corner, 10 dots in from both edges, and a free one at the top-left. + var doc = new LabelDocument { Width = Measure.Dots(300), Height = Measure.Dots(200), Dpi = PrinterDpi.Dpi203 }; + doc.Elements.Add(new BoxElement { Id = "free", X = 10, Y = 10, WidthDots = 60, HeightDots = 40, ThicknessDots = 60 }); + doc.Elements.Add(new BoxElement { Id = "corner", X = -10, Y = -10, WidthDots = 60, HeightDots = 40, ThicknessDots = 60, Anchor = AnchorPoint.BottomRight }); + + string dir = Environment.GetEnvironmentVariable("ROTULA_ANCHOR_PNG_DIR") is { Length: > 0 } wanted ? wanted : AppTestSupport.TempDir(); + try + { + string before = Path.Combine(dir, "anchored-before.png"); + Assert.Empty(LabelExporter.SavePng(LabelDocumentAdapter.ToRenderLabel(doc), before)); + using (var bmp = SKBitmap.Decode(before)) + { + Assert.Equal((300, 200), SizeOf(bmp)); + Assert.Equal(SKColors.Black, bmp.GetPixel(40, 30)); // the free box + Assert.Equal(SKColors.Black, bmp.GetPixel(260, 170)); // the anchored box: x 230..289, y 150..189 + Assert.Equal(SKColors.White, bmp.GetPixel(295, 195)); // the 10-dot margin stays white + Assert.Equal(SKColors.White, bmp.GetPixel(225, 145)); + } + + doc.Width = Measure.Dots(500); + doc.Height = Measure.Dots(300); + string after = Path.Combine(dir, "anchored-after.png"); + Assert.Empty(LabelExporter.SavePng(LabelDocumentAdapter.ToRenderLabel(doc), after)); + using (var bmp = SKBitmap.Decode(after)) + { + Assert.Equal((500, 300), SizeOf(bmp)); + Assert.Equal(SKColors.Black, bmp.GetPixel(40, 30)); // the free box did not move + Assert.Equal(SKColors.White, bmp.GetPixel(260, 170)); // where the anchored box used to be + Assert.Equal(SKColors.Black, bmp.GetPixel(460, 270)); // it followed the corner: x 430..489, y 250..289 + Assert.Equal(SKColors.White, bmp.GetPixel(495, 295)); + } + // The ZPL says the same. + Assert.Contains("^FO430,250^GB60,40", ZplGenerator.Generate(doc).Zpl); + } + finally + { + if (Environment.GetEnvironmentVariable("ROTULA_ANCHOR_PNG_DIR") is not { Length: > 0 } && Directory.Exists(dir)) Directory.Delete(dir, true); + } + } + + // ------------------------------------------------------------------------------------ editing + + [Fact] + public void TheSelectionFrame_IsDrawnAtTheResolvedPosition_AndHitTestingFindsItThere() + { + var box = Anchored(AnchorPoint.BottomRight, -10, -10); + var s = AppTestSupport.Session(box); // 800 x 1200 + var bounds = s.Bounds.GetBounds(box); + Assert.Equal(new RectD(690, 1140, 100, 50), bounds); + Assert.Same(box, HitTesting.HitElement(s, 700, 1150, 1)); + Assert.Null(HitTesting.HitElement(s, 5, 5, 1)); + } + + [Fact] + public void Nudging_MovesTheOffset_SoTheObjectMovesByTheSameDistance() + { + var box = Anchored(AnchorPoint.BottomRight, -10, -10); + var s = AppTestSupport.Session(box); + var c = new EditorCommands(s); + s.Select(box); + c.Nudge(5, -3); + var moved = s.Document.Elements[0]; + Assert.Equal((-5, -13), (moved.X, moved.Y)); + Assert.Equal(new RectD(695, 1137, 100, 50), s.Bounds.GetBounds(moved)); + } + + [Fact] + public void MovingAReferenceAndItsFollowerTogether_MovesTheFollowerOnce() + { + var a = new BoxElement { Id = "a", X = 100, Y = 100, WidthDots = 100, HeightDots = 50 }; + var b = new BoxElement { Id = "b", X = 20, Y = 0, WidthDots = 50, HeightDots = 50, Anchor = AnchorPoint.TopLeft, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }; + var s = AppTestSupport.Session(a, b); + var c = new EditorCommands(s); + s.SelectAll(); + Assert.Equal(new[] { "a" }, EditorCommands.IndependentlyMovable(s.Document, s.SelectedElements)); + + Assert.Equal(new RectD(120, 100, 50, 50), s.Bounds.GetBounds(b)); // 20 dots right of a's top-left corner + c.Nudge(10, 10); + Assert.Equal((110, 110), (s.Document.Elements[0].X, s.Document.Elements[0].Y)); + Assert.Equal((20, 0), (s.Document.Elements[1].X, s.Document.Elements[1].Y)); // the offset is untouched ... + Assert.Equal(new RectD(130, 110, 50, 50), s.Bounds.GetBounds(s.Document.Elements[1])); // ... and the box moved exactly 10/10 + + // Aligning the pair to the label's left edge moves a to 0; b follows and is not aligned a second time. + c.AlignToLabel(AlignKind.Left); + Assert.Equal(new RectD(0, 110, 100, 50), s.Bounds.GetBounds(s.Document.Elements[0])); + Assert.Equal(new RectD(20, 110, 50, 50), s.Bounds.GetBounds(s.Document.Elements[1])); + } + + [Fact] + public void DeletingAReference_MakesItsFollowersRelativeToTheLabel_WithoutMovingThem() + { + var a = new BoxElement { Id = "a", X = 100, Y = 100, WidthDots = 100, HeightDots = 50 }; + var b = new BoxElement { Id = "b", X = 20, Y = 0, WidthDots = 50, HeightDots = 50, Anchor = AnchorPoint.TopLeft, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }; + var s = AppTestSupport.Session(a, b); + var before = s.Bounds.GetBounds(b); + s.Select(a); + new EditorCommands(s).DeleteSelected(); + + var left = Assert.Single(s.Document.Elements); + Assert.Equal("b", left.Id); + Assert.Equal(AnchorReference.Label, left.RelativeTo); + Assert.Null(left.ReferenceElementId); + Assert.Equal(AnchorPoint.TopLeft, left.Anchor); + Assert.Equal(before, s.Bounds.GetBounds(left)); + Assert.Empty(ZplGenerator.Generate(s.Document).Issues); + + s.PerformUndo(); + Assert.Equal("a", s.Document.Elements[1].ReferenceElementId); + } + + [Fact] + public void DuplicatingAPair_PointsTheCopyAtTheCopiedReference() + { + var a = new BoxElement { Id = "a", X = 100, Y = 100, WidthDots = 100, HeightDots = 50 }; + var b = new BoxElement { Id = "b", X = 20, Y = 0, WidthDots = 50, HeightDots = 50, Anchor = AnchorPoint.TopLeft, RelativeTo = AnchorReference.Element, ReferenceElementId = "a" }; + var s = AppTestSupport.Session(a, b); + s.SelectAll(); + new EditorCommands(s).DuplicateSelected(); + var copies = s.SelectedElements; + Assert.Equal(2, copies.Count); + Assert.Equal(copies[0].Id, copies[1].ReferenceElementId); + Assert.NotEqual("a", copies[1].ReferenceElementId); + } + + [Fact] + public void AlignToLabel_LinesSeveralObjectsUpWithTheLabelEdge_NotWithEachOther() + { + var one = AppTestSupport.Box(100, 100); + var two = AppTestSupport.Box(300, 300); + var s = AppTestSupport.Session(one, two); + var c = new EditorCommands(s); + s.SelectAll(); + c.AlignToLabel(AlignKind.Right); + Assert.All(s.Document.Elements, e => Assert.Equal(800, s.Bounds.GetBounds(e).Right)); + c.AlignToLabel(AlignKind.CenterVertical); + Assert.All(s.Document.Elements, e => Assert.Equal(600, s.Bounds.GetBounds(e).CenterY)); + Assert.True(s.Undo.CanUndo); + } + + // ------------------------------------------------------------------------------------ property panel + + private static T Row(PropertyPanelViewModel p, string labelKey) where T : PropertyRow => p.Rows.OfType().First(r => r.LabelKey == labelKey); + + [Fact] + public void ThePositionSection_OffersTheAnchorPicker_AndChoosingAPointKeepsTheObjectWhereItIs() + { + var box = AppTestSupport.Box(650, 1100); // 100 x 50 on an 800 x 1200 label + var s = AppTestSupport.Session(box); + var panel = new PropertyPanelViewModel(s); + s.Select(box); + + var anchorRow = Row(panel, "Prop.Anchor"); + Assert.True(anchorRow.IsFree); + Assert.Equal(9, anchorRow.Cells.Count); + Assert.DoesNotContain(panel.Rows, r => r.LabelKey == "Prop.Reference"); + Assert.Contains(panel.Rows, r => r.LabelKey == "Prop.X"); + + anchorRow.PickCommand.Execute(AnchorPoint.BottomRight); + var e = s.Document.Elements[0]; + Assert.Equal(AnchorPoint.BottomRight, e.Anchor); + // Same place, new numbers: 50 dots in from the right and bottom edges. + Assert.Equal((-50, -50), (e.X, e.Y)); + Assert.Equal(new RectD(650, 1100, 100, 50), s.Bounds.GetBounds(e)); + + // The panel rebuilt: offsets instead of positions, and the reference drop-down appeared. + Assert.Contains(panel.Rows, r => r.LabelKey == "Prop.X.Offset"); + Assert.DoesNotContain(panel.Rows, r => r.LabelKey == "Prop.X"); + var reference = Row(panel, "Prop.Reference"); + Assert.Null(reference.Selected!.Value); + Assert.True(Row(panel, "Prop.Anchor").Cells.Single(c => c.IsSelected).Point == AnchorPoint.BottomRight); + + // Negative offsets are accepted now. + Row(panel, "Prop.X.Offset").Text = "-80"; + Assert.Equal(-80, s.Document.Elements[0].X); + Assert.False(Row(panel, "Prop.X.Offset").HasError); + + // Back to free: the absolute position comes back as the numbers. + Row(panel, "Prop.Anchor").PickCommand.Execute(null); + e = s.Document.Elements[0]; + Assert.Null(e.Anchor); + Assert.Equal((620, 1100), (e.X, e.Y)); + Assert.Contains(panel.Rows, r => r.LabelKey == "Prop.X"); + + s.PerformUndo(); + Assert.Equal(AnchorPoint.BottomRight, s.Document.Elements[0].Anchor); + } + + [Fact] + public void TheReferenceList_OffersTheLabelAndEveryOtherObject_AndRefusesACycleWithAMessage() + { + var a = new BoxElement { Id = "a", Name = "Header", X = 100, Y = 100, WidthDots = 100, HeightDots = 50 }; + var b = new BoxElement { Id = "b", X = 300, Y = 300, WidthDots = 50, HeightDots = 50, Anchor = AnchorPoint.TopLeft }; + var s = AppTestSupport.Session(a, b); + var panel = new PropertyPanelViewModel(s); + s.Select(b); + + var reference = Row(panel, "Prop.Reference"); + Assert.Equal(new object?[] { null, "a" }, reference.Items.Select(i => i.Value)); + Assert.Equal("Header (Box)", reference.Items[1].Display); + + var bounds = s.Bounds.GetBounds(b); + reference.Selected = reference.Items[1]; + var e = s.Document.Elements[1]; + Assert.Equal(AnchorReference.Element, e.RelativeTo); + Assert.Equal("a", e.ReferenceElementId); + Assert.Equal((200, 200), (e.X, e.Y)); // 300 - 100: the same place, measured from the header's corner + Assert.Equal(bounds, s.Bounds.GetBounds(e)); + + // Now make the header relative to the box: refused, explained, nothing written. + s.Select(a); + Row(panel, "Prop.Anchor").PickCommand.Execute(AnchorPoint.Center); + var headerReference = Row(panel, "Prop.Reference"); + headerReference.Selected = headerReference.Items.First(i => Equals(i.Value, "b")); + Assert.True(headerReference.HasError); + Assert.Contains("already positioned relative to this object", headerReference.Error); + Assert.Null(headerReference.Selected!.Value); + Assert.Equal(AnchorReference.Label, s.Document.Elements[0].RelativeTo); + Assert.DoesNotContain(ZplGenerator.Generate(s.Document).Issues, i => i.Severity == ValidationSeverity.Error); + } + + [Fact] + public void EveryNewTextIsInBothLanguages() + { + foreach (var lang in new[] { "en", "es" }) + { + Loc.Instance.SetLanguage(lang); + foreach (var key in new[] { "Prop.Anchor", "Prop.Anchor.Hint", "Prop.Anchor.Free", "Prop.Anchor.Free.Tip", "Prop.Anchor.Free.Status", "Prop.Reference", "Prop.Reference.Hint", + "Prop.Reference.Label", "Prop.Reference.Missing", "Prop.X.Offset", "Prop.Y.Offset", "Prop.Offset.Hint", "Anchor.Cycle", "Menu.AlignToLabel", + "Issue." + AnchorResolver.CycleIssue, "Issue." + AnchorResolver.MissingReferenceIssue }) + Assert.True(Loc.Instance.HasKey(key), key + " / " + lang); + foreach (var point in Enum.GetValues()) + Assert.True(Loc.Instance.HasKey("Enum.AnchorPoint." + point), point + " / " + lang); + } + Loc.Instance.SetLanguage("es"); + var issue = new ValidationIssue(ValidationSeverity.Error, AnchorResolver.CycleIssue, "x", "Cabecera"); + Assert.Contains("Cabecera", Loc.FormatIssue(issue)); + Assert.DoesNotContain("Problem found", Loc.FormatIssue(issue)); + } +} diff --git a/tests/fixtures/anchor_relative.zpl b/tests/fixtures/anchor_relative.zpl new file mode 100644 index 0000000..9761d57 --- /dev/null +++ b/tests/fixtures/anchor_relative.zpl @@ -0,0 +1,16 @@ +^XA +^MNY +^MTT +^CI28 +^PW800 +^LL400 +^PON +^LH0,0 +^FWN +^CF0,30,30 +^FO10,10^GB780,380,3,B,0^FS +^FO30,30^A0,40,30^FDRotula^FS +^FO524,260^BY2,3.0^BC,90,Y,N,N,A^FDSKU-0042^FS +^FO361,372^A0,24,18^FDSKU-0042^FS +^PQ1,0,1,N +^XZ From 69368264b4da4407cbbafcd34ca0a73346e8e010 Mon Sep 17 00:00:00 2001 From: glenntosco Date: Mon, 12 Oct 2026 15:14:16 -0500 Subject: [PATCH 04/25] feat: editable ZPL source panel and ^FN field round trip The ZPL panel under the canvas is now an editor: typed or pasted ZPL is re-imported through the existing importer after a 300 ms pause, as one undo step per edit, with an explicit Generated / Edited by hand state, a Revert to design button, a status line that counts and names the commands kept as raw ZPL, and a hint line explaining the command under the caret from a new C# parameter reference (ZplCommandReference) that covers every supported command. Designer-only settings (names, bindings, conditions, locks, text fit, "do not print" objects) are carried over to the re-imported elements. Numbered fields (^FN) import as {{placeholders}} with the field data as the sample value; GenerateStoredFormat writes a label as a ^DF format with one ^FN per placeholder and GenerateFormatRecall writes the matching ^XF recall. --- CHANGELOG.md | 2 + README.es.md | 9 +- README.md | 9 +- docs/decisions.md | 30 ++ docs/variables.md | 23 ++ docs/zpl-coverage.md | 6 +- src/Rotula.App/Editing/DesignCarryOver.cs | 123 +++++++ src/Rotula.App/MainWindow.xaml | 48 ++- src/Rotula.App/MainWindow.xaml.cs | 3 + src/Rotula.App/Resources/Strings.en.resx | 79 ++++- src/Rotula.App/Resources/Strings.es.resx | 79 ++++- src/Rotula.App/Services/CoreZplImporter.cs | 14 +- src/Rotula.App/Services/ServiceInterfaces.cs | 13 + src/Rotula.App/Services/WpfServices.cs | 38 +++ src/Rotula.App/ViewModels/MainViewModel.cs | 20 +- .../ViewModels/ZplSourceViewModel.cs | 268 +++++++++++++++ .../Zpl/Catalog/ZplCommandReference.cs | 271 +++++++++++++++ src/Rotula.Core/Zpl/Catalog/commands.json | 6 +- src/Rotula.Core/Zpl/Import/ZplImportTypes.cs | 13 + src/Rotula.Core/Zpl/Import/ZplImporter.cs | 59 ++++ src/Rotula.Core/Zpl/ZplGenerator.cs | 231 ++++++++++++- tests/Rotula.Tests/AppTestSupport.cs | 33 +- tests/Rotula.Tests/AppZplSourceTests.cs | 316 ++++++++++++++++++ .../Rotula.Tests/ZplCommandReferenceTests.cs | 85 +++++ tests/Rotula.Tests/ZplFieldNumberTests.cs | 188 +++++++++++ tests/Rotula.Tests/ZplNewCommandsTests.cs | 18 +- tests/fixtures/formats/format_recall.zpl | 7 + tests/fixtures/formats/format_stored.zpl | 17 + tests/fixtures/import/field_numbers.zpl | 10 + 29 files changed, 1964 insertions(+), 54 deletions(-) create mode 100644 src/Rotula.App/Editing/DesignCarryOver.cs create mode 100644 src/Rotula.App/ViewModels/ZplSourceViewModel.cs create mode 100644 src/Rotula.Core/Zpl/Catalog/ZplCommandReference.cs create mode 100644 tests/Rotula.Tests/AppZplSourceTests.cs create mode 100644 tests/Rotula.Tests/ZplCommandReferenceTests.cs create mode 100644 tests/Rotula.Tests/ZplFieldNumberTests.cs create mode 100644 tests/fixtures/formats/format_recall.zpl create mode 100644 tests/fixtures/formats/format_stored.zpl create mode 100644 tests/fixtures/import/field_numbers.zpl diff --git a/CHANGELOG.md b/CHANGELOG.md index ed4083e..0f67442 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] ### Added +- **The ZPL panel is editable.** Type or paste ZPL under the canvas and, after a short pause, the label is rebuilt from it through the same importer as File > Import ZPL; every edit is one undo step (Ctrl+Z works from inside the panel). A badge says whether the text is *Generated* from the design or *Edited by hand - re-imported*; "Revert to design" regenerates it. Commands the designer does not model are never dropped: the status line says how many were kept as raw ZPL and names the first ones (hover for every notice). Element names, data bindings, print conditions, locks, text fit and "do not print" objects survive an edit when the element is still there. A hint line explains the command under the caret: its name, its parameters and what each one means, for every command Rotula supports. +- **Numbered fields (`^FN`) are data fields.** Importing ZPL turns `^FN3"Customer name"` into the placeholder `{{Customer_name}}` (or `{{Field3}}` without a prompt) with the field's data as the sample value, so the field can be merged from a file or database; the number and prompt are written back unchanged. A label can be written as a **stored format** (`ZplGenerator.GenerateStoredFormat`: `^DF` plus one `^FN` per placeholder) and its **recall** (`GenerateFormatRecall`: `^XF` plus the values), with the same numbering both ways. - **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. diff --git a/README.es.md b/README.es.md index bcf2c95..4742231 100644 --- a/README.es.md +++ b/README.es.md @@ -150,10 +150,13 @@ dentro de la etiqueta. ### Lee exactamente lo que recibe la impresora -El panel ZPL muestra el código generado, en vivo y de solo lectura, con un botón Copiar ZPL y un botón Exportar .zpl. La -salida es determinista y en ASCII puro, así que puedes versionarla, compararla o enviarla con cualquier otra herramienta. +El panel ZPL muestra el código generado en vivo, con un botón Copiar ZPL y un botón Exportar .zpl. La salida es determinista y +en ASCII puro, así que puedes versionarla, compararla o enviarla con cualquier otra herramienta. El panel también es un editor: +escribe o pega ZPL y, tras una breve pausa, la etiqueta se reconstruye a partir de él (un paso de deshacer por edición); una +insignia indica si el texto está generado o editado a mano, "Volver al diseño" lo regenera, la línea de estado nombra cualquier +comando que se conservó como ZPL sin procesar, y una línea de ayuda explica el comando bajo el cursor y sus parámetros. -

El panel de código ZPL de solo lectura bajo el lienzo, con el ZPL generado

+

El panel de código ZPL bajo el lienzo, con el ZPL generado

### Todo de un vistazo diff --git a/README.md b/README.md index 3746db9..c444ce0 100644 --- a/README.md +++ b/README.md @@ -147,10 +147,13 @@ every record for problems and print the whole batch. The data file itself is nev ### Read exactly what the printer receives -The ZPL panel shows the generated code, live, read-only, with a Copy button and an Export .zpl button. The output is -deterministic and plain ASCII, so you can version it, diff it or send it with any other tool. +The ZPL panel shows the generated code live, with a Copy button and an Export .zpl button. The output is deterministic and +plain ASCII, so you can version it, diff it or send it with any other tool. The panel is also an editor: type or paste ZPL and, +after a short pause, the label is rebuilt from it (one undo step per edit); a badge says whether the text is generated or edited +by hand, "Revert to design" regenerates it, the status line names any command that was kept as raw ZPL, and a hint line explains +the command under the caret and its parameters. -

The read-only ZPL code panel under the canvas showing the generated ZPL

+

The ZPL code panel under the canvas showing the generated ZPL

### Everything at a glance diff --git a/docs/decisions.md b/docs/decisions.md index 1beea9e..8cbefc0 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -97,6 +97,25 @@ written as "one field" turns it into an ordinary text element with the same id a Memento command: each edit stores the whole document before/after (documents are small; image pixels are shared). Unlimited. Arrow nudges and typing in one property merge into one step within one second. Drags are gestures: one undo step per drag. +### Editable ZPL panel (`ZplSourceViewModel`) +The panel under the canvas is a plain `TextBox` bound two-way to `ZplSource.Text`; the view only reports the caret. Typing starts a 300 ms +quiet period (`IDebouncer`, a `DispatcherTimer` in the app, fired by hand in tests); when it elapses the text goes through `ZplParser` (the +same importer as File > Import ZPL) and the session is edited once (`Undo.ZplSource`), so every pause is one undo step. Source of truth is an +explicit state: *Generated* (the text mirrors the design), *Pending* (typing) or *Edited* (the design was rebuilt from the text, which stays as +typed). Two guards break the loop between the two directions: while the panel applies its own text the regenerated text is ignored, and while an +edit is pending nothing overwrites what is being typed; any other change of the design (canvas, undo, language) regenerates the text and the +state goes back to *Generated*. "Revert to design" cancels a pending edit and regenerates. The editor's own undo is switched off so Ctrl+Z +reaches the label's undo stack. Syntax colouring was left out on purpose: a highlighting editor package would need its own two-way text +binding, dark-theme styling, accessibility checks and a licence entry for a cosmetic gain. +- **ZPL cannot say everything the design knows**, so `DesignCarryOver` pairs each previous element with the re-imported one of the same kind + (same place and content first, then same content anywhere, then same place) and copies ids, names, print conditions, locks and text fit; a text + or barcode whose content equals the shown value of a `{{field}}` gets the field back; objects marked "do not print" (never written to ZPL) + are put back. The resolution, the label id, the stock and the declared variable definitions are restored too; an empty text changes nothing. +- **Nothing is dropped silently**: the status line counts the commands kept as raw ZPL (`ZplImportWarning.KeptAsRaw`) and names the first + three; the other notices are counted and listed in the tooltip, like the Import ZPL dialog. +- **Command hints** come from `ZplCommandReference` (Core, C# data: the parameters of every supported command and the usual kept-verbatim + ones, in Rotula's own words) merged with the name and summary of `commands.json`; a test fails when a supported command has no signature. + ### Preview `PreviewCache` keeps the `RenderLabel` per document revision and the bitmap per (revision, scale). Selection, handles, grid and guides are drawn by WPF on top, so pointer movement never re-renders the label. A visible notice says the text preview is @@ -491,6 +510,17 @@ which means "used when generated", the safe side; a caller that wants "used when `CounterStoreException`s with message keys, never bare `InvalidOperationException`s. A read-only look (previews, the MCP server) creates no folder and no lock when `counters.json` does not exist. +### Numbered fields are placeholders; stored formats are a generator mode, not a document property +A `^FN` field on import becomes `{{name}}` (the prompt made into a valid name, or `Field` plus the number) with the field's data as the +variable's sample value, so the printed output is unchanged and the field can be merged; the number and prompt stay on the element so the +ZPL written back is byte-identical. Fields that share a number share the variable (they share their data on the printer); a prompt already used by +another number gets a suffix. `^SN` fields are left alone (their data is a start value). `^DF` stays out of the label model: a stored format is +written on request (`GenerateStoredFormat`, `^DF` first, every pure `{{placeholder}}` as `^FN n"prompt"` without data, no `^PQ`) and its recall +(`GenerateFormatRecall`, `^XF`, `^CI`, one `^FN n^FD value` per number, `^PQ`) reuses the same numbering (element numbers first, then the lowest +free numbers in label order, one number per name). A field that mixes fixed text and placeholders, a Windows-font text and a QR code with a +data header cannot be numbered: they are written with their current value and reported (`Format.Field.Mixed`, `Format.Field.Picture`). The +catalogue keeps `^DF` and `^XF` as *Planned*: opening such a file still keeps the commands verbatim with a notice. + ### The printer-clock codes and ^SO parameter order are not confirmed on a printer The `^FC` code table and the `^SO` order (clock set, months, days, years, hours, minutes, seconds) come from the ZPL II Programming Guide as remembered by the author; no excerpt is in the repository and no printer with a clock was used. `docs/variables.md` says so. Confirm on hardware diff --git a/docs/variables.md b/docs/variables.md index 8f06a98..2ef138b 100644 --- a/docs/variables.md +++ b/docs/variables.md @@ -269,6 +269,27 @@ The `.rotula` file stores the definitions. A label that only uses plain text var still open it). A label with a definition, or with a label id, is saved as **format version 7**; an older Rotula says the file is newer instead of silently dropping the definition. Files are untrusted input: lists are capped, text is clipped, unknown kinds open as plain variables. +### Numbered fields in ZPL (`^FN`) and stored formats (`^DF` / `^XF`) + +ZPL has its own idea of a variable: a *numbered field*. `^FN3"Customer name"` marks a field whose data is filled in later, usually when a +format stored on the printer is recalled. Rotula treats numbered fields as placeholders, both ways: + +* **Import.** A field with `^FN` becomes `{{Customer_name}}` (the prompt text made into a name: letters, digits and underscores) or + `{{Field3}}` when there is no prompt. The data the field carried (`^FD`) becomes the variable's sample value, so the label prints exactly as + before and the field can be filled from a data file or a database. Fields that share a number share the variable, as they share their data + on the printer. The number and prompt stay on the element, so exporting the label writes the same `^FN3"Customer name"` again. A field with + a printer-side serial number (`^SN`) is left alone: its data is a start value, not something to fill in. +* **Stored format.** `ZplGenerator.GenerateStoredFormat(label, "R:SHIP.ZPL")` writes the label with `^DF` right after `^XA` and every text + or barcode whose content is exactly one `{{placeholder}}` as a numbered field without data (`^FN1"Customer"`). Elements that already have a + field number keep it; the others get the lowest free numbers in label order, one number per name. The print quantity is left out. +* **Recall.** `ZplGenerator.GenerateFormatRecall(label, "R:SHIP.ZPL", data)` writes `^XF` followed by one `^FN n^FD value` per number (data + values, or the sample values) and the print quantity. The numbering is the one the stored format got, so the two always match + (`ZplGenerationResult.FormatFields` lists it). +* **What cannot be numbered**: a field that mixes fixed text with placeholders (`Order {{Order}}`, the printer replaces the whole data), a text + in a Windows font (printed as a picture) and a QR code (its data starts with a header). They are written with their current value and + reported as `Format.Field.Mixed` / `Format.Field.Picture`. Opening a stored format keeps the `^DF` line as raw ZPL with a notice; the fields + come back as placeholders. + ### Using it from an AI assistant (MCP) `set_variable` takes a `kind` (`text`, `date`, `counter`, `prompt`, `computed`) and a `definition` object. See [mcp.md](mcp.md). @@ -421,6 +442,8 @@ lists them; a test checks that every key has text in both languages. | `Variable.Expr.Cycle` | variable, position (0), chain `a > b > a` | | `Variable.Expr.ResultTooLong` | variable, position, limit | | `Variable.Expr.NotNumber` / `.NotDate` / `.BadFormat` | variable, position, function, value | +| `Format.Path.Invalid` | the stored format name (error) | +| `Format.Field.Mixed` / `Format.Field.Picture` | none; the issue carries the element id (warning) | ### File format version 7 diff --git a/docs/zpl-coverage.md b/docs/zpl-coverage.md index 52fb9b6..770a0a1 100644 --- a/docs/zpl-coverage.md +++ b/docs/zpl-coverage.md @@ -99,7 +99,7 @@ Nothing is silently dropped. | `^FE` | Field number embedding character | Supported | Yes | Yes | No | Chooses the character that marks references to numbered fields inside the data of a field. The text is written unchanged; the printer expands the references. | | `^FH` | Hexadecimal escapes in field data | Supported | Yes | Yes | Close | Lets field data carry any byte as an underscore and two hex digits, so accents and control characters survive. Written automatically whenever the text needs it. | | `^FM` | Multiple field origins | Passthrough | No | No | Box | Gives the places where each part of a PDF417 or MicroPDF417 symbol that is split over several symbols is printed. A field that uses it is kept whole as raw ZPL. | -| `^FN` | Field number | Supported | Yes | Yes | No | Gives a field a number so its data can be filled in or reused later; an optional prompt can be stored with it. Kept on the field and written back. Filling numbered fields from a stored template is not done by Rotula yet. | +| `^FN` | Field number | Supported | Yes | Yes | No | Gives a field a number so its data can be filled in or reused later; an optional prompt can be stored with it. A numbered field becomes a {{placeholder}} named after the prompt (Customer name gives {{Customer_name}}) or Field plus the number, with the field's data as the sample value, so it can be filled from a data merge. The number and prompt stay on the field and are written back unchanged. Saving the label as a stored format writes every placeholder as a numbered field. | | `^FO` | Field origin | Supported | Yes | Yes | Exact | Starts a field and places its top-left corner at the given position. | | `^FP` | Field direction | Supported | Yes | Yes | No | Prints the characters of a text field top to bottom or right to left, with an optional gap between characters. Text fields only. The preview always draws left to right. | | `^FR` | Reverse a field | Supported | Yes | Yes | Exact | Prints one field as the opposite of what is already under it. | @@ -180,7 +180,7 @@ Nothing is silently dropped. | Command | Name | Status | Generate | Read | Preview | What it does | |---|---|---|:---:|:---:|:---:|---| -| `^DF` | Store format | Planned | No | No | No | Saves the commands that follow as a reusable template in the printer's memory. Templates, with numbered fields filled in at print time, are planned. Today the command is kept verbatim with a warning. | +| `^DF` | Store format | Planned | No | No | No | Saves the commands that follow as a reusable template in the printer's memory. Rotula can write a label as a stored format (ZplGenerator.GenerateStoredFormat): ^DF right after ^XA and every {{placeholder}} as a numbered field without data. Opening such a file keeps the ^DF command verbatim with a notice, and its numbered fields come back as placeholders; the stored-format name itself is not part of the label model yet. | | `~DG` | Download graphic | Supported | Yes | Yes | Exact | Sends a bitmap to the printer's memory under a name, to be printed later with ^XG or ^IM. Kept as a document setting and written before the label. A recall in the same file is drawn in the preview. | | `~DN` | Abort graphic download | Out of scope | No | No | No | Stops a graphic download that is in progress. *Out of scope: A diagnostic or immediate printer action that prints nothing on the label.* | | `~DY` | Download object | Supported | No | Yes | Exact | Sends a file (a bitmap, font or other object) to the printer's memory. Only plain bitmap data (hex or Z64 text, format A, type G or B) is understood; it is written back as ~DG. PNG, TrueType and binary downloads are kept as raw ZPL. | @@ -190,7 +190,7 @@ Nothing is silently dropped. | `^IM` | Image move | Passthrough | No | No | Box | Prints a graphic stored on the printer at its original size. Kept as a stored-object placeholder. When the same file downloads the graphic with ~DG, the preview draws it. | | `^IS` | Image save | Passthrough | No | No | No | Saves the label format as an image in the printer's memory instead of printing it. Kept verbatim with a warning. | | `^TO` | Transfer object | Out of scope | No | No | No | Copies stored objects from one storage device to another. *Out of scope: Manages the printer's own storage; it does not change what a label looks like.* | -| `^XF` | Recall format | Planned | No | No | No | Prints a stored template, filling its numbered fields. Planned together with ^DF. Today the recall is kept as a stored-object placeholder with a warning. | +| `^XF` | Recall format | Planned | No | No | No | Prints a stored template, filling its numbered fields. Rotula can write the recall of a label saved as a stored format (ZplGenerator.GenerateFormatRecall): ^XF followed by one numbered field per placeholder with its value. Opening a file with a recall keeps it as a stored-object placeholder with a notice, because the format lives on the printer; numbered fields after it come back as placeholders. | | `^XG` | Recall graphic | Passthrough | No | No | Box | Prints a graphic stored on the printer, at a chosen magnification. Kept as a stored-object placeholder. When the same file downloads the graphic with ~DG, the preview draws it. | ### Media diff --git a/src/Rotula.App/Editing/DesignCarryOver.cs b/src/Rotula.App/Editing/DesignCarryOver.cs new file mode 100644 index 0000000..11c2026 --- /dev/null +++ b/src/Rotula.App/Editing/DesignCarryOver.cs @@ -0,0 +1,123 @@ +using Rotula.Core.Barcodes; +using Rotula.Core.Elements; +using Rotula.Core.Model; +using Rotula.Core.Validation; +using Rotula.Core.Zpl; + +namespace Rotula.App.Editing; + +/// What managed to bring across from the previous design. +/// Elements of the re-imported label that were recognised as elements of the previous design (same kind and place). +/// Texts and barcodes whose {{field}} content was put back because the text held the field's value. +/// Objects marked "do not print" that were put back, since ZPL never carries them. +public sealed record CarryOverReport(int Matched, int RestoredBindings, int ReinsertedNotPrintable); + +/// +/// When the label is rebuilt from ZPL the user edited by hand, everything the printer language cannot express would be lost: element +/// names, data bindings (the text shows the record's values, not {{fields}}), print conditions, locks, text fit and the objects +/// marked "do not print". This pairs each element of the previous design with the re-imported element of the same kind at the same +/// place and copies those designer-only settings across, so editing a position or a font in the text does not silently unbind a field. +/// Elements the user removed or moved in the text simply find no partner and come through as parsed. +/// +public static class DesignCarryOver +{ + /// + /// Carries the designer-only settings of onto the elements of (in place) and + /// puts back the "do not print" objects. Nothing the ZPL text states is changed, except that a text equal to a field's shown + /// value becomes the field again. + /// + /// The design before the edit. + /// The label read from the edited text; its elements are modified. + /// The record shown on the canvas when the text was generated (its values are what the text contains), or null for the sample values. + public static CarryOverReport Apply(LabelDocument previous, LabelDocument parsed, IReadOnlyDictionary? data) + { + ArgumentNullException.ThrowIfNull(previous); + ArgumentNullException.ThrowIfNull(parsed); + int matched = 0, restored = 0, reinserted = 0; + var taken = new HashSet(ReferenceEqualityComparer.Instance); + var scratch = new List(); + + foreach (var old in previous.Elements) + { + if (old.NotPrintable) continue; // handled below: it was never in the text + string? shown = ShownContent(previous, old, data, scratch); + // Best partner first: same place and the very content the text held; then the same content anywhere (the user moved it in the + // text); then the same kind at the same place (the user changed its content). Each parsed element is claimed at most once. + var candidates = parsed.Elements.Where(p => !taken.Contains(p) && SameKind(old, p)).ToList(); + var partner = (shown is null ? null : candidates.FirstOrDefault(p => SamePlace(old, p) && Content(p) == shown)) + ?? (shown is null ? null : candidates.FirstOrDefault(p => Content(p) == shown)) + ?? candidates.FirstOrDefault(p => SamePlace(old, p)); + if (partner is null) continue; + taken.Add(partner); + matched++; + + partner.Id = old.Id; + partner.Name = old.Name; + old.CopyObjectSettingsTo(partner); + if (old is TextElement oldText && partner is TextElement newText) + { + if (shown is not null && newText.Text == shown && oldText.GetTemplate() != shown && HasField(oldText.GetTemplate())) + { + newText.Text = oldText.GetTemplate(); + restored++; + } + // Text fit is Rotula metadata the ZPL only carries as computed values. + if (newText.Block is not null && oldText.Block?.Fit is { } fit) newText.Block.Fit = fit.Clone(); + } + else if (old is BarcodeElement oldBarcode && partner is BarcodeElement newBarcode && shown is not null && newBarcode.Data == shown + && oldBarcode.Data != shown && HasField(oldBarcode.Data)) + { + newBarcode.Data = oldBarcode.Data; + newBarcode.QrContentSpec = oldBarcode.QrContentSpec; + restored++; + } + } + + // Objects that never reach the printer cannot be in the text; losing them because the text was edited would be a surprise. + for (int i = 0; i < previous.Elements.Count; i++) + { + var old = previous.Elements[i]; + if (!old.NotPrintable) continue; + parsed.Elements.Insert(Math.Min(i, parsed.Elements.Count), ElementCloner.Clone(old)); + reinserted++; + } + return new CarryOverReport(matched, restored, reinserted); + } + + private static bool HasField(string template) => VariableResolver.FindVariables(template).Count > 0; + + // Data fields and plain texts are both texts in ZPL, so the exact class is not compared for them. + private static bool SameKind(LabelElement a, LabelElement b) => a.GetType() == b.GetType() || (a is TextElement && b is TextElement); + + private static bool SamePlace(LabelElement a, LabelElement b) => a.X == b.X && a.Y == b.Y && a.OriginKind == b.OriginKind; + + /// The content of a text or barcode as it is after parsing; null for elements without data. + private static string? Content(LabelElement e) => e switch + { + TextElement t => t.Text, + BarcodeElement b => b.Data, + _ => null, + }; + + /// + /// What the generated text contained for the element: its template with the fields replaced by the shown record's values (or the + /// sample values), normalised the way the generator writes it. Null for elements without data. + /// + private static string? ShownContent(LabelDocument doc, LabelElement e, IReadOnlyDictionary? data, List scratch) + { + switch (e) + { + case TextElement t: + return VariableResolver.Resolve(t.GetTemplate(), data, doc.Variables, null, scratch); + case BarcodeElement b: + { + string resolved = Rotula.Core.Barcodes.QrContent.QrContentBuilder.ResolveForElement(b, data, doc.Variables, scratch) + ?? VariableResolver.Resolve(b.Data, data, doc.Variables, null, scratch); + // EAN-13 and UPC-A lose a typed check digit on the way out, so the parsed data is the normalised form. + return BarcodeValidator.NormalizeForPrinter(b, resolved); + } + default: + return null; + } + } +} diff --git a/src/Rotula.App/MainWindow.xaml b/src/Rotula.App/MainWindow.xaml index 02c60f5..bddfcfe 100644 --- a/src/Rotula.App/MainWindow.xaml +++ b/src/Rotula.App/MainWindow.xaml @@ -616,8 +616,8 @@ - - + @@ -628,15 +628,49 @@ +