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