Compare commits

...
Author SHA1 Message Date
james.maesandClaude Sonnet 5.5 9811979d6c feat: add branded tests for short Zebra label stock
Generate compact four by one and four by two PDF and ZPL labels, and carry explicit PDF paper dimensions per job without altering queue defaults.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 16:34:51 -06:00
7 changed files with 612 additions and 23 deletions

No files matched your search

+1 -1
View File
@@ -149,7 +149,7 @@ For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTER
## Deliberate first-version limits
The implemented path targets an explicit device/printer. Logical destination bindings, cancellation, reprint workflow, automatic reconciliation, clustered throughput optimization, retention and alerts are follow-ups. Documents are durably stored as base64 in the database for this first path; object-storage integration is deferred. Custom paper dimensions are rejected; configure the Windows queue. Received/submitting jobs are not automatically offered again. The queue records SPOOLED honestly and does not assert physical delivery.
The implemented path targets an explicit device/printer. Logical destination bindings, cancellation, reprint workflow, automatic reconciliation, clustered throughput optimization, retention and alerts are follow-ups. Documents are durably stored as base64 in the database for this first path; object-storage integration is deferred. Job paper (`paperWidthMm` and `paperHeightMm`, both positive, at most 2000, never with `mediaProfile`) is accepted; the built-in 4 x 1 and 4 x 2 in PDF test labels use it (101.6 x 25.4 / 50.8 mm) so Windows does not feed 4 x 6 stock. Only a client from the same unreleased 0.4 cohort applies it; older clients reject the job. Other jobs follow the Windows queue default. Received/submitting jobs are not automatically offered again. The queue records SPOOLED honestly and does not assert physical delivery.
The protocol demo and synthetic native UI preview are separate local hosts. Attaching the QBit to a deployed application and its real authentication/facility policy remains a host integration task. PostgreSQL behavior, production deployment, upgrades, cancellation/reprint and broad printer-driver coverage are not certified by the initial H2/laser tests.
@@ -102,7 +102,18 @@ public class PrintingProcesses implements BackendStep
for(TestPrintProfile profile : TestPrintProfile.values())
{
QPossibleValue<String> option = new QPossibleValue<>(profile.name(), profile.getLabel());
pdf.add(option);
if(profile.getDpi() > 0 && profile.getHeightInches() != 6)
{
// PDF is vector, so the printer resolution is irrelevant: one friendly choice per short stock.
if(profile.getDpi() == 300)
{
pdf.add(new QPossibleValue<>(profile.name(), "4 x " + profile.getHeightInches() + " in label"));
}
}
else
{
pdf.add(option);
}
if(profile.getDpi() > 0)
{
zpl.add(option);
@@ -349,8 +360,14 @@ public class PrintingProcesses implements BackendStep
{
TestPrintProfile profile = TestPrintProfile.resolve(pdf ? "PDF" : "ZPL", profileName);
byte[] bytes = pdf ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile);
JSONObject job = currentService().submit(new JSONObject().put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
.put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key));
JSONObject request = new JSONObject().put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
.put("title", "QQQ print test").put("format", pdf ? "PDF" : "ZPL").put("contentBase64", Base64.getEncoder().encodeToString(bytes)).put("idempotencyKey", key);
JSONObject options = profile.jobOptions(pdf ? "PDF" : "ZPL");
if(options != null)
{
request.put("options", options);
}
JSONObject job = currentService().submit(request);
return (job.getString("jobId"));
}
catch(java.sql.SQLException | PrintingException exception)
@@ -1184,7 +1184,11 @@ public class PrintingService
}
if(options.has("paperWidthMm") || options.has("paperHeightMm"))
{
throw new PrintingException(400, "Custom paper dimensions are not supported yet; configure the Windows queue");
// Explicit job paper is a complete, positive size and is never combined with a queue media profile.
if(!validPaperMillimetres(options.opt("paperWidthMm")) || !validPaperMillimetres(options.opt("paperHeightMm")) || options.has("mediaProfile"))
{
throw new PrintingException(400, "Paper dimensions require both a width and height in millimetres (up to 2000) and cannot be combined with a media profile");
}
}
PrintingMediaProfile.selection(options);
options.put("copies", count).put("scale", scale);
@@ -1197,6 +1201,16 @@ public class PrintingService
/***************************************************************************
** A finite number within the protocol's physical paper bounds.
***************************************************************************/
private static boolean validPaperMillimetres(Object value)
{
return (value instanceof Number number && Double.isFinite(number.doubleValue()) && number.doubleValue() > 0 && number.doubleValue() <= 2000);
}
/***************************************************************************
** Claim one job; replay the same assignment until the device advances it.
***************************************************************************/
@@ -1205,9 +1219,15 @@ public class PrintingService
String format = required(request.optString("format"), "format");
TestPrintProfile profile = TestPrintProfile.resolve(format, request.has("testProfile") ? request.optString("testProfile") : null);
byte[] bytes = "PDF".equals(format) ? PrintingTestDocuments.pdf(profile) : PrintingTestDocuments.zpl(profile);
return (submit(new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId"))
JSONObject job = new JSONObject().put("deviceId", deviceId).put("printerId", required(request.optString("printerId"), "printerId"))
.put("title", "QQQ print test").put("format", format).put("contentBase64", Base64.getEncoder().encodeToString(bytes))
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId"))));
.put("idempotencyKey", "device-test:" + deviceId + ":" + required(request.optString("requestId"), "requestId"));
JSONObject options = profile.jobOptions(format);
if(options != null)
{
job.put("options", options);
}
return (submit(job));
}
@@ -44,6 +44,7 @@ import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.apache.pdfbox.pdmodel.graphics.image.LosslessFactory;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
/*******************************************************************************
@@ -55,6 +56,10 @@ public final class PrintingTestDocuments
static final String FONT_RESOURCE = "/com/kingsrook/qbits/printing/fonts/JetBrainsMono-Bold.ttf";
private static final String LOGO_RESOURCE = "/com/kingsrook/qbits/printing/images/query-mascot.png";
static final String BARCODE_VALUE = "QQQ-TEST-123456";
private static final int LEGACY_LABEL_HEIGHT_INCHES = 6;
static final int COMPACT_BARCODE_X = 16;
static final int COMPACT_BARCODE_Y = 118;
static final int COMPACT_BARCODE_HEIGHT = 16;
@@ -80,16 +85,21 @@ public final class PrintingTestDocuments
identifiers.add(new COSString("QQQ-TEST-" + profile.name()));
document.getDocument().getTrailer().setItem(COSName.ID, identifiers);
PDType0Font font = PDType0Font.load(document, fontStream, true);
PDRectangle media = profile.getDpi() > 0 ? new PDRectangle(288, 432) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER);
PDRectangle media = profile.getDpi() > 0 ? new PDRectangle(profile.getWidthPoints(), profile.getHeightPoints()) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER);
PDPage page = new PDPage(media);
document.addPage(page);
try(PDPageContentStream content = new PDPageContentStream(document, page))
{
if(profile.getDpi() > 0)
if(profile.getDpi() > 0 && profile.getHeightInches() == LEGACY_LABEL_HEIGHT_INCHES)
{
pdfLabel(content, font);
content.drawImage(LosslessFactory.createFromImage(document, logo()), 210, 348, 54, 54);
}
else if(profile.getDpi() > 0)
{
content.setLineWidth(1);
compactLabel(new PdfCanvas(content, font, LosslessFactory.createFromImage(document, logo()), profile.getHeightPoints()), profile.getHeightInches(), "PDF / ACTUAL SIZE");
}
else
{
pdfText(content, font, 56, 48, 690, "QQQ");
@@ -144,18 +154,30 @@ public final class PrintingTestDocuments
***************************************************************************/
public static byte[] zpl(TestPrintProfile profile) throws QException
{
return (zpl(profile.getDpi()));
return (zpl(profile.getDpi(), profile.getHeightInches()));
}
/***************************************************************************
** A four-by-six-inch label at the explicitly chosen printer resolution.
** Text uses the bundled font rasterized to one bit; Code 128 stays native.
***************************************************************************/
public static byte[] zpl(int dpi) throws QException
{
BufferedImage image = labelImage(dpi);
return (zpl(dpi, LEGACY_LABEL_HEIGHT_INCHES));
}
/***************************************************************************
** A four-inch-wide label of the explicit height at the chosen resolution.
** Text uses the bundled font rasterized to one bit; Code 128 stays native.
** No media-type, darkness or speed commands are sent, so the printer keeps
** its own direct-thermal configuration and this never changes its defaults.
***************************************************************************/
static byte[] zpl(int dpi, int heightInches) throws QException
{
BufferedImage image = labelImage(dpi, heightInches);
int rowBytes = (image.getWidth() + 7) / 8;
byte[] bitmap = new byte[rowBytes * image.getHeight()];
for(int y = 0; y < image.getHeight(); y++)
@@ -171,8 +193,17 @@ public final class PrintingTestDocuments
int module = Math.max(2, dpi / 100);
StringBuilder zpl = new StringBuilder("^XA^PW" + image.getWidth() + "^LL" + image.getHeight() + "^LH0,0");
appendGraphicStrips(zpl, bitmap, rowBytes, image.getHeight());
zpl.append("^FO").append(dots(24, dpi)).append(',').append(dots(276, dpi)).append("^BY").append(module)
.append("^BCN,").append(dots(42, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS^PQ1^XZ");
if(heightInches == LEGACY_LABEL_HEIGHT_INCHES)
{
zpl.append("^FO").append(dots(24, dpi)).append(',').append(dots(276, dpi)).append("^BY").append(module)
.append("^BCN,").append(dots(42, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS");
}
else if(heightInches == 2)
{
zpl.append("^FO").append(dots(COMPACT_BARCODE_X, dpi)).append(',').append(dots(COMPACT_BARCODE_Y, dpi)).append("^BY").append(module)
.append("^BCN,").append(dots(COMPACT_BARCODE_HEIGHT, dpi)).append(",N,N,N^FD").append(BARCODE_VALUE).append("^FS");
}
zpl.append("^PQ1^XZ");
return (zpl.toString().getBytes(StandardCharsets.US_ASCII));
}
@@ -201,15 +232,29 @@ public final class PrintingTestDocuments
** Deterministic image also supports inspection without contacting a printer.
***************************************************************************/
static BufferedImage labelImage(int dpi) throws QException
{
return (labelImage(dpi, LEGACY_LABEL_HEIGHT_INCHES));
}
/***************************************************************************
** Four-inch-wide label raster of an explicit supported height.
***************************************************************************/
static BufferedImage labelImage(int dpi, int heightInches) throws QException
{
if(!Set.of(203, 300, 600).contains(dpi))
{
throw new IllegalArgumentException("Label resolution must be 203, 300 or 600 dpi");
}
if(!Set.of(1, 2, LEGACY_LABEL_HEIGHT_INCHES).contains(heightInches))
{
throw new IllegalArgumentException("Label height must be 1, 2 or 6 inches");
}
try(InputStream stream = fontStream())
{
Font font = Font.createFont(Font.TRUETYPE_FONT, stream);
BufferedImage image = new BufferedImage(dpi * 4, dpi * 6, BufferedImage.TYPE_BYTE_BINARY);
BufferedImage image = new BufferedImage(dpi * 4, dpi * heightInches, BufferedImage.TYPE_BYTE_BINARY);
Graphics2D graphics = image.createGraphics();
try
{
@@ -218,6 +263,12 @@ public final class PrintingTestDocuments
graphics.scale(dpi / 72.0, dpi / 72.0);
graphics.setColor(Color.BLACK);
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_OFF);
if(heightInches != LEGACY_LABEL_HEIGHT_INCHES)
{
compactLabel(new ImageCanvas(graphics, font, logo()), heightInches, dpi + " dpi / 1 copy");
scanText(graphics, font, heightInches);
return (image);
}
graphics.drawRect(12, 12, 264, 408);
graphics.drawImage(logo(), 210, 30, 54, 54, null);
labelText(graphics, font, 43, 24, 58, "QQQ");
@@ -378,5 +429,203 @@ public final class PrintingTestDocuments
content.addRect(12, 12, 264, 408);
content.stroke();
}
/***************************************************************************
** Top-left origin drawing surface in points, shared by the PDF and raster
** paths so the compact 4 x 1 and 4 x 2 layouts cannot drift apart.
***************************************************************************/
private interface LabelCanvas
{
/************************************************************************
** Draw a text baseline at the given point size and position.
************************************************************************/
void text(int size, int x, int y, String text) throws IOException;
/************************************************************************
** Stroke a one-point line between two points.
************************************************************************/
void line(int x1, int y1, int x2, int y2) throws IOException;
/************************************************************************
** Stroke a rectangle outline from its top-left corner.
************************************************************************/
void rect(int x, int y, int width, int height) throws IOException;
/************************************************************************
** Draw the square Query mascot from its top-left corner.
************************************************************************/
void logo(int x, int y, int size) throws IOException;
}
/***************************************************************************
** Own layouts for the short stocks; the 4 x 6 layout is never scaled down.
** Text starts at 7 pt (about 20 dots at 203 dpi); only a 4 x 2 label has
** room for the native barcode, which the ZPL path places at COMPACT_BARCODE_*.
***************************************************************************/
private static void compactLabel(LabelCanvas canvas, int heightInches, String medium) throws IOException
{
String stock = "4 x " + heightInches + " in / " + medium;
if(heightInches == 2)
{
canvas.rect(5, 5, 278, 134);
canvas.logo(240, 11, 36);
canvas.text(26, 12, 38, "QQQ");
canvas.text(9, 70, 26, "TEST PRINT - NOT POSTAGE");
canvas.text(8, 70, 38, stock);
canvas.line(12, 54, 232, 54);
canvas.text(16, 12, 72, "0O 1Il 5S 8B 2Z");
canvas.text(9, 12, 86, "BIN A01-I1 / SKU O0-5S8B");
canvas.text(9, 12, 98, "ORDER QQQ-123456 / QTY 02");
ruler(canvas, 108, 8, 92, 111);
}
else
{
canvas.rect(5, 5, 278, 62);
canvas.logo(248, 9, 28);
canvas.text(20, 12, 28, "QQQ");
canvas.text(8, 56, 17, "TEST PRINT - NOT POSTAGE");
canvas.text(7, 56, 27, stock);
canvas.line(12, 33, 240, 33);
canvas.text(12, 12, 46, "0O 1Il 5S 8B 2Z");
canvas.text(7, 158, 44, "BIN A01-I1 / SKU O0-5S8B");
ruler(canvas, 57, 7, 92, 60);
canvas.text(7, 165, 60, "ORDER QQQ-123456 / QTY 02");
}
}
/***************************************************************************
** One-inch (25.4 mm) measuring line with end ticks and its caption.
***************************************************************************/
private static void ruler(LabelCanvas canvas, int y, int size, int captionX, int captionY) throws IOException
{
canvas.line(12, y, 84, y);
canvas.line(12, y - 3, 12, y + 3);
canvas.line(84, y - 3, 84, y + 3);
canvas.text(size, captionX, captionY, "25.4 mm / 1 inch");
}
/***************************************************************************
** Caption beside the native 4 x 2 barcode; it exists only in the ZPL raster.
***************************************************************************/
private static void scanText(Graphics2D graphics, Font font, int heightInches)
{
if(heightInches == 2)
{
labelText(graphics, font, 8, 170, 130, "SCAN: " + BARCODE_VALUE);
}
}
/***************************************************************************
** Vector PDF surface; converts the top-left layout to PDF's bottom-left origin.
***************************************************************************/
private static final class PdfCanvas implements LabelCanvas
{
private final PDPageContentStream content;
private final PDType0Font font;
private final PDImageXObject mascot;
private final int height;
/************************************************************************
** Draw onto one page whose height converts top-left layout coordinates.
************************************************************************/
PdfCanvas(PDPageContentStream content, PDType0Font font, PDImageXObject mascot, int height)
{
this.content = content;
this.font = font;
this.mascot = mascot;
this.height = height;
}
@Override
public void text(int size, int x, int y, String text) throws IOException
{
pdfText(content, font, size, x, height - y, text);
}
@Override
public void line(int x1, int y1, int x2, int y2) throws IOException
{
content.moveTo(x1, height - y1);
content.lineTo(x2, height - y2);
content.stroke();
}
@Override
public void rect(int x, int y, int width, int rectHeight) throws IOException
{
content.addRect(x, height - y - rectHeight, width, rectHeight);
content.stroke();
}
@Override
public void logo(int x, int y, int size) throws IOException
{
content.drawImage(mascot, x, height - y - size, size, size);
}
}
/***************************************************************************
** One-bit raster surface drawing the same layout with the same bundled font.
***************************************************************************/
private record ImageCanvas(Graphics2D graphics, Font font, BufferedImage mascot) implements LabelCanvas
{
@Override
public void text(int size, int x, int y, String text)
{
labelText(graphics, font, size, x, y, text);
}
@Override
public void line(int x1, int y1, int x2, int y2)
{
graphics.drawLine(x1, y1, x2, y2);
}
@Override
public void rect(int x, int y, int width, int height)
{
graphics.drawRect(x, y, width, height);
}
@Override
public void logo(int x, int y, int size)
{
graphics.drawImage(mascot, x, y, size, size, null);
}
}
}
@@ -21,6 +21,9 @@
package com.kingsrook.qbits.printing;
import org.json.JSONObject;
/*******************************************************************************
** Explicit stock and resolution choices for built-in diagnostic documents.
*******************************************************************************/
@@ -30,10 +33,20 @@ public enum TestPrintProfile
A4("A4 - 210 x 297 mm", 0),
LABEL_4X6_203("4 x 6 in label - 203 dpi", 203),
LABEL_4X6_300("4 x 6 in label - 300 dpi", 300),
LABEL_4X6_600("4 x 6 in label - 600 dpi", 600);
LABEL_4X6_600("4 x 6 in label - 600 dpi", 600),
LABEL_4X2_203("4 x 2 in label - 203 dpi", 203, 2),
LABEL_4X2_300("4 x 2 in label - 300 dpi", 300, 2),
LABEL_4X2_600("4 x 2 in label - 600 dpi", 600, 2),
LABEL_4X1_203("4 x 1 in label - 203 dpi", 203, 1),
LABEL_4X1_300("4 x 1 in label - 300 dpi", 300, 1),
LABEL_4X1_600("4 x 1 in label - 600 dpi", 600, 1);
private static final int LEGACY_LABEL_HEIGHT_INCHES = 6;
private static final double MM_PER_INCH = 25.4;
private final String label;
private final int dpi;
private final int heightInches;
@@ -41,9 +54,20 @@ public enum TestPrintProfile
** Store a human label and printer density without guessing from a name.
***************************************************************************/
TestPrintProfile(String label, int dpi)
{
this(label, dpi, dpi > 0 ? LEGACY_LABEL_HEIGHT_INCHES : 0);
}
/***************************************************************************
** Labels are always four inches wide; the height is explicit stock data.
***************************************************************************/
TestPrintProfile(String label, int dpi, int heightInches)
{
this.label = label;
this.dpi = dpi;
this.heightInches = heightInches;
}
@@ -68,7 +92,7 @@ public enum TestPrintProfile
}
if("ZPL".equals(format) && profile.dpi == 0)
{
throw new PrintingException(400, "ZPL test prints require a 4 x 6 label profile with the matching printer dpi");
throw new PrintingException(400, "ZPL test prints require a label profile with the matching printer dpi");
}
return (profile);
}
@@ -92,5 +116,61 @@ public enum TestPrintProfile
{
return (dpi);
}
/***************************************************************************
** Label height in inches; zero for page profiles.
***************************************************************************/
public int getHeightInches()
{
return (heightInches);
}
/***************************************************************************
** Exact PDF media width in points (72 per inch); zero for page profiles.
***************************************************************************/
public int getWidthPoints()
{
return (dpi > 0 ? 4 * 72 : 0);
}
/***************************************************************************
** Exact PDF media height in points (72 per inch); zero for page profiles.
***************************************************************************/
public int getHeightPoints()
{
return (heightInches * 72);
}
/***************************************************************************
** Only labels shorter than the legacy 4 x 6 stock need job-specific paper;
** 4 x 6 and page profiles keep using the Windows queue default untouched.
***************************************************************************/
public boolean needsJobPaper(String format)
{
return ("PDF".equals(format) && dpi > 0 && heightInches != LEGACY_LABEL_HEIGHT_INCHES);
}
/***************************************************************************
** Actual-size paper for this job (never a media profile), or null when the
** configured queue default applies. Millimetres are exact: 101.6 x 25.4/50.8.
***************************************************************************/
public JSONObject jobOptions(String format)
{
if(!needsJobPaper(format))
{
return (null);
}
return (new JSONObject().put("paperWidthMm", 4 * MM_PER_INCH).put("paperHeightMm", heightInches * MM_PER_INCH));
}
}
@@ -268,8 +268,83 @@ class PrintingProcessesTest
assertEquals("testProfile", selected.getStepList().get(1).getName());
QFrontendStepMetaData form = (QFrontendStepMetaData) selected.getStep("testProfile");
assertEquals("printing_testProfilePdf", form.getFormField("testProfile").getPossibleValueSourceName());
assertEquals(3, fixture.instance().getPossibleValueSource("printing_testProfileZpl").getEnumValues().size());
assertEquals(5, fixture.instance().getPossibleValueSource("printing_testProfilePdf").getEnumValues().size());
// 4 x 6 choices are unchanged; each short stock adds friendly choices without raw IDs or typed sizes.
assertEquals(9, fixture.instance().getPossibleValueSource("printing_testProfileZpl").getEnumValues().size());
var pdfChoices = fixture.instance().getPossibleValueSource("printing_testProfilePdf").getEnumValues();
assertEquals(7, pdfChoices.size());
assertEquals(List.of("LETTER", "A4", "LABEL_4X6_203", "LABEL_4X6_300", "LABEL_4X6_600", "LABEL_4X2_300", "LABEL_4X1_300"),
pdfChoices.stream().map(choice -> String.valueOf(choice.getId())).toList());
assertEquals("4 x 1 in label", pdfChoices.getLast().getLabel());
assertEquals(TestPrintProfile.LABEL_4X6_203.name(), TestPrintProfile.resolve("ZPL", null).name());
}
/***************************************************************************
** Job paper is one complete positive size and never mixes with a profile.
***************************************************************************/
@Test
void jobPaperRequiresACompleteBoundedPairWithoutMediaProfile() throws Exception
{
Fixture fixture = fixture();
fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser")
.put("name", "Packing test printer").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
String content = java.util.Base64.getEncoder().encodeToString(PrintingTestDocuments.pdf(TestPrintProfile.LABEL_4X1_300));
JSONObject selection = new JSONObject().put("id", "default").put("revision", "a".repeat(64));
List<JSONObject> rejected = List.of(
new JSONObject().put("paperWidthMm", 101.6),
new JSONObject().put("paperHeightMm", 25.4),
new JSONObject().put("paperWidthMm", 0).put("paperHeightMm", 25.4),
new JSONObject().put("paperWidthMm", 101.6).put("paperHeightMm", -25.4),
new JSONObject().put("paperWidthMm", "101.6").put("paperHeightMm", 25.4),
new JSONObject().put("paperWidthMm", 101.6).put("paperHeightMm", 2000.1),
new JSONObject().put("paperWidthMm", 101.6).put("paperHeightMm", 25.4).put("mediaProfile", selection));
for(int index = 0; index < rejected.size(); index++)
{
JSONObject request = new JSONObject().put("deviceId", fixture.device()).put("printerId", "laser").put("title", "paper").put("format", "PDF")
.put("contentBase64", content).put("idempotencyKey", "job-paper-" + index).put("options", rejected.get(index));
assertEquals(400, assertThrows(PrintingException.class, () -> fixture.service().submit(request)).getStatus(), rejected.get(index).toString());
}
assertEquals(0, fixture.service().queuedJobs(fixture.device()).length());
JSONObject accepted = new JSONObject().put("deviceId", fixture.device()).put("printerId", "laser").put("title", "paper").put("format", "PDF")
.put("contentBase64", content).put("idempotencyKey", "job-paper-valid").put("options", new JSONObject().put("paperWidthMm", 101.6).put("paperHeightMm", 25.4));
JSONObject options = fixture.service().getJob(fixture.service().submit(accepted).getString("jobId")).getJSONObject("options");
assertEquals(25.4, options.getDouble("paperHeightMm"), 0.0001);
}
/***************************************************************************
** Short PDF stock sends exact actual-size job paper and no media profile;
** legacy 4 x 6 and every ZPL label keep the queue/payload-defined path.
***************************************************************************/
@Test
void shortLabelPdfJobsCarryExactJobPaperOnly() throws Exception
{
Fixture fixture = fixture();
fixture.service().inventory(fixture.device(), new JSONArray().put(new JSONObject().put("printerId", "laser")
.put("name", "Packing test printer").put("formats", new JSONArray(List.of("PDF", "ZPL")))));
QRecord printer = PrintingProcesses.resolvePrinter(fixture.printerId(), fixture.device());
String[][] stocks = {{"LABEL_4X1_300", "25.4"}, {"LABEL_4X2_300", "50.8"}};
for(String[] stock : stocks)
{
JSONObject options = fixture.service().getJob(PrintingProcesses.submitTest(printer, true, stock[0])).getJSONObject("options");
assertEquals(101.6, options.getDouble("paperWidthMm"), 0.0001);
assertEquals(Double.parseDouble(stock[1]), options.getDouble("paperHeightMm"), 0.0001);
assertFalse(options.has("mediaProfile"));
assertEquals(1, options.getInt("copies"));
assertEquals("ACTUAL_SIZE", options.getString("scale"));
}
for(boolean pdf : new boolean[] {true, false})
{
for(String profile : pdf ? new String[] {"LABEL_4X6_300"} : new String[] {"LABEL_4X6_300", "LABEL_4X2_203", "LABEL_4X1_600"})
{
JSONObject options = fixture.service().getJob(PrintingProcesses.submitTest(printer, pdf, profile)).getJSONObject("options");
assertFalse(options.has("paperWidthMm"));
assertFalse(options.has("paperHeightMm"));
assertFalse(options.has("mediaProfile"));
}
}
}
@@ -31,11 +31,14 @@ import java.util.regex.Pattern;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.json.JSONObject;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -82,8 +85,14 @@ class PrintingTestDocumentsTest
if(profile.getDpi() > 0)
{
double scale = profile.getDpi() / 72.0;
BufferedImage raster = PrintingTestDocuments.labelImage(profile.getDpi());
BufferedImage mascot = raster.getSubimage((int) Math.floor(210 * scale), (int) Math.floor(30 * scale), (int) Math.ceil(54 * scale), (int) Math.ceil(54 * scale));
BufferedImage raster = PrintingTestDocuments.labelImage(profile.getDpi(), profile.getHeightInches());
int[] box = switch(profile.getHeightInches())
{
case 1 -> new int[] {248, 9, 28};
case 2 -> new int[] {240, 11, 36};
default -> new int[] {210, 30, 54};
};
BufferedImage mascot = raster.getSubimage((int) Math.floor(box[0] * scale), (int) Math.floor(box[1] * scale), (int) Math.ceil(box[2] * scale), (int) Math.ceil(box[2] * scale));
assertEquals(expectedRatio, visibleAspectRatio(mascot), 0.03);
}
}
@@ -134,7 +143,7 @@ class PrintingTestDocumentsTest
{
assertEquals(1, document.getNumberOfPages());
PDRectangle size = document.getPage(0).getMediaBox();
PDRectangle expected = profile.getDpi() > 0 ? new PDRectangle(288, 432) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER);
PDRectangle expected = profile.getDpi() > 0 ? new PDRectangle(288, profile.getHeightInches() * 72) : (profile == TestPrintProfile.A4 ? PDRectangle.A4 : PDRectangle.LETTER);
assertEquals(expected.getWidth(), size.getWidth(), 0.01);
assertEquals(expected.getHeight(), size.getHeight(), 0.01);
String text = new PDFTextStripper().getText(document);
@@ -211,6 +220,16 @@ class PrintingTestDocumentsTest
** strip before comparing the complete bitmap with the rendered source.
***************************************************************************/
private static byte[] reconstructGraphicStrips(String zpl, int rowBytes, int height)
{
return (reconstructGraphicStrips(zpl, rowBytes, height, 2));
}
/***************************************************************************
** Short labels fit one strip, so callers state how many strips they require.
***************************************************************************/
private static byte[] reconstructGraphicStrips(String zpl, int rowBytes, int height, int minimumStrips)
{
Matcher matcher = Pattern.compile("\\^FO0,(\\d+)\\^GFA,(\\d+),(\\d+),(\\d+),([0-9A-F]+)\\^FS").matcher(zpl);
byte[] bitmap = new byte[rowBytes * height];
@@ -233,7 +252,7 @@ class PrintingTestDocumentsTest
nextRow += length / rowBytes;
}
assertEquals(height, nextRow);
assertTrue(strips > 1);
assertTrue(strips >= minimumStrips);
assertEquals(strips, zpl.split("\\^GFA,", -1).length - 1);
return (bitmap);
}
@@ -268,6 +287,135 @@ class PrintingTestDocumentsTest
/***************************************************************************
** Short stock has exact media boxes and job paper; 4 x 6 is unchanged and
** never gets job paper. Drawn pixels stay inside the physical label.
***************************************************************************/
@Test
void shortLabelPdfsUseExactMediaAndJobPaperWhileFourBySixIsPreserved() throws Exception
{
Object[][] stocks = {
{TestPrintProfile.LABEL_4X1_300, 72f, 25.4},
{TestPrintProfile.LABEL_4X2_300, 144f, 50.8},
{TestPrintProfile.LABEL_4X6_300, 432f, null}};
for(Object[] stock : stocks)
{
TestPrintProfile profile = (TestPrintProfile) stock[0];
float height = (Float) stock[1];
try(PDDocument document = PDDocument.load(PrintingTestDocuments.pdf(profile)))
{
PDRectangle media = document.getPage(0).getMediaBox();
assertEquals(0, media.getLowerLeftX(), 0.001);
assertEquals(0, media.getLowerLeftY(), 0.001);
assertEquals(288, media.getWidth(), 0.001);
assertEquals(height, media.getHeight(), 0.001);
BufferedImage page = new PDFRenderer(document).renderImageWithDPI(0, 300);
// PDFBox truncates float scale * points (e.g. 432 * 4.1666665 = 1799.9999), so the raster may be one pixel short.
// The exact physical size is asserted on the media box above.
assertEquals(1200, page.getWidth(), 1);
assertEquals(Math.round(height / 72 * 300), page.getHeight(), 1);
int margin = profile.getHeightInches() == 6 ? 11 * 300 / 72 : 4 * 300 / 72;
int dark = 0;
for(int y = 0; y < page.getHeight(); y++)
{
for(int x = 0; x < page.getWidth(); x++)
{
if((page.getRGB(x, y) & 0xff) < 128)
{
dark++;
assertTrue(x >= margin - 1 && x < page.getWidth() - margin + 1 && y >= margin - 1 && y < page.getHeight() - margin + 1, "Ink outside quiet margin at " + x + "," + y);
}
}
}
assertTrue(dark > 1000);
}
JSONObject options = profile.jobOptions("PDF");
if(stock[2] == null)
{
assertNull(options);
}
else
{
assertEquals(101.6, options.getDouble("paperWidthMm"), 0.0001);
assertEquals((Double) stock[2], options.getDouble("paperHeightMm"), 0.0001);
assertEquals(2, options.length());
}
assertNull(profile.jobOptions("ZPL"));
}
}
/***************************************************************************
** Every short-label density emits proper PW/LL, one copy, no printer-wide
** settings, an exact raster, and native barcode only where it fits.
***************************************************************************/
@Test
void shortLabelZplCarriesExactDimensionsAndBoundedBarcode() throws Exception
{
for(TestPrintProfile profile : TestPrintProfile.values())
{
if(profile.getDpi() == 0 || profile.getHeightInches() == 6)
{
continue;
}
int dpi = profile.getDpi();
int width = dpi * 4;
int height = dpi * profile.getHeightInches();
String zpl = new String(PrintingTestDocuments.zpl(profile), StandardCharsets.US_ASCII);
assertTrue(zpl.startsWith("^XA^PW" + width + "^LL" + height + "^LH0,0"), profile.name());
assertTrue(zpl.endsWith("^PQ1^XZ"));
assertEquals(1, zpl.split("\\^PQ", -1).length - 1);
assertEquals(1, zpl.split("\\^XA", -1).length - 1);
for(String forbidden : new String[] {"~D", "^A0", "^MT", "^MD", "^PR", "^JU", "^CF", "^PO"})
{
assertFalse(zpl.contains(forbidden), forbidden);
}
int rowBytes = (width + 7) / 8;
byte[] bitmap = reconstructGraphicStrips(zpl, rowBytes, height, 1);
BufferedImage expected = PrintingTestDocuments.labelImage(dpi, profile.getHeightInches());
assertEquals(width, expected.getWidth());
assertEquals(height, expected.getHeight());
int margin = dpi / 18;
int black = 0;
for(int y = 0; y < height; y++)
{
for(int x = 0; x < rowBytes * 8; x++)
{
boolean set = (bitmap[y * rowBytes + x / 8] & (0x80 >>> (x % 8))) != 0;
boolean expectedBlack = x < width && (expected.getRGB(x, y) & 0xffffff) == 0;
assertEquals(expectedBlack, set, "Reconstructed raster and row padding must match");
if(expectedBlack)
{
black++;
assertTrue(x >= margin && x < width - margin && y >= margin && y < height - margin, "Ink outside margin at " + x + "," + y);
}
}
}
assertTrue(black > dpi * dpi / 8);
Matcher barcode = Pattern.compile("\\^FO(\\d+),(\\d+)\\^BY(\\d+)\\^BCN,(\\d+),N,N,N\\^FD" + PrintingTestDocuments.BARCODE_VALUE + "\\^FS").matcher(zpl);
if(profile.getHeightInches() == 1)
{
assertFalse(zpl.contains("^BC"), "A 4 x 1 label has no room for a scannable barcode");
continue;
}
assertTrue(barcode.find());
int x = Integer.parseInt(barcode.group(1));
int y = Integer.parseInt(barcode.group(2));
int module = Integer.parseInt(barcode.group(3));
int barHeight = Integer.parseInt(barcode.group(4));
assertFalse(barcode.find(), "Exactly one native barcode");
int modules = 11 * (PrintingTestDocuments.BARCODE_VALUE.length() + 2) + 13;
assertTrue(x >= 10 * module + margin, "Quiet zone");
assertTrue(x + modules * module <= 170 * dpi / 72, "Barcode must end before its caption");
assertTrue(y > 111 * dpi / 72, "Barcode must sit below the ruler");
assertTrue(y + barHeight < height - margin);
assertTrue(barHeight >= dpi / 8);
}
}
/***************************************************************************
** Invalid combinations fail before a job can be created.
***************************************************************************/