diff --git a/renderer/README.md b/renderer/README.md index ec32bf7..a06cb19 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -84,7 +84,13 @@ ZPL sources are JSON scenes with schema `qqq-zpl-scene/1`, **not raw ZPL templat } ``` -Text/barcodes require exactly one literal `value` or JSON Pointer `valuePath`; only scalar values are accepted. Barcode `options` is the same typed object as PDF. Boxes provide outlines or lines. Images print at their supplied pixel dimensions; preprocess retained assets to the intended printer resolution. Existing `text` uses the printer's font 0 with explicit width/height. The additive `rasterText` element renders dynamic record text using one exact retained TrueType face, without installing fonts on the printer or host: +Text/barcodes require exactly one literal `value` or JSON Pointer `valuePath`; only scalar values are accepted. Barcode `options` is the same typed object as PDF. Boxes provide outlines or lines. Images print at their supplied pixel dimensions; preprocess retained assets to the intended printer resolution. Existing `text` uses the printer's font 0 with explicit width/height. + +A barcode with a `fit` box may carry `align` (`left`, `center`, `right`) and `valign` (`top`, `middle`, `bottom`). The fitted size depends on the data, so the renderer places the fitted raster inside the box; without them it stays at the box origin, as before. Either field without `fit`, or any other word, refuses with `INVALID_BARCODE_FIT_PROFILE`. To centre a barcode on a label, make the box symmetric about the label's centre. + +A `text` element becomes a justified block when it has any of `align` (`left`, `center`, `right`), `blockWidth` (dots from `x`; default the rest of the stock) or `maxLines` (1 to 8, default 1). Each line is its own font 0 field inside a one-line `^FB` field block that the printer justifies. The renderer breaks the lines itself, so the printer never wraps or hyphenates: a line holds at most `blockWidth / width` characters, and ends at the last space, or just before the last opening parenthesis, that fits. Lines are `height + ceil(height / 8)` dots apart. Text needing more than `maxLines` lines refuses with `ELEMENT_OUTSIDE_STOCK`. Text without these fields is the single field it always was. Justification by the printer's own font has not been qualified on hardware; print one label on the target printer before relying on it. + +The additive `rasterText` element renders dynamic record text using one exact retained TrueType face, without installing fonts on the printer or host: ```json {"type":"rasterText","x":20,"y":250,"fontAsset":"headingItalic","fontSize":32,"width":500,"height":120,"valuePath":"/record/customerName","align":"left","rotation":0,"threshold":128} diff --git a/renderer/src/main/java/com/kingsrook/qbits/renderer/ZplRenderer.java b/renderer/src/main/java/com/kingsrook/qbits/renderer/ZplRenderer.java index 3e51dc1..265b0a9 100644 --- a/renderer/src/main/java/com/kingsrook/qbits/renderer/ZplRenderer.java +++ b/renderer/src/main/java/com/kingsrook/qbits/renderer/ZplRenderer.java @@ -101,6 +101,7 @@ public final class ZplRenderer { Wire.integer(element.get("width"), 1, 999); Wire.integer(element.get("height"), 1, 999); + textBlock(element, x, input.stock.dots(input.stock.widthMicrons())); } else if("rasterText".equals(type)) { @@ -134,6 +135,8 @@ public final class ZplRenderer else if("barcode".equals(type)) { String symbology = symbology(element, input, resolveBinary); + alignment(element, "align", "left", "center", "right"); + alignment(element, "valign", "top", "middle", "bottom"); if(element.has("fit")) { fitRequest(element, input, symbology, false); @@ -257,6 +260,115 @@ public final class ZplRenderer } + /******************************************************************************* + ** Where a fitted barcode sits inside its fit box: 0 at the box origin, 1 in + ** the middle, 2 against the far edge. The fitted size depends on the data, so + ** only the renderer can place it; without a fit box there is nothing to align in. + *******************************************************************************/ + private static int alignment(JsonNode element, String field, String near, String middle, String far) + { + if(!element.has(field)) + { + return (0); + } + int index = List.of(near, middle, far).indexOf(Wire.text(element.get(field), 16)); + if(index < 0 || !element.has("fit")) + { + throw new IllegalArgumentException("INVALID_BARCODE_FIT_PROFILE"); + } + return (index); + } + + + /******************************************************************************* + ** Printer font 0 text justified inside a block of dots, on at most maximumLines lines. + *******************************************************************************/ + private record TextBlock(char justification, int width, int maximumLines) + { + } + + + /******************************************************************************* + ** Text without align, blockWidth or maxLines keeps its original single field. + ** A block starts at x and must end on the stock; its width defaults to the rest + ** of the stock. + *******************************************************************************/ + private static TextBlock textBlock(JsonNode element, int x, int pageWidth) + { + if(!element.has("align") && !element.has("blockWidth") && !element.has("maxLines")) + { + return (null); + } + int justification = element.has("align") ? List.of("left", "center", "right").indexOf(Wire.text(element.get("align"), 16)) : 0; + if(justification < 0) + { + throw new IllegalArgumentException("INVALID_ZPL_TEXT"); + } + int width = element.has("blockWidth") ? Wire.integer(element.get("blockWidth"), 1, 9999) : pageWidth - x; + if(x + (long) width > pageWidth) + { + throw new IllegalArgumentException("ELEMENT_OUTSIDE_STOCK"); + } + return (new TextBlock("LCR".charAt(justification), width, element.has("maxLines") ? Wire.integer(element.get("maxLines"), 1, 8) : 1)); + } + + + /******************************************************************************* + ** The renderer breaks block text itself, so the printer never wraps or inserts + ** a hyphen: a line holds at most perLine characters (the same font-width bound + ** the single field uses) and ends at the last space, or just before the last + ** opening parenthesis, that fits, else at the bound. More lines than allowed refuses. + *******************************************************************************/ + static List lines(String text, int perLine, int maximumLines) + { + int[] codes = text.codePoints().toArray(); + List lines = new java.util.ArrayList<>(); + int start = 0; + while(start < codes.length) + { + if(perLine < 1 || lines.size() == maximumLines) + { + throw new IllegalArgumentException("ELEMENT_OUTSIDE_STOCK"); + } + int end = Math.min(codes.length, start + perLine); + int next = end; + if(end < codes.length) + { + for(int index = end; index > start; index--) + { + if(codes[index] == ' ' || codes[index] == '(') + { + end = index; + next = codes[index] == ' ' ? index + 1 : index; + break; + } + } + } + lines.add(new String(codes, start, end - start)); + start = next; + } + if(lines.isEmpty()) + { + throw new IllegalArgumentException("ELEMENT_OUTSIDE_STOCK"); + } + return (lines); + } + + + /******************************************************************************* + ** Field data as hexadecimal UTF-8 bytes; no character reaches the printer raw. + *******************************************************************************/ + private static String hexadecimal(String text) + { + StringBuilder escaped = new StringBuilder(); + for(byte value : text.getBytes(StandardCharsets.UTF_8)) + { + escaped.append('_').append(HexFormat.of().withUpperCase().toHexDigits(value)); + } + return (escaped.toString()); + } + + /******************************************************************************* ** An explicit JSON Pointer provides data binding without executable code. *******************************************************************************/ @@ -322,13 +434,28 @@ public final class ZplRenderer { throw new IllegalArgumentException("INVALID_ZPL_TEXT"); } - placement(x, y, (long) fontWidth * text.codePointCount(0, text.length()), fontHeight, width, height); - StringBuilder escaped = new StringBuilder(); - for(byte value : text.getBytes(StandardCharsets.UTF_8)) + TextBlock block = textBlock(element, x, width); + if(block == null) { - escaped.append('_').append(HexFormat.of().withUpperCase().toHexDigits(value)); + placement(x, y, (long) fontWidth * text.codePointCount(0, text.length()), fontHeight, width, height); + emit(output, "^FO" + x + "," + y + "^A0N," + fontHeight + "," + fontWidth + "^FH_^FD" + hexadecimal(text) + "^FS\n"); + } + else + { + ////////////////////////////////////////////////////////////////////////////////////// + // One field per line, each a one-line field block that the printer justifies. // + // Inside a field block a backslash starts a command, so a literal one is doubled; // + // centred data ends with the line terminator Zebra asks for to centre exactly. // + ////////////////////////////////////////////////////////////////////////////////////// + int line = 0; + for(String part : lines(text, block.width() / fontWidth, block.maximumLines())) + { + int top = y + line++ * (fontHeight + (fontHeight + 7) / 8); + placement(x, top, block.width(), fontHeight, width, height); + emit(output, "^FO" + x + "," + top + "^A0N," + fontHeight + "," + fontWidth + "^FB" + block.width() + ",1,0," + block.justification() + ",0^FH_^FD" + + hexadecimal(part.replace("\\", "\\\\") + (block.justification() == 'C' ? "\\&" : "")) + "^FS\n"); + } } - emit(output, "^FO" + x + "," + y + "^A0N," + fontHeight + "," + fontWidth + "^FH_^FD" + escaped + "^FS\n"); } case "box" -> { @@ -349,8 +476,20 @@ public final class ZplRenderer if("barcode".equals(type)) { String symbology = symbology(element, input, true); - image = element.has("fit") ? BarcodeFit.fit(fitRequest(element, input, symbology, true)).image() - : Barcodes.renderForFormat("ZPL", symbology, value(element, input), Wire.integer(element.get("scale"), 1, 16), Wire.integer(element.get("quiet"), 0, 64), element.has("options") ? element.get("options") : Wire.JSON.createObjectNode(), BinaryBarcodes.encoding(element)); + if(element.has("fit")) + { + //////////////////////////////////////////////////////////////////////////////////// + // The fitted raster is never larger than its box, so the slack is not negative. // + //////////////////////////////////////////////////////////////////////////////////// + BarcodeFit.Request request = fitRequest(element, input, symbology, true); + image = BarcodeFit.fit(request).image(); + x += alignment(element, "align", "left", "center", "right") * (BarcodeFit.dotsInside(request.widthMicrons(), input.stock.dpi()) - image.getWidth()) / 2; + y += alignment(element, "valign", "top", "middle", "bottom") * (BarcodeFit.dotsInside(request.heightMicrons(), input.stock.dpi()) - image.getHeight()) / 2; + } + else + { + image = Barcodes.renderForFormat("ZPL", symbology, value(element, input), Wire.integer(element.get("scale"), 1, 16), Wire.integer(element.get("quiet"), 0, 64), element.has("options") ? element.get("options") : Wire.JSON.createObjectNode(), BinaryBarcodes.encoding(element)); + } } else { diff --git a/renderer/src/test/java/com/kingsrook/qbits/renderer/ZplAlignmentTest.java b/renderer/src/test/java/com/kingsrook/qbits/renderer/ZplAlignmentTest.java new file mode 100644 index 0000000..e3f2f8a --- /dev/null +++ b/renderer/src/test/java/com/kingsrook/qbits/renderer/ZplAlignmentTest.java @@ -0,0 +1,297 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2025. Kingsrook, LLC + * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States + * contact@kingsrook.com + * https://github.com/Kingsrook/ + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kingsrook.qbits.renderer; + + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** A fitted barcode's size depends on its data, so only the renderer can centre + ** it: these tests hold the placement of the bars inside the fit box, and of the + ** printed value in a justified block beneath, on four by one inch label stock. + *******************************************************************************/ +class ZplAlignmentTest +{ + private static final String TYPICAL = "(01)00875011000222(11)201016(21)FA1683504152"; + private static final String SHORT = "(01)00875011000222"; + private static final String LONG = "(01)00875011000222(11)201016(10)LOT1234567(21)FA16835041"; + private static final Pattern GRAPHIC = Pattern.compile("\\^FO(\\d+),(\\d+)\\^GFA,(\\d+),(\\d+),(\\d+),([0-9A-F]+)\\^FS"); + private static final Pattern TEXT = Pattern.compile("\\^FO(\\d+),(\\d+)\\^A0N,(\\d+),(\\d+)(?:\\^FB(\\d+),1,0,([LCR]),0)?\\^FH_\\^FD((?:_[0-9A-F]{2})*)\\^FS"); + + + /******************************************************************************* + ** Where the black dots of one rendered graphic field are on the label. + *******************************************************************************/ + private record Ink(int left, int right, int top, int bottom) + { + } + + + /******************************************************************************* + ** One printer font field: position, font size, optional block and its text. + *******************************************************************************/ + private record Field(int x, int y, int height, int width, Integer block, String justification, String text) + { + } + + + /******************************************************************************* + ** Four by one inch stock with three millimetre margins at one resolution. + *******************************************************************************/ + private static RenderInput.Stock stock(int dpi) + { + return (new RenderInput.Stock(101600, 25400, 3000, 3000, 3000, 3000, dpi)); + } + + + /******************************************************************************* + ** A GS1-128 fit box across the printable width, then the printed value. + *******************************************************************************/ + private static ObjectNode scene(int dpi, String align, String valign, boolean block) + { + ObjectNode scene = Wire.JSON.createObjectNode().put("schema", "qqq-zpl-scene/1"); + int margin = dpi == 300 ? 36 : 24; + ObjectNode barcode = scene.putArray("elements").addObject().put("type", "barcode").put("symbology", "gs1-128") + .put("valuePath", "/record/rendererValue").put("quiet", 10).put("x", margin).put("y", margin); + barcode.putObject("fit").put("widthMicrons", dpi == 300 ? 95504 : 95595).put("heightMicrons", dpi == 300 ? 15240 : 15015); + if(align != null) + { + barcode.put("align", align); + } + if(valign != null) + { + barcode.put("valign", valign); + } + ObjectNode text = ((com.fasterxml.jackson.databind.node.ArrayNode) scene.get("elements")).addObject().put("type", "text") + .put("valuePath", "/record/humanReadable").put("x", margin).put("y", dpi == 300 ? 211 : 138) + .put("height", dpi == 300 ? 40 : 27).put("width", dpi == 300 ? 20 : 13); + if(block) + { + text.put("align", "center").put("blockWidth", dpi == 300 ? 1128 : 764).put("maxLines", 2); + } + return (scene); + } + + + /******************************************************************************* + ** Render a scene for one printed value through the worker's own entry point. + *******************************************************************************/ + private static String render(ObjectNode scene, int dpi, String readable) throws Exception + { + ObjectNode input = UltracodeImageQualification.request("ZPL", Wire.JSON.writeValueAsString(scene), dpi); + input.set("stock", Wire.JSON.valueToTree(stock(dpi))); + ((ObjectNode) input.get("context")).putObject("record").put("rendererValue", readable.replace('(', '[').replace(')', ']')).put("humanReadable", readable); + return (new String(java.util.Base64.getDecoder().decode((String) RendererMain.process(input).get("base64")), StandardCharsets.US_ASCII)); + } + + + /******************************************************************************* + ** The bounding box of the black dots of the only graphic field of a label. + *******************************************************************************/ + private static Ink ink(String zpl) + { + Matcher matcher = GRAPHIC.matcher(zpl); + assertTrue(matcher.find()); + int x = Integer.parseInt(matcher.group(1)); + int y = Integer.parseInt(matcher.group(2)); + int stride = Integer.parseInt(matcher.group(5)); + byte[] raster = HexFormat.of().parseHex(matcher.group(6)); + int left = Integer.MAX_VALUE; + int right = -1; + int top = Integer.MAX_VALUE; + int bottom = -1; + for(int index = 0; index < raster.length * 8; index++) + { + if((raster[index / 8] & (128 >> index % 8)) != 0) + { + left = Math.min(left, index % (stride * 8)); + right = Math.max(right, index % (stride * 8)); + top = Math.min(top, index / (stride * 8)); + bottom = Math.max(bottom, index / (stride * 8)); + } + } + assertFalse(matcher.find()); + return (new Ink(x + left, x + right, y + top, y + bottom)); + } + + + /******************************************************************************* + ** Every printer font field of a label, with its field data decoded. + *******************************************************************************/ + private static List fields(String zpl) + { + List fields = new ArrayList<>(); + Matcher matcher = TEXT.matcher(zpl); + while(matcher.find()) + { + fields.add(new Field(Integer.parseInt(matcher.group(1)), Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3)), Integer.parseInt(matcher.group(4)), + matcher.group(5) == null ? null : Integer.valueOf(matcher.group(5)), matcher.group(6), + new String(HexFormat.of().parseHex(matcher.group(7).replace("_", "")), StandardCharsets.UTF_8))); + } + return (fields); + } + + + /******************************************************************************* + ** Centred bars leave the same blank width on both sides of the label, within + ** one dot, for short, typical and long values, at the largest module that fits. + *******************************************************************************/ + @Test + void centredBarcodeIsCentredOnTheLabelForShortTypicalAndLongValues() throws Exception + { + for(int dpi : List.of(203, 300)) + { + int labelWidth = stock(dpi).dots(101600); + for(String value : List.of(SHORT, TYPICAL, LONG)) + { + Ink ink = ink(render(scene(dpi, "center", "bottom", true), dpi, value)); + assertTrue(Math.abs(ink.left() - (labelWidth - 1 - ink.right())) <= 1, dpi + " dpi " + value + " " + ink); + } + Ink typical = ink(render(scene(dpi, "center", "bottom", true), dpi, TYPICAL)); + assertEquals(288 * (dpi == 300 ? 3 : 2), typical.right() - typical.left() + 1); + assertEquals(40 * (dpi == 300 ? 3 : 2), typical.bottom() - typical.top() + 1); + } + } + + + /******************************************************************************* + ** Without align the barcode stays at the box origin, as every published scene + ** expects; right and bottom put it against the far edges of the box. + *******************************************************************************/ + @Test + void alignmentMovesTheBarcodeInsideItsBoxAndDefaultsToTheOrigin() throws Exception + { + Ink origin = ink(render(scene(300, null, null, false), 300, SHORT)); + assertEquals(36 + 30, origin.left()); + assertEquals(36 + 30, origin.top()); + assertEquals(origin, ink(render(scene(300, "left", "top", false), 300, SHORT))); + Ink far = ink(render(scene(300, "right", "bottom", false), 300, SHORT)); + assertEquals(36 + 1128 - 30 - 1, far.right()); + assertEquals(36 + 180 - 30 - 1, far.bottom()); + ObjectNode tall = scene(203, "center", "middle", false); + ((ObjectNode) tall.get("elements").get(0).get("fit")).put("heightMicrons", 17000); + Ink middle = ink(render(tall, 203, SHORT)); + assertEquals(24 + (764 - 308) / 2 + 20, middle.left()); + assertEquals(24 + (135 - 120) / 2 + 20, middle.top()); + } + + + /******************************************************************************* + ** The printed value is one centred field block across the printable width, + ** inside the label and below the bars, carrying the whole value. + *******************************************************************************/ + @Test + void centredTextIsABlockBelowTheBarsWithTheWholeValue() throws Exception + { + for(int dpi : List.of(203, 300)) + { + String zpl = render(scene(dpi, "center", "bottom", true), dpi, TYPICAL); + Ink ink = ink(zpl); + List fields = fields(zpl); + assertEquals(1, fields.size(), zpl); + Field field = fields.getFirst(); + assertEquals(TYPICAL + "\\&", field.text()); + assertEquals("C", field.justification()); + assertEquals(stock(dpi).dots(101600), 2 * field.x() + field.block()); + assertTrue(field.y() > ink.bottom()); + assertTrue(field.y() + field.height() <= stock(dpi).dots(25400)); + } + } + + + /******************************************************************************* + ** A value too long for one line breaks before an application identifier onto + ** a second centred line; nothing is dropped and no line exceeds the block. + *******************************************************************************/ + @Test + void longValueBreaksBeforeAnIdentifierOntoASecondLine() throws Exception + { + String value = "(01)00875011000222(11)201016(13)201017(15)221016(10)LOT1234567(21)FA16835041"; + ObjectNode scene = scene(300, "center", "bottom", true); + ((ObjectNode) scene.get("elements").get(1)).put("y", 190); + List fields = fields(render(scene, 300, value)); + assertEquals(2, fields.size()); + assertEquals("(01)00875011000222(11)201016(13)201017(15)221016\\&", fields.get(0).text()); + assertEquals("(10)LOT1234567(21)FA16835041\\&", fields.get(1).text()); + assertEquals(190 + 45, fields.get(1).y()); + assertTrue(fields.get(1).y() + 40 <= 300); + assertEquals(List.of("AAAA", "BBBB", "CC"), ZplRenderer.lines("AAAA BBBBCC", 4, 3)); + assertEquals(List.of("ab\\", "cd"), ZplRenderer.lines("ab\\cd", 3, 2)); + } + + + /******************************************************************************* + ** Text without the block fields is the single field it always was, and a + ** backslash in block text is doubled so the printer prints it. + *******************************************************************************/ + @Test + void plainTextIsUnchangedAndBlockTextEscapesBackslashes() throws Exception + { + String plain = render(scene(300, null, null, false), 300, TYPICAL); + Field field = fields(plain).getFirst(); + assertEquals(null, field.block()); + assertEquals(TYPICAL, field.text()); + assertTrue(plain.contains("^FO36,211^A0N,40,20^FH_^FD_28_30_31_29")); + ObjectNode scene = scene(300, null, null, true); + ObjectNode text = (ObjectNode) scene.get("elements").get(1); + text.remove("valuePath"); + text.put("value", "a\\b").put("align", "right"); + Field right = fields(render(scene, 300, SHORT)).getFirst(); + assertEquals("R", right.justification()); + assertEquals("a\\\\b", right.text()); + } + + + /******************************************************************************* + ** Unknown alignment words, alignment without a fit box, a block that leaves the + ** stock and a value that needs more lines than allowed all refuse. + *******************************************************************************/ + @Test + void invalidAlignmentAndOverlongTextRefuse() throws Exception + { + ObjectNode sideways = scene(300, "middle", null, false); + assertEquals("INVALID_BARCODE_FIT_PROFILE", assertThrows(IllegalArgumentException.class, () -> render(sideways, 300, SHORT)).getMessage()); + ObjectNode unfitted = scene(300, "center", null, false); + ((ObjectNode) unfitted.get("elements").get(0)).put("scale", 2).remove("fit"); + assertEquals("INVALID_BARCODE_FIT_PROFILE", assertThrows(IllegalArgumentException.class, () -> render(unfitted, 300, SHORT)).getMessage()); + ObjectNode justified = scene(300, null, null, true); + ((ObjectNode) justified.get("elements").get(1)).put("align", "justify"); + assertEquals("INVALID_ZPL_TEXT", assertThrows(IllegalArgumentException.class, () -> render(justified, 300, SHORT)).getMessage()); + ObjectNode wide = scene(300, null, null, true); + ((ObjectNode) wide.get("elements").get(1)).put("blockWidth", 1165); + assertEquals("ELEMENT_OUTSIDE_STOCK", assertThrows(IllegalArgumentException.class, () -> render(wide, 300, SHORT)).getMessage()); + ObjectNode oneLine = scene(300, null, null, true); + ((ObjectNode) oneLine.get("elements").get(1)).put("maxLines", 1); + assertEquals("ELEMENT_OUTSIDE_STOCK", assertThrows(IllegalArgumentException.class, () -> render(oneLine, 300, "(01)00875011000222(11)201016(13)201017(15)221016(10)LOT1234567(21)FA16835041")).getMessage()); + } +}