From 9b908ac5165c7f6c5db0ec21072b5391d6d76a7b Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 12 Nov 2026 12:02:38 -0600 Subject: [PATCH 1/2] feat(foundation): a generation request names its render target (P7) A request could not say which printer resolution to render a label for: RenderRequest.targetDpi existed and nothing set it. DocumentGenerationService.Request gains a trailing targetDpi (absent, 203, 300 or 600; every earlier constructor stays and means none). It is stored in a new nullable column, target_dpi, on document_generation_request, compared on retry and lookup beside destination, media and copies, framed into the intent digest as target-dpi/v1 when stated, and passed to the renderer. A request without a target has exactly the digest it had, so earlier rows and their retries are unchanged; the same key with another target, or with none, is another intent. Nobody who asks for 300 dpi may silently get another resolution, so a target is refused before any row exists when it cannot be honoured: for a PDF, and for any renderer that does not declare the new IsolatedRenderer.supportsRenderTarget() (false by default, so an adapter written before the target cannot be handed one and ignore it). A host applies one statement, from the shipped script for its database: db/migrations/20261112-render-target-dpi-{h2,mysql,postgres}.sql (ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER NULL). The generate process form offers the target and the request status shows it. Batches, the queue and reprints carry none. Also from the review of the ports: RenderRequest and PreviewRequest hold their own unmodifiable copy of the context (null values allowed) and print only their format and render target (and the request id), never the record data or the template source. Neither wire form changes. Co-Authored-By: Claude Fable 5.2 --- docs/EXPECTED-PRINT-INTENT.md | 31 +++ .../DocumentGenerationService.java | 76 +++++- .../documentgeneration/GenerationPorts.java | 46 +++- .../GenerationProcesses.java | 26 +- .../documentgeneration/GenerationTables.java | 2 +- .../20261112-render-target-dpi-h2.sql | 4 + .../20261112-render-target-dpi-mysql.sql | 4 + .../20261112-render-target-dpi-postgres.sql | 5 + .../GenerationExpectedIntentTest.java | 233 ++++++++++++++++++ .../GenerationPortsContractTest.java | 35 +++ .../GenerationProcessesTest.java | 35 +++ .../GenerationQueueJdbcTest.java | 35 +++ .../MySqlGenerationQueueTest.java | 9 + .../PostgresGenerationQueueTest.java | 9 + 14 files changed, 539 insertions(+), 11 deletions(-) create mode 100644 foundation/src/main/resources/db/migrations/20261112-render-target-dpi-h2.sql create mode 100644 foundation/src/main/resources/db/migrations/20261112-render-target-dpi-mysql.sql create mode 100644 foundation/src/main/resources/db/migrations/20261112-render-target-dpi-postgres.sql 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/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. ***********************************************************************/ -- 2.54.0 From bf546e16c521c99660b31016b194cd2e47a8f465 Mon Sep 17 00:00:00 2001 From: James Maes Date: Thu, 12 Nov 2026 12:02:54 -0600 Subject: [PATCH 2/2] feat(bridge): accept renderer 0.2.0, read its diagnostics, preview a label (P5) On develop the renderer speaks qqq-renderer-0.2.0 and the bridge accepted only 0.1.0, so the bridge refused its own renderer, a label could not be previewed, and a render target was accepted and then ignored. The version rule (RendererVersion). The bridge accepts 0.1.0 (the released fields, the 13 released refusals) and 0.2.0 (the same with diagnostics and detail, those refusals and the 15 scene refusals); any other version is rejected. Each set is written out, replacing the temporary enum range. A job is framed as 0.1.0 reads it unless it needs more. Before a job only 0.2.0 can do (a version 2 scene, a preview) the bridge asks the worker its version by validating a fixed, empty version 1 scene, so an old image is refused as RENDERER_TOO_OLD and receives nothing of the scene or the record; an answer from 0.1.0 to such a job is refused whatever it says. Diagnostics (WorkerAnswer, SceneFacts). A version 2 refusal is RenderException(code, diagnostic). The worker is untrusted, so every field is checked again: the closed vocabulary, whole numbers in bounds, an element only if the scene that was sent declares it, and a pointer only of array indexes, schema property names and declared ids. An answer holds at most 2048 diagnostics in one megabyte beside its label, and no frame passes the frame of the largest document the deployment admits. A refused answer that carries any part of a label is rejected. Render target. targetDpi 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, because renderer 0.1.0 does not read the field. supportsRenderTarget() is true. Preview. preview() 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. ZplRaster is its own strict reader of the policy's grammar and runs the policy first; it paints ^GFA and ^GB exactly, hatches printer font text (the page is then not exact) and writes a one-bit PNG itself. Refusals come back as diagnostics, never as a label. A refused scene is drawn on the host from the diagnostics' boxes alone: the worker sends no label for it and the wire gains no field for the partial canvas, so no host can print a label with elements missing. The design records the decision in sections 6.1 and B.1, the version question in 11.3 and what the host now reads from a scene in 8.1. renderer/qualification.json said qqq-renderer-0.1.0 and qqq-barcodes-1; it now says what the worker reports, and a test holds it to the answer. The mutation campaign covers the page decoder and the answer frames. The new classes are gated at 100% line coverage and the bundle gate rises to the measured 90% of instructions and 88% of lines. The version 1 corpus is untouched. Co-Authored-By: Claude Fable 5.2 --- .../remote/RemoteRendererBridge.java | 19 + bridges/renderer/README.md | 29 + bridges/renderer/pom.xml | 13 +- .../renderer/IsolatedRendererBridge.java | 345 ++++++++--- .../renderer/RendererJson.java | 64 --- .../renderer/RendererVersion.java | 113 ++++ .../renderer/SceneFacts.java | 228 ++++++++ .../renderer/WorkerAnswer.java | 406 +++++++++++++ .../renderer/ZplRaster.java | 537 ++++++++++++++++++ .../renderer/BridgeTest.java | 5 +- .../renderer/FakeWorker.java | 272 +++++++++ .../renderer/MutationCampaignTest.java | 253 ++++++++- .../renderer/PreviewTest.java | 379 ++++++++++++ .../renderer/SceneFactsTest.java | 177 ++++++ .../renderer/VersionRuleTest.java | 354 ++++++++++++ .../renderer/WorkerAnswerTest.java | 420 ++++++++++++++ .../renderer/ZplRasterTest.java | 355 ++++++++++++ docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md | 92 ++- docs/security/fuzzing.md | 19 +- renderer/qualification.json | 4 +- .../qbits/renderer/HostContractTest.java | 124 ++++ 21 files changed, 4031 insertions(+), 177 deletions(-) create mode 100644 bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/RendererVersion.java create mode 100644 bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFacts.java create mode 100644 bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswer.java create mode 100644 bridges/renderer/src/main/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRaster.java create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/FakeWorker.java create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/PreviewTest.java create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/SceneFactsTest.java create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionRuleTest.java create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/WorkerAnswerTest.java create mode 100644 bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/ZplRasterTest.java create mode 100644 renderer/src/test/java/com/kingsrook/qbits/renderer/HostContractTest.java 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/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/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); + } + } + } +} -- 2.54.0