diff --git a/bridges/remote-renderer/adapter/src/main/java/com/kingsrook/qbits/documentgeneration/remote/RemoteRendererBridge.java b/bridges/remote-renderer/adapter/src/main/java/com/kingsrook/qbits/documentgeneration/remote/RemoteRendererBridge.java index 0246f34..28b6945 100644 --- a/bridges/remote-renderer/adapter/src/main/java/com/kingsrook/qbits/documentgeneration/remote/RemoteRendererBridge.java +++ b/bridges/remote-renderer/adapter/src/main/java/com/kingsrook/qbits/documentgeneration/remote/RemoteRendererBridge.java @@ -73,6 +73,25 @@ public final class RemoteRendererBridge implements GenerationPorts.IsolatedRende return (delegate.render(request)); } + /*************************************************************************** + ** The existing bridge renders, validates the label exactly as for printing + ** and decodes the page from it; nothing is retained. + ***************************************************************************/ + @Override + public GenerationPorts.Preview preview(GenerationPorts.PreviewRequest request) throws QException + { + return (delegate.preview(request)); + } + + /*************************************************************************** + ** The existing bridge honours a render target or refuses it by name. + ***************************************************************************/ + @Override + public boolean supportsRenderTarget() + { + return (delegate.supportsRenderTarget()); + } + /*************************************************************************** ** The host closes this adapter during its normal shutdown lifecycle. ***************************************************************************/ diff --git a/bridges/renderer/README.md b/bridges/renderer/README.md index 8d01839..a644e20 100644 --- a/bridges/renderer/README.md +++ b/bridges/renderer/README.md @@ -11,6 +11,35 @@ verification (private source-matched reactor, 2026-11-02): bridge 52 tests, rend 167 tests, zero failures/errors/skips, Checkstyle 0; see `docs/reviews/code-standards-followup-20261102/` in `qbit-documents` for exact commands. +## Renderer versions, render target and preview + +The bridge accepts renderer `qqq-renderer-0.1.0` and `qqq-renderer-0.2.0` (`RendererVersion` is the rule): + +| Renderer | Answers | Refusals it may name | Version 2 scene, render target, preview | +|---|---|---|---| +| `0.1.0` | the released fields only | the 13 released codes | refused: `RENDERER_TOO_OLD` | +| `0.2.0` | the same, with `diagnostics` (and `detail` on an error) | those and the 15 scene codes | rendered | + +Upgrade this library before the renderer image. A job is framed as renderer 0.1.0 reads it unless it needs more, +so version 1 printing is unchanged against either image. Before a job only 0.2.0 can do, the bridge asks the +worker its version with a fixed, empty validation; an old image is refused with `RENDERER_TOO_OLD` and receives +nothing of the scene or the record. + +- **Render target.** `RenderRequest.targetDpi` (203, 300 or 600) is sent with a version 2 scene, and the label + is held by the output policy to the page at that resolution. A version 1 scene or a PDF asked for another + resolution than its stock's is refused as `TARGET_REQUIRES_SCENE_2` before any worker is contacted. + `supportsRenderTarget()` is true. +- **Diagnostics.** A version 2 refusal arrives as `RenderException(code, diagnostic)`. The worker's answer is + untrusted: every diagnostic is checked again on the host, including that its element is an id the scene that + was sent declares and that its pointer is made only of array indexes, schema property names and those ids. + An answer holds at most 2048 diagnostics in one megabyte beside its label. +- **Preview.** `preview(PreviewRequest)` is the print path stopped before retention: the same job with collected + diagnostics, the same integrity checks and output policy, and a page decoded by `ZplRaster` from the accepted + label bytes (`PreviewPage` is a one-bit PNG, one pixel for each dot). Refusals come back as diagnostics with + `printable: false`, never as a label: a refused scene has no output, and its page shows only the hatched + boxes of the refused elements. A page of more than sixteen million dots is `PREVIEW_TOO_LARGE`. PDF previews + are not provided. The bridge writes and logs nothing for a preview. + Three transports share the same bridge, asset, wire and output policy: - **Production (default):** `new IsolatedRendererBridge(configuration, assetReader)` diff --git a/bridges/renderer/pom.xml b/bridges/renderer/pom.xml index c6cc17d..dca1b62 100644 --- a/bridges/renderer/pom.xml +++ b/bridges/renderer/pom.xml @@ -38,18 +38,21 @@ org.apache.maven.pluginsmaven-dependency-plugin3.8.1runtime-distributionpackagecopy-dependenciesruntime${project.build.directory}/lib org.apache.maven.pluginsmaven-checkstyle-plugin3.3.1com.puppycrawl.toolscheckstyle10.12.6${project.basedir}/checkstyle/config.xmlcheckstyle.reactor.root=${project.basedir}truetruewarningtruevalidatecheck - + it needs no change here. The classes of the bridge preview package (ZplRaster, and WorkerAnswer, SceneFacts and RendererVersion, which read the worker's answers) are listed by name. Nothing is excluded: JaCoCo skips only code annotated as generated, and this build defines no such annotation. There is no skip switch. --> org.jacocojacoco-maven-plugin0.8.15 prepare-agent reportverifyreport coverageverifycheck BUNDLE - INSTRUCTIONCOVEREDRATIO0.85 - LINECOVEREDRATIO0.82 + INSTRUCTIONCOVEREDRATIO0.90 + LINECOVEREDRATIO0.88 - CLASScom.kingsrook.qbits.documentgeneration.renderer.ZplRastercom.kingsrook.qbits.documentgeneration.renderer.ZplRaster.* + CLASScom.kingsrook.qbits.documentgeneration.renderer.ZplRastercom.kingsrook.qbits.documentgeneration.renderer.ZplRaster.* + com.kingsrook.qbits.documentgeneration.renderer.WorkerAnswercom.kingsrook.qbits.documentgeneration.renderer.WorkerAnswer.* + com.kingsrook.qbits.documentgeneration.renderer.SceneFactscom.kingsrook.qbits.documentgeneration.renderer.SceneFacts.* + com.kingsrook.qbits.documentgeneration.renderer.RendererVersioncom.kingsrook.qbits.documentgeneration.renderer.RendererVersion.* LINECOVEREDRATIO1.00 diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java index dd8850b..067cff7 100644 --- a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/IsolatedRendererBridge.java @@ -34,27 +34,44 @@ import java.util.Set; import java.util.UUID; import com.fasterxml.jackson.databind.JsonNode; import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; import com.kingsrook.qqq.backend.core.exceptions.QException; /******************************************************************************* ** Optional host adapter. All template interpretation remains inside the isolated ** worker; this class authorizes bytes and validates the transport/output contract. + ** + ** It accepts renderer 0.1.0 and 0.2.0 (`RendererVersion` is the rule). A job + ** that only 0.2.0 can do (a version 2 scene, a render target, a preview) is + ** refused as `RENDERER_TOO_OLD` when the worker is older: the host asks the + ** worker its version with a fixed, empty job before it sends such a scene, and + ** an answer from an older worker to such a job is refused whatever it says. *******************************************************************************/ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRenderer { - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - // the refusals renderer 0.1.0 can name. FailureCode also holds the version 2 scene refusals; a 0.1.0 worker // - // cannot produce those, so one that names them is rejected like any other unknown code // - /////////////////////////////////////////////////////////////////////////////////////////////////////////////// - private static final Set RENDERER_0_1_REFUSALS = java.util.EnumSet.range( - GenerationPorts.FailureCode.BARCODE_FIT_TOO_SMALL, GenerationPorts.FailureCode.UNSUPPORTED_BARCODE_OPTION); + private static final int ENVELOPE_BYTES = 4096; + private static final List RESOLUTIONS = List.of(203, 300, 600); + + //////////////////////////////////////////////////////////////////////////////////////////////// + // the version question: validate an empty version 1 scene on a fixed stock. Every released // + // renderer answers it, it holds nothing of any template or record, and the answer names the // + // renderer's version // + //////////////////////////////////////////////////////////////////////////////////////////////// + static final String PROBE_SCENE = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[]}"; + static final GenerationPorts.Stock PROBE_STOCK = new GenerationPorts.Stock(101600, 152400, 0, 0, 0, 0, 203); private final RendererLimits config; private final AssetReader assets; private final Exchange exchange; private final RendererAdmission admission; + //////////////////////////////////////////////////////////////////////////////////////////////// + // whether the last verified answer came from a renderer that draws version 2 scenes. It only // + // saves the version question; every answer is still held to the version rule // + //////////////////////////////////////////////////////////////////////////////////////////////// + private volatile boolean structured; + /******************************************************************************* ** Must check current actor, tenant and immutable retained-version access, then ** read at most maximumBytes. Never return a URL or storage credential. @@ -137,6 +154,18 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen } } + /*************************************************************************** + ** This adapter renders a version 2 scene at the request's render target and + ** refuses a target it cannot honour: `TARGET_REQUIRES_SCENE_2` for a version + ** 1 scene or a PDF asked for another resolution than its stock's, and + ** `RENDERER_TOO_OLD` when the worker cannot draw version 2. + ***************************************************************************/ + @Override + public boolean supportsRenderTarget() + { + return (true); + } + /******************************************************************************* ** Validate each supplied format with the same authorized offline asset snapshot. *******************************************************************************/ @@ -154,7 +183,7 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen if(source != null && !source.isBlank()) { found = true; - invoke(UUID.randomUUID().toString(), "VALIDATE", format, revision, Map.of(), resolved, config.maximumBytes()); + invoke(UUID.randomUUID().toString(), "VALIDATE", format, revision, Map.of(), resolved, config.maximumBytes(), null, false); } } if(!found) @@ -182,32 +211,9 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen { throw new IllegalArgumentException("OUTPUT_LIMIT"); } - JsonNode response = invoke(request.requestId(), "RENDER", request.format(), request.revision(), request.context(), resolve(request.actorId(), request.revision().assets()), maximum); - String encoded = RendererJson.text(response.get("base64"), 4 * ((maximum + 2) / 3)); - byte[] bytes = Base64.getDecoder().decode(encoded); - if(bytes.length < 1 || bytes.length > maximum || RendererJson.integer(response.get("length"), 1, maximum) != bytes.length - || !RendererJson.hash(bytes).equals(RendererJson.text(response.get("sha256"), 64))) - { - throw new IllegalArgumentException("OUTPUT_INTEGRITY"); - } - int pages = RendererJson.integer(response.get("pageCount"), 1, config.maximumPages()); - if(request.format() == GenerationPorts.Format.ZPL) - { - if(pages != 1 || !"application/vnd.zebra-zpl".equals(response.path("mediaType").asText())) - { - throw new IllegalArgumentException("ZPL_METADATA"); - } - ZplOutputPolicy.validate(bytes, request.revision().stock()); - } - else - { - if(!"application/pdf".equals(response.path("mediaType").asText())) - { - throw new IllegalArgumentException("PDF_METADATA"); - } - pdfEnvelope(bytes); - } - return (new GenerationPorts.Content(bytes)); + WorkerAnswer answer = invoke(request.requestId(), "RENDER", request.format(), request.revision(), request.context(), resolve(request.actorId(), request.revision().assets()), maximum, + request.targetDpi(), false); + return (new GenerationPorts.Content(output(answer, request.format(), maximum, at(request.revision().stock(), request.targetDpi())))); } catch(Exception failure) { @@ -215,11 +221,165 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen } } + /*************************************************************************** + ** The print path stopped before retention: the worker renders the same + ** revision with the same context, the label passes the same integrity checks + ** and the same output policy, and the page is decoded from those accepted + ** bytes. The result holds a picture and numbers, never the label, and + ** nothing is written, retained or logged. A refusal is not an exception + ** here: the layout is reported as not printable with its diagnostics. + ** + ** A scene with a refused element has no label at all (the worker sends + ** none), so there is nothing a host could print by mistake. Its page shows + ** only where the refused elements are, hatched, from the boxes in their + ** diagnostics; when no diagnostic has a box there is no page. + ***************************************************************************/ + @Override + @SuppressWarnings("try") // The admission slot is held for its scope only; its value is never read. + public GenerationPorts.Preview preview(GenerationPorts.PreviewRequest request) throws QException + { + if(request.format() != GenerationPorts.Format.ZPL) + { + throw new QException("Preview is available for labels only."); + } + try(var slot = admission.enter()) + { + int maximum = request.maximumBytes(); + if(maximum > config.maximumBytes()) + { + throw new IllegalArgumentException("OUTPUT_LIMIT"); + } + GenerationPorts.Stock printed = at(request.revision().stock(), request.targetDpi()); + stock(printed); + int width = dots(printed.widthMicrons(), printed.dpi(), 12700); + int height = dots(printed.heightMicrons(), printed.dpi(), 12700); + if(!RESOLUTIONS.contains(printed.dpi()) || width > 9999 || height > 32000) + { + throw new IllegalArgumentException("INVALID_ZPL_STOCK"); + } + try + { + if((long) width * height > ZplRaster.MAXIMUM_DOTS) + { + throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.PREVIEW_TOO_LARGE, new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, + Map.of("widthDots", width, "heightDots", height), Map.of("dots", (int) ZplRaster.MAXIMUM_DOTS))); + } + WorkerAnswer answer = invoke(UUID.randomUUID().toString(), "RENDER", request.format(), request.revision(), request.context(), resolve(request.actorId(), request.revision().assets()), + maximum, request.targetDpi(), true); + if(answer.refused()) + { + return (refused(printed, answer.diagnostics())); + } + byte[] bytes = output(answer, request.format(), maximum, printed); + ZplRaster page = ZplRaster.page(bytes, printed); + List diagnostics = new ArrayList<>(answer.diagnostics()); + if(!page.exact()) + { + diagnostics.add(new Diagnostic("PRINTER_FONT_NOT_SHOWN", Diagnostic.Severity.WARNING, null, null, null, null)); + } + return (new GenerationPorts.Preview(List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height())), page.exact(), true, diagnostics, (long) bytes.length, + RendererJson.hash(bytes))); + } + catch(GenerationPorts.RenderException refusal) + { + return (refused(printed, List.of(refusal.diagnostic() != null ? refusal.diagnostic() : new Diagnostic(refusal.code().name(), Diagnostic.Severity.ERROR, null, null, null, null)))); + } + } + catch(Exception failure) + { + throw (failure(failure)); + } + } + + /*************************************************************************** + ** A preview of a layout that cannot print. There is no output, so its size + ** is zero and its hash is the hash of no bytes. The page is drawn on the + ** host from the diagnostics' boxes, which are in dots from the top left of + ** the printable area: the stock less its margins, placed as the renderer + ** places it (the left and top margins rounded up to whole dots and moved + ** back only as far as keeps the area on the page). + ***************************************************************************/ + private static GenerationPorts.Preview refused(GenerationPorts.Stock printed, List diagnostics) throws Exception + { + List> boxes = diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Diagnostic.Severity.ERROR && diagnostic.box() != null).map(Diagnostic::box).toList(); + List pages = List.of(); + if(!boxes.isEmpty()) + { + int dpi = printed.dpi(); + int width = dots(printed.widthMicrons(), dpi, 12700); + int height = dots(printed.heightMicrons(), dpi, 12700); + int left = Math.min(dots(printed.leftMicrons(), dpi, 25399), width - dots(printed.widthMicrons() - printed.leftMicrons() - printed.rightMicrons(), dpi, 0)); + int top = Math.min(dots(printed.topMicrons(), dpi, 25399), height - dots(printed.heightMicrons() - printed.topMicrons() - printed.bottomMicrons(), dpi, 0)); + ZplRaster page = ZplRaster.refused(width, height, left, top, boxes); + pages = List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height())); + } + return (new GenerationPorts.Preview(pages, false, false, diagnostics, 0L, RendererJson.hash(new byte[0]))); + } + + /*************************************************************************** + ** The document of an accepted answer, checked exactly as for printing: its + ** decoded size and hash, its metadata, and for a label the output policy for + ** the stock at the resolution it was rendered for. + ***************************************************************************/ + private byte[] output(WorkerAnswer answer, GenerationPorts.Format format, int maximum, GenerationPorts.Stock printed) throws Exception + { + JsonNode response = answer.fields(); + String encoded = RendererJson.text(response.get("base64"), 4 * ((maximum + 2) / 3)); + byte[] bytes = Base64.getDecoder().decode(encoded); + if(bytes.length < 1 || bytes.length > maximum || RendererJson.integer(response.get("length"), 1, maximum) != bytes.length + || !RendererJson.hash(bytes).equals(RendererJson.text(response.get("sha256"), 64))) + { + throw new IllegalArgumentException("OUTPUT_INTEGRITY"); + } + int pages = RendererJson.integer(response.get("pageCount"), 1, config.maximumPages()); + if(format == GenerationPorts.Format.ZPL) + { + if(pages != 1 || !"application/vnd.zebra-zpl".equals(response.path("mediaType").asText())) + { + throw new IllegalArgumentException("ZPL_METADATA"); + } + ZplOutputPolicy.validate(bytes, printed); + } + else + { + if(!"application/pdf".equals(response.path("mediaType").asText())) + { + throw new IllegalArgumentException("PDF_METADATA"); + } + pdfEnvelope(bytes); + } + return (bytes); + } + + /*************************************************************************** + ** The stock at the resolution a label is rendered for: its own, or the + ** render target. + ***************************************************************************/ + private static GenerationPorts.Stock at(GenerationPorts.Stock stock, Integer targetDpi) + { + return (targetDpi == null ? stock : new GenerationPorts.Stock(stock.widthMicrons(), stock.heightMicrons(), stock.topMicrons(), stock.rightMicrons(), stock.bottomMicrons(), + stock.leftMicrons(), targetDpi)); + } + + /*************************************************************************** + ** Micrometres to dots. The addend chooses the rounding: half an inch of + ** micrometres for the nearest dot (a page side, as the output policy + ** computes it), one less than an inch to round up and nothing to round down. + ***************************************************************************/ + private static int dots(int micrometres, int dpi, int addend) + { + return (Math.toIntExact(((long) micrometres * dpi + addend) / 25400)); + } + /******************************************************************************* ** Encode pure data only; host objects are rejected before Jackson can reflect. + ** The job is framed as renderer 0.1.0 reads it unless it needs more: a render + ** target is sent only for a version 2 scene, and collected diagnostics only + ** for a preview. A version 1 scene or a PDF asked for another resolution than + ** its stock's is refused here, because a renderer would ignore the target. *******************************************************************************/ - private JsonNode invoke(String id, String operation, GenerationPorts.Format format, GenerationPorts.Revision revision, - Map context, List> resolved, int maximum) throws Exception + private WorkerAnswer invoke(String id, String operation, GenerationPorts.Format format, GenerationPorts.Revision revision, + Map context, List> resolved, int maximum, Integer targetDpi, boolean collect) throws Exception { if(id == null || !id.matches("[A-Za-z0-9._:-]{1,128}") || format == null) { @@ -230,65 +390,78 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen { throw new IllegalArgumentException("SOURCE_LIMIT"); } + SceneFacts facts = SceneFacts.of(format, source); + if(targetDpi != null && !facts.version2() && !targetDpi.equals(revision.stock().dpi())) + { + throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.TARGET_REQUIRES_SCENE_2, new Diagnostic("TARGET_REQUIRES_SCENE_2", Diagnostic.Severity.ERROR, null, null, + Map.of("dpi", targetDpi), Map.of("dpi", revision.stock().dpi()))); + } + boolean current = facts.version2() || collect; + Map request = request(id, operation, format, source, revision.stock(), new Snapshot().copy(context, 0), resolved, maximum); + if(targetDpi != null && facts.version2()) + { + request.put("targetDpi", targetDpi); + } + if(collect) + { + request.put("diagnostics", "COLLECT"); + } + byte[] input = (RendererJson.JSON.writeValueAsString(request) + "\n").getBytes(StandardCharsets.UTF_8); + if(input.length > 16 * 1024 * 1024) + { + throw new IllegalArgumentException("FRAME_LIMIT"); + } + if(current && !structured) + { + probe(); + } + //////////////////////////////////////////////////////////////////////////////////////////////////// + // an answer of renderer 0.2.0 to such a job also carries diagnostics. They get their own bounded // + // room, and the whole frame never passes what the deployment's transport was sized for: the // + // frame of the largest document this adapter may ask for // + //////////////////////////////////////////////////////////////////////////////////////////////////// + long room = (operation.equals("VALIDATE") ? 0 : 4L * ((maximum + 2) / 3)) + ENVELOPE_BYTES + (current ? WorkerAnswer.DIAGNOSTICS_BYTES : 0); + int maximumFrame = (int) Math.min(room, 4L * ((config.maximumBytes() + 2) / 3) + ENVELOPE_BYTES); + WorkerAnswer answer = WorkerAnswer.read(exchange.invoke(id, input, maximumFrame), maximumFrame, id, facts); + structured = answer.version().structured(); + return (answer.settle(operation, collect, current)); + } + + /*************************************************************************** + ** Ask the worker its version before a job only renderer 0.2.0 can do, so + ** that nothing of a version 2 scene or its record reaches a renderer that + ** cannot draw it. The question is a constant. + ***************************************************************************/ + private void probe() throws Exception + { + String id = UUID.randomUUID().toString(); + byte[] input = (RendererJson.JSON.writeValueAsString(request(id, "VALIDATE", GenerationPorts.Format.ZPL, PROBE_SCENE, PROBE_STOCK, Map.of(), List.of(), ENVELOPE_BYTES)) + "\n") + .getBytes(StandardCharsets.UTF_8); + WorkerAnswer answer = WorkerAnswer.read(exchange.invoke(id, input, ENVELOPE_BYTES), ENVELOPE_BYTES, id, SceneFacts.of(GenerationPorts.Format.ZPL, PROBE_SCENE)); + structured = answer.settle("VALIDATE", false, false).version().structured(); + if(!structured) + { + throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.RENDERER_TOO_OLD); + } + } + + /*************************************************************************** + ** The fields every job has, in the order every released renderer was sent. + ***************************************************************************/ + private static Map request(String id, String operation, GenerationPorts.Format format, String source, GenerationPorts.Stock stock, Object context, + List> resolved, int maximum) + { Map request = new LinkedHashMap<>(); request.put("protocol", RendererJson.PROTOCOL); request.put("requestId", id); request.put("operation", operation); request.put("format", format.name()); request.put("source", source); - request.put("stock", stock(revision.stock())); - request.put("context", new Snapshot().copy(context, 0)); + request.put("stock", stock(stock)); + request.put("context", context); request.put("assets", resolved); request.put("maximumBytes", maximum); - byte[] input = (RendererJson.JSON.writeValueAsString(request) + "\n").getBytes(StandardCharsets.UTF_8); - if(input.length > 16 * 1024 * 1024) - { - throw new IllegalArgumentException("FRAME_LIMIT"); - } - int maximumFrame = operation.equals("VALIDATE") ? 4096 : 4 * ((maximum + 2) / 3) + 4096; - byte[] output = exchange.invoke(id, input, maximumFrame); - if(output.length == 0 || output[output.length - 1] != '\n') - { - throw new IllegalArgumentException("FRAME_TRUNCATED"); - } - for(int i = 0; i < output.length - 1; i++) - { - if(output[i] == '\n' || output[i] == '\r') - { - throw new IllegalArgumentException("EXTRA_FRAME"); - } - } - JsonNode result = RendererJson.response(output, maximumFrame); - if("ERROR".equals(result.path("outcome").asText())) - { - if(result.size() != 6 || !RendererJson.PROTOCOL.equals(result.path("protocol").asText()) || !id.equals(result.path("requestId").asText()) - || !"qqq-renderer-0.1.0".equals(result.path("rendererVersion").asText()) || !"qqq-barcodes-9".equals(result.path("catalogVersion").asText())) - { - throw new IllegalArgumentException("WORKER_ERROR_REJECTED"); - } - GenerationPorts.FailureCode code; - try - { - code = GenerationPorts.FailureCode.valueOf(RendererJson.text(result.get("code"), 64)); - } - catch(IllegalArgumentException unknown) - { - throw new IllegalArgumentException("WORKER_ERROR_REJECTED"); - } - if(!RENDERER_0_1_REFUSALS.contains(code)) - { - throw new IllegalArgumentException("WORKER_ERROR_REJECTED"); - } - throw new GenerationPorts.RenderException(code); - } - int expectedFields = operation.equals("VALIDATE") ? 5 : 10; - if(result.size() != expectedFields || !RendererJson.PROTOCOL.equals(result.path("protocol").asText()) || !id.equals(result.path("requestId").asText()) - || !"OK".equals(result.path("outcome").asText()) || !"qqq-renderer-0.1.0".equals(result.path("rendererVersion").asText()) - || !"qqq-barcodes-9".equals(result.path("catalogVersion").asText())) - { - throw new IllegalArgumentException("WORKER_RESPONSE_REJECTED"); - } - return (result); + return (request); } /******************************************************************************* diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererJson.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererJson.java index ae3586c..c1ef079 100644 --- a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererJson.java +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererJson.java @@ -21,22 +21,17 @@ package com.kingsrook.qbits.documentgeneration.renderer; -import java.io.ByteArrayInputStream; -import java.io.InputStreamReader; import java.nio.ByteBuffer; import java.nio.charset.CodingErrorAction; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HexFormat; -import java.util.Set; import com.fasterxml.jackson.core.JsonFactory; -import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.core.StreamReadConstraints; import com.fasterxml.jackson.core.StreamReadFeature; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; /******************************************************************************* @@ -77,65 +72,6 @@ final class RendererJson } - /******************************************************************************* - ** Worker success/error responses have known scalar fields, not arbitrary trees. - ** Reject unknown/nested values before allocating an attacker-shaped graph. - *******************************************************************************/ - static JsonNode response(byte[] bytes, int maximum) throws Exception - { - if(bytes.length > maximum) - { - throw new IllegalArgumentException("RESPONSE_BYTE_LIMIT"); - } - Set textFields = Set.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "mediaType", "base64", "sha256", "code"); - ObjectNode result = JSON.createObjectNode(); - var decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT); - try(var reader = new InputStreamReader(new ByteArrayInputStream(bytes), decoder); var parser = JSON.getFactory().createParser(reader)) - { - if(parser.nextToken() != JsonToken.START_OBJECT) - { - throw new IllegalArgumentException("RESPONSE_OBJECT_REQUIRED"); - } - while(parser.nextToken() != JsonToken.END_OBJECT) - { - if(parser.currentToken() != JsonToken.FIELD_NAME) - { - throw new IllegalArgumentException("RESPONSE_FIELD_REQUIRED"); - } - String field = parser.currentName(); - boolean text = textFields.contains(field); - if(!text && !field.equals("length") && !field.equals("pageCount")) - { - throw new IllegalArgumentException("UNKNOWN_RESPONSE_FIELD"); - } - JsonToken token = parser.nextToken(); - if(text && token == JsonToken.VALUE_STRING) - { - String value = parser.getText(); - if(value.length() > (field.equals("base64") ? maximum : 128)) - { - throw new IllegalArgumentException("RESPONSE_TEXT_LIMIT"); - } - result.put(field, value); - } - else if(!text && token == JsonToken.VALUE_NUMBER_INT) - { - result.put(field, parser.getIntValue()); - } - else - { - throw new IllegalArgumentException("RESPONSE_SCALAR_REQUIRED"); - } - } - if(parser.nextToken() != null) - { - throw new IllegalArgumentException("RESPONSE_TRAILING_VALUE"); - } - } - return (result); - } - - /******************************************************************************* ** Identity is calculated locally from exact bytes, never trusted from worker. *******************************************************************************/ diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererVersion.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererVersion.java new file mode 100644 index 0000000..5163828 --- /dev/null +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererVersion.java @@ -0,0 +1,113 @@ +/* + * 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.documentgeneration.renderer; + + +import java.util.Set; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode; + + +/******************************************************************************* + ** The version rule: the renderers this bridge accepts, and for each one what + ** its answers hold and which refusals it may name. Anything else a worker says + ** its version is, is rejected. + ** + ** | Renderer | Answers | Refusals it may name | Version 2 scene, render target, preview | + ** |----------|--------------------------------------------|-------------------------------|-----------------------------------------| + ** | 0.1.0 | the released fields only | the 13 released codes | refused: `RENDERER_TOO_OLD` | + ** | 0.2.0 | the same, with `diagnostics` (and `detail`) | those and the 15 scene codes | rendered | + ** + ** Each set is written out. A range of the enum would take in whatever is added + ** between its ends, and a worker would gain a refusal nobody decided it has. + *******************************************************************************/ +enum RendererVersion +{ + RELEASED("qqq-renderer-0.1.0", false, Set.of( + FailureCode.BARCODE_FIT_TOO_SMALL, FailureCode.BARCODE_FIT_UNSUPPORTED, FailureCode.BARCODE_FIT_BOX_OUTSIDE_STOCK, FailureCode.BARCODE_FIT_GEOMETRY_MISMATCH, + FailureCode.INVALID_BARCODE_FIT_PROFILE, FailureCode.INVALID_BARCODE_FIT_REQUEST, FailureCode.BARCODE_SYMBOLOGY_NOT_ALLOWED, FailureCode.SYMBOLOGY_ALLOWLIST_REQUIRED, + FailureCode.SCANNER_BARCODE_PROFILE_UNSUPPORTED, FailureCode.SCANNER_BARCODE_PAYLOAD_UNSUPPORTED, FailureCode.SCANNER_BARCODE_CHECKSUM_REQUIRED, + FailureCode.SCANNER_BARCODE_CHECKSUM_MISMATCH, FailureCode.UNSUPPORTED_BARCODE_OPTION)), + + CURRENT("qqq-renderer-0.2.0", true, Set.of( + FailureCode.BARCODE_FIT_TOO_SMALL, FailureCode.BARCODE_FIT_UNSUPPORTED, FailureCode.BARCODE_FIT_BOX_OUTSIDE_STOCK, FailureCode.BARCODE_FIT_GEOMETRY_MISMATCH, + FailureCode.INVALID_BARCODE_FIT_PROFILE, FailureCode.INVALID_BARCODE_FIT_REQUEST, FailureCode.BARCODE_SYMBOLOGY_NOT_ALLOWED, FailureCode.SYMBOLOGY_ALLOWLIST_REQUIRED, + FailureCode.SCANNER_BARCODE_PROFILE_UNSUPPORTED, FailureCode.SCANNER_BARCODE_PAYLOAD_UNSUPPORTED, FailureCode.SCANNER_BARCODE_CHECKSUM_REQUIRED, + FailureCode.SCANNER_BARCODE_CHECKSUM_MISMATCH, FailureCode.UNSUPPORTED_BARCODE_OPTION, + FailureCode.SCENE_INVALID, FailureCode.STOCK_MISMATCH, FailureCode.TARGET_REQUIRES_SCENE_2, FailureCode.ELEMENT_OUTSIDE_PRINTABLE_AREA, FailureCode.TEXT_DOES_NOT_FIT, + FailureCode.VALUE_MISSING, FailureCode.VALUE_TOO_LONG, FailureCode.FONT_UNKNOWN, FailureCode.FONT_MISSING_GLYPH, FailureCode.RESERVED_FONT_NAME, + FailureCode.BARCODE_DOES_NOT_FIT, FailureCode.GS1_INVALID, FailureCode.ASSET_UNKNOWN, FailureCode.PREVIEW_TOO_LARGE, FailureCode.RENDERER_TOO_OLD)); + + private final String wire; + private final boolean structured; + private final Set refusals; + + + + /*************************************************************************** + ** One accepted renderer. + ***************************************************************************/ + RendererVersion(String wire, boolean structured, Set refusals) + { + this.wire = wire; + this.structured = structured; + this.refusals = refusals; + } + + + + /*************************************************************************** + ** The accepted renderer an answer names, or null: a version this bridge was + ** not built for is never guessed at. + ***************************************************************************/ + static RendererVersion of(String rendererVersion) + { + for(RendererVersion version : values()) + { + if(version.wire.equals(rendererVersion)) + { + return (version); + } + } + return (null); + } + + + + /*************************************************************************** + ** Whether this renderer's answers carry `diagnostics` and `detail`, and so + ** whether it renders a version 2 scene, a render target and a preview. + ***************************************************************************/ + boolean structured() + { + return (structured); + } + + + + /*************************************************************************** + ** Whether this renderer may name a refusal. A code outside its set is + ** rejected like any unknown text. + ***************************************************************************/ + boolean names(FailureCode code) + { + return (refusals.contains(code)); + } +} diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFacts.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFacts.java new file mode 100644 index 0000000..43262cd --- /dev/null +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFacts.java @@ -0,0 +1,228 @@ +/* + * 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.documentgeneration.renderer; + + +import java.io.IOException; +import java.util.HashSet; +import java.util.Set; +import java.util.regex.Pattern; +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.core.StreamReadConstraints; +import com.fasterxml.jackson.core.StreamReadFeature; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; + + +/******************************************************************************* + ** What the host reads from a label source it is about to send, and nothing + ** more: whether it says it is a version 2 scene, and the element ids it + ** declares. The host does not lay out, validate or draw a scene; the worker + ** does. These two facts are what the version rule and the check of a worker's + ** diagnostics need: a diagnostic may name only an element this scene declares + ** and point only through array indexes, property names of the version 2 schema + ** and declared ids. + ** + ** A source the host cannot read is not a version 2 scene to the host and + ** declares nothing. If the worker reads it differently, every disagreement + ** fails closed: its diagnostics name ids the host does not know and are + ** rejected, and no render target was sent for it. + *******************************************************************************/ +final class SceneFacts +{ + static final String SCHEMA = "qqq-zpl-scene/2"; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // every property name `renderer/schema/zpl-scene-2.schema.json` declares; a test holds the // + // two equal, so a property added to the schema is added here in the same change // + /////////////////////////////////////////////////////////////////////////////////////////////// + static final Set VOCABULARY = Set.of("schema", "design", "defaults", "samples", "elements", "overrides", "when", "dpi", "set", "children", + "id", "type", "anchor", "x", "y", "width", "height", "align", "valign", + "thickness", "fill", + "value", "valuePath", "font", "size", "lineHeight", "fit", "maxLength", "threshold", "reverse", "rotation", + "symbology", "symbologyPath", "allowedSymbologies", "minModule", "maxModule", "quiet", "gs1", "hri", "scannerSymbologyPath", "aimIdentifierPath", + "asset", "mode"); + + private static final SceneFacts NONE = new SceneFacts(false, Set.of()); + private static final Pattern ID = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,31}"); + private static final Pattern INDEX = Pattern.compile("0|[1-9][0-9]{0,3}"); + private static final int MAXIMUM_IDS = 1000; + + /////////////////////////////////////////////////////////////////////////////////////////////// + // the worker's own reading limits, so the host and the worker read the same sources // + /////////////////////////////////////////////////////////////////////////////////////////////// + private static final JsonFactory JSON = JsonFactory.builder().streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(24).maxNumberLength(32).build()) + .enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build(); + + private final boolean version2; + private final Set ids; + + + + /*************************************************************************** + ** The facts of one source. + ***************************************************************************/ + private SceneFacts(boolean version2, Set ids) + { + this.version2 = version2; + this.ids = ids; + } + + + + /*************************************************************************** + ** Read the facts of the source a job will send. Only a label can be a scene. + ***************************************************************************/ + static SceneFacts of(GenerationPorts.Format format, String source) + { + if(format != GenerationPorts.Format.ZPL) + { + return (NONE); + } + Set ids = new HashSet<>(); + boolean version2 = false; + try(JsonParser parser = JSON.createParser(source)) + { + if(parser.nextToken() != JsonToken.START_OBJECT) + { + return (NONE); + } + while(parser.nextToken() == JsonToken.FIELD_NAME) + { + String name = parser.currentName(); + JsonToken value = parser.nextToken(); + if(name.equals("schema")) + { + version2 = value == JsonToken.VALUE_STRING && SCHEMA.equals(parser.getText()); + } + if(name.equals("elements") && value == JsonToken.START_ARRAY) + { + elements(parser, ids); + } + else + { + parser.skipChildren(); + } + } + /////////////////////////////////////////////////////////////////////////////////////// + // read to the end, as the worker does: text after the scene makes it no scene // + /////////////////////////////////////////////////////////////////////////////////////// + if(parser.currentToken() != JsonToken.END_OBJECT || parser.nextToken() != null) + { + return (NONE); + } + } + catch(IOException | IllegalArgumentException unreadable) + { + return (NONE); + } + return (version2 ? new SceneFacts(true, Set.copyOf(ids)) : NONE); + } + + + + /*************************************************************************** + ** The ids of a list of elements and of the children of each, where the + ** worker's parser looks for them and nowhere else: a sample or an override + ** cannot declare one. + ***************************************************************************/ + private static void elements(JsonParser parser, Set ids) throws IOException + { + while(parser.nextToken() != JsonToken.END_ARRAY) + { + if(parser.currentToken() != JsonToken.START_OBJECT) + { + parser.skipChildren(); + continue; + } + while(parser.nextToken() == JsonToken.FIELD_NAME) + { + String name = parser.currentName(); + JsonToken value = parser.nextToken(); + if(name.equals("id") && value == JsonToken.VALUE_STRING && ID.matcher(parser.getText()).matches()) + { + ids.add(parser.getText()); + if(ids.size() > MAXIMUM_IDS) + { + throw new IllegalArgumentException("SCENE_ELEMENT_LIMIT"); + } + } + if(name.equals("children") && value == JsonToken.START_ARRAY) + { + elements(parser, ids); + } + else + { + parser.skipChildren(); + } + } + } + } + + + + /*************************************************************************** + ** Whether the source says it is a version 2 scene, which only renderer 0.2.0 + ** and later can draw. + ***************************************************************************/ + boolean version2() + { + return (version2); + } + + + + /*************************************************************************** + ** Whether the scene declares an element with this id. + ***************************************************************************/ + boolean declares(String id) + { + return (ids.contains(id)); + } + + + + /*************************************************************************** + ** Whether a diagnostic's pointer could have been made from this scene: the + ** empty pointer (the whole scene), or segments that are each an array index, + ** a property name of the schema or a declared id. + ***************************************************************************/ + boolean points(String pointer) + { + if(pointer.isEmpty()) + { + return (true); + } + if(!pointer.startsWith("/")) + { + return (false); + } + for(String segment : pointer.substring(1).split("/", -1)) + { + if(!(INDEX.matcher(segment).matches() || VOCABULARY.contains(segment) || ids.contains(segment))) + { + return (false); + } + } + return (true); + } +} diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswer.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswer.java new file mode 100644 index 0000000..155e1ad --- /dev/null +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswer.java @@ -0,0 +1,406 @@ +/* + * 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.documentgeneration.renderer; + + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.CodingErrorAction; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; + + +/******************************************************************************* + ** One answer of the worker, read as untrusted bytes. Nothing in it is believed + ** until it has been held to the request it answers, to the version rule and, + ** for a diagnostic, to the scene the host sent: a diagnostic is a code of the + ** closed vocabulary, numbers, an element id that scene declares and a pointer + ** made of array indexes, schema property names and declared ids. A worker + ** cannot put a sentence, a record value or a made-up name into one. + ** + ** Reading has two steps. `read` takes the frame apart and checks everything + ** that does not depend on what was asked: the frame, the field shapes, the + ** protocol, the request id, the catalog and a renderer version this bridge + ** accepts. `settle` then holds the answer to the question: a refusal becomes + ** the typed exception, anything out of place is rejected by name. + *******************************************************************************/ +final class WorkerAnswer +{ + static final String CATALOG = "qqq-barcodes-9"; + + /////////////////////////////////////////////////////////////////////////////////////////////////// + // what one answer may hold beyond its label. A scene has at most 1000 elements; each can be // + // refused once and warned about a few times. 2048 findings in one megabyte is far more than a // + // real label makes and bounds what a hostile worker can make the host read and keep // + /////////////////////////////////////////////////////////////////////////////////////////////////// + static final int MAXIMUM_DIAGNOSTICS = 2048; + static final int DIAGNOSTICS_BYTES = 1024 * 1024; + + private static final Set TEXT = Set.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "mediaType", "base64", "sha256", "code"); + private static final Set NUMBERS = Set.of("length", "pageCount"); + private static final int MAXIMUM_BOX = 1_000_000; + + private final ObjectNode fields; + private final RendererVersion version; + private final List diagnostics; + private final Diagnostic detail; + private final boolean detailed; + + + + /*************************************************************************** + ** An answer whose parts have been read and whose envelope has been checked. + ***************************************************************************/ + private WorkerAnswer(ObjectNode fields, RendererVersion version, List diagnostics, Diagnostic detail, boolean detailed) + { + this.fields = fields; + this.version = version; + this.diagnostics = diagnostics; + this.detail = detail; + this.detailed = detailed; + } + + + + /*************************************************************************** + ** Read one frame: exactly one line of strict JSON whose fields are the known + ** ones in their known shapes, from an accepted renderer, for this request. + ***************************************************************************/ + static WorkerAnswer read(byte[] output, int maximumFrame, String requestId, SceneFacts facts) throws IOException + { + if(output.length > maximumFrame) + { + throw new IllegalArgumentException("RESPONSE_BYTE_LIMIT"); + } + if(output.length == 0 || output[output.length - 1] != '\n') + { + throw new IllegalArgumentException("FRAME_TRUNCATED"); + } + for(int index = 0; index < output.length - 1; index++) + { + if(output[index] == '\n' || output[index] == '\r') + { + throw new IllegalArgumentException("EXTRA_FRAME"); + } + } + ObjectNode fields = RendererJson.JSON.createObjectNode(); + List diagnostics = null; + Diagnostic detail = null; + boolean detailed = false; + var decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT); + try(var reader = new InputStreamReader(new ByteArrayInputStream(output), decoder); JsonParser parser = RendererJson.JSON.getFactory().createParser(reader)) + { + if(parser.nextToken() != JsonToken.START_OBJECT) + { + throw new IllegalArgumentException("RESPONSE_OBJECT_REQUIRED"); + } + while(parser.nextToken() != JsonToken.END_OBJECT) + { + String field = parser.currentName(); + JsonToken token = parser.nextToken(); + if(TEXT.contains(field) && token == JsonToken.VALUE_STRING) + { + fields.put(field, text(parser, field.equals("base64") ? maximumFrame : 128)); + } + else if(NUMBERS.contains(field) && token == JsonToken.VALUE_NUMBER_INT) + { + fields.put(field, parser.getIntValue()); + } + else if(field.equals("diagnostics") && token == JsonToken.START_ARRAY) + { + diagnostics = new ArrayList<>(); + while(parser.nextToken() != JsonToken.END_ARRAY) + { + if(diagnostics.size() == MAXIMUM_DIAGNOSTICS) + { + throw new IllegalArgumentException("DIAGNOSTIC_LIMIT"); + } + diagnostics.add(diagnostic(parser, facts)); + } + } + else if(field.equals("detail") && token == JsonToken.START_OBJECT) + { + detail = diagnostic(parser, facts); + detailed = true; + } + else + { + throw new IllegalArgumentException("RESPONSE_FIELD_REJECTED"); + } + } + if(parser.nextToken() != null) + { + throw new IllegalArgumentException("RESPONSE_TRAILING_VALUE"); + } + } + RendererVersion version = RendererVersion.of(fields.path("rendererVersion").textValue()); + if(version == null || !RendererJson.PROTOCOL.equals(fields.path("protocol").textValue()) || !requestId.equals(fields.path("requestId").textValue()) + || !CATALOG.equals(fields.path("catalogVersion").textValue()) || !Set.of("OK", "REFUSED", "ERROR").contains(fields.path("outcome").asText())) + { + throw new IllegalArgumentException("WORKER_RESPONSE_REJECTED"); + } + return (new WorkerAnswer(fields, version, diagnostics == null ? null : List.copyOf(diagnostics), detail, detailed)); + } + + + + /*************************************************************************** + ** Hold the answer to what was asked. `current` says the job needs renderer + ** 0.2.0 (a version 2 scene, or collected diagnostics); `collect` says the + ** job asked for every diagnostic, which is the only way an answer may be + ** `REFUSED`. A refusal the renderer may name leaves as the typed exception + ** with its diagnostic; an answer that is returned is `OK` or `REFUSED` and + ** has exactly the fields its outcome and renderer allow. + ***************************************************************************/ + WorkerAnswer settle(String operation, boolean collect, boolean current) throws GenerationPorts.RenderException + { + if(current && !version.structured()) + { + //////////////////////////////////////////////////////////////////////////////////////// + // whatever an old renderer said about a job it cannot do, it did not do the job // + //////////////////////////////////////////////////////////////////////////////////////// + throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.RENDERER_TOO_OLD); + } + String outcome = fields.path("outcome").textValue(); + if(outcome.equals("ERROR")) + { + throw refusal(); + } + boolean refused = outcome.equals("REFUSED"); + int expected = refused || operation.equals("VALIDATE") ? 5 : 10; + boolean errors = diagnostics != null && diagnostics.stream().anyMatch(WorkerAnswer::error); + if(fields.size() != expected || detailed || (diagnostics != null) != version.structured() || (refused && !(collect && errors)) || (!refused && errors)) + { + throw new IllegalArgumentException("WORKER_RESPONSE_REJECTED"); + } + return (this); + } + + + + /*************************************************************************** + ** The typed refusal of an error answer, or the rejection of one that is not + ** exactly an error answer of its renderer. + ***************************************************************************/ + private GenerationPorts.RenderException refusal() + { + GenerationPorts.FailureCode code; + try + { + code = GenerationPorts.FailureCode.valueOf(RendererJson.text(fields.get("code"), 64)); + } + catch(IllegalArgumentException unknown) + { + throw new IllegalArgumentException("WORKER_ERROR_REJECTED"); + } + if(fields.size() != 6 || diagnostics != null || (detailed && !version.structured()) || !version.names(code) + || (detailed && !(error(detail) && detail.code().equals(code.name())))) + { + throw new IllegalArgumentException("WORKER_ERROR_REJECTED"); + } + return (new GenerationPorts.RenderException(code, detail)); + } + + + + /*************************************************************************** + ** The renderer that answered. + ***************************************************************************/ + RendererVersion version() + { + return (version); + } + + + + /*************************************************************************** + ** Whether the renderer collected at least one error and so sent no label. + ***************************************************************************/ + boolean refused() + { + return ("REFUSED".equals(fields.path("outcome").textValue())); + } + + + + /*************************************************************************** + ** The scalar fields of the answer. + ***************************************************************************/ + JsonNode fields() + { + return (fields); + } + + + + /*************************************************************************** + ** The findings of the answer, in the order the renderer found them; none + ** for a renderer that has none. + ***************************************************************************/ + List diagnostics() + { + return (diagnostics == null ? List.of() : diagnostics); + } + + + + /*************************************************************************** + ** Whether a finding stops printing. + ***************************************************************************/ + private static boolean error(Diagnostic diagnostic) + { + return (diagnostic.severity() == Diagnostic.Severity.ERROR); + } + + + + /*************************************************************************** + ** One diagnostic object, field by field. The record admits only the closed + ** vocabulary and number shapes; the scene admits only its own ids and + ** pointers. + ***************************************************************************/ + private static Diagnostic diagnostic(JsonParser parser, SceneFacts facts) throws IOException + { + if(parser.currentToken() != JsonToken.START_OBJECT) + { + throw new IllegalArgumentException("DIAGNOSTIC_OBJECT_REQUIRED"); + } + Map words = new LinkedHashMap<>(); + List box = null; + Map measured = null; + Map limit = null; + while(parser.nextToken() != JsonToken.END_OBJECT) + { + String field = parser.currentName(); + JsonToken token = parser.nextToken(); + if(Set.of("code", "severity", "element").contains(field) && token == JsonToken.VALUE_STRING) + { + words.put(field, text(parser, 64)); + } + else if(field.equals("pointer") && token == JsonToken.VALUE_STRING) + { + words.put(field, text(parser, 16 * 33)); + } + else if(field.equals("box") && token == JsonToken.START_ARRAY) + { + box = new ArrayList<>(); + while(parser.nextToken() != JsonToken.END_ARRAY) + { + int value = integer(parser); + if(box.size() == 4 || Math.abs((long) value) > MAXIMUM_BOX) + { + throw new IllegalArgumentException("DIAGNOSTIC_BOX"); + } + box.add(value); + } + } + else if(field.equals("measured") && token == JsonToken.START_OBJECT) + { + measured = numbers(parser); + } + else if(field.equals("limit") && token == JsonToken.START_OBJECT) + { + limit = numbers(parser); + } + else + { + throw new IllegalArgumentException("DIAGNOSTIC_FIELD_REJECTED"); + } + } + Diagnostic diagnostic; + try + { + diagnostic = new Diagnostic(words.get("code"), Diagnostic.Severity.valueOf(String.valueOf(words.get("severity"))), words.get("element"), box, measured, limit, words.get("pointer")); + } + catch(IllegalArgumentException outside) + { + ///////////////////////////////////////////////////////////////////////////////////////// + // the record's own messages are sentences for a developer; one name is enough here // + ///////////////////////////////////////////////////////////////////////////////////////// + throw new IllegalArgumentException("DIAGNOSTIC_OUTSIDE_VOCABULARY"); + } + if((diagnostic.element() != null && !facts.declares(diagnostic.element())) || (diagnostic.pointer() != null && !facts.points(diagnostic.pointer()))) + { + throw new IllegalArgumentException("DIAGNOSTIC_NOT_OF_THIS_SCENE"); + } + return (diagnostic); + } + + + + /*************************************************************************** + ** At most eight named integers. The record checks the names. + ***************************************************************************/ + private static Map numbers(JsonParser parser) throws IOException + { + Map numbers = new LinkedHashMap<>(); + while(parser.nextToken() != JsonToken.END_OBJECT) + { + String name = parser.currentName(); + parser.nextToken(); + if(numbers.size() == 8 || name.length() > 32) + { + throw new IllegalArgumentException("DIAGNOSTIC_NUMBERS"); + } + numbers.put(name, integer(parser)); + } + return (numbers); + } + + + + /*************************************************************************** + ** A whole number that fits an int; nothing is coerced into one. + ***************************************************************************/ + private static int integer(JsonParser parser) throws IOException + { + if(parser.currentToken() != JsonToken.VALUE_NUMBER_INT) + { + throw new IllegalArgumentException("DIAGNOSTIC_INTEGER"); + } + return (parser.getIntValue()); + } + + + + /*************************************************************************** + ** A string no longer than its field allows. + ***************************************************************************/ + private static String text(JsonParser parser, int maximum) throws IOException + { + if(parser.getTextLength() > maximum) + { + throw new IllegalArgumentException("RESPONSE_TEXT_LIMIT"); + } + return (parser.getText()); + } +} diff --git a/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java new file mode 100644 index 0000000..0db6ba5 --- /dev/null +++ b/bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java @@ -0,0 +1,537 @@ +/* + * 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.documentgeneration.renderer; + + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.zip.CRC32; +import java.util.zip.Deflater; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; + + +/******************************************************************************* + ** The page a printer would draw, decoded on the host from the label bytes the + ** host would send it. There is no second renderer: the picture is made from + ** the printer commands themselves, after the output policy has accepted them, + ** so a preview cannot show something other than what prints. + ** + ** The reader is its own strict parser of the policy's grammar and trusts + ** nothing it is handed: a byte that is not the next byte of that grammar, a + ** number that is not canonical, a field that leaves the page or a page too + ** large to draw is refused by name. Graphic fields (`^GFA`) and boxes (`^GB`) + ** are painted exactly. Printer font text (`^A0N`), which only a version 1 + ** scene emits, cannot be drawn without the printer's font: its estimated area + ** is hatched and the page says it is not exact. + ** + ** No image or font is decoded here. The only image work is writing this + ** class's own one-bit bitmap as a PNG. + *******************************************************************************/ +final class ZplRaster +{ + /////////////////////////////////////////////////////////////////////////////////////////////// + // a 4 x 6 in label at 600 dpi is 8.6 million dots; twice that is the most a preview draws // + /////////////////////////////////////////////////////////////////////////////////////////////// + static final long MAXIMUM_DOTS = 16_000_000L; + + private static final int HATCH = 6; + private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10}; + + private final int width; + private final int height; + private final int stride; + private final byte[] ink; + private boolean exact = true; + + + + /*************************************************************************** + ** A blank page of whole dots, within what a printer can address and a + ** preview may draw. + ***************************************************************************/ + private ZplRaster(int width, int height) + { + if(width < 1 || width > 9999 || height < 1 || height > 32000) + { + throw new IllegalArgumentException("RASTER_PAGE"); + } + if((long) width * height > MAXIMUM_DOTS) + { + throw new IllegalArgumentException("PREVIEW_TOO_LARGE"); + } + this.width = width; + this.height = height; + stride = (width + 7) / 8; + ink = new byte[stride * height]; + } + + + + /*************************************************************************** + ** The page of a label for its stock. The output policy runs first, here, so + ** no caller can draw bytes it did not accept. + ***************************************************************************/ + static ZplRaster page(byte[] zpl, GenerationPorts.Stock stock) throws Exception + { + ZplOutputPolicy.validate(zpl, stock); + return (decode(zpl)); + } + + + + /*************************************************************************** + ** Read a label and paint its fields. Fields are combined as a printer + ** combines them: ink over ink is ink. + ***************************************************************************/ + static ZplRaster decode(byte[] zpl) + { + Reader reader = new Reader(zpl); + reader.literal("^XA^PW"); + int pageWidth = reader.number(); + reader.literal("^LL"); + ZplRaster page = new ZplRaster(pageWidth, reader.number()); + reader.literal("^LH0,0^CI28\n"); + while(!reader.peek("^XZ\n")) + { + reader.literal("^FO"); + int x = reader.number(); + reader.literal(","); + int y = reader.number(); + if(reader.peek("^A0N,")) + { + page.text(reader, x, y); + } + else if(reader.peek("^GB")) + { + page.box(reader, x, y); + } + else + { + page.graphic(reader, x, y); + } + } + reader.literal("^XZ\n"); + reader.end(); + return (page); + } + + + + /*************************************************************************** + ** A page that shows only where elements were refused: each box, given in + ** dots from the printable area's top left as a diagnostic gives it, hatched + ** at its place on the page and cut to the page. It is drawn from numbers + ** alone. No label exists for it, so nothing of it can be printed. + ***************************************************************************/ + static ZplRaster refused(int width, int height, int originX, int originY, List> boxes) + { + ZplRaster page = new ZplRaster(width, height); + page.exact = false; + for(List box : boxes) + { + long left = Math.max(0L, (long) originX + box.get(0)); + long top = Math.max(0L, (long) originY + box.get(1)); + long right = Math.min(width, (long) originX + box.get(0) + box.get(2)); + long bottom = Math.min(height, (long) originY + box.get(1) + box.get(3)); + if(left < right && top < bottom) + { + page.hatch((int) left, (int) top, (int) (right - left), (int) (bottom - top)); + } + } + return (page); + } + + + + /*************************************************************************** + ** The page width in dots. + ***************************************************************************/ + int width() + { + return (width); + } + + + + /*************************************************************************** + ** The page height in dots. + ***************************************************************************/ + int height() + { + return (height); + } + + + + /*************************************************************************** + ** Whether every dot is what a printer draws. False when the label holds + ** printer font text, or the page shows refused elements instead of a label. + ***************************************************************************/ + boolean exact() + { + return (exact); + } + + + + /*************************************************************************** + ** Whether a dot of the page is inked. + ***************************************************************************/ + boolean ink(int x, int y) + { + return ((ink[y * stride + x / 8] & (128 >> x % 8)) != 0); + } + + + + /*************************************************************************** + ** The page as a one-bit greyscale PNG: one pixel for each dot, ink black. + ***************************************************************************/ + byte[] png() + { + byte[] lines = new byte[(stride + 1) * height]; + for(int row = 0; row < height; row++) + { + for(int index = 0; index < stride; index++) + { + lines[row * (stride + 1) + 1 + index] = (byte) ~ink[row * stride + index]; + } + } + Deflater deflater = new Deflater(); + ByteArrayOutputStream data = new ByteArrayOutputStream(); + try + { + deflater.setInput(lines); + deflater.finish(); + byte[] buffer = new byte[65536]; + while(!deflater.finished()) + { + data.write(buffer, 0, deflater.deflate(buffer)); + } + } + finally + { + deflater.end(); + } + ByteArrayOutputStream png = new ByteArrayOutputStream(); + png.writeBytes(SIGNATURE); + byte[] header = new byte[13]; + number(header, 0, width); + number(header, 4, height); + header[8] = 1; + chunk(png, "IHDR", header); + chunk(png, "IDAT", data.toByteArray()); + chunk(png, "IEND", new byte[0]); + return (png.toByteArray()); + } + + + + /*************************************************************************** + ** One PNG chunk: length, type, data and the checksum of type and data. + ***************************************************************************/ + private static void chunk(ByteArrayOutputStream png, String type, byte[] data) + { + byte[] name = type.getBytes(StandardCharsets.US_ASCII); + byte[] word = new byte[4]; + number(word, 0, data.length); + png.writeBytes(word); + png.writeBytes(name); + png.writeBytes(data); + CRC32 checksum = new CRC32(); + checksum.update(name); + checksum.update(data); + number(word, 0, (int) checksum.getValue()); + png.writeBytes(word); + } + + + + /*************************************************************************** + ** A big-endian 32-bit number. + ***************************************************************************/ + private static void number(byte[] target, int at, int value) + { + target[at] = (byte) (value >>> 24); + target[at + 1] = (byte) (value >>> 16); + target[at + 2] = (byte) (value >>> 8); + target[at + 3] = (byte) value; + } + + + + /*************************************************************************** + ** Printer font text: the area the policy holds it to (one cell of the font + ** width for each character, one line of the font height), hatched. + ***************************************************************************/ + private void text(Reader reader, int x, int y) + { + reader.literal("^A0N,"); + int fontHeight = reader.number(); + reader.literal(","); + int fontWidth = reader.number(); + reader.literal("^FH_^FD"); + int characters = 0; + while(reader.peek("_")) + { + reader.literal("_"); + int value = reader.hex() * 16 + reader.hex(); + characters += (value & 0xC0) == 0x80 ? 0 : 1; + } + reader.literal("^FS\n"); + long cells = (long) fontWidth * characters; + inside(x, y, cells, fontHeight); + hatch(x, y, (int) cells, fontHeight); + exact = false; + } + + + + /*************************************************************************** + ** A box as the printer draws it: a border of the stated thickness inside + ** the stated size, which is solid when the border meets itself. + ***************************************************************************/ + private void box(Reader reader, int x, int y) + { + reader.literal("^GB"); + int boxWidth = reader.number(); + reader.literal(","); + int boxHeight = reader.number(); + reader.literal(","); + int thickness = reader.number(); + reader.literal("^FS\n"); + inside(x, y, boxWidth, boxHeight); + if(thickness < 1 || thickness > Math.min(boxWidth, boxHeight)) + { + throw new IllegalArgumentException("RASTER_FIELD"); + } + for(int row = 0; row < boxHeight; row++) + { + boolean border = row < thickness || row >= boxHeight - thickness; + for(int column = 0; column < boxWidth; column++) + { + if(border || column < thickness || column >= boxWidth - thickness) + { + set(x + column, y + row); + } + } + } + } + + + + /*************************************************************************** + ** An uncompressed graphic field: rows of whole bytes, most significant bit + ** first. Bits that pad the last byte of a row past the page edge must be + ** white, as the policy requires. + ***************************************************************************/ + private void graphic(Reader reader, int x, int y) + { + reader.literal("^GFA,"); + int transmitted = reader.number(); + reader.literal(","); + int total = reader.number(); + reader.literal(","); + int rowBytes = reader.number(); + reader.literal(","); + if(total < 1 || total > 99999 || transmitted != total || rowBytes < 1 || rowBytes > 1250 || total % rowBytes != 0) + { + throw new IllegalArgumentException("RASTER_FIELD"); + } + inside(x, y, 1, total / rowBytes); + for(int index = 0; index < total; index++) + { + int value = reader.hex() * 16 + reader.hex(); + for(int bit = 0; bit < 8; bit++) + { + if((value & (128 >> bit)) != 0) + { + int column = x + index % rowBytes * 8 + bit; + if(column >= width) + { + throw new IllegalArgumentException("RASTER_FIELD"); + } + set(column, y + index / rowBytes); + } + } + } + reader.literal("^FS\n"); + } + + + + /*************************************************************************** + ** Refuse a field that is not wholly on the page. + ***************************************************************************/ + private void inside(int x, int y, long fieldWidth, long fieldHeight) + { + if(fieldWidth < 1 || fieldHeight < 1 || x + fieldWidth > width || y + fieldHeight > height) + { + throw new IllegalArgumentException("RASTER_FIELD"); + } + } + + + + /*************************************************************************** + ** An outline with diagonal lines across it: plainly a placeholder, never + ** mistaken for ink the label holds. + ***************************************************************************/ + private void hatch(int x, int y, int areaWidth, int areaHeight) + { + for(int row = 0; row < areaHeight; row++) + { + for(int column = 0; column < areaWidth; column++) + { + if(row == 0 || column == 0 || row == areaHeight - 1 || column == areaWidth - 1 || (row + column) % HATCH == 0) + { + set(x + column, y + row); + } + } + } + } + + + + /*************************************************************************** + ** Ink one dot that is known to be on the page. + ***************************************************************************/ + private void set(int x, int y) + { + ink[y * stride + x / 8] |= (byte) (128 >> x % 8); + } + + + + /*************************************************************************** + ** The bytes of a label and a place in them. Every read is bounded and every + ** mismatch ends the reading: nothing is skipped or repaired. + ***************************************************************************/ + private static final class Reader + { + private final byte[] bytes; + private int position; + + + + /************************************************************************ + ** Start at the first byte. + ************************************************************************/ + private Reader(byte[] bytes) + { + if(bytes == null) + { + throw new IllegalArgumentException("RASTER_GRAMMAR"); + } + this.bytes = bytes; + } + + + + /************************************************************************ + ** Whether the next bytes are these. + ************************************************************************/ + private boolean peek(String literal) + { + if(position + literal.length() > bytes.length) + { + return (false); + } + for(int index = 0; index < literal.length(); index++) + { + if(bytes[position + index] != literal.charAt(index)) + { + return (false); + } + } + return (true); + } + + + + /************************************************************************ + ** Pass these bytes or refuse. + ************************************************************************/ + private void literal(String literal) + { + if(!peek(literal)) + { + throw new IllegalArgumentException("RASTER_GRAMMAR"); + } + position += literal.length(); + } + + + + /************************************************************************ + ** An unsigned decimal number without a leading zero, of at most six + ** digits: more than any size, place or length a label can state. + ************************************************************************/ + private int number() + { + int start = position; + int value = 0; + while(position < bytes.length && bytes[position] >= '0' && bytes[position] <= '9' && position - start < 7) + { + value = value * 10 + bytes[position++] - '0'; + } + if(position == start || position - start > 6 || (position - start > 1 && bytes[start] == '0')) + { + throw new IllegalArgumentException("RASTER_NUMBER"); + } + return (value); + } + + + + /************************************************************************ + ** One uppercase hexadecimal digit. + ************************************************************************/ + private int hex() + { + int value = position < bytes.length ? bytes[position] : -1; + position++; + if(value >= '0' && value <= '9') + { + return (value - '0'); + } + if(value >= 'A' && value <= 'F') + { + return (value - 'A' + 10); + } + throw new IllegalArgumentException("RASTER_HEX"); + } + + + + /************************************************************************ + ** Nothing may follow the end of the label. + ************************************************************************/ + private void end() + { + if(position != bytes.length) + { + throw new IllegalArgumentException("RASTER_GRAMMAR"); + } + } + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java index 85b0062..cfab90b 100644 --- a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/BridgeTest.java @@ -393,9 +393,10 @@ class BridgeTest { for(String malformed : List.of("{\"base64\":[]}", "{\"base64\":{\"nested\":1}}", "{\"unknown\":1}", "{\"length\":1,\"length\":2}", "{\"length\":2147483648}", "{\"length\":1} {}")) { - assertThrows(Exception.class, () -> RendererJson.response(malformed.getBytes(StandardCharsets.UTF_8), 4096)); + assertThrows(Exception.class, () -> WorkerAnswer.read((malformed + "\n").getBytes(StandardCharsets.UTF_8), 4096, "request-1", SceneFacts.of(GenerationPorts.Format.ZPL, "{}"))); } - assertThrows(Exception.class, () -> RendererJson.response(new byte[] { '{', '"', 'b', 'a', 's', 'e', '6', '4', '"', ':', '"', (byte) 0xff, '"', '}' }, 4096)); + assertThrows(Exception.class, () -> WorkerAnswer.read(new byte[] { '{', '"', 'b', 'a', 's', 'e', '6', '4', '"', ':', '"', (byte) 0xff, '"', '}', '\n' }, 4096, "request-1", + SceneFacts.of(GenerationPorts.Format.ZPL, "{}"))); } /******************************************************************************* diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java new file mode 100644 index 0000000..eb50152 --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java @@ -0,0 +1,272 @@ +/* + * 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.documentgeneration.renderer; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.fasterxml.jackson.databind.JsonNode; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; + + +/******************************************************************************* + ** A stand-in for the worker, for tests of the host side: it keeps every frame + ** the bridge sends and answers as a renderer of a stated version would, or as + ** a test tells it to. It answers the bridge's version question by itself, so a + ** test writes only the answer to the job it is about. No renderer code runs. + *******************************************************************************/ +final class FakeWorker implements IsolatedRendererBridge.Exchange +{ + static final String RELEASED = "qqq-renderer-0.1.0"; + static final String CURRENT = "qqq-renderer-0.2.0"; + static final Path SCENES = Path.of("..", "..", "renderer", "src", "test", "resources", "scene2"); + static final String VERSION_ONE_SCENE = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[{\"type\":\"box\",\"x\":2,\"y\":40,\"width\":20,\"height\":10,\"thickness\":1}]}"; + + final List jobs = new ArrayList<>(); + final List questions = new ArrayList<>(); + final List frameLimits = new ArrayList<>(); + private final String version; + private final Answer answer; + + + + /*************************************************************************** + ** What the worker says to one job. + ***************************************************************************/ + @FunctionalInterface + interface Answer + { + /************************************************************************ + ** The fields of the answer frame for a job the bridge sent. + ************************************************************************/ + Map to(JsonNode job) throws Exception; + } + + + + /*************************************************************************** + ** A worker of a version that answers every job the same way. + ***************************************************************************/ + FakeWorker(String version, Answer answer) + { + this.version = version; + this.answer = answer; + } + + + + /*************************************************************************** + ** Keep the frame; answer the version question as this renderer would, and a + ** job as the test says. + ***************************************************************************/ + @Override + public byte[] invoke(String requestId, byte[] input, int maximumOutput) throws Exception + { + JsonNode job = RendererJson.parse(input, 16 * 1024 * 1024); + if(IsolatedRendererBridge.PROBE_SCENE.equals(job.path("source").textValue())) + { + questions.add(job); + return (frame(validated(requestId, version))); + } + jobs.add(job); + frameLimits.add(maximumOutput); + return (frame(answer.to(job))); + } + + + + /*************************************************************************** + ** A bridge over this exchange whose deployment admits documents of four + ** megabytes, with no assets to read. + ***************************************************************************/ + static IsolatedRendererBridge bridge(Path directory, IsolatedRendererBridge.Exchange exchange) + { + RendererConfiguration base = BridgeTest.config(directory); + return (new IsolatedRendererBridge(new RendererConfiguration(base.dockerExecutable(), base.image(), base.imageId(), base.qualificationId(), base.requiredQualificationId(), base.ownerId(), + directory, base.renderDeadline(), 4 * 1024 * 1024, 20), (actor, asset, maximum) -> new byte[0], exchange)); + } + + + + /*************************************************************************** + ** One answer frame. + ***************************************************************************/ + static byte[] frame(Map fields) throws Exception + { + return ((RendererJson.JSON.writeValueAsString(fields) + "\n").getBytes(StandardCharsets.UTF_8)); + } + + + + /*************************************************************************** + ** The fields every answer has. + ***************************************************************************/ + static Map envelope(String requestId, String version, String outcome) + { + Map fields = new LinkedHashMap<>(); + fields.put("protocol", RendererJson.PROTOCOL); + fields.put("requestId", requestId); + fields.put("outcome", outcome); + fields.put("rendererVersion", version); + fields.put("catalogVersion", WorkerAnswer.CATALOG); + return (fields); + } + + + + /*************************************************************************** + ** A successful validation, as a renderer of this version answers it. + ***************************************************************************/ + static Map validated(String requestId, String version) + { + Map fields = envelope(requestId, version, "OK"); + if(CURRENT.equals(version)) + { + fields.put("diagnostics", List.of()); + } + return (fields); + } + + + + /*************************************************************************** + ** A rendered label, as a renderer of this version answers it; renderer + ** 0.2.0 adds the diagnostics. + ***************************************************************************/ + static Map rendered(String requestId, String version, byte[] label, List> diagnostics) throws Exception + { + Map fields = envelope(requestId, version, "OK"); + fields.put("mediaType", "application/vnd.zebra-zpl"); + fields.put("base64", Base64.getEncoder().encodeToString(label)); + fields.put("sha256", RendererJson.hash(label)); + fields.put("length", label.length); + fields.put("pageCount", 1); + if(CURRENT.equals(version)) + { + fields.put("diagnostics", diagnostics); + } + return (fields); + } + + + + /*************************************************************************** + ** A scene whose diagnostics were collected and hold an error: no label. + ***************************************************************************/ + static Map refused(String requestId, List> diagnostics) + { + Map fields = envelope(requestId, CURRENT, "REFUSED"); + fields.put("diagnostics", diagnostics); + return (fields); + } + + + + /*************************************************************************** + ** An error answer, with its one diagnostic when there is one. + ***************************************************************************/ + static Map error(String requestId, String version, String code, Map detail) + { + Map fields = envelope(requestId, version, "ERROR"); + fields.put("code", code); + if(detail != null) + { + fields.put("detail", detail); + } + return (fields); + } + + + + /*************************************************************************** + ** One diagnostic as the wire carries it: a code, a severity and, in pairs, + ** any of its other parts. + ***************************************************************************/ + static Map diagnostic(String code, String severity, Object... parts) + { + Map wire = new LinkedHashMap<>(); + wire.put("code", code); + wire.put("severity", severity); + for(int index = 0; index < parts.length; index += 2) + { + wire.put((String) parts[index], parts[index + 1]); + } + return (wire); + } + + + + /*************************************************************************** + ** The source of one of the renderer's version 2 layout cases. + ***************************************************************************/ + static String scene(String name) throws Exception + { + return (RendererJson.JSON.writeValueAsString(definition(name).path("scene"))); + } + + + + /*************************************************************************** + ** The stock of one of those cases. + ***************************************************************************/ + static GenerationPorts.Stock stock(String name) throws Exception + { + JsonNode stock = definition(name).path("stock"); + return (new GenerationPorts.Stock(stock.path("widthMicrons").intValue(), stock.path("heightMicrons").intValue(), stock.path("topMicrons").intValue(), + stock.path("rightMicrons").intValue(), stock.path("bottomMicrons").intValue(), stock.path("leftMicrons").intValue(), stock.path("dpi").intValue())); + } + + + + /*************************************************************************** + ** The golden label of a case at a resolution, as the renderer emits it. + ***************************************************************************/ + static byte[] golden(String name, int dpi) throws Exception + { + return (Files.readAllBytes(SCENES.resolve("golden").resolve(name + "-" + dpi + ".zpl"))); + } + + + + /*************************************************************************** + ** A revision whose label source is this scene on this stock. + ***************************************************************************/ + static GenerationPorts.Revision revision(String zplSource, GenerationPorts.Stock stock) + { + return (new GenerationPorts.Revision(1, 1, "orders", stock, null, zplSource, null, List.of())); + } + + + + /*************************************************************************** + ** One case file. + ***************************************************************************/ + private static JsonNode definition(String name) throws Exception + { + return (RendererJson.JSON.readTree(Files.readAllBytes(SCENES.resolve("cases").resolve(name + ".json")))); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java index 63f8673..a8a7fd0 100644 --- a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/MutationCampaignTest.java @@ -29,7 +29,12 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.kingsrook.qbits.documentgeneration.GenerationPorts; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertArrayEquals; @@ -41,9 +46,11 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /******************************************************************************* - ** The mutation harness is deterministic and reports what it should, and one - ** bounded campaign runs in the normal test phase over the host printer gate. - ** Seeds, limits and the replay command: `docs/security/fuzzing.md`. + ** The mutation harness is deterministic and reports what it should, and three + ** bounded campaigns run in the normal test phase over what the host reads from + ** the worker: the printer gate, the page decoder behind it, and the answer + ** frames with their diagnostics. Seeds, limits and the replay command: + ** `docs/security/fuzzing.md`. *******************************************************************************/ class MutationCampaignTest { @@ -52,6 +59,13 @@ class MutationCampaignTest private static final Path CORPUS = Path.of("..", "..", "renderer", "src", "test", "resources", "compat-v1", "zpl"); private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(76200, 50800, 0, 0, 0, 0, 203); private static final Set COMMANDS = Set.of("XA", "XZ", "PW", "LL", "LH", "CI", "FO", "A0", "FH", "FD", "FS", "GB", "GF"); + private static final List RELEASED = List.of("box-203", "text-font0-203", "image-203", "barcode-scale-203", "raster-text-rotation-203"); + private static final List TOKENS = List.of("^XA", "^XZ\n", "^FO", "^GFA,", "^GB", "^FS\n", "^A0N,", "^FH_^FD", "^PW", "^LL", "^LH0,0", "^CI28\n", + "~DG", "~JR", "^XG", "^DF", "^JU", "^FX", "^FB", "99999", "0", ",", "\n", "_5E", "_7E"); + private static final Pattern FIELD = Pattern.compile("\\^FO([0-9]+),([0-9]+)\\^(?:GFA,[0-9]+,[0-9]+,([0-9]+),([0-9A-F]*)|GB([0-9]+),([0-9]+),([0-9]+))\\^FS\n"); + private static final String REQUEST = "request-1"; + private static final List DECLARED = List.of("top-left", "top-center", "top-right", "middle-left", "center", "left-of-center", "middle-right", "bottom-left", "bottom-center", + "bottom-right"); @@ -136,15 +150,7 @@ class MutationCampaignTest @Test void theHostPrinterGateAcceptsOnlyTheRendererCommandSet() throws Exception { - List corpus = new ArrayList<>(); - for(String name : List.of("box-203", "text-font0-203", "image-203", "barcode-scale-203", "raster-text-rotation-203")) - { - byte[] released = Files.readAllBytes(CORPUS.resolve(name + ".zpl")); - ZplOutputPolicy.validate(released, STOCK); - corpus.add(released); - } - MutationCampaign campaign = new MutationCampaign(corpus, List.of("^XA", "^XZ\n", "^FO", "^GFA,", "^GB", "^FS\n", "^A0N,", "^FH_^FD", "^PW", "^LL", "^LH0,0", "^CI28\n", - "~DG", "~JR", "^XG", "^DF", "^JU", "^FX", "^FB", "99999", "0", ",", "\n", "_5E", "_7E")); + MutationCampaign campaign = new MutationCampaign(released(), TOKENS); int iterations = MutationCampaign.iterations(ITERATIONS); for(long seed : MutationCampaign.seeds(SEEDS)) { @@ -157,6 +163,229 @@ class MutationCampaignTest + /*************************************************************************** + ** The released labels the printer campaigns mutate, each accepted by the + ** gate for the stock it was rendered for. + ***************************************************************************/ + private static List released() throws Exception + { + List corpus = new ArrayList<>(); + for(String name : RELEASED) + { + byte[] released = Files.readAllBytes(CORPUS.resolve(name + ".zpl")); + ZplOutputPolicy.validate(released, STOCK); + corpus.add(released); + } + return (corpus); + } + + + + /*************************************************************************** + ** Property: the page decoder never fails on a label the printer gate + ** accepts, and what it draws for one is exactly the label's graphic fields + ** and boxes as a second, independent reading draws them. Handed bytes the + ** gate refuses, as it never is in production, it draws or refuses by its + ** own IllegalArgumentException and does nothing else. Any other failure is + ** a finding. + ***************************************************************************/ + @Test + void thePageDecoderDrawsExactlyWhatThePrinterGateAccepts() throws Exception + { + MutationCampaign campaign = new MutationCampaign(released(), TOKENS); + int iterations = MutationCampaign.iterations(ITERATIONS); + for(long seed : MutationCampaign.seeds(SEEDS)) + { + MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::page, + failure -> failure instanceof IllegalArgumentException || failure instanceof CharacterCodingException); + assertEquals(List.of(), result.findings(), "replay with -Dfuzz.seed=" + seed + " -Dfuzz.iterations=" + iterations); + assertFalse(result.accepted() == 0 || result.refused() == 0, "the campaign must reach both acceptance and refusal: " + result); + } + } + + + + /*************************************************************************** + ** The decoder beside the gate, and an independent drawing of what the gate + ** let through. + ***************************************************************************/ + private static void page(byte[] zpl) throws Exception + { + try + { + ZplOutputPolicy.validate(zpl, STOCK); + } + catch(IllegalArgumentException | CharacterCodingException refused) + { + try + { + ZplRaster page = ZplRaster.decode(zpl); + assertTrue((long) page.width() * page.height() <= ZplRaster.MAXIMUM_DOTS, "drew a page past the limit"); + } + catch(IllegalArgumentException alsoRefused) + { + // The decoder's own refusal of bytes the gate refused. + } + throw refused; + } + ZplRaster page; + try + { + page = ZplRaster.decode(zpl); + } + catch(IllegalArgumentException refused) + { + throw new AssertionError("the decoder refused a label the gate accepts: " + refused.getMessage()); + } + assertEquals(609, page.width()); + assertEquals(406, page.height()); + String text = new String(zpl, StandardCharsets.US_ASCII); + assertEquals(!text.contains("^A0N,"), page.exact()); + if(page.exact()) + { + boolean[][] expected = new boolean[406][609]; + Matcher field = FIELD.matcher(text); + while(field.find()) + { + int x = Integer.parseInt(field.group(1)); + int y = Integer.parseInt(field.group(2)); + if(field.group(3) != null) + { + int stride = Integer.parseInt(field.group(3)); + byte[] raster = HexFormat.of().parseHex(field.group(4)); + for(int index = 0; index < raster.length * 8; index++) + { + if((raster[index / 8] & (128 >> index % 8)) != 0) + { + expected[y + index / 8 / stride][x + index / 8 % stride * 8 + index % 8] = true; + } + } + } + else + { + int width = Integer.parseInt(field.group(5)); + int height = Integer.parseInt(field.group(6)); + int thickness = Integer.parseInt(field.group(7)); + for(int row = 0; row < height; row++) + { + for(int column = 0; column < width; column++) + { + expected[y + row][x + column] |= row < thickness || column < thickness || row >= height - thickness || column >= width - thickness; + } + } + } + } + for(int y = 0; y < 406; y++) + { + for(int x = 0; x < 609; x++) + { + if(expected[y][x] != page.ink(x, y)) + { + throw new AssertionError("the decoder and the independent drawing differ at dot " + x + ", " + y); + } + } + } + } + } + + + + /*************************************************************************** + ** Property: whatever the answer reader accepts for a collected render of a + ** version 2 scene is an answer of renderer 0.2.0 that is either a label with + ** no error or a refusal with no label, and every diagnostic it carries names + ** only an element the scene declares and points only through array indexes, + ** schema property names and declared ids, as a second and plain JSON reader + ** sees it. A typed refusal is held to the same rule. Everything else is + ** rejected with the reader's IllegalArgumentException or the JSON reader's + ** IOException; any other failure is a finding. + ***************************************************************************/ + @Test + void theAnswerReaderBelievesOnlyDiagnosticsOfTheSceneThatWasSent() throws Exception + { + byte[] label = "^XA^PW406^LL203^LH0,0^CI28\n^FO2,40^GFA,2,2,1,AA80^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII); + Map error = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "pointer", "/elements/4", "box", List.of(171, 68, 47, 23)); + Map warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "top-left", "pointer", "/overrides/0/set/top-left/x", "measured", Map.of("widthDots", 902, "lines", 2), + "limit", Map.of("widthDots", 764)); + List corpus = List.of(FakeWorker.frame(FakeWorker.rendered(REQUEST, FakeWorker.CURRENT, label, List.of(warning))), FakeWorker.frame(FakeWorker.refused(REQUEST, List.of(warning, error))), + FakeWorker.frame(FakeWorker.error(REQUEST, FakeWorker.CURRENT, "RENDERER_TOO_OLD", error)), FakeWorker.frame(FakeWorker.rendered(REQUEST, FakeWorker.CURRENT, label, List.of())), + FakeWorker.frame(FakeWorker.rendered(REQUEST, FakeWorker.RELEASED, label, List.of())), FakeWorker.frame(FakeWorker.error(REQUEST, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", null))); + MutationCampaign campaign = new MutationCampaign(corpus, List.of("\"diagnostics\":", "\"detail\":", "\"REFUSED\"", "\"OK\"", "\"ERROR\"", "\"WARNING\"", "\"element\":\"", "\"pointer\":\"/", + "/elements/0", "/set/", "\"box\":[", "\"measured\":{", "\"limit\":{", "\"base64\":\"", "\"code\":\"", "\"severity\":\"", "top-left", "center", "undeclared", "secret", "qqq-renderer-0.1.0", + "qqq-renderer-0.2.0", "RENDER_FAILED", "TEXT_DOES_NOT_FIT", "SCENE_INVALID", "[", "]", "{", "}", ",", ":", "\"", "\n", "0", "-1", "2147483648")); + SceneFacts facts = SceneFacts.of(GenerationPorts.Format.ZPL, FakeWorker.scene("anchors")); + DECLARED.forEach(id -> assertTrue(facts.declares(id), id)); + int iterations = MutationCampaign.iterations(ITERATIONS); + for(long seed : MutationCampaign.seeds(SEEDS)) + { + MutationCampaign.Result result = campaign.run(seed, iterations, input -> answer(input, facts), + failure -> failure instanceof IllegalArgumentException || failure instanceof java.io.IOException || failure instanceof GenerationPorts.RenderException); + assertEquals(List.of(), result.findings(), "replay with -Dfuzz.seed=" + seed + " -Dfuzz.iterations=" + iterations); + assertFalse(result.accepted() == 0 || result.refused() == 0, "the campaign must reach both acceptance and refusal: " + result); + } + } + + + + /*************************************************************************** + ** The reader, and an independent reading of what it let through. + ***************************************************************************/ + private static void answer(byte[] frame, SceneFacts facts) throws Exception + { + WorkerAnswer answer; + try + { + answer = WorkerAnswer.read(frame, 65536, REQUEST, facts).settle("RENDER", true, true); + } + catch(GenerationPorts.RenderException refusal) + { + if(refusal.diagnostic() != null) + { + ofTheScene(refusal.diagnostic().element(), refusal.diagnostic().pointer()); + } + throw refusal; + } + JsonNode tree = new ObjectMapper().readTree(frame); + assertEquals("qqq-renderer-0.2.0", tree.path("rendererVersion").textValue(), "accepted an answer of another renderer"); + boolean refused = tree.path("outcome").textValue().equals("REFUSED"); + assertTrue(refused || tree.path("outcome").textValue().equals("OK"), "accepted an outcome it does not know"); + assertEquals(refused, answer.refused()); + boolean errors = false; + for(JsonNode diagnostic : tree.path("diagnostics")) + { + errors |= diagnostic.path("severity").textValue().equals("ERROR"); + diagnostic.fieldNames().forEachRemaining(name -> assertTrue(Set.of("code", "severity", "element", "pointer", "box", "measured", "limit").contains(name), "accepted a diagnostic field: " + name)); + ofTheScene(diagnostic.path("element").textValue(), diagnostic.path("pointer").textValue()); + } + assertEquals(refused, errors, "an answer is refused exactly when it holds an error"); + for(String part : List.of("mediaType", "base64", "sha256", "length", "pageCount")) + { + assertEquals(!refused, tree.has(part), "a refused answer carries no label and a label is whole: " + part); + } + assertEquals(tree.path("diagnostics").size(), answer.diagnostics().size()); + } + + + + /*************************************************************************** + ** An element is one the scene declares, and a pointer is made of array + ** indexes, property names of the schema and declared ids. + ***************************************************************************/ + private static void ofTheScene(String element, String pointer) + { + assertTrue(element == null || DECLARED.contains(element), "accepted an element the scene does not declare: " + element); + if(pointer != null && !pointer.isEmpty()) + { + assertTrue(pointer.startsWith("/"), pointer); + for(String segment : pointer.substring(1).split("/", -1)) + { + assertTrue(segment.matches("0|[1-9][0-9]{0,3}") || SceneFacts.VOCABULARY.contains(segment) || DECLARED.contains(segment), "accepted a pointer segment: " + segment); + } + } + } + + + /*************************************************************************** ** The target, and an independent reading of what it let through. ***************************************************************************/ diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java new file mode 100644 index 0000000..a82a416 --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java @@ -0,0 +1,379 @@ +/* + * 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.documentgeneration.renderer; + + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** The preview is the print path stopped before retention: the same job, the + ** same checks on the label, and a picture decoded from the accepted bytes. A + ** layout that cannot print comes back as diagnostics, with a page drawn from + ** their boxes and no output at all. + *******************************************************************************/ +class PreviewTest +{ + private static final String SECRET = "a-record-value-that-must-not-travel"; + private static final String NOTHING = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + private static final int MAXIMUM = 1024 * 1024; + + @TempDir + Path directory; + + + + /*************************************************************************** + ** A preview job for a case of the renderer's version 2 goldens. + ***************************************************************************/ + private static GenerationPorts.PreviewRequest preview(String name, Integer targetDpi) throws Exception + { + return (new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene(name), FakeWorker.stock(name)), Map.of("name", SECRET), targetDpi, + MAXIMUM)); + } + + + + /*************************************************************************** + ** A worker of renderer 0.2.0 that answers every job with these fields. + ***************************************************************************/ + private static FakeWorker answering(FakeWorker.Answer answer) + { + return (new FakeWorker(FakeWorker.CURRENT, answer)); + } + + + + /*************************************************************************** + ** The files under the adapter's state directory. + ***************************************************************************/ + private List files() throws Exception + { + try(Stream files = Files.walk(directory)) + { + return (files.filter(Files::isRegularFile).toList()); + } + } + + + + /*************************************************************************** + ** At each resolution the preview of a layout that prints is the golden + ** picture, dot for dot, with the size and hash of exactly the bytes the + ** print path returns for the same job. The two jobs differ only in their + ** request id and in asking for collected diagnostics. Nothing is written. + ***************************************************************************/ + @ParameterizedTest + @ValueSource(ints = {203, 300, 600}) + void aPreviewIsThePrintPathStoppedBeforeRetention(int dpi) throws Exception + { + Map warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "side", "pointer", "/elements/4/x"); + FakeWorker worker = answering(job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("margins", dpi), + job.has("diagnostics") ? List.of(warning) : List.of())); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + GenerationPorts.PreviewRequest request = preview("margins", dpi); + GenerationPorts.Preview preview = bridge.preview(request); + GenerationPorts.Content printed = bridge.render(new GenerationPorts.RenderRequest("request-1", request.actorId(), new GenerationPorts.Source("orders", "1"), request.format(), request.revision(), + request.context(), request.maximumBytes(), request.targetDpi())); + + assertTrue(preview.printable()); + assertTrue(preview.exact()); + assertEquals(List.of(new Diagnostic("DEVICE_UNIT_USED", Diagnostic.Severity.WARNING, "side", null, null, null, "/elements/4/x")), preview.diagnostics()); + assertArrayEquals(FakeWorker.golden("margins", dpi), printed.bytes()); + assertEquals((long) printed.bytes().length, preview.outputBytes()); + assertEquals(RendererJson.hash(printed.bytes()), preview.outputSha256()); + + BufferedImage golden = ImageIO.read(FakeWorker.SCENES.resolve("golden").resolve("margins-" + dpi + ".png").toFile()); + assertEquals(1, preview.pages().size()); + assertEquals(golden.getWidth(), preview.pages().get(0).widthDots()); + assertEquals(golden.getHeight(), preview.pages().get(0).heightDots()); + BufferedImage page = ImageIO.read(new ByteArrayInputStream(preview.pages().get(0).png())); + for(int y = 0; y < golden.getHeight(); y++) + { + for(int x = 0; x < golden.getWidth(); x++) + { + assertEquals(golden.getRGB(x, y) & 0xFFFFFF, page.getRGB(x, y) & 0xFFFFFF); + } + } + + assertEquals(2, worker.jobs.size()); + ObjectNode previewed = (ObjectNode) worker.jobs.get(0); + ObjectNode rendered = (ObjectNode) worker.jobs.get(1); + assertEquals("COLLECT", previewed.remove("diagnostics").textValue()); + assertTrue(previewed.remove("requestId").textValue().matches("[0-9a-f-]{36}")); + assertEquals("request-1", rendered.remove("requestId").textValue()); + assertEquals(rendered, previewed); + assertEquals("RENDER", previewed.path("operation").textValue()); + assertEquals(dpi, previewed.path("targetDpi").intValue()); + assertEquals(SECRET, previewed.path("context").path("name").textValue()); + assertEquals(List.of(), files()); + } + + + + /*************************************************************************** + ** A scene with a refused element has no label: the worker sends none and + ** the preview has no output. Its page is drawn on the host from the boxes of + ** the error diagnostics alone, placed from the printable area's corner as + ** the renderer places it (the corner of the golden label at each + ** resolution), cut to the page and hatched. + ***************************************************************************/ + @ParameterizedTest + @ValueSource(ints = {203, 300, 600}) + void aRefusedSceneIsDrawnFromItsDiagnosticsAndHasNoOutput(int dpi) throws Exception + { + Matcher corner = Pattern.compile("\\^FO([0-9]+),([0-9]+)\\^GFA").matcher(new String(FakeWorker.golden("margins", dpi), StandardCharsets.US_ASCII)); + assertTrue(corner.find()); + int left = Integer.parseInt(corner.group(1)); + int top = Integer.parseInt(corner.group(2)); + List> diagnostics = List.of( + FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "first", "box", List.of(0, 0, 16, 12)), + FakeWorker.diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", "ERROR", "element", "last", "box", List.of(-100, 30, 120, 100_000)), + FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "side", "box", List.of(100, 0, 50, 50)), + FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "middle", "pointer", "/elements/3/thickness")); + FakeWorker worker = answering(job -> FakeWorker.refused(job.path("requestId").textValue(), diagnostics)); + GenerationPorts.Preview preview = FakeWorker.bridge(directory, worker).preview(preview("margins", dpi)); + + assertFalse(preview.printable()); + assertFalse(preview.exact()); + assertEquals(0L, preview.outputBytes()); + assertEquals(NOTHING, preview.outputSha256()); + assertEquals(4, preview.diagnostics().size()); + assertEquals(new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, "first", List.of(0, 0, 16, 12), null, null), preview.diagnostics().get(0)); + assertEquals(1, preview.pages().size()); + BufferedImage golden = ImageIO.read(FakeWorker.SCENES.resolve("golden").resolve("margins-" + dpi + ".png").toFile()); + BufferedImage page = ImageIO.read(new ByteArrayInputStream(preview.pages().get(0).png())); + assertEquals(golden.getWidth(), page.getWidth()); + assertEquals(golden.getHeight(), page.getHeight()); + for(int y = 0; y < page.getHeight(); y++) + { + for(int x = 0; x < page.getWidth(); x++) + { + boolean first = hatched(x - left, y - top, 16, 12); + boolean last = hatched(x, y - top - 30, left + 20, page.getHeight() - top - 30); + assertEquals(first || last, (page.getRGB(x, y) & 0xFFFFFF) == 0, x + ", " + y); + } + } + assertEquals("COLLECT", worker.jobs.get(0).path("diagnostics").textValue()); + assertEquals(List.of(), files()); + } + + + + /*************************************************************************** + ** Whether a dot is ink in a hatched area of this size whose corner is the + ** origin of `column` and `row`. + ***************************************************************************/ + private static boolean hatched(int column, int row, int width, int height) + { + return (column >= 0 && column < width && row >= 0 && row < height && (column == 0 || row == 0 || column == width - 1 || row == height - 1 || (column + row) % 6 == 0)); + } + + + + /*************************************************************************** + ** A refusal is a result of a preview, not an exception: whatever the print + ** path would refuse by name comes back as the diagnostic, not printable, + ** with no output, and with no page when no diagnostic has a box. + ***************************************************************************/ + @Test + void aRefusalComesBackAsADiagnostic() throws Exception + { + FakeWorker invalid = answering(job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "SCENE_INVALID", FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", "/design"))); + GenerationPorts.Preview refused = FakeWorker.bridge(directory, invalid).preview(preview("anchors", null)); + assertFalse(refused.printable()); + assertEquals(List.of(new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, null, null, null, null, "/design")), refused.diagnostics()); + assertEquals(List.of(), refused.pages()); + assertEquals(0L, refused.outputBytes()); + assertEquals(NOTHING, refused.outputSha256()); + + FakeWorker fit = answering(job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "BARCODE_FIT_TOO_SMALL", null)); + var versionOne = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, FakeWorker.stock("anchors")), Map.of(), null, MAXIMUM); + assertEquals(List.of(new Diagnostic("BARCODE_FIT_TOO_SMALL", Diagnostic.Severity.ERROR, null, null, null, null)), FakeWorker.bridge(directory, fit).preview(versionOne).diagnostics()); + + /////////////////////////////////////////////////////////////////////////////////////////// + // a version 1 scene previews only at its stock's resolution; no worker is contacted // + /////////////////////////////////////////////////////////////////////////////////////////// + var elsewhere = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, versionOne.revision(), Map.of(), 300, MAXIMUM); + GenerationPorts.Preview target = FakeWorker.bridge(directory, fit).preview(elsewhere); + assertEquals(List.of(new Diagnostic("TARGET_REQUIRES_SCENE_2", Diagnostic.Severity.ERROR, null, null, Map.of("dpi", 300), Map.of("dpi", 203))), target.diagnostics()); + assertFalse(target.printable()); + assertEquals(1, fit.jobs.size()); + + /////////////////////////////////////////////////////////////////////////////////////////// + // a refused scene whose diagnostics have no box has no page // + /////////////////////////////////////////////////////////////////////////////////////////// + FakeWorker whole = answering(job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(FakeWorker.diagnostic("STOCK_MISMATCH", "ERROR", "pointer", "/design")))); + assertEquals(List.of(), FakeWorker.bridge(directory, whole).preview(preview("anchors", null)).pages()); + } + + + + /*************************************************************************** + ** An old image cannot preview: the answer is `RENDERER_TOO_OLD`, and the + ** worker was asked its version and sent nothing else, for a version 2 scene + ** and for a version 1 scene alike. + ***************************************************************************/ + @Test + void anOldRendererCannotPreviewAndIsSentNothing() throws Exception + { + FakeWorker old = new FakeWorker(FakeWorker.RELEASED, job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.RELEASED, FakeWorker.golden("anchors", 203), List.of())); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, old); + var versionOne = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, FakeWorker.stock("anchors")), Map.of("name", SECRET), + null, MAXIMUM); + for(GenerationPorts.PreviewRequest request : List.of(preview("anchors", null), preview("anchors", 300), versionOne)) + { + GenerationPorts.Preview preview = bridge.preview(request); + assertFalse(preview.printable()); + assertEquals(List.of(new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, null, null, null, null)), preview.diagnostics()); + assertEquals(List.of(), preview.pages()); + } + assertEquals(List.of(), old.jobs); + assertEquals(3, old.questions.size()); + assertFalse(old.questions.toString().contains(SECRET)); + } + + + + /*************************************************************************** + ** A page of more than sixteen million dots is refused before any worker is + ** contacted, with its size and the limit. + ***************************************************************************/ + @Test + void aPreviewTooLargeToDrawIsRefusedBeforeRendering() throws Exception + { + FakeWorker worker = answering(job -> FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT)); + var large = new GenerationPorts.Stock(203200, 304800, 0, 0, 0, 0, 203); + var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), large), Map.of(), 600, MAXIMUM); + GenerationPorts.Preview preview = FakeWorker.bridge(directory, worker).preview(request); + assertFalse(preview.printable()); + assertEquals(List.of(new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, Map.of("widthDots", 4800, "heightDots", 7200), Map.of("dots", 16_000_000))), preview.diagnostics()); + assertEquals(List.of(), preview.pages()); + assertEquals(List.of(), worker.jobs); + assertEquals(List.of(), worker.questions); + } + + + + /*************************************************************************** + ** A version 1 label previews with its graphics and boxes exact and each + ** printer font field as a placeholder: the page is not exact and says why. + ***************************************************************************/ + @Test + void aVersionOneLabelIsPreviewedWithPlaceholdersForPrinterFontText() throws Exception + { + byte[] label = BridgeTest.LABEL.getBytes(StandardCharsets.US_ASCII); + FakeWorker worker = answering(job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, label, List.of())); + var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, BridgeTest.STOCK), Map.of(), null, MAXIMUM); + GenerationPorts.Preview preview = FakeWorker.bridge(directory, worker).preview(request); + assertTrue(preview.printable()); + assertFalse(preview.exact()); + assertEquals(List.of(new Diagnostic("PRINTER_FONT_NOT_SHOWN", Diagnostic.Severity.WARNING, null, null, null, null)), preview.diagnostics()); + assertEquals(812, preview.pages().get(0).widthDots()); + assertEquals(1218, preview.pages().get(0).heightDots()); + assertEquals(RendererJson.hash(label), preview.outputSha256()); + assertFalse(worker.jobs.get(0).has("targetDpi")); + } + + + + /*************************************************************************** + ** Only a label that passed every check of the print path is drawn. An answer + ** that is not what the version rule allows, a label the output policy + ** refuses, an opaque failure and a job outside the adapter's limits are all + ** the same generic refusal, never a picture. + ***************************************************************************/ + @Test + void onlyAnAcceptedLabelBecomesAPicture() throws Exception + { + byte[] golden = FakeWorker.golden("anchors", 203); + Map error = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "box", List.of(0, 0, 10, 10)); + List answers = List.of( + job -> + { + Map partial = FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, golden, List.of(error)); + partial.put("outcome", "REFUSED"); + return (partial); + }, + job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, golden, List.of(error)), + job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, (new String(golden, StandardCharsets.US_ASCII) + "~JR").getBytes(StandardCharsets.US_ASCII), List.of()), + job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 300), List.of()), + job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "RENDER_FAILED", null), + job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "undeclared", "box", List.of(0, 0, 10, 10)))), + job -> FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT)); + for(FakeWorker.Answer answer : answers) + { + QException failure = assertThrows(QException.class, () -> FakeWorker.bridge(directory, answering(answer)).preview(preview("anchors", null))); + assertEquals(QException.class, failure.getClass()); + assertEquals("Isolated renderer rejected or could not complete the request.", failure.getMessage()); + } + /////////////////////////////////////////////////////////////////////////////////////////// + // the print path, which never collects, rejects a refused answer outright // + /////////////////////////////////////////////////////////////////////////////////////////// + IsolatedRendererBridge printing = FakeWorker.bridge(directory, answering(job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(error)))); + var render = new GenerationPorts.RenderRequest("request-1", "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, preview("anchors", null).revision(), Map.of(), MAXIMUM); + assertEquals(QException.class, assertThrows(QException.class, () -> printing.render(render)).getClass()); + + FakeWorker unused = answering(job -> FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT)); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, unused); + var revision = preview("anchors", null).revision(); + var page = new GenerationPorts.Revision(1, 1, "orders", revision.stock(), "

a page

", null, null, List.of()); + assertEquals("Preview is available for labels only.", + assertThrows(QException.class, () -> bridge.preview(new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, page, Map.of(), null, MAXIMUM))).getMessage()); + var coarse = new GenerationPorts.Stock(50800, 25400, 0, 0, 0, 0, 150); + var tall = new GenerationPorts.Stock(25400, 1600000, 0, 0, 0, 0, 203); + var negative = new GenerationPorts.Stock(50800, 25400, -1, 0, 0, 0, 203); + for(GenerationPorts.PreviewRequest outside : List.of( + new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, revision, Map.of(), null, 4 * 1024 * 1024 + 1), + new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), coarse), Map.of(), null, MAXIMUM), + new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), tall), Map.of(), 600, MAXIMUM), + new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), negative), Map.of(), null, MAXIMUM), + new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), revision.stock()), Map.of("object", Path.of("private")), null, MAXIMUM))) + { + assertEquals("Isolated renderer rejected or could not complete the request.", assertThrows(QException.class, () -> bridge.preview(outside)).getMessage()); + } + assertEquals(List.of(), unused.jobs); + assertEquals(List.of(), unused.questions); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFactsTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFactsTest.java new file mode 100644 index 0000000..378e90b --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFactsTest.java @@ -0,0 +1,177 @@ +/* + * 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.documentgeneration.renderer; + + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import com.fasterxml.jackson.databind.JsonNode; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** What the host reads from a label source (its version and the ids it + ** declares), and the version rule's two written-out sets of refusals. + *******************************************************************************/ +class SceneFactsTest +{ + private static final String SCENE = "{\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"samples\":{\"default\":{\"id\":\"sampled\",\"elements\":[{\"id\":\"nested\"}]}}," + + "\"elements\":[7,{\"id\":\"caption\",\"type\":\"text\",\"fit\":{\"id\":\"inside\"}},{\"id\":\"panel\",\"type\":\"group\",\"children\":[{\"id\":\"inner\",\"type\":\"box\"},[]]}," + + "{\"id\":7},{\"id\":\"not an id\"},{\"children\":\"none\"}],\"overrides\":[{\"when\":{\"dpi\":[203]},\"set\":{\"overridden\":{\"id\":\"renamed\"}}}],\"schema\":\"qqq-zpl-scene/2\"}"; + + + + /*************************************************************************** + ** The property names the host admits in a pointer are exactly the names the + ** version 2 schema declares, wherever in the schema they are declared. + ***************************************************************************/ + @Test + void theVocabularyIsThePropertyNamesOfTheSchema() throws Exception + { + Set declared = new TreeSet<>(); + names(RendererJson.JSON.readTree(Files.readAllBytes(Path.of("..", "..", "renderer", "schema", "zpl-scene-2.schema.json"))), declared); + assertEquals(declared, new TreeSet<>(SceneFacts.VOCABULARY)); + assertEquals(43, SceneFacts.VOCABULARY.size()); + } + + + + /*************************************************************************** + ** Every key of every `properties` object in a schema. + ***************************************************************************/ + private static void names(JsonNode node, Set names) + { + for(Map.Entry property : node.properties()) + { + if(property.getKey().equals("properties") && property.getValue().isObject()) + { + property.getValue().fieldNames().forEachRemaining(names::add); + } + names(property.getValue(), names); + } + if(node.isArray()) + { + node.forEach(item -> names(item, names)); + } + } + + + + /*************************************************************************** + ** A version 2 scene declares the ids of its elements and of their children, + ** and nothing else: not a sample's, an override's or a property's `id`, not + ** an id of the wrong shape. + ***************************************************************************/ + @Test + void aSceneDeclaresTheIdsOfItsElementsAndTheirChildrenOnly() + { + SceneFacts facts = SceneFacts.of(GenerationPorts.Format.ZPL, SCENE); + assertTrue(facts.version2()); + for(String id : List.of("caption", "panel", "inner")) + { + assertTrue(facts.declares(id), id); + } + for(String id : List.of("sampled", "nested", "inside", "overridden", "renamed", "7", "not an id", "Caption", "")) + { + assertFalse(facts.declares(id), id); + } + assertTrue(facts.points("")); + assertTrue(facts.points("/elements/2/children/0/width")); + assertTrue(facts.points("/overrides/0/set/caption/size")); + assertTrue(facts.points("/elements/9999")); + for(String pointer : List.of("elements", "/elements/sampled", "/elements/0/", "//", "/elements/10000", "/elements/0/Width", "/elements/-1")) + { + assertFalse(facts.points(pointer), pointer); + } + } + + + + /*************************************************************************** + ** Anything the host cannot read as a version 2 scene is not one to the host + ** and declares nothing: another schema, a PDF, text that is not one JSON + ** object, a repeated property, too deep a nesting, too many elements. + ***************************************************************************/ + @Test + void aSourceThatIsNotAReadableVersionTwoSceneDeclaresNothing() + { + StringBuilder crowded = new StringBuilder("{\"schema\":\"qqq-zpl-scene/2\",\"elements\":["); + for(int index = 0; index < 1001; index++) + { + crowded.append(index == 0 ? "" : ",").append("{\"id\":\"e").append(index).append("\"}"); + } + String full = crowded + "]}"; + for(String source : List.of(SCENE.replace("qqq-zpl-scene/2", "qqq-zpl-scene/1"), SCENE.replace("\"qqq-zpl-scene/2\"", "2"), "{\"elements\":[{\"id\":\"caption\"}]}", "{}", "[]", "", + "not json", SCENE + "{}", SCENE.substring(0, SCENE.length() - 1), SCENE.replace("\"schema\"", "\"design\""), "{\"schema\":\"qqq-zpl-scene/2\",\"elements\":" + "[".repeat(30) + "]".repeat(30) + "}", + full)) + { + SceneFacts facts = SceneFacts.of(GenerationPorts.Format.ZPL, source); + assertFalse(facts.version2(), source); + assertFalse(facts.declares("caption"), source); + assertFalse(facts.declares("e1"), source); + } + assertFalse(SceneFacts.of(GenerationPorts.Format.PDF, SCENE).version2()); + assertTrue(SceneFacts.of(GenerationPorts.Format.ZPL, full.replace(",{\"id\":\"e1000\"}", "")).declares("e999")); + assertTrue(SceneFacts.of(GenerationPorts.Format.ZPL, "{\"schema\":\"qqq-zpl-scene/2\"}").version2()); + } + + + + /*************************************************************************** + ** The version rule's sets are written out: the released renderer names the + ** thirteen codes it shipped with, the current one those and every scene + ** refusal. A code added to the port later is in neither until someone + ** decides which renderer may name it, and this test says so. + ***************************************************************************/ + @Test + void eachRendererNamesExactlyItsOwnRefusals() + { + List released = List.of("BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_UNSUPPORTED", "BARCODE_FIT_BOX_OUTSIDE_STOCK", "BARCODE_FIT_GEOMETRY_MISMATCH", "INVALID_BARCODE_FIT_PROFILE", + "INVALID_BARCODE_FIT_REQUEST", "BARCODE_SYMBOLOGY_NOT_ALLOWED", "SYMBOLOGY_ALLOWLIST_REQUIRED", "SCANNER_BARCODE_PROFILE_UNSUPPORTED", "SCANNER_BARCODE_PAYLOAD_UNSUPPORTED", + "SCANNER_BARCODE_CHECKSUM_REQUIRED", "SCANNER_BARCODE_CHECKSUM_MISMATCH", "UNSUPPORTED_BARCODE_OPTION"); + assertEquals(13, released.size()); + for(FailureCode code : FailureCode.values()) + { + assertEquals(released.contains(code.name()), RendererVersion.RELEASED.names(code), code.name()); + assertTrue(RendererVersion.CURRENT.names(code), code.name()); + } + assertSame(RendererVersion.RELEASED, RendererVersion.of("qqq-renderer-0.1.0")); + assertSame(RendererVersion.CURRENT, RendererVersion.of("qqq-renderer-0.2.0")); + assertFalse(RendererVersion.RELEASED.structured()); + assertTrue(RendererVersion.CURRENT.structured()); + for(String other : List.of("qqq-renderer-0.3.0", "qqq-renderer-0.2.0 ", "qqq-renderer-0.1", "0.2.0", "")) + { + assertNull(RendererVersion.of(other), other); + } + assertNull(RendererVersion.of(null)); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java new file mode 100644 index 0000000..6f64360 --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java @@ -0,0 +1,354 @@ +/* + * 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.documentgeneration.renderer; + + +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import com.fasterxml.jackson.databind.JsonNode; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode; +import com.kingsrook.qqq.backend.core.exceptions.QException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** The version rule and the render target, through the bridge: renderer 0.1.0 + ** and 0.2.0 against version 1 and version 2 scenes, what is sent to each, and + ** that no job asked for one resolution is ever answered with another. + *******************************************************************************/ +class VersionRuleTest +{ + private static final String SECRET = "a-record-value-that-must-not-travel"; + private static final byte[] VERSION_ONE_LABEL = "^XA^PW406^LL203^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII); + private static final int MAXIMUM = 1024 * 1024; + private static final int LABEL_FRAME = 4 * ((MAXIMUM + 2) / 3) + 4096; + + @TempDir + Path directory; + + + + /*************************************************************************** + ** A render job for a scene on the stock of the `anchors` case (2 x 1 in at + ** 203 dpi), with a record value in its context. + ***************************************************************************/ + private static GenerationPorts.RenderRequest job(String scene, Integer targetDpi) throws Exception + { + return (new GenerationPorts.RenderRequest("request-1", "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, + FakeWorker.revision(scene, FakeWorker.stock("anchors")), Map.of("name", SECRET), MAXIMUM, targetDpi)); + } + + + + /*************************************************************************** + ** A worker of a version that renders the right label for whatever it is + ** asked: the golden of the `anchors` case at the job's resolution for a + ** version 2 scene, the fixed label for a version 1 scene. + ***************************************************************************/ + private static FakeWorker renderer(String version) + { + return (new FakeWorker(version, job -> + { + String id = job.path("requestId").textValue(); + if(job.path("operation").textValue().equals("VALIDATE")) + { + return (FakeWorker.validated(id, version)); + } + boolean version2 = job.path("source").textValue().contains("qqq-zpl-scene/2"); + int dpi = job.has("targetDpi") ? job.path("targetDpi").intValue() : job.path("stock").path("dpi").intValue(); + return (FakeWorker.rendered(id, version, version2 ? FakeWorker.golden("anchors", dpi) : VERSION_ONE_LABEL, List.of())); + })); + } + + + + /*************************************************************************** + ** The names of the fields of a frame. + ***************************************************************************/ + private static Set names(JsonNode frame) + { + Set names = new TreeSet<>(); + frame.fieldNames().forEachRemaining(names::add); + return (names); + } + + + + /*************************************************************************** + ** Renderer 0.1.0 with a version 1 scene: printing continues exactly as + ** released. The job has the released fields and nothing else, no version + ** question is asked, and the frame bound is the released one. + ***************************************************************************/ + @Test + void theReleasedRendererStillPrintsVersionOne() throws Exception + { + FakeWorker worker = renderer(FakeWorker.RELEASED); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, null)).bytes()); + bridge.validate("actor-1", job(FakeWorker.VERSION_ONE_SCENE, null).revision()); + assertEquals(List.of(), worker.questions); + assertEquals(2, worker.jobs.size()); + assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(worker.jobs.get(0))); + assertEquals(List.of(LABEL_FRAME, 4096), worker.frameLimits); + } + + + + /*************************************************************************** + ** Renderer 0.2.0 with a version 1 scene: the same job, and the answer with + ** its (empty) diagnostics is accepted. + ***************************************************************************/ + @Test + void theCurrentRendererPrintsVersionOne() throws Exception + { + FakeWorker worker = renderer(FakeWorker.CURRENT); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, null)).bytes()); + bridge.validate("actor-1", job(FakeWorker.VERSION_ONE_SCENE, null).revision()); + assertEquals(List.of(), worker.questions); + assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(worker.jobs.get(0))); + assertEquals(List.of(LABEL_FRAME, 4096), worker.frameLimits); + } + + + + /*************************************************************************** + ** Renderer 0.1.0 with a version 2 scene: refused as `RENDERER_TOO_OLD` + ** before anything of the scene or the record is sent. All the worker ever + ** receives is the version question, which is a constant. + ***************************************************************************/ + @Test + void theReleasedRendererIsNeverSentAVersionTwoScene() throws Exception + { + FakeWorker worker = renderer(FakeWorker.RELEASED); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + String scene = FakeWorker.scene("anchors"); + var refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))); + assertEquals(FailureCode.RENDERER_TOO_OLD, refusal.code()); + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, 300))).code()); + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.validate("actor-1", job(scene, null).revision())).code()); + assertEquals(List.of(), worker.jobs); + assertEquals(3, worker.questions.size()); + for(JsonNode question : worker.questions) + { + assertEquals(IsolatedRendererBridge.PROBE_SCENE, question.path("source").textValue()); + assertEquals("VALIDATE", question.path("operation").textValue()); + assertEquals("{}", question.path("context").toString()); + assertEquals("[]", question.path("assets").toString()); + assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(question)); + assertFalse(question.toString().contains(SECRET) || question.toString().contains("qqq-zpl-scene/2") || question.toString().contains("top-left")); + } + /////////////////////////////////////////////////////////////////////////////////////////// + // version 1 printing goes on beside it, on the same adapter // + /////////////////////////////////////////////////////////////////////////////////////////// + assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, null)).bytes()); + } + + + + /*************************************************************************** + ** Renderer 0.2.0 with a version 2 scene: rendered. The version question is + ** asked once; after a verified answer of 0.2.0 it is not asked again. + ***************************************************************************/ + @Test + void theCurrentRendererPrintsVersionTwo() throws Exception + { + FakeWorker worker = renderer(FakeWorker.CURRENT); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + String scene = FakeWorker.scene("anchors"); + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + bridge.validate("actor-1", job(scene, null).revision()); + assertEquals(1, worker.questions.size()); + assertEquals(3, worker.jobs.size()); + assertEquals(scene, worker.jobs.get(0).path("source").textValue()); + assertEquals(SECRET, worker.jobs.get(0).path("context").path("name").textValue()); + assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(worker.jobs.get(0))); + /////////////////////////////////////////////////////////////////////////////////////////// + // the answers to a version 2 job have room for diagnostics, which is bounded // + /////////////////////////////////////////////////////////////////////////////////////////// + assertEquals(List.of(LABEL_FRAME + WorkerAnswer.DIAGNOSTICS_BYTES, LABEL_FRAME + WorkerAnswer.DIAGNOSTICS_BYTES, 4096 + WorkerAnswer.DIAGNOSTICS_BYTES), worker.frameLimits); + } + + + + /*************************************************************************** + ** A renderer that is replaced by an older one is noticed on its first + ** answer: that answer is refused whatever it says, and the next job asks + ** the version again and sends nothing. + ***************************************************************************/ + @Test + void aRendererReplacedByAnOlderOneIsRefused() throws Exception + { + String scene = FakeWorker.scene("anchors"); + List versions = new ArrayList<>(List.of(FakeWorker.CURRENT, FakeWorker.CURRENT, FakeWorker.RELEASED, FakeWorker.RELEASED)); + List sent = new ArrayList<>(); + IsolatedRendererBridge changing = FakeWorker.bridge(directory, (id, input, maximum) -> + { + JsonNode job = RendererJson.parse(input, 16 * 1024 * 1024); + String version = versions.remove(0); + sent.add(job.path("source").textValue().equals(IsolatedRendererBridge.PROBE_SCENE) ? "question" : "job"); + return (FakeWorker.frame(job.path("operation").textValue().equals("VALIDATE") ? FakeWorker.validated(id, version) : FakeWorker.rendered(id, version, FakeWorker.golden("anchors", 203), List.of()))); + }); + assertArrayEquals(FakeWorker.golden("anchors", 203), changing.render(job(scene, null)).bytes()); + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> changing.render(job(scene, null))).code()); + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> changing.render(job(scene, null))).code()); + assertEquals(List.of("question", "job", "job", "question"), sent); + } + + + + /*************************************************************************** + ** A renderer of a version this bridge was not built for is rejected, and so + ** is a refusal a renderer may not name; a refusal renderer 0.2.0 may name + ** arrives typed, with its diagnostic. + ***************************************************************************/ + @Test + void onlyAcceptedRenderersAndTheirOwnRefusalsAreBelieved() throws Exception + { + IsolatedRendererBridge unknown = FakeWorker.bridge(directory, renderer("qqq-renderer-0.3.0")); + assertEquals(QException.class, assertThrows(QException.class, () -> unknown.render(job(FakeWorker.VERSION_ONE_SCENE, null))).getClass()); + IsolatedRendererBridge early = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.RELEASED, job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.RELEASED, "SCENE_INVALID", null))); + assertEquals(QException.class, assertThrows(QException.class, () -> early.render(job(FakeWorker.VERSION_ONE_SCENE, null))).getClass()); + IsolatedRendererBridge current = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.CURRENT, job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "SCENE_INVALID", + FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "center", "pointer", "/elements/4/fill")))); + var refusal = assertThrows(GenerationPorts.RenderException.class, () -> current.render(job(FakeWorker.scene("anchors"), null))); + assertEquals(FailureCode.SCENE_INVALID, refusal.code()); + assertEquals(new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, "center", null, null, null, "/elements/4/fill"), refusal.diagnostic()); + assertEquals(FailureCode.SCENE_INVALID, assertThrows(GenerationPorts.RenderException.class, () -> current.validate("actor-1", job(FakeWorker.scene("anchors"), null).revision())).code()); + /////////////////////////////////////////////////////////////////////////////////////////// + // the same diagnostic about a scene that does not declare `center` is not believed // + /////////////////////////////////////////////////////////////////////////////////////////// + assertEquals(QException.class, assertThrows(QException.class, () -> current.render(job(FakeWorker.scene("rules"), null))).getClass()); + } + + + + /*************************************************************************** + ** A render target is sent with a version 2 scene, and the label that comes + ** back is held to the page at that resolution. A worker that renders at + ** another resolution than it was asked for is refused, in either direction. + ***************************************************************************/ + @Test + void aVersionTwoSceneIsRenderedAtItsTargetOrNotAtAll() throws Exception + { + FakeWorker worker = renderer(FakeWorker.CURRENT); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + String scene = FakeWorker.scene("anchors"); + assertTrue(bridge.supportsRenderTarget()); + for(int dpi : List.of(203, 300, 600)) + { + assertArrayEquals(FakeWorker.golden("anchors", dpi), bridge.render(job(scene, dpi)).bytes()); + assertEquals(dpi, worker.jobs.getLast().path("targetDpi").intValue()); + assertEquals(203, worker.jobs.getLast().path("stock").path("dpi").intValue()); + } + assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes()); + assertFalse(worker.jobs.getLast().has("targetDpi")); + + IsolatedRendererBridge ignoring = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.CURRENT, + job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 203), List.of()))); + assertArrayEquals(FakeWorker.golden("anchors", 203), ignoring.render(job(scene, null)).bytes()); + assertEquals(QException.class, assertThrows(QException.class, () -> ignoring.render(job(scene, 300))).getClass()); + IsolatedRendererBridge eager = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.CURRENT, + job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 300), List.of()))); + assertEquals(QException.class, assertThrows(QException.class, () -> eager.render(job(scene, null))).getClass()); + assertEquals(QException.class, assertThrows(QException.class, () -> eager.render(job(scene, 600))).getClass()); + } + + + + /*************************************************************************** + ** A version 1 scene and a PDF have one resolution, their stock's. Asked for + ** another they are refused by name before any worker is contacted, because + ** a renderer would ignore the target; asked for their own they are rendered + ** as they always were, with no target on the wire. + ***************************************************************************/ + @Test + void aVersionOneSceneOrAPdfIsNeverRenderedForAnotherResolution() throws Exception + { + for(String version : List.of(FakeWorker.RELEASED, FakeWorker.CURRENT)) + { + FakeWorker worker = renderer(version); + IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker); + for(int dpi : List.of(300, 600)) + { + var refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(FakeWorker.VERSION_ONE_SCENE, dpi))); + assertEquals(FailureCode.TARGET_REQUIRES_SCENE_2, refusal.code()); + assertEquals(new Diagnostic("TARGET_REQUIRES_SCENE_2", Diagnostic.Severity.ERROR, null, null, Map.of("dpi", dpi), Map.of("dpi", 203)), refusal.diagnostic()); + var zpl = job(FakeWorker.VERSION_ONE_SCENE, dpi); + var page = new GenerationPorts.Revision(1, 1, "orders", zpl.revision().stock(), "

a page

", null, null, List.of()); + var pdf = new GenerationPorts.RenderRequest(zpl.requestId(), zpl.actorId(), zpl.source(), GenerationPorts.Format.PDF, page, zpl.context(), zpl.maximumBytes(), dpi); + assertEquals(FailureCode.TARGET_REQUIRES_SCENE_2, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(pdf)).code()); + //////////////////////////////////////////////////////////////////////////////////////// + // text that is not a scene the host can read is treated the same way // + //////////////////////////////////////////////////////////////////////////////////////// + assertEquals(FailureCode.TARGET_REQUIRES_SCENE_2, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job("not a scene", dpi))).code()); + } + assertEquals(List.of(), worker.jobs); + assertEquals(List.of(), worker.questions); + assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, 203)).bytes()); + assertFalse(worker.jobs.getLast().has("targetDpi")); + } + } + + + + /*************************************************************************** + ** The frame of an answer never passes what the deployment was sized for, + ** and an answer longer than its bound is rejected. + ***************************************************************************/ + @Test + void anAnswerIsBoundedByTheDeployment() throws Exception + { + FakeWorker worker = new FakeWorker(FakeWorker.CURRENT, job -> + { + List> many = new ArrayList<>(); + for(int index = 0; index < 150; index++) + { + many.add(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "center", "measured", Map.of("index", index))); + } + Map answer = FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT); + answer.put("diagnostics", many); + return (answer); + }); + var revision = job(FakeWorker.scene("anchors"), null).revision(); + FakeWorker.bridge(directory, worker).validate("actor-1", revision); + /////////////////////////////////////////////////////////////////////////////////////////// + // a deployment that admits documents of 4096 bytes has frames of 9560 bytes at most // + /////////////////////////////////////////////////////////////////////////////////////////// + IsolatedRendererBridge small = new IsolatedRendererBridge(BridgeTest.config(directory), (actor, asset, maximum) -> new byte[0], worker); + assertEquals(QException.class, assertThrows(QException.class, () -> small.validate("actor-1", revision)).getClass()); + assertEquals(List.of(4096 + WorkerAnswer.DIAGNOSTICS_BYTES, 4 * ((4096 + 2) / 3) + 4096), worker.frameLimits); + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswerTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswerTest.java new file mode 100644 index 0000000..4d67762 --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswerTest.java @@ -0,0 +1,420 @@ +/* + * 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.documentgeneration.renderer; + + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic; +import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + + +/******************************************************************************* + ** The worker's answer is untrusted. These are the answers a compromised or + ** defective worker could send, each held to the name it is rejected by, beside + ** the answers that are accepted and what they become. + *******************************************************************************/ +class WorkerAnswerTest +{ + private static final String ID = "request-1"; + private static final String SCENE = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"samples\":{\"default\":{\"id\":\"sampled\"}}," + + "\"elements\":[{\"id\":\"caption\",\"type\":\"text\"},{\"id\":\"panel\",\"type\":\"group\",\"children\":[{\"id\":\"inner\",\"type\":\"box\"}]}]," + + "\"overrides\":[{\"when\":{\"dpi\":[203]},\"set\":{\"caption\":{\"size\":\"7pt\"}}}]}"; + private static final SceneFacts FACTS = SceneFacts.of(GenerationPorts.Format.ZPL, SCENE); + private static final byte[] LABEL = "^XA^PW812^LL203^LH0,0^CI28\n^XZ\n".getBytes(StandardCharsets.US_ASCII); + private static final int FRAME = 4 * 1024 * 1024; + + + + /*************************************************************************** + ** Read a frame for the scene above. + ***************************************************************************/ + private static WorkerAnswer read(Map fields) throws Exception + { + return (WorkerAnswer.read(FakeWorker.frame(fields), FRAME, ID, FACTS)); + } + + + + /*************************************************************************** + ** The name an answer is rejected by, when reading it or when holding it to a + ** render that collected diagnostics for a version 2 scene. + ***************************************************************************/ + private static String rejection(Map fields) + { + return (assertThrows(IllegalArgumentException.class, () -> read(fields).settle("RENDER", true, true)).getMessage()); + } + + + + /*************************************************************************** + ** A refused answer holding these diagnostics. + ***************************************************************************/ + private static Map refused(Map diagnostic) + { + return (FakeWorker.refused(ID, List.of(diagnostic))); + } + + + + /*************************************************************************** + ** A full diagnostic is read into the port's record, part for part, and a + ** refused answer gives them in the worker's order. + ***************************************************************************/ + @Test + void aDiagnosticOfTheSceneIsReadPartForPart() throws Exception + { + Map error = FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption", "pointer", "/elements/0/fit", "box", List.of(24, -3, 764, 56), + "measured", Map.of("widthDots", 902, "lines", 2), "limit", Map.of("widthDots", 764)); + Map warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "inner", "pointer", "/elements/1/children/0/width"); + Map override = FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "caption", "pointer", "/overrides/0/set/caption/size"); + Map whole = FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", ""); + WorkerAnswer answer = read(FakeWorker.refused(ID, List.of(error, warning, override, whole))).settle("RENDER", true, true); + assertTrue(answer.refused()); + assertSame(RendererVersion.CURRENT, answer.version()); + assertEquals(List.of( + new Diagnostic("TEXT_DOES_NOT_FIT", Diagnostic.Severity.ERROR, "caption", List.of(24, -3, 764, 56), Map.of("widthDots", 902, "lines", 2), Map.of("widthDots", 764), "/elements/0/fit"), + new Diagnostic("DEVICE_UNIT_USED", Diagnostic.Severity.WARNING, "inner", null, null, null, "/elements/1/children/0/width"), + new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, "caption", null, null, null, "/overrides/0/set/caption/size"), + new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, null, null, null, null, "")), answer.diagnostics()); + assertNull(answer.fields().get("base64")); + } + + + + /*************************************************************************** + ** A pointer may hold only array indexes, property names of the schema and + ** ids the scene declares; an element must be declared by the scene. An id + ** that appears only in a sample, a record-like value, a path upward or an + ** empty segment is not one of those. + ***************************************************************************/ + @Test + void aDiagnosticThatIsNotOfTheSceneTheHostSentIsRejected() + { + for(String pointer : List.of("/elements/0/secret", "/overrides/0/set/undeclared", "/elements/0/sampled", "/elements/0/ACME-12345")) + { + assertEquals("DIAGNOSTIC_NOT_OF_THIS_SCENE", rejection(refused(FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", pointer))), pointer); + } + for(String pointer : List.of("elements/0", "/elements//0", "/elements/0/", "/elements/..", "/elements/0/a b", "/elements/0/caf%", "/elements/10000", "/elements/01", "/" + "a/".repeat(16) + "a")) + { + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", pointer))), pointer); + } + for(String element : List.of("undeclared", "sampled", "Caption")) + { + assertEquals("DIAGNOSTIC_NOT_OF_THIS_SCENE", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", element))), element); + } + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "a customer's name")))); + /////////////////////////////////////////////////////////////////////////////////////////// + // a source the host cannot read as version 2 declares nothing, so nothing can be named // + /////////////////////////////////////////////////////////////////////////////////////////// + SceneFacts none = SceneFacts.of(GenerationPorts.Format.ZPL, "{}"); + assertEquals("DIAGNOSTIC_NOT_OF_THIS_SCENE", assertThrows(IllegalArgumentException.class, + () -> WorkerAnswer.read(FakeWorker.frame(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption"))), FRAME, ID, none)).getMessage()); + } + + + + /*************************************************************************** + ** A diagnostic is its closed vocabulary and bounded numbers: an unknown code + ** or severity, a sentence, a wrong shape or a number that is not a whole + ** int is rejected. + ***************************************************************************/ + @Test + void aDiagnosticOutsideItsShapeIsRejected() + { + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("RENDER_FAILED", "ERROR")))); + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_SHRUNK", "ERROR")))); + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "FATAL")))); + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(Map.of("code", "TEXT_DOES_NOT_FIT")))); + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(Map.of("severity", "ERROR")))); + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3))))); + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, -3, 4))))); + assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", Map.of("Order number", 1))))); + assertEquals("DIAGNOSTIC_BOX", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3, 4, 5))))); + assertEquals("DIAGNOSTIC_BOX", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3, 1_000_001))))); + assertEquals("DIAGNOSTIC_BOX", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(-1_000_001, 2, 3, 4))))); + assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3, "4"))))); + assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", Map.of("widthDots", 1.5))))); + assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", Map.of("widthDots", "wide"))))); + assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", Map.of("widthDots", List.of(1)))))); + assertEquals("DIAGNOSTIC_NUMBERS", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", Map.of("n" + "a".repeat(32), 1))))); + Map nine = new LinkedHashMap<>(); + for(int index = 0; index < 9; index++) + { + nine.put("n" + index, index); + } + assertEquals("DIAGNOSTIC_NUMBERS", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", nine)))); + for(Map shape : List.of(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "message", "free text"), FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", 7), + FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "pointer", List.of()), FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", "1,2,3,4"), + FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", List.of()), FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", 4), Map.of("code", "TEXT_DOES_NOT_FIT", "severity", 1))) + { + assertEquals("DIAGNOSTIC_FIELD_REJECTED", rejection(refused(shape)), shape.toString()); + } + assertEquals("RESPONSE_TEXT_LIMIT", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "a".repeat(65))))); + assertEquals("RESPONSE_TEXT_LIMIT", rejection(refused(FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", "/a".repeat(265))))); + Map list = FakeWorker.envelope(ID, FakeWorker.CURRENT, "REFUSED"); + list.put("diagnostics", List.of("TEXT_DOES_NOT_FIT")); + assertEquals("DIAGNOSTIC_OBJECT_REQUIRED", rejection(list)); + } + + + + /*************************************************************************** + ** An answer is bounded: its frame, its texts and how many diagnostics it + ** holds. + ***************************************************************************/ + @Test + void anOversizedAnswerIsRejected() throws Exception + { + List> many = new ArrayList<>(); + for(int index = 0; index < WorkerAnswer.MAXIMUM_DIAGNOSTICS; index++) + { + many.add(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "caption", "measured", Map.of("index", index))); + } + many.add(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption")); + assertEquals("DIAGNOSTIC_LIMIT", rejection(FakeWorker.refused(ID, many))); + many.remove(0); + assertEquals(WorkerAnswer.MAXIMUM_DIAGNOSTICS, read(FakeWorker.refused(ID, many)).settle("RENDER", true, true).diagnostics().size()); + byte[] frame = FakeWorker.frame(FakeWorker.refused(ID, many)); + assertEquals("RESPONSE_BYTE_LIMIT", assertThrows(IllegalArgumentException.class, () -> WorkerAnswer.read(frame, frame.length - 1, ID, FACTS)).getMessage()); + assertEquals(WorkerAnswer.MAXIMUM_DIAGNOSTICS, WorkerAnswer.read(frame, frame.length, ID, FACTS).diagnostics().size()); + Map text = FakeWorker.envelope(ID, FakeWorker.CURRENT, "OK"); + text.put("mediaType", "x".repeat(129)); + assertEquals("RESPONSE_TEXT_LIMIT", rejection(text)); + Map number = FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of()); + number.put("length", 2147483648L); + assertThrows(java.io.IOException.class, () -> read(number)); + } + + + + /*************************************************************************** + ** One frame is one line of strict JSON whose fields are the known ones. + ***************************************************************************/ + @Test + void aMalformedFrameIsRejected() throws Exception + { + String valid = new String(FakeWorker.frame(FakeWorker.validated(ID, FakeWorker.CURRENT)), StandardCharsets.UTF_8); + for(Map.Entry malformed : Map.of("", "FRAME_TRUNCATED", valid.strip(), "FRAME_TRUNCATED", valid + valid, "EXTRA_FRAME", valid.replace(",", ",\r"), "EXTRA_FRAME", + "[]\n", "RESPONSE_OBJECT_REQUIRED", valid.strip() + " {}\n", "RESPONSE_TRAILING_VALUE", valid.replace("\"diagnostics\":[]", "\"diagnostics\":{}"), "RESPONSE_FIELD_REJECTED", + valid.replace("\"diagnostics\":[]", "\"detail\":[]"), "RESPONSE_FIELD_REJECTED", valid.replace("\"diagnostics\":[]", "\"unknown\":1"), "RESPONSE_FIELD_REJECTED", + valid.replace("\"OK\"", "7"), "RESPONSE_FIELD_REJECTED").entrySet()) + { + assertEquals(malformed.getValue(), assertThrows(IllegalArgumentException.class, + () -> WorkerAnswer.read(malformed.getKey().getBytes(StandardCharsets.UTF_8), FRAME, ID, FACTS)).getMessage(), malformed.getKey()); + } + for(String broken : List.of(valid.replace("\"diagnostics\":[]", "\"outcome\":\"OK\""), valid.replace("}", ""))) + { + assertThrows(java.io.IOException.class, () -> WorkerAnswer.read(broken.getBytes(StandardCharsets.UTF_8), FRAME, ID, FACTS), broken); + } + byte[] bytes = valid.getBytes(StandardCharsets.UTF_8); + bytes[10] = (byte) 0xFF; + assertThrows(java.io.IOException.class, () -> WorkerAnswer.read(bytes, FRAME, ID, FACTS)); + } + + + + /*************************************************************************** + ** An answer is for this request, this protocol and catalog, from a renderer + ** this bridge accepts, with an outcome it knows. + ***************************************************************************/ + @Test + void anAnswerOutsideItsEnvelopeIsRejected() throws Exception + { + for(String field : List.of("protocol", "requestId", "rendererVersion", "catalogVersion", "outcome")) + { + Map changed = FakeWorker.validated(ID, FakeWorker.CURRENT); + changed.put(field, "qqq-renderer-0.3.0"); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(changed), field); + Map missing = FakeWorker.validated(ID, FakeWorker.CURRENT); + missing.remove(field); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(missing), field); + } + assertSame(RendererVersion.RELEASED, read(FakeWorker.validated(ID, FakeWorker.RELEASED)).version()); + } + + + + /*************************************************************************** + ** A refused answer carries no label. One that does, in any of its parts, is + ** rejected, so nothing downstream can find bytes of a label with elements + ** missing. An answer is refused only when diagnostics were collected and one + ** of them is an error; an accepted answer has no error. + ***************************************************************************/ + @Test + void aRefusedAnswerThatCarriesLabelBytesIsRejected() throws Exception + { + Map error = FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption"); + Map label = FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of(error)); + label.put("outcome", "REFUSED"); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(label)); + for(String part : List.of("mediaType", "base64", "sha256", "length", "pageCount")) + { + Map partial = refused(error); + partial.put(part, label.get(part)); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(partial), part); + } + assertEquals("WORKER_RESPONSE_REJECTED", rejection(FakeWorker.refused(ID, List.of()))); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(refused(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING")))); + Map bare = FakeWorker.envelope(ID, FakeWorker.CURRENT, "REFUSED"); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(bare)); + Map detailed = refused(error); + detailed.put("detail", error); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(detailed)); + /////////////////////////////////////////////////////////////////////////////////////////// + // the print path never collects, so to it a refused answer is always a rejected answer // + /////////////////////////////////////////////////////////////////////////////////////////// + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(refused(error)).settle("RENDER", false, true)).getMessage()); + assertEquals("WORKER_RESPONSE_REJECTED", rejection(FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of(error)))); + assertFalse(read(FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING")))).settle("RENDER", true, true).refused()); + } + + + + /*************************************************************************** + ** An answer has exactly the fields of its operation, outcome and renderer: + ** none missing, none added, and diagnostics only from the renderer that has + ** them. + ***************************************************************************/ + @Test + void aWrongNumberOfFieldsIsRejected() throws Exception + { + for(String version : List.of(FakeWorker.RELEASED, FakeWorker.CURRENT)) + { + assertEquals(List.of(), read(FakeWorker.rendered(ID, version, LABEL, List.of())).settle("RENDER", false, false).diagnostics()); + read(FakeWorker.validated(ID, version)).settle("VALIDATE", false, false); + for(String part : List.of("mediaType", "base64", "sha256", "length", "pageCount")) + { + Map missing = FakeWorker.rendered(ID, version, LABEL, List.of()); + missing.remove(part); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(missing).settle("RENDER", false, false)).getMessage(), part); + } + Map coded = FakeWorker.rendered(ID, version, LABEL, List.of()); + coded.put("code", "BARCODE_FIT_TOO_SMALL"); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(coded).settle("RENDER", false, false)).getMessage()); + Map label = FakeWorker.rendered(ID, version, LABEL, List.of()); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(label).settle("VALIDATE", false, false)).getMessage()); + Map none = FakeWorker.validated(ID, version); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(none).settle("RENDER", false, false)).getMessage()); + } + Map early = FakeWorker.validated(ID, FakeWorker.RELEASED); + early.put("diagnostics", List.of()); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(early).settle("VALIDATE", false, false)).getMessage()); + Map late = FakeWorker.validated(ID, FakeWorker.CURRENT); + late.remove("diagnostics"); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(late).settle("VALIDATE", false, false)).getMessage()); + Map detailed = FakeWorker.validated(ID, FakeWorker.CURRENT); + detailed.put("detail", FakeWorker.diagnostic("SCENE_INVALID", "ERROR")); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(detailed).settle("VALIDATE", false, false)).getMessage()); + } + + + + /*************************************************************************** + ** An error answer becomes the typed refusal with its diagnostic. The code + ** must be one its renderer may name, the detail must be that same error, + ** and an error answer has no other parts. + ***************************************************************************/ + @Test + void anErrorAnswerIsTheTypedRefusalOrIsRejected() throws Exception + { + Map detail = FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "caption", "pointer", "/elements/0/fit"); + var refusal = assertThrows(GenerationPorts.RenderException.class, () -> read(FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", detail)).settle("RENDER", false, true)); + assertEquals(FailureCode.SCENE_INVALID, refusal.code()); + assertEquals(new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, "caption", null, null, null, "/elements/0/fit"), refusal.diagnostic()); + var bare = assertThrows(GenerationPorts.RenderException.class, () -> read(FakeWorker.error(ID, FakeWorker.CURRENT, "STOCK_MISMATCH", null)).settle("VALIDATE", false, true)); + assertEquals(FailureCode.STOCK_MISMATCH, bare.code()); + assertNull(bare.diagnostic()); + assertEquals(FailureCode.BARCODE_FIT_TOO_SMALL, assertThrows(GenerationPorts.RenderException.class, + () -> read(FakeWorker.error(ID, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", null)).settle("RENDER", false, false)).code()); + + List> rejected = new ArrayList<>(); + rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "RENDER_FAILED", null)); + rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "A sentence about a customer.", null)); + rejected.add(FakeWorker.error(ID, FakeWorker.RELEASED, "SCENE_INVALID", null)); + rejected.add(FakeWorker.error(ID, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", FakeWorker.diagnostic("BARCODE_FIT_TOO_SMALL", "ERROR"))); + rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", FakeWorker.diagnostic("STOCK_MISMATCH", "ERROR"))); + rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING"))); + Map listed = FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", null); + listed.put("diagnostics", List.of()); + rejected.add(listed); + Map labelled = FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", null); + labelled.put("mediaType", "application/vnd.zebra-zpl"); + rejected.add(labelled); + Map uncoded = FakeWorker.envelope(ID, FakeWorker.CURRENT, "ERROR"); + rejected.add(uncoded); + Map numbered = FakeWorker.envelope(ID, FakeWorker.CURRENT, "ERROR"); + numbered.put("length", 1); + rejected.add(numbered); + for(Map answer : rejected) + { + assertEquals("WORKER_ERROR_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(answer).settle("RENDER", false, false)).getMessage(), answer.toString()); + } + } + + + + /*************************************************************************** + ** A job that needs renderer 0.2.0 was not done by an older renderer, + ** whatever its answer says: success, a refusal or an unknown error. + ***************************************************************************/ + @Test + void anOlderRendererNeverAnswersAJobThatNeedsTheNewOne() throws Exception + { + for(Map answer : List.of(FakeWorker.rendered(ID, FakeWorker.RELEASED, LABEL, List.of()), FakeWorker.validated(ID, FakeWorker.RELEASED), + FakeWorker.error(ID, FakeWorker.RELEASED, "RENDER_FAILED", null), FakeWorker.error(ID, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", null))) + { + for(boolean collect : List.of(true, false)) + { + assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> read(answer).settle("RENDER", collect, true)).code()); + } + } + } + + + + /*************************************************************************** + ** A refused answer may name only the errors its renderer may name. Every + ** error code is one renderer 0.2.0 may name today, so the rule is shown on + ** the released renderer, which has no refused answer at all. + ***************************************************************************/ + @Test + void aRefusedAnswerIsOnlyEverFromTheRendererThatCollects() throws Exception + { + Map old = FakeWorker.envelope(ID, FakeWorker.RELEASED, "REFUSED"); + assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(old).settle("RENDER", true, false)).getMessage()); + for(FailureCode code : FailureCode.values()) + { + assertTrue(read(refused(FakeWorker.diagnostic(code.name(), "ERROR"))).settle("RENDER", true, true).refused(), code.name()); + } + } +} diff --git a/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java new file mode 100644 index 0000000..599c677 --- /dev/null +++ b/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java @@ -0,0 +1,355 @@ +/* + * 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.documentgeneration.renderer; + + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import com.fasterxml.jackson.databind.JsonNode; +import com.kingsrook.qbits.documentgeneration.GenerationPorts; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +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; + + +/******************************************************************************* + ** The page decoded from label bytes is the page that was drawn: every golden + ** label of the renderer's version 2 cases decodes, dot for dot, to the golden + ** picture of the canvas it was emitted from, at 203, 300 and 600 dpi. Every + ** released version 1 label decodes too. A label that is not exactly the + ** policy's grammar, or is too large to draw, is refused by name. + *******************************************************************************/ +class ZplRasterTest +{ + private static final Path CORPUS = Path.of("..", "..", "renderer", "src", "test", "resources", "compat-v1"); + private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(25400, 12700, 0, 0, 0, 0, 203); + private static final String HEADER = "^XA^PW203^LL102^LH0,0^CI28\n"; + + + + /*************************************************************************** + ** The stored version 2 labels, named `-.zpl`. + ***************************************************************************/ + static List goldens() throws Exception + { + return (ZplSceneTwoPolicyTest.labels()); + } + + + + /*************************************************************************** + ** The stored version 1 labels, by case name. + ***************************************************************************/ + static List released() throws Exception + { + try(Stream files = Files.list(CORPUS.resolve("zpl"))) + { + List names = files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".zpl")).map(name -> name.substring(0, name.length() - 4)).sorted().toList(); + assertEquals(42, names.size()); + return (names); + } + } + + + + /*************************************************************************** + ** A small label on the one-inch stock of these tests. + ***************************************************************************/ + private static byte[] label(String fields) + { + return ((HEADER + fields + "^XZ\n").getBytes(StandardCharsets.US_ASCII)); + } + + + + /*************************************************************************** + ** The name a label is refused by. + ***************************************************************************/ + private static String refusal(String label) + { + return (assertThrows(IllegalArgumentException.class, () -> ZplRaster.decode(label.getBytes(StandardCharsets.US_ASCII))).getMessage()); + } + + + + /*************************************************************************** + ** A PNG read back with the platform's own decoder, never this class's. + ***************************************************************************/ + private static BufferedImage picture(byte[] png) throws Exception + { + return (ImageIO.read(new ByteArrayInputStream(png))); + } + + + + /*************************************************************************** + ** The round trip: the picture decoded from the emitted label equals the + ** golden picture of the canvas that was emitted, and the PNG this class + ** writes holds exactly those dots. + ***************************************************************************/ + @ParameterizedTest(name = "{0}") + @MethodSource("goldens") + void aVersionTwoLabelDecodesToTheCanvasThatWasEmitted(String label) throws Exception + { + String name = label.substring(0, label.length() - 8); + int dpi = Integer.parseInt(label.substring(label.length() - 7, label.length() - 4)); + GenerationPorts.Stock stock = FakeWorker.stock(name); + GenerationPorts.Stock printed = new GenerationPorts.Stock(stock.widthMicrons(), stock.heightMicrons(), stock.topMicrons(), stock.rightMicrons(), stock.bottomMicrons(), stock.leftMicrons(), dpi); + ZplRaster page = ZplRaster.page(FakeWorker.golden(name, dpi), printed); + BufferedImage golden = ImageIO.read(FakeWorker.SCENES.resolve("golden").resolve(name + "-" + dpi + ".png").toFile()); + BufferedImage written = picture(page.png()); + assertTrue(page.exact()); + assertEquals(golden.getWidth(), page.width()); + assertEquals(golden.getHeight(), page.height()); + assertEquals(golden.getWidth(), written.getWidth()); + assertEquals(golden.getHeight(), written.getHeight()); + int inked = 0; + for(int y = 0; y < page.height(); y++) + { + for(int x = 0; x < page.width(); x++) + { + boolean ink = (golden.getRGB(x, y) & 0xFFFFFF) == 0; + inked += ink ? 1 : 0; + if(page.ink(x, y) != ink || ((written.getRGB(x, y) & 0xFFFFFF) == 0) != ink) + { + throw new AssertionError(label + " differs from its golden picture at dot " + x + ", " + y); + } + } + } + assertTrue(inked > 0, label); + } + + + + /*************************************************************************** + ** Every released version 1 label decodes for the stock it was rendered for. + ** It is exact unless it holds printer font text, and its PNG is its page. + ***************************************************************************/ + @ParameterizedTest(name = "{0}") + @MethodSource("released") + void aReleasedVersionOneLabelDecodes(String name) throws Exception + { + byte[] bytes = Files.readAllBytes(CORPUS.resolve("zpl").resolve(name + ".zpl")); + JsonNode stock = RendererJson.JSON.readTree(Files.readAllBytes(CORPUS.resolve("cases").resolve(name + ".json"))).path("stock"); + ZplRaster page = ZplRaster.page(bytes, new GenerationPorts.Stock(stock.path("widthMicrons").intValue(), stock.path("heightMicrons").intValue(), stock.path("topMicrons").intValue(), + stock.path("rightMicrons").intValue(), stock.path("bottomMicrons").intValue(), stock.path("leftMicrons").intValue(), stock.path("dpi").intValue())); + String text = new String(bytes, StandardCharsets.US_ASCII); + assertEquals(!text.contains("^A0N,"), page.exact(), name); + BufferedImage written = picture(page.png()); + assertEquals(page.width(), written.getWidth()); + assertEquals(page.height(), written.getHeight()); + int inked = 0; + for(int y = 0; y < page.height(); y++) + { + for(int x = 0; x < page.width(); x++) + { + inked += page.ink(x, y) ? 1 : 0; + assertEquals(page.ink(x, y), (written.getRGB(x, y) & 0xFFFFFF) == 0); + } + } + assertTrue(inked > 0, name); + } + + + + /*************************************************************************** + ** A graphic field is its bits, most significant first, row after row; a box + ** is a border inside its size, solid when the border meets itself; fields + ** that overlap combine as ink. + ***************************************************************************/ + @Test + void graphicsAndBoxesArePaintedExactly() + { + ZplRaster page = ZplRaster.decode(label("^FO8,2^GFA,4,4,2,A0018001^FS\n^FO20,10^GB6,5,1^FS\n^FO40,10^GB6,5,3^FS\n^FO9,2^GB1,2,1^FS\n")); + assertTrue(page.exact()); + assertEquals(203, page.width()); + assertEquals(102, page.height()); + Map> expected = Map.of(2, List.of(8, 9, 10, 23), 3, List.of(8, 9, 23)); + for(int y = 0; y < 9; y++) + { + for(int x = 0; x < 32; x++) + { + assertEquals(expected.getOrDefault(y, List.of()).contains(x), page.ink(x, y), x + ", " + y); + } + } + for(int y = 9; y < 16; y++) + { + for(int x = 19; x < 47; x++) + { + boolean outline = x >= 20 && x < 26 && y >= 10 && y < 15 && (x == 20 || x == 25 || y == 10 || y == 14); + boolean solid = x >= 40 && x < 46 && y >= 10 && y < 15; + assertEquals(outline || solid, page.ink(x, y), x + ", " + y); + } + } + } + + + + /*************************************************************************** + ** Printer font text cannot be drawn on the host. Its area (one cell for each + ** character, counted as characters and not as bytes) is outlined and + ** hatched, and the page says it is not exact. + ***************************************************************************/ + @Test + void printerFontTextIsAHatchedPlaceholderAndThePageIsNotExact() + { + ZplRaster page = ZplRaster.decode(label("^FO10,20^A0N,12,7^FH_^FD_41_C3_A9_E2_82_AC^FS\n")); + assertFalse(page.exact()); + for(int y = 18; y < 34; y++) + { + for(int x = 8; x < 34; x++) + { + int column = x - 10; + int row = y - 20; + boolean inside = column >= 0 && column < 21 && row >= 0 && row < 12; + assertEquals(inside && (column == 0 || row == 0 || column == 20 || row == 11 || (column + row) % 6 == 0), page.ink(x, y), x + ", " + y); + } + } + } + + + + /*************************************************************************** + ** Strips whose lengths, digits or places are wrong, and anything that is not + ** the next byte of the grammar, are refused: nothing is skipped or guessed. + ***************************************************************************/ + @Test + void malformedStripsAreRefused() + { + String strip = "^FO8,2^GFA,4,4,2,A0018001^FS\n"; + assertEquals(4, ZplRaster.decode(label(strip)).width() - 199); + for(Map.Entry malformed : Map.ofEntries( + Map.entry(strip.replace("A0018001", "A00180"), "RASTER_HEX"), Map.entry(strip.replace("A0018001", "A001800100"), "RASTER_GRAMMAR"), + Map.entry(strip.replace("A0018001", "a0018001"), "RASTER_HEX"), Map.entry(strip.replace("A0018001", "A001800G"), "RASTER_HEX"), + Map.entry(strip.replace("A0018001", "A001800"), "RASTER_HEX"), Map.entry(strip.replace(",4,4,2,", ",4,5,2,"), "RASTER_FIELD"), + Map.entry(strip.replace(",4,4,2,", ",3,3,2,"), "RASTER_FIELD"), Map.entry(strip.replace(",4,4,2,", ",0,0,1,"), "RASTER_FIELD"), + Map.entry(strip.replace(",4,4,2,", ",4,4,0,"), "RASTER_FIELD"), Map.entry(strip.replace(",4,4,2,", ",100000,100000,2,"), "RASTER_FIELD"), + Map.entry("^FO0,0^GFA,1251,1251,1251," + "00".repeat(1251) + "^FS\n", "RASTER_FIELD"), Map.entry(strip.replace("^FO8,2", "^FO8,101"), "RASTER_FIELD"), + Map.entry(strip.replace("^FO8,2", "^FO203,2"), "RASTER_FIELD"), Map.entry(strip.replace("^FO8,2", "^FO196,2"), "RASTER_FIELD"), + Map.entry(strip.replace("^FO8,2", "^FO999999,2"), "RASTER_FIELD"), Map.entry(strip.replace("^FO8,2", "^FO08,2"), "RASTER_NUMBER"), + Map.entry(strip.replace("^FO8,2", "^FO1000000,2"), "RASTER_NUMBER"), Map.entry(strip.replace("^FO8,2", "^FO,2"), "RASTER_NUMBER"), + Map.entry(strip.replace("^FO8,2", "^FO-8,2"), "RASTER_NUMBER"), Map.entry(strip.replace("^FS\n", "^FS"), "RASTER_GRAMMAR"), + Map.entry(strip.replace("^GFA", "^GFB"), "RASTER_GRAMMAR"), Map.entry(strip.replace("^GFA,4,4,2,A0018001", "^XGR:LOGO.GRF,1,1"), "RASTER_GRAMMAR"), + Map.entry(strip + "~JR", "RASTER_GRAMMAR"), Map.entry("^FO20,10^GB6,5,6^FS\n", "RASTER_FIELD"), Map.entry("^FO20,10^GB6,5,0^FS\n", "RASTER_FIELD"), + Map.entry("^FO200,10^GB6,5,1^FS\n", "RASTER_FIELD"), Map.entry("^FO20,100^GB6,5,1^FS\n", "RASTER_FIELD"), Map.entry("^FO20,10^GB0,5,1^FS\n", "RASTER_FIELD"), + Map.entry("^FO20,10^GB6,5^FS\n", "RASTER_GRAMMAR"), Map.entry("^FO10,20^A0N,12,7^FH_^FD^FS\n", "RASTER_FIELD"), Map.entry("^FO10,20^A0N,12,7^FH_^FD_4^FS\n", "RASTER_HEX"), + Map.entry("^FO10,20^A0N,12,70^FH_^FD_41_42_43^FS\n", "RASTER_FIELD"), Map.entry("^FO10,95^A0N,12,7^FH_^FD_41^FS\n", "RASTER_FIELD"), + Map.entry("^FO10,20^A0N,12,7^FDplain^FS\n", "RASTER_GRAMMAR"), Map.entry("^FO10,20^A0R,12,7^FH_^FD_41^FS\n", "RASTER_GRAMMAR")).entrySet()) + { + assertEquals(malformed.getValue(), refusal(HEADER + malformed.getKey() + "^XZ\n"), malformed.getKey()); + } + String valid = HEADER + strip + "^XZ\n"; + for(Map.Entry malformed : Map.of(valid + valid, "RASTER_GRAMMAR", valid + " ", "RASTER_GRAMMAR", valid.replace("^XZ\n", "^XZ"), "RASTER_GRAMMAR", "", "RASTER_GRAMMAR", + valid.replace("^XA", "^XA^MMT"), "RASTER_GRAMMAR", valid.replace("^LH0,0", "^LH1,0"), "RASTER_GRAMMAR", valid.replace("^PW203", "^PW"), "RASTER_NUMBER", + valid.substring(0, 48), "RASTER_HEX", valid.substring(0, 10), "RASTER_GRAMMAR").entrySet()) + { + assertEquals(malformed.getValue(), refusal(malformed.getKey()), malformed.getKey()); + } + assertEquals("RASTER_GRAMMAR", assertThrows(IllegalArgumentException.class, () -> ZplRaster.decode(null)).getMessage()); + } + + + + /*************************************************************************** + ** A preview draws at most sixteen million dots, and only a page a printer + ** can address. + ***************************************************************************/ + @Test + void aPageTooLargeToDrawIsRefused() + { + assertEquals(4000, ZplRaster.decode("^XA^PW4000^LL4000^LH0,0^CI28\n^XZ\n".getBytes(StandardCharsets.US_ASCII)).height()); + assertEquals("PREVIEW_TOO_LARGE", refusal("^XA^PW4000^LL4001^LH0,0^CI28\n^XZ\n")); + assertEquals("PREVIEW_TOO_LARGE", refusal("^XA^PW9999^LL32000^LH0,0^CI28\n^XZ\n")); + for(String page : List.of("^XA^PW0^LL100^LH0,0^CI28\n^XZ\n", "^XA^PW100^LL0^LH0,0^CI28\n^XZ\n", "^XA^PW10000^LL1^LH0,0^CI28\n^XZ\n", "^XA^PW1^LL32001^LH0,0^CI28\n^XZ\n")) + { + assertEquals("RASTER_PAGE", refusal(page), page); + } + } + + + + /*************************************************************************** + ** Only bytes the output policy accepts are drawn: the policy runs first, and + ** a label it refuses is refused with the policy's own name. + ***************************************************************************/ + @Test + void onlyALabelTheOutputPolicyAcceptsIsDrawn() throws Exception + { + byte[] accepted = label("^FO8,2^GFA,4,4,2,A0018001^FS\n"); + assertTrue(ZplRaster.page(accepted, STOCK).ink(8, 2)); + GenerationPorts.Stock other = new GenerationPorts.Stock(25400, 12700, 0, 0, 0, 0, 300); + assertEquals("ZPL_STOCK_MISMATCH", assertThrows(IllegalArgumentException.class, () -> ZplRaster.page(accepted, other)).getMessage()); + /////////////////////////////////////////////////////////////////////////////////////////// + // whole white bytes past the page edge draw nothing, but the policy does not allow them // + /////////////////////////////////////////////////////////////////////////////////////////// + byte[] padded = label("^FO196,2^GFA,2,2,2,8000^FS\n"); + assertTrue(ZplRaster.decode(padded).ink(196, 2)); + assertEquals("ZPL_GRAPHIC_OUTSIDE_STOCK", assertThrows(IllegalArgumentException.class, () -> ZplRaster.page(padded, STOCK)).getMessage()); + } + + + + /*************************************************************************** + ** The page of a refused scene shows only where its refused elements are: + ** each diagnostic box, moved from the printable area to the page, cut to the + ** page and hatched. It is never exact, and a box off the page draws nothing. + ***************************************************************************/ + @Test + void theRefusedPageIsDrawnFromBoxesAlone() + { + ZplRaster page = ZplRaster.refused(60, 40, 10, 5, List.of(List.of(0, 0, 13, 7), List.of(45, 30, 20, 20), List.of(-20, -20, 22, 23), List.of(100, 0, 5, 5), List.of(0, -50, 5, 5), + List.of(20, 20, 0, 4))); + assertFalse(page.exact()); + for(int y = 0; y < 40; y++) + { + for(int x = 0; x < 60; x++) + { + boolean first = hatched(x - 10, y - 5, 13, 7); + boolean cut = hatched(x - 55, y - 35, 5, 5); + boolean corner = hatched(x, y, 12, 8); + assertEquals(first || cut || corner, page.ink(x, y), x + ", " + y); + } + } + assertEquals("PREVIEW_TOO_LARGE", assertThrows(IllegalArgumentException.class, () -> ZplRaster.refused(4000, 4001, 0, 0, List.of())).getMessage()); + } + + + + /*************************************************************************** + ** Whether a dot is ink in a hatched area of this size whose corner is the + ** origin of `column` and `row`. + ***************************************************************************/ + private static boolean hatched(int column, int row, int width, int height) + { + return (column >= 0 && column < width && row >= 0 && row < height && (column == 0 || row == 0 || column == width - 1 || row == height - 1 || (column + row) % 6 == 0)); + } +} diff --git a/docs/EXPECTED-PRINT-INTENT.md b/docs/EXPECTED-PRINT-INTENT.md index 8877431..5de94d6 100644 --- a/docs/EXPECTED-PRINT-INTENT.md +++ b/docs/EXPECTED-PRINT-INTENT.md @@ -102,6 +102,37 @@ test uses an explicitly capable fixture; the actual legacy Printing bridge is separately tested to refuse selected media. No real-owner selected-media support, new renderer image, device observation or physical output is claimed by this gate. +## Render target: 2026-11-12 + +`DocumentGenerationService.Request` gains a trailing `Integer targetDpi`: the resolution a label is rendered at. +It is absent (the stock's own resolution, as before) or 203, 300 or 600; any other value throws +`IllegalArgumentException("A render target is 203, 300 or 600 dpi.")` when the request is built. Every earlier +constructor stays and means no target. `GenerationPorts.RenderRequest.targetDpi` carries it to the renderer. + +- **Persistence.** One nullable `target_dpi` column on `document_generation_request`. A host applies exactly + one statement before adopting this version, from the shipped script for its database: + `20261112-render-target-dpi-h2.sql`, `20261112-render-target-dpi-mysql.sql` or + `20261112-render-target-dpi-postgres.sql` + (`ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER NULL`). Earlier rows stay null and + read as no target. Nothing else in the schema changes; the bootstrap scripts of `DocumentGenerationSchema` + include the column for a new database. +- **Idempotent retry and lookup.** The stored target is compared with the request's in the same check as + destination, media and copies, so the same request key with another target, or with none, is refused as a + different generation intent, for `generate` and `lookupAcceptedPrint`. +- **Digest.** A request with no target (including every earlier row) keeps the exact earlier intent digest; a + stated target adds a framed `target-dpi/v1` domain after `copies/v1`, also when it equals the stock's + resolution. +- **Refusals before any row, render or submit.** A target for a PDF is refused ("A render target applies to + ZPL output only."). A target is refused with `RENDER_TARGET_UNSUPPORTED` unless the configured renderer + declares `IsolatedRenderer.supportsRenderTarget()`, which defaults to false: an adapter that does not read + the field would print at another resolution than was asked for. The renderer bridge declares it. +- **Refusals by the renderer.** The bridge refuses a version 1 scene asked for another resolution than its + stock's as `TARGET_REQUIRES_SCENE_2`, and a version 2 scene on an old image as `RENDERER_TOO_OLD`. Both are + typed: the request is `INCOMPLETE` with that `errorCode` and a retry throws it again without rendering. +- **Not changed.** Reprints reuse retained bytes and carry no target. Queued and batch generation render at the + stock's resolution. The draft preview that retains its output (`preview(Request, fingerprint)`) refuses a + target. The request status process shows the stored `targetDpi`, and the generate process form offers it. + ## Copies per print: source candidate, 2026-11-02 An owner-handoff source candidate, not a deployed or public API release. Nothing here diff --git a/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md b/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md index 96325a9..0173744 100644 --- a/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md +++ b/docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md @@ -161,6 +161,26 @@ accepted. Lengths convert to integer micrometres first (the unit the stock alrea - Later round: `design.stock: "atLeast"` lets one design print on a larger stock, with anchors and percentages resolving against the actual printable area. The first round supports `exact` only. +**Decided while building the render target (P7).** + +- **Nobody asked for 300 dpi silently gets another resolution.** Three places hold that, each before the next + could let it slip. Generation refuses a target for a PDF and refuses any target when the configured renderer + does not declare `IsolatedRenderer.supportsRenderTarget()` (a new default method that answers false, so a host + adapter written before the target existed cannot be handed one and ignore it); both refusals come before a + request row exists. The renderer bridge refuses a version 1 scene (or a source it cannot read as version 2) + asked for another resolution than its stock's as `TARGET_REQUIRES_SCENE_2` before any worker is contacted, + because renderer 0.1.0 does not read the field at all; the host reads only the scene's `schema` for this. And + the label that comes back is held by the output policy to the page at the target, so a worker that rendered + at another resolution is refused. +- **A version 1 scene may be asked for its own stock's resolution.** That is what it prints at, so the target + is not sent and nothing changes on the wire. +- **The digest frames a target whenever one is stated**, also when it equals the stock's resolution. A request + with no target has exactly the digest it had before (`target-dpi/v1` is added after `copies/v1`), so stored + digests and retries of earlier rows are unchanged. The same request key with another target, or with none, is + another intent and is refused. +- **Batches and the queue have no target.** They render at the stock's resolution, as reprints reuse retained + bytes. The option is on the single generate request and its process form. + ### 2.4 Anchors and alignment Two separate ideas, both available on every element. @@ -451,9 +471,35 @@ the preview cannot differ from the print for a version 2 scene. each `^A0N` field is shown as a hatched placeholder of its estimated size, the result carries `exact: false` and the warning `PRINTER_FONT_NOT_SHOWN`. A version 1 scene previews only at its stock's dpi. - **Refusals in preview.** The request asks the renderer to collect diagnostics instead of stopping at the first - failure. Elements that failed are left out of the bitmap and reported with their boxes, and the result says - `printable: false`. When there is no error, the ZPL is byte for byte what printing produces; a test asserts the - hashes are equal. + failure. Elements that failed are reported with their boxes, and the result says `printable: false`. When + there is no error, the ZPL is byte for byte what printing produces; a test asserts the hashes are equal. + +**Decided while building the bridge (P5).** + +- **A refused scene is drawn on the host from the diagnostics' boxes, and from nothing else.** The worker's + refused answer carries no label (section 8.1), and it stays that way: the wire gains no field for the partial + canvas. The preview of a refused scene is a page of the stock's size on which each error diagnostic's box is + outlined and hatched at its place (the box is in dots from the printable area's corner, which the host places + by the rule of section 2.3), cut to the page. Elements that had no error are not shown. The other option, a + separately named field holding the partial canvas, would have shown them, but it puts a printable label with + elements missing on the host, one careless reader away from a printer; and it would make the host draw bytes + that the output policy never accepted as the label. With boxes alone there are no such bytes anywhere outside + the worker, so no host can print a partial label, whatever it does. The cost is a plainer picture for a + layout that cannot print, beside diagnostics that already name each element, its box and its numbers. +- **What a refused preview holds.** `printable` and `exact` are false, `outputBytes` is zero and `outputSha256` + is the hash of no bytes. There is one page when at least one error has a box and none otherwise (a refusal + about the scene as a whole has nothing to draw, and an empty page would look like an empty label). +- **A refusal is a result, not an exception.** Whatever the print path refuses by name comes back from + `preview` as the diagnostic: a collected `REFUSED` answer, an error answer with its `detail`, a fit refusal of + a version 1 scene, `TARGET_REQUIRES_SCENE_2`, `RENDERER_TOO_OLD` and `PREVIEW_TOO_LARGE` (decided from the + stock and the target before any worker is contacted). An opaque failure stays the generic exception. +- **`ZplRaster` is its own strict reader.** It is handed bytes the policy accepted (its entry point runs the + policy itself), and still refuses by name anything that is not the next byte of that grammar. It paints + `^GFA` and `^GB` exactly and hatches the estimated area of each `^A0N` field, as above. +- **Page (PDF) previews are not in this package**; `preview` refuses a PDF by name until P11. +- **Nothing of a preview is kept by the bridge**: no file, no row, no log line. The production Docker transport + journals every worker run it starts (container and request identity only, never source or context); a + preview's run is journalled like a validation's. ### 6.2 Data for a preview @@ -612,6 +658,7 @@ Version 1 scenes keep their existing codes and the opaque `RENDER_FAILED`. of section B.1 and the source of the picture in section 6.1 ("Refusals in preview"): the partial canvas exists in the worker (`Scene2.compose`) but does not travel as a label. P5 decides how a refused preview is drawn: from the diagnostics' boxes alone, or from a separately named field that the print path never reads. + (Decided in P5: from the boxes alone; section 6.1.) - **A length too long for any stock** is `SCENE_INVALID` at its pointer however long it is. The limit of 2 m is compared before the micrometres are narrowed to an `int`, so `84600in` (which does not fit one) is refused like `79in` and never as an arithmetic failure, which the worker would answer as the opaque `RENDER_FAILED`. @@ -623,6 +670,21 @@ Version 1 scenes keep their existing codes and the opaque `RENDER_FAILED`. so no layered design is refused, and the worst scene falls from 1000 fills of a 64 million dot canvas (8 GB of byte writes, seconds of CPU) to sixteen (128 MB). +**Decided while building the bridge (P5).** + +- **The host believes a diagnostic only when it is of the scene the host sent.** The worker's answer is + untrusted, so the bridge checks every field again: the code and severity against the closed vocabulary, the + numbers as whole integers within bounds, the element against the ids that scene declares, and each segment of + the pointer against array indexes, the property names of the version 2 schema and those ids. For this the + host reads two facts from the source it is about to send and nothing more: its `schema`, and the `id` of + each element and child. It lays out and validates nothing. A source the host cannot read is not version 2 + to the host and declares nothing, so any disagreement with the worker fails closed. +- **An answer is bounded.** Beside its label an answer may hold 2048 diagnostics in one megabyte, and its frame + never passes the frame of the largest document the deployment admits, which is what the transport was sized + for. A scene that produces more than that is rejected rather than read. +- **A refused answer that carries any part of a label is rejected**, as is a refused answer to a job that did + not ask for collected diagnostics (the print path never does) and an accepted answer that holds an error. + ### 8.2 A document that is not offered for a printer Whether a document can print on a printer has three owners, and the message is assembled from them: @@ -765,6 +827,24 @@ change: - Renderer `0.2.0` is not accepted by bridges before `1.4`. - Therefore: upgrade the host library first, then the image. Never the reverse. +**Decided while building the bridge (P5).** The rule is one table in the code (`RendererVersion`): + +| Renderer | Answers | Refusals it may name | Version 2 scene, render target, preview | +|---|---|---|---| +| `0.1.0` | the released fields only | the 13 released codes | refused: `RENDERER_TOO_OLD` | +| `0.2.0` | the same, with `diagnostics` (and `detail` on an error) | those and the 15 scene codes | rendered | +| any other | rejected | | | + +- **A job is framed as renderer 0.1.0 reads it unless it needs more**, so version 1 printing is byte for byte + the same request against either image. +- **The bridge asks before it sends.** No configuration tells the bridge which image answers, so before a job + that only 0.2.0 can do it validates a fixed, empty version 1 scene on a fixed stock, which every released + renderer answers with its version. An old image is therefore refused with `RENDERER_TOO_OLD` and receives + nothing of the version 2 scene or the record. After a verified answer of 0.2.0 the question is not repeated; + an answer from 0.1.0 to such a job is refused whatever it says, and the next job asks again. +- **Each set of refusals is written out**, not taken as a range of the enum, so a code added later belongs to + no renderer until someone decides which. + ### 11.4 What Forge pins, adds and deletes Pins and additions (first slice): @@ -861,7 +941,8 @@ agent work including review. - `RENDER` and `VALIDATE` accept optional `"targetDpi"` and `"diagnostics": "COLLECT"`. - A successful answer adds `"diagnostics": [ ... ]` (section 8.1). With `COLLECT` and at least one error, the outcome is `"REFUSED"`, with `diagnostics` and no output (decided in the review of P1, section 8.1: a refused - answer carries no label bytes). + answer carries no label bytes). Decided in P5 (section 6.1): the wire gains no field for the partial canvas; + the host draws a refused preview from the diagnostics' boxes. - An error answer adds `"detail": { ... }`, one diagnostic. - New operations, each routed by `RendererMain` to one class: `INSPECT_ASSET` (`AssetInspector`), `UPGRADE_SCENE` (`scene2.upgrade.SceneUpgrade`). P1 creates both as classes that refuse with `UNSUPPORTED_OPERATION`. @@ -877,7 +958,8 @@ agent work including review. **Foundation ports (owned by P6a).** `GenerationPorts.Diagnostic`, `PreviewRequest`, `PreviewPage`, `Preview`, the new `FailureCode` values, `RenderException(FailureCode, Diagnostic)`, `IsolatedRenderer.preview` as a refusing -default, `Integer targetDpi` on `RenderRequest`. P6a also adds the registration calls for +default, `Integer targetDpi` on `RenderRequest`. P7 adds `IsolatedRenderer.supportsRenderTarget()`, false by +default (section 2.3). P6a also adds the registration calls for `GenerationPreviewProcesses`, `GenerationAssetProcesses`, `GenerationUpgradeProcesses` and `GenerationAssetTables` in the producer, and the call to `GenerationPreviewWidget.section(table)` in `GenerationTables`, each an empty class or no-op, so later packages fill only their own file. diff --git a/docs/security/fuzzing.md b/docs/security/fuzzing.md index 225c233..557d18b 100644 --- a/docs/security/fuzzing.md +++ b/docs/security/fuzzing.md @@ -10,6 +10,8 @@ and the isolated renderer is treated as compromisable, so its output is untruste | Version 1 scene parser, `ZplRenderer.validate` | `renderer` | A `qqq-zpl-scene/1` source | `MutationCampaignTest.theVersionOneSceneParserAcceptsOnlyVersionOneScenes` | | Version 2 scene parser, canvas and emitter behind the worker's routing, `RendererMain.process` with `VALIDATE` and, for about one input in four, `RENDER` | `renderer` | A `qqq-zpl-scene/2` source | `MutationCampaignTest.theVersionTwoSceneParserAcceptsOnlyVersionTwoScenes` | | Host printer gate, `ZplOutputPolicy.validate` | `bridges/renderer` | ZPL bytes returned by the worker | `MutationCampaignTest.theHostPrinterGateAcceptsOnlyTheRendererCommandSet` | +| Page decoder for previews, `ZplRaster.decode` | `bridges/renderer` | ZPL bytes returned by the worker | `MutationCampaignTest.thePageDecoderDrawsExactlyWhatThePrinterGateAccepts` | +| Answer reader, `WorkerAnswer.read` and `settle` | `bridges/renderer` | The worker's answer frame, with its diagnostics | `MutationCampaignTest.theAnswerReaderBelievesOnlyDiagnosticsOfTheSceneThatWasSent` | The harness is `MutationCampaign`, one copy in each module's test tree (the modules share no test code). It mutates valid inputs from the version 1 compatibility corpus (`renderer/src/test/resources/compat-v1`) and the @@ -42,6 +44,19 @@ crashes: A refusal is the gate's `IllegalArgumentException`, or the strict decoder's `CharacterCodingException` for field text that is not UTF-8. Any other failure, or a failed acceptance check, is a finding. +- **Page decoder.** The same mutated labels go to the gate and to the decoder. A label the gate accepts must + decode, and for a label without printer font text the page must equal, dot for dot, a second drawing of its + graphic fields and boxes made by a regular expression and none of the decoder's code. A label the gate refuses + (which production never hands the decoder) must be drawn within the dot limit or refused with the decoder's + own `IllegalArgumentException`. Any other failure is a finding. +- **Answer reader.** The corpus is answer frames of both accepted renderers for a version 2 scene: a label with + a warning, a refused answer, an error with its detail. An accepted input, read again by a plain JSON reader, + is an answer of renderer 0.2.0 that is a whole label with no error or a refusal with no part of a label, and + each of its diagnostics has only the known fields, names an element the scene declares and points only + through array indexes, schema property names and declared ids. A typed refusal's diagnostic is held to the + same rule. A rejection is the reader's `IllegalArgumentException` or the JSON reader's `IOException`; any + other failure is a finding. + Each campaign also asserts that it reached both acceptance and refusal, so a campaign that only ever refuses cannot pass. @@ -77,7 +92,7 @@ sent to a printer. Generated version 2 scenes are rendered in memory on a 2 x 1 A new parser of untrusted input adds one test method to the `MutationCampaignTest` of its module, with a corpus of valid inputs, a dictionary of the format's tokens, the refusal it declares and an independent check -of what it accepts, and adds a row to the table above. `ZplRaster` is the next target. +of what it accepts, and adds a row to the table above. ## Review record @@ -91,6 +106,8 @@ of what it accepts, and adds a row to the table above. `ZplRaster` is the next t rule. The defect the review found by reading (a length such as `84600in` overflowing an `int` of micrometres, an `ArithmeticException`) was already outside the old rule; the mutations did not reach it because they rarely write a five-digit length, so it is covered by named tests instead (`LengthTest`, `Scene2Test`). +- **2026-11-12, bridge preview:** the page decoder and answer reader campaigns were added with their classes. + Both seeds at 20000 iterations found nothing. - The first run of the printer gate campaign showed the `CharacterCodingException` refusal, which the property had not declared. It is a refusal, not a defect; the property now names it. - Not yet done: a weekly long campaign and the `docs/security/assurance.json` record that K22-TEST-25 also asks diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationService.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationService.java index ac9f994..1ad6fbd 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationService.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/DocumentGenerationService.java @@ -51,18 +51,30 @@ public class DocumentGenerationService /******************************************************************************* ** Explicit generation intent, with optional immediate queue submission. Copies ** is how many copies the one queued job prints (1 through 100); it only has a - ** meaning with a destination. + ** meaning with a destination. The render target is the resolution a label is + ** rendered at (203, 300 or 600 dpi); absent means the stock's own resolution. *******************************************************************************/ public record Request(Integer templateId, GenerationPorts.Source source, GenerationPorts.Format format, String requestKey, String destination, Expected expected, - GenerationPorts.MediaProfile mediaProfile, Integer copies) + GenerationPorts.MediaProfile mediaProfile, Integer copies, Integer targetDpi) { /*************************************************************************** ** Absent means one; anything outside 1 through 100 is refused when built, ** so an invalid count can never reach admission, rendering or submission. + ** A render target other than 203, 300 or 600 is refused the same way. ***************************************************************************/ public Request { copies = GenerationPorts.PrintRequest.normalizeCopies(copies); + GenerationPorts.renderTarget(targetDpi); + } + + /*************************************************************************** + ** Existing copies callers render at the stock's own resolution. + ***************************************************************************/ + public Request(Integer templateId, GenerationPorts.Source source, GenerationPorts.Format format, String requestKey, String destination, Expected expected, + GenerationPorts.MediaProfile mediaProfile, Integer copies) + { + this(templateId, source, format, requestKey, destination, expected, mediaProfile, copies, null); } /*************************************************************************** @@ -169,7 +181,8 @@ public class DocumentGenerationService String actor = GenerationSecurity.require("generate"); GenerationSecurity.require("configure"); noAmbientTransaction(); - if(request == null || request.destination() != null || request.format() == null || request.expected() != null || request.mediaProfile() != null || request.copies() != 1) + if(request == null || request.destination() != null || request.format() == null || request.expected() != null || request.mediaProfile() != null || request.copies() != 1 + || request.targetDpi() != null) { throw new QBadRequestException("Preview requires a source record and format without a print destination."); } @@ -425,9 +438,11 @@ public class DocumentGenerationService QRecord selected = GenerationSecurity.sourceForMutation(requested.source()); String canonicalId = selected.getValueString(QContext.getQInstance().getTable(requested.source().tableName()).getPrimaryKeyField()); Request request = new Request(requested.templateId(), new GenerationPorts.Source(requested.source().tableName(), canonicalId), - requested.format(), requested.requestKey(), requested.destination(), requested.expected(), requested.mediaProfile(), requested.copies()); + requested.format(), requested.requestKey(), requested.destination(), requested.expected(), requested.mediaProfile(), requested.copies(), requested.targetDpi()); // Before any row, render or submission: a count the owner cannot honor is refused, not silently reduced. requireCopiesSupport(request); + // The same for a render target: a renderer that would ignore it never gets the job. + requireTargetSupport(request); Admission admission = admit(actor, request, pinned); if(admission.existing()) { @@ -462,7 +477,7 @@ public class DocumentGenerationService GenerationSecurity.require("generate"); GenerationSecurity.sourceForMutation(request.source()); GenerationPorts.Content content = config.getRenderer().render(new GenerationPorts.RenderRequest(row.getValueString("operationId"), actor, - request.source(), request.format(), revision, snapshot, config.getMaximumOutputBytes())); + request.source(), request.format(), revision, snapshot, config.getMaximumOutputBytes(), target(row))); if(content == null) { throw (new QException("Renderer returned no output.")); @@ -593,6 +608,7 @@ public class DocumentGenerationService GenerationSecurity.requireRead(request.source() == null ? null : request.source().tableName()); requireMediaSupport(request.mediaProfile()); requireCopiesSupport(request); + requireTargetSupport(request); QRecord row = existing(actor, request, "GENERATE"); if(row == null) { @@ -684,7 +700,9 @@ public class DocumentGenerationService || !Objects.equals(row.getValueString("format"), request.format().name()) || !Objects.equals(row.getValueString("destination"), request.destination()) || !Objects.equals(row.getValueString("kind"), kind) || !Objects.equals(media(row), request.mediaProfile()) // Rows stored before copies existed have no count; compare them as one so a legacy default-one retry still matches. - || !Objects.equals(copies(row), request.copies())) + || !Objects.equals(copies(row), request.copies()) + // Rows stored before the render target existed have none, which is what a request without one asks for. + || !Objects.equals(target(row), request.targetDpi())) { throw (new QBadRequestException("Request key already belongs to a different generation intent.")); } @@ -719,7 +737,7 @@ public class DocumentGenerationService .withValue("format", request.format().name()).withValue("destination", request.destination()).withValue("state", "STARTED").withValue("kind", kind) .withValue("mediaProfileId", request.mediaProfile() == null ? null : request.mediaProfile().id()) .withValue("mediaProfileRevision", request.mediaProfile() == null ? null : request.mediaProfile().revision()) - .withValue("copies", request.copies()))); + .withValue("copies", request.copies()).withValue("targetDpi", request.targetDpi()))); } /******************************************************************************* @@ -815,6 +833,11 @@ public class DocumentGenerationService { identity.addAll(List.of("copies/v1", String.valueOf(copies(row)))); } + // No render target (including every earlier row) keeps the digest it always had; a stated target is part of the identity. + if(target(row) != null) + { + identity.addAll(List.of("target-dpi/v1", String.valueOf(target(row)))); + } String digest = hash(identity.stream().map(value -> value.length() + ":" + value).collect(java.util.stream.Collectors.joining()) .getBytes(java.nio.charset.StandardCharsets.UTF_8)); return (new Result(row.getValueInteger("id"), row.getValueString("state"), new GenerationPorts.Receipt(row.getValueString("fileId"), @@ -859,6 +882,45 @@ public class DocumentGenerationService } } + /*************************************************************************** + ** Rows stored before the render target existed carry none, which means the + ** stock's own resolution; a stored value that is not a resolution a printer + ** can have is corrupt and refuses rather than rendering at another one. + ***************************************************************************/ + private Integer target(QRecord row) throws QException + { + Integer target = row.getValueInteger("targetDpi"); + try + { + GenerationPorts.renderTarget(target); + } + catch(IllegalArgumentException invalid) + { + throw new QException("Accepted render target is unavailable."); + } + return (target); + } + + /*************************************************************************** + ** A render target is a label's resolution, and only a renderer that declares + ** it renders at the target may be given one; refuse before creating work. + ***************************************************************************/ + private void requireTargetSupport(Request request) throws QException + { + if(request.targetDpi() == null) + { + return; + } + if(request.format() != GenerationPorts.Format.ZPL) + { + throw new QBadRequestException("A render target applies to ZPL output only."); + } + if(!config.getRenderer().supportsRenderTarget()) + { + throw new QBadRequestException("RENDER_TARGET_UNSUPPORTED"); + } + } + /*************************************************************************** ** More than one copy needs a destination to print to and an owner that ** declares it prints them; refuse before creating work or any print call. diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java index da1dfd3..94b2d69 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPorts.java @@ -41,7 +41,7 @@ public final class GenerationPorts /*************************************************************************** ** A render target is absent or one of the resolutions the renderer supports. ***************************************************************************/ - private static void renderTarget(Integer dpi) + static void renderTarget(Integer dpi) { if(dpi != null && !List.of(203, 300, 600).contains(dpi)) { @@ -125,6 +125,16 @@ public final class GenerationPorts RENDERER_TOO_OLD } + /*************************************************************************** + ** A caller's context, held apart from the map the caller keeps: the entries + ** are copied (a value may be null) and the copy cannot be changed. Values are + ** not copied; the renderer adapter snapshots them when it frames the job. + ***************************************************************************/ + private static Map held(Map context) + { + return (context == null ? null : java.util.Collections.unmodifiableMap(new java.util.LinkedHashMap<>(context))); + } + /*************************************************************************** ** One renderer finding about a layout: a code from a closed vocabulary, the ** element id the template author wrote, numbers, and for a finding about the @@ -378,6 +388,17 @@ public final class GenerationPorts public RenderRequest { renderTarget(targetDpi); + context = held(context); + } + + /*************************************************************************** + ** The context is record data and the revision is template source; neither + ** belongs in a log line, so only the job's identity is shown. + ***************************************************************************/ + @Override + public String toString() + { + return ("RenderRequest[requestId=" + requestId + ", format=" + format + ", targetDpi=" + targetDpi + "]"); } /*************************************************************************** @@ -407,6 +428,17 @@ public final class GenerationPorts throw new IllegalArgumentException("A preview needs a format, a revision and an output limit."); } renderTarget(targetDpi); + context = held(context); + } + + /*************************************************************************** + ** The context is record data and the revision is template source; neither + ** belongs in a log line, so only the format and the render target are shown. + ***************************************************************************/ + @Override + public String toString() + { + return ("PreviewRequest[format=" + format + ", targetDpi=" + targetDpi + "]"); } } @@ -641,6 +673,18 @@ public final class GenerationPorts throw new QException("Renderer preview is unavailable."); } + /*********************************************************************** + ** Declare that this renderer renders a version 2 scene at the request's + ** render target and refuses, by name, a job it cannot render there. An + ** adapter that does not read `RenderRequest.targetDpi` keeps the default, + ** and generation then refuses a render target before creating any work: + ** a caller who asks for 300 dpi never silently gets another resolution. + ***********************************************************************/ + default boolean supportsRenderTarget() + { + return (false); + } + /******************************************************************************* ** Authorize assets and validate user source outside the application JVM. *******************************************************************************/ diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationProcesses.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationProcesses.java index 2f1351b..ba92291 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationProcesses.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationProcesses.java @@ -128,6 +128,10 @@ public final class GenerationProcesses form.withFormField(new QFieldMetaData("templateId", QFieldType.INTEGER).withLabel("Published template").withPossibleValueSourceName(GenerationTables.TEMPLATE)); form.withFormField(new QFieldMetaData("format", QFieldType.STRING).withLabel("Output format").withPossibleValueSourceName(GenerationAuthoringProcesses.FORMAT)); } + if("generate".equals(operation)) + { + form.withFormField(new QFieldMetaData("targetDpi", QFieldType.INTEGER).withLabel("Render target in dpi: 203, 300 or 600 (optional, ZPL only; default is the stock's resolution)")); + } if(List.of("generate", "enqueue", "reprint").contains(operation)) { form.withFormField(new QFieldMetaData("destination", QFieldType.STRING).withLabel("Print destination (optional for generation)").withMaxLength(255)); @@ -261,7 +265,7 @@ public final class GenerationProcesses throw new QBadRequestException("Select PDF or ZPL output."); } result = "generate".equals(operation) - ? service.generate(new DocumentGenerationService.Request(input.getValueInteger("templateId"), sources.getFirst(), format, input.getValueString("requestKey"), input.getValueString("destination"))) + ? service.generate(request(input, sources.getFirst(), format)) : new GenerationQueue(config).enqueue(input.getValueInteger("templateId"), sources, format, input.getValueString("requestKey"), input.getValueString("destination")); } else @@ -335,6 +339,24 @@ public final class GenerationProcesses } } + /******************************************************************************* + ** The generate action's intent: one record, with the optional render target. + ** A target that is not a resolution a printer can have is refused here, as a + ** bad request, before the service sees it. + *******************************************************************************/ + private static DocumentGenerationService.Request request(RunBackendStepInput input, GenerationPorts.Source source, GenerationPorts.Format format) throws QException + { + try + { + return (new DocumentGenerationService.Request(input.getValueInteger("templateId"), source, format, input.getValueString("requestKey"), input.getValueString("destination"), + null, null, 1, input.getValueInteger("targetDpi"))); + } + catch(IllegalArgumentException invalid) + { + throw new QBadRequestException("A render target is 203, 300 or 600 dpi."); + } + } + /******************************************************************************* ** Status exposes only the currently authorized request, never hidden context. *******************************************************************************/ @@ -344,7 +366,7 @@ public final class GenerationProcesses QRecord row = GenerationStore.get(GenerationTables.REQUEST, id); GenerationRetainedAccess.require(row, false); java.util.Map result = new java.util.LinkedHashMap<>(); - for(String field : List.of("id", "state", "format", "tableName", "recordId", "revisionId", "fileId", "versionId", "sha256", "length", "available", "printJobId", "errorCode", "mediaProfileId", "mediaProfileRevision")) + for(String field : List.of("id", "state", "format", "tableName", "recordId", "revisionId", "fileId", "versionId", "sha256", "length", "available", "printJobId", "errorCode", "mediaProfileId", "mediaProfileRevision", "targetDpi")) { result.put(field, row.getValue(field)); } diff --git a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java index d01d7b1..8a26c10 100644 --- a/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java +++ b/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationTables.java @@ -90,7 +90,7 @@ public final class GenerationTables .withField(string("actorId", 255)).withField(string("tableName", 128)).withField(string("recordId", 255)) .withField(integer("templateId")).withField(integer("revisionId")).withField(string("format", 16)) .withField(string("destination", 255)).withField(string("state", 32)).withField(string("fileId", 255)) - .withField(string("mediaProfileId", 128)).withField(string("mediaProfileRevision", 64)).withField(integer("copies")) + .withField(string("mediaProfileId", 128)).withField(string("mediaProfileRevision", 64)).withField(integer("copies")).withField(integer("targetDpi")) .withField(string("versionId", 255)).withField(string("sha256", 64)).withField(new QFieldMetaData("length", QFieldType.LONG)) .withField(string("printJobId", 255)).withField(string("errorCode", 64)).withField(string("resolvedFileName", 184)) .withUniqueKey(new UniqueKey("actorId", "requestKey")) diff --git a/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-h2.sql b/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-h2.sql new file mode 100644 index 0000000..6707a29 --- /dev/null +++ b/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-h2.sql @@ -0,0 +1,4 @@ +-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0 +-- Apply once before adopting render-target-aware Generation metadata. +-- Existing requests keep a null target, which is read as the stock's own resolution: their identity digests and retries are unchanged. +ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER NULL; diff --git a/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-mysql.sql b/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-mysql.sql new file mode 100644 index 0000000..e523e41 --- /dev/null +++ b/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-mysql.sql @@ -0,0 +1,4 @@ +-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0 +-- Apply once before adopting render-target-aware Generation metadata. +-- Existing requests keep a null target, which is read as the stock's own resolution: their identity digests and retries are unchanged. +ALTER TABLE document_generation_request ADD COLUMN target_dpi INT NULL; diff --git a/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-postgres.sql b/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-postgres.sql new file mode 100644 index 0000000..466a45f --- /dev/null +++ b/foundation/src/main/resources/db/migrations/20261112-render-target-dpi-postgres.sql @@ -0,0 +1,5 @@ +-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0 +-- Apply once before adopting render-target-aware Generation metadata. +-- Existing requests keep a null target, which is read as the stock's own resolution: their identity digests and retries are unchanged. +-- PostgreSQL17: re-runnable via IF NOT EXISTS, no rewrite of existing rows. +ALTER TABLE document_generation_request ADD COLUMN IF NOT EXISTS target_dpi INTEGER NULL; diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationExpectedIntentTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationExpectedIntentTest.java index 138a7f8..8b921d9 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationExpectedIntentTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationExpectedIntentTest.java @@ -453,6 +453,239 @@ class GenerationExpectedIntentTest assertThat(submissions).hasValue(1); } + /*************************************************************************** + ** Publish a label on the fixture's template and install a renderer that + ** declares it renders at the request's target and records each target. + ***************************************************************************/ + private List labelRenderer() throws Exception + { + List targets = new java.util.ArrayList<>(); + fixture.config.withRenderer(new GenerationPorts.IsolatedRenderer() + { + /*************************************************************************** + ** The fixture renders at the target, as the renderer bridge declares. + ***************************************************************************/ + @Override + public boolean supportsRenderTarget() + { + return (true); + } + + /*************************************************************************** + ** Accept every revision. + ***************************************************************************/ + @Override + public void validate(String actor, GenerationPorts.Revision revision) + { + } + + /*************************************************************************** + ** Record the target and return bytes that differ by it. + ***************************************************************************/ + @Override + public GenerationPorts.Content render(GenerationPorts.RenderRequest request) + { + targets.add(request.targetDpi()); + return (new GenerationPorts.Content(("label at " + request.targetDpi()).getBytes(java.nio.charset.StandardCharsets.UTF_8))); + } + }); + definitions.saveDraft(fixture.template, fixture.stock, new DocumentDefinitionService.Draft("page", "{\"schema\":\"qqq-zpl-scene/2\"}", null, List.of())); + definitions.publish(fixture.template); + return (targets); + } + + /*************************************************************************** + ** A label intent for the first order, with or without a render target. + ***************************************************************************/ + private DocumentGenerationService.Request label(String key, Integer targetDpi) + { + return (new DocumentGenerationService.Request(fixture.template, new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, key, null, null, null, 1, targetDpi)); + } + + /*************************************************************************** + ** The intent digest as it was framed before the render target existed, with + ** the target's own frame added when one is given. Written out here so the + ** test does not borrow the service's arithmetic. + ***************************************************************************/ + private String digest(DocumentGenerationService.Result result, String key, Integer targetDpi) throws Exception + { + List identity = new java.util.ArrayList<>(List.of("document-generation-intent/v1", "alice", key, "GENERATE", "6:orders1:1", String.valueOf(result.accepted().templateId()), + String.valueOf(result.accepted().revisionId()), result.accepted().stock().toString(), "ZPL", "")); + if(targetDpi != null) + { + identity.addAll(List.of("target-dpi/v1", String.valueOf(targetDpi))); + } + StringBuilder framed = new StringBuilder(); + identity.forEach(value -> framed.append(value.length()).append(':').append(value)); + return (java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(framed.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)))); + } + + /*************************************************************************** + ** A render target travels from the request to the row and to the renderer, + ** and is part of the intent: the same key with another target, or with + ** none, is another intent and is refused. A request without a target has + ** exactly the digest it had before the target existed. + ***************************************************************************/ + @Test + void aRenderTargetIsCarriedPersistedAndPartOfTheIntent() throws Exception + { + List targets = labelRenderer(); + var none = service.generate(label("label:target-none", null)); + var three = service.generate(label("label:target-300", 300)); + var six = service.generate(label("label:target-600", 600)); + assertThat(targets).containsExactly(null, 300, 600); + assertThat(service.generate(label("label:target-300", 300))).isEqualTo(three); + assertThat(service.generate(label("label:target-none", null))).isEqualTo(none); + assertThat(targets).hasSize(3); + for(var other : List.of(label("label:target-300", null), label("label:target-300", 203), label("label:target-300", 600), label("label:target-none", 203), label("label:target-none", 300))) + { + assertThatThrownBy(() -> service.generate(other)).hasMessageContaining("different generation intent"); + } + assertThat(targets).hasSize(3); + + assertThat(none.intentSha256()).isEqualTo(digest(none, "label:target-none", null)); + assertThat(three.intentSha256()).isEqualTo(digest(three, "label:target-300", 300)).isNotEqualTo(digest(three, "label:target-300", null)).isNotEqualTo(digest(three, "label:target-300", 600)); + assertThat(six.intentSha256()).isEqualTo(digest(six, "label:target-600", 600)); + try(var connection = fixture.connection(); var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT request_key, target_dpi FROM document_generation_request ORDER BY id")) + { + for(Integer expected : new Integer[] {null, 300, 600}) + { + assertThat(rows.next()).isTrue(); + int stored = rows.getInt(2); + assertThat(rows.wasNull() ? null : Integer.valueOf(stored)).isEqualTo(expected); + } + assertThat(rows.next()).isFalse(); + } + /////////////////////////////////////////////////////////////////////////////////////////// + // a stored target that no printer has is corrupt: it refuses, and renders at no dpi // + /////////////////////////////////////////////////////////////////////////////////////////// + fixture.sql("UPDATE document_generation_request SET target_dpi = 250 WHERE request_key = 'label:target-300'"); + assertThatThrownBy(() -> service.generate(label("label:target-300", 300))).hasMessageContaining("Accepted render target is unavailable"); + assertThat(targets).hasSize(3); + } + + /*************************************************************************** + ** A target that cannot be honoured is refused before any row, render or + ** event: a value that is not a printer resolution, a target for a PDF, and + ** a renderer that does not declare it renders at a target (it would ignore + ** the value and print at another resolution). Without a target that same + ** renderer works as it always did. + ***************************************************************************/ + @Test + void aRenderTargetThatCannotBeHonouredIsRefusedBeforeAnyEffect() throws Exception + { + for(int invalid : new int[] {0, 72, 200, 204, 1200, -300}) + { + assertThatThrownBy(() -> label("label:invalid-target", invalid)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("203, 300 or 600"); + } + var page = request("label:pdf-target"); + var pdf = new DocumentGenerationService.Request(page.templateId(), page.source(), page.format(), page.requestKey(), null, null, null, 1, 300); + assertThatThrownBy(() -> service.generate(pdf)).hasMessageContaining("A render target applies to ZPL output only"); + assertThatThrownBy(() -> service.generate(label("label:unsupported-target", 300))).hasMessageContaining("RENDER_TARGET_UNSUPPORTED"); + assertThatThrownBy(() -> service.generate(label("label:unsupported-target", 203))).hasMessageContaining("RENDER_TARGET_UNSUPPORTED"); + var withDestination = new DocumentGenerationService.Request(page.templateId(), page.source(), GenerationPorts.Format.ZPL, page.requestKey(), page.destination(), page.expected(), null, 1, 300); + assertThatThrownBy(() -> service.lookupAcceptedPrint(withDestination)).hasMessageContaining("RENDER_TARGET_UNSUPPORTED"); + assertThatThrownBy(() -> service.preview(label("label:preview-target", 300), "0".repeat(64))).hasMessageContaining("Preview requires"); + assertThat(count("document_generation_request")).isZero(); + assertThat(count("document_generation_event")).isZero(); + assertThat(submissions).hasValue(0); + assertThat(service.generate(page).state()).isEqualTo("QUEUED"); + assertThat(count("document_generation_request")).isEqualTo(1); + } + + /*************************************************************************** + ** A renderer that refuses the target by name (a version 1 scene asked for + ** another resolution than its stock's) leaves a typed, replayable refusal + ** and no output. + ***************************************************************************/ + @Test + void aRefusedRenderTargetIsTypedAndIsNotRenderedAgain() throws Exception + { + labelRenderer(); + AtomicInteger attempts = new AtomicInteger(); + fixture.config.withRenderer(new GenerationPorts.IsolatedRenderer() + { + /*************************************************************************** + ** The fixture reads the target and refuses it, as the bridge does. + ***************************************************************************/ + @Override + public boolean supportsRenderTarget() + { + return (true); + } + + /*************************************************************************** + ** Accept every revision. + ***************************************************************************/ + @Override + public void validate(String actor, GenerationPorts.Revision revision) + { + } + + /*************************************************************************** + ** Refuse a target other than the stock's resolution, by name. + ***************************************************************************/ + @Override + public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException + { + attempts.incrementAndGet(); + throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.TARGET_REQUIRES_SCENE_2); + } + }); + for(int attempt = 0; attempt < 2; attempt++) + { + assertThatThrownBy(() -> service.generate(label("label:refused-target", 300))).isInstanceOf(GenerationPorts.RenderException.class).hasMessage("TARGET_REQUIRES_SCENE_2"); + } + assertThat(attempts).hasValue(1); + try(var connection = fixture.connection(); var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT state, error_code, target_dpi, version_id FROM document_generation_request")) + { + assertThat(rows.next()).isTrue(); + assertThat(rows.getString(1)).isEqualTo("INCOMPLETE"); + assertThat(rows.getString(2)).isEqualTo("TARGET_REQUIRES_SCENE_2"); + assertThat(rows.getInt(3)).isEqualTo(300); + assertThat(rows.getString(4)).isNull(); + } + } + + /*************************************************************************** + ** The shipped additive migration leaves earlier rows without a target: they + ** keep their receipt and digest, still match a retry without a target, and + ** never match a retry that names one. + ***************************************************************************/ + @Test + void renderTargetMigrationKeepsEarlierRowsAndTheirDigests() throws Exception + { + labelRenderer(); + var before = service.generate(label("label:legacy-target", null)); + int events = count("document_generation_event"); + fixture.sql("ALTER TABLE document_generation_request DROP COLUMN target_dpi"); + for(String statement : java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/migrations/20261112-render-target-dpi-h2.sql")).split(";")) + { + if(!statement.isBlank()) + { + fixture.sql(statement); + } + } + try(var connection = fixture.connection(); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT target_dpi FROM document_generation_request")) + { + assertThat(rows.next()).isTrue(); + rows.getInt(1); + assertThat(rows.wasNull()).isTrue(); + } + assertThat(service.generate(label("label:legacy-target", null))).isEqualTo(before); + assertThat(before.intentSha256()).isEqualTo(digest(before, "label:legacy-target", null)); + assertThatThrownBy(() -> service.generate(label("label:legacy-target", 203))).hasMessageContaining("different generation intent"); + assertThat(count("document_generation_event")).isEqualTo(events); + assertThat(count("document_generation_request")).isEqualTo(1); + for(String dialect : List.of("mysql", "postgres")) + { + assertThat(java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/migrations/20261112-render-target-dpi-" + dialect + ".sql"))) + .contains("ALTER TABLE document_generation_request ADD COLUMN").contains("target_dpi IN").contains("NULL;"); + } + } + /*************************************************************************** ** A closed worker refusal is persisted and replayed without repeated render, ** while arbitrary exception messages remain the generic operational code. diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java index f1d1083..3adf239 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationPortsContractTest.java @@ -95,6 +95,41 @@ class GenerationPortsContractTest assertEquals("Renderer preview is unavailable.", assertThrows(QException.class, () -> adapter.preview(new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), null, 1024))).getMessage()); assertEquals("Renderer barcode reference is unavailable.", assertThrows(QException.class, adapter::barcodeReference).getMessage()); + assertEquals(false, adapter.supportsRenderTarget()); + } + + /*************************************************************************** + ** A job holds its own copy of the context, which may hold a null value and + ** cannot be changed through the job or through the map the caller kept. A + ** job written to a log shows its format and render target and nothing of + ** the record or the template source. + ***************************************************************************/ + @Test + void aJobHoldsItsOwnContextAndNeverPrintsIt() + { + Map context = new HashMap<>(); + context.put("name", "PRIVATE_SENTINEL"); + context.put("absent", null); + var secret = new GenerationPorts.Revision(1, 2, "orders", STOCK, "

SOURCE_SENTINEL

", "{\"text\":\"SOURCE_SENTINEL\"}", null, List.of()); + var preview = new GenerationPorts.PreviewRequest("actor-PRIVATE", GenerationPorts.Format.ZPL, secret, context, 300, 1024); + var render = new GenerationPorts.RenderRequest("request-1", "actor-PRIVATE", SOURCE, GenerationPorts.Format.ZPL, secret, context, 1024, 600); + context.put("name", "changed"); + context.put("added", "later"); + for(Map held : List.of(preview.context(), render.context())) + { + assertEquals("PRIVATE_SENTINEL", held.get("name")); + assertEquals(2, held.size()); + assertNull(held.get("absent")); + assertEquals(true, held.containsKey("absent")); + assertThrows(UnsupportedOperationException.class, () -> held.put("more", "x")); + assertThrows(UnsupportedOperationException.class, () -> held.remove("name")); + } + assertEquals("PreviewRequest[format=ZPL, targetDpi=300]", preview.toString()); + assertEquals("RenderRequest[requestId=request-1, format=ZPL, targetDpi=600]", render.toString()); + assertEquals("PreviewRequest[format=PDF, targetDpi=null]", new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, secret, null, null, 1).toString()); + assertNull(new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, secret, null, null, 1).context()); + assertNull(new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.PDF, secret, null, 1).context()); + assertEquals(preview, new GenerationPorts.PreviewRequest("actor-PRIVATE", GenerationPorts.Format.ZPL, secret, preview.context(), 300, 1024)); } /*************************************************************************** diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java index 75ec49a..e715484 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationProcessesTest.java @@ -1283,6 +1283,41 @@ class GenerationProcessesTest assertThatThrownBy(() -> execute(input)).hasMessageContaining("PDF or ZPL"); } + /*************************************************************************** + ** The generate action offers a render target, carries it to the service and + ** refuses one that is not a printer resolution; queueing has no such option. + ** The request status names the stored target. + ***************************************************************************/ + @Test + void generateActionCarriesTheRenderTargetOrRefusesIt() throws Exception + { + var options = (com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData) instance.getProcess(GenerationProcesses.name("orders", "generate")).getStep("options"); + assertThat(options.getFormFields().stream().map(QFieldMetaData::getName)).contains("targetDpi"); + var queued = (com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData) instance.getProcess(GenerationProcesses.name("orders", "enqueue")).getStep("options"); + assertThat(queued.getFormFields().stream().map(QFieldMetaData::getName)).doesNotContain("targetDpi"); + for(int invalid : new int[] {150, 0, 601}) + { + var input = prepared("orders", "generate", 1); + input.addValue("templateId", template); + input.addValue("targetDpi", invalid); + assertThatThrownBy(() -> execute(input)).hasMessageContaining("A render target is 203, 300 or 600 dpi."); + } + var page = prepared("orders", "generate", 1); + page.addValue("templateId", template); + page.addValue("targetDpi", 300); + assertThatThrownBy(() -> execute(page)).hasMessageContaining("A render target applies to ZPL output only"); + /////////////////////////////////////////////////////////////////////////////////////////// + // the fixture renderer does not declare that it renders at a target, so the service, // + // which was given the value, refuses it // + /////////////////////////////////////////////////////////////////////////////////////////// + var label = prepared("orders", "generate", 1); + label.addValue("templateId", template); + label.addValue("format", "ZPL"); + label.addValue("targetDpi", 300); + assertThatThrownBy(() -> execute(label)).hasMessageContaining("RENDER_TARGET_UNSUPPORTED"); + assertThat(new ObjectMapper().readTree(execute(prepared(GenerationTables.REQUEST, "status", generate()))).has("targetDpi")).isTrue(); + } + /*************************************************************************** ** Reject substituted retained versions and unsafe download links before exposing delivery metadata. ***************************************************************************/ diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationQueueJdbcTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationQueueJdbcTest.java index 6318a94..29e4637 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationQueueJdbcTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/GenerationQueueJdbcTest.java @@ -173,6 +173,41 @@ class GenerationQueueJdbcTest queue = new GenerationQueue(config,clock,1,30); } + /*************************************************************************** + ** The database this suite runs on, as the shipped migration scripts name it. + ***************************************************************************/ + protected String dialect() + { + return ("h2"); + } + + /*************************************************************************** + ** The shipped render target migration for this database adds one nullable + ** column to the request table: a request made before it reads as having no + ** target, and generation goes on afterwards. + ***************************************************************************/ + @Test + void renderTargetMigrationAddsOneNullableColumn() throws Exception + { + assertThat(queue.advance(enqueue("before-target")).state()).isEqualTo("GENERATED"); + sql("ALTER TABLE document_generation_request DROP COLUMN target_dpi"); + for(String statement : Files.readString(Path.of("src/main/resources/db/migrations/20261112-render-target-dpi-" + dialect() + ".sql")).split(";")) + { + if(!statement.isBlank()) + { + sql(statement); + } + } + try(var connection = connection(); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT target_dpi FROM document_generation_request")) + { + assertThat(rows.next()).isTrue(); + rows.getInt(1); + assertThat(rows.wasNull()).isTrue(); + assertThat(rows.next()).isFalse(); + } + assertThat(queue.advance(enqueue("after-target")).state()).isEqualTo("GENERATED"); + } + /*************************************************************************** ** Clear native context, pooled connections and memory records after each database scenario. ***************************************************************************/ diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/MySqlGenerationQueueTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/MySqlGenerationQueueTest.java index a61ce6e..31945d8 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/MySqlGenerationQueueTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/MySqlGenerationQueueTest.java @@ -97,6 +97,15 @@ class MySqlGenerationQueueTest extends GenerationQueueJdbcTest mysql.sql(source); } + /*********************************************************************** + ** The shipped migration scripts for this engine. + ***********************************************************************/ + @Override + protected String dialect() + { + return ("mysql"); + } + /*********************************************************************** ** Exercise the shipped native schema, including binary idempotency keys. ***********************************************************************/ diff --git a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/PostgresGenerationQueueTest.java b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/PostgresGenerationQueueTest.java index 67ae8a9..7a93702 100644 --- a/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/PostgresGenerationQueueTest.java +++ b/foundation/src/test/java/com/kingsrook/qbits/documentgeneration/PostgresGenerationQueueTest.java @@ -100,6 +100,15 @@ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest return (current == null ? postgres : current); } + /*********************************************************************** + ** The shipped migration scripts for this engine. + ***********************************************************************/ + @Override + protected String dialect() + { + return ("postgres"); + } + /*********************************************************************** ** Exercise the shipped native schema without the H2 timestamp rewrite. ***********************************************************************/ diff --git a/renderer/qualification.json b/renderer/qualification.json index 4dc6e52..0d3d6ab 100644 --- a/renderer/qualification.json +++ b/renderer/qualification.json @@ -3,9 +3,9 @@ "qualified": false, "qualificationId": null, "imageDigest": null, - "rendererVersion": "qqq-renderer-0.1.0", + "rendererVersion": "qqq-renderer-0.2.0", "protocol": "qbit-document-renderer/1", - "catalogVersion": "qqq-barcodes-1", + "catalogVersion": "qqq-barcodes-9", "javaVersion": "21.0.13", "requiredRuntime": "runsc", "requiredHostOs": "linux", diff --git a/renderer/src/test/java/com/kingsrook/qbits/renderer/HostContractTest.java b/renderer/src/test/java/com/kingsrook/qbits/renderer/HostContractTest.java new file mode 100644 index 0000000..69b2afb --- /dev/null +++ b/renderer/src/test/java/com/kingsrook/qbits/renderer/HostContractTest.java @@ -0,0 +1,124 @@ +/* + * 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.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.kingsrook.qbits.renderer.scene2.SceneFixture; +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.assertTrue; + + +/******************************************************************************* + ** What the host side relies on from this worker and cannot test against it, + ** because the host bridge shares no code with the renderer: the version it + ** reports is the one its qualification record names, the bridge's version + ** question is answered, and a preview's label is the label that prints. + *******************************************************************************/ +class HostContractTest +{ + /////////////////////////////////////////////////////////////////////////////////////////////// + // the scene of the host bridge's version question (`IsolatedRendererBridge.PROBE_SCENE` in // + // `bridges/renderer`), on its stock: 4 x 6 in with no margins at 203 dpi // + /////////////////////////////////////////////////////////////////////////////////////////////// + private static final String VERSION_QUESTION = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[]}"; + + + + /*************************************************************************** + ** `qualification.json` names the renderer version, protocol and barcode + ** catalog that this build's answers carry. + ***************************************************************************/ + @Test + void theQualificationRecordNamesWhatTheWorkerReports() throws Exception + { + JsonNode record = Wire.JSON.readTree(Files.readAllBytes(Path.of("qualification.json"))); + Map answer = RendererMain.process(SceneFixture.request("VALIDATE", VERSION_QUESTION)); + for(String field : List.of("rendererVersion", "protocol", "catalogVersion")) + { + assertEquals(answer.get(field), record.path(field).textValue(), field); + } + assertEquals(RendererMain.VERSION, record.path("rendererVersion").textValue()); + assertEquals(Wire.PROTOCOL, record.path("protocol").textValue()); + assertEquals(Barcodes.VERSION, record.path("catalogVersion").textValue()); + assertFalse(record.path("qualified").booleanValue()); + } + + + + /*************************************************************************** + ** The host asks a worker its version by validating an empty version 1 scene + ** before it sends a version 2 scene. The answer is the plain validation + ** answer, naming the version. + ***************************************************************************/ + @Test + void theHostVersionQuestionIsAnswered() throws Exception + { + ObjectNode question = SceneFixture.request("VALIDATE", SceneFixture.stock(101_600, 152_400, 0, 203), VERSION_QUESTION).put("maximumBytes", 4096); + Map answer = RendererMain.process(question); + assertEquals(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "diagnostics"), List.copyOf(answer.keySet())); + assertEquals("OK", answer.get("outcome")); + assertEquals(RendererMain.VERSION, answer.get("rendererVersion")); + assertEquals(List.of(), answer.get("diagnostics")); + } + + + + /*************************************************************************** + ** A preview asks for collected diagnostics. For a scene with no error that + ** changes nothing in the label: every golden case, at every resolution, is + ** byte for byte the golden label either way. + ***************************************************************************/ + @Test + void collectingDiagnosticsDoesNotChangeALabelThatPrints() throws Exception + { + Path scenes = Path.of("src", "test", "resources", "scene2"); + List names; + try(Stream files = Files.list(scenes.resolve("cases"))) + { + names = files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".json")).map(name -> name.substring(0, name.length() - 5)).sorted().toList(); + } + assertTrue(names.size() >= 4); + for(String name : names) + { + JsonNode definition = Wire.JSON.readTree(Files.readAllBytes(scenes.resolve("cases").resolve(name + ".json"))); + for(int dpi : List.of(203, 300, 600)) + { + ObjectNode request = SceneFixture.request("RENDER", definition.get("stock"), Wire.JSON.writeValueAsString(definition.get("scene"))).put("targetDpi", dpi); + byte[] printed = SceneFixture.render(request); + Map previewed = RendererMain.process(request.put("diagnostics", "COLLECT")); + assertEquals("OK", previewed.get("outcome"), name); + assertTrue(Arrays.equals(printed, Base64.getDecoder().decode((String) previewed.get("base64"))), name + " at " + dpi); + assertTrue(Arrays.equals(Files.readAllBytes(scenes.resolve("golden").resolve(name + "-" + dpi + ".zpl")), printed), name + " at " + dpi); + } + } + } +}