feat(bridge): accept renderer 0.2.0, structured diagnostics, ZplRaster and preview; render target dpi end to end (P5, P7) #15
No files matched your search
+19
@@ -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.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -38,18 +38,21 @@
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.8.1</version><executions><execution><id>runtime-distribution</id><phase>package</phase><goals><goal>copy-dependencies</goal></goals><configuration><includeScope>runtime</includeScope><outputDirectory>${project.build.directory}/lib</outputDirectory></configuration></execution></executions></plugin>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.3.1</version><dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>10.12.6</version></dependency></dependencies><configuration><configLocation>${project.basedir}/checkstyle/config.xml</configLocation><propertyExpansion>checkstyle.reactor.root=${project.basedir}</propertyExpansion><includeTestSourceDirectory>true</includeTestSourceDirectory><consoleOutput>true</consoleOutput><violationSeverity>warning</violationSeverity><failOnViolation>true</failOnViolation></configuration><executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions></plugin>
|
||||
<plugin>
|
||||
<!-- Coverage gates. The BUNDLE rule is the measured baseline of the existing bridge, rounded down (2026-11-11: 85.6% of instructions and 82.6% of lines); raise it when coverage rises and
|
||||
<!-- Coverage gates. The BUNDLE rule is the measured baseline of the bridge, rounded down (2026-11-12, with the preview classes: 90.9% of instructions and 88.4% of lines; 2026-11-11: 85.6% and 82.6%); raise it when coverage rises and
|
||||
never lower it. The PACKAGE and CLASS rules hold new code to 100% line coverage from its first commit; a pattern matches nothing until its package or class exists, so the package that adds
|
||||
it needs no change here. Nothing is excluded: JaCoCo skips only code annotated as generated, and this build defines no such annotation. There is no skip switch. -->
|
||||
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. -->
|
||||
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.15</version><executions>
|
||||
<execution><goals><goal>prepare-agent</goal></goals></execution>
|
||||
<execution><id>report</id><phase>verify</phase><goals><goal>report</goal></goals></execution>
|
||||
<execution><id>coverage</id><phase>verify</phase><goals><goal>check</goal></goals><configuration><rules>
|
||||
<rule><element>BUNDLE</element><limits>
|
||||
<limit><counter>INSTRUCTION</counter><value>COVEREDRATIO</value><minimum>0.85</minimum></limit>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.82</minimum></limit>
|
||||
<limit><counter>INSTRUCTION</counter><value>COVEREDRATIO</value><minimum>0.90</minimum></limit>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.88</minimum></limit>
|
||||
</limits></rule>
|
||||
<rule><element>CLASS</element><includes><include>com.kingsrook.qbits.documentgeneration.renderer.ZplRaster</include><include>com.kingsrook.qbits.documentgeneration.renderer.ZplRaster.*</include></includes><limits>
|
||||
<rule><element>CLASS</element><includes><include>com.kingsrook.qbits.documentgeneration.renderer.ZplRaster</include><include>com.kingsrook.qbits.documentgeneration.renderer.ZplRaster.*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.renderer.WorkerAnswer</include><include>com.kingsrook.qbits.documentgeneration.renderer.WorkerAnswer.*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.renderer.SceneFacts</include><include>com.kingsrook.qbits.documentgeneration.renderer.SceneFacts.*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.renderer.RendererVersion</include><include>com.kingsrook.qbits.documentgeneration.renderer.RendererVersion.*</include></includes><limits>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
|
||||
</limits></rule>
|
||||
</rules></configuration></execution>
|
||||
|
||||
+259
-86
@@ -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<GenerationPorts.FailureCode> 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<Integer> 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<Diagnostic> 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<Diagnostic> diagnostics) throws Exception
|
||||
{
|
||||
List<List<Integer>> boxes = diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Diagnostic.Severity.ERROR && diagnostic.box() != null).map(Diagnostic::box).toList();
|
||||
List<GenerationPorts.PreviewPage> 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<String, Object> context, List<Map<String, Object>> resolved, int maximum) throws Exception
|
||||
private WorkerAnswer invoke(String id, String operation, GenerationPorts.Format format, GenerationPorts.Revision revision,
|
||||
Map<String, Object> context, List<Map<String, Object>> 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<String, Object> 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<String, Object> request(String id, String operation, GenerationPorts.Format format, String source, GenerationPorts.Stock stock, Object context,
|
||||
List<Map<String, Object>> resolved, int maximum)
|
||||
{
|
||||
Map<String, Object> 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);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
-64
@@ -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<String> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
+113
@@ -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<FailureCode> refusals;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One accepted renderer.
|
||||
***************************************************************************/
|
||||
RendererVersion(String wire, boolean structured, Set<FailureCode> 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));
|
||||
}
|
||||
}
|
||||
+228
@@ -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<String> 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<String> ids;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The facts of one source.
|
||||
***************************************************************************/
|
||||
private SceneFacts(boolean version2, Set<String> 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<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
+406
@@ -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<String> TEXT = Set.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "mediaType", "base64", "sha256", "code");
|
||||
private static final Set<String> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<Diagnostic> 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<String, String> words = new LinkedHashMap<>();
|
||||
List<Integer> box = null;
|
||||
Map<String, Integer> measured = null;
|
||||
Map<String, Integer> 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<String, Integer> numbers(JsonParser parser) throws IOException
|
||||
{
|
||||
Map<String, Integer> 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());
|
||||
}
|
||||
}
|
||||
+537
@@ -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<List<Integer>> boxes)
|
||||
{
|
||||
ZplRaster page = new ZplRaster(width, height);
|
||||
page.exact = false;
|
||||
for(List<Integer> 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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, "{}")));
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+272
@@ -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<JsonNode> jobs = new ArrayList<>();
|
||||
final List<JsonNode> questions = new ArrayList<>();
|
||||
final List<Integer> 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<String, Object> 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<String, Object> fields) throws Exception
|
||||
{
|
||||
return ((RendererJson.JSON.writeValueAsString(fields) + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The fields every answer has.
|
||||
***************************************************************************/
|
||||
static Map<String, Object> envelope(String requestId, String version, String outcome)
|
||||
{
|
||||
Map<String, Object> 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<String, Object> validated(String requestId, String version)
|
||||
{
|
||||
Map<String, Object> 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<String, Object> rendered(String requestId, String version, byte[] label, List<Map<String, Object>> diagnostics) throws Exception
|
||||
{
|
||||
Map<String, Object> 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<String, Object> refused(String requestId, List<Map<String, Object>> diagnostics)
|
||||
{
|
||||
Map<String, Object> fields = envelope(requestId, CURRENT, "REFUSED");
|
||||
fields.put("diagnostics", diagnostics);
|
||||
return (fields);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An error answer, with its one diagnostic when there is one.
|
||||
***************************************************************************/
|
||||
static Map<String, Object> error(String requestId, String version, String code, Map<String, Object> detail)
|
||||
{
|
||||
Map<String, Object> 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<String, Object> diagnostic(String code, String severity, Object... parts)
|
||||
{
|
||||
Map<String, Object> 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"))));
|
||||
}
|
||||
}
|
||||
+241
-12
@@ -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<String> COMMANDS = Set.of("XA", "XZ", "PW", "LL", "LH", "CI", "FO", "A0", "FH", "FD", "FS", "GB", "GF");
|
||||
private static final List<String> RELEASED = List.of("box-203", "text-font0-203", "image-203", "barcode-scale-203", "raster-text-rotation-203");
|
||||
private static final List<String> 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<String> 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<byte[]> 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<byte[]> released() throws Exception
|
||||
{
|
||||
List<byte[]> 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<String, Object> error = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "pointer", "/elements/4", "box", List.of(171, 68, 47, 23));
|
||||
Map<String, Object> 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<byte[]> 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.
|
||||
***************************************************************************/
|
||||
|
||||
+379
@@ -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<Path> files() throws Exception
|
||||
{
|
||||
try(Stream<Path> 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<String, Object> 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<Map<String, Object>> 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<String, Object> error = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "box", List.of(0, 0, 10, 10));
|
||||
List<FakeWorker.Answer> answers = List.of(
|
||||
job ->
|
||||
{
|
||||
Map<String, Object> 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(), "<p>a page</p>", 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);
|
||||
}
|
||||
}
|
||||
+177
@@ -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<String> 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<String> names)
|
||||
{
|
||||
for(Map.Entry<String, JsonNode> 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<String> 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));
|
||||
}
|
||||
}
|
||||
+354
@@ -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<String> names(JsonNode frame)
|
||||
{
|
||||
Set<String> 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<String> versions = new ArrayList<>(List.of(FakeWorker.CURRENT, FakeWorker.CURRENT, FakeWorker.RELEASED, FakeWorker.RELEASED));
|
||||
List<String> 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(), "<p>a page</p>", 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<Map<String, Object>> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+420
@@ -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<String, Object> 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<String, Object> fields)
|
||||
{
|
||||
return (assertThrows(IllegalArgumentException.class, () -> read(fields).settle("RENDER", true, true)).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A refused answer holding these diagnostics.
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> refused(Map<String, Object> 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<String, Object> 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<String, Object> warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "inner", "pointer", "/elements/1/children/0/width");
|
||||
Map<String, Object> override = FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "caption", "pointer", "/overrides/0/set/caption/size");
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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.<String, Object>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<String, Object> 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<Map<String, Object>> 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<String, Object> text = FakeWorker.envelope(ID, FakeWorker.CURRENT, "OK");
|
||||
text.put("mediaType", "x".repeat(129));
|
||||
assertEquals("RESPONSE_TEXT_LIMIT", rejection(text));
|
||||
Map<String, Object> 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<String, String> 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<String, Object> changed = FakeWorker.validated(ID, FakeWorker.CURRENT);
|
||||
changed.put(field, "qqq-renderer-0.3.0");
|
||||
assertEquals("WORKER_RESPONSE_REJECTED", rejection(changed), field);
|
||||
Map<String, Object> 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<String, Object> error = FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption");
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> bare = FakeWorker.envelope(ID, FakeWorker.CURRENT, "REFUSED");
|
||||
assertEquals("WORKER_RESPONSE_REJECTED", rejection(bare));
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> label = FakeWorker.rendered(ID, version, LABEL, List.of());
|
||||
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(label).settle("VALIDATE", false, false)).getMessage());
|
||||
Map<String, Object> none = FakeWorker.validated(ID, version);
|
||||
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(none).settle("RENDER", false, false)).getMessage());
|
||||
}
|
||||
Map<String, Object> 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<String, Object> late = FakeWorker.validated(ID, FakeWorker.CURRENT);
|
||||
late.remove("diagnostics");
|
||||
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(late).settle("VALIDATE", false, false)).getMessage());
|
||||
Map<String, Object> 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<String, Object> 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<Map<String, Object>> 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<String, Object> listed = FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", null);
|
||||
listed.put("diagnostics", List.of());
|
||||
rejected.add(listed);
|
||||
Map<String, Object> labelled = FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", null);
|
||||
labelled.put("mediaType", "application/vnd.zebra-zpl");
|
||||
rejected.add(labelled);
|
||||
Map<String, Object> uncoded = FakeWorker.envelope(ID, FakeWorker.CURRENT, "ERROR");
|
||||
rejected.add(uncoded);
|
||||
Map<String, Object> numbered = FakeWorker.envelope(ID, FakeWorker.CURRENT, "ERROR");
|
||||
numbered.put("length", 1);
|
||||
rejected.add(numbered);
|
||||
for(Map<String, Object> 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<String, Object> 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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+355
@@ -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 `<case>-<dpi>.zpl`.
|
||||
***************************************************************************/
|
||||
static List<String> goldens() throws Exception
|
||||
{
|
||||
return (ZplSceneTwoPolicyTest.labels());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The stored version 1 labels, by case name.
|
||||
***************************************************************************/
|
||||
static List<String> released() throws Exception
|
||||
{
|
||||
try(Stream<Path> files = Files.list(CORPUS.resolve("zpl")))
|
||||
{
|
||||
List<String> 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<Integer, List<Integer>> 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<String, String> 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<String, String> 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));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+69
-7
@@ -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.
|
||||
|
||||
+45
-1
@@ -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<String, Object> held(Map<String, Object> 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.
|
||||
*******************************************************************************/
|
||||
|
||||
+24
-2
@@ -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<String, java.io.Serializable> 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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -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"))
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+233
@@ -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<Integer> labelRenderer() throws Exception
|
||||
{
|
||||
List<Integer> 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<String> 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<Integer> 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.
|
||||
|
||||
+35
@@ -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<String, Object> context = new HashMap<>();
|
||||
context.put("name", "PRIVATE_SENTINEL");
|
||||
context.put("absent", null);
|
||||
var secret = new GenerationPorts.Revision(1, 2, "orders", STOCK, "<p>SOURCE_SENTINEL</p>", "{\"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<String, Object> 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));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
|
||||
+35
@@ -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.
|
||||
***************************************************************************/
|
||||
|
||||
+35
@@ -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.
|
||||
***************************************************************************/
|
||||
|
||||
+9
@@ -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.
|
||||
***********************************************************************/
|
||||
|
||||
+9
@@ -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.
|
||||
***********************************************************************/
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<String, Object> 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<String, Object> 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<String> names;
|
||||
try(Stream<Path> 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<String, Object> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user