diff --git a/README.md b/README.md
index 5670134..889095e 100644
--- a/README.md
+++ b/README.md
@@ -45,6 +45,13 @@ Every default provider fails closed. No adapter calls the trusted in-process ren
Context contains bounded immutable JSON-like data, including the authorized selected `record`. A script may add context objects but cannot replace that reserved original record. Credentials, QQQ sessions, raw host objects and lazy entities are rejected. Source-record permissions are checked before context preparation, before rendering, before intake and before printing. Asset access is rechecked by the isolated renderer adapter, and retained-version access by retained/printing adapters.
+### What a host must configure for label previews
+
+The "Label preview" widget and the "Preview layout" action arrive with the QBit's registration and need no host code. Two things are the host's:
+
+- **Send `Cache-Control: no-store` for widget responses.** The widget's answer holds the picture of a label, which in the action can show the values of a record. QQQ's widget route sets no `Cache-Control` header and a widget renderer cannot set response headers, so the host, or the proxy in front of it, must send `no-store` for widget responses. Nothing in this QBit can.
+- **Know what your request log holds.** QQQ's widget route writes the parameters of a widget request that fails or takes longer than one second to the application log. For this widget those parameters hold no record identity and no sample name: from a template's record screen they are the table name and the template's identity, and from the action they are the identifier of the run (`processUUID`) with the plain process values a client adds to it (`previewTemplateId` or `previewRevisionId`, `sourceTable`, and whatever the client itself sent to start the run, which for the standard clients is its selection of the template or revision row). The chosen record, sample, stock and resolution stay in the state of the run and are not sent; this holds for a client that sends plain process values only, as the standard clients do. `DocumentPreviewService` itself logs one line per preview: the actor, the template or revision identity, the kind of data, the resolution, the outcome and the diagnostic codes.
+
## Known boundaries before release
Native table/record processes and source-editor forms are implemented through standard QQQ metadata. Results include record navigation and download/preview metadata consumed by generic frontend components. Authenticated delivery requires verified bytes and a completed owner receipt before preview. There is no visual designer. Final integrated browser workflow acceptance, a qualified worker deployment, production storage/restore and printing integration remain release work. Service-only fields cannot be edited through unrestricted generic CRUD. Domain actions use validated services.
diff --git a/bridges/renderer/README.md b/bridges/renderer/README.md
index a644e20..ecf204a 100644
--- a/bridges/renderer/README.md
+++ b/bridges/renderer/README.md
@@ -36,8 +36,10 @@ nothing of the scene or the record.
- **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
+ `printable: false`, never as a label: a refused scene has no output, and its page shows only the outlined,
+ crossed boxes of the refused elements. A page of more than sixteen million dots is `PREVIEW_TOO_LARGE`, and so
+ is a label (or the boxes of a refused scene) that would paint more than sixteen times its page's dots; such a
+ label still prints, and the foundation's widget says so. 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:
diff --git a/bridges/renderer/pom.xml b/bridges/renderer/pom.xml
index dca1b62..30633ca 100644
--- a/bridges/renderer/pom.xml
+++ b/bridges/renderer/pom.xml
@@ -14,6 +14,8 @@
com.fasterxml.jackson.corejackson-databind2.22.4
org.junit.jupiterjunit-jupiter6.0.1test
+
+ com.kingsrook.qqqqqq-backend-module-rdbms${qqq.version}test
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 067cff7..f377852 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
@@ -47,6 +47,13 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
** 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.
+ **
+ ** The worker's answer to that question is remembered, so asking first is not
+ ** a promise about every job. After a renderer image is rolled back, ONE such
+ ** job can reach the old (0.1.0) renderer, its scene and its record's values
+ ** with it, before the bridge knows. That job is refused as
+ ** `RENDERER_TOO_OLD`, and a job that ends in `RENDERER_TOO_OLD`, however it
+ ** came to, ends what was remembered: the next such job asks the version first.
*******************************************************************************/
public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRenderer
{
@@ -68,7 +75,11 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
////////////////////////////////////////////////////////////////////////////////////////////////
// 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 //
+ // saves the version question; every answer is still held to the version rule. It is what //
+ // the bridge last saw, not what the renderer is now: after a renderer image is rolled back, //
+ // ONE job that only 0.2.0 can do is sent to the old (0.1.0) renderer on the strength of it. //
+ // That job is refused as RENDERER_TOO_OLD, and every job that ends in RENDERER_TOO_OLD //
+ // clears this, so the window is that one job and the next asks the version again //
////////////////////////////////////////////////////////////////////////////////////////////////
private volatile boolean structured;
@@ -231,8 +242,12 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
**
** 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.
+ ** only where the refused elements are, outlined and crossed, from the boxes
+ ** in their diagnostics; when no diagnostic has a box there is no page.
+ **
+ ** Drawing is bounded like the page: a label, or the boxes of a refused
+ ** scene, that would paint more than sixteen times the page's dots is
+ ** `PREVIEW_TOO_LARGE`, a result like any other named refusal.
***************************************************************************/
@Override
@SuppressWarnings("try") // The admission slot is held for its scope only; its value is never read.
@@ -271,7 +286,15 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
return (refused(printed, answer.diagnostics()));
}
byte[] bytes = output(answer, request.format(), maximum, printed);
- ZplRaster page = ZplRaster.page(bytes, printed);
+ ZplRaster page;
+ try
+ {
+ page = ZplRaster.page(bytes, printed);
+ }
+ catch(IllegalArgumentException undrawn)
+ {
+ throw (painted(undrawn));
+ }
List diagnostics = new ArrayList<>(answer.diagnostics());
if(!page.exact())
{
@@ -297,12 +320,15 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
** 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).
+ ** back only as far as keeps the area on the page). Boxes that would paint
+ ** more than a page may be painted with are not drawn at all: there is no
+ ** page, and `PREVIEW_TOO_LARGE` joins the diagnostics.
***************************************************************************/
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();
+ List findings = diagnostics;
if(!boxes.isEmpty())
{
int dpi = printed.dpi();
@@ -310,10 +336,38 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
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()));
+ try
+ {
+ ZplRaster page = ZplRaster.refused(width, height, left, top, boxes);
+ pages = List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height()));
+ }
+ catch(IllegalArgumentException undrawn)
+ {
+ if(!(painted(undrawn) instanceof GenerationPorts.RenderException large))
+ {
+ throw (undrawn);
+ }
+ findings = new ArrayList<>(diagnostics);
+ findings.add(large.diagnostic());
+ }
}
- return (new GenerationPorts.Preview(pages, false, false, diagnostics, 0L, RendererJson.hash(new byte[0])));
+ return (new GenerationPorts.Preview(pages, false, false, findings, 0L, RendererJson.hash(new byte[0])));
+ }
+
+ /***************************************************************************
+ ** What `ZplRaster` would not draw. A page that would be painted with more
+ ** than `ZplRaster.MAXIMUM_COATS` times its dots becomes the named refusal,
+ ** with the limit as a percentage of the page as the renderer states its
+ ** own; anything else it refuses stays what it was, an opaque failure.
+ ***************************************************************************/
+ private static Exception painted(IllegalArgumentException undrawn)
+ {
+ if(!GenerationPorts.FailureCode.PREVIEW_TOO_LARGE.name().equals(undrawn.getMessage()))
+ {
+ return (undrawn);
+ }
+ return (new GenerationPorts.RenderException(GenerationPorts.FailureCode.PREVIEW_TOO_LARGE, new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, null,
+ Map.of("paintedPercent", 100 * ZplRaster.MAXIMUM_COATS))));
}
/***************************************************************************
@@ -377,6 +431,14 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
** 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.
+ **
+ ** A job only renderer 0.2.0 can do is sent without the version question when
+ ** the last verified answer came from 0.2.0. If the renderer was replaced by
+ ** an older one since, this one job reaches it and is refused as
+ ** `RENDERER_TOO_OLD` whatever it answers. Any job that ends in
+ ** `RENDERER_TOO_OLD` (the worker is older, or a worker names the refusal
+ ** itself, alone or among collected diagnostics) forgets what was remembered,
+ ** so the next such job asks the version before it sends anything.
*******************************************************************************/
private WorkerAnswer invoke(String id, String operation, GenerationPorts.Format format, GenerationPorts.Revision revision,
Map context, List