Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9811979d6c
|
No files matched your search
@@ -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.
|
||||
***************************************************************************/
|
||||
|
||||
Reference in new issue
Block a user