Compare commits

...
1 Commits
Author SHA1 Message Date
james.maesandClaude Fable 5.2 65344f5981 feat(renderer): align a fitted ZPL barcode inside its fit box; justified font 0 text blocks
maven / build (push) Successful in 11m47s
build / maven (push) Successful in 11m47s
A fitted barcode's size depends on its data, so a scene cannot centre it by
position: until now the fitted raster always sat at the box origin and an
"align" on a barcode element was read and ignored.

- barcode with fit: optional align (left, center, right) and valign (top,
  middle, bottom) place the fitted raster inside the fit box. Absent, the
  raster stays at the origin, so published scenes print as before.
- text: optional align, blockWidth and maxLines make a justified block. Each
  line is one font 0 field in a one-line ^FB block; the renderer breaks lines
  itself (at a space, or before an opening parenthesis) so the printer never
  wraps or hyphenates. Text without these fields is unchanged.

Renderer module verify: 180 tests (6 new in ZplAlignmentTest), 0 Checkstyle
violations. Printer-side ^FB justification is not yet qualified on hardware.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 11:07:55 -06:00
3 changed files with 450 additions and 8 deletions

No files matched your search

+7 -1
View File
@@ -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}
@@ -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<String> lines(String text, int perLine, int maximumLines)
{
int[] codes = text.codePoints().toArray();
List<String> 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
{
@@ -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<Field> fields(String zpl)
{
List<Field> 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<Field> 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<Field> 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());
}
}