Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
589b6d41ee
|
||
|
|
f5acf93e3d
|
||
|
|
d3378825d7
|
||
|
|
bb25d6c46a
|
||
|
|
bf546e16c5
|
||
|
|
9b908ac516
|
||
|
|
595d094ec8
|
||
|
|
58051e7b99
|
||
|
|
1992fdf703
|
||
|
|
ad6b0ffa88
|
||
|
|
4cd2f93e37
|
||
|
|
f4342e345b
|
||
|
|
fda9ad0de6
|
||
|
|
22063ccfb1
|
No files matched your search
@@ -12,3 +12,10 @@
|
||||
*.woff2 binary
|
||||
*.jar binary
|
||||
*.mmdb binary
|
||||
|
||||
# Stored renderer outputs are compared byte for byte: git must never rewrite them.
|
||||
renderer/src/test/resources/compat-v1/** -text
|
||||
renderer/src/test/resources/scene2/golden/** -text
|
||||
|
||||
# Bundled fonts and their licence texts are shipped exactly as released and are hashed: git must never rewrite them.
|
||||
renderer/src/main/resources/fonts/** -text
|
||||
+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", "maxLines", "shrinkTo", "overflow", "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(46, 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));
|
||||
}
|
||||
}
|
||||
+7
@@ -47,6 +47,12 @@ class ZplSceneOneCompatibilityTest
|
||||
{
|
||||
private static final Path CORPUS = Path.of("..", "..", "renderer", "src", "test", "resources", "compat-v1");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the SHA-256 of `SHA256SUMS` itself, the same value the renderer's test pins, so the //
|
||||
// outputs and their sums cannot be regenerated together and still pass //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final String MANIFEST_SHA256 = "cb6ec01d084e9077f3b5f6c8f39ed2f1de1116d92697dbbf529557fc84da5b2a";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
@@ -54,6 +60,7 @@ class ZplSceneOneCompatibilityTest
|
||||
***************************************************************************/
|
||||
static List<String> outputs() throws Exception
|
||||
{
|
||||
assertEquals(MANIFEST_SHA256, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(Files.readAllBytes(CORPUS.resolve("SHA256SUMS")))), "The corpus manifest was regenerated or edited");
|
||||
List<String> lines = Files.readAllLines(CORPUS.resolve("SHA256SUMS"), StandardCharsets.US_ASCII);
|
||||
assertFalse(lines.isEmpty());
|
||||
return (lines);
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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.List;
|
||||
import java.util.stream.Stream;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A version 2 label, as the host receives it: each golden label of the
|
||||
** renderer's `scene2` cases passes the unchanged host printer gate for its
|
||||
** stock at the resolution it was rendered for. A version 2 label is graphic
|
||||
** fields only, which the gate has always admitted. The goldens have one copy,
|
||||
** in the renderer module, where the renderer's own test proves this build
|
||||
** still emits them byte for byte.
|
||||
*******************************************************************************/
|
||||
class ZplSceneTwoPolicyTest
|
||||
{
|
||||
private static final Path SCENES = Path.of("..", "..", "renderer", "src", "test", "resources", "scene2");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The stored labels, named `<case>-<dpi>.zpl`.
|
||||
***************************************************************************/
|
||||
static List<String> labels() throws Exception
|
||||
{
|
||||
try(Stream<Path> files = Files.list(SCENES.resolve("golden")))
|
||||
{
|
||||
List<String> labels = files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".zpl")).sorted().toList();
|
||||
assertFalse(labels.isEmpty());
|
||||
return (labels);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The gate accepts the label for the stock at its render target, and for no
|
||||
** other resolution: the page size in the label is the target's.
|
||||
***************************************************************************/
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("labels")
|
||||
void aVersionTwoLabelPassesTheUnchangedHostPrinterGate(String label) throws Exception
|
||||
{
|
||||
assertTrue(label.matches("[a-z0-9-]+-(203|300|600)\\.zpl"), label);
|
||||
String name = label.substring(0, label.length() - 8);
|
||||
int dpi = Integer.parseInt(label.substring(label.length() - 7, label.length() - 4));
|
||||
byte[] bytes = Files.readAllBytes(SCENES.resolve("golden").resolve(label));
|
||||
JsonNode stock = RendererJson.JSON.readTree(Files.readAllBytes(SCENES.resolve("cases").resolve(name + ".json"))).path("stock");
|
||||
assertDoesNotThrow(() -> ZplOutputPolicy.validate(bytes, stock(stock, dpi)), label);
|
||||
for(int other : List.of(203, 300, 600))
|
||||
{
|
||||
if(other != dpi)
|
||||
{
|
||||
assertEquals("ZPL_STOCK_MISMATCH", assertThrows(IllegalArgumentException.class, () -> ZplOutputPolicy.validate(bytes, stock(stock, other))).getMessage());
|
||||
}
|
||||
}
|
||||
String text = new String(bytes, StandardCharsets.US_ASCII);
|
||||
assertTrue(text.contains("^GFA,"), label);
|
||||
assertFalse(text.contains("^GB") || text.contains("^A0") || text.contains("~"), label);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The case's stock at a render target.
|
||||
***************************************************************************/
|
||||
private static GenerationPorts.Stock stock(JsonNode stock, int dpi)
|
||||
{
|
||||
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(), dpi));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+65
-11
@@ -8,21 +8,64 @@ and the isolated renderer is treated as compromisable, so its output is untruste
|
||||
| Target | Module | Input | Campaign |
|
||||
|---|---|---|---|
|
||||
| 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`): bit
|
||||
flips, byte and range edits, truncation, duplication and tokens of the format spliced in. Each campaign checks
|
||||
unsafe acceptance, not only crashes:
|
||||
mutates valid inputs from the version 1 compatibility corpus (`renderer/src/test/resources/compat-v1`) and the
|
||||
version 2 layout cases (`renderer/src/test/resources/scene2/cases`): bit flips, byte and range edits,
|
||||
truncation, duplication and tokens of the format spliced in. Each campaign checks unsafe acceptance, not only
|
||||
crashes:
|
||||
|
||||
- **Scene parser.** An accepted input, read again by a second and differently configured JSON parser, is a
|
||||
version 1 scene of known element types within the element limit. Any exception is a refusal, because the
|
||||
worker turns every exception into its opaque error answer. A stack overflow or a failed acceptance check is a
|
||||
finding.
|
||||
- **Version 1 scene parser.** An accepted input, read again by a second and differently configured JSON parser,
|
||||
is a version 1 scene of known element types within the element limit. A refusal is the parser's
|
||||
`IllegalArgumentException` or exactly one of the JSON reader's exceptions for text that is not one JSON value
|
||||
(`JsonParseException`, `JsonEOFException`, `MismatchedInputException`, `StreamConstraintsException`). Any
|
||||
other exception (a `NullPointerException`, an index out of bounds), a stack overflow or a failed acceptance
|
||||
check is a finding.
|
||||
- **Version 2 scene parser.** The target is the worker's own routing, so a mutated source may also become a
|
||||
version 1 scene, which is then held to the version 1 property. An accepted version 2 scene, read by the
|
||||
second parser, has only the declared top-level properties, a physical design size, at most 1000 elements
|
||||
with unique ids of the fixed pattern, only `box`, `group` and `text` elements with only their declared
|
||||
properties (barcodes and images are refused until their packages exist), groups at most four deep, every
|
||||
length a number and a unit, and overrides only for the three resolutions and for declared ids. A `text`
|
||||
element is read as it stands at each resolution (the defaults, then the element, then the overrides for that
|
||||
resolution): it names a bundled face, has a physical size from 1 to 1000 pt, exactly one of a literal value
|
||||
and a pointer into the record, and only fit, length, threshold, rotation, reverse and line height settings of
|
||||
the documented kinds and ranges. The corpus holds the three text cases (`text-faces`, `text-fit`,
|
||||
`text-turned`) beside the layout cases, and the spliced tokens include the text properties, a combining
|
||||
mark, a tab, half of a surrogate pair, a size of `1000pt` and the number `1e400`. About one version 2 input in
|
||||
four, chosen by its bytes so that a replay does the same, is rendered instead of validated: the label is read
|
||||
back into a page, must be only the header, uncompressed graphic fields and the end of a label, must put no ink
|
||||
outside the printable area, and validation of the same input must give the same answer (accepted with the same
|
||||
findings, or refused with the same diagnostic). The one difference allowed is `VALUE_MISSING`: a text bound to
|
||||
the record has no value in the campaign's empty record, and validation reads no record. A refusal is the scene's own `Refusal`, the exception that
|
||||
carries a diagnostic, and nothing else: the routing turns unreadable JSON and an unknown schema into one. A
|
||||
plain `IllegalArgumentException` is a finding, because the package raises those for its own broken invariants
|
||||
(`CANVAS_BOUNDS`, `CANVAS_LIMIT`, `LENGTH_NOT_ABSOLUTE`, `LENGTH_NOT_PHYSICAL`), which no scene may reach; so is
|
||||
the text package's `IllegalStateException` for ink outside a measured line (`TEXT_INK_OUTSIDE_MEASURE`), and
|
||||
any arithmetic or number-format failure. The
|
||||
one exception is a source the routing sends to the frozen version 1 parser, whose own refusal is a plain
|
||||
`IllegalArgumentException`; such sources are validated only. The campaign also asserts that it rendered labels.
|
||||
- **Printer gate.** An accepted input contains only the commands the renderer may emit and no tilde command.
|
||||
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.
|
||||
|
||||
@@ -51,18 +94,29 @@ public issue.
|
||||
|
||||
Limits: inputs are at most 1 MiB, a campaign is at most 100000 iterations, and the corpus entries used are
|
||||
under 20 KiB. The harness writes no files, launches no process and reads no credentials. Generated ZPL is never
|
||||
sent to a printer and generated scenes are only validated, not rendered.
|
||||
sent to a printer. Generated version 2 scenes are rendered in memory on a 2 x 1 in stock at 203 dpi, a canvas of
|
||||
374 by 171 dots; generated version 1 scenes are only validated.
|
||||
|
||||
## Adding a target
|
||||
|
||||
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. The version 2 scene parser and `ZplRaster` are the next
|
||||
two targets.
|
||||
of what it accepts, and adds a row to the table above.
|
||||
|
||||
## Review record
|
||||
|
||||
- **Added:** 2026-11-11, with the two campaigns above.
|
||||
- **Added:** 2026-11-11, with the version 1 scene parser and printer gate campaigns.
|
||||
- **2026-11-12:** the version 2 scene parser campaign was added with the parser. The version 1 campaign had
|
||||
counted any exception as a refusal, so a crash in the parser would have passed; it now accepts only the
|
||||
declared refusals. Both seeds at 20000 iterations, and both at 100000, found nothing under the stricter rule.
|
||||
- **2026-11-12, review of the scene 2 core:** the version 2 campaign had counted any `IllegalArgumentException`
|
||||
as a refusal and only validated. It now accepts only a `Refusal` and renders about a quarter of its inputs,
|
||||
reading each label back. Both seeds at 20000 iterations, and both at 100000, found nothing under the stricter
|
||||
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.
|
||||
|
||||
+70
-6
@@ -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))
|
||||
{
|
||||
@@ -126,12 +126,27 @@ public final class GenerationPorts
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** One renderer finding about a layout: a code from a closed vocabulary, the
|
||||
** element id the template author wrote, and numbers. It has no free text, so
|
||||
** no record value, context text or exception message can travel in it.
|
||||
** 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.
|
||||
***************************************************************************/
|
||||
public record Diagnostic(String code, Severity severity, String element, List<Integer> box, Map<String, Integer> measured, Map<String, Integer> limit)
|
||||
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
|
||||
** scene source itself the JSON pointer of the offending place. It has no free
|
||||
** text, so no record value, context text or exception message can travel in
|
||||
** it: a pointer is only property names of the scene schema, array indexes
|
||||
** and declared element ids, which the renderer checks where it creates the
|
||||
** diagnostic and this record holds to their shape.
|
||||
***************************************************************************/
|
||||
public record Diagnostic(String code, Severity severity, String element, List<Integer> box, Map<String, Integer> measured, Map<String, Integer> limit, String pointer)
|
||||
{
|
||||
private static final java.util.regex.Pattern POINTER = java.util.regex.Pattern.compile("(/(0|[1-9][0-9]{0,3}|[A-Za-z][A-Za-z0-9_-]{0,31})){0,16}");
|
||||
private static final java.util.Set<String> WARNINGS = java.util.Set.of("TEXT_TRUNCATED", "TEXT_SMALL_FOR_DPI", "TEXT_LIGHT_FACE", "FIT_NOT_PROVEN",
|
||||
"BARCODE_MODULE_BELOW_RECOMMENDED", "QUIET_ZONE_INTRUDED", "ELEMENTS_OVERLAP", "IMAGE_UPSCALED", "DEVICE_UNIT_USED", "PRINTER_FONT_NOT_SHOWN");
|
||||
private static final java.util.Set<String> INFORMATION = java.util.Set.of("TEXT_SHRUNK", "TEXT_WRAPPED", "BARCODE_MODULE", "IMAGE_SCALED");
|
||||
@@ -144,14 +159,29 @@ public final class GenerationPorts
|
||||
ERROR, WARNING, INFO
|
||||
}
|
||||
|
||||
/************************************************************************
|
||||
** A diagnostic that points at no place in the scene source.
|
||||
************************************************************************/
|
||||
public Diagnostic(String code, Severity severity, String element, List<Integer> box, Map<String, Integer> measured, Map<String, Integer> limit)
|
||||
{
|
||||
this(code, severity, element, box, measured, limit, null);
|
||||
}
|
||||
|
||||
/************************************************************************
|
||||
** Admit only the vocabulary: an error is a FailureCode name, a warning or
|
||||
** information is one of the fixed names. The element is absent or an
|
||||
** author-written id, the box is absent or x, y, width and height in dots,
|
||||
** and measured values and limits are at most eight named integers each.
|
||||
** measured values and limits are at most eight named integers each, and
|
||||
** the pointer is absent or at most sixteen segments, each an array index
|
||||
** or a name of the same shape as an element id (the empty pointer is the
|
||||
** whole scene).
|
||||
************************************************************************/
|
||||
public Diagnostic
|
||||
{
|
||||
if(pointer != null && !POINTER.matcher(pointer).matches())
|
||||
{
|
||||
throw new IllegalArgumentException("A diagnostic pointer is made of names and indexes.");
|
||||
}
|
||||
if(severity == null || code == null || !known(code, severity))
|
||||
{
|
||||
throw new IllegalArgumentException("Unknown diagnostic code for its severity.");
|
||||
@@ -358,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 + "]");
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
@@ -387,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 + "]");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -621,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.
|
||||
|
||||
+58
@@ -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));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
@@ -285,6 +320,29 @@ class GenerationPortsContractTest
|
||||
assertThrows(UnsupportedOperationException.class, () -> diagnostic.measured().put("widthDots", 1));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A finding about the scene source says where: a JSON pointer made only of
|
||||
** names and array indexes. A value, a sentence or an escaped segment has no
|
||||
** way into it, and a diagnostic made without one has none.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aDiagnosticPointerIsMadeOfNamesAndIndexes()
|
||||
{
|
||||
assertNull(new Diagnostic("SCENE_INVALID", Severity.ERROR, "caption", null, null, null).pointer());
|
||||
for(String pointer : List.of("", "/schema", "/design/width", "/elements/0", "/elements/12/fit/shrinkTo", "/elements/3/children/0/children/1/children/2/children/9999/thickness",
|
||||
"/overrides/1/set/order_id-2/threshold", "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p"))
|
||||
{
|
||||
assertEquals(pointer, new Diagnostic("SCENE_INVALID", Severity.ERROR, null, null, null, null, pointer).pointer());
|
||||
}
|
||||
for(String pointer : List.of("elements", "/", "/elements/", "//elements", "/elements/01", "/elements/-1", "/elements/12345", "/Jane Example", "/record/name value", "/elements/0/~1", "/elements/0/fit\n",
|
||||
"/9caption", "/" + "a".repeat(33), "/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q", "/(01)00012345678905"))
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("SCENE_INVALID", Severity.ERROR, null, null, null, null, pointer), pointer);
|
||||
}
|
||||
var detail = new Diagnostic("SCENE_INVALID", Severity.ERROR, "caption", null, Map.of("unknownProperties", 2), Map.of("unknownProperties", 0), "/elements/1");
|
||||
assertEquals("/elements/1", new GenerationPorts.RenderException(FailureCode.SCENE_INVALID, detail).diagnostic().pointer());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A refusal carries only the error diagnostic with its own code, and its
|
||||
** public message is still the code alone.
|
||||
|
||||
+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.
|
||||
***********************************************************************/
|
||||
|
||||
@@ -26,3 +26,12 @@ The encoder and prime-field ECC helper retain the MIT license. See
|
||||
META-INF/licenses/bwipp-code93i-MIT.txt and Code93i.java. Pinned corresponding
|
||||
upstream source, helper, tests and provenance are retained under
|
||||
src/test/resources/barcodes/code93i.
|
||||
|
||||
Bundled fonts (2026-11-12). This distribution contains, unmodified and as data,
|
||||
IBM Plex Sans Regular and Bold and IBM Plex Sans Condensed Bold, Copyright (c)
|
||||
2017 IBM Corp. with Reserved Font Name "Plex", and JetBrains Mono Regular and
|
||||
Bold, Copyright 2020 The JetBrains Mono Project Authors
|
||||
(https://github.com/JetBrains/JetBrainsMono). They are licensed under the SIL
|
||||
Open Font License, Version 1.1, separately from the renderer's Apache license,
|
||||
which does not replace it. Each font is in /fonts of the renderer JAR beside
|
||||
the full license text and a provenance note; /fonts/SHA256SUMS lists the hashes.
|
||||
+44
-2
@@ -40,7 +40,11 @@ One UTF-8 JSON object followed by a newline enters stdin, and one JSON response
|
||||
|
||||
`operation` is `RENDER`, `VALIDATE`, or `CATALOG`. A catalog request needs only protocol, requestId and operation and returns versioned barcode IDs and typed option metadata. `VALIDATE` compiles PDF template syntax or checks the ZPL scene structure plus asset identity. It does not evaluate arbitrary context, certify future layout fit, or promise that every source option will render with every record. Production publication should also render representative fixtures inside the qualified boundary.
|
||||
|
||||
A successful render returns protocol, requestId, `outcome: "OK"`, `rendererVersion: "qqq-renderer-0.1.0"`, `catalogVersion: "qqq-barcodes-9"`, mediaType, base64, SHA-256, decoded byte length and pageCount. Validation returns no document bytes. Errors return `outcome: "ERROR", code: "RENDER_FAILED"`; no source/context or exception stack is included. The host must independently check request identity, response shape, decoded size/hash, format policy, successful process termination and container removal. Worker-reported metadata is untrusted. An external parent must not persist raw stderr containing dependency diagnostics.
|
||||
A successful render returns protocol, requestId, `outcome: "OK"`, `rendererVersion: "qqq-renderer-0.2.0"`, `catalogVersion: "qqq-barcodes-9"`, mediaType, base64, SHA-256, decoded byte length, pageCount and `diagnostics` (a list, empty except for a version 2 scene). Validation returns no document bytes. Errors return `outcome: "ERROR", code: "RENDER_FAILED"`; no source/context or exception stack is included. A version 2 scene refusal returns its own code and one diagnostic as `detail` (see "ZPL scene 2").
|
||||
|
||||
Renderer `0.2.0` adds, all optional: `"targetDpi"` (203, 300 or 600; the resolution a version 2 scene is rendered at, by default the stock's `dpi`; a version 1 scene with a target other than its stock's `dpi` is refused as `TARGET_REQUIRES_SCENE_2`) and `"diagnostics": "COLLECT"` (keep every element's refusal instead of stopping at the first; the outcome is then `REFUSED` when there is at least one, and the answer carries the diagnostics and no document: no `mediaType`, `base64`, `sha256`, `length` or `pageCount`, so a host that looks for `base64` without reading the outcome finds nothing to print). The operations `INSPECT_ASSET` and `UPGRADE_SCENE` are routed and answer `UNSUPPORTED_OPERATION` until their packages exist. A host bridge that accepts only `qqq-renderer-0.1.0` does not accept these answers: upgrade the host library before the image. The host must independently check request identity, response shape, decoded size/hash, format policy, successful process termination and container removal. Worker-reported metadata is untrusted. An external parent must not persist raw stderr containing dependency diagnostics.
|
||||
|
||||
**Compatibility of version 1 answers.** The ZPL bytes of a version 1 scene are unchanged; the corpus in `src/test/resources/compat-v1` holds them byte for byte. The answer envelope around them changed in two ways in `0.2.0`, for a request a `0.1.0` host already sends. Every `OK` answer gains `diagnostics: []`: six fields for `VALIDATE` and eleven for `RENDER`, where there were five and ten. And a ZPL source that is not readable JSON, has no `schema` or has an unknown `schema` now answers `code: "SCENE_INVALID"` with a `detail` diagnostic (pointer `""`, or `/schema`), where `0.1.0` answered the opaque `RENDER_FAILED`. Every other version 1 refusal keeps its code. Nothing in a released host depended on `RENDER_FAILED` for those three cases: it is not a `GenerationPorts.FailureCode`, so `bridges/renderer` rejects such an answer as `WORKER_ERROR_REJECTED`, exactly as it rejects every `0.2.0` answer today (it accepts only `qqq-renderer-0.1.0` and the exact `0.1.0` field counts). The bridge that accepts `0.2.0` must take the extra `diagnostics` field, `detail` on an error, the `REFUSED` outcome, and `SCENE_INVALID` and the other diagnostic codes as failure codes.
|
||||
|
||||
## PDF authoring
|
||||
|
||||
@@ -98,9 +102,47 @@ Before any text font/raster allocation, the scene is bounded to 64 `rasterText`
|
||||
|
||||
Only `^XA`, `^XZ`, `^PW`, `^LL`, `^LH`, `^CI28`, `^FO`, `^A0N`, `^FH_`, `^FD`, `^FS`, `^GB` and `^GFA` are generated. Text is fully hex-encoded UTF-8, so `^`, `~` and `_` in record values cannot become new commands. Barcode/image raster fields use fixed uppercase hex and split below the 99,999-byte field limit. Placement uses overflow-safe bounds checks. No stored formats, downloads, network commands, persistent settings, cuts or resets are emitted. The printing boundary still needs independent output grammar enforcement because a compromised worker must not be trusted to report safe printer bytes.
|
||||
|
||||
## ZPL scene 2
|
||||
|
||||
A source with schema `qqq-zpl-scene/2` is a layout in physical units that renders at 203, 300 or 600 dpi. The worker chooses the code path from `schema` alone: version 1 is unchanged and frozen, and any other source is refused as `SCENE_INVALID`. The schema is `schema/zpl-scene-2.schema.json`; the design is `docs/design/LABEL-LAYOUT-FONTS-PREVIEW.md`.
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "qqq-zpl-scene/2",
|
||||
"design": { "width": "4in", "height": "1in" },
|
||||
"elements": [
|
||||
{ "id": "frame", "type": "box", "thickness": "0.5pt" },
|
||||
{ "id": "panel", "type": "group", "anchor": "center", "width": "50%", "height": "60%", "children": [
|
||||
{ "id": "mark", "type": "box", "anchor": "bottom-right", "x": "1mm", "y": "1mm", "width": "6mm", "height": "3mm", "fill": true } ] },
|
||||
{ "id": "rule", "type": "box", "anchor": "bottom-center", "y": "2mm", "width": "80%", "height": "0.2mm", "thickness": "0.2mm" }
|
||||
],
|
||||
"overrides": [ { "when": { "dpi": [203] }, "set": { "frame": { "thickness": "1dot" } } } ]
|
||||
}
|
||||
```
|
||||
|
||||
- **Lengths** are strings of a number (up to three decimals) and a unit: `mm`, `in`, `pt` (1/72 in), `%` (of the container's width for `x` and `width`, of its height for `y` and `height`) and `dot`. The most a length may be is 2 m (`2000mm`, `78.74in`, `5669.292pt`), `32000dot` or `100%`; anything longer, up to the largest number the syntax can write, is `SCENE_INVALID` at its pointer. A physical length becomes whole micrometres, then dots at the render target: a position to the nearest dot, a box width or height rounded down, a thickness to the nearest dot and never less than one. A `dot` length outside an override raises the warning `DEVICE_UNIT_USED`.
|
||||
- **Coordinates** start at the top left of the printable area, the stock less its margins. In dots the printable area is `floor((width - left - right) x dpi)` wide and `floor((height - top - bottom) x dpi)` high, which is how a box size is rounded, and it starts at `ceil(left x dpi)`, `ceil(top x dpi)`, moved back by one dot where it would otherwise pass the page edge. So a box of exactly the printable width and height fits at 203, 300 and 600 dpi alike. No ink is ever a whole dot inside a margin; the right and bottom edges may pass theirs by a fraction of a dot (less than 0.125 mm at 203 dpi). `design` states the stock the layout was drawn for; the request's stock must match it within 0.5 mm on each side (`STOCK_MISMATCH`).
|
||||
- **Every element** has a unique `id` (`[A-Za-z][A-Za-z0-9_-]{0,31}`), a `type`, an `anchor` (one of nine points, default `top-left`: the same point of the box is placed on that point of its container), `x` and `y` (distances inward from the anchored edge; on a centred axis a shift that may be negative), `width` and `height` (omitted: the rest of the container), and `align` and `valign` (where content sits in the box).
|
||||
- **`box`** has `thickness` and optional `fill`; a line is a box whose width or height is its thickness. **`group`** has `children`, placed relative to its box; groups nest four deep. A scene holds at most 1000 elements, and its elements may paint at most sixteen times its printable area in all, an overlap counted each time: a filled box counts its area, an outline its four bars, a group nothing. The element that would pass that is refused as `SCENE_INVALID` at its pointer, with its box and `measured.paintedPercent` against `limit.paintedPercent` of 1600. A real label paints less than twice its area; the limit holds the worst scene to sixteen fills of the canvas where 1000 were possible.
|
||||
- **`text`** draws one value in a face bundled with the renderer: `qqq-sans`, `qqq-sans-bold` (IBM Plex Sans), `qqq-condensed-bold` (IBM Plex Sans Condensed) and `qqq-mono`, `qqq-mono-bold` (JetBrains Mono). There is no printer font in version 2 and no installed font is used; any other `font` is `FONT_UNKNOWN`, and a request that supplies an asset whose name begins `qqq-` is refused as `RESERVED_FONT_NAME`. `font` and `size` may come from the scene's `defaults`. The value is `value` or, read from the record when rendering, `valuePath` (text as it is, `true` or `false`, or a number in plain decimal digits with no exponent and no fraction that is only zeros: `1.0E10` prints `10000000000`, `2.50` prints `2.5`; anything else is `VALUE_MISSING`); at most 1024 characters, each of which the face must draw as one glyph from left to right (no control or format characters, combining marks, shaping or right-to-left scripts: `FONT_MISSING_GLYPH`, with the count).
|
||||
- **Size and measurement.** `size` is the em as a physical length from `1pt` to `1000pt`, not rounded to dots. A text is measured before it is drawn, by integer arithmetic on the face's own numbers, so the measurement is the same on every machine. A line's box is its pen advance widened to hold the ink of every character in it, plus two dots on each inked side for the rasteriser. A text whose ink the rasteriser puts outside that box is not printed in part and does not fail the render: the one element is refused as `TEXT_DOES_NOT_FIT` with `measured.glyphsOutsideMeasure` and `measured.dotsOutsideMeasure`, when the scene is validated if its value is a literal. No bundled face does so at any size from 1 pt to 1000 pt. The block of lines is placed in the element's box by `align` and `valign`, and each line in the block by `align`. `lineHeight` is the distance between baselines as a multiple of the size (default: the face's own). `rotation` turns the text clockwise in a box given as it appears on the label; `reverse` prints white letters on the box filled black.
|
||||
- **Fit.** In this order: lay the value out at `size` within `fit.maxLines` (default 1); if it does not fit, take the largest size, in steps of 0.25 pt down to `fit.shrinkTo`, at which it does (`TEXT_SHRUNK`); if none does, apply `fit.overflow`. `shrinkTo` defaults to 70% of the size and not below 5 pt. `overflow` is `refuse` (the default: `TEXT_DOES_NOT_FIT`, with the measured width, height, lines and size against the box) or `truncate`, which cuts at a character, ends with an ellipsis and warns `TEXT_TRUNCATED`. At each size a line is broken between words while that fits and at any character when it does not, so a value with no space wraps and a larger size is preferred to whole words (`TEXT_WRAPPED`). A member of `fit` that is wrong is `SCENE_INVALID` at that member (`/elements/3/fit/maxLines`). The size chosen is exactly the one a search of every quarter-point step gives, found without making that search, and all the text of one scene is held to a stated amount of work: 64 million steps, where a character visited while fitting is one step, a character to be drawn is 16 and a glyph the scene draws for the first time at a size is 4096. A scene that passes it is refused as `SCENE_INVALID` at the element that did, with `measured.textWorkSteps` against `limit.textWorkSteps`, the same whether it is validated or rendered and on every machine. That is about a second of a worker; a label of a hundred values takes a few percent of it.
|
||||
- **Proving the fit.** `maxLength` is the longest value in characters. When a scene is validated, that many of the face's widest character must fit whole, each taken to reach as far above, below and beside its advance as the furthest-reaching character of the face; otherwise the scene is refused as `TEXT_DOES_NOT_FIT`. The proof never cuts. An element with `overflow: truncate` whose stated length is not proven is still valid, since cutting is what its author chose, but every validation warns `FIT_NOT_PROVEN` with `measured.provenCharacters`, the longest length that is proven to fit whole, against `limit.characters`: longer values may be cut. A scene that passes cannot be refused at that render target because a value of up to that length does not fit, whichever of the face's characters it holds. The proof is therefore stricter than a typical value needs, and a box that is exactly `lines x lineHeight` high is not proven: leave room for an accent above a capital. When printing, a longer value is `VALUE_TOO_LONG` before any layout. A bound element without `maxLength` warns `FIT_NOT_PROVEN` with no number. Validation reads no record. The proof is for the render target of the request; validate at each resolution the document prints at.
|
||||
- **Legibility.** Glyphs are drawn antialiased and a dot is ink when its grey is darker than `threshold` (1 to 254, default 128), so a higher value prints heavier. `TEXT_SMALL_FOR_DPI` warns when the capitals are under 12 dots high at the render target (about 6 pt at 203 dpi), `TEXT_LIGHT_FACE` when a regular weight is used with capitals under 20 dots (about 10 pt at 203 dpi). A literal is judged at the size it is drawn at; a bound value, when validated, at the size its `maxLength` is proven at, which is the smallest any value is printed at (the floor when the proof fails and the element cuts), and at its stated size when it has no `maxLength`. An override is how a scene answers for one resolution: a bold face or a higher `threshold` at 203 dpi.
|
||||
- **`barcode` and `image`** are parsed and placed by renderer `0.2.0` and then refused as `RENDERER_TOO_OLD`, with the box they would have had, until their packages are in the renderer.
|
||||
- **`overrides`** replace properties of declared elements at the listed resolutions and are the only place a scene may depend on resolution. They are checked for all three resolutions whatever the request's target. **`samples`** are accepted and ignored by rendering.
|
||||
- **Output.** The whole label is composed on one 1-bit canvas and emitted as uncompressed `^GFA` fields: rows are cut into bands wherever eight or more blank rows separate ink, each band is trimmed to its inked columns, and a band is continued in a further field at 99,999 bytes. Only `^XA`, `^PW`, `^LL`, `^LH`, `^CI28`, `^FO`, `^GFA`, `^FS` and `^XZ` are emitted.
|
||||
- **Diagnostics.** A refusal is one diagnostic: `code`, `severity`, the `element` id, the element's `box` in dots from the top left of the printable area, and `measured` and `limit` as named integers. `SCENE_INVALID` also carries `pointer`, the JSON pointer of the offending place. A diagnostic names only an id the scene declares and points only through schema property names, array indexes and declared ids, so no record value, sample or unknown property name can leave the worker in one. A stock the printer language cannot address stays the opaque `RENDER_FAILED`, as for version 1.
|
||||
|
||||
```json
|
||||
{ "code": "ELEMENT_OUTSIDE_PRINTABLE_AREA", "severity": "ERROR", "element": "mark", "box": [719, 0, 79, 39],
|
||||
"measured": { "bottomDots": 39, "leftDots": 719, "rightDots": 798, "topDots": 0 },
|
||||
"limit": { "bottomDots": 155, "leftDots": 0, "rightDots": 764, "topDots": 0 } }
|
||||
```
|
||||
|
||||
## Limits
|
||||
|
||||
Sources: 262,144 characters. Context: depth 16, 10,000 nodes, 65,536 characters per string, 262,144 aggregate text characters. Stock: 1 mm–2 m, valid margins, 72–1200 DPI (ZPL additionally bounds width to 9,999 dots and height to 32,000). Assets: 100 entries, 2 MiB each, 8 MiB aggregate; image dimensions ≤4096 and ≤4 million pixels. Expanded HTML: 2 million characters and 20,000 elements. PDF pages: 1–500, checked after rendering. Output: caller limit, hard maximum 100 MiB; normal host default is 20 MiB. ZPL: 1,000 elements. Barcodes: 4,000-character payload, scale 1–16, bounded quiet zones, ≤4 million pixels.
|
||||
Sources: 262,144 characters. Context: depth 16, 10,000 nodes, 65,536 characters per string, 262,144 aggregate text characters. Stock: 1 mm–2 m, valid margins, 72–1200 DPI (ZPL additionally bounds width to 9,999 dots and height to 32,000). Assets: 100 entries, 2 MiB each, 8 MiB aggregate; image dimensions ≤4096 and ≤4 million pixels. Expanded HTML: 2 million characters and 20,000 elements. PDF pages: 1–500, checked after rendering. Output: caller limit, hard maximum 100 MiB; normal host default is 20 MiB. ZPL: 1,000 elements; a version 2 scene paints at most 16 times its printable area. Barcodes: 4,000-character payload, scale 1–16, bounded quiet zones, ≤4 million pixels.
|
||||
|
||||
The PDF engine and font/image decoders can consume resources before post-render limits fire. A qualified container's independent CPU/memory/PID/wall limits remain mandatory for both validation and rendering. These ceilings are not performance guarantees; load testing and font/locale/large-batch qualification remain outstanding.
|
||||
|
||||
|
||||
@@ -58,3 +58,32 @@ the Java file preserves the full license and identifies the dated port.
|
||||
Unmodified source, tests, hashes and maintenance policy are retained in
|
||||
`src/test/resources/barcodes/coop2of5`. No interpreter or additional runtime
|
||||
dependency is introduced. See [the capability contract](BARCODES.md#coop-2-of-5).
|
||||
|
||||
## Bundled fonts for version 2 label text (2026-11-12)
|
||||
|
||||
Version 2 label scenes draw text in five TrueType faces shipped inside the renderer JAR under `/fonts`. They are
|
||||
data, not code: unmodified files taken from each project's official release archive, each beside its licence text
|
||||
and a provenance note, with their SHA-256 in `/fonts/SHA256SUMS`. A test (`FaceTest`) fails the build when a
|
||||
licence, a provenance note or a hash is missing or a file's bytes change.
|
||||
|
||||
| Scene name | Family and style | File | Release | SHA-256 | Licence |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `qqq-sans` | IBM Plex Sans Regular | `ibm-plex-sans/IBMPlexSans-Regular.ttf` | [`@ibm/plex-sans@1.1.0`](https://github.com/IBM/plex/releases/download/%40ibm/plex-sans%401.1.0/ibm-plex-sans.zip), font version 3.005 | `975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5` | SIL OFL 1.1 |
|
||||
| `qqq-sans-bold` | IBM Plex Sans Bold | `ibm-plex-sans/IBMPlexSans-Bold.ttf` | the same archive, font version 3.005 | `9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f` | SIL OFL 1.1 |
|
||||
| `qqq-condensed-bold` | IBM Plex Sans Condensed Bold | `ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf` | [`@ibm/plex-sans-condensed@2.0.0`](https://github.com/IBM/plex/releases/download/%40ibm/plex-sans-condensed%402.0.0/ibm-plex-sans-condensed.zip), font version 3.000 | `723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7` | SIL OFL 1.1 |
|
||||
| `qqq-mono` | JetBrains Mono Regular | `jetbrains-mono/JetBrainsMono-Regular.ttf` | [`v2.304`](https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip), font version 2.304 | `a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f` | SIL OFL 1.1 |
|
||||
| `qqq-mono-bold` | JetBrains Mono Bold | `jetbrains-mono/JetBrainsMono-Bold.ttf` | the same archive, font version 2.304 | `5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb` | SIL OFL 1.1 |
|
||||
|
||||
Each licence was confirmed from the release archive itself and not from memory: the archive's own licence file
|
||||
(`LICENSE.txt` for the two IBM Plex archives, `OFL.txt` for JetBrains Mono) states the SIL Open Font License,
|
||||
Version 1.1 and carries its full text, the licence field of every font's name table says the same, and every
|
||||
font's embedding bits are 0 (installable embedding). The licence files are shipped byte for byte. IBM Plex
|
||||
declares the Reserved Font Name "Plex"; the files are neither modified nor renamed, and the scene names
|
||||
(`qqq-sans` and so on) are names of the renderer's faces, not of derived fonts.
|
||||
|
||||
The SIL Open Font License 1.1 is not on the allowlist of `K22-DEP-02`. The maintainer approved one recorded
|
||||
exception for unmodified OFL 1.1 fonts shipped as data on 2026-11-12. The register is kept in the standards
|
||||
repository (`exceptions/register.md`), not here; the entry was proposed with the pull request that added the
|
||||
fonts. The OFL permits bundling and redistribution with software provided the copyright and
|
||||
licence notices accompany the fonts, the fonts are not sold by themselves and a Reserved Font Name is not used
|
||||
for a modified version; it does not apply to documents made with the fonts.
|
||||
@@ -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,143 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "QQQ ZPL scene 2",
|
||||
"description": "A label layout in physical units, rendered at 203, 300 or 600 dpi. Coordinates start at the top left of the printable area (the stock less its margins). The renderer is the authority: it also enforces unique element ids, 1000 elements in all, groups four deep, magnitudes (2000 mm, 100 %, 32000 dot), a negative x or y only on a centred axis, overrides only for declared ids, a stock within 0.5 mm of the design, every box inside the printable area and at least one dot in size, and at most sixteen times the printable area painted in all. A refusal is a diagnostic with a code, the element id and numbers; SCENE_INVALID carries the JSON pointer of the offending place.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["schema", "design", "elements"],
|
||||
"properties": {
|
||||
"schema": {"const": "qqq-zpl-scene/2"},
|
||||
"design": {
|
||||
"description": "The stock the layout was drawn for.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["width", "height"],
|
||||
"properties": {"width": {"$ref": "#/$defs/physical"}, "height": {"$ref": "#/$defs/physical"}}
|
||||
},
|
||||
"defaults": {
|
||||
"description": "The face and size of a text element that states none.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {"font": {"type": "string", "pattern": "^[A-Za-z0-9_-][A-Za-z0-9_.-]{0,99}$"}, "size": {"$ref": "#/$defs/physical"}}
|
||||
},
|
||||
"samples": {
|
||||
"description": "Named synthetic contexts for a preview. Accepted and ignored by rendering.",
|
||||
"type": "object",
|
||||
"maxProperties": 16,
|
||||
"additionalProperties": {"type": "object"}
|
||||
},
|
||||
"elements": {"type": "array", "items": {"$ref": "#/$defs/element"}},
|
||||
"overrides": {
|
||||
"description": "The only place a scene may depend on resolution. Each entry replaces properties of declared elements at the listed resolutions; later entries win. `id`, `type` and `children` cannot change.",
|
||||
"type": "array",
|
||||
"maxItems": 32,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["when", "set"],
|
||||
"properties": {
|
||||
"when": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["dpi"],
|
||||
"properties": {"dpi": {"type": "array", "minItems": 1, "uniqueItems": true, "items": {"enum": [203, 300, 600]}}}
|
||||
},
|
||||
"set": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"propertyNames": {"$ref": "#/$defs/id"},
|
||||
"additionalProperties": {"type": "object", "minProperties": 1, "not": {"anyOf": [{"required": ["id"]}, {"required": ["type"]}, {"required": ["children"]}]}}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"id": {"type": "string", "pattern": "^[A-Za-z][A-Za-z0-9_-]{0,31}$"},
|
||||
"length": {
|
||||
"description": "A number of up to three decimals and a unit. `%` is a share of the container's width (x, width) or height (y, height); `dot` is one printer dot and is whole.",
|
||||
"type": "string",
|
||||
"pattern": "^-?(0|[1-9][0-9]{0,4})(\\.[0-9]{1,3})?(mm|in|pt|%)$|^-?(0|[1-9][0-9]{0,4})dot$"
|
||||
},
|
||||
"size": {"type": "string", "pattern": "^(0|[1-9][0-9]{0,4})(\\.[0-9]{1,3})?(mm|in|pt|%)$|^(0|[1-9][0-9]{0,4})dot$"},
|
||||
"physical": {"type": "string", "pattern": "^(0|[1-9][0-9]{0,4})(\\.[0-9]{1,3})?(mm|in|pt)$"},
|
||||
"absolute": {"type": "string", "pattern": "^(0|[1-9][0-9]{0,4})(\\.[0-9]{1,3})?(mm|in|pt)$|^(0|[1-9][0-9]{0,4})dot$"},
|
||||
"placement": {
|
||||
"type": "object",
|
||||
"required": ["id", "type"],
|
||||
"properties": {
|
||||
"id": {"$ref": "#/$defs/id"},
|
||||
"anchor": {
|
||||
"description": "The point of the box that is placed on the same point of its container.",
|
||||
"enum": ["top-left", "top-center", "top-right", "middle-left", "center", "middle-right", "bottom-left", "bottom-center", "bottom-right"],
|
||||
"default": "top-left"
|
||||
},
|
||||
"x": {"$ref": "#/$defs/length", "default": "0mm", "description": "Distance inward from the anchored edge; on a centred axis a shift right, which may be negative."},
|
||||
"y": {"$ref": "#/$defs/length", "default": "0mm", "description": "Distance inward from the anchored edge; on a centred axis a shift down, which may be negative."},
|
||||
"width": {"$ref": "#/$defs/size", "description": "Rounded down to whole dots. Omitted: the rest of the container."},
|
||||
"height": {"$ref": "#/$defs/size", "description": "Rounded down to whole dots. Omitted: the rest of the container."},
|
||||
"align": {"enum": ["left", "center", "right"], "default": "left"},
|
||||
"valign": {"enum": ["top", "middle", "bottom"], "default": "top"}
|
||||
}
|
||||
},
|
||||
"element": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/$defs/placement",
|
||||
"description": "A rectangle. A line is a box whose width or height is its thickness.",
|
||||
"unevaluatedProperties": false,
|
||||
"properties": {"type": {"const": "box"}, "thickness": {"$ref": "#/$defs/absolute", "description": "Nearest dot, never less than one. Required unless `fill` is true."}, "fill": {"type": "boolean", "default": false}},
|
||||
"anyOf": [{"required": ["thickness"]}, {"required": ["fill"], "properties": {"fill": {"const": true}}}]
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/placement",
|
||||
"description": "A box whose children are positioned relative to it.",
|
||||
"unevaluatedProperties": false,
|
||||
"required": ["children"],
|
||||
"properties": {"type": {"const": "group"}, "children": {"type": "array", "items": {"$ref": "#/$defs/element"}}}
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/placement",
|
||||
"description": "Text in a face bundled with the renderer, measured before it is drawn and placed in its box by `align` and `valign`. `font` and `size` may come from `defaults`. The renderer is the authority: it also enforces a size from 1pt to 1000pt, a `shrinkTo` from 1pt up to the size, at most three decimals in `lineHeight`, at most 1024 characters, only characters the face draws as one glyph each from left to right (FONT_MISSING_GLYPH), no supplied asset whose name begins `qqq-` (RESERVED_FONT_NAME), and a stated amount of work for all the text of one scene (SCENE_INVALID with `textWorkSteps`).",
|
||||
"unevaluatedProperties": false,
|
||||
"oneOf": [{"required": ["value"]}, {"required": ["valuePath"]}],
|
||||
"properties": {
|
||||
"type": {"const": "text"},
|
||||
"value": {"type": "string", "maxLength": 1024, "description": "The text itself."},
|
||||
"valuePath": {"type": "string", "pattern": "^/", "maxLength": 256, "description": "A JSON pointer into the record. Text is printed as it is; true and false as those words; a number in plain decimal digits, with no exponent and no fraction that is only zeros (`1.0E10` prints `10000000000`, `2.50` prints `2.5`, `12.0` prints `12`). Anything else, a number that is not finite among them, is VALUE_MISSING. No record is read when a scene is validated."},
|
||||
"font": {"enum": ["qqq-sans", "qqq-sans-bold", "qqq-condensed-bold", "qqq-mono", "qqq-mono-bold"], "description": "A bundled face: IBM Plex Sans Regular and Bold, IBM Plex Sans Condensed Bold, JetBrains Mono Regular and Bold. Any other name is FONT_UNKNOWN."},
|
||||
"size": {"$ref": "#/$defs/physical", "description": "The em size. Type scales continuously: the size is not rounded to dots."},
|
||||
"lineHeight": {"type": "number", "minimum": 0.5, "maximum": 5, "description": "The distance between baselines as a multiple of the size. Default: the face's own ascent, descent and line gap."},
|
||||
"fit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "What happens when the value does not fit the box. In order: lay out at `size` within `maxLines`; else the largest size, in steps of 0.25pt down to `shrinkTo`, that fits; else `overflow`. At each size a line is broken between words while that fits and at any character when it does not. A member that is wrong is SCENE_INVALID at that member (`/fit/maxLines`); a member `fit` does not have is SCENE_INVALID at `fit`.",
|
||||
"properties": {
|
||||
"maxLines": {"type": "integer", "minimum": 1, "maximum": 64, "default": 1, "description": "The most lines the value may take."},
|
||||
"shrinkTo": {"$ref": "#/$defs/physical", "description": "The smallest size the text may be reduced to: from 1pt up to `size`, which the renderer enforces. Default: 70% of `size` and not below 5pt, or `size` itself when that is smaller."},
|
||||
"overflow": {"enum": ["refuse", "truncate"], "default": "refuse", "description": "What is done with a value that fits at no size: `refuse` (TEXT_DOES_NOT_FIT), or `truncate` (cut at a character, end with an ellipsis, warn TEXT_TRUNCATED)."}
|
||||
}
|
||||
},
|
||||
"maxLength": {"type": "integer", "minimum": 1, "maximum": 1024, "description": "The longest value, in characters. Validation proves that this many of the face's widest character fit whole, never cut, so a length refusal happens when the scene is published; a longer value is VALUE_TOO_LONG when printing. When the proof fails and `fit.overflow` is `truncate` the scene stands and warns FIT_NOT_PROVEN with the longest length that is proven (`provenCharacters`): longer values may be cut. Without `maxLength` a bound element warns FIT_NOT_PROVEN."},
|
||||
"threshold": {"type": "integer", "minimum": 1, "maximum": 254, "default": 128, "description": "Glyphs are drawn antialiased; a dot is ink when its grey is darker than this. Higher prints heavier."},
|
||||
"reverse": {"type": "boolean", "default": false, "description": "White letters on the element's box filled black."},
|
||||
"rotation": {"enum": [0, 90, 180, 270], "description": "Clockwise. The box is given as it appears on the label; a text turned a quarter is fitted along the box's height."}
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/placement",
|
||||
"description": "A barcode. Parsed and placed by renderer 0.2.0 and refused as RENDERER_TOO_OLD until the barcode package; the values of these properties are not yet read.",
|
||||
"unevaluatedProperties": false,
|
||||
"properties": {"type": {"const": "barcode"}, "symbology": {}, "symbologyPath": {}, "allowedSymbologies": {}, "value": {}, "valuePath": {}, "minModule": {}, "maxModule": {}, "quiet": {}, "gs1": {}, "hri": {},
|
||||
"scannerSymbologyPath": {}, "aimIdentifierPath": {}, "rotation": {"enum": [0, 90, 180, 270]}}
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/placement",
|
||||
"description": "An image. Parsed and placed by renderer 0.2.0 and refused as RENDERER_TOO_OLD until the graphics package; the values of these properties are not yet read.",
|
||||
"unevaluatedProperties": false,
|
||||
"properties": {"type": {"const": "image"}, "asset": {}, "fit": {}, "mode": {}, "rotation": {"enum": [0, 90, 180, 270]}}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** `INSPECT_ASSET`: parse an uploaded font or image inside the isolated worker
|
||||
** and report what it is, so the application never parses one. The operation is
|
||||
** routed here from the first version 2 renderer so the wire is settled; the
|
||||
** inspection itself is a later package, and until then the operation is
|
||||
** refused by name.
|
||||
*******************************************************************************/
|
||||
public final class AssetInspector
|
||||
{
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private AssetInspector()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Answer one `INSPECT_ASSET` request.
|
||||
***************************************************************************/
|
||||
public static Map<String, Object> inspect(JsonNode request)
|
||||
{
|
||||
Wire.text(request.get("requestId"), 128);
|
||||
throw new IllegalArgumentException("UNSUPPORTED_OPERATION");
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,15 @@ public final class RenderInput
|
||||
public final Stock stock;
|
||||
public final Map<String, Asset> assets = new LinkedHashMap<>();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the render target of a version 2 scene, or null for the stock's own resolution; and whether //
|
||||
// the caller asked for every diagnostic rather than the first refusal //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
public final Integer targetDpi;
|
||||
public final boolean collectDiagnostics;
|
||||
|
||||
public static final String RESERVED_ASSET_PREFIX = "qqq-";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Physical stock uses exact integer microns; dots are checked separately.
|
||||
@@ -95,6 +104,16 @@ public final class RenderInput
|
||||
validateData(context, 0, new int[2]);
|
||||
maximumBytes = Wire.integer(root.get("maximumBytes"), 1, 100 * 1024 * 1024);
|
||||
stock = stock(root.path("stock"));
|
||||
targetDpi = root.has("targetDpi") ? Integer.valueOf(Wire.integer(root.get("targetDpi"), 203, 600)) : null;
|
||||
if(targetDpi != null && !java.util.List.of(203, 300, 600).contains(targetDpi))
|
||||
{
|
||||
throw new IllegalArgumentException("INVALID_TARGET_DPI");
|
||||
}
|
||||
collectDiagnostics = root.has("diagnostics");
|
||||
if(collectDiagnostics && !"COLLECT".equals(root.get("diagnostics").textValue()))
|
||||
{
|
||||
throw new IllegalArgumentException("INVALID_DIAGNOSTICS_MODE");
|
||||
}
|
||||
JsonNode supplied = root.path("assets");
|
||||
if(!supplied.isArray() || supplied.size() > 100)
|
||||
{
|
||||
@@ -135,6 +154,17 @@ public final class RenderInput
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The supplied assets whose names begin with the prefix reserved for the faces
|
||||
** bundled in the renderer. A version 2 scene may not carry one; the text
|
||||
** package refuses it as `RESERVED_FONT_NAME`. Version 1 never had the rule.
|
||||
*******************************************************************************/
|
||||
public java.util.List<String> reservedAssetNames()
|
||||
{
|
||||
return (assets.keySet().stream().filter(name -> name.startsWith(RESERVED_ASSET_PREFIX)).toList());
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared immutable physical stock parser for rendering and fit measurement.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -29,8 +29,14 @@ import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.renderer.scene2.Diagnostics;
|
||||
import com.kingsrook.qbits.renderer.scene2.Refusal;
|
||||
import com.kingsrook.qbits.renderer.scene2.Scene2;
|
||||
import com.kingsrook.qbits.renderer.scene2.upgrade.SceneUpgrade;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
|
||||
@@ -41,7 +47,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
*******************************************************************************/
|
||||
public final class RendererMain
|
||||
{
|
||||
public static final String VERSION = "qqq-renderer-0.1.0";
|
||||
public static final String VERSION = "qqq-renderer-0.2.0";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -84,8 +90,7 @@ public final class RendererMain
|
||||
catch(Exception expected)
|
||||
{
|
||||
// A failed invocation exposes no template/context text or stack trace.
|
||||
output.write(Wire.JSON.writeValueAsBytes(Map.of("protocol", Wire.PROTOCOL, "requestId", requestId, "outcome", "ERROR",
|
||||
"rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION, "code", errorCode(expected))));
|
||||
output.write(Wire.JSON.writeValueAsBytes(error(requestId, expected)));
|
||||
output.write('\n');
|
||||
}
|
||||
}
|
||||
@@ -93,13 +98,40 @@ public final class RendererMain
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only fixed public fit refusals cross the process boundary. Encoder messages
|
||||
** and all other failures remain opaque because they can contain source data.
|
||||
** The error answer. A version 2 refusal adds its one diagnostic as `detail`:
|
||||
** a code, a declared element id, a pointer of schema names and numbers.
|
||||
*******************************************************************************/
|
||||
static Map<String, Object> error(String requestId, Exception failure)
|
||||
{
|
||||
Map<String, Object> answer = new LinkedHashMap<>();
|
||||
answer.put("protocol", Wire.PROTOCOL);
|
||||
answer.put("requestId", requestId);
|
||||
answer.put("outcome", "ERROR");
|
||||
answer.put("rendererVersion", VERSION);
|
||||
answer.put("catalogVersion", Barcodes.VERSION);
|
||||
answer.put("code", errorCode(failure));
|
||||
if(failure instanceof Refusal refusal)
|
||||
{
|
||||
answer.put("detail", refusal.diagnostic().wire());
|
||||
}
|
||||
return (answer);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Only fixed public refusals cross the process boundary: the fit refusals, an
|
||||
** operation this renderer does not perform, and the code of a version 2
|
||||
** diagnostic. Encoder messages and all other failures remain opaque because
|
||||
** they can contain source data.
|
||||
*******************************************************************************/
|
||||
static String errorCode(Exception failure)
|
||||
{
|
||||
String message = failure.getMessage();
|
||||
if(failure instanceof IllegalArgumentException && message != null && java.util.Set.of(
|
||||
if(failure instanceof Refusal refusal)
|
||||
{
|
||||
return (refusal.diagnostic().code());
|
||||
}
|
||||
if(failure instanceof IllegalArgumentException && message != null && java.util.Set.of("UNSUPPORTED_OPERATION",
|
||||
"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",
|
||||
@@ -154,29 +186,100 @@ public final class RendererMain
|
||||
return (Map.of("protocol", Wire.PROTOCOL, "requestId", Wire.text(root.get("requestId"), 128), "outcome", "OK",
|
||||
"rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION, "fit", BarcodeFit.fit(request).metadata()));
|
||||
}
|
||||
RenderInput input = new RenderInput(root);
|
||||
if("VALIDATE".equals(input.operation))
|
||||
String operation = root.path("operation").asText();
|
||||
if("INSPECT_ASSET".equals(operation) || "UPGRADE_SCENE".equals(operation))
|
||||
{
|
||||
if("PDF".equals(input.format))
|
||||
if(!Wire.PROTOCOL.equals(root.path("protocol").asText()))
|
||||
{
|
||||
PdfRenderer.validate(input);
|
||||
throw new IllegalArgumentException("INVALID_PROTOCOL");
|
||||
}
|
||||
else
|
||||
{
|
||||
ZplRenderer.validate(input);
|
||||
}
|
||||
return (Map.of("protocol", Wire.PROTOCOL, "requestId", input.requestId, "outcome", "OK", "rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION));
|
||||
return ("INSPECT_ASSET".equals(operation) ? AssetInspector.inspect(root) : SceneUpgrade.upgrade(root));
|
||||
}
|
||||
byte[] bytes = "PDF".equals(input.format) ? PdfRenderer.render(input) : ZplRenderer.render(input);
|
||||
RenderInput input = new RenderInput(root);
|
||||
Diagnostics diagnostics = new Diagnostics(input.collectDiagnostics);
|
||||
byte[] bytes = null;
|
||||
int pageCount = 1;
|
||||
if("PDF".equals(input.format))
|
||||
if("ZPL".equals(input.format))
|
||||
{
|
||||
bytes = label(input, diagnostics);
|
||||
}
|
||||
else if("VALIDATE".equals(input.operation))
|
||||
{
|
||||
PdfRenderer.validate(input);
|
||||
}
|
||||
else
|
||||
{
|
||||
bytes = PdfRenderer.render(input);
|
||||
try(PDDocument document = Loader.loadPDF(bytes))
|
||||
{
|
||||
pageCount = document.getNumberOfPages();
|
||||
}
|
||||
}
|
||||
return (Map.of("protocol", Wire.PROTOCOL, "requestId", input.requestId, "outcome", "OK", "rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION,
|
||||
"mediaType", "PDF".equals(input.format) ? "application/pdf" : "application/vnd.zebra-zpl", "base64", Base64.getEncoder().encodeToString(bytes), "sha256", Wire.sha256(bytes), "length", bytes.length, "pageCount", pageCount));
|
||||
Map<String, Object> answer = new LinkedHashMap<>();
|
||||
answer.put("protocol", Wire.PROTOCOL);
|
||||
answer.put("requestId", input.requestId);
|
||||
answer.put("outcome", diagnostics.refused() ? "REFUSED" : "OK");
|
||||
answer.put("rendererVersion", VERSION);
|
||||
answer.put("catalogVersion", Barcodes.VERSION);
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a refused answer carries no document: a host that looks for `base64` without reading //
|
||||
// the outcome must find nothing it could print //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if(bytes != null && !diagnostics.refused())
|
||||
{
|
||||
answer.put("mediaType", "PDF".equals(input.format) ? "application/pdf" : "application/vnd.zebra-zpl");
|
||||
answer.put("base64", Base64.getEncoder().encodeToString(bytes));
|
||||
answer.put("sha256", Wire.sha256(bytes));
|
||||
answer.put("length", bytes.length);
|
||||
answer.put("pageCount", pageCount);
|
||||
}
|
||||
answer.put("diagnostics", diagnostics.wire());
|
||||
return (answer);
|
||||
}
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Route a label by the `schema` of its scene alone. Version 1 takes exactly
|
||||
** the path it always took and prints at the stock's own resolution; version 2
|
||||
** takes the scene 2 package; anything else is refused by name. The answer is
|
||||
** the label, or null when the request only validates or a version 2 scene
|
||||
** was refused while its diagnostics were collected.
|
||||
*******************************************************************************/
|
||||
private static byte[] label(RenderInput input, Diagnostics diagnostics) throws Exception
|
||||
{
|
||||
boolean validate = "VALIDATE".equals(input.operation);
|
||||
JsonNode scene;
|
||||
try
|
||||
{
|
||||
scene = Wire.JSON.readTree(input.source);
|
||||
}
|
||||
catch(JsonProcessingException expected)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", null, "", null, null, null);
|
||||
}
|
||||
String schema = scene.path("schema").textValue();
|
||||
if(Scene2.SCHEMA.equals(schema))
|
||||
{
|
||||
if(validate)
|
||||
{
|
||||
Scene2.validate(input, scene, diagnostics);
|
||||
return (null);
|
||||
}
|
||||
return (Scene2.render(input, scene, diagnostics));
|
||||
}
|
||||
if(!"qqq-zpl-scene/1".equals(schema))
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", null, scene.isObject() ? "/schema" : "", null, null, null);
|
||||
}
|
||||
if(input.targetDpi != null && input.targetDpi != input.stock.dpi())
|
||||
{
|
||||
throw diagnostics.refuse("TARGET_REQUIRES_SCENE_2", null, null, null, Map.of("dpi", input.targetDpi), Map.of("dpi", input.stock.dpi()));
|
||||
}
|
||||
if(validate)
|
||||
{
|
||||
ZplRenderer.validate(input);
|
||||
return (null);
|
||||
}
|
||||
return (ZplRenderer.render(input));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A rectangle: an outline of a stated thickness, or filled. A line is a box
|
||||
** whose width or height is its thickness, and on that axis it takes the
|
||||
** thickness rule (nearest dot, never less than one) so a thin rule does not
|
||||
** vanish; every other size is rounded down.
|
||||
*******************************************************************************/
|
||||
final class BoxElement implements SceneElement
|
||||
{
|
||||
private final String id;
|
||||
private final Placement placement;
|
||||
private final Length thickness;
|
||||
private final boolean fill;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The factory for `type: "box"`.
|
||||
***************************************************************************/
|
||||
static final class Factory implements ElementFactory
|
||||
{
|
||||
/************************************************************************
|
||||
** A box reads its outline thickness and whether it is filled.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public Set<String> properties()
|
||||
{
|
||||
return (Set.of("thickness", "fill"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Build one box.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public SceneElement create(ElementSource source)
|
||||
{
|
||||
return (new BoxElement(source));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An outline needs a thickness that is more than nothing; a filled box
|
||||
** needs none.
|
||||
***************************************************************************/
|
||||
private BoxElement(ElementSource source)
|
||||
{
|
||||
id = source.id();
|
||||
placement = source.placement();
|
||||
fill = source.flag("fill", false);
|
||||
thickness = source.length("thickness", null, ElementSource.ABSOLUTE);
|
||||
if(thickness == null ? !fill : thickness.thousandths() <= 0)
|
||||
{
|
||||
throw source.invalid("thickness");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The id the scene declares for the box.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The standing rules, except on an axis where the box is a line.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Frame place(Layout layout)
|
||||
{
|
||||
int width = thickness != null && thickness.equals(placement.width()) ? thickness.thickness(layout.dpi()) : layout.width(placement);
|
||||
int height = thickness != null && thickness.equals(placement.height()) ? thickness.thickness(layout.dpi()) : layout.height(placement);
|
||||
return (layout.frame(placement.anchor(), layout.x(placement), layout.y(placement), width, height));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The whole box when it is painted whole, else its four bars; the corners,
|
||||
** where two bars cross, are painted twice and counted twice.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public long paintedDots(Frame frame, int dpi)
|
||||
{
|
||||
int bar = bar(frame, dpi);
|
||||
return (bar < 0 ? SceneElement.super.paintedDots(frame, dpi) : 2L * bar * (frame.width() + frame.height()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The thickness of the outline in dots, or -1 when the box is painted whole:
|
||||
** it is filled, or its bars would meet.
|
||||
***************************************************************************/
|
||||
private int bar(Frame frame, int dpi)
|
||||
{
|
||||
int bar = fill ? frame.width() : thickness.thickness(dpi);
|
||||
return (bar * 2 >= Math.min(frame.width(), frame.height()) ? -1 : bar);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Four bars inside the box, or the whole box when it is filled or when the
|
||||
** bars would meet.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
int bar = bar(frame, canvas.dpi());
|
||||
if(bar < 0)
|
||||
{
|
||||
canvas.fill(frame, true);
|
||||
return;
|
||||
}
|
||||
canvas.fill(new Frame(frame.x(), frame.y(), frame.width(), bar), true);
|
||||
canvas.fill(new Frame(frame.x(), frame.bottom() - bar, frame.width(), bar), true);
|
||||
canvas.fill(new Frame(frame.x(), frame.y(), bar, frame.height()), true);
|
||||
canvas.fill(new Frame(frame.right() - bar, frame.y(), bar, frame.height()), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The whole label as one 1-bit picture at the target resolution: a dot is ink
|
||||
** or it is not. Elements paint in list order, so a later element covers an
|
||||
** earlier one, white as well as black. Painting outside the canvas is an error
|
||||
** in the caller, never a silent clip, so ink cannot be lost at an edge.
|
||||
*******************************************************************************/
|
||||
public final class Canvas
|
||||
{
|
||||
public static final long MAXIMUM_DOTS = 64_000_000L;
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// what one scene may paint, in canvases: the dots its elements paint, an overlap counted each //
|
||||
// time, may total sixteen times the canvas. A real label paints less than two (a solid //
|
||||
// ground, reversed panels, rules, text and codes), so sixteen refuses no layered design, and //
|
||||
// it holds the worst scene to 16 x 64 million dots, 128 MB of byte writes, where the 1000 //
|
||||
// full-canvas boxes the element limit allows were 8 GB //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
public static final int MAXIMUM_COATS = 16;
|
||||
|
||||
private final int width;
|
||||
private final int height;
|
||||
private final int dpi;
|
||||
private final int stride;
|
||||
private final byte[] bits;
|
||||
private long painted;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A blank canvas the size of the printable area, in dots.
|
||||
***************************************************************************/
|
||||
public Canvas(int width, int height, int dpi)
|
||||
{
|
||||
if(width < 1 || height < 1 || (long) width * height > MAXIMUM_DOTS)
|
||||
{
|
||||
throw new IllegalArgumentException("CANVAS_LIMIT");
|
||||
}
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.dpi = dpi;
|
||||
stride = (width + 7) / 8;
|
||||
bits = new byte[stride * height];
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Columns of dots.
|
||||
***************************************************************************/
|
||||
public int width()
|
||||
{
|
||||
return (width);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Rows of dots.
|
||||
***************************************************************************/
|
||||
public int height()
|
||||
{
|
||||
return (height);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The resolution the canvas was composed at, in dots per inch.
|
||||
***************************************************************************/
|
||||
public int dpi()
|
||||
{
|
||||
return (dpi);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Count the dots an element is about to paint. When they would take the
|
||||
** scene past `MAXIMUM_COATS` canvases nothing is counted and the answer is
|
||||
** no, so that element is refused and a later, smaller one may still paint.
|
||||
***************************************************************************/
|
||||
public boolean admit(long dots)
|
||||
{
|
||||
if(painted + dots > MAXIMUM_COATS * (long) width * height)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
painted += dots;
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the scene would have painted with `dots` more, as a percentage of
|
||||
** the canvas, rounded up.
|
||||
***************************************************************************/
|
||||
public int paintedPercent(long dots)
|
||||
{
|
||||
long area = (long) width * height;
|
||||
return (Math.toIntExact(((painted + dots) * 100 + area - 1) / area));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether one dot holds ink.
|
||||
***************************************************************************/
|
||||
public boolean get(int x, int y)
|
||||
{
|
||||
return ((bits[index(x, y)] & (128 >> (x & 7))) != 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Put ink on one dot, or take it off.
|
||||
***************************************************************************/
|
||||
public void set(int x, int y, boolean ink)
|
||||
{
|
||||
int index = index(x, y);
|
||||
int mask = 128 >> (x & 7);
|
||||
bits[index] = (byte) (ink ? bits[index] | mask : bits[index] & ~mask);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Paint a box black, or white to clear what an earlier element painted.
|
||||
***************************************************************************/
|
||||
public void fill(Frame area, boolean ink)
|
||||
{
|
||||
paint(area, ink ? Paint.BLACK : Paint.WHITE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reverse a box: black becomes white and white becomes black.
|
||||
***************************************************************************/
|
||||
public void invert(Frame area)
|
||||
{
|
||||
paint(area, Paint.REVERSE);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What painting does to the dots of a box.
|
||||
***************************************************************************/
|
||||
private enum Paint
|
||||
{
|
||||
BLACK, WHITE, REVERSE
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Paint a box a byte of dots at a time, so that a scene of many large filled
|
||||
** boxes costs memory writes rather than a visit to every dot. A box with no
|
||||
** dots paints nothing.
|
||||
***************************************************************************/
|
||||
private void paint(Frame area, Paint paint)
|
||||
{
|
||||
if(area.width() < 1 || area.height() < 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
index(area.x(), area.y());
|
||||
index(area.right() - 1, area.bottom() - 1);
|
||||
for(int y = area.y(); y < area.bottom(); y++)
|
||||
{
|
||||
int x = area.x();
|
||||
while(x < area.right())
|
||||
{
|
||||
int first = x & 7;
|
||||
int count = Math.min(8 - first, area.right() - x);
|
||||
int mask = (0xFF >> first) & (0xFF << (8 - first - count));
|
||||
int index = y * stride + (x >> 3);
|
||||
int painted = switch(paint)
|
||||
{
|
||||
case BLACK -> bits[index] | mask;
|
||||
case WHITE -> bits[index] & ~mask;
|
||||
case REVERSE -> bits[index] ^ mask;
|
||||
};
|
||||
bits[index] = (byte) painted;
|
||||
x += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The first inked column of a row, or -1 when the row is blank.
|
||||
***************************************************************************/
|
||||
public int firstInk(int row)
|
||||
{
|
||||
for(int x = 0; x < width; x++)
|
||||
{
|
||||
if(get(x, row))
|
||||
{
|
||||
return (x);
|
||||
}
|
||||
}
|
||||
return (-1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The last inked column of a row, or -1 when the row is blank.
|
||||
***************************************************************************/
|
||||
public int lastInk(int row)
|
||||
{
|
||||
for(int x = width - 1; x >= 0; x--)
|
||||
{
|
||||
if(get(x, row))
|
||||
{
|
||||
return (x);
|
||||
}
|
||||
}
|
||||
return (-1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The byte that holds a dot, after refusing a dot that is not on the canvas.
|
||||
***************************************************************************/
|
||||
private int index(int x, int y)
|
||||
{
|
||||
if(x < 0 || y < 0 || x >= width || y >= height)
|
||||
{
|
||||
throw new IllegalArgumentException("CANVAS_BOUNDS");
|
||||
}
|
||||
return (y * stride + (x >> 3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.TreeMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One finding about a version 2 scene, in a closed vocabulary: a code, a
|
||||
** severity, the id of an element the scene declares, a pointer made of schema
|
||||
** names, indexes and declared ids, a box in dots, and named integers. It has no
|
||||
** field that can hold a sentence, so no record value, context text or exception
|
||||
** message can leave the renderer in it. Only `Diagnostics` creates one, because
|
||||
** only the collector knows which ids the scene declares.
|
||||
*******************************************************************************/
|
||||
public final class Diagnostic
|
||||
{
|
||||
private static final Set<String> ERRORS = Set.of("SCENE_INVALID", "STOCK_MISMATCH", "TARGET_REQUIRES_SCENE_2", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "TEXT_DOES_NOT_FIT", "VALUE_MISSING",
|
||||
"VALUE_TOO_LONG", "FONT_UNKNOWN", "FONT_MISSING_GLYPH", "RESERVED_FONT_NAME", "BARCODE_DOES_NOT_FIT", "GS1_INVALID", "ASSET_UNKNOWN", "PREVIEW_TOO_LARGE", "RENDERER_TOO_OLD");
|
||||
private static final Set<String> WARNINGS = Set.of("TEXT_TRUNCATED", "TEXT_SMALL_FOR_DPI", "TEXT_LIGHT_FACE", "FIT_NOT_PROVEN", "BARCODE_MODULE_BELOW_RECOMMENDED", "QUIET_ZONE_INTRUDED",
|
||||
"ELEMENTS_OVERLAP", "IMAGE_UPSCALED", "DEVICE_UNIT_USED", "PRINTER_FONT_NOT_SHOWN");
|
||||
private static final Set<String> INFORMATION = Set.of("TEXT_SHRUNK", "TEXT_WRAPPED", "BARCODE_MODULE", "IMAGE_SCALED");
|
||||
private static final Pattern NAME = Pattern.compile("[a-z][A-Za-z0-9]{0,31}");
|
||||
|
||||
private final String code;
|
||||
private final Severity severity;
|
||||
private final String element;
|
||||
private final String pointer;
|
||||
private final Frame box;
|
||||
private final Map<String, Integer> measured;
|
||||
private final Map<String, Integer> limit;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An error stops printing; a warning and information do not.
|
||||
***************************************************************************/
|
||||
public enum Severity
|
||||
{
|
||||
ERROR, WARNING, INFO
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Admit only the vocabulary. A mistake here is a defect in the renderer, not
|
||||
** in the scene, so it is an illegal state and stays opaque to the host.
|
||||
***************************************************************************/
|
||||
Diagnostic(String code, Severity severity, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
|
||||
{
|
||||
Set<String> codes = switch(severity)
|
||||
{
|
||||
case ERROR -> ERRORS;
|
||||
case WARNING -> WARNINGS;
|
||||
case INFO -> INFORMATION;
|
||||
};
|
||||
if(code == null || !codes.contains(code) || (box != null && (box.width() < 0 || box.height() < 0)))
|
||||
{
|
||||
throw new IllegalStateException("DIAGNOSTIC_OUTSIDE_VOCABULARY");
|
||||
}
|
||||
this.code = code;
|
||||
this.severity = severity;
|
||||
this.element = element;
|
||||
this.pointer = pointer;
|
||||
this.box = box;
|
||||
this.measured = numbers(measured);
|
||||
this.limit = numbers(limit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The code, from the closed vocabulary of its severity.
|
||||
***************************************************************************/
|
||||
public String code()
|
||||
{
|
||||
return (code);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the finding stops printing.
|
||||
***************************************************************************/
|
||||
public Severity severity()
|
||||
{
|
||||
return (severity);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The declared id of the element the finding is about, or null.
|
||||
***************************************************************************/
|
||||
public String element()
|
||||
{
|
||||
return (element);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The JSON pointer of the offending property in the scene, or null.
|
||||
***************************************************************************/
|
||||
public String pointer()
|
||||
{
|
||||
return (pointer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element's resolved box in dots from the printable area's top left, or
|
||||
** null.
|
||||
***************************************************************************/
|
||||
public Frame box()
|
||||
{
|
||||
return (box);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What was measured, by name.
|
||||
***************************************************************************/
|
||||
public Map<String, Integer> measured()
|
||||
{
|
||||
return (measured);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the measured values were held against, by name.
|
||||
***************************************************************************/
|
||||
public Map<String, Integer> limit()
|
||||
{
|
||||
return (limit);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The diagnostic as the renderer's answer carries it. Absent parts are left
|
||||
** out, and names are in a fixed order so equal findings are equal bytes.
|
||||
***************************************************************************/
|
||||
public Map<String, Object> wire()
|
||||
{
|
||||
Map<String, Object> wire = new LinkedHashMap<>();
|
||||
wire.put("code", code);
|
||||
wire.put("severity", severity.name());
|
||||
if(element != null)
|
||||
{
|
||||
wire.put("element", element);
|
||||
}
|
||||
if(pointer != null)
|
||||
{
|
||||
wire.put("pointer", pointer);
|
||||
}
|
||||
if(box != null)
|
||||
{
|
||||
wire.put("box", box.box());
|
||||
}
|
||||
if(!measured.isEmpty())
|
||||
{
|
||||
wire.put("measured", measured);
|
||||
}
|
||||
if(!limit.isEmpty())
|
||||
{
|
||||
wire.put("limit", limit);
|
||||
}
|
||||
return (wire);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Freeze at most eight named integers; a name is a short identifier.
|
||||
***************************************************************************/
|
||||
private static Map<String, Integer> numbers(Map<String, Integer> values)
|
||||
{
|
||||
Map<String, Integer> numbers = new TreeMap<>(values == null ? Map.of() : values);
|
||||
if(numbers.size() > 8 || numbers.keySet().stream().anyMatch(name -> !NAME.matcher(name).matches()) || numbers.containsValue(null))
|
||||
{
|
||||
throw new IllegalStateException("DIAGNOSTIC_OUTSIDE_VOCABULARY");
|
||||
}
|
||||
return (Collections.unmodifiableMap(numbers));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The collector of one render or validation, and the only maker of
|
||||
** diagnostics. It knows the element ids the scene declares, so it is where the
|
||||
** rule is enforced that a diagnostic names only a declared id and points only
|
||||
** through schema names, indexes and declared ids: a data value has no way in.
|
||||
**
|
||||
** By default the first error ends the work as a `Refusal`. When the caller asks
|
||||
** to collect, errors are kept and the work goes on as far as it can, which is
|
||||
** what a preview wants. The findings of one scene are bounded by the scene: at
|
||||
** most one error for each element and one warning for each property.
|
||||
*******************************************************************************/
|
||||
public final class Diagnostics
|
||||
{
|
||||
public 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 final boolean collect;
|
||||
private final Map<String, String> declared = new LinkedHashMap<>();
|
||||
private final List<Diagnostic> findings = new ArrayList<>();
|
||||
private final Set<Map<String, Object>> seen = new HashSet<>();
|
||||
private boolean refused;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A collector that stops at the first error, or one that keeps them.
|
||||
***************************************************************************/
|
||||
public Diagnostics(boolean collect)
|
||||
{
|
||||
this.collect = collect;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Record an id the scene declares and where its element is. From here on a
|
||||
** diagnostic may name it.
|
||||
***************************************************************************/
|
||||
public void declare(String id, String pointer)
|
||||
{
|
||||
if(id == null || !ID.matcher(id).matches() || declared.containsKey(id))
|
||||
{
|
||||
throw new IllegalStateException("ELEMENT_ID_NOT_DECLARABLE");
|
||||
}
|
||||
declared.put(id, pointer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the scene declares this id.
|
||||
***************************************************************************/
|
||||
public boolean declared(String id)
|
||||
{
|
||||
return (declared.containsKey(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The pointer of a declared element, or null.
|
||||
***************************************************************************/
|
||||
public String pointer(String id)
|
||||
{
|
||||
return (declared.get(id));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An error, as the exception to throw. Every part is optional but the code.
|
||||
***************************************************************************/
|
||||
public Refusal refuse(String code, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
|
||||
{
|
||||
return (new Refusal(create(code, Diagnostic.Severity.ERROR, element, pointer, box, measured, limit)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A finding that does not stop printing. Reporting the same one twice keeps
|
||||
** one.
|
||||
***************************************************************************/
|
||||
public void warn(String code, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
|
||||
{
|
||||
add(create(code, Diagnostic.Severity.WARNING, element, pointer, box, measured, limit));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the renderer chose, for the author to see: a module size, a shrunk
|
||||
** type size.
|
||||
***************************************************************************/
|
||||
public void inform(String code, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
|
||||
{
|
||||
add(create(code, Diagnostic.Severity.INFO, element, pointer, box, measured, limit));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What to do with a refusal that was caught where work can continue: keep it
|
||||
** when collecting, otherwise let it end the work.
|
||||
***************************************************************************/
|
||||
public void keep(Refusal refusal)
|
||||
{
|
||||
if(!collect)
|
||||
{
|
||||
throw refusal;
|
||||
}
|
||||
refused = true;
|
||||
add(refusal.diagnostic());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether an error was kept, so the label must not print.
|
||||
***************************************************************************/
|
||||
public boolean refused()
|
||||
{
|
||||
return (refused);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every finding so far, in the order found.
|
||||
***************************************************************************/
|
||||
public List<Diagnostic> findings()
|
||||
{
|
||||
return (List.copyOf(findings));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The findings as the renderer's answer carries them.
|
||||
***************************************************************************/
|
||||
public List<Map<String, Object>> wire()
|
||||
{
|
||||
return (findings.stream().map(Diagnostic::wire).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The gate: the element is declared, and every segment of the pointer is an
|
||||
** index, a property name of the schema or a declared id.
|
||||
***************************************************************************/
|
||||
private Diagnostic create(String code, Diagnostic.Severity severity, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
|
||||
{
|
||||
boolean known = element == null || declared.containsKey(element);
|
||||
if(pointer != null && !pointer.isEmpty())
|
||||
{
|
||||
known &= pointer.startsWith("/");
|
||||
for(String segment : pointer.substring(1).split("/", -1))
|
||||
{
|
||||
known &= INDEX.matcher(segment).matches() || ElementFactory.VOCABULARY.contains(segment) || declared.containsKey(segment);
|
||||
}
|
||||
}
|
||||
if(!known)
|
||||
{
|
||||
throw new IllegalStateException("DIAGNOSTIC_NOT_DECLARED");
|
||||
}
|
||||
return (new Diagnostic(code, severity, element, pointer, box, measured, limit));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Keep a finding unless an equal one is already there.
|
||||
***************************************************************************/
|
||||
private void add(Diagnostic diagnostic)
|
||||
{
|
||||
if(seen.add(diagnostic.wire()))
|
||||
{
|
||||
findings.add(diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qbits.renderer.scene2.barcode.BarcodeFactory;
|
||||
import com.kingsrook.qbits.renderer.scene2.graphics.GraphicsFactory;
|
||||
import com.kingsrook.qbits.renderer.scene2.text.TextFactory;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Builds the elements of one `type`. The core owns `box` and `group`; text,
|
||||
** barcodes and images each live in their own sub-package and are registered
|
||||
** here by class, so the package that fills one in edits only its own file.
|
||||
*******************************************************************************/
|
||||
public interface ElementFactory
|
||||
{
|
||||
/***************************************************************************
|
||||
** Properties every element may state. The parser reads them into the
|
||||
** element's `Placement` before any factory runs.
|
||||
***************************************************************************/
|
||||
Set<String> COMMON = Set.of("id", "type", "anchor", "x", "y", "width", "height", "align", "valign");
|
||||
|
||||
/***************************************************************************
|
||||
** The factories by scene `type`. A `group` is built by the parser itself,
|
||||
** because its children are elements.
|
||||
***************************************************************************/
|
||||
Map<String, ElementFactory> FACTORIES = Map.of("box", new BoxElement.Factory(), "text", new TextFactory(), "barcode", new BarcodeFactory(), "image", new GraphicsFactory());
|
||||
|
||||
/***************************************************************************
|
||||
** Every property name the version 2 schema declares, the members of a text
|
||||
** element's `fit` among them. A diagnostic's pointer is made only of these,
|
||||
** of array indexes and of declared element ids.
|
||||
***************************************************************************/
|
||||
Set<String> VOCABULARY = Stream.concat(Stream.of("schema", "design", "defaults", "font", "size", "samples", "elements", "overrides", "when", "dpi", "set", "children", "maxLines", "shrinkTo", "overflow"),
|
||||
Stream.concat(COMMON.stream(), FACTORIES.values().stream().flatMap(factory -> factory.properties().stream()))).collect(Collectors.toUnmodifiableSet());
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The properties this type reads beyond the common ones. The parser refuses
|
||||
** an element that states any other.
|
||||
***************************************************************************/
|
||||
Set<String> properties();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build one element from its source, refusing a property value it cannot use
|
||||
** with `source.invalid(name)`.
|
||||
***************************************************************************/
|
||||
SceneElement create(ElementSource source);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One element as the scene states it for one resolution: its own properties
|
||||
** with the matching overrides applied, and its common placement already read.
|
||||
** A factory reads its own properties through the typed readers here, which
|
||||
** refuse a bad value as `SCENE_INVALID` at the property's own pointer (in the
|
||||
** element, or in the override that set it).
|
||||
*******************************************************************************/
|
||||
public final class ElementSource
|
||||
{
|
||||
public static final Set<Length.Unit> PHYSICAL = Set.of(Length.Unit.MM, Length.Unit.IN, Length.Unit.PT);
|
||||
public static final Set<Length.Unit> ABSOLUTE = Set.of(Length.Unit.MM, Length.Unit.IN, Length.Unit.PT, Length.Unit.DOT);
|
||||
public static final Set<Length.Unit> ANY = Set.of(Length.Unit.values());
|
||||
|
||||
private final String id;
|
||||
private final String pointer;
|
||||
private final Diagnostics diagnostics;
|
||||
private final Map<String, JsonNode> values = new LinkedHashMap<>();
|
||||
private final Map<String, String> pointers = new LinkedHashMap<>();
|
||||
private final Placement placement;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element at `pointer`, whose id the collector already holds, with the
|
||||
** overrides for this resolution: property name to its value and to the
|
||||
** pointer of the override that states it.
|
||||
***************************************************************************/
|
||||
ElementSource(String id, String pointer, JsonNode element, Map<String, JsonNode> overrides, Map<String, String> overridePointers, Diagnostics diagnostics)
|
||||
{
|
||||
this.id = id;
|
||||
this.pointer = pointer;
|
||||
this.diagnostics = diagnostics;
|
||||
element.fieldNames().forEachRemaining(name -> values.put(name, element.get(name)));
|
||||
values.putAll(overrides);
|
||||
pointers.putAll(overridePointers);
|
||||
Placement.Anchor anchor = choice("anchor", Placement.Anchor.TOP_LEFT, Placement.Anchor::of);
|
||||
placement = new Placement(anchor, offset("x", anchor.horizontal()), offset("y", anchor.vertical()), extent("width"), extent("height"),
|
||||
choice("align", Placement.Align.LEFT, Placement.Align::of), choice("valign", Placement.Valign.TOP, Placement.Valign::of));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The id the scene declares for the element.
|
||||
***************************************************************************/
|
||||
public String id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element's `type`.
|
||||
***************************************************************************/
|
||||
public String type()
|
||||
{
|
||||
return (values.get("type").textValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Anchor, offsets, box and content alignment, as the scene states them.
|
||||
***************************************************************************/
|
||||
public Placement placement()
|
||||
{
|
||||
return (placement);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A property's value as JSON, or null when the element does not state it.
|
||||
***************************************************************************/
|
||||
public JsonNode value(String name)
|
||||
{
|
||||
return (values.get(name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A length in one of the allowed units, or the fallback when the element
|
||||
** does not state it. A `dot` length stated outside an override depends on
|
||||
** the printer, which the author is told.
|
||||
***************************************************************************/
|
||||
public Length length(String name, Length fallback, Set<Length.Unit> units)
|
||||
{
|
||||
JsonNode value = values.get(name);
|
||||
if(value == null)
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
Length length;
|
||||
try
|
||||
{
|
||||
length = Length.parse(value.textValue());
|
||||
}
|
||||
catch(IllegalArgumentException expected)
|
||||
{
|
||||
throw invalid(name);
|
||||
}
|
||||
if(!units.contains(length.unit()))
|
||||
{
|
||||
throw invalid(name);
|
||||
}
|
||||
if(length.unit() == Length.Unit.DOT && !pointers.containsKey(name))
|
||||
{
|
||||
diagnostics.warn("DEVICE_UNIT_USED", id, pointer(name), null, null, null);
|
||||
}
|
||||
return (length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A true or false property, or the fallback when the element does not state
|
||||
** it.
|
||||
***************************************************************************/
|
||||
public boolean flag(String name, boolean fallback)
|
||||
{
|
||||
JsonNode value = values.get(name);
|
||||
if(value == null)
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
if(!value.isBoolean())
|
||||
{
|
||||
throw invalid(name);
|
||||
}
|
||||
return (value.booleanValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One word of a fixed set, or the fallback when the element does not state
|
||||
** it. `lookup` answers null for a word outside the set.
|
||||
***************************************************************************/
|
||||
public <T> T choice(String name, T fallback, Function<String, T> lookup)
|
||||
{
|
||||
JsonNode value = values.get(name);
|
||||
if(value == null)
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
T choice = lookup.apply(value.textValue());
|
||||
if(choice == null)
|
||||
{
|
||||
throw invalid(name);
|
||||
}
|
||||
return (choice);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Clockwise rotation of text, a barcode or an image: 0, 90, 180 or 270.
|
||||
***************************************************************************/
|
||||
public int rotation()
|
||||
{
|
||||
JsonNode value = values.get("rotation");
|
||||
if(value == null)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
if(!value.isIntegralNumber() || !value.canConvertToInt() || !Set.of(0, 90, 180, 270).contains(value.intValue()))
|
||||
{
|
||||
throw invalid("rotation");
|
||||
}
|
||||
return (value.intValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The refusal for a property the element states wrongly, or lacks. A member
|
||||
** of a property that is an object is named with a slash: `fit/maxLines`.
|
||||
***************************************************************************/
|
||||
public Refusal invalid(String name)
|
||||
{
|
||||
return (refuse("SCENE_INVALID", name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The refusal, by its own code, for a property that is well formed but names
|
||||
** something this renderer does not have, such as a font.
|
||||
***************************************************************************/
|
||||
public Refusal refuse(String code, String name)
|
||||
{
|
||||
return (diagnostics.refuse(code, id, pointer(name), null, null, null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Refuse an element that states a property its type does not read. The
|
||||
** names themselves are not reported: only declared names leave the renderer.
|
||||
***************************************************************************/
|
||||
void only(Set<String> properties)
|
||||
{
|
||||
int unknown = Math.toIntExact(values.keySet().stream().filter(name -> !ElementFactory.COMMON.contains(name) && !properties.contains(name)).count());
|
||||
if(unknown > 0)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", id, pointer, null, Map.of("unknownProperties", unknown), Map.of("unknownProperties", 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Give the element the scene's `defaults` for the properties its type reads
|
||||
** and it does not state. A default is reported where the scene states it.
|
||||
***************************************************************************/
|
||||
void inherit(JsonNode defaults, Set<String> properties)
|
||||
{
|
||||
for(Map.Entry<String, JsonNode> property : defaults.properties())
|
||||
{
|
||||
if(properties.contains(property.getKey()) && !values.containsKey(property.getKey()))
|
||||
{
|
||||
values.put(property.getKey(), property.getValue());
|
||||
pointers.put(property.getKey(), "/defaults/" + property.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Where a property is stated: in the override that set it, in the scene's
|
||||
** defaults, else in the element. A member of a property that is an object
|
||||
** (`fit/maxLines`) is in that property, wherever the property is stated.
|
||||
***************************************************************************/
|
||||
private String pointer(String name)
|
||||
{
|
||||
int member = name.indexOf('/');
|
||||
String property = member < 0 ? name : name.substring(0, member);
|
||||
return (pointers.getOrDefault(property, pointer + "/" + property) + (member < 0 ? "" : name.substring(member)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A distance inward from the anchored edge. Only about the middle may it be
|
||||
** negative, where it shifts left or up.
|
||||
***************************************************************************/
|
||||
private Length offset(String name, Placement.Position position)
|
||||
{
|
||||
Length offset = length(name, Length.ZERO, ANY);
|
||||
if(offset.thousandths() < 0 && position != Placement.Position.CENTER)
|
||||
{
|
||||
throw invalid(name);
|
||||
}
|
||||
return (offset);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A box width or height, more than nothing; null when the scene leaves it
|
||||
** to the rest of the container.
|
||||
***************************************************************************/
|
||||
private Length extent(String name)
|
||||
{
|
||||
Length extent = length(name, null, ANY);
|
||||
if(extent != null && extent.thousandths() <= 0)
|
||||
{
|
||||
throw invalid(name);
|
||||
}
|
||||
return (extent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A resolved box in whole dots. The origin is the top left of the printable
|
||||
** area, so a frame at 0, 0 touches the margins and never enters them.
|
||||
*******************************************************************************/
|
||||
public record Frame(int x, int y, int width, int height)
|
||||
{
|
||||
/***************************************************************************
|
||||
** The first column to the right of the box.
|
||||
***************************************************************************/
|
||||
public int right()
|
||||
{
|
||||
return (x + width);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The first row below the box.
|
||||
***************************************************************************/
|
||||
public int bottom()
|
||||
{
|
||||
return (y + height);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether every dot of the other box is a dot of this one.
|
||||
***************************************************************************/
|
||||
public boolean contains(Frame other)
|
||||
{
|
||||
return (other.x >= x && other.y >= y && other.right() <= right() && other.bottom() <= bottom());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box as a diagnostic carries it: x, y, width and height.
|
||||
***************************************************************************/
|
||||
public List<Integer> box()
|
||||
{
|
||||
return (List.of(x, y, width, height));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A box with its own anchor, position and size whose children are placed
|
||||
** relative to it. It moves a block as a unit and gives percentages something
|
||||
** smaller than the label to refer to. It paints nothing itself.
|
||||
*******************************************************************************/
|
||||
final class GroupElement implements SceneElement
|
||||
{
|
||||
private final String id;
|
||||
private final Placement placement;
|
||||
private final List<SceneElement> children;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A group and the elements inside it, in paint order.
|
||||
***************************************************************************/
|
||||
GroupElement(ElementSource source, List<SceneElement> children)
|
||||
{
|
||||
id = source.id();
|
||||
placement = source.placement();
|
||||
this.children = List.copyOf(children);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The id the scene declares for the group.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The group's box, by the standing rules.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Frame place(Layout layout)
|
||||
{
|
||||
return (layout.place(placement));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A group paints nothing, whatever the size of its box.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public long paintedDots(Frame frame, int dpi)
|
||||
{
|
||||
return (0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A group has no ink of its own.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
// The scene paints the children after their group, inside its box.
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The elements inside the group.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public List<SceneElement> children()
|
||||
{
|
||||
return (children);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Anchor resolution inside one container at one resolution: lengths in, whole
|
||||
** dots out. The container is the printable area or a group, and percentages
|
||||
** refer to it. Integer arithmetic only.
|
||||
*******************************************************************************/
|
||||
public final class Layout
|
||||
{
|
||||
private final Frame container;
|
||||
private final int dpi;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A container, in dots from the top left of the printable area, and the
|
||||
** resolution every length resolves at.
|
||||
***************************************************************************/
|
||||
public Layout(Frame container, int dpi)
|
||||
{
|
||||
this.container = container;
|
||||
this.dpi = dpi;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box positions and percentages refer to.
|
||||
***************************************************************************/
|
||||
public Frame container()
|
||||
{
|
||||
return (container);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The render target, in dots per inch.
|
||||
***************************************************************************/
|
||||
public int dpi()
|
||||
{
|
||||
return (dpi);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same resolution inside a smaller box, for the children of a group.
|
||||
***************************************************************************/
|
||||
public Layout within(Frame frame)
|
||||
{
|
||||
return (new Layout(frame, dpi));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element's box by the standing rules: position to the nearest dot, size
|
||||
** rounded down.
|
||||
***************************************************************************/
|
||||
public Frame place(Placement placement)
|
||||
{
|
||||
return (frame(placement.anchor(), x(placement), y(placement), width(placement), height(placement)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The horizontal distance inward from the anchored edge, or the shift right
|
||||
** on a centred axis.
|
||||
***************************************************************************/
|
||||
public int x(Placement placement)
|
||||
{
|
||||
return (placement.x().position(dpi, container.width()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The vertical distance inward from the anchored edge, or the shift down on
|
||||
** a centred axis.
|
||||
***************************************************************************/
|
||||
public int y(Placement placement)
|
||||
{
|
||||
return (placement.y().position(dpi, container.height()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box width, or the rest of the container when the scene states none.
|
||||
***************************************************************************/
|
||||
public int width(Placement placement)
|
||||
{
|
||||
return (extent(placement.width(), x(placement), placement.anchor().horizontal(), container.width()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box height, or the rest of the container when the scene states none.
|
||||
***************************************************************************/
|
||||
public int height(Placement placement)
|
||||
{
|
||||
return (extent(placement.height(), y(placement), placement.anchor().vertical(), container.height()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A box of known size on an anchor: the same point of the box and of the
|
||||
** container coincide, moved by the offsets.
|
||||
***************************************************************************/
|
||||
public Frame frame(Placement.Anchor anchor, int x, int y, int width, int height)
|
||||
{
|
||||
return (new Frame(container.x() + anchor.horizontal().place(container.width(), width, x), container.y() + anchor.vertical().place(container.height(), height, y), width, height));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Content of a measured size inside its box: text lines, a fitted barcode, a
|
||||
** scaled image.
|
||||
***************************************************************************/
|
||||
public static Frame align(Frame box, int width, int height, Placement.Align align, Placement.Valign valign)
|
||||
{
|
||||
return (new Frame(box.x() + align.position().place(box.width(), width, 0), box.y() + valign.position().place(box.height(), height, 0), width, height));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A stated size is rounded down. An omitted one is what the offset leaves:
|
||||
** from an edge, the container less the offset; about the middle, the
|
||||
** container less the shift on both sides, so the box stays centred on it.
|
||||
***************************************************************************/
|
||||
private int extent(Length size, int offset, Placement.Position position, int available)
|
||||
{
|
||||
if(size != null)
|
||||
{
|
||||
return (size.size(dpi, available));
|
||||
}
|
||||
return (available - Math.abs(offset) * (position == Placement.Position.CENTER ? 2 : 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A length as a version 2 scene writes it: a number of up to three decimals and
|
||||
** a unit, such as `12.5mm`, `0.5in`, `9pt`, `25%` or `1dot`. There is no ambient
|
||||
** unit, so a value read out of context is never ambiguous. A physical length
|
||||
** becomes whole micrometres first (the unit the stock uses) and then dots by
|
||||
** the rule its use states; no floating point takes part in a position or size.
|
||||
*******************************************************************************/
|
||||
public record Length(Unit unit, long thousandths)
|
||||
{
|
||||
public static final Length ZERO = new Length(Unit.MM, 0);
|
||||
|
||||
private static final Pattern SYNTAX = Pattern.compile("(-?)(0|[1-9][0-9]{0,4})(?:\\.([0-9]{1,3}))?(mm|in|pt|%|dot)");
|
||||
private static final int MAXIMUM_MICROMETRES = 2_000_000;
|
||||
private static final int MAXIMUM_DOTS = 32_000;
|
||||
private static final int MICROMETRES_PER_INCH = 25_400;
|
||||
private static final Map<String, Unit> UNITS = Map.of("mm", Unit.MM, "in", Unit.IN, "pt", Unit.PT, "%", Unit.PERCENT, "dot", Unit.DOT);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The five units of a scene. `%` is a share of the container and `dot` is one
|
||||
** printer dot; the other three are physical.
|
||||
***************************************************************************/
|
||||
public enum Unit
|
||||
{
|
||||
MM, IN, PT, PERCENT, DOT
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How a fraction of a dot is resolved. The magnitude is rounded and the sign
|
||||
** restored, so a length and its negative are mirror images.
|
||||
***************************************************************************/
|
||||
public enum Rounding
|
||||
{
|
||||
NEAREST, DOWN, UP
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read one length. Anything else, including a bare number, an exponent, a
|
||||
** fourth decimal, a fraction of a dot, more than the whole container or more
|
||||
** than the largest stock, is refused.
|
||||
***************************************************************************/
|
||||
public static Length parse(String text)
|
||||
{
|
||||
Matcher matcher = SYNTAX.matcher(text == null ? "" : text);
|
||||
if(!matcher.matches())
|
||||
{
|
||||
throw new IllegalArgumentException("INVALID_LENGTH");
|
||||
}
|
||||
String fraction = matcher.group(3) == null ? "" : matcher.group(3);
|
||||
long magnitude = Long.parseLong(matcher.group(2)) * 1000 + Long.parseLong((fraction + "000").substring(0, 3));
|
||||
Length length = new Length(UNITS.get(matcher.group(4)), matcher.group(1).isEmpty() ? magnitude : -magnitude);
|
||||
long limit = switch(length.unit)
|
||||
{
|
||||
case PERCENT -> 100_000L;
|
||||
case DOT -> MAXIMUM_DOTS * 1000L;
|
||||
default -> Long.MAX_VALUE;
|
||||
};
|
||||
if(magnitude > limit || (length.unit == Unit.DOT && !fraction.isEmpty()) || (length.physical() && length.magnitude() > MAXIMUM_MICROMETRES))
|
||||
{
|
||||
throw new IllegalArgumentException("INVALID_LENGTH");
|
||||
}
|
||||
return (length);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Millimetres, inches and points are physical: the same on every printer.
|
||||
***************************************************************************/
|
||||
public boolean physical()
|
||||
{
|
||||
return (unit == Unit.MM || unit == Unit.IN || unit == Unit.PT);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whole micrometres, nearest, with a half rounded away from zero. One inch
|
||||
** is 25400 micrometres and one point is a 72nd of an inch.
|
||||
***************************************************************************/
|
||||
public int micrometres()
|
||||
{
|
||||
long micrometres = magnitude();
|
||||
return (Math.toIntExact(thousandths < 0 ? -micrometres : micrometres));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The micrometres of the magnitude, not yet narrowed, so that the largest
|
||||
** number the syntax allows (99999.999 of any unit) is compared with the limit
|
||||
** as it is and refused by name, never as an arithmetic overflow.
|
||||
***************************************************************************/
|
||||
private long magnitude()
|
||||
{
|
||||
long magnitude = Math.abs(thousandths);
|
||||
return (switch(unit)
|
||||
{
|
||||
case MM -> magnitude;
|
||||
case IN -> (magnitude * 254 + 5) / 10;
|
||||
case PT -> (magnitude * 127 + 180) / 360;
|
||||
default -> throw new IllegalArgumentException("LENGTH_NOT_PHYSICAL");
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whole dots at a resolution. A percentage is a share of `container` dots;
|
||||
** a `dot` length is itself; a physical length goes through micrometres.
|
||||
***************************************************************************/
|
||||
public int dots(int dpi, int container, Rounding rounding)
|
||||
{
|
||||
long numerator;
|
||||
long denominator;
|
||||
switch(unit)
|
||||
{
|
||||
case PERCENT ->
|
||||
{
|
||||
numerator = Math.abs(thousandths) * container;
|
||||
denominator = 100_000;
|
||||
}
|
||||
case DOT ->
|
||||
{
|
||||
numerator = Math.abs(thousandths);
|
||||
denominator = 1000;
|
||||
}
|
||||
default ->
|
||||
{
|
||||
numerator = (long) Math.abs(micrometres()) * dpi;
|
||||
denominator = MICROMETRES_PER_INCH;
|
||||
}
|
||||
}
|
||||
long magnitude = switch(rounding)
|
||||
{
|
||||
case NEAREST -> (numerator + denominator / 2) / denominator;
|
||||
case DOWN -> numerator / denominator;
|
||||
case UP -> (numerator + denominator - 1) / denominator;
|
||||
};
|
||||
return (Math.toIntExact(thousandths < 0 ? -magnitude : magnitude));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A position: the nearest dot, the rule the stock already uses, so the error
|
||||
** is symmetric.
|
||||
***************************************************************************/
|
||||
public int position(int dpi, int container)
|
||||
{
|
||||
return (dots(dpi, container, Rounding.NEAREST));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A box width or height: rounded down, so content never leaves its designed
|
||||
** box.
|
||||
***************************************************************************/
|
||||
public int size(int dpi, int container)
|
||||
{
|
||||
return (dots(dpi, container, Rounding.DOWN));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A line or box thickness: the nearest dot and never less than one, so a
|
||||
** thin rule does not vanish.
|
||||
***************************************************************************/
|
||||
public int thickness(int dpi)
|
||||
{
|
||||
return (Math.max(1, absolute().dots(dpi, 0, Rounding.NEAREST)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A barcode module from its stated minimum: up to a whole dot, because bars
|
||||
** are whole dots and may not be narrower than the minimum.
|
||||
***************************************************************************/
|
||||
public int module(int dpi)
|
||||
{
|
||||
return (Math.max(1, absolute().dots(dpi, 0, Rounding.UP)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A font size in dots, not rounded: a face is scaled by points times dpi
|
||||
** over 72, so type scales continuously.
|
||||
***************************************************************************/
|
||||
public double scale(int dpi)
|
||||
{
|
||||
return (switch(absolute().unit)
|
||||
{
|
||||
case MM -> thousandths * (double) dpi / MICROMETRES_PER_INCH;
|
||||
case IN -> thousandths * (double) dpi / 1000;
|
||||
case PT -> thousandths * (double) dpi / 72_000;
|
||||
default -> thousandths / 1000.0;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A thickness, a module and a font size have no container to be a share of.
|
||||
***************************************************************************/
|
||||
private Length absolute()
|
||||
{
|
||||
if(unit == Unit.PERCENT)
|
||||
{
|
||||
throw new IllegalArgumentException("LENGTH_NOT_ABSOLUTE");
|
||||
}
|
||||
return (this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What every element states about where it sits: the point it is anchored to,
|
||||
** its distances inward from that point, its box, and where content goes inside
|
||||
** the box. `width` and `height` are null when the scene leaves them out, which
|
||||
** means the rest of the container on that axis.
|
||||
*******************************************************************************/
|
||||
public record Placement(Anchor anchor, Length x, Length y, Length width, Length height, Align align, Valign valign)
|
||||
{
|
||||
/***************************************************************************
|
||||
** One axis, three places: against the start, in the middle or against the end.
|
||||
***************************************************************************/
|
||||
public enum Position
|
||||
{
|
||||
START, CENTER, END;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Where `size` dots begin within `available` dots. From an edge, `offset`
|
||||
** runs inward. In the middle the slack is split with integer division, so
|
||||
** a centred box is off by at most one dot, and `offset` shifts it right or
|
||||
** down.
|
||||
************************************************************************/
|
||||
public int place(int available, int size, int offset)
|
||||
{
|
||||
return (switch(this)
|
||||
{
|
||||
case START -> offset;
|
||||
case CENTER -> Math.floorDiv(available - size, 2) + offset;
|
||||
case END -> available - size - offset;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Horizontal placement of content in its box.
|
||||
***************************************************************************/
|
||||
public enum Align
|
||||
{
|
||||
LEFT("left", Position.START), CENTER("center", Position.CENTER), RIGHT("right", Position.END);
|
||||
|
||||
private final String word;
|
||||
private final Position position;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The word a scene writes and the place it means.
|
||||
************************************************************************/
|
||||
Align(String word, Position position)
|
||||
{
|
||||
this.word = word;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The place on the horizontal axis.
|
||||
************************************************************************/
|
||||
public Position position()
|
||||
{
|
||||
return (position);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The value a scene word names, or null.
|
||||
************************************************************************/
|
||||
public static Align of(String word)
|
||||
{
|
||||
for(Align align : values())
|
||||
{
|
||||
if(align.word.equals(word))
|
||||
{
|
||||
return (align);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Vertical placement of content in its box.
|
||||
***************************************************************************/
|
||||
public enum Valign
|
||||
{
|
||||
TOP("top", Position.START), MIDDLE("middle", Position.CENTER), BOTTOM("bottom", Position.END);
|
||||
|
||||
private final String word;
|
||||
private final Position position;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The word a scene writes and the place it means.
|
||||
************************************************************************/
|
||||
Valign(String word, Position position)
|
||||
{
|
||||
this.word = word;
|
||||
this.position = position;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The place on the vertical axis.
|
||||
************************************************************************/
|
||||
public Position position()
|
||||
{
|
||||
return (position);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The value a scene word names, or null.
|
||||
************************************************************************/
|
||||
public static Valign of(String word)
|
||||
{
|
||||
for(Valign valign : values())
|
||||
{
|
||||
if(valign.word.equals(word))
|
||||
{
|
||||
return (valign);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The nine points of a box. The same point of the element is placed on that
|
||||
** point of its container.
|
||||
***************************************************************************/
|
||||
public enum Anchor
|
||||
{
|
||||
TOP_LEFT("top-left", Position.START, Position.START),
|
||||
TOP_CENTER("top-center", Position.CENTER, Position.START),
|
||||
TOP_RIGHT("top-right", Position.END, Position.START),
|
||||
MIDDLE_LEFT("middle-left", Position.START, Position.CENTER),
|
||||
CENTER("center", Position.CENTER, Position.CENTER),
|
||||
MIDDLE_RIGHT("middle-right", Position.END, Position.CENTER),
|
||||
BOTTOM_LEFT("bottom-left", Position.START, Position.END),
|
||||
BOTTOM_CENTER("bottom-center", Position.CENTER, Position.END),
|
||||
BOTTOM_RIGHT("bottom-right", Position.END, Position.END);
|
||||
|
||||
private final String word;
|
||||
private final Position horizontal;
|
||||
private final Position vertical;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The word a scene writes and the place it means on each axis.
|
||||
************************************************************************/
|
||||
Anchor(String word, Position horizontal, Position vertical)
|
||||
{
|
||||
this.word = word;
|
||||
this.horizontal = horizontal;
|
||||
this.vertical = vertical;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The place on the horizontal axis.
|
||||
************************************************************************/
|
||||
public Position horizontal()
|
||||
{
|
||||
return (horizontal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The place on the vertical axis.
|
||||
************************************************************************/
|
||||
public Position vertical()
|
||||
{
|
||||
return (vertical);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The value a scene word names, or null.
|
||||
************************************************************************/
|
||||
public static Anchor of(String word)
|
||||
{
|
||||
for(Anchor anchor : values())
|
||||
{
|
||||
if(anchor.word.equals(word))
|
||||
{
|
||||
return (anchor);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The renderer saying no to a version 2 scene, with the one error diagnostic
|
||||
** that says where and by how much. Its message is the diagnostic's code, never
|
||||
** anything from the scene or the record.
|
||||
*******************************************************************************/
|
||||
public final class Refusal extends IllegalArgumentException
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final transient Diagnostic diagnostic;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only the collector raises a refusal, from a diagnostic it has checked.
|
||||
***************************************************************************/
|
||||
Refusal(Diagnostic diagnostic)
|
||||
{
|
||||
super(diagnostic.code());
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The error this refusal is.
|
||||
***************************************************************************/
|
||||
public Diagnostic diagnostic()
|
||||
{
|
||||
return (diagnostic);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A version 2 scene from source to label: one design in physical units,
|
||||
** resolved to whole dots at the render target, composed on one canvas and
|
||||
** emitted as graphic fields. Coordinates start at the top left of the
|
||||
** printable area and every box is held inside it, so nothing a scene says
|
||||
** can print in a margin (`Page` states the rule to the dot).
|
||||
*******************************************************************************/
|
||||
public final class Scene2
|
||||
{
|
||||
public static final String SCHEMA = "qqq-zpl-scene/2";
|
||||
public static final List<Integer> RESOLUTIONS = List.of(203, 300, 600);
|
||||
|
||||
static final int STOCK_TOLERANCE_MICROMETRES = 500;
|
||||
|
||||
private static final int MICROMETRES_PER_INCH = 25_400;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private Scene2()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The page and its printable area in dots at the render target: the request's
|
||||
** target when it names one, else the stock's own resolution.
|
||||
**
|
||||
** The printable area is the stock less its margins, in micrometres, rounded
|
||||
** down to whole dots: exactly the size a box of the stated printable width
|
||||
** and height resolves to, so a design that fills the printable area fits at
|
||||
** every resolution. It starts at the left and top margins rounded up, and is
|
||||
** moved back only as far as keeps it on the page. The left and top margins
|
||||
** are therefore never entered unless the page itself ends first; the far
|
||||
** edge may pass the right or bottom margin by less than one dot.
|
||||
***************************************************************************/
|
||||
record Page(int dpi, int width, int height, Frame printable)
|
||||
{
|
||||
/************************************************************************
|
||||
** Resolve the stock, or refuse one the printer language cannot address.
|
||||
************************************************************************/
|
||||
static Page of(RenderInput input)
|
||||
{
|
||||
RenderInput.Stock stock = input.stock;
|
||||
int dpi = input.targetDpi == null ? stock.dpi() : input.targetDpi;
|
||||
if(!RESOLUTIONS.contains(dpi))
|
||||
{
|
||||
throw new IllegalArgumentException("UNSUPPORTED_ZPL_STOCK");
|
||||
}
|
||||
int pageWidth = dots(stock.widthMicrons(), dpi, MICROMETRES_PER_INCH / 2);
|
||||
int pageHeight = dots(stock.heightMicrons(), dpi, MICROMETRES_PER_INCH / 2);
|
||||
int width = dots(stock.widthMicrons() - stock.leftMicrons() - stock.rightMicrons(), dpi, 0);
|
||||
int height = dots(stock.heightMicrons() - stock.topMicrons() - stock.bottomMicrons(), dpi, 0);
|
||||
int left = Math.min(dots(stock.leftMicrons(), dpi, MICROMETRES_PER_INCH - 1), pageWidth - width);
|
||||
int top = Math.min(dots(stock.topMicrons(), dpi, MICROMETRES_PER_INCH - 1), pageHeight - height);
|
||||
Frame printable = new Frame(left, top, width, height);
|
||||
Page page = new Page(dpi, pageWidth, pageHeight, printable);
|
||||
if(page.width > 9999 || page.height > 32000 || printable.width() < 1 || printable.height() < 1 || (long) printable.width() * printable.height() > Canvas.MAXIMUM_DOTS)
|
||||
{
|
||||
throw new IllegalArgumentException("UNSUPPORTED_ZPL_STOCK");
|
||||
}
|
||||
return (page);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Micrometres to dots. The addend chooses the rounding: half an inch of
|
||||
** micrometres for the nearest dot (the page, as the host gate computes
|
||||
** it), one less than an inch to round up (the left and top margins) and
|
||||
** nothing to round down (the size of the printable area, as the size of
|
||||
** a box is rounded).
|
||||
************************************************************************/
|
||||
private static int dots(int micrometres, int dpi, int addend)
|
||||
{
|
||||
return (Math.toIntExact(((long) micrometres * dpi + addend) / MICROMETRES_PER_INCH));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Check a scene without drawing it: its grammar for every resolution, and
|
||||
** its layout on this stock at the render target.
|
||||
***************************************************************************/
|
||||
public static void validate(RenderInput input, JsonNode scene, Diagnostics diagnostics)
|
||||
{
|
||||
compose(input, scene, diagnostics, false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Draw a scene and return the label, or null when the collector kept an
|
||||
** error: a refused scene has no label, so one with elements missing cannot
|
||||
** be printed by mistake. The canvas of such a scene, holding the elements
|
||||
** that had no error, is what `compose` answers.
|
||||
***************************************************************************/
|
||||
public static byte[] render(RenderInput input, JsonNode scene, Diagnostics diagnostics) throws IOException
|
||||
{
|
||||
Page page = Page.of(input);
|
||||
Canvas canvas = compose(input, scene, diagnostics, true);
|
||||
return (diagnostics.refused() ? null : ZplEmitter.emit(canvas, page.width(), page.height(), page.printable().x(), page.printable().y(), input.maximumBytes));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Parse, lay out and, when asked, paint. A refusal about the scene as a
|
||||
** whole ends the pass; a refusal about one element ends that element.
|
||||
***************************************************************************/
|
||||
static Canvas compose(RenderInput input, JsonNode root, Diagnostics diagnostics, boolean paint)
|
||||
{
|
||||
Page page = Page.of(input);
|
||||
Canvas canvas = new Canvas(page.printable().width(), page.printable().height(), page.dpi());
|
||||
try
|
||||
{
|
||||
SceneParser.Scene scene = SceneParser.parse(root, page.dpi(), diagnostics);
|
||||
RenderInput.Stock stock = input.stock;
|
||||
if(Math.abs(stock.widthMicrons() - scene.width().micrometres()) > STOCK_TOLERANCE_MICROMETRES || Math.abs(stock.heightMicrons() - scene.height().micrometres()) > STOCK_TOLERANCE_MICROMETRES)
|
||||
{
|
||||
throw diagnostics.refuse("STOCK_MISMATCH", null, "/design", null, Map.of("widthMicrometres", stock.widthMicrons(), "heightMicrometres", stock.heightMicrons()),
|
||||
Map.of("widthMicrometres", scene.width().micrometres(), "heightMicrometres", scene.height().micrometres(), "toleranceMicrometres", STOCK_TOLERANCE_MICROMETRES));
|
||||
}
|
||||
walk(scene.elements(), new Layout(new Frame(0, 0, canvas.width(), canvas.height()), page.dpi()), canvas, paint, input, diagnostics);
|
||||
}
|
||||
catch(Refusal refusal)
|
||||
{
|
||||
diagnostics.keep(refusal);
|
||||
}
|
||||
return (canvas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Elements in list order: place, hold the box to the printable area, check,
|
||||
** count the dots it would paint against what one scene may paint, paint
|
||||
** when asked, then the children inside the box. The dots are counted when a
|
||||
** scene is only validated too, so validation refuses what rendering would.
|
||||
***************************************************************************/
|
||||
private static void walk(List<SceneElement> elements, Layout layout, Canvas canvas, boolean paint, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
Frame printable = new Frame(0, 0, canvas.width(), canvas.height());
|
||||
for(SceneElement element : elements)
|
||||
{
|
||||
try
|
||||
{
|
||||
Frame frame = element.place(layout);
|
||||
if(frame.width() < 1 || frame.height() < 1)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", element.id(), diagnostics.pointer(element.id()), null, Map.of("widthDots", frame.width(), "heightDots", frame.height()),
|
||||
Map.of("widthDots", 1, "heightDots", 1));
|
||||
}
|
||||
if(!printable.contains(frame))
|
||||
{
|
||||
throw diagnostics.refuse("ELEMENT_OUTSIDE_PRINTABLE_AREA", element.id(), null, frame,
|
||||
Map.of("leftDots", frame.x(), "topDots", frame.y(), "rightDots", frame.right(), "bottomDots", frame.bottom()),
|
||||
Map.of("leftDots", 0, "topDots", 0, "rightDots", printable.right(), "bottomDots", printable.bottom()));
|
||||
}
|
||||
element.check(frame, input, diagnostics);
|
||||
long dots = element.paintedDots(frame, layout.dpi());
|
||||
if(!canvas.admit(dots))
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", element.id(), diagnostics.pointer(element.id()), frame, Map.of("paintedPercent", canvas.paintedPercent(dots)),
|
||||
Map.of("paintedPercent", Canvas.MAXIMUM_COATS * 100));
|
||||
}
|
||||
if(paint)
|
||||
{
|
||||
element.paint(canvas, frame, input, diagnostics);
|
||||
}
|
||||
walk(element.children(), layout.within(frame), canvas, paint, input, diagnostics);
|
||||
}
|
||||
catch(Refusal refusal)
|
||||
{
|
||||
diagnostics.keep(refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** One element of a version 2 scene after parsing. The scene walks its elements
|
||||
** in list order: place, check that the box is inside the printable area, check
|
||||
** the element itself, paint, then the same for its children inside its box.
|
||||
*******************************************************************************/
|
||||
public interface SceneElement
|
||||
{
|
||||
/***************************************************************************
|
||||
** The id the scene declares for the element.
|
||||
***************************************************************************/
|
||||
String id();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element's box, in dots, inside the layout's container.
|
||||
***************************************************************************/
|
||||
Frame place(Layout layout);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Checks that need no ink, run when a scene is validated and again before it
|
||||
** is painted. An element that cannot be drawn refuses here.
|
||||
***************************************************************************/
|
||||
default void check(Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The dots the element paints in this box, counted against what one scene
|
||||
** may paint (`Canvas.MAXIMUM_COATS`) before any ink. An element paints only
|
||||
** inside its box, so the box is the answer unless the element knows less.
|
||||
***************************************************************************/
|
||||
default long paintedDots(Frame frame, int dpi)
|
||||
{
|
||||
return ((long) frame.width() * frame.height());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Draw the element in its box.
|
||||
***************************************************************************/
|
||||
void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The elements positioned relative to this one's box; only a group has any.
|
||||
***************************************************************************/
|
||||
default List<SceneElement> children()
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The parser of `qqq-zpl-scene/2`, a format written by template authors and so
|
||||
** untrusted. It reads only the documented grammar: an unknown property, a
|
||||
** wrong type, a bad length or a limit passed is `SCENE_INVALID` at the JSON
|
||||
** pointer of the offending place. It evaluates nothing and reads no record
|
||||
** data.
|
||||
**
|
||||
** The whole structure is checked first, which declares every element id, and
|
||||
** the elements are then built once for each resolution a printer can have, so
|
||||
** an override meant for another printer cannot hide a mistake until that
|
||||
** printer is used. The elements of the requested resolution are returned.
|
||||
*******************************************************************************/
|
||||
final class SceneParser
|
||||
{
|
||||
static final int MAXIMUM_ELEMENTS = 1000;
|
||||
static final int MAXIMUM_DEPTH = 4;
|
||||
static final int MAXIMUM_OVERRIDES = 32;
|
||||
static final int MAXIMUM_SAMPLES = 16;
|
||||
|
||||
private static final Set<String> TOP = Set.of("schema", "design", "defaults", "samples", "elements", "overrides");
|
||||
private static final Set<String> FIXED = Set.of("id", "type", "children");
|
||||
private static final Pattern FONT = Pattern.compile("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,99}");
|
||||
|
||||
private final JsonNode root;
|
||||
private final Diagnostics diagnostics;
|
||||
private int elements;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What a scene says once parsed: the stock it was drawn for and its
|
||||
** elements for one resolution, in paint order.
|
||||
***************************************************************************/
|
||||
record Scene(Length width, Length height, List<SceneElement> elements)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One scene and the collector that will hold its ids and findings.
|
||||
***************************************************************************/
|
||||
private SceneParser(JsonNode root, Diagnostics diagnostics)
|
||||
{
|
||||
this.root = root;
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Parse a scene for one of the three resolutions.
|
||||
***************************************************************************/
|
||||
static Scene parse(JsonNode root, int dpi, Diagnostics diagnostics)
|
||||
{
|
||||
SceneParser parser = new SceneParser(root, diagnostics);
|
||||
parser.structure();
|
||||
Length width = parser.physical(root.get("design").get("width"), "/design/width");
|
||||
Length height = parser.physical(root.get("design").get("height"), "/design/height");
|
||||
Scene scene = new Scene(width, height, parser.build(root.get("elements"), "/elements", dpi));
|
||||
for(int other : Scene2.RESOLUTIONS)
|
||||
{
|
||||
if(other != dpi)
|
||||
{
|
||||
parser.build(root.get("elements"), "/elements", other);
|
||||
}
|
||||
}
|
||||
return (scene);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Everything that does not depend on the resolution.
|
||||
***************************************************************************/
|
||||
private void structure()
|
||||
{
|
||||
object(root, "", TOP);
|
||||
if(!Scene2.SCHEMA.equals(root.path("schema").textValue()))
|
||||
{
|
||||
throw invalid(null, "/schema");
|
||||
}
|
||||
object(root.get("design"), "/design", Set.of("width", "height"));
|
||||
JsonNode defaults = root.get("defaults");
|
||||
if(defaults != null)
|
||||
{
|
||||
object(defaults, "/defaults", Set.of("font", "size"));
|
||||
String font = defaults.path("font").textValue();
|
||||
if(defaults.has("font") && (font == null || !FONT.matcher(font).matches()))
|
||||
{
|
||||
throw invalid(null, "/defaults/font");
|
||||
}
|
||||
if(defaults.has("size"))
|
||||
{
|
||||
physical(defaults.get("size"), "/defaults/size");
|
||||
}
|
||||
}
|
||||
JsonNode samples = root.get("samples");
|
||||
if(samples != null)
|
||||
{
|
||||
object(samples, "/samples", null);
|
||||
if(samples.size() > MAXIMUM_SAMPLES)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", null, "/samples", null, Map.of("samples", samples.size()), Map.of("samples", MAXIMUM_SAMPLES));
|
||||
}
|
||||
samples.forEach(sample -> object(sample, "/samples", null));
|
||||
}
|
||||
declare(root.get("elements"), "/elements", 1);
|
||||
JsonNode overrides = root.get("overrides");
|
||||
if(overrides != null)
|
||||
{
|
||||
array(overrides, "/overrides");
|
||||
if(overrides.size() > MAXIMUM_OVERRIDES)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", null, "/overrides", null, Map.of("overrides", overrides.size()), Map.of("overrides", MAXIMUM_OVERRIDES));
|
||||
}
|
||||
for(int index = 0; index < overrides.size(); index++)
|
||||
{
|
||||
override(overrides.get(index), "/overrides/" + index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Declare every element id, depth first, before anything refers to one. An
|
||||
** id is the only author text a diagnostic may carry, so it is held to its
|
||||
** pattern here and must be unique.
|
||||
***************************************************************************/
|
||||
private void declare(JsonNode list, String pointer, int depth)
|
||||
{
|
||||
array(list, pointer);
|
||||
for(int index = 0; index < list.size(); index++)
|
||||
{
|
||||
JsonNode element = list.get(index);
|
||||
String at = pointer + "/" + index;
|
||||
if(++elements > MAXIMUM_ELEMENTS)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", null, "/elements", null, Map.of("elements", elements), Map.of("elements", MAXIMUM_ELEMENTS));
|
||||
}
|
||||
object(element, at, null);
|
||||
String id = element.path("id").textValue();
|
||||
if(id == null || !Diagnostics.ID.matcher(id).matches() || diagnostics.declared(id))
|
||||
{
|
||||
throw invalid(null, at + "/id");
|
||||
}
|
||||
diagnostics.declare(id, at);
|
||||
String type = element.path("type").textValue();
|
||||
if("group".equals(type))
|
||||
{
|
||||
if(depth > MAXIMUM_DEPTH)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", id, at, null, Map.of("depth", depth), Map.of("depth", MAXIMUM_DEPTH));
|
||||
}
|
||||
declare(element.get("children"), at + "/children", depth + 1);
|
||||
}
|
||||
else if(type == null || !ElementFactory.FACTORIES.containsKey(type))
|
||||
{
|
||||
throw invalid(id, at + "/type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One override: the resolutions it is for, and replacement properties by
|
||||
** element id. It may not change what an element is.
|
||||
***************************************************************************/
|
||||
private void override(JsonNode override, String pointer)
|
||||
{
|
||||
object(override, pointer, Set.of("when", "set"));
|
||||
JsonNode when = override.get("when");
|
||||
JsonNode set = override.get("set");
|
||||
object(when, pointer + "/when", Set.of("dpi"));
|
||||
array(when.get("dpi"), pointer + "/when/dpi");
|
||||
Set<Integer> resolutions = new HashSet<>();
|
||||
for(JsonNode dpi : when.get("dpi"))
|
||||
{
|
||||
if(!dpi.isIntegralNumber() || !dpi.canConvertToInt() || !Scene2.RESOLUTIONS.contains(dpi.intValue()) || !resolutions.add(dpi.intValue()))
|
||||
{
|
||||
throw invalid(null, pointer + "/when/dpi");
|
||||
}
|
||||
}
|
||||
if(resolutions.isEmpty())
|
||||
{
|
||||
throw invalid(null, pointer + "/when/dpi");
|
||||
}
|
||||
object(set, pointer + "/set", null);
|
||||
if(set.isEmpty())
|
||||
{
|
||||
throw invalid(null, pointer + "/set");
|
||||
}
|
||||
for(Map.Entry<String, JsonNode> entry : set.properties())
|
||||
{
|
||||
if(!diagnostics.declared(entry.getKey()))
|
||||
{
|
||||
throw invalid(null, pointer + "/set");
|
||||
}
|
||||
JsonNode replacement = entry.getValue();
|
||||
if(!replacement.isObject() || replacement.isEmpty() || FIXED.stream().anyMatch(replacement::has))
|
||||
{
|
||||
throw invalid(entry.getKey(), pointer + "/set/" + entry.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build a list of elements for one resolution, with the overrides for it.
|
||||
***************************************************************************/
|
||||
private List<SceneElement> build(JsonNode list, String pointer, int dpi)
|
||||
{
|
||||
List<SceneElement> built = new ArrayList<>();
|
||||
for(int index = 0; index < list.size(); index++)
|
||||
{
|
||||
JsonNode element = list.get(index);
|
||||
String at = pointer + "/" + index;
|
||||
String id = element.get("id").textValue();
|
||||
Map<String, JsonNode> overrides = new LinkedHashMap<>();
|
||||
Map<String, String> pointers = new LinkedHashMap<>();
|
||||
JsonNode all = root.path("overrides");
|
||||
for(int entry = 0; entry < all.size(); entry++)
|
||||
{
|
||||
JsonNode replacement = all.get(entry).get("set").get(id);
|
||||
if(replacement != null && applies(all.get(entry), dpi))
|
||||
{
|
||||
String from = "/overrides/" + entry + "/set/" + id + "/";
|
||||
replacement.properties().forEach(property ->
|
||||
{
|
||||
overrides.put(property.getKey(), property.getValue());
|
||||
pointers.put(property.getKey(), from + property.getKey());
|
||||
});
|
||||
}
|
||||
}
|
||||
ElementSource source = new ElementSource(id, at, element, overrides, pointers, diagnostics);
|
||||
if("group".equals(source.type()))
|
||||
{
|
||||
source.only(Set.of("children"));
|
||||
built.add(new GroupElement(source, build(element.get("children"), at + "/children", dpi)));
|
||||
}
|
||||
else
|
||||
{
|
||||
ElementFactory factory = ElementFactory.FACTORIES.get(source.type());
|
||||
source.only(factory.properties());
|
||||
source.inherit(root.path("defaults"), factory.properties());
|
||||
built.add(factory.create(source));
|
||||
}
|
||||
}
|
||||
return (built);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether an override is for a resolution.
|
||||
***************************************************************************/
|
||||
private static boolean applies(JsonNode override, int dpi)
|
||||
{
|
||||
for(JsonNode resolution : override.get("when").get("dpi"))
|
||||
{
|
||||
if(resolution.intValue() == dpi)
|
||||
{
|
||||
return (true);
|
||||
}
|
||||
}
|
||||
return (false);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A physical length that is more than nothing: a stock side or a type size.
|
||||
***************************************************************************/
|
||||
private Length physical(JsonNode value, String pointer)
|
||||
{
|
||||
try
|
||||
{
|
||||
Length length = Length.parse(value == null ? null : value.textValue());
|
||||
if(length.physical() && length.thousandths() > 0)
|
||||
{
|
||||
return (length);
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException expected)
|
||||
{
|
||||
// Refused below, with the pointer; the cause holds nothing more.
|
||||
}
|
||||
throw invalid(null, pointer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A JSON object whose properties are all among `names`, when names are
|
||||
** given. Unknown names are counted, never repeated.
|
||||
***************************************************************************/
|
||||
private void object(JsonNode node, String pointer, Set<String> names)
|
||||
{
|
||||
if(node == null || !node.isObject())
|
||||
{
|
||||
throw invalid(null, pointer);
|
||||
}
|
||||
if(names != null)
|
||||
{
|
||||
int unknown = 0;
|
||||
for(Map.Entry<String, JsonNode> property : node.properties())
|
||||
{
|
||||
unknown += names.contains(property.getKey()) ? 0 : 1;
|
||||
}
|
||||
if(unknown > 0)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", null, pointer, null, Map.of("unknownProperties", unknown), Map.of("unknownProperties", 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A JSON array; each caller holds it to its own bound.
|
||||
***************************************************************************/
|
||||
private void array(JsonNode node, String pointer)
|
||||
{
|
||||
if(node == null || !node.isArray())
|
||||
{
|
||||
throw invalid(null, pointer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The refusal for a place in the scene that is not what the schema says.
|
||||
***************************************************************************/
|
||||
private Refusal invalid(String element, String pointer)
|
||||
{
|
||||
return (diagnostics.refuse("SCENE_INVALID", element, pointer, null, null, null));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** An element the schema declares but this renderer cannot draw yet. It is
|
||||
** parsed and placed like any other, so its box is checked and reported, and it
|
||||
** is then refused by name: a scene that needs it needs a newer renderer. It is
|
||||
** never drawn as something else and never skipped.
|
||||
*******************************************************************************/
|
||||
public final class UnavailableElement implements SceneElement
|
||||
{
|
||||
private final String id;
|
||||
private final Placement placement;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The placeholder for one element; its rotation is read so a bad one is
|
||||
** still refused as the scene's mistake.
|
||||
***************************************************************************/
|
||||
public UnavailableElement(ElementSource source)
|
||||
{
|
||||
id = source.id();
|
||||
placement = source.placement();
|
||||
source.rotation();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The id the scene declares for the element.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element's box, by the standing rules.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Frame place(Layout layout)
|
||||
{
|
||||
return (layout.place(placement));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Refused when the scene is validated and before any ink.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void check(Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
throw diagnostics.refuse("RENDERER_TOO_OLD", id, null, frame, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Never reached through the scene, which checks first; refused all the same.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
throw diagnostics.refuse("RENDERER_TOO_OLD", id, null, frame, null, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HexFormat;
|
||||
import com.kingsrook.qbits.renderer.Wire;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A canvas as printer commands: the header version 1 writes, then uncompressed
|
||||
** `^GFA` graphic fields, then the end of the label. Nothing else is emitted,
|
||||
** so the host's output policy accepts a version 2 label with no change.
|
||||
**
|
||||
** Only ink is sent. Rows are cut into bands wherever at least `BLANK_ROWS`
|
||||
** blank rows separate ink, each band is trimmed to its inked columns, and a
|
||||
** band taller than one field may hold is continued in the next field. A band
|
||||
** needs at least one inked row and is followed by a gap, so a label of the
|
||||
** greatest height has far fewer fields than the policy allows.
|
||||
*******************************************************************************/
|
||||
final class ZplEmitter
|
||||
{
|
||||
static final int BLANK_ROWS = 8;
|
||||
static final int MAXIMUM_FIELD_BYTES = 99_999;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private ZplEmitter()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The label for a page of `pageWidth` by `pageHeight` dots whose printable
|
||||
** area, the canvas, starts at `originX`, `originY`.
|
||||
***************************************************************************/
|
||||
static byte[] emit(Canvas canvas, int pageWidth, int pageHeight, int originX, int originY, int maximumBytes) throws IOException
|
||||
{
|
||||
Wire.Bytes output = new Wire.Bytes(maximumBytes);
|
||||
write(output, "^XA^PW" + pageWidth + "^LL" + pageHeight + "^LH0,0^CI28\n");
|
||||
int row = 0;
|
||||
while(row < canvas.height())
|
||||
{
|
||||
if(canvas.firstInk(row) < 0)
|
||||
{
|
||||
row++;
|
||||
continue;
|
||||
}
|
||||
int last = row;
|
||||
int left = canvas.width();
|
||||
int right = 0;
|
||||
for(int next = row; next < canvas.height() && next - last <= BLANK_ROWS; next++)
|
||||
{
|
||||
int first = canvas.firstInk(next);
|
||||
if(first >= 0)
|
||||
{
|
||||
last = next;
|
||||
left = Math.min(left, first);
|
||||
right = Math.max(right, canvas.lastInk(next));
|
||||
}
|
||||
}
|
||||
int stride = (right - left + 8) / 8;
|
||||
int rowsPerField = MAXIMUM_FIELD_BYTES / stride;
|
||||
for(int first = row; first <= last; first += rowsPerField)
|
||||
{
|
||||
field(output, canvas, left, right, first, Math.min(rowsPerField, last + 1 - first), originX, originY);
|
||||
}
|
||||
row = last + 1;
|
||||
}
|
||||
write(output, "^XZ\n");
|
||||
return (output.result());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One graphic field: `rows` rows of the canvas from column `left` to column
|
||||
** `right`, most significant bit first, with the padding bits left white.
|
||||
***************************************************************************/
|
||||
private static void field(Wire.Bytes output, Canvas canvas, int left, int right, int top, int rows, int originX, int originY) throws IOException
|
||||
{
|
||||
int stride = (right - left + 8) / 8;
|
||||
byte[] raster = new byte[stride * rows];
|
||||
for(int row = 0; row < rows; row++)
|
||||
{
|
||||
for(int column = left; column <= right; column++)
|
||||
{
|
||||
if(canvas.get(column, top + row))
|
||||
{
|
||||
raster[row * stride + (column - left) / 8] |= (byte) (128 >> (column - left) % 8);
|
||||
}
|
||||
}
|
||||
}
|
||||
write(output, "^FO" + (originX + left) + "," + (originY + top) + "^GFA," + raster.length + "," + raster.length + "," + stride + "," + HexFormat.of().withUpperCase().formatHex(raster) + "^FS\n");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** All output is generated ASCII.
|
||||
***************************************************************************/
|
||||
private static void write(Wire.Bytes output, String value) throws IOException
|
||||
{
|
||||
output.write(value.getBytes(StandardCharsets.US_ASCII));
|
||||
}
|
||||
}
|
||||
+61
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* 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.scene2.barcode;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementFactory;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementSource;
|
||||
import com.kingsrook.qbits.renderer.scene2.SceneElement;
|
||||
import com.kingsrook.qbits.renderer.scene2.UnavailableElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Barcodes: a symbol fitted to its box in whole-dot modules, never below its
|
||||
** stated minimum.
|
||||
**
|
||||
** This is the placeholder of the scene 2 core: a `barcode` element is parsed and
|
||||
** placed, then refused as `RENDERER_TOO_OLD`. The package that draws it
|
||||
** replaces this class and nothing else.
|
||||
*******************************************************************************/
|
||||
public final class BarcodeFactory implements ElementFactory
|
||||
{
|
||||
/***************************************************************************
|
||||
** The property names the design gives a `barcode` element. Their values are
|
||||
** not read yet.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Set<String> properties()
|
||||
{
|
||||
return (Set.of("symbology", "symbologyPath", "allowedSymbologies", "value", "valuePath", "minModule", "maxModule", "quiet", "gs1", "hri", "scannerSymbologyPath", "aimIdentifierPath", "rotation"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element, placed and then refused by name.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public SceneElement create(ElementSource source)
|
||||
{
|
||||
return (new UnavailableElement(source));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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.scene2.graphics;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementFactory;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementSource;
|
||||
import com.kingsrook.qbits.renderer.scene2.SceneElement;
|
||||
import com.kingsrook.qbits.renderer.scene2.UnavailableElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Images: an asset scaled into a physical box and reduced to black and white.
|
||||
**
|
||||
** This is the placeholder of the scene 2 core: a `image` element is parsed and
|
||||
** placed, then refused as `RENDERER_TOO_OLD`. The package that draws it
|
||||
** replaces this class and nothing else.
|
||||
*******************************************************************************/
|
||||
public final class GraphicsFactory implements ElementFactory
|
||||
{
|
||||
/***************************************************************************
|
||||
** The property names the design gives a `image` element. Their values are
|
||||
** not read yet.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Set<String> properties()
|
||||
{
|
||||
return (Set.of("asset", "fit", "mode", "rotation"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element, placed and then refused by name.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public SceneElement create(ElementSource source)
|
||||
{
|
||||
return (new UnavailableElement(source));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.awt.Font;
|
||||
import java.awt.FontFormatException;
|
||||
import java.awt.font.FontRenderContext;
|
||||
import java.awt.font.GlyphVector;
|
||||
import java.awt.font.LineMetrics;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The faces bundled with the renderer, by the name a scene writes. Each is an
|
||||
** unmodified TrueType file under the SIL Open Font License 1.1, shipped in the
|
||||
** jar beside its licence, provenance and hash (`/fonts`). No installed font is
|
||||
** ever used and there is no fallback between faces.
|
||||
**
|
||||
** Every number here is in thousandths of the em, read once from the face at a
|
||||
** reference size of 1000, where the bundled faces (1000 units to the em) give
|
||||
** whole numbers. Layout multiplies those integers by the size and never asks
|
||||
** the font for a measurement at the size it draws, so a measurement is the
|
||||
** same on every machine.
|
||||
**
|
||||
** The four numbers after the widest character are the most any character of
|
||||
** the face reaches past its own advance on the left and on the right, above
|
||||
** the baseline and below it. They bound every character, so a value that was
|
||||
** never seen can be proven to fit; a test recomputes them from the font file.
|
||||
*******************************************************************************/
|
||||
public final class Face
|
||||
{
|
||||
public static final Face SANS = new Face("qqq-sans", "ibm-plex-sans/IBMPlexSans-Regular.ttf", false, 0x2030, 203, 203, 1119, 245);
|
||||
public static final Face SANS_BOLD = new Face("qqq-sans-bold", "ibm-plex-sans/IBMPlexSans-Bold.ttf", true, 0x2030, 245, 245, 1150, 275);
|
||||
public static final Face CONDENSED_BOLD = new Face("qqq-condensed-bold", "ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf", true, 0x2030, 241, 241, 1150, 275);
|
||||
public static final Face MONO = new Face("qqq-mono", "jetbrains-mono/JetBrainsMono-Regular.ttf", false, 0x21, 165, 165, 1120, 400);
|
||||
public static final Face MONO_BOLD = new Face("qqq-mono-bold", "jetbrains-mono/JetBrainsMono-Bold.ttf", true, 0x21, 165, 165, 1120, 400);
|
||||
|
||||
static final FontRenderContext CONTEXT = new FontRenderContext(null, true, true);
|
||||
static final int REFERENCE = 1000;
|
||||
|
||||
private static final List<Face> ALL = List.of(SANS, SANS_BOLD, CONDENSED_BOLD, MONO, MONO_BOLD);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// characters that cannot be drawn one glyph each from left to right: controls and formats //
|
||||
// (which would print nothing), marks (which need placing on the letter before) and the like //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final Set<Integer> UNDRAWN = Set.of((int) Character.CONTROL, (int) Character.FORMAT, (int) Character.SURROGATE, (int) Character.PRIVATE_USE, (int) Character.UNASSIGNED,
|
||||
(int) Character.LINE_SEPARATOR, (int) Character.PARAGRAPH_SEPARATOR, (int) Character.NON_SPACING_MARK, (int) Character.ENCLOSING_MARK, (int) Character.COMBINING_SPACING_MARK);
|
||||
|
||||
private final String scene;
|
||||
private final String file;
|
||||
private final boolean bold;
|
||||
private final int widest;
|
||||
private final int left;
|
||||
private final int right;
|
||||
private final int top;
|
||||
private final int bottom;
|
||||
private final Map<Integer, Glyph> glyphs = new ConcurrentHashMap<>();
|
||||
private Font font;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One character of a face: how far the pen moves, and the box its outline
|
||||
** fills, from the pen (`left`, `right`) and from the baseline (`top` above,
|
||||
** `bottom` below), rounded outward. A space has no ink; only U+0020 is a
|
||||
** `space`, the one place a line may be broken between words.
|
||||
***************************************************************************/
|
||||
public record Glyph(int advance, int left, int right, int top, int bottom, boolean ink, boolean space)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A face, where its file is, and the bounds a test holds it to.
|
||||
***************************************************************************/
|
||||
private Face(String scene, String file, boolean bold, int widest, int left, int right, int top, int bottom)
|
||||
{
|
||||
this.scene = scene;
|
||||
this.file = file;
|
||||
this.bold = bold;
|
||||
this.widest = widest;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.top = top;
|
||||
this.bottom = bottom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The bundled set: a proportional sans in regular and bold, a condensed bold
|
||||
** for long values on narrow labels, and a monospace in regular and bold.
|
||||
***************************************************************************/
|
||||
public static List<Face> all()
|
||||
{
|
||||
return (ALL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The face a scene names, or null.
|
||||
***************************************************************************/
|
||||
public static Face of(String scene)
|
||||
{
|
||||
for(Face face : ALL)
|
||||
{
|
||||
if(face.scene.equals(scene))
|
||||
{
|
||||
return (face);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The name a scene writes.
|
||||
***************************************************************************/
|
||||
public String scene()
|
||||
{
|
||||
return (scene);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The font file, under `/fonts` in the jar.
|
||||
***************************************************************************/
|
||||
public String file()
|
||||
{
|
||||
return (file);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the strokes are bold. A regular face prints light when small.
|
||||
***************************************************************************/
|
||||
public boolean bold()
|
||||
{
|
||||
return (bold);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The character with the greatest advance, the lowest when several share it.
|
||||
***************************************************************************/
|
||||
public int widest()
|
||||
{
|
||||
return (widest);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A character no real one exceeds in any direction: the greatest advance and
|
||||
** the greatest reach on each side. A line of these is at least as wide and
|
||||
** as tall as a line of the same number of any characters of the face.
|
||||
***************************************************************************/
|
||||
public Glyph worst()
|
||||
{
|
||||
int advance = glyph(widest).advance();
|
||||
return (new Glyph(advance, -left, advance + right, top, bottom, true, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The face at a size in dots, for drawing only.
|
||||
***************************************************************************/
|
||||
Font font(float size)
|
||||
{
|
||||
return (reference().deriveFont(size));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Height above the baseline that a line reserves.
|
||||
***************************************************************************/
|
||||
public int ascent()
|
||||
{
|
||||
return (Math.round(metrics().getAscent()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Depth below the baseline that a line reserves.
|
||||
***************************************************************************/
|
||||
public int descent()
|
||||
{
|
||||
return (Math.round(metrics().getDescent()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Extra space the face asks for between lines.
|
||||
***************************************************************************/
|
||||
public int leading()
|
||||
{
|
||||
return (Math.round(metrics().getLeading()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The height of a capital letter, which is what legibility is judged by.
|
||||
***************************************************************************/
|
||||
public int capHeight()
|
||||
{
|
||||
return (glyph('H').top());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the face can draw a character as one glyph, left to right.
|
||||
***************************************************************************/
|
||||
public boolean draws(int codePoint)
|
||||
{
|
||||
return (codePoint <= Character.MAX_VALUE && !UNDRAWN.contains(Character.getType(codePoint)) && reference().canDisplay(codePoint)
|
||||
&& !Font.textRequiresLayout(new char[] {(char) codePoint}, 0, 1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many characters of a text the face cannot draw.
|
||||
***************************************************************************/
|
||||
public int missing(String text)
|
||||
{
|
||||
return (Math.toIntExact(text.codePoints().filter(codePoint -> !draws(codePoint)).count()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The measurements of one character the face draws.
|
||||
***************************************************************************/
|
||||
public Glyph glyph(int codePoint)
|
||||
{
|
||||
return (glyphs.computeIfAbsent(codePoint, this::measure));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The measurements of a text the face draws, one for each character.
|
||||
***************************************************************************/
|
||||
public Glyph[] glyphs(String text)
|
||||
{
|
||||
return (text.codePoints().mapToObj(this::glyph).toArray(Glyph[]::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read one character from the face at the reference size.
|
||||
***************************************************************************/
|
||||
private Glyph measure(int codePoint)
|
||||
{
|
||||
GlyphVector vector = reference().createGlyphVector(CONTEXT, new char[] {(char) codePoint});
|
||||
int advance = Math.round(vector.getGlyphMetrics(0).getAdvanceX());
|
||||
Rectangle2D bounds = vector.getGlyphOutline(0).getBounds2D();
|
||||
if(bounds.isEmpty())
|
||||
{
|
||||
return (new Glyph(advance, 0, 0, 0, 0, false, codePoint == ' '));
|
||||
}
|
||||
return (new Glyph(advance, (int) Math.floor(bounds.getMinX()), (int) Math.ceil(bounds.getMaxX()), (int) Math.ceil(-bounds.getMinY()), (int) Math.ceil(bounds.getMaxY()), true, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The line metrics of the face at the reference size.
|
||||
***************************************************************************/
|
||||
private LineMetrics metrics()
|
||||
{
|
||||
return (reference().getLineMetrics("H", CONTEXT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The face at the reference size, read from the jar the first time.
|
||||
***************************************************************************/
|
||||
private synchronized Font reference()
|
||||
{
|
||||
if(font == null)
|
||||
{
|
||||
font = load("/fonts/" + file);
|
||||
}
|
||||
return (font);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a TrueType file from the jar. A file that is not a font is a broken
|
||||
** build, not a mistake in a scene, so it is an illegal state.
|
||||
***************************************************************************/
|
||||
static Font load(String resource)
|
||||
{
|
||||
try(InputStream stream = Face.class.getResourceAsStream(resource))
|
||||
{
|
||||
return (Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont((float) REFERENCE));
|
||||
}
|
||||
catch(IOException | FontFormatException broken)
|
||||
{
|
||||
throw new IllegalStateException("BUNDLED_FONT_UNREADABLE", broken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
import com.kingsrook.qbits.renderer.scene2.Canvas;
|
||||
import com.kingsrook.qbits.renderer.scene2.Diagnostics;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementSource;
|
||||
import com.kingsrook.qbits.renderer.scene2.Frame;
|
||||
import com.kingsrook.qbits.renderer.scene2.Layout;
|
||||
import com.kingsrook.qbits.renderer.scene2.Length;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement;
|
||||
import com.kingsrook.qbits.renderer.scene2.SceneElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A `text` element: one value in one bundled face, measured before it is
|
||||
** drawn, placed in its box by `align` and `valign`, and fitted by the rule the
|
||||
** element states (`TextFit`). The value is a literal (`value`) or is read from
|
||||
** the record by a JSON pointer (`valuePath`).
|
||||
**
|
||||
** Every refusal names the element and carries numbers only: a width, a line
|
||||
** count, a size, a count of characters. The value itself never enters a
|
||||
** diagnostic or an exception.
|
||||
**
|
||||
** A bound value is not known when a scene is validated, so validation reads no
|
||||
** record. With `maxLength` it proves instead that the longest value fits whole:
|
||||
** that many of the face's widest character, each taken to reach as far in
|
||||
** every direction as the furthest-reaching character of the face. The proof
|
||||
** never cuts. When it fails the scene is refused, unless the element asked for
|
||||
** cutting: then the scene stands, with the warning `FIT_NOT_PROVEN` and the
|
||||
** longest length that is proven. Without `maxLength` it warns `FIT_NOT_PROVEN`
|
||||
** with no number. When rendering, a value longer than `maxLength` is refused
|
||||
** before any layout.
|
||||
**
|
||||
** The text of one scene is held to a stated amount of work (`SCENE_WORK_STEPS`):
|
||||
** every character visited while fitting counts one step, every character of
|
||||
** a text that is to be drawn counts `TextRaster.PEN_STEPS`, and every glyph
|
||||
** the scene draws for the first time at a size, which the runtime must
|
||||
** rasterise, counts `TextRaster.GLYPH_STEPS`. A scene that passes it is
|
||||
** refused by name at the element that did, and so is every text after it.
|
||||
** The count is integer arithmetic, the same on every machine, and the same
|
||||
** whether the scene is validated or rendered.
|
||||
*******************************************************************************/
|
||||
final class TextElement implements SceneElement
|
||||
{
|
||||
static final int MAXIMUM_CHARACTERS = 1024;
|
||||
static final int MAXIMUM_LINES = 64;
|
||||
static final int MINIMUM_MILLIPOINTS = 1000;
|
||||
static final int MAXIMUM_MILLIPOINTS = 1_000_000;
|
||||
static final int DEFAULT_FLOOR_PERCENT = 70;
|
||||
static final int DEFAULT_FLOOR_MILLIPOINTS = 5000;
|
||||
static final int DEFAULT_THRESHOLD = 128;
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// capital heights in dots below which thermal print is hard to read: any face under 12 (about //
|
||||
// 6 pt at 203 dpi), a regular weight under 20 (about 10 pt at 203 dpi). Starting values, to //
|
||||
// be confirmed on the printed specimen //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final int SMALL_CAPITAL_DOTS = 12;
|
||||
static final int LIGHT_CAPITAL_DOTS = 20;
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the work the text of one scene may take, in steps. A value of forty characters costs //
|
||||
// about a hundred thousand at a size its scene has not drawn, nearly all of it for the //
|
||||
// glyphs the runtime must rasterise, and under two thousand at a size it has. The //
|
||||
// allowance is fifteen thousand glyphs rasterised, or three thousand fits of the longest //
|
||||
// value over the widest range of sizes: about a second of the worker, where one hostile //
|
||||
// scene took minutes //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final int SCENE_WORK_STEPS = 64_000_000;
|
||||
|
||||
private static final Set<String> FIT = Set.of("maxLines", "shrinkTo", "overflow");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the work of each scene, by the collector of its one render or validation, which is all a //
|
||||
// text element is given that belongs to the scene; it is forgotten with the collector //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<Diagnostics, Scene> SCENES = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private final String id;
|
||||
private final Placement placement;
|
||||
private final int rotation;
|
||||
private final boolean reverse;
|
||||
private final Face face;
|
||||
private final int size;
|
||||
private final int floor;
|
||||
private final Integer lineHeight;
|
||||
private final int maxLines;
|
||||
private final boolean truncate;
|
||||
private final Integer maxLength;
|
||||
private final int threshold;
|
||||
private final String value;
|
||||
private final String path;
|
||||
private Composed composed;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the text of one scene has cost so far: the steps counted, and the
|
||||
** glyphs it has drawn, each a face, a size and a character in one number.
|
||||
** There are never more of those than the steps allow.
|
||||
***************************************************************************/
|
||||
private record Scene(TextFit.Work work, Set<Long> glyphs)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What `check` composed, kept for the `paint` of the same pass, so a text
|
||||
** is fitted once.
|
||||
***************************************************************************/
|
||||
private record Composed(Frame frame, RenderInput input, Diagnostics diagnostics, TextRaster.Drawing drawing)
|
||||
{
|
||||
/************************************************************************
|
||||
** Whether this is the answer for the same box of the same pass.
|
||||
************************************************************************/
|
||||
boolean of(Frame other, RenderInput request, Diagnostics collector)
|
||||
{
|
||||
return (frame.equals(other) && input == request && diagnostics == collector);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the element. A property that is not what the schema says is
|
||||
** `SCENE_INVALID` at that property, a member of `fit` at that member; a
|
||||
** member `fit` does not have is reported at `fit`, since only names the
|
||||
** schema declares leave the renderer. A face this renderer does not bundle
|
||||
** is `FONT_UNKNOWN`.
|
||||
***************************************************************************/
|
||||
TextElement(ElementSource source)
|
||||
{
|
||||
id = source.id();
|
||||
placement = source.placement();
|
||||
rotation = source.rotation();
|
||||
reverse = source.flag("reverse", false);
|
||||
JsonNode font = source.value("font");
|
||||
if(font == null || !font.isTextual())
|
||||
{
|
||||
throw source.invalid("font");
|
||||
}
|
||||
face = Face.of(font.textValue());
|
||||
if(face == null)
|
||||
{
|
||||
throw source.refuse("FONT_UNKNOWN", "font");
|
||||
}
|
||||
Length stated = source.length("size", null, ElementSource.PHYSICAL);
|
||||
size = stated == null ? 0 : milliPoints(stated);
|
||||
if(size < MINIMUM_MILLIPOINTS || size > MAXIMUM_MILLIPOINTS)
|
||||
{
|
||||
throw source.invalid("size");
|
||||
}
|
||||
lineHeight = lineHeight(source);
|
||||
threshold = integer(source, "threshold", source.value("threshold"), 1, 254, DEFAULT_THRESHOLD);
|
||||
maxLength = source.value("maxLength") == null ? null : Integer.valueOf(integer(source, "maxLength", source.value("maxLength"), 1, MAXIMUM_CHARACTERS, 0));
|
||||
JsonNode fit = source.value("fit") == null ? JsonNodeFactory.instance.objectNode() : source.value("fit");
|
||||
if(!fit.isObject() || fit.properties().stream().anyMatch(property -> !FIT.contains(property.getKey())))
|
||||
{
|
||||
throw source.invalid("fit");
|
||||
}
|
||||
maxLines = integer(source, "fit/maxLines", fit.get("maxLines"), 1, MAXIMUM_LINES, 1);
|
||||
floor = floor(source, fit.get("shrinkTo"));
|
||||
String overflow = fit.has("overflow") ? fit.get("overflow").textValue() : "refuse";
|
||||
if(!"refuse".equals(overflow) && !"truncate".equals(overflow))
|
||||
{
|
||||
throw source.invalid("fit/overflow");
|
||||
}
|
||||
truncate = "truncate".equals(overflow);
|
||||
JsonNode literal = source.value("value");
|
||||
JsonNode pointer = source.value("valuePath");
|
||||
if((literal == null) == (pointer == null))
|
||||
{
|
||||
throw source.invalid("value");
|
||||
}
|
||||
if(literal != null && (!literal.isTextual() || literal.textValue().codePointCount(0, literal.textValue().length()) > MAXIMUM_CHARACTERS))
|
||||
{
|
||||
throw source.invalid("value");
|
||||
}
|
||||
if(pointer != null && (!pointer.isTextual() || pointer.textValue().length() > 256 || !pointer.textValue().startsWith("/")))
|
||||
{
|
||||
throw source.invalid("valuePath");
|
||||
}
|
||||
value = literal == null ? null : literal.textValue();
|
||||
path = pointer == null ? null : pointer.textValue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A type size in thousandths of a point. A point is a 72nd of an inch and an
|
||||
** inch is 25400 micrometres, so a micrometre is 360/127 of a millipoint.
|
||||
***************************************************************************/
|
||||
private static int milliPoints(Length length)
|
||||
{
|
||||
long thousandths = length.thousandths();
|
||||
return (Math.toIntExact(switch(length.unit())
|
||||
{
|
||||
case IN -> thousandths * 72;
|
||||
case MM -> Math.floorDiv(thousandths * 360 + 63, 127);
|
||||
default -> thousandths;
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A whole number in a range, or the fallback when the scene states none.
|
||||
***************************************************************************/
|
||||
private static int integer(ElementSource source, String name, JsonNode stated, int minimum, int maximum, int fallback)
|
||||
{
|
||||
if(stated == null)
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
if(!stated.isIntegralNumber() || !stated.canConvertToInt() || stated.intValue() < minimum || stated.intValue() > maximum)
|
||||
{
|
||||
throw source.invalid(name);
|
||||
}
|
||||
return (stated.intValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The distance between baselines as a multiple of the size, in thousandths:
|
||||
** a number from 0.5 to 5 with at most three decimals. Null is the face's own.
|
||||
** A number too large to be a number at all (`1e400`) is refused like any
|
||||
** other, not left to fail as arithmetic.
|
||||
***************************************************************************/
|
||||
private static Integer lineHeight(ElementSource source)
|
||||
{
|
||||
JsonNode stated = source.value("lineHeight");
|
||||
if(stated == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
BigDecimal thousandths = stated.isNumber() && Double.isFinite(stated.doubleValue()) ? stated.decimalValue().movePointRight(3) : BigDecimal.ZERO;
|
||||
if(thousandths.stripTrailingZeros().scale() > 0 || thousandths.compareTo(BigDecimal.valueOf(500)) < 0 || thousandths.compareTo(BigDecimal.valueOf(5000)) > 0)
|
||||
{
|
||||
throw source.invalid("lineHeight");
|
||||
}
|
||||
return (thousandths.intValueExact());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The smallest size the text may be reduced to. Stated, it is a physical
|
||||
** length from one point up to the size. Otherwise it is 70% of the size and
|
||||
** not below 5 pt, or the size itself when that is smaller still.
|
||||
***************************************************************************/
|
||||
private int floor(ElementSource source, JsonNode stated)
|
||||
{
|
||||
if(stated == null)
|
||||
{
|
||||
return (Math.min(size, Math.max(size * DEFAULT_FLOOR_PERCENT / 100, DEFAULT_FLOOR_MILLIPOINTS)));
|
||||
}
|
||||
try
|
||||
{
|
||||
Length length = Length.parse(stated.textValue());
|
||||
if(length.physical() && milliPoints(length) >= MINIMUM_MILLIPOINTS && milliPoints(length) <= size)
|
||||
{
|
||||
return (milliPoints(length));
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException expected)
|
||||
{
|
||||
// Refused below, at the property; the cause holds nothing more.
|
||||
}
|
||||
throw source.invalid("fit/shrinkTo");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The id the scene declares for the text.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element's box, by the standing rules.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Frame place(Layout layout)
|
||||
{
|
||||
return (layout.place(placement));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Everything that can refuse the text, with no ink: run when a scene is
|
||||
** validated and before it is painted. What it composed is kept for the
|
||||
** paint that follows.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void check(Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
composed = null;
|
||||
composed = new Composed(frame, input, diagnostics, compose(frame, input, diagnostics));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reverse text paints its box and then clears its letters, so it is counted
|
||||
** twice against what one scene may paint.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public long paintedDots(Frame frame, int dpi)
|
||||
{
|
||||
return ((reverse ? 2L : 1L) * frame.width() * frame.height());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Draw the text as `check` fitted it, or not at all: a text whose ink the
|
||||
** rasteriser put outside what was measured is refused and leaves the label
|
||||
** as it was. Reverse text is white letters on its own box filled black.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
Composed checked = composed;
|
||||
composed = null;
|
||||
TextRaster.Drawing drawing = checked != null && checked.of(frame, input, diagnostics) ? checked.drawing() : compose(frame, input, diagnostics);
|
||||
measured(diagnostics, id, frame, TextRaster.paint(canvas, frame, drawing, TextRaster.BAND_DOTS), drawing.block().milliPoints());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Refuse a text whose ink is not where it was measured: the element, by
|
||||
** name, with how many glyphs and dots strayed and at what size. The label
|
||||
** holds none of it.
|
||||
***************************************************************************/
|
||||
static void measured(Diagnostics diagnostics, String id, Frame frame, TextRaster.Stray stray, int milliPoints)
|
||||
{
|
||||
if(stray.any())
|
||||
{
|
||||
throw diagnostics.refuse("TEXT_DOES_NOT_FIT", id, null, frame, Map.of("glyphsOutsideMeasure", stray.glyphs(), "dotsOutsideMeasure", stray.dots(), "sizeCentipoints", milliPoints / 10),
|
||||
Map.of("glyphsOutsideMeasure", 0, "dotsOutsideMeasure", 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Compose the text within the work its scene may take, or refuse the
|
||||
** element by name with the steps counted against the steps allowed.
|
||||
***************************************************************************/
|
||||
private TextRaster.Drawing compose(Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
Scene scene = SCENES.computeIfAbsent(diagnostics, collector -> new Scene(new TextFit.Work(SCENE_WORK_STEPS), new HashSet<>()));
|
||||
try
|
||||
{
|
||||
return (compose(frame, input, diagnostics, scene));
|
||||
}
|
||||
catch(TextFit.Exhausted exhausted)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", id, diagnostics.pointer(id), frame, Map.of("textWorkSteps", Math.toIntExact(Math.min(scene.work().spent(), Integer.MAX_VALUE))), Map.of("textWorkSteps", SCENE_WORK_STEPS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the value, hold it to its length and to the face, and fit it. The
|
||||
** answer is what to draw, or null when a bound value is only being
|
||||
** validated. A text that is only validated is also held to what the
|
||||
** rasteriser would do with it, glyph by glyph, with no ink.
|
||||
***************************************************************************/
|
||||
private TextRaster.Drawing compose(Frame frame, RenderInput input, Diagnostics diagnostics, Scene scene)
|
||||
{
|
||||
TextFit.Work work = scene.work();
|
||||
int reserved = input.reservedAssetNames().size();
|
||||
if(reserved > 0)
|
||||
{
|
||||
throw diagnostics.refuse("RESERVED_FONT_NAME", id, null, frame, Map.of("reservedAssets", reserved), Map.of("reservedAssets", 0));
|
||||
}
|
||||
int dpi = input.targetDpi == null ? input.stock.dpi() : input.targetDpi;
|
||||
boolean upright = rotation % 180 == 0;
|
||||
boolean validate = "VALIDATE".equals(input.operation);
|
||||
TextFit.Request request = new TextFit.Request(face, upright ? frame.width() : frame.height(), upright ? frame.height() : frame.width(), dpi, size, floor, lineHeight, maxLines, truncate);
|
||||
if(path != null && maxLength == null)
|
||||
{
|
||||
diagnostics.warn("FIT_NOT_PROVEN", id, null, frame, null, null);
|
||||
}
|
||||
if(path != null && validate)
|
||||
{
|
||||
legibility(maxLength == null ? size : proven(request, frame, diagnostics, work), dpi, frame, diagnostics);
|
||||
return (null);
|
||||
}
|
||||
String text = path == null ? value : bound(input.context.at(path), frame, diagnostics);
|
||||
int characters = text.codePointCount(0, text.length());
|
||||
int longest = maxLength == null ? MAXIMUM_CHARACTERS : maxLength;
|
||||
if(characters > longest)
|
||||
{
|
||||
throw diagnostics.refuse("VALUE_TOO_LONG", id, null, frame, Map.of("characters", characters), Map.of("characters", longest));
|
||||
}
|
||||
int missing = face.missing(text);
|
||||
if(missing > 0)
|
||||
{
|
||||
throw diagnostics.refuse("FONT_MISSING_GLYPH", id, null, frame, Map.of("missingGlyphs", missing, "characters", characters), Map.of("missingGlyphs", 0));
|
||||
}
|
||||
TextFit.Block block = fitted(request, face.glyphs(text), frame, diagnostics, work);
|
||||
long drawn = (long) Face.all().indexOf(face) << 48 | (long) block.milliPoints() << 16;
|
||||
long fresh = text.codePoints().filter(codePoint -> scene.glyphs().add(drawn | codePoint)).count();
|
||||
work.spend(fresh * TextRaster.GLYPH_STEPS + (long) characters * TextRaster.PEN_STEPS);
|
||||
if(block.milliPoints() < size)
|
||||
{
|
||||
diagnostics.inform("TEXT_SHRUNK", id, null, frame, Map.of("sizeCentipoints", block.milliPoints() / 10), Map.of("sizeCentipoints", size / 10, "floorCentipoints", floor / 10));
|
||||
}
|
||||
if(block.lines().size() > 1)
|
||||
{
|
||||
diagnostics.inform("TEXT_WRAPPED", id, null, frame, Map.of("lines", block.lines().size()), Map.of("lines", maxLines));
|
||||
}
|
||||
if(block.truncated())
|
||||
{
|
||||
diagnostics.warn("TEXT_TRUNCATED", id, null, frame, Map.of("characters", block.characters(), "shownCharacters", block.shown(), "lines", block.lines().size()), Map.of("lines", maxLines));
|
||||
}
|
||||
legibility(block.milliPoints(), dpi, frame, diagnostics);
|
||||
TextRaster.Drawing drawing = new TextRaster.Drawing(face, text, block, placement.align(), placement.valign(), rotation, threshold, !reverse);
|
||||
measured(diagnostics, id, frame, new TextRaster.Stray(validate ? TextRaster.outside(drawing, dpi) : 0, 0), block.milliPoints());
|
||||
return (drawing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The value the record holds at the element's pointer, as it is printed.
|
||||
** Text is printed as it is and a truth value as `true` or `false`. A number
|
||||
** is printed in plain decimal digits, as the schema says: no exponent, and
|
||||
** no fraction that is only zeros, so `1.0E10` prints `10000000000`, `2.50`
|
||||
** prints `2.5` and `12.0` prints `12`. Anything else, a number that is not
|
||||
** finite among them, is `VALUE_MISSING`.
|
||||
***************************************************************************/
|
||||
private String bound(JsonNode bound, Frame frame, Diagnostics diagnostics)
|
||||
{
|
||||
if(bound.isTextual() || bound.isBoolean())
|
||||
{
|
||||
return (bound.asText());
|
||||
}
|
||||
if(bound.isIntegralNumber())
|
||||
{
|
||||
return (bound.bigIntegerValue().toString());
|
||||
}
|
||||
if(bound.isNumber() && Double.isFinite(bound.doubleValue()))
|
||||
{
|
||||
return (BigDecimal.valueOf(bound.doubleValue()).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
throw diagnostics.refuse("VALUE_MISSING", id, null, frame, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Prove that the longest value fits whole, and answer the size it is
|
||||
** proven at, which is the smallest any value is printed at. The proof never
|
||||
** cuts: a cut value is not a value that fits. When it fails, an element
|
||||
** that did not ask for cutting is refused; one that did stands, since
|
||||
** cutting is what its author chose, with a warning that says how many
|
||||
** characters are proven to fit whole. Fewer characters never fit worse, so
|
||||
** that number is found by halving. The size is then the floor, where a cut
|
||||
** value is printed.
|
||||
***************************************************************************/
|
||||
private int proven(TextFit.Request request, Frame frame, Diagnostics diagnostics, TextFit.Work work)
|
||||
{
|
||||
TextFit.Request whole = new TextFit.Request(face, request.width(), request.height(), request.dpi(), size, floor, lineHeight, maxLines, false);
|
||||
Face.Glyph[] worst = new Face.Glyph[maxLength];
|
||||
Arrays.fill(worst, face.worst());
|
||||
TextFit.Block proof = truncate ? TextFit.fit(whole, worst, null, work) : fitted(whole, worst, frame, diagnostics, work);
|
||||
if(!proof.fits())
|
||||
{
|
||||
int low = 0;
|
||||
int high = maxLength - 1;
|
||||
while(low < high)
|
||||
{
|
||||
int middle = (low + high + 1) >>> 1;
|
||||
if(TextFit.fit(whole, Arrays.copyOf(worst, middle), null, work).fits())
|
||||
{
|
||||
low = middle;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
diagnostics.warn("FIT_NOT_PROVEN", id, null, frame, Map.of("provenCharacters", low), Map.of("characters", maxLength));
|
||||
}
|
||||
return (proof.milliPoints());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit, or refuse with what was measured at the floor size against the box.
|
||||
***************************************************************************/
|
||||
private TextFit.Block fitted(TextFit.Request request, Face.Glyph[] glyphs, Frame frame, Diagnostics diagnostics, TextFit.Work work)
|
||||
{
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, face.glyph(TextFit.MARK), work);
|
||||
if(!block.fits())
|
||||
{
|
||||
throw diagnostics.refuse("TEXT_DOES_NOT_FIT", id, null, frame,
|
||||
Map.of("widthDots", block.width(), "heightDots", block.height(), "lines", block.lines().size(), "sizeCentipoints", block.milliPoints() / 10, "characters", block.characters()),
|
||||
Map.of("widthDots", request.width(), "heightDots", request.height(), "lines", maxLines));
|
||||
}
|
||||
return (block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Warn when the type is small for the printer: any face whose capitals are
|
||||
** under 12 dots, and a regular weight whose capitals are under 20.
|
||||
***************************************************************************/
|
||||
private void legibility(int milliPoints, int dpi, Frame frame, Diagnostics diagnostics)
|
||||
{
|
||||
int capitals = new TextFit.Scale(milliPoints, dpi).floor(face.capHeight());
|
||||
if(capitals < SMALL_CAPITAL_DOTS)
|
||||
{
|
||||
diagnostics.warn("TEXT_SMALL_FOR_DPI", id, null, frame, Map.of("capHeightDots", capitals, "sizeCentipoints", milliPoints / 10), Map.of("capHeightDots", SMALL_CAPITAL_DOTS));
|
||||
}
|
||||
if(!face.bold() && capitals < LIGHT_CAPITAL_DOTS)
|
||||
{
|
||||
diagnostics.warn("TEXT_LIGHT_FACE", id, null, frame, Map.of("capHeightDots", capitals, "sizeCentipoints", milliPoints / 10), Map.of("capHeightDots", LIGHT_CAPITAL_DOTS));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementFactory;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementSource;
|
||||
import com.kingsrook.qbits.renderer.scene2.SceneElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Text: a TrueType face bundled with the renderer, rasterised by it, with
|
||||
** size, fit and legibility rules. Version 2 has no printer font: a face the
|
||||
** renderer cannot measure can be neither fitted nor previewed truthfully.
|
||||
*******************************************************************************/
|
||||
public final class TextFactory implements ElementFactory
|
||||
{
|
||||
/***************************************************************************
|
||||
** The properties of a `text` element beyond the common ones. `font` and
|
||||
** `size` may come from the scene's `defaults`.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Set<String> properties()
|
||||
{
|
||||
return (Set.of("value", "valuePath", "font", "size", "lineHeight", "fit", "maxLength", "threshold", "reverse", "rotation"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build one text element.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public SceneElement create(ElementSource source)
|
||||
{
|
||||
return (new TextElement(source));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.renderer.scene2.text.Face.Glyph;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Measurement and fit of one text in one box, in whole dots, by integer
|
||||
** arithmetic on the face's own numbers. Nothing is drawn here, and what is
|
||||
** drawn later is drawn from this answer: the pen position of every character
|
||||
** and the box of every line are decided once.
|
||||
**
|
||||
** The order is fixed. Lay the text out at the nominal size within the allowed
|
||||
** lines. If it does not fit, take the largest size, in steps of a quarter of
|
||||
** a point down to the floor, at which it does. If none does, the text is
|
||||
** refused, or, only when the element asked for it, cut at a character and
|
||||
** ended with an ellipsis at the floor size.
|
||||
**
|
||||
** The answer is the one a search of every step from the nominal size down
|
||||
** gives, and is found without making it. A count that errs only toward yes
|
||||
** (`possible`) says whether a size could hold the text; it never says no to a
|
||||
** size that fits and never says yes to a larger size after no to a smaller,
|
||||
** so the largest size it allows is found by halving, and no size above that
|
||||
** one fits. From there the steps are laid out one by one as before; the count
|
||||
** is within a few dots of the layout, so they are few. When one line at the
|
||||
** floor is higher than the box, no size fits and none is searched.
|
||||
**
|
||||
** Every character visited is counted against the `Work` the caller gives, so
|
||||
** a caller can hold all the fitting of one scene to a stated amount.
|
||||
**
|
||||
** At each size a line is first broken between words only. If that does not
|
||||
** fit, it is broken at any character. So a value with no spaces wraps, a
|
||||
** sentence keeps its words while it can, and a larger size is preferred to
|
||||
** whole words. Breaking at any character is also what makes a length provable:
|
||||
** a text of n characters needs no more lines than n of the face's widest.
|
||||
**
|
||||
** A line's box is its pen advance, widened to hold the ink of every character
|
||||
** in it (a `j` that starts left of the pen, an accent above the ascent) plus
|
||||
** `SLOP` dots on each inked side. The drawn glyph is the font's outline fitted
|
||||
** to the dot grid by the rasteriser, which moves an edge by less than two dots
|
||||
** from the outline scaled exactly; the slop is that allowance, and a test
|
||||
** holds every character of every bundled face to it.
|
||||
*******************************************************************************/
|
||||
public final class TextFit
|
||||
{
|
||||
public static final int SLOP = 2;
|
||||
public static final int STEP_MILLIPOINTS = 250;
|
||||
public static final int MARK = 0x2026;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private TextFit()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What is asked: a face, a box in dots as the text reads (before any
|
||||
** rotation), the resolution, the nominal size and the floor in thousandths
|
||||
** of a point, the line pitch in thousandths of the size (null for the
|
||||
** face's own), the most lines, and whether a text that fits at no size is
|
||||
** cut short rather than refused.
|
||||
***************************************************************************/
|
||||
public record Request(Face face, int width, int height, int dpi, int nominal, int floor, Integer lineHeight, int maxLines, boolean truncate)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One line: the characters `from` to `to` of the text, followed by the
|
||||
** ellipsis when `marked`. `left` (never positive) and `right` are the sides
|
||||
** of its box measured from the pen at the start of the line; `above` and
|
||||
** `below` are how far its ink reaches from the baseline.
|
||||
***************************************************************************/
|
||||
public record Line(int from, int to, boolean marked, int left, int right, int above, int below)
|
||||
{
|
||||
/************************************************************************
|
||||
** The width of the line's box.
|
||||
************************************************************************/
|
||||
public int width()
|
||||
{
|
||||
return (right - left);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The answer: the size chosen, the lines, the size of the block they make,
|
||||
** the baseline of the first line from the top of the block, the distance
|
||||
** between baselines, how many characters there were (trailing spaces never
|
||||
** print and are not counted), how many are shown, and whether it fits. When
|
||||
** it does not, the numbers are those of the text broken at any character at
|
||||
** the floor size, which is what a refusal reports.
|
||||
***************************************************************************/
|
||||
public record Block(int milliPoints, List<Line> lines, int width, int height, int baseline, int pitch, int characters, int shown, boolean fits)
|
||||
{
|
||||
/************************************************************************
|
||||
** Whether the text was cut short.
|
||||
************************************************************************/
|
||||
public boolean truncated()
|
||||
{
|
||||
return (shown < characters);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Dots from thousandths of the em at one size and resolution. A size of
|
||||
** `milliPoints` is `milliPoints * dpi / 72000` dots to the em, so a length
|
||||
** of `units` is `units * milliPoints * dpi / 72000000` dots, rounded as the
|
||||
** use says. The products stay far inside a long.
|
||||
***************************************************************************/
|
||||
public record Scale(int milliPoints, int dpi)
|
||||
{
|
||||
private static final long DIVISOR = 72_000_000L;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Rounded down.
|
||||
************************************************************************/
|
||||
public int floor(long units)
|
||||
{
|
||||
return (Math.toIntExact(Math.floorDiv(units * milliPoints * dpi, DIVISOR)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Rounded up.
|
||||
************************************************************************/
|
||||
public int ceil(long units)
|
||||
{
|
||||
return (-floor(-units));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** To the nearest dot, a half upward.
|
||||
************************************************************************/
|
||||
public int round(long units)
|
||||
{
|
||||
return (Math.toIntExact(Math.floorDiv(units * milliPoints * dpi + DIVISOR / 2, DIVISOR)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The size of the em in dots, for the font that draws.
|
||||
************************************************************************/
|
||||
public float size()
|
||||
{
|
||||
return ((float) ((double) milliPoints * dpi / 72_000));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The work fitting may do, in characters visited, shared by as many fits
|
||||
** as the caller wants held to one amount. It is counted by integer
|
||||
** arithmetic alone, so it is the same on every machine.
|
||||
***************************************************************************/
|
||||
public static final class Work
|
||||
{
|
||||
private final long limit;
|
||||
private long spent;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** An allowance of this many steps.
|
||||
************************************************************************/
|
||||
public Work(long limit)
|
||||
{
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Count steps, and end the work when the allowance is passed. It stays
|
||||
** passed: whatever is asked next ends at once.
|
||||
************************************************************************/
|
||||
public void spend(long steps)
|
||||
{
|
||||
spent += steps;
|
||||
if(spent > limit)
|
||||
{
|
||||
throw new Exhausted();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The steps counted so far.
|
||||
************************************************************************/
|
||||
public long spent()
|
||||
{
|
||||
return (spent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The allowance of a `Work` was passed. It says nothing else: what was
|
||||
** being fitted is not in it.
|
||||
***************************************************************************/
|
||||
public static final class Exhausted extends RuntimeException
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** No message beyond the name, no cause and no stack.
|
||||
************************************************************************/
|
||||
Exhausted()
|
||||
{
|
||||
super("TEXT_WORK_EXHAUSTED", null, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What a line reserves, in thousandths of the em, read from the face once
|
||||
** for a fit: the ascent above the baseline, the descent below it, and the
|
||||
** distance between baselines.
|
||||
***************************************************************************/
|
||||
record Leading(int ascent, int descent, int pitch)
|
||||
{
|
||||
/************************************************************************
|
||||
** The face's own, with the line height the request states if it does.
|
||||
************************************************************************/
|
||||
static Leading of(Request request)
|
||||
{
|
||||
Face face = request.face();
|
||||
return (new Leading(face.ascent(), face.descent(), request.lineHeight() == null ? face.ascent() + face.descent() + face.leading() : request.lineHeight()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The distance between baselines in dots, never less than one.
|
||||
************************************************************************/
|
||||
int pitch(Scale scale)
|
||||
{
|
||||
return (Math.max(1, scale.ceil(pitch)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The least a block of this many lines is high: one ascent, the pitches
|
||||
** between and one descent. Ink that reaches further makes it higher.
|
||||
************************************************************************/
|
||||
int height(Scale scale, int lines)
|
||||
{
|
||||
return (scale.ceil(ascent) + (lines - 1) * pitch(scale) + scale.ceil(descent));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box of a line as characters are added to it from the left.
|
||||
***************************************************************************/
|
||||
private static final class Extent
|
||||
{
|
||||
private final Scale scale;
|
||||
private long units;
|
||||
private int left;
|
||||
private int right;
|
||||
private int above;
|
||||
private int below;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** An empty line at one size.
|
||||
************************************************************************/
|
||||
Extent(Scale scale)
|
||||
{
|
||||
this.scale = scale;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Add the next character at the pen. A space moves the pen and widens
|
||||
** nothing, so spaces at the end of a line take no room.
|
||||
************************************************************************/
|
||||
void add(Glyph glyph)
|
||||
{
|
||||
int pen = scale.round(units);
|
||||
units += glyph.advance();
|
||||
if(!glyph.space())
|
||||
{
|
||||
right = Math.max(right, scale.ceil(units));
|
||||
}
|
||||
if(glyph.ink())
|
||||
{
|
||||
left = Math.min(left, pen + scale.floor(glyph.left()) - SLOP);
|
||||
right = Math.max(right, pen + scale.ceil(glyph.right()) + SLOP);
|
||||
above = Math.max(above, scale.ceil(glyph.top()) + SLOP);
|
||||
below = Math.max(below, scale.ceil(glyph.bottom()) + SLOP);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The width of the box so far.
|
||||
************************************************************************/
|
||||
int width()
|
||||
{
|
||||
return (right - left);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text, given as the measurements of its characters, by the fixed
|
||||
** order, with no limit on the work. `mark` is the face's ellipsis, used
|
||||
** only when the request allows cutting.
|
||||
***************************************************************************/
|
||||
public static Block fit(Request request, Glyph[] glyphs, Glyph mark)
|
||||
{
|
||||
return (fit(request, glyphs, mark, new Work(Long.MAX_VALUE)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text by the fixed order, counting the work against an allowance
|
||||
** and ending as `Exhausted` when it is passed. The floor is never above
|
||||
** the nominal size and no character has a negative advance.
|
||||
***************************************************************************/
|
||||
public static Block fit(Request request, Glyph[] glyphs, Glyph mark, Work work)
|
||||
{
|
||||
int count = glyphs.length;
|
||||
while(count > 0 && glyphs[count - 1].space())
|
||||
{
|
||||
count--;
|
||||
}
|
||||
Leading leading = Leading.of(request);
|
||||
int last = (request.nominal() - request.floor() + STEP_MILLIPOINTS - 1) / STEP_MILLIPOINTS;
|
||||
Block block = at(request, leading, request.nominal(), glyphs, count, mark, work);
|
||||
int step = block.fits() || last == 0 ? last + 1 : first(request, leading, glyphs, count, last, work);
|
||||
while(step <= last)
|
||||
{
|
||||
block = at(request, leading, size(request, step), glyphs, count, mark, work);
|
||||
step = block.fits() ? last + 1 : step + 1;
|
||||
}
|
||||
return (block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The size a number of quarter-point steps below the nominal, which is the
|
||||
** floor once the steps reach it.
|
||||
***************************************************************************/
|
||||
private static int size(Request request, int step)
|
||||
{
|
||||
return (Math.max(request.floor(), request.nominal() - step * STEP_MILLIPOINTS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The answer at one size: the text broken between words if that fits, else
|
||||
** broken at any character; and at the floor, when that does not fit either
|
||||
** and the request allows it, cut. This is one step of the search of every
|
||||
** size, which a test makes in full to hold `fit` to it.
|
||||
***************************************************************************/
|
||||
static Block at(Request request, Leading leading, int size, Glyph[] glyphs, int count, Glyph mark, Work work)
|
||||
{
|
||||
Scale scale = new Scale(size, request.dpi());
|
||||
Block words = block(request, leading, scale, lines(request, scale, glyphs, count, true, work), count, count);
|
||||
if(words.fits())
|
||||
{
|
||||
return (words);
|
||||
}
|
||||
Block characters = block(request, leading, scale, lines(request, scale, glyphs, count, false, work), count, count);
|
||||
return (characters.fits() || size > request.floor() || !request.truncate() ? characters : cut(request, leading, scale, glyphs, mark, characters, work));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The fewest steps below the nominal, from one, at which the text could
|
||||
** fit; `last`, the floor, when it could at none above it. No size above the
|
||||
** answer fits, because `possible` never refuses a size that fits and, once
|
||||
** it allows a size, allows every smaller one. One line at the floor higher
|
||||
** than the box settles it for every text without a count.
|
||||
***************************************************************************/
|
||||
private static int first(Request request, Leading leading, Glyph[] glyphs, int count, int last, Work work)
|
||||
{
|
||||
if(leading.height(new Scale(request.floor(), request.dpi()), 1) > request.height())
|
||||
{
|
||||
return (last);
|
||||
}
|
||||
int low = 1;
|
||||
int high = last;
|
||||
while(low < high)
|
||||
{
|
||||
int middle = (low + high) >>> 1;
|
||||
if(possible(request, leading, new Scale(size(request, middle), request.dpi()), glyphs, count, work))
|
||||
{
|
||||
high = middle;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
return (low);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a text could fit at one size, erring only toward yes. Lines are
|
||||
** filled at any character, each held to a width no layout of it can be
|
||||
** under: its advance to the end of its last character that is not a space,
|
||||
** or, when wider, the span of its ink scaled exactly and the allowance at
|
||||
** both ends less the one dot two roundings can give back. A line of a real
|
||||
** layout, by words or by characters, is never narrower than this, and a
|
||||
** line that holds more is never narrower than one that holds less, so no
|
||||
** layout has fewer lines than are counted here; and a block is never lower
|
||||
** than its lines' ascent, pitches and descent. Every one of these numbers
|
||||
** grows with the size, so a yes at one size is a yes at every smaller one.
|
||||
***************************************************************************/
|
||||
private static boolean possible(Request request, Leading leading, Scale scale, Glyph[] glyphs, int count, Work work)
|
||||
{
|
||||
work.spend(count);
|
||||
int lines = 0;
|
||||
int index = 0;
|
||||
while(index < count)
|
||||
{
|
||||
lines++;
|
||||
if(lines > request.maxLines() || leading.height(scale, lines) > request.height())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int from = index;
|
||||
long units = 0;
|
||||
long end = 0;
|
||||
long reach = Long.MIN_VALUE;
|
||||
long lead = Long.MAX_VALUE;
|
||||
while(index < count)
|
||||
{
|
||||
Glyph glyph = glyphs[index];
|
||||
long after = units + glyph.advance();
|
||||
long ended = glyph.space() ? end : after;
|
||||
long reached = glyph.ink() ? Math.max(reach, units + glyph.right()) : reach;
|
||||
long led = glyph.ink() ? Math.min(lead, units + glyph.left()) : lead;
|
||||
int width = Math.max(scale.ceil(ended), reached < led ? 0 : scale.ceil(reached - led) + 2 * SLOP - 1);
|
||||
if(width > request.width() && index > from)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if(width > request.width())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
units = after;
|
||||
end = ended;
|
||||
reach = reached;
|
||||
lead = led;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Break a text into lines at one size, between words or at any character.
|
||||
** Spaces where a line is broken are dropped. A line is measured twice, to
|
||||
** find its end and to box it, and is counted so.
|
||||
***************************************************************************/
|
||||
private static List<Line> lines(Request request, Scale scale, Glyph[] glyphs, int count, boolean words, Work work)
|
||||
{
|
||||
List<Line> lines = new ArrayList<>();
|
||||
int from = 0;
|
||||
while(from < count)
|
||||
{
|
||||
int to = end(request, scale, glyphs, from, count, words);
|
||||
work.spend(2L * (to - from) + 1);
|
||||
lines.add(line(scale, glyphs, from, to, null));
|
||||
from = to;
|
||||
while(from < count && glyphs[from].space())
|
||||
{
|
||||
from++;
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Where the line that starts at `from` ends: after the last word, or the
|
||||
** last character, that still fits the width. The first one is taken even
|
||||
** when it is too wide, so the line is then wider than the box and the
|
||||
** layout does not fit. Broken at any character, a line may end after a
|
||||
** space, which takes no room; so spaces at the start of a text cannot push
|
||||
** its first letter out of the box.
|
||||
***************************************************************************/
|
||||
private static int end(Request request, Scale scale, Glyph[] glyphs, int from, int count, boolean words)
|
||||
{
|
||||
Extent extent = new Extent(scale);
|
||||
int accepted = from;
|
||||
for(int index = from; index < count; index++)
|
||||
{
|
||||
extent.add(glyphs[index]);
|
||||
if(!words || (!glyphs[index].space() && (index + 1 == count || glyphs[index + 1].space())))
|
||||
{
|
||||
boolean wide = extent.width() > request.width();
|
||||
if(wide && accepted > from)
|
||||
{
|
||||
return (accepted);
|
||||
}
|
||||
accepted = index + 1;
|
||||
if(wide)
|
||||
{
|
||||
return (accepted);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (accepted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Measure one line, with the ellipsis after it when one is given.
|
||||
***************************************************************************/
|
||||
private static Line line(Scale scale, Glyph[] glyphs, int from, int to, Glyph mark)
|
||||
{
|
||||
Extent extent = new Extent(scale);
|
||||
for(int index = from; index < to; index++)
|
||||
{
|
||||
extent.add(glyphs[index]);
|
||||
}
|
||||
if(mark != null)
|
||||
{
|
||||
extent.add(mark);
|
||||
}
|
||||
return (new Line(from, to, mark != null, extent.left, extent.right, extent.above, extent.below));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Stack lines into a block and hold it against the box. A line reserves the
|
||||
** face's ascent above its baseline and its descent below; the block grows
|
||||
** where the ink of a line reaches further.
|
||||
***************************************************************************/
|
||||
private static Block block(Request request, Leading leading, Scale scale, List<Line> lines, int characters, int shown)
|
||||
{
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
return (new Block(scale.milliPoints(), lines, 0, 0, 0, 0, characters, shown, true));
|
||||
}
|
||||
int ascent = scale.ceil(leading.ascent());
|
||||
int pitch = leading.pitch(scale);
|
||||
int top = 0;
|
||||
int bottom = leading.height(scale, lines.size());
|
||||
int width = 0;
|
||||
for(int index = 0; index < lines.size(); index++)
|
||||
{
|
||||
Line line = lines.get(index);
|
||||
int baseline = ascent + index * pitch;
|
||||
top = Math.min(top, baseline - line.above());
|
||||
bottom = Math.max(bottom, baseline + line.below());
|
||||
width = Math.max(width, line.width());
|
||||
}
|
||||
boolean fits = lines.size() <= request.maxLines() && width <= request.width() && bottom - top <= request.height();
|
||||
return (new Block(scale.milliPoints(), lines, width, bottom - top, ascent - top, pitch, characters, shown, fits));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Cut a text that fits at no size: keep as many lines as the box holds, and
|
||||
** end the last of them with the ellipsis after as many of its characters as
|
||||
** still fit with it, down to the ellipsis alone. Something is always left
|
||||
** out, so the ellipsis always stands for text, and spaces before it are
|
||||
** dropped. When not even the ellipsis fits, the answer is the refusal that
|
||||
** was passed in. A box lower than a number of lines holds no cut of that
|
||||
** many, which is known without trying; so one lower than a single line
|
||||
** holds no ellipsis at all.
|
||||
***************************************************************************/
|
||||
private static Block cut(Request request, Leading leading, Scale scale, Glyph[] glyphs, Glyph mark, Block refused, Work work)
|
||||
{
|
||||
int all = refused.lines().size();
|
||||
int most = Math.min(request.maxLines(), all);
|
||||
while(most > 0 && leading.height(scale, most) > request.height())
|
||||
{
|
||||
most--;
|
||||
}
|
||||
for(int kept = most; kept > 0; kept--)
|
||||
{
|
||||
List<Line> lines = new ArrayList<>(refused.lines().subList(0, kept));
|
||||
int from = lines.get(kept - 1).from();
|
||||
for(int to = lines.get(kept - 1).to() - (kept == all ? 1 : 0); to >= from; to--)
|
||||
{
|
||||
if(to > from && glyphs[to - 1].space())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
work.spend(to - from + 1L + kept);
|
||||
lines.set(kept - 1, line(scale, glyphs, from, to, mark));
|
||||
Block block = block(request, leading, scale, lines, refused.characters(), to);
|
||||
if(block.fits())
|
||||
{
|
||||
return (block);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (refused);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.font.GlyphVector;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferByte;
|
||||
import java.util.BitSet;
|
||||
import com.kingsrook.qbits.renderer.scene2.Canvas;
|
||||
import com.kingsrook.qbits.renderer.scene2.Frame;
|
||||
import com.kingsrook.qbits.renderer.scene2.Layout;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Draws a fitted text onto the label. Each glyph is drawn antialiased at the
|
||||
** whole-dot pen position the fit decided, by the same Java rasteriser version 1
|
||||
** raster text uses, and a dot is ink when its grey is darker than the
|
||||
** element's threshold, so a higher threshold prints heavier.
|
||||
**
|
||||
** A line is drawn on a strip: its measured box and a guard of `GUARD` dots all
|
||||
** round. Ink found in the guard, or a glyph whose bitmap the runtime places
|
||||
** anywhere outside the measured box, means the measurement was wrong. Such
|
||||
** ink is never clipped away and never printed outside the box, and a text is
|
||||
** never printed in part: the dots of a text are gathered first and reach the
|
||||
** label only when none strayed. What strayed is counted (`Stray`), for the
|
||||
** element to be refused by name.
|
||||
**
|
||||
** A strip of more than `BAND_DOTS` dots (very large type) is drawn in bands of
|
||||
** whole rows, so one line never needs more grey than that. Where the bands
|
||||
** fall depends only on the box of the line, so the picture is the same on
|
||||
** every machine; the rasteriser may shade an edge dot at the border of a band
|
||||
** differently than it would in one piece.
|
||||
*******************************************************************************/
|
||||
final class TextRaster
|
||||
{
|
||||
static final int GUARD = 2;
|
||||
static final int BAND_DOTS = 1 << 23;
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// what drawing counts against the work of a scene, in characters visited while fitting, //
|
||||
// which take about 15 ns each. A glyph the runtime has not drawn at a size is rasterised //
|
||||
// once, in 20 to 50 microseconds; after that, asking for it and for where its bitmap //
|
||||
// falls takes about a quarter of a microsecond //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final int GLYPH_STEPS = 4096;
|
||||
static final int PEN_STEPS = 16;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private TextRaster()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What to draw and how: the face, the text, its fitted block, where lines
|
||||
** sit across the block, the clockwise rotation, the grey below which a dot
|
||||
** is ink, and whether the dots are ink or (for reverse text) cleared from
|
||||
** the element's box filled black.
|
||||
***************************************************************************/
|
||||
record Drawing(Face face, String text, TextFit.Block block, Placement.Align align, Placement.Valign valign, int rotation, int threshold, boolean ink)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the rasteriser put outside the measure: glyphs whose bitmap is not
|
||||
** wholly inside the box of their line, and dots of ink found in the guard
|
||||
** round a line or outside the element's box.
|
||||
***************************************************************************/
|
||||
record Stray(int glyphs, int dots)
|
||||
{
|
||||
/************************************************************************
|
||||
** Whether anything strayed.
|
||||
************************************************************************/
|
||||
boolean any()
|
||||
{
|
||||
return (glyphs > 0 || dots > 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many glyphs of a fitted text the runtime would place outside the box
|
||||
** measured for their line at this resolution, without drawing any: what
|
||||
** can be known of a text when a scene is only validated.
|
||||
***************************************************************************/
|
||||
static int outside(Drawing drawing, int dpi)
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(drawing.block().milliPoints(), dpi);
|
||||
Font font = drawing.face().font(scale.size());
|
||||
int outside = 0;
|
||||
for(TextFit.Line line : drawing.block().lines())
|
||||
{
|
||||
outside += glyphs(drawing, scale, font, line, null, 0);
|
||||
}
|
||||
return (outside);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Draw into the element's box, which is given as it appears on the label:
|
||||
** a text turned a quarter reads along the box's height. The answer is what
|
||||
** strayed; when anything did, the label is left as it was.
|
||||
***************************************************************************/
|
||||
static Stray paint(Canvas canvas, Frame frame, Drawing drawing, int bandDots)
|
||||
{
|
||||
TextFit.Block block = drawing.block();
|
||||
boolean upright = drawing.rotation() % 180 == 0;
|
||||
Frame reading = new Frame(0, 0, upright ? frame.width() : frame.height(), upright ? frame.height() : frame.width());
|
||||
Frame content = Layout.align(reading, block.width(), block.height(), drawing.align(), drawing.valign());
|
||||
TextFit.Scale scale = new TextFit.Scale(block.milliPoints(), canvas.dpi());
|
||||
Font font = drawing.face().font(scale.size());
|
||||
BitSet ink = new BitSet();
|
||||
int glyphs = 0;
|
||||
int dots = 0;
|
||||
for(int index = 0; index < block.lines().size(); index++)
|
||||
{
|
||||
TextFit.Line line = block.lines().get(index);
|
||||
int left = content.x() + drawing.align().position().place(block.width(), line.width(), 0);
|
||||
int top = content.y() + block.baseline() + index * block.pitch() - line.above();
|
||||
Stray stray = strip(ink, reading, drawing, scale, font, line, left, top, bandDots);
|
||||
glyphs += stray.glyphs();
|
||||
dots += stray.dots();
|
||||
}
|
||||
Stray stray = new Stray(glyphs, dots);
|
||||
if(!stray.any())
|
||||
{
|
||||
if(!drawing.ink())
|
||||
{
|
||||
canvas.fill(frame, true);
|
||||
}
|
||||
for(int dot = ink.nextSetBit(0); dot >= 0; dot = ink.nextSetBit(dot + 1))
|
||||
{
|
||||
dot(canvas, frame, reading, drawing, dot % reading.width(), dot / reading.width());
|
||||
}
|
||||
}
|
||||
return (stray);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Gather the ink of one line whose box has its top left at `left`, `top` of
|
||||
** the reading frame, a band of rows at a time, and count what strayed.
|
||||
***************************************************************************/
|
||||
private static Stray strip(BitSet ink, Frame reading, Drawing drawing, TextFit.Scale scale, Font font, TextFit.Line line, int left, int top, int bandDots)
|
||||
{
|
||||
int width = line.width() + 2 * GUARD;
|
||||
int height = line.above() + line.below() + 2 * GUARD;
|
||||
int rows = Math.max(1, bandDots / width);
|
||||
int glyphs = 0;
|
||||
int dots = 0;
|
||||
for(int first = 0; first < height; first += rows)
|
||||
{
|
||||
BufferedImage band = new BufferedImage(width, Math.min(rows, height - first), BufferedImage.TYPE_BYTE_GRAY);
|
||||
Graphics2D graphics = band.createGraphics();
|
||||
try
|
||||
{
|
||||
graphics.setColor(Color.WHITE);
|
||||
graphics.fillRect(0, 0, band.getWidth(), band.getHeight());
|
||||
graphics.setColor(Color.BLACK);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// ink that fell wholly beyond the guard is clipped by the strip and leaves no //
|
||||
// dot to find; the bounds of its glyph say so, and they are the same for every //
|
||||
// band, so those of the first are the count //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
int beyond = glyphs(drawing, scale, font, line, graphics, first);
|
||||
glyphs = first == 0 ? beyond : glyphs;
|
||||
}
|
||||
finally
|
||||
{
|
||||
graphics.dispose();
|
||||
}
|
||||
byte[] grey = ((DataBufferByte) band.getRaster().getDataBuffer()).getData();
|
||||
for(int y = 0; y < band.getHeight(); y++)
|
||||
{
|
||||
for(int x = 0; x < width; x++)
|
||||
{
|
||||
if((grey[y * width + x] & 255) < drawing.threshold())
|
||||
{
|
||||
int row = first + y;
|
||||
int dotX = left + x - GUARD;
|
||||
int dotY = top + row - GUARD;
|
||||
if(x < GUARD || x >= width - GUARD || row < GUARD || row >= height - GUARD || dotX < 0 || dotY < 0 || dotX >= reading.width() || dotY >= reading.height())
|
||||
{
|
||||
dots++;
|
||||
}
|
||||
else
|
||||
{
|
||||
ink.set(dotY * reading.width() + dotX);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Stray(glyphs, dots));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The glyphs of one line at their pens on its strip: how many the runtime
|
||||
** places outside the line's measured box, drawing each on a band of the
|
||||
** strip whose first row is `first` when one is given.
|
||||
***************************************************************************/
|
||||
private static int glyphs(Drawing drawing, TextFit.Scale scale, Font font, TextFit.Line line, Graphics2D band, int first)
|
||||
{
|
||||
int width = line.width() + 2 * GUARD;
|
||||
int height = line.above() + line.below() + 2 * GUARD;
|
||||
int outside = 0;
|
||||
long units = 0;
|
||||
for(int index = line.from(); index < line.to() + (line.marked() ? 1 : 0); index++)
|
||||
{
|
||||
char character = index < line.to() ? drawing.text().charAt(index) : (char) TextFit.MARK;
|
||||
Face.Glyph glyph = drawing.face().glyph(character);
|
||||
if(glyph.ink())
|
||||
{
|
||||
GlyphVector vector = font.createGlyphVector(Face.CONTEXT, new char[] {character});
|
||||
int pen = GUARD - line.left() + scale.round(units);
|
||||
Rectangle pixels = vector.getGlyphPixelBounds(0, Face.CONTEXT, pen, GUARD + line.above());
|
||||
outside += !pixels.isEmpty() && (pixels.x < GUARD || pixels.y < GUARD || pixels.x + pixels.width > width - GUARD || pixels.y + pixels.height > height - GUARD) ? 1 : 0;
|
||||
if(band != null)
|
||||
{
|
||||
band.drawGlyphVector(vector, pen, GUARD + line.above() - first);
|
||||
}
|
||||
}
|
||||
units += glyph.advance();
|
||||
}
|
||||
return (outside);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Put one dot of the reading frame on the label, turned clockwise.
|
||||
***************************************************************************/
|
||||
private static void dot(Canvas canvas, Frame frame, Frame reading, Drawing drawing, int x, int y)
|
||||
{
|
||||
int turnedX = switch(drawing.rotation())
|
||||
{
|
||||
case 90 -> reading.height() - 1 - y;
|
||||
case 180 -> reading.width() - 1 - x;
|
||||
case 270 -> y;
|
||||
default -> x;
|
||||
};
|
||||
int turnedY = switch(drawing.rotation())
|
||||
{
|
||||
case 90 -> x;
|
||||
case 180 -> reading.height() - 1 - y;
|
||||
case 270 -> reading.width() - 1 - x;
|
||||
default -> y;
|
||||
};
|
||||
canvas.set(frame.x() + turnedX, frame.y() + turnedY, drawing.ink());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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.scene2.upgrade;
|
||||
|
||||
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.renderer.Wire;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** `UPGRADE_SCENE`: a version 2 rendering of a version 1 scene, for an author to
|
||||
** review and save as a draft. The operation is routed here from the first
|
||||
** version 2 renderer so the wire is settled; the conversion itself is a later
|
||||
** package, and until then the operation is refused by name.
|
||||
*******************************************************************************/
|
||||
public final class SceneUpgrade
|
||||
{
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private SceneUpgrade()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Answer one `UPGRADE_SCENE` request.
|
||||
***************************************************************************/
|
||||
public static Map<String, Object> upgrade(JsonNode request)
|
||||
{
|
||||
Wire.text(request.get("requestId"), 128);
|
||||
throw new IllegalArgumentException("UNSUPPORTED_OPERATION");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f ibm-plex-sans/IBMPlexSans-Bold.ttf
|
||||
975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5 ibm-plex-sans/IBMPlexSans-Regular.ttf
|
||||
7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da ibm-plex-sans/LICENSE.txt
|
||||
723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7 ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf
|
||||
7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da ibm-plex-sans-condensed/LICENSE.txt
|
||||
5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb jetbrains-mono/JetBrainsMono-Bold.ttf
|
||||
a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f jetbrains-mono/JetBrainsMono-Regular.ttf
|
||||
30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad jetbrains-mono/OFL.txt
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,23 @@
|
||||
# IBM Plex Sans Condensed (Bold)
|
||||
|
||||
An unmodified file from the official release of the IBM Plex project, retrieved 2026-11-12.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Project | https://github.com/IBM/plex |
|
||||
| Release | `@ibm/plex-sans-condensed@2.0.0` |
|
||||
| Archive | https://github.com/IBM/plex/releases/download/%40ibm/plex-sans-condensed%402.0.0/ibm-plex-sans-condensed.zip |
|
||||
| Archive SHA-256 | `f86ba73abbeed7cf7690ead863192a87211dfc2b0cef0ac6bf4700533ec5ab9e` |
|
||||
| Licence | SIL Open Font License 1.1, `LICENSE.txt` in this directory (the archive's `ibm-plex-sans-condensed/LICENSE.txt`, byte for byte) |
|
||||
| Reserved Font Name | "Plex" (the file is not modified and not renamed) |
|
||||
|
||||
| File here | Path in the archive | Font version (name table) | SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `IBMPlexSansCondensed-Bold.ttf` | `ibm-plex-sans-condensed/fonts/complete/ttf/IBMPlexSansCondensed-Bold.ttf` | Version 3.000 | `723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7` |
|
||||
| `LICENSE.txt` | `ibm-plex-sans-condensed/LICENSE.txt` | | `7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da` |
|
||||
|
||||
The licence was confirmed from the release archive itself: its `LICENSE.txt` states "This Font Software is
|
||||
licensed under the SIL Open Font License, Version 1.1" and carries the full text; the licence field of the
|
||||
font's name table says the same, and the font's embedding bits (`OS/2.fsType`) are 0, installable embedding.
|
||||
|
||||
Scene name: `qqq-condensed-bold`.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,24 @@
|
||||
# IBM Plex Sans (Regular, Bold)
|
||||
|
||||
Unmodified files from the official release of the IBM Plex project, retrieved 2026-11-12.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Project | https://github.com/IBM/plex |
|
||||
| Release | `@ibm/plex-sans@1.1.0` |
|
||||
| Archive | https://github.com/IBM/plex/releases/download/%40ibm/plex-sans%401.1.0/ibm-plex-sans.zip |
|
||||
| Archive SHA-256 | `fb365d910566e6d199cc2c15579a7dd9a267128e18431a394ed81f1970c69200` |
|
||||
| Licence | SIL Open Font License 1.1, `LICENSE.txt` in this directory (the archive's `ibm-plex-sans/LICENSE.txt`, byte for byte) |
|
||||
| Reserved Font Name | "Plex" (the files are not modified and not renamed) |
|
||||
|
||||
| File here | Path in the archive | Font version (name table) | SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `IBMPlexSans-Regular.ttf` | `ibm-plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf` | Version 3.005 | `975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5` |
|
||||
| `IBMPlexSans-Bold.ttf` | `ibm-plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf` | Version 3.005 | `9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f` |
|
||||
| `LICENSE.txt` | `ibm-plex-sans/LICENSE.txt` | | `7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da` |
|
||||
|
||||
The licence was confirmed from the release archive itself: its `LICENSE.txt` states "This Font Software is
|
||||
licensed under the SIL Open Font License, Version 1.1" and carries the full text; the licence field of each
|
||||
font's name table says the same, and each font's embedding bits (`OS/2.fsType`) are 0, installable embedding.
|
||||
|
||||
Scene names: `qqq-sans` (Regular), `qqq-sans-bold` (Bold).
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,24 @@
|
||||
# JetBrains Mono (Regular, Bold)
|
||||
|
||||
Unmodified files from the official release of the JetBrains Mono project, retrieved 2026-11-12.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Project | https://github.com/JetBrains/JetBrainsMono |
|
||||
| Release | `v2.304` |
|
||||
| Archive | https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip |
|
||||
| Archive SHA-256 | `6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf` |
|
||||
| Licence | SIL Open Font License 1.1, `OFL.txt` in this directory (the archive's `OFL.txt`, byte for byte) |
|
||||
| Reserved Font Name | none declared |
|
||||
|
||||
| File here | Path in the archive | Font version (name table) | SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `JetBrainsMono-Regular.ttf` | `fonts/ttf/JetBrainsMono-Regular.ttf` | Version 2.304; ttfautohint (v1.8.4.7-5d5b) | `a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f` |
|
||||
| `JetBrainsMono-Bold.ttf` | `fonts/ttf/JetBrainsMono-Bold.ttf` | Version 2.304; ttfautohint (v1.8.4.7-5d5b) | `5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb` |
|
||||
| `OFL.txt` | `OFL.txt` | | `30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad` |
|
||||
|
||||
The licence was confirmed from the release archive itself: its `OFL.txt` states "This Font Software is
|
||||
licensed under the SIL Open Font License, Version 1.1" and carries the full text; the licence field of each
|
||||
font's name table says the same, and each font's embedding bits (`OS/2.fsType`) are 0, installable embedding.
|
||||
|
||||
Scene names: `qqq-mono` (Regular), `qqq-mono-bold` (Bold).
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,14 +22,28 @@ package com.kingsrook.qbits.renderer;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.exc.StreamConstraintsException;
|
||||
import com.fasterxml.jackson.core.io.JsonEOFException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.exc.MismatchedInputException;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.renderer.scene2.Refusal;
|
||||
import com.kingsrook.qbits.renderer.scene2.SceneFixture;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
@@ -40,9 +54,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 version 1 scene
|
||||
** parser. Seeds, limits and the replay command: `docs/security/fuzzing.md`.
|
||||
** The mutation harness is deterministic and reports what it should, and two
|
||||
** bounded campaigns run in the normal test phase: over the version 1 scene
|
||||
** parser, and over the version 2 scene parser, canvas and emitter behind the
|
||||
** worker's routing.
|
||||
** Seeds, limits and the replay command: `docs/security/fuzzing.md`.
|
||||
*******************************************************************************/
|
||||
class MutationCampaignTest
|
||||
{
|
||||
@@ -55,6 +71,72 @@ class MutationCampaignTest
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final ObjectMapper INDEPENDENT = new ObjectMapper();
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// what a parser may throw to say no: its own IllegalArgumentException, and exactly the JSON //
|
||||
// reader's exceptions for text that is not one JSON value. A NullPointerException, an index //
|
||||
// out of bounds or any other failure is a crash in the parser, and so a finding //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final Set<Class<?>> JSON_REFUSALS = Set.of(JsonParseException.class, JsonEOFException.class, MismatchedInputException.class, StreamConstraintsException.class);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// labels the version 2 campaign rendered and read back, so that it cannot pass without //
|
||||
// having reached the canvas and the emitter //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final AtomicInteger LABELS = new AtomicInteger();
|
||||
|
||||
private static final Pattern LENGTH = Pattern.compile("-?[0-9]{1,5}(\\.[0-9]{1,3})?(mm|in|pt|%|dot)");
|
||||
private static final Pattern ELEMENT_ID = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,31}");
|
||||
private static final Pattern TYPE_SIZE = Pattern.compile("([0-9]{1,5}(?:\\.[0-9]{1,3})?)(mm|in|pt)");
|
||||
private static final Set<String> FACES = Set.of("qqq-sans", "qqq-sans-bold", "qqq-condensed-bold", "qqq-mono", "qqq-mono-bold");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a failure is a parser saying no in a declared way.
|
||||
***************************************************************************/
|
||||
private static boolean refusal(Throwable failure)
|
||||
{
|
||||
return (failure instanceof IllegalArgumentException || JSON_REFUSALS.contains(failure.getClass()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The version 1 parser's own refusal of a source the routing sent to it. It
|
||||
** is a plain `IllegalArgumentException` there, which on the version 2 route
|
||||
** is a broken invariant, so the target marks the one that is declared.
|
||||
***************************************************************************/
|
||||
private static final class VersionOneRefusal extends RuntimeException
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The refusal the version 1 parser raised.
|
||||
************************************************************************/
|
||||
VersionOneRefusal(IllegalArgumentException cause)
|
||||
{
|
||||
super(cause);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a failure is the worker's routing saying no to a label source in
|
||||
** a declared way: the version 2 `Refusal`, which carries a diagnostic (the
|
||||
** routing raises one for unreadable JSON and for an unknown schema too), or
|
||||
** the version 1 parser's refusal of a version 1 source. Any other
|
||||
** `IllegalArgumentException` is not a refusal: the scene 2 package raises
|
||||
** them for its own broken invariants (`CANVAS_BOUNDS`, `CANVAS_LIMIT`,
|
||||
** `LENGTH_NOT_ABSOLUTE`, `LENGTH_NOT_PHYSICAL`), which no scene may reach.
|
||||
***************************************************************************/
|
||||
private static boolean labelRefusal(Throwable failure)
|
||||
{
|
||||
return (failure instanceof Refusal || failure instanceof VersionOneRefusal);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
@@ -131,8 +213,9 @@ class MutationCampaignTest
|
||||
/***************************************************************************
|
||||
** Property: whatever the version 1 scene parser accepts is, read by a second
|
||||
** parser, a version 1 scene of known elements within the element limit. Every
|
||||
** other input is refused with an exception (the worker turns any exception
|
||||
** into its opaque error answer); a stack overflow would be a finding.
|
||||
** other input is refused in a declared way: the parser's own
|
||||
** IllegalArgumentException or the JSON reader's parse exception. Any other
|
||||
** exception, and a stack overflow, is a finding.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theVersionOneSceneParserAcceptsOnlyVersionOneScenes() throws Exception
|
||||
@@ -150,7 +233,7 @@ class MutationCampaignTest
|
||||
int iterations = MutationCampaign.iterations(ITERATIONS);
|
||||
for(long seed : MutationCampaign.seeds(SEEDS))
|
||||
{
|
||||
MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::validateScene, Exception.class::isInstance);
|
||||
MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::validateScene, MutationCampaignTest::refusal);
|
||||
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);
|
||||
}
|
||||
@@ -158,6 +241,341 @@ class MutationCampaignTest
|
||||
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The refusal rule itself: a crash is never a refusal.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void onlyDeclaredRefusalsCountAsRefusals() throws Exception
|
||||
{
|
||||
assertTrue(refusal(new IllegalArgumentException("INVALID_ZPL_SCENE")));
|
||||
assertTrue(refusal(assertThrows(Refusal.class, () -> RendererMain.process(SceneFixture.request("VALIDATE", "{}")))));
|
||||
assertTrue(refusal(assertThrows(JsonParseException.class, () -> Wire.JSON.readTree("{\"schema\":"))));
|
||||
assertTrue(refusal(assertThrows(MismatchedInputException.class, () -> Wire.JSON.readTree("{} {}"))));
|
||||
for(Throwable crash : List.of(new NullPointerException(), new IndexOutOfBoundsException(), new ClassCastException(), new ArithmeticException(), new IllegalStateException("DIAGNOSTIC_NOT_DECLARED"),
|
||||
new java.io.IOException("OUTPUT_LIMIT"), new RuntimeException(), new StackOverflowError(), new AssertionError()))
|
||||
{
|
||||
assertFalse(refusal(crash), crash.toString());
|
||||
}
|
||||
MutationCampaign campaign = new MutationCampaign(List.of("0123456789".getBytes(StandardCharsets.US_ASCII)), List.of());
|
||||
MutationCampaign.Result result = campaign.run(42L, 50, input ->
|
||||
{
|
||||
throw new NullPointerException("a parser that crashes on everything");
|
||||
}, MutationCampaignTest::refusal);
|
||||
assertEquals(50, result.findings().size());
|
||||
assertEquals(0, result.refused());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The rule of the version 2 campaign: only a refusal that carries a
|
||||
** diagnostic, or the version 1 parser's own for a version 1 source. The
|
||||
** package's broken invariants are `IllegalArgumentException`s as well, and a
|
||||
** campaign that took any of those for a refusal would pass a placement bug.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void onTheLabelRouteOnlyARefusalWithADiagnosticCountsAsARefusal() throws Exception
|
||||
{
|
||||
assertTrue(labelRefusal(assertThrows(Refusal.class, () -> RendererMain.process(SceneFixture.request("VALIDATE", "{}")))));
|
||||
assertTrue(labelRefusal(assertThrows(Refusal.class, () -> RendererMain.process(SceneFixture.request("RENDER", "{\"schema\":")))));
|
||||
assertTrue(labelRefusal(assertThrows(Refusal.class, () -> RendererMain.process(SceneFixture.request("RENDER", SceneFixture.scene("{\"id\":\"a\",\"type\":\"box\",\"width\":\"84600in\",\"fill\":true}"))))));
|
||||
assertTrue(labelRefusal(assertThrows(VersionOneRefusal.class, () -> checkLabelSource("{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[{\"type\":\"circle\"}]}".getBytes(StandardCharsets.UTF_8)))));
|
||||
for(Throwable crash : List.of(new IllegalArgumentException("CANVAS_BOUNDS"), new IllegalArgumentException("CANVAS_LIMIT"), new IllegalArgumentException("LENGTH_NOT_ABSOLUTE"),
|
||||
new IllegalArgumentException("LENGTH_NOT_PHYSICAL"), new IllegalArgumentException("INVALID_ZPL_SCENE"), new ArithmeticException("integer overflow"), new NullPointerException(), new IllegalStateException("DIAGNOSTIC_NOT_DECLARED"),
|
||||
new java.io.IOException("OUTPUT_LIMIT"), new JsonParseException(null, "not JSON"), new StackOverflowError(), new AssertionError()))
|
||||
{
|
||||
assertFalse(labelRefusal(crash), crash.toString());
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the share that is rendered is chosen by the input alone, so a replay takes the same //
|
||||
// path, and it is about a quarter of the version 2 inputs //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
int rendered = 0;
|
||||
for(int index = 0; index < 400; index++)
|
||||
{
|
||||
byte[] source = ("{\"schema\":\"qqq-zpl-scene/2\",\"index\":" + index + "}").getBytes(StandardCharsets.UTF_8);
|
||||
assertEquals(rendered(source), rendered(source.clone()));
|
||||
rendered += rendered(source) ? 1 : 0;
|
||||
}
|
||||
assertTrue(rendered > 60 && rendered < 140, rendered + " of 400 rendered");
|
||||
assertFalse(rendered("{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[],\"n\":3}".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: whatever the worker accepts as a label source is, read by a
|
||||
** second parser, either a version 1 scene (as above) or a version 2 scene
|
||||
** that keeps every rule this checks on its own: only the declared top-level
|
||||
** properties, a physical design size, at most 1000 elements with unique ids
|
||||
** of the fixed pattern, only boxes, groups and text (the types this renderer
|
||||
** draws), groups at most four deep, every length a number and a unit, every
|
||||
** text in a bundled face at a physical size with one value and only
|
||||
** documented fit, length and legibility settings at each resolution, and
|
||||
** overrides only for the three resolutions and for declared ids. About one
|
||||
** version 2 input in four is rendered instead of validated: its label must
|
||||
** then be the commands and graphic fields a version 2 label is made of, with
|
||||
** no ink outside the printable area, and validation must give the same
|
||||
** answer (but for a text bound to the record, which has no value here). Every other input is refused with a `Refusal`, which carries a
|
||||
** diagnostic (or, for a version 1 source, with that parser's own refusal).
|
||||
** Any other exception, an `IllegalArgumentException` of the package's own
|
||||
** invariants among them, is a finding.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theVersionTwoSceneParserAcceptsOnlyVersionTwoScenes() throws Exception
|
||||
{
|
||||
List<byte[]> corpus = new ArrayList<>();
|
||||
for(String name : List.of("anchors", "rules", "groups", "text-faces", "text-fit", "text-turned"))
|
||||
{
|
||||
try(InputStream stream = MutationCampaignTest.class.getResourceAsStream("/scene2/cases/" + name + ".json"))
|
||||
{
|
||||
corpus.add(Wire.JSON.writeValueAsBytes(Wire.JSON.readTree(stream).get("scene")));
|
||||
}
|
||||
}
|
||||
MutationCampaign campaign = new MutationCampaign(corpus, List.of("\"schema\"", "\"qqq-zpl-scene/2\"", "\"qqq-zpl-scene/1\"", "\"design\"", "\"elements\"", "\"overrides\"", "\"when\"", "\"dpi\"", "\"set\"",
|
||||
"\"children\"", "\"group\"", "\"box\"", "\"text\"", "\"barcode\"", "\"image\"", "\"id\"", "\"type\"", "\"anchor\"", "\"thickness\"", "\"fill\"", "\"rotation\"", "\"center\"", "true", "false", "null",
|
||||
"\"defaults\"", "\"font\"", "\"size\"", "\"value\"", "\"valuePath\"", "\"lineHeight\"", "\"fit\"", "\"maxLines\"", "\"shrinkTo\"", "\"overflow\"", "\"truncate\"", "\"refuse\"", "\"maxLength\"", "\"threshold\"",
|
||||
"\"reverse\"", "\"qqq-sans\"", "\"qqq-mono-bold\"", "\"qqq-\"", "\"/record/name\"", "90", "180", "270", "1000pt", "0.5", "1e400", "\\u0301", "\\t", "\\uD83D",
|
||||
"mm", "in", "pt", "%", "dot", "-", ".", "203", "300", "600", "99999999999", "4294967499", "{", "}", "[", "]", ":", ",", "\\u0000"));
|
||||
int iterations = MutationCampaign.iterations(ITERATIONS);
|
||||
for(long seed : MutationCampaign.seeds(SEEDS))
|
||||
{
|
||||
LABELS.set(0);
|
||||
MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::checkLabelSource, MutationCampaignTest::labelRefusal);
|
||||
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);
|
||||
assertTrue(LABELS.get() > 0, "the campaign must render labels as well as validate scenes: " + result);
|
||||
System.out.println("version 2 campaign, seed " + seed + ": " + result.accepted() + " accepted (" + LABELS.get() + " labels rendered and read back), " + result.refused() + " refused");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the routing sends a source to the frozen version 1 parser, as the
|
||||
** second parser reads it.
|
||||
***************************************************************************/
|
||||
private static boolean versionOne(byte[] scene)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ("qqq-zpl-scene/1".equals(INDEPENDENT.readTree(scene).path("schema").textValue()));
|
||||
}
|
||||
catch(java.io.IOException notJson)
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether an input is rendered rather than validated: about one in four of
|
||||
** those that are not version 1 scenes (which stay frozen and have their own
|
||||
** campaign), chosen by the bytes alone so that a replay does the same.
|
||||
***************************************************************************/
|
||||
private static boolean rendered(byte[] scene)
|
||||
{
|
||||
return (!versionOne(scene) && (Arrays.hashCode(scene) & 3) == 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The target: one label source through the worker's routing, validated or
|
||||
** rendered, on the stock the version 2 corpus was drawn for. At 203 dpi its
|
||||
** page is 406 by 203 dots and its printable area 374 by 171 dots at 16, 16.
|
||||
***************************************************************************/
|
||||
private static void checkLabelSource(byte[] scene) throws Exception
|
||||
{
|
||||
String source = new String(scene, StandardCharsets.UTF_8);
|
||||
boolean render = rendered(scene);
|
||||
JsonNode stock = SceneFixture.stock(50_800, 25_400, 2000, 203);
|
||||
try
|
||||
{
|
||||
Map<String, Object> answer = RendererMain.process(SceneFixture.request(render ? "RENDER" : "VALIDATE", stock, source));
|
||||
assertEquals("OK", answer.get("outcome"));
|
||||
assertEquals(render, answer.containsKey("base64"));
|
||||
if(render)
|
||||
{
|
||||
boolean[][] page = SceneFixture.raster(Base64.getDecoder().decode((String) answer.get("base64")));
|
||||
assertTrue(page.length == 203 && page[0].length == 406, "accepted a label of another page");
|
||||
for(int y = 0; y < page.length; y++)
|
||||
{
|
||||
for(int x = 0; x < page[y].length; x++)
|
||||
{
|
||||
assertFalse(page[y][x] && (x < 16 || y < 16 || x >= 390 || y >= 187), "ink outside the printable area");
|
||||
}
|
||||
}
|
||||
assertEquals(answer.get("diagnostics"), RendererMain.process(SceneFixture.request("VALIDATE", stock, source)).get("diagnostics"), "rendered what validation finds otherwise");
|
||||
LABELS.incrementAndGet();
|
||||
}
|
||||
}
|
||||
catch(Refusal refusal)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// a text bound to the record has no value here, since the campaign's record is //
|
||||
// empty: that is the one refusal a render gives that validation, which reads no //
|
||||
// record, cannot //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
if(render && !"VALUE_MISSING".equals(refusal.diagnostic().code()))
|
||||
{
|
||||
Refusal validated = assertThrows(Refusal.class, () -> RendererMain.process(SceneFixture.request("VALIDATE", stock, source)), "refused to render what validation accepts");
|
||||
assertEquals(refusal.diagnostic().wire(), validated.diagnostic().wire());
|
||||
}
|
||||
throw refusal;
|
||||
}
|
||||
catch(IllegalArgumentException undeclared)
|
||||
{
|
||||
if(versionOne(scene))
|
||||
{
|
||||
throw new VersionOneRefusal(undeclared);
|
||||
}
|
||||
throw undeclared;
|
||||
}
|
||||
JsonNode accepted = INDEPENDENT.readTree(source);
|
||||
if("qqq-zpl-scene/1".equals(accepted.path("schema").textValue()))
|
||||
{
|
||||
assertTrue(accepted.path("elements").isArray() && accepted.path("elements").size() <= 1000);
|
||||
accepted.path("elements").forEach(element -> assertTrue(ELEMENTS.contains(element.path("type").textValue()), () -> "accepted element type " + element.path("type")));
|
||||
return;
|
||||
}
|
||||
assertEquals("qqq-zpl-scene/2", accepted.path("schema").textValue());
|
||||
accepted.fieldNames().forEachRemaining(name -> assertTrue(Set.of("schema", "design", "defaults", "samples", "elements", "overrides").contains(name), () -> "accepted top-level property " + name));
|
||||
for(String side : List.of("width", "height"))
|
||||
{
|
||||
String length = accepted.path("design").path(side).textValue();
|
||||
assertTrue(length != null && LENGTH.matcher(length).matches() && !length.startsWith("-") && !length.endsWith("%") && !length.endsWith("dot"), () -> "accepted design " + side + " " + length);
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
assertVersionTwoElements(accepted.path("elements"), 1, ids, accepted);
|
||||
assertTrue(ids.size() <= 1000, () -> "accepted " + ids.size() + " elements");
|
||||
for(JsonNode override : accepted.path("overrides"))
|
||||
{
|
||||
override.path("when").path("dpi").forEach(dpi -> assertTrue(dpi.isInt() && Set.of(203, 300, 600).contains(dpi.intValue()), () -> "accepted override resolution " + dpi));
|
||||
assertTrue(override.path("when").path("dpi").size() > 0 && override.path("set").size() > 0);
|
||||
override.path("set").fieldNames().forEachRemaining(id -> assertTrue(ids.contains(id), () -> "accepted an override for an undeclared id"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The independent reading of a version 2 element list.
|
||||
***************************************************************************/
|
||||
private static void assertVersionTwoElements(JsonNode elements, int depth, Set<String> ids, JsonNode scene)
|
||||
{
|
||||
assertTrue(elements.isArray(), "accepted elements that are not a list");
|
||||
for(JsonNode element : elements)
|
||||
{
|
||||
String id = element.path("id").textValue();
|
||||
assertTrue(id != null && ELEMENT_ID.matcher(id).matches() && ids.add(id), () -> "accepted element id " + element.path("id"));
|
||||
String type = element.path("type").textValue();
|
||||
assertTrue("box".equals(type) || "group".equals(type) || "text".equals(type), () -> "accepted element type " + element.path("type"));
|
||||
for(String name : List.of("x", "y", "width", "height", "thickness"))
|
||||
{
|
||||
assertTrue(!element.has(name) || (element.get(name).isTextual() && LENGTH.matcher(element.get(name).textValue()).matches()), () -> "accepted " + name + " " + element.get(name));
|
||||
}
|
||||
Set<String> allowed = "box".equals(type) ? Set.of("id", "type", "anchor", "x", "y", "width", "height", "align", "valign", "thickness", "fill")
|
||||
: "text".equals(type) ? Set.of("id", "type", "anchor", "x", "y", "width", "height", "align", "valign", "value", "valuePath", "font", "size", "lineHeight", "fit", "maxLength", "threshold", "reverse", "rotation")
|
||||
: Set.of("id", "type", "anchor", "x", "y", "width", "height", "align", "valign", "children");
|
||||
element.fieldNames().forEachRemaining(name -> assertTrue(allowed.contains(name), () -> "accepted " + type + " property " + name));
|
||||
if("group".equals(type))
|
||||
{
|
||||
assertTrue(depth <= 4, "accepted groups more than four deep");
|
||||
assertVersionTwoElements(element.path("children"), depth + 1, ids, scene);
|
||||
}
|
||||
if("text".equals(type))
|
||||
{
|
||||
assertVersionTwoText(element, scene);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The independent reading of an accepted text element, as it stands at each
|
||||
** resolution: the scene's defaults, then the element, then the overrides
|
||||
** for that resolution in order. It names a bundled face, has a physical
|
||||
** size from 1 to 1000 pt, exactly one of a literal and a pointer into the
|
||||
** record, and only fit, length, threshold, rotation, reverse and line
|
||||
** height values of the documented kinds and ranges.
|
||||
***************************************************************************/
|
||||
private static void assertVersionTwoText(JsonNode element, JsonNode scene)
|
||||
{
|
||||
for(int dpi : List.of(203, 300, 600))
|
||||
{
|
||||
ObjectNode stated = INDEPENDENT.createObjectNode();
|
||||
scene.path("defaults").properties().forEach(property -> stated.set(property.getKey(), property.getValue()));
|
||||
element.properties().forEach(property -> stated.set(property.getKey(), property.getValue()));
|
||||
for(JsonNode override : scene.path("overrides"))
|
||||
{
|
||||
boolean applies = false;
|
||||
for(JsonNode resolution : override.path("when").path("dpi"))
|
||||
{
|
||||
applies |= resolution.intValue() == dpi;
|
||||
}
|
||||
if(applies)
|
||||
{
|
||||
override.path("set").path(element.path("id").textValue()).properties().forEach(property -> stated.set(property.getKey(), property.getValue()));
|
||||
}
|
||||
}
|
||||
String about = "accepted text " + element.path("id") + " at " + dpi + " dpi with ";
|
||||
assertTrue(stated.path("font").isTextual() && FACES.contains(stated.path("font").textValue()), () -> about + "font " + stated.path("font"));
|
||||
assertTrue(typeSize(stated.path("size"), 1, 1000), () -> about + "size " + stated.path("size"));
|
||||
assertTrue(stated.has("value") != stated.has("valuePath"), () -> about + "both or neither of value and valuePath");
|
||||
assertTrue(!stated.has("value") || (stated.get("value").isTextual() && stated.get("value").textValue().codePointCount(0, stated.get("value").textValue().length()) <= 1024), () -> about + "a value that is not text");
|
||||
assertTrue(!stated.has("valuePath") || (stated.get("valuePath").isTextual() && stated.get("valuePath").textValue().startsWith("/")), () -> about + "valuePath " + stated.path("valuePath"));
|
||||
JsonNode fit = stated.path("fit");
|
||||
assertTrue(fit.isMissingNode() || fit.isObject(), () -> about + "fit " + fit);
|
||||
fit.fieldNames().forEachRemaining(name -> assertTrue(Set.of("maxLines", "shrinkTo", "overflow").contains(name), () -> about + "fit property " + name));
|
||||
assertTrue(whole(fit.path("maxLines"), 1, 64) && whole(stated.path("threshold"), 1, 254) && whole(stated.path("maxLength"), 1, 1024), () -> about + "a count out of range");
|
||||
assertTrue(fit.path("shrinkTo").isMissingNode() || typeSize(fit.path("shrinkTo"), 1, 1000), () -> about + "shrinkTo " + fit.path("shrinkTo"));
|
||||
assertTrue(fit.path("overflow").isMissingNode() || Set.of("refuse", "truncate").contains(String.valueOf(fit.path("overflow").textValue())), () -> about + "overflow " + fit.path("overflow"));
|
||||
assertTrue(stated.path("rotation").isMissingNode() || (stated.path("rotation").isInt() && Set.of(0, 90, 180, 270).contains(stated.path("rotation").intValue())), () -> about + "rotation " + stated.path("rotation"));
|
||||
assertTrue(stated.path("reverse").isMissingNode() || stated.path("reverse").isBoolean(), () -> about + "reverse " + stated.path("reverse"));
|
||||
JsonNode lineHeight = stated.path("lineHeight");
|
||||
assertTrue(lineHeight.isMissingNode() || (lineHeight.isNumber() && lineHeight.doubleValue() >= 0.5 && lineHeight.doubleValue() <= 5), () -> about + "lineHeight " + lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a value is absent or a whole number in a range.
|
||||
***************************************************************************/
|
||||
private static boolean whole(JsonNode value, int minimum, int maximum)
|
||||
{
|
||||
return (value.isMissingNode() || (value.isInt() && value.intValue() >= minimum && value.intValue() <= maximum));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a value is a type size: an unsigned physical length between two
|
||||
** numbers of points, by decimal arithmetic with a hair of tolerance for the
|
||||
** renderer's rounding to thousandths of a point.
|
||||
***************************************************************************/
|
||||
private static boolean typeSize(JsonNode value, int minimum, int maximum)
|
||||
{
|
||||
Matcher matcher = TYPE_SIZE.matcher(value.isTextual() ? value.textValue() : "");
|
||||
if(!matcher.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
BigDecimal points = new BigDecimal(matcher.group(1)).multiply(switch(matcher.group(2))
|
||||
{
|
||||
case "in" -> BigDecimal.valueOf(72);
|
||||
case "mm" -> BigDecimal.valueOf(72).divide(new BigDecimal("25.4"), 12, RoundingMode.HALF_EVEN);
|
||||
default -> BigDecimal.ONE;
|
||||
});
|
||||
return (points.compareTo(new BigDecimal(minimum).subtract(new BigDecimal("0.001"))) >= 0 && points.compareTo(new BigDecimal(maximum).add(new BigDecimal("0.001"))) <= 0);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The target: static validation of one scene on a stock all corpus scenes fit.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* 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.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.renderer.scene2.SceneFixture;
|
||||
import com.kingsrook.qbits.renderer.scene2.upgrade.SceneUpgrade;
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The worker routes a label by the `schema` of its scene alone: version 1 takes
|
||||
** the path it always took, version 2 takes the scene 2 package, and anything
|
||||
** else is refused by name. The request and the answers gain the fields of
|
||||
** renderer 0.2.0.
|
||||
*******************************************************************************/
|
||||
class SceneRoutingTest
|
||||
{
|
||||
private static final String VERSION_ONE = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[{\"type\":\"box\",\"x\":30,\"y\":30,\"width\":100,\"height\":50,\"thickness\":2}]}";
|
||||
private static final String VERSION_TWO = SceneFixture.scene("{\"id\":\"a\",\"type\":\"box\",\"x\":\"6dot\",\"y\":\"6dot\",\"width\":\"100dot\",\"height\":\"50dot\",\"thickness\":\"2dot\"}");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The label of a request that is accepted.
|
||||
***************************************************************************/
|
||||
private static String label(Map<String, Object> answer)
|
||||
{
|
||||
return (new String(Base64.getDecoder().decode((String) answer.get("base64")), StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same box as a version 1 scene and as a version 2 scene: the first is
|
||||
** the printer's own box command, as it always was; the second is a graphic.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void eachSchemaTakesItsOwnPath() throws Exception
|
||||
{
|
||||
Map<String, Object> one = RendererMain.process(SceneFixture.request("RENDER", VERSION_ONE));
|
||||
assertEquals("^XA^PW812^LL203^LH0,0^CI28\n^FO30,30^GB100,50,2^FS\n^XZ\n", label(one));
|
||||
Map<String, Object> two = RendererMain.process(SceneFixture.request("RENDER", VERSION_TWO));
|
||||
assertTrue(label(two).startsWith("^XA^PW812^LL203^LH0,0^CI28\n^FO30,30^GFA,650,650,13," + "F".repeat(25) + "0" + "F".repeat(25) + "0C"), label(two));
|
||||
for(Map<String, Object> answer : List.of(one, two))
|
||||
{
|
||||
assertEquals("OK", answer.get("outcome"));
|
||||
assertEquals("qqq-renderer-0.2.0", answer.get("rendererVersion"));
|
||||
assertEquals("application/vnd.zebra-zpl", answer.get("mediaType"));
|
||||
assertEquals(1, answer.get("pageCount"));
|
||||
assertEquals(Wire.sha256(label(answer).getBytes(StandardCharsets.US_ASCII)), answer.get("sha256"));
|
||||
assertEquals(label(answer).length(), answer.get("length"));
|
||||
}
|
||||
assertEquals(List.of(), one.get("diagnostics"));
|
||||
assertEquals(5, ((List<?>) two.get("diagnostics")).size());
|
||||
for(String source : List.of(VERSION_ONE, VERSION_TWO))
|
||||
{
|
||||
Map<String, Object> validated = RendererMain.process(SceneFixture.request("VALIDATE", source));
|
||||
assertEquals("OK", validated.get("outcome"));
|
||||
assertEquals(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "diagnostics"), List.copyOf(validated.keySet()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A source that is neither version is refused by name: an unknown or absent
|
||||
** schema at `/schema`, and a source that is not a JSON object at the root.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anythingElseIsRefusedByName()
|
||||
{
|
||||
for(String source : List.of("{\"schema\":\"qqq-zpl-scene/3\",\"elements\":[]}", "{\"schema\":\"qqq-zpl-scene/1 \",\"elements\":[]}", "{\"elements\":[]}", "{\"schema\":2,\"elements\":[]}", "{}"))
|
||||
{
|
||||
for(String operation : List.of("VALIDATE", "RENDER"))
|
||||
{
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", "/schema"), SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request(operation, source))), source);
|
||||
}
|
||||
}
|
||||
for(String source : List.of("[]", "\"qqq-zpl-scene/2\"", "7", "null", "^XA^FO10,10^GB100,50,2^FS^XZ", "{\"schema\":\"qqq-zpl-scene/2\"", "{\"schema\":\"qqq-zpl-scene/2\",\"schema\":\"qqq-zpl-scene/1\"}", "{} {}", ""))
|
||||
{
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", ""), SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request("VALIDATE", source))), source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A render target is 203, 300 or 600. A version 2 scene renders at it; a
|
||||
** version 1 scene has dots in it and prints only at the stock's resolution.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aRenderTargetNeedsAVersionTwoScene() throws Exception
|
||||
{
|
||||
assertEquals("^XA^PW812^LL203^LH0,0^CI28\n^FO30,30^GB100,50,2^FS\n^XZ\n", label(RendererMain.process(SceneFixture.request("RENDER", VERSION_ONE).put("targetDpi", 203))));
|
||||
for(String operation : List.of("VALIDATE", "RENDER"))
|
||||
{
|
||||
assertEquals(Map.of("code", "TARGET_REQUIRES_SCENE_2", "severity", "ERROR", "measured", Map.of("dpi", 300), "limit", Map.of("dpi", 203)),
|
||||
SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request(operation, VERSION_ONE).put("targetDpi", 300))));
|
||||
}
|
||||
assertTrue(label(RendererMain.process(SceneFixture.request("RENDER", VERSION_TWO).put("targetDpi", 600))).startsWith("^XA^PW2400^LL600^LH0,0^CI28\n^FO77,77^GFA,"));
|
||||
assertNull(new RenderInput(SceneFixture.request("RENDER", VERSION_TWO)).targetDpi);
|
||||
assertEquals(Integer.valueOf(300), new RenderInput(SceneFixture.request("RENDER", VERSION_TWO).put("targetDpi", 300)).targetDpi);
|
||||
assertEquals("INVALID_TARGET_DPI", assertThrows(IllegalArgumentException.class, () -> new RenderInput(SceneFixture.request("RENDER", VERSION_TWO).put("targetDpi", 250))).getMessage());
|
||||
for(ObjectNode request : List.of(SceneFixture.request("RENDER", VERSION_TWO).put("targetDpi", 72), SceneFixture.request("RENDER", VERSION_TWO).put("targetDpi", 1200), SceneFixture.request("RENDER", VERSION_TWO).put("targetDpi", "300"),
|
||||
SceneFixture.request("RENDER", VERSION_TWO).put("targetDpi", 300.5), SceneFixture.request("RENDER", VERSION_TWO).putNull("targetDpi")))
|
||||
{
|
||||
assertEquals("INVALID_INTEGER", assertThrows(IllegalArgumentException.class, () -> new RenderInput(request)).getMessage(), request.get("targetDpi").toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The only diagnostics mode is to collect them; any other word is refused.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theDiagnosticsModeIsCollectOrAbsent() throws Exception
|
||||
{
|
||||
assertFalse(new RenderInput(SceneFixture.request("RENDER", VERSION_TWO)).collectDiagnostics);
|
||||
assertTrue(new RenderInput(SceneFixture.request("RENDER", VERSION_TWO).put("diagnostics", "COLLECT")).collectDiagnostics);
|
||||
for(ObjectNode request : List.of(SceneFixture.request("RENDER", VERSION_TWO).put("diagnostics", "ALL"), SceneFixture.request("RENDER", VERSION_TWO).put("diagnostics", "collect"), SceneFixture.request("RENDER", VERSION_TWO).put("diagnostics", true),
|
||||
SceneFixture.request("RENDER", VERSION_TWO).putNull("diagnostics")))
|
||||
{
|
||||
assertEquals("INVALID_DIAGNOSTICS_MODE", assertThrows(IllegalArgumentException.class, () -> new RenderInput(request)).getMessage(), request.get("diagnostics").toString());
|
||||
}
|
||||
Map<String, Object> answer = RendererMain.process(SceneFixture.request("RENDER", VERSION_ONE).put("diagnostics", "COLLECT"));
|
||||
assertEquals("OK", answer.get("outcome"));
|
||||
assertEquals(List.of(), answer.get("diagnostics"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The error answer of a version 2 refusal carries its diagnostic as `detail`;
|
||||
** every other failure stays the opaque code, with nothing from the source.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anErrorAnswerCarriesTheDiagnosticOfAVersionTwoRefusal()
|
||||
{
|
||||
Exception refusal = assertThrows(IllegalArgumentException.class, () -> RendererMain.process(SceneFixture.request("RENDER", SceneFixture.scene("{\"id\":\"wide\",\"type\":\"box\",\"x\":\"90mm\",\"width\":\"10mm\",\"height\":\"5mm\",\"fill\":true}"))));
|
||||
assertEquals("ELEMENT_OUTSIDE_PRINTABLE_AREA", RendererMain.errorCode(refusal));
|
||||
Map<String, Object> answer = RendererMain.error("request-7", refusal);
|
||||
assertEquals(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "code", "detail"), List.copyOf(answer.keySet()));
|
||||
assertEquals(Wire.PROTOCOL, answer.get("protocol"));
|
||||
assertEquals("request-7", answer.get("requestId"));
|
||||
assertEquals("ERROR", answer.get("outcome"));
|
||||
assertEquals("qqq-renderer-0.2.0", answer.get("rendererVersion"));
|
||||
assertEquals(Barcodes.VERSION, answer.get("catalogVersion"));
|
||||
assertEquals("ELEMENT_OUTSIDE_PRINTABLE_AREA", answer.get("code"));
|
||||
assertEquals(Map.of("code", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "severity", "ERROR", "element", "wide", "box", List.of(719, 0, 79, 39),
|
||||
"measured", Map.of("leftDots", 719, "topDots", 0, "rightDots", 798, "bottomDots", 39), "limit", Map.of("leftDots", 0, "topDots", 0, "rightDots", 764, "bottomDots", 155)), answer.get("detail"));
|
||||
|
||||
Map<String, Object> opaque = RendererMain.error("request-8", new IllegalStateException("Customer Jane Example could not be printed"));
|
||||
assertEquals(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "code"), List.copyOf(opaque.keySet()));
|
||||
assertEquals("RENDER_FAILED", opaque.get("code"));
|
||||
assertEquals("RENDER_FAILED", RendererMain.error("request-9", new IllegalArgumentException("UNSUPPORTED_ZPL_STOCK")).get("code"));
|
||||
assertEquals("BARCODE_FIT_TOO_SMALL", RendererMain.error("request-9", new IllegalArgumentException("BARCODE_FIT_TOO_SMALL")).get("code"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Nothing a record, a sample or an unknown property holds reaches an answer:
|
||||
** the context, the samples and the stray property all carry a marker, the
|
||||
** scene is refused in several ways, and no answer contains the marker.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void noAnswerCarriesRecordSampleOrSourceText() throws Exception
|
||||
{
|
||||
String marker = "Jane-Example-0012345";
|
||||
String head = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"samples\":{\"typical\":{\"record\":{\"name\":\"" + marker + "\"}}},\"elements\":[";
|
||||
for(String elements : List.of("{\"id\":\"a\",\"type\":\"box\",\"fill\":true,\"" + marker + "\":\"" + marker + "\"}", "{\"id\":\"a\",\"type\":\"box\",\"thickness\":\"" + marker + "\"}",
|
||||
"{\"id\":\"" + marker + " \",\"type\":\"box\",\"fill\":true}", "{\"id\":\"a\",\"type\":\"" + marker + "\"}", "{\"id\":\"a\",\"type\":\"text\",\"value\":\"" + marker + "\",\"valuePath\":\"/record/name\"}",
|
||||
"{\"id\":\"a\",\"type\":\"box\",\"x\":\"90mm\",\"width\":\"10mm\",\"height\":\"5mm\",\"fill\":true}", "{\"id\":\"a\",\"type\":\"box\",\"anchor\":\"" + marker + "\",\"fill\":true}"))
|
||||
{
|
||||
for(boolean collect : List.of(false, true))
|
||||
{
|
||||
ObjectNode request = SceneFixture.request("RENDER", head + elements + "]}");
|
||||
((ObjectNode) request.get("context")).putObject("record").put("name", marker);
|
||||
if(collect)
|
||||
{
|
||||
request.put("diagnostics", "COLLECT");
|
||||
}
|
||||
Map<String, Object> answer;
|
||||
try
|
||||
{
|
||||
answer = RendererMain.process(request);
|
||||
assertEquals("REFUSED", answer.get("outcome"), elements);
|
||||
assertTrue(collect, elements);
|
||||
}
|
||||
catch(IllegalArgumentException refusal)
|
||||
{
|
||||
answer = RendererMain.error("request-1", refusal);
|
||||
assertFalse(collect, elements);
|
||||
assertTrue(answer.containsKey("detail"), elements);
|
||||
}
|
||||
assertFalse(Wire.JSON.writeValueAsString(answer).contains("Jane"), elements);
|
||||
assertFalse(new String(Base64.getDecoder().decode(String.valueOf(answer.getOrDefault("base64", ""))), StandardCharsets.US_ASCII).contains("Jane"), elements);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The two operations of the later packages are routed and, until those
|
||||
** packages exist, refused by name.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theNewOperationsAreRoutedAndRefusedByName()
|
||||
{
|
||||
for(String operation : List.of("INSPECT_ASSET", "UPGRADE_SCENE"))
|
||||
{
|
||||
ObjectNode request = Wire.JSON.createObjectNode().put("protocol", Wire.PROTOCOL).put("requestId", "request-1").put("operation", operation);
|
||||
IllegalArgumentException refusal = assertThrows(IllegalArgumentException.class, () -> RendererMain.process(request));
|
||||
assertEquals("UNSUPPORTED_OPERATION", refusal.getMessage());
|
||||
assertEquals("UNSUPPORTED_OPERATION", RendererMain.errorCode(refusal));
|
||||
assertEquals("INVALID_TEXT", assertThrows(IllegalArgumentException.class, () -> RendererMain.process(request.deepCopy().without("requestId"))).getMessage());
|
||||
assertEquals("INVALID_PROTOCOL", assertThrows(IllegalArgumentException.class, () -> RendererMain.process(request.deepCopy().put("protocol", "qbit-document-renderer/2"))).getMessage());
|
||||
}
|
||||
assertEquals("UNSUPPORTED_OPERATION", assertThrows(IllegalArgumentException.class, () -> AssetInspector.inspect(Wire.JSON.createObjectNode().put("requestId", "request-1"))).getMessage());
|
||||
assertEquals("UNSUPPORTED_OPERATION", assertThrows(IllegalArgumentException.class, () -> SceneUpgrade.upgrade(Wire.JSON.createObjectNode().put("requestId", "request-1"))).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The prefix of the bundled faces is reserved, and the input says which
|
||||
** supplied assets use it, for the text package to refuse.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theInputNamesAssetsThatUseTheReservedPrefix() throws Exception
|
||||
{
|
||||
byte[] font = {0, 1, 0, 0, 0};
|
||||
ObjectNode request = SceneFixture.request("VALIDATE", VERSION_TWO);
|
||||
for(String name : List.of("qqq-sans", "brand-sans", "qqq-mono-bold", "qqqsans"))
|
||||
{
|
||||
((ArrayNode) request.get("assets")).addObject().put("name", name).put("mediaType", "font/ttf").put("base64", Base64.getEncoder().encodeToString(font)).put("sha256", Wire.sha256(font)).put("fontFamily", "Test Family");
|
||||
}
|
||||
assertEquals("qqq-", RenderInput.RESERVED_ASSET_PREFIX);
|
||||
assertEquals(List.of("qqq-sans", "qqq-mono-bold"), new RenderInput(request).reservedAssetNames());
|
||||
assertEquals(List.of(), new RenderInput(SceneFixture.request("VALIDATE", VERSION_TWO)).reservedAssetNames());
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -56,6 +56,13 @@ class ZplSceneOneCompatibilityTest
|
||||
{
|
||||
private static final String ROOT = "/compat-v1/";
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the SHA-256 of `SHA256SUMS` itself, as committed with the corpus. The manifest pins every //
|
||||
// stored output and this pins the manifest, so regenerating the outputs together with their //
|
||||
// sums still fails here. It changes only when a case is added from a tag that is in production //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final String MANIFEST_SHA256 = "cb6ec01d084e9077f3b5f6c8f39ed2f1de1116d92697dbbf529557fc84da5b2a";
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// every property a version 1 element reads, by element type; the corpus must use each one //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -110,12 +117,14 @@ class ZplSceneOneCompatibilityTest
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The manifest names exactly the cases, and each stored file has its hash, so
|
||||
** a case cannot be dropped or its bytes edited without this failing.
|
||||
** The manifest is the committed one, it names exactly the cases, and each
|
||||
** stored file has its hash, so a case cannot be dropped, its bytes edited or
|
||||
** the whole corpus regenerated without this failing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void manifestNamesExactlyTheCasesAndTheirStoredBytes() throws Exception
|
||||
{
|
||||
assertEquals(MANIFEST_SHA256, Wire.sha256(java.util.Objects.requireNonNull(resource("SHA256SUMS"))), "The corpus manifest was regenerated or edited");
|
||||
Map<String, String> manifest = manifest();
|
||||
assertEquals(cases(), List.copyOf(manifest.keySet()));
|
||||
assertFalse(manifest.isEmpty());
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.SplittableRandom;
|
||||
import org.junit.jupiter.api.Test;
|
||||
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 1-bit canvas and its emission: paint order, reverse, bounds, and the
|
||||
** proof that the bitmap rebuilt from the emitted fields is the canvas.
|
||||
*******************************************************************************/
|
||||
class CanvasTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** The canvas as a page bitmap, moved by the origin of the printable area.
|
||||
***************************************************************************/
|
||||
private static boolean[][] page(Canvas canvas, int pageWidth, int pageHeight, int originX, int originY)
|
||||
{
|
||||
boolean[][] page = new boolean[pageHeight][pageWidth];
|
||||
for(int y = 0; y < canvas.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < canvas.width(); x++)
|
||||
{
|
||||
page[originY + y][originX + x] = canvas.get(x, y);
|
||||
}
|
||||
}
|
||||
return (page);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A new canvas is blank, and a dot is ink or it is not.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aDotIsInkOrItIsNot()
|
||||
{
|
||||
Canvas canvas = new Canvas(19, 5, 203);
|
||||
assertEquals(19, canvas.width());
|
||||
assertEquals(5, canvas.height());
|
||||
assertEquals(203, canvas.dpi());
|
||||
assertFalse(canvas.get(0, 0) || canvas.get(18, 4));
|
||||
assertEquals(-1, canvas.firstInk(2));
|
||||
assertEquals(-1, canvas.lastInk(2));
|
||||
canvas.set(7, 2, true);
|
||||
canvas.set(8, 2, true);
|
||||
canvas.set(18, 2, true);
|
||||
assertTrue(canvas.get(7, 2) && canvas.get(8, 2) && canvas.get(18, 2));
|
||||
assertFalse(canvas.get(6, 2) || canvas.get(9, 2) || canvas.get(7, 1) || canvas.get(7, 3));
|
||||
assertEquals(7, canvas.firstInk(2));
|
||||
assertEquals(18, canvas.lastInk(2));
|
||||
canvas.set(7, 2, false);
|
||||
assertFalse(canvas.get(7, 2));
|
||||
assertEquals(8, canvas.firstInk(2));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Elements paint in order: a later white box clears an earlier black one,
|
||||
** and reversing a box swaps black and white inside it only.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void laterPaintCoversEarlierPaintAndReverseSwaps()
|
||||
{
|
||||
Canvas canvas = new Canvas(20, 10, 300);
|
||||
canvas.fill(new Frame(2, 2, 10, 6), true);
|
||||
canvas.fill(new Frame(4, 4, 3, 2), false);
|
||||
assertTrue(canvas.get(2, 2) && canvas.get(11, 7) && canvas.get(3, 4) && canvas.get(7, 5));
|
||||
assertFalse(canvas.get(4, 4) || canvas.get(6, 5) || canvas.get(12, 2) || canvas.get(2, 8) || canvas.get(1, 2));
|
||||
canvas.invert(new Frame(0, 0, 5, 5));
|
||||
assertTrue(canvas.get(0, 0) && canvas.get(4, 4) && canvas.get(1, 2));
|
||||
assertFalse(canvas.get(2, 2) || canvas.get(3, 4));
|
||||
assertTrue(canvas.get(5, 2) && canvas.get(2, 5));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ink cannot be lost at an edge: painting off the canvas is refused, and so
|
||||
** is a canvas of no size or of more dots than the limit.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void paintingOffTheCanvasIsRefused()
|
||||
{
|
||||
Canvas canvas = new Canvas(16, 4, 203);
|
||||
for(int[] dot : new int[][] {{-1, 0}, {0, -1}, {16, 0}, {0, 4}})
|
||||
{
|
||||
assertEquals("CANVAS_BOUNDS", assertThrows(IllegalArgumentException.class, () -> canvas.set(dot[0], dot[1], true)).getMessage());
|
||||
assertEquals("CANVAS_BOUNDS", assertThrows(IllegalArgumentException.class, () -> canvas.get(dot[0], dot[1])).getMessage());
|
||||
}
|
||||
assertEquals("CANVAS_BOUNDS", assertThrows(IllegalArgumentException.class, () -> canvas.fill(new Frame(10, 0, 7, 1), true)).getMessage());
|
||||
assertEquals("CANVAS_BOUNDS", assertThrows(IllegalArgumentException.class, () -> canvas.invert(new Frame(0, 3, 1, 2))).getMessage());
|
||||
assertEquals("CANVAS_LIMIT", assertThrows(IllegalArgumentException.class, () -> new Canvas(0, 4, 203)).getMessage());
|
||||
assertEquals("CANVAS_LIMIT", assertThrows(IllegalArgumentException.class, () -> new Canvas(4, 0, 203)).getMessage());
|
||||
assertEquals("CANVAS_LIMIT", assertThrows(IllegalArgumentException.class, () -> new Canvas(9999, 6401, 203)).getMessage());
|
||||
for(Frame outside : List.of(new Frame(-1, 0, 4, 2), new Frame(0, -1, 4, 2), new Frame(14, 0, 3, 2), new Frame(0, 3, 4, 2), new Frame(16, 0, 1, 1), new Frame(0, 4, 1, 1)))
|
||||
{
|
||||
assertEquals("CANVAS_BOUNDS", assertThrows(IllegalArgumentException.class, () -> canvas.fill(outside, true)).getMessage(), outside.toString());
|
||||
assertEquals("CANVAS_BOUNDS", assertThrows(IllegalArgumentException.class, () -> canvas.fill(outside, false)).getMessage(), outside.toString());
|
||||
assertEquals("CANVAS_BOUNDS", assertThrows(IllegalArgumentException.class, () -> canvas.invert(outside)).getMessage(), outside.toString());
|
||||
}
|
||||
canvas.fill(new Frame(3, 1, 0, 2), true);
|
||||
canvas.fill(new Frame(3, 1, 2, 0), true);
|
||||
canvas.invert(new Frame(99, 99, 0, 0));
|
||||
for(int row = 0; row < canvas.height(); row++)
|
||||
{
|
||||
assertEquals(-1, canvas.firstInk(row), "a box with no dots paints nothing");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property, over seeded boxes on canvases of every width modulo eight: a box
|
||||
** is painted a byte at a time, and the result is the same as visiting each
|
||||
** dot, at byte boundaries as everywhere else.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void paintingABoxIsPaintingEachOfItsDots()
|
||||
{
|
||||
SplittableRandom random = new SplittableRandom(20261112L);
|
||||
for(int iteration = 0; iteration < 400; iteration++)
|
||||
{
|
||||
Canvas canvas = new Canvas(1 + random.nextInt(70), 1 + random.nextInt(12), 203);
|
||||
boolean[][] expected = new boolean[canvas.height()][canvas.width()];
|
||||
for(int box = 0; box < 12; box++)
|
||||
{
|
||||
int x = random.nextInt(canvas.width());
|
||||
int y = random.nextInt(canvas.height());
|
||||
Frame frame = new Frame(x, y, 1 + random.nextInt(canvas.width() - x), 1 + random.nextInt(canvas.height() - y));
|
||||
int paint = random.nextInt(3);
|
||||
if(paint == 2)
|
||||
{
|
||||
canvas.invert(frame);
|
||||
}
|
||||
else
|
||||
{
|
||||
canvas.fill(frame, paint == 1);
|
||||
}
|
||||
for(int row = frame.y(); row < frame.bottom(); row++)
|
||||
{
|
||||
for(int column = frame.x(); column < frame.right(); column++)
|
||||
{
|
||||
expected[row][column] = paint == 2 ? !expected[row][column] : paint == 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertArrayEquals(expected, page(canvas, canvas.width(), canvas.height(), 0, 0), "iteration " + iteration);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A blank canvas is a label with no fields, and a single dot is one field of
|
||||
** one byte at the dot's place on the page.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void onlyInkIsSent() throws IOException
|
||||
{
|
||||
Canvas canvas = new Canvas(764, 155, 203);
|
||||
assertEquals("^XA^PW812^LL203^LH0,0^CI28\n^XZ\n", new String(ZplEmitter.emit(canvas, 812, 203, 24, 24, 1024), java.nio.charset.StandardCharsets.US_ASCII));
|
||||
canvas.set(100, 50, true);
|
||||
assertEquals("^XA^PW812^LL203^LH0,0^CI28\n^FO124,74^GFA,1,1,1,80^FS\n^XZ\n", new String(ZplEmitter.emit(canvas, 812, 203, 24, 24, 1024), java.nio.charset.StandardCharsets.US_ASCII));
|
||||
canvas.fill(new Frame(96, 49, 12, 3), true);
|
||||
assertEquals("^XA^PW812^LL203^LH0,0^CI28\n^FO120,73^GFA,6,6,2,FFF0FFF0FFF0^FS\n^XZ\n", new String(ZplEmitter.emit(canvas, 812, 203, 24, 24, 1024), java.nio.charset.StandardCharsets.US_ASCII));
|
||||
assertEquals("OUTPUT_LIMIT", assertThrows(IOException.class, () -> ZplEmitter.emit(canvas, 812, 203, 24, 24, 40)).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Rows are cut into bands where at least eight blank rows separate ink, and
|
||||
** each band is trimmed to its own columns. Seven blank rows stay inside one
|
||||
** band.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void bandsAreCutAtBlankRowsAndTrimmedToTheirColumns() throws IOException
|
||||
{
|
||||
Canvas canvas = new Canvas(200, 100, 203);
|
||||
canvas.fill(new Frame(10, 5, 8, 2), true);
|
||||
canvas.fill(new Frame(100, 14, 16, 1), true);
|
||||
canvas.fill(new Frame(40, 23, 8, 1), true);
|
||||
canvas.fill(new Frame(190, 99, 10, 1), true);
|
||||
byte[] zpl = ZplEmitter.emit(canvas, 220, 110, 10, 5, 4096);
|
||||
String fields = new String(zpl, java.nio.charset.StandardCharsets.US_ASCII);
|
||||
assertEquals(3, SceneFixture.fields(zpl));
|
||||
assertTrue(fields.contains("^FO20,10^GFA,140,140,14,"), fields);
|
||||
assertTrue(fields.contains("^FO50,28^GFA,1,1,1,FF^FS\n"), fields);
|
||||
assertTrue(fields.contains("^FO200,104^GFA,2,2,2,FFC0^FS\n"), fields);
|
||||
assertArrayEquals(page(canvas, 220, 110, 10, 5), SceneFixture.raster(zpl));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A band taller than one field may hold continues in the next field: a
|
||||
** field is at most 99999 bytes, so 1250 bytes a row leaves 79 rows a field.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aTallBandContinuesInTheNextField() throws IOException
|
||||
{
|
||||
Canvas canvas = new Canvas(9999, 200, 600);
|
||||
canvas.fill(new Frame(0, 10, 9999, 180), true);
|
||||
byte[] zpl = ZplEmitter.emit(canvas, 9999, 200, 0, 0, 8 * 1024 * 1024);
|
||||
assertEquals(3, SceneFixture.fields(zpl));
|
||||
String text = new String(zpl, java.nio.charset.StandardCharsets.US_ASCII);
|
||||
assertTrue(text.contains("^FO0,10^GFA,98750,98750,1250,") && text.contains("^FO0,89^GFA,98750,98750,1250,") && text.contains("^FO0,168^GFA,27500,27500,1250,"));
|
||||
assertArrayEquals(page(canvas, 9999, 200, 0, 0), SceneFixture.raster(zpl));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The worst case for the number of fields is a label of the greatest height
|
||||
** with ink on every ninth row. It stays well inside the 6000 fields the host
|
||||
** gate allows.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theGreatestLabelStaysInsideTheFieldLimit() throws IOException
|
||||
{
|
||||
Canvas canvas = new Canvas(8, 32_000, 600);
|
||||
for(int row = 0; row < canvas.height(); row += ZplEmitter.BLANK_ROWS + 1)
|
||||
{
|
||||
canvas.set(3, row, true);
|
||||
}
|
||||
byte[] zpl = ZplEmitter.emit(canvas, 8, 32_000, 0, 0, 8 * 1024 * 1024);
|
||||
assertEquals(3556, SceneFixture.fields(zpl));
|
||||
assertArrayEquals(page(canvas, 8, 32_000, 0, 0), SceneFixture.raster(zpl));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property, over seeded canvases of scattered boxes at every origin: the
|
||||
** bitmap rebuilt from the emitted fields equals the canvas, dot for dot, so
|
||||
** trimming and banding lose and add nothing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theRebuiltBitmapEqualsTheCanvas() throws IOException
|
||||
{
|
||||
SplittableRandom random = new SplittableRandom(7919L);
|
||||
for(int iteration = 0; iteration < 300; iteration++)
|
||||
{
|
||||
Canvas canvas = new Canvas(1 + random.nextInt(400), 1 + random.nextInt(300), 203);
|
||||
int originX = random.nextInt(40);
|
||||
int originY = random.nextInt(40);
|
||||
for(int box = random.nextInt(12); box > 0; box--)
|
||||
{
|
||||
int x = random.nextInt(canvas.width());
|
||||
int y = random.nextInt(canvas.height());
|
||||
Frame frame = new Frame(x, y, 1 + random.nextInt(canvas.width() - x), 1 + random.nextInt(Math.min(20, canvas.height() - y)));
|
||||
if(random.nextInt(4) == 0)
|
||||
{
|
||||
canvas.invert(frame);
|
||||
}
|
||||
else
|
||||
{
|
||||
canvas.fill(frame, random.nextInt(5) != 0);
|
||||
}
|
||||
}
|
||||
byte[] zpl = ZplEmitter.emit(canvas, canvas.width() + originX + random.nextInt(9), canvas.height() + originY + random.nextInt(9), originX, originY, 4 * 1024 * 1024);
|
||||
boolean[][] rebuilt = SceneFixture.raster(zpl);
|
||||
assertArrayEquals(page(canvas, rebuilt[0].length, rebuilt.length, originX, originY), rebuilt, "iteration " + iteration);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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 gate every diagnostic passes: a closed vocabulary of codes, an element id
|
||||
** the scene declares, a pointer of schema names, indexes and declared ids, and
|
||||
** named integers. Nothing a record or a context holds has a way in.
|
||||
*******************************************************************************/
|
||||
class DiagnosticsTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** The design's example diagnostic, as the wire carries it.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aDiagnosticIsACodeADeclaredIdAndNumbers()
|
||||
{
|
||||
Diagnostics diagnostics = new Diagnostics(false);
|
||||
diagnostics.declare("caption", "/elements/1");
|
||||
Refusal refusal = diagnostics.refuse("TEXT_DOES_NOT_FIT", "caption", "/elements/1/fit", new Frame(24, 131, 764, 56),
|
||||
Map.of("widthDots", 902, "sizeCentipoints", 700, "lines", 2), Map.of("widthDots", 764, "lines", 2));
|
||||
assertEquals("TEXT_DOES_NOT_FIT", refusal.getMessage());
|
||||
Diagnostic diagnostic = refusal.diagnostic();
|
||||
assertEquals("TEXT_DOES_NOT_FIT", diagnostic.code());
|
||||
assertEquals(Diagnostic.Severity.ERROR, diagnostic.severity());
|
||||
assertEquals("caption", diagnostic.element());
|
||||
assertEquals("/elements/1/fit", diagnostic.pointer());
|
||||
assertEquals(new Frame(24, 131, 764, 56), diagnostic.box());
|
||||
assertEquals(Map.of("widthDots", 902, "sizeCentipoints", 700, "lines", 2), diagnostic.measured());
|
||||
assertEquals(Map.of("widthDots", 764, "lines", 2), diagnostic.limit());
|
||||
assertEquals(Map.of("code", "TEXT_DOES_NOT_FIT", "severity", "ERROR", "element", "caption", "pointer", "/elements/1/fit", "box", List.of(24, 131, 764, 56),
|
||||
"measured", Map.of("widthDots", 902, "sizeCentipoints", 700, "lines", 2), "limit", Map.of("widthDots", 764, "lines", 2)), diagnostic.wire());
|
||||
assertEquals(List.of("code", "severity", "element", "pointer", "box", "measured", "limit"), List.copyOf(diagnostic.wire().keySet()));
|
||||
assertEquals(List.of("lines", "sizeCentipoints", "widthDots"), List.copyOf(diagnostic.measured().keySet()));
|
||||
assertThrows(UnsupportedOperationException.class, () -> diagnostic.measured().put("widthDots", 1));
|
||||
assertEquals(Map.of("code", "STOCK_MISMATCH", "severity", "ERROR"), diagnostics.refuse("STOCK_MISMATCH", null, null, null, null, null).diagnostic().wire());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An element id travels only when the scene declared it, so a record value
|
||||
** put where an id goes is a defect that stays inside the renderer.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void onlyADeclaredIdMayBeNamed()
|
||||
{
|
||||
Diagnostics diagnostics = new Diagnostics(false);
|
||||
assertFalse(diagnostics.declared("caption"));
|
||||
assertNull(diagnostics.pointer("caption"));
|
||||
diagnostics.declare("caption", "/elements/0");
|
||||
assertTrue(diagnostics.declared("caption"));
|
||||
assertEquals("/elements/0", diagnostics.pointer("caption"));
|
||||
for(String element : List.of("Jane Example", "code", "Caption", "caption ", "(01)00012345678905"))
|
||||
{
|
||||
assertEquals("DIAGNOSTIC_NOT_DECLARED", assertThrows(IllegalStateException.class, () -> diagnostics.refuse("VALUE_MISSING", element, null, null, null, null)).getMessage(), element);
|
||||
assertEquals("DIAGNOSTIC_NOT_DECLARED", assertThrows(IllegalStateException.class, () -> diagnostics.warn("FIT_NOT_PROVEN", element, null, null, null, null)).getMessage(), element);
|
||||
}
|
||||
for(String id : new String[] {null, "", "9caption", "two words", "a".repeat(33), "caption\n", "/record/name", "caption"})
|
||||
{
|
||||
assertEquals("ELEMENT_ID_NOT_DECLARABLE", assertThrows(IllegalStateException.class, () -> diagnostics.declare(id, "/elements/1")).getMessage(), String.valueOf(id));
|
||||
}
|
||||
diagnostics.declare("order_id-2", "/elements/1");
|
||||
assertEquals("order_id-2", diagnostics.refuse("VALUE_MISSING", "order_id-2", null, null, null, null).diagnostic().element());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A pointer is made only of schema property names, array indexes and
|
||||
** declared ids. A name the schema does not declare cannot be a segment.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aPointerIsMadeOfDeclaredNamesAndIndexes()
|
||||
{
|
||||
Diagnostics diagnostics = new Diagnostics(false);
|
||||
diagnostics.declare("caption", "/elements/1");
|
||||
for(String pointer : List.of("", "/schema", "/design/width", "/elements/0", "/elements/1/width", "/elements/3/children/0/children/12/thickness", "/overrides/0/when/dpi", "/overrides/1/set/caption/thickness",
|
||||
"/elements/1/fit", "/elements/1/valuePath", "/elements/1/minModule", "/elements/1/asset", "/samples", "/defaults/size"))
|
||||
{
|
||||
assertEquals(pointer, diagnostics.refuse("SCENE_INVALID", null, pointer, null, null, null).diagnostic().pointer());
|
||||
}
|
||||
for(String pointer : List.of("elements", "/elements/", "//elements", "/elements/01", "/elements/-1", "/elements/12345", "/Jane Example", "/record/name", "/overrides/1/set/code/thickness",
|
||||
"/samples/typical", "/elements/1/customerName", "/elements/1/width/", "/elements/1/~0"))
|
||||
{
|
||||
assertEquals("DIAGNOSTIC_NOT_DECLARED", assertThrows(IllegalStateException.class, () -> diagnostics.refuse("SCENE_INVALID", null, pointer, null, null, null)).getMessage(), pointer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Codes are a closed vocabulary by severity, and numbers have short names.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void codesAndNumberNamesAreClosed()
|
||||
{
|
||||
Diagnostics diagnostics = new Diagnostics(true);
|
||||
for(String code : List.of("SCENE_INVALID", "STOCK_MISMATCH", "TARGET_REQUIRES_SCENE_2", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "TEXT_DOES_NOT_FIT", "VALUE_MISSING", "VALUE_TOO_LONG", "FONT_UNKNOWN",
|
||||
"FONT_MISSING_GLYPH", "RESERVED_FONT_NAME", "BARCODE_DOES_NOT_FIT", "GS1_INVALID", "ASSET_UNKNOWN", "PREVIEW_TOO_LARGE", "RENDERER_TOO_OLD"))
|
||||
{
|
||||
assertEquals(code, diagnostics.refuse(code, null, null, null, null, null).getMessage());
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.warn(code, null, null, null, null, null));
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.inform(code, null, null, null, null, null));
|
||||
}
|
||||
for(String code : List.of("TEXT_TRUNCATED", "TEXT_SMALL_FOR_DPI", "TEXT_LIGHT_FACE", "FIT_NOT_PROVEN", "BARCODE_MODULE_BELOW_RECOMMENDED", "QUIET_ZONE_INTRUDED", "ELEMENTS_OVERLAP", "IMAGE_UPSCALED",
|
||||
"DEVICE_UNIT_USED", "PRINTER_FONT_NOT_SHOWN"))
|
||||
{
|
||||
diagnostics.warn(code, null, null, null, null, null);
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.refuse(code, null, null, null, null, null));
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.inform(code, null, null, null, null, null));
|
||||
}
|
||||
for(String code : List.of("TEXT_SHRUNK", "TEXT_WRAPPED", "BARCODE_MODULE", "IMAGE_SCALED"))
|
||||
{
|
||||
diagnostics.inform(code, null, null, null, Map.of("dots", 2, "micrometres", 250), null);
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.refuse(code, null, null, null, null, null));
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.warn(code, null, null, null, null, null));
|
||||
}
|
||||
assertEquals(14, diagnostics.findings().size());
|
||||
assertFalse(diagnostics.refused());
|
||||
assertEquals(List.of(Diagnostic.Severity.WARNING, Diagnostic.Severity.INFO), diagnostics.findings().stream().map(Diagnostic::severity).distinct().toList());
|
||||
for(String code : new String[] {"RENDER_FAILED", "Customer Jane Example could not be printed", "", null})
|
||||
{
|
||||
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", assertThrows(IllegalStateException.class, () -> diagnostics.refuse(code, null, null, null, null, null)).getMessage());
|
||||
}
|
||||
Map<String, Integer> unnamed = new HashMap<>();
|
||||
unnamed.put("widthDots", null);
|
||||
Map<String, Integer> many = new HashMap<>();
|
||||
for(int index = 0; index < 9; index++)
|
||||
{
|
||||
many.put("value" + index, index);
|
||||
}
|
||||
for(Map<String, Integer> numbers : List.of(Map.of("Width", 1), Map.of("width dots", 1), Map.of("the value Jane Example is too long", 1), Map.of("w".repeat(33), 1), unnamed, many))
|
||||
{
|
||||
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", assertThrows(IllegalStateException.class, () -> diagnostics.refuse("TEXT_DOES_NOT_FIT", null, null, null, numbers, null)).getMessage());
|
||||
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", assertThrows(IllegalStateException.class, () -> diagnostics.refuse("TEXT_DOES_NOT_FIT", null, null, null, null, numbers)).getMessage());
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.refuse("ELEMENT_OUTSIDE_PRINTABLE_AREA", null, null, new Frame(0, 0, -1, 1), null, null));
|
||||
assertThrows(IllegalStateException.class, () -> diagnostics.refuse("ELEMENT_OUTSIDE_PRINTABLE_AREA", null, null, new Frame(0, 0, 1, -1), null, null));
|
||||
assertEquals(List.of(-12, -4, 0, 0), diagnostics.refuse("ELEMENT_OUTSIDE_PRINTABLE_AREA", null, null, new Frame(-12, -4, 0, 0), null, null).diagnostic().wire().get("box"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** By default the first error ends the work. A collector keeps errors, in
|
||||
** order and once each, and remembers that the label must not print.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theFirstErrorEndsTheWorkUnlessCollecting()
|
||||
{
|
||||
Diagnostics strict = new Diagnostics(false);
|
||||
Refusal refusal = strict.refuse("STOCK_MISMATCH", null, null, null, null, null);
|
||||
assertSame(refusal, assertThrows(Refusal.class, () -> strict.keep(refusal)));
|
||||
assertFalse(strict.refused());
|
||||
assertEquals(List.of(), strict.wire());
|
||||
|
||||
Diagnostics collecting = new Diagnostics(true);
|
||||
collecting.warn("DEVICE_UNIT_USED", null, "/elements/0/thickness", null, null, null);
|
||||
collecting.keep(refusal);
|
||||
collecting.keep(collecting.refuse("STOCK_MISMATCH", null, null, null, null, null));
|
||||
collecting.warn("DEVICE_UNIT_USED", null, "/elements/0/thickness", null, null, null);
|
||||
collecting.keep(collecting.refuse("SCENE_INVALID", null, "/design", null, null, null));
|
||||
assertTrue(collecting.refused());
|
||||
assertEquals(List.of(Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "pointer", "/elements/0/thickness"), Map.of("code", "STOCK_MISMATCH", "severity", "ERROR"),
|
||||
Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", "/design")), collecting.wire());
|
||||
assertThrows(UnsupportedOperationException.class, () -> collecting.findings().clear());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import java.util.SplittableRandom;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement.Align;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement.Anchor;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement.Valign;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Anchor resolution for all nine anchors, shifts about the middle, omitted
|
||||
** sizes and content alignment, and the symmetry the design promises: mirrored
|
||||
** anchors give mirrored boxes, and a centred box is off by at most one dot.
|
||||
*******************************************************************************/
|
||||
class LayoutTest
|
||||
{
|
||||
private static final Frame CONTAINER = new Frame(10, 20, 200, 100);
|
||||
private static final Layout LAYOUT = new Layout(CONTAINER, 203);
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A placement in dot units, so the expected numbers are plain to read.
|
||||
***************************************************************************/
|
||||
private static Placement placement(Anchor anchor, String x, String y, String width, String height)
|
||||
{
|
||||
return (new Placement(anchor, Length.parse(x), Length.parse(y), width == null ? null : Length.parse(width), height == null ? null : Length.parse(height), Align.LEFT, Valign.TOP));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same point of the box and of the container coincide; `x` and `y` run
|
||||
** inward from an anchored edge.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void eachOfTheNineAnchorsPlacesTheSamePointOfTheBox()
|
||||
{
|
||||
assertEquals(new Frame(15, 23, 40, 20), LAYOUT.place(placement(Anchor.TOP_LEFT, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(95, 23, 40, 20), LAYOUT.place(placement(Anchor.TOP_CENTER, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(165, 23, 40, 20), LAYOUT.place(placement(Anchor.TOP_RIGHT, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(15, 63, 40, 20), LAYOUT.place(placement(Anchor.MIDDLE_LEFT, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(95, 63, 40, 20), LAYOUT.place(placement(Anchor.CENTER, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(165, 63, 40, 20), LAYOUT.place(placement(Anchor.MIDDLE_RIGHT, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(15, 97, 40, 20), LAYOUT.place(placement(Anchor.BOTTOM_LEFT, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(95, 97, 40, 20), LAYOUT.place(placement(Anchor.BOTTOM_CENTER, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(165, 97, 40, 20), LAYOUT.place(placement(Anchor.BOTTOM_RIGHT, "5dot", "3dot", "40dot", "20dot")));
|
||||
assertEquals(CONTAINER, LAYOUT.container());
|
||||
assertEquals(203, LAYOUT.dpi());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** About the middle a shift may be negative, and odd slack leaves the box one
|
||||
** dot nearer the start.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aCentredBoxShiftsEitherWayAndIsOffByAtMostOneDot()
|
||||
{
|
||||
assertEquals(new Frame(85, 57, 40, 20), LAYOUT.place(placement(Anchor.CENTER, "-5dot", "-3dot", "40dot", "20dot")));
|
||||
Layout odd = new Layout(new Frame(10, 20, 201, 101), 203);
|
||||
assertEquals(new Frame(90, 60, 40, 20), odd.place(placement(Anchor.CENTER, "0dot", "0dot", "40dot", "20dot")));
|
||||
assertEquals(new Frame(88, 58, 43, 23), LAYOUT.place(placement(Anchor.CENTER, "0dot", "0dot", "43dot", "23dot")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An omitted width or height is the rest of the container on that axis.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anOmittedSizeIsTheRestOfTheContainer()
|
||||
{
|
||||
assertEquals(new Frame(15, 23, 195, 97), LAYOUT.place(placement(Anchor.TOP_LEFT, "5dot", "3dot", null, null)));
|
||||
assertEquals(new Frame(10, 20, 195, 97), LAYOUT.place(placement(Anchor.BOTTOM_RIGHT, "5dot", "3dot", null, null)));
|
||||
assertEquals(new Frame(20, 26, 190, 94), LAYOUT.place(placement(Anchor.CENTER, "5dot", "3dot", null, null)));
|
||||
assertEquals(new Frame(10, 20, 190, 94), LAYOUT.place(placement(Anchor.CENTER, "-5dot", "-3dot", null, null)));
|
||||
assertEquals(CONTAINER, LAYOUT.place(placement(Anchor.CENTER, "0dot", "0dot", null, null)));
|
||||
assertEquals(195, LAYOUT.width(placement(Anchor.TOP_LEFT, "5dot", "3dot", null, null)));
|
||||
assertEquals(97, LAYOUT.height(placement(Anchor.TOP_LEFT, "5dot", "3dot", null, null)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Physical lengths resolve at the layout's resolution, positions to the
|
||||
** nearest dot and sizes down; a percentage is a share of the container, and
|
||||
** of a group's box for the group's children.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void lengthsResolveAtTheTargetAndPercentagesReferToTheContainer()
|
||||
{
|
||||
Layout page = new Layout(new Frame(0, 0, 764, 155), 203);
|
||||
assertEquals(new Frame(6, 4, 751, 87), page.place(placement(Anchor.TOP_CENTER, "0mm", "0.5mm", "94mm", "11mm")));
|
||||
Layout finer = new Layout(new Frame(0, 0, 1129, 229), 300);
|
||||
assertEquals(new Frame(9, 6, 1110, 129), finer.place(placement(Anchor.TOP_CENTER, "0mm", "0.5mm", "94mm", "11mm")));
|
||||
Frame group = page.place(placement(Anchor.TOP_LEFT, "10%", "20%", "50%", "50%"));
|
||||
assertEquals(new Frame(76, 31, 382, 77), group);
|
||||
Layout inside = page.within(group);
|
||||
assertEquals(203, inside.dpi());
|
||||
assertEquals(new Frame(76 + 191, 31 + 39, 191, 38), inside.place(placement(Anchor.BOTTOM_RIGHT, "0%", "0%", "50%", "50%")));
|
||||
assertEquals(4, page.x(placement(Anchor.TOP_LEFT, "0.5mm", "1mm", null, null)));
|
||||
assertEquals(8, page.y(placement(Anchor.TOP_LEFT, "0.5mm", "1mm", null, null)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Content of a measured size sits in its box by `align` and `valign`.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void contentSitsInItsBoxByAlignAndValign()
|
||||
{
|
||||
Frame box = new Frame(100, 50, 61, 31);
|
||||
assertEquals(new Frame(100, 50, 20, 10), Layout.align(box, 20, 10, Align.LEFT, Valign.TOP));
|
||||
assertEquals(new Frame(120, 60, 20, 10), Layout.align(box, 20, 10, Align.CENTER, Valign.MIDDLE));
|
||||
assertEquals(new Frame(141, 71, 20, 10), Layout.align(box, 20, 10, Align.RIGHT, Valign.BOTTOM));
|
||||
assertEquals(box, Layout.align(box, 61, 31, Align.CENTER, Valign.MIDDLE));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The words a scene writes name exactly the nine anchors and the three
|
||||
** places on each axis.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void sceneWordsNameTheAnchorsAndAlignments()
|
||||
{
|
||||
List<String> anchors = List.of("top-left", "top-center", "top-right", "middle-left", "center", "middle-right", "bottom-left", "bottom-center", "bottom-right");
|
||||
assertEquals(List.of(Anchor.values()), anchors.stream().map(Anchor::of).toList());
|
||||
assertEquals(List.of(Align.values()), List.of("left", "center", "right").stream().map(Align::of).toList());
|
||||
assertEquals(List.of(Valign.values()), List.of("top", "middle", "bottom").stream().map(Valign::of).toList());
|
||||
assertNull(Anchor.of("centre"));
|
||||
assertNull(Anchor.of(null));
|
||||
assertNull(Align.of("middle"));
|
||||
assertNull(Valign.of("center"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property, over seeded containers, sizes and offsets at every resolution:
|
||||
** mirroring the anchor mirrors the box. From an edge the mirror is exact;
|
||||
** about the middle, with the shift negated, it is exact when the slack is
|
||||
** even and one dot out when it is odd, never more.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void mirroredAnchorsGiveMirroredBoxes()
|
||||
{
|
||||
SplittableRandom random = new SplittableRandom(20261112L);
|
||||
for(int iteration = 0; iteration < 20_000; iteration++)
|
||||
{
|
||||
int dpi = Scene2.RESOLUTIONS.get(random.nextInt(3));
|
||||
Frame container = new Frame(random.nextInt(50), random.nextInt(50), 1 + random.nextInt(2400), 1 + random.nextInt(1200));
|
||||
Layout layout = new Layout(container, dpi);
|
||||
String x = random.nextBoolean() ? random.nextInt(300) + "dot" : random.nextInt(60) + "." + random.nextInt(10) + "mm";
|
||||
String y = random.nextInt(20) + (random.nextBoolean() ? "%" : "mm");
|
||||
String width = (1 + random.nextInt(60)) + (random.nextBoolean() ? "mm" : "%");
|
||||
String height = (1 + random.nextInt(30)) + (random.nextBoolean() ? "mm" : "pt");
|
||||
|
||||
Frame left = layout.place(placement(Anchor.TOP_LEFT, x, y, width, height));
|
||||
Frame right = layout.place(placement(Anchor.TOP_RIGHT, x, y, width, height));
|
||||
assertEquals(left.width(), right.width());
|
||||
assertEquals(left.x() - container.x(), container.right() - right.right(), "left and right edges mirror exactly");
|
||||
assertEquals(left.y(), right.y());
|
||||
|
||||
Frame top = layout.place(placement(Anchor.TOP_LEFT, x, y, width, height));
|
||||
Frame bottom = layout.place(placement(Anchor.BOTTOM_LEFT, x, y, width, height));
|
||||
assertEquals(top.y() - container.y(), container.bottom() - bottom.bottom(), "top and bottom edges mirror exactly");
|
||||
|
||||
Frame shifted = layout.place(placement(Anchor.CENTER, x, y, width, height));
|
||||
Frame opposite = layout.place(placement(Anchor.CENTER, "-" + x, "-" + y, width, height));
|
||||
int horizontal = (shifted.x() - container.x()) - (container.right() - opposite.right());
|
||||
int vertical = (shifted.y() - container.y()) - (container.bottom() - opposite.bottom());
|
||||
assertEquals(-Math.floorMod(container.width() - shifted.width(), 2), horizontal, "centre mirror, horizontal");
|
||||
assertEquals(-Math.floorMod(container.height() - shifted.height(), 2), vertical, "centre mirror, vertical");
|
||||
assertTrue(Math.abs(horizontal) <= 1 && Math.abs(vertical) <= 1);
|
||||
|
||||
Frame content = Layout.align(shifted, Math.max(0, shifted.width() - random.nextInt(9)), Math.max(0, shifted.height() - random.nextInt(9)), Align.CENTER, Valign.MIDDLE);
|
||||
int before = content.x() - shifted.x();
|
||||
int after = shifted.right() - content.right();
|
||||
assertTrue(shifted.contains(content) && (after == before || after == before + 1), "centred content is off by at most one dot");
|
||||
assertEquals(Layout.align(shifted, content.width(), content.height(), Align.LEFT, Valign.TOP).x() - shifted.x(),
|
||||
shifted.right() - Layout.align(shifted, content.width(), content.height(), Align.RIGHT, Valign.BOTTOM).right());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.renderer.BarcodeFit;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
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;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Every unit and every rounding rule of the design's section 2.2, with the
|
||||
** numbers worked by hand.
|
||||
*******************************************************************************/
|
||||
class LengthTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** A length is a number of up to three decimals and one of five units.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aLengthIsANumberAndAUnit()
|
||||
{
|
||||
assertEquals(new Length(Length.Unit.MM, 12_500), Length.parse("12.5mm"));
|
||||
assertEquals(new Length(Length.Unit.IN, 500), Length.parse("0.5in"));
|
||||
assertEquals(new Length(Length.Unit.PT, 9000), Length.parse("9pt"));
|
||||
assertEquals(new Length(Length.Unit.PERCENT, 25_000), Length.parse("25%"));
|
||||
assertEquals(new Length(Length.Unit.DOT, 1000), Length.parse("1dot"));
|
||||
assertEquals(new Length(Length.Unit.MM, -2000), Length.parse("-2mm"));
|
||||
assertEquals(new Length(Length.Unit.MM, 1), Length.parse("0.001mm"));
|
||||
assertEquals(new Length(Length.Unit.MM, 120), Length.parse("0.12mm"));
|
||||
assertEquals(Length.ZERO, Length.parse("0mm"));
|
||||
assertEquals(Length.ZERO, Length.parse("-0mm"));
|
||||
assertTrue(Length.parse("1mm").physical() && Length.parse("1in").physical() && Length.parse("1pt").physical());
|
||||
assertFalse(Length.parse("1%").physical() || Length.parse("1dot").physical());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Nothing else is a length: no bare number, no ambient unit, no exponent, no
|
||||
** fourth decimal, no fraction of a dot, nothing past the container or the
|
||||
** largest stock.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anythingElseIsRefused()
|
||||
{
|
||||
for(String text : List.of("", "12", "mm", "1.2345mm", "1e3mm", " 1mm", "1mm ", "1 mm", "1MM", "01mm", ".5mm", "1.mm", "+1mm", "--1mm", "1px", "1cm", "1mm1mm", "0x10mm",
|
||||
"1.5dot", "32001dot", "100.001%", "101%", "-101%", "2000.001mm", "-2000.001mm", "78.741in", "5669.293pt", "123456mm"))
|
||||
{
|
||||
assertEquals("INVALID_LENGTH", assertThrows(IllegalArgumentException.class, () -> Length.parse(text), text).getMessage(), text);
|
||||
}
|
||||
assertEquals("INVALID_LENGTH", assertThrows(IllegalArgumentException.class, () -> Length.parse(null)).getMessage());
|
||||
for(String text : List.of("2000mm", "-2000mm", "78.74in", "5669.291pt", "32000dot", "100%", "-100%"))
|
||||
{
|
||||
assertDoesNotThrow(() -> Length.parse(text), text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Each unit at its limit and just past it, and then as far past as the
|
||||
** syntax allows. The physical limit is two metres whatever the unit, and it
|
||||
** is applied before a length is narrowed: 84547 in is the first whole inch
|
||||
** whose micrometres do not fit an `int`, and it is the same named refusal as
|
||||
** 78.741 in, never an arithmetic failure.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyUnitIsBoundedAtItsLimitAndFarPastIt()
|
||||
{
|
||||
assertEquals(2_000_000, Length.parse("2000mm").micrometres());
|
||||
assertEquals(-2_000_000, Length.parse("-2000mm").micrometres());
|
||||
assertEquals(1_999_996, Length.parse("78.74in").micrometres());
|
||||
assertEquals(-1_999_996, Length.parse("-78.74in").micrometres());
|
||||
assertEquals(2_000_000, Length.parse("5669.291pt").micrometres());
|
||||
assertEquals(2_000_000, Length.parse("5669.292pt").micrometres());
|
||||
assertEquals(-2_000_000, Length.parse("-5669.292pt").micrometres());
|
||||
assertEquals(32_000, Length.parse("32000dot").position(203, 0));
|
||||
assertEquals(100_000, Length.parse("100%").thousandths());
|
||||
for(String text : List.of("2000.001mm", "2001mm", "99999mm", "99999.999mm", "-99999.999mm",
|
||||
"78.741in", "79in", "84546in", "84547in", "84600in", "99999in", "99999.999in", "-84600in", "-99999.999in",
|
||||
"5669.293pt", "5670pt", "99999pt", "99999.999pt", "-99999.999pt",
|
||||
"32001dot", "99999dot", "-32001dot", "100.001%", "99999.999%", "-99999.999%"))
|
||||
{
|
||||
assertEquals("INVALID_LENGTH", assertThrows(IllegalArgumentException.class, () -> Length.parse(text), text).getMessage(), text);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A physical length is whole micrometres first: an inch is 25400 and a point
|
||||
** is a 72nd of an inch, to the nearest micrometre.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void physicalLengthsBecomeWholeMicrometres()
|
||||
{
|
||||
assertEquals(12_500, Length.parse("12.5mm").micrometres());
|
||||
assertEquals(25_400, Length.parse("1in").micrometres());
|
||||
assertEquals(12_700, Length.parse("0.5in").micrometres());
|
||||
assertEquals(101_600, Length.parse("4in").micrometres());
|
||||
assertEquals(25, Length.parse("0.001in").micrometres());
|
||||
assertEquals(25_400, Length.parse("72pt").micrometres());
|
||||
assertEquals(3175, Length.parse("9pt").micrometres());
|
||||
assertEquals(353, Length.parse("1pt").micrometres());
|
||||
assertEquals(2469, Length.parse("7pt").micrometres());
|
||||
assertEquals(-353, Length.parse("-1pt").micrometres());
|
||||
assertEquals(-12_700, Length.parse("-0.5in").micrometres());
|
||||
assertEquals("LENGTH_NOT_PHYSICAL", assertThrows(IllegalArgumentException.class, () -> Length.parse("50%").micrometres()).getMessage());
|
||||
assertEquals("LENGTH_NOT_PHYSICAL", assertThrows(IllegalArgumentException.class, () -> Length.parse("5dot").micrometres()).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A position goes to the nearest dot, exactly as the stock already converts
|
||||
** micrometres, and a negative position is the mirror of the positive one.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aPositionIsTheNearestDotAsTheStockRounds()
|
||||
{
|
||||
assertEquals(4, Length.parse("0.5mm").position(203, 0));
|
||||
assertEquals(-4, Length.parse("-0.5mm").position(203, 0));
|
||||
assertEquals(751, Length.parse("94mm").position(203, 0));
|
||||
assertEquals(1110, Length.parse("94mm").position(300, 0));
|
||||
assertEquals(2220, Length.parse("94mm").position(600, 0));
|
||||
assertEquals(5, Length.parse("5dot").position(203, 0));
|
||||
assertEquals(5, Length.parse("5dot").position(600, 0));
|
||||
assertEquals(382, Length.parse("50%").position(203, 764));
|
||||
assertEquals(39, Length.parse("25%").position(203, 155));
|
||||
assertEquals(-51, Length.parse("-50%").position(203, 101));
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
RenderInput.Stock stock = new RenderInput.Stock(101_600, 25_400, 0, 0, 0, 0, dpi);
|
||||
for(int micrometres = 0; micrometres <= 120_000; micrometres += 37)
|
||||
{
|
||||
Length length = new Length(Length.Unit.MM, micrometres);
|
||||
assertEquals(stock.dots(micrometres), length.position(dpi, 0));
|
||||
assertEquals(-stock.dots(micrometres), new Length(Length.Unit.MM, -micrometres).position(dpi, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A box width or height is rounded down, exactly as the version 1 fit box
|
||||
** rounds inward, so content never leaves the designed box.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aBoxSizeIsRoundedDownAsTheFitBoxRounds()
|
||||
{
|
||||
assertEquals(751, Length.parse("94mm").size(203, 0));
|
||||
assertEquals(87, Length.parse("11mm").size(203, 0));
|
||||
assertEquals(129, Length.parse("11mm").size(300, 0));
|
||||
assertEquals(259, Length.parse("11mm").size(600, 0));
|
||||
assertEquals(3, Length.parse("0.5mm").size(203, 0));
|
||||
assertEquals(38, Length.parse("25%").size(203, 155));
|
||||
assertEquals(764, Length.parse("100%").size(203, 764));
|
||||
assertEquals(7, Length.parse("7dot").size(300, 0));
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
for(int micrometres = 0; micrometres <= 120_000; micrometres += 41)
|
||||
{
|
||||
assertEquals(BarcodeFit.dotsInside(micrometres, dpi), new Length(Length.Unit.MM, micrometres).size(dpi, 0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A thickness is the nearest dot and never less than one.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aThicknessIsTheNearestDotAndNeverVanishes()
|
||||
{
|
||||
assertEquals(1, Length.parse("0.1mm").thickness(203));
|
||||
assertEquals(1, Length.parse("0.01mm").thickness(203));
|
||||
assertEquals(3, Length.parse("1pt").thickness(203));
|
||||
assertEquals(4, Length.parse("1pt").thickness(300));
|
||||
assertEquals(8, Length.parse("1pt").thickness(600));
|
||||
assertEquals(1, Length.parse("1dot").thickness(600));
|
||||
assertEquals(3, Length.parse("3dot").thickness(203));
|
||||
assertEquals("LENGTH_NOT_ABSOLUTE", assertThrows(IllegalArgumentException.class, () -> Length.parse("5%").thickness(203)).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A barcode module goes up to a whole dot from its stated minimum: the
|
||||
** design's defaults are 2, 3 and 6 dots for 0.25 mm and 3, 5 and 9 dots for
|
||||
** 0.375 mm at the three resolutions.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aModuleIsRoundedUpFromItsMinimum()
|
||||
{
|
||||
assertEquals(List.of(2, 3, 6), Scene2.RESOLUTIONS.stream().map(dpi -> Length.parse("0.25mm").module(dpi)).toList());
|
||||
assertEquals(List.of(3, 5, 9), Scene2.RESOLUTIONS.stream().map(dpi -> Length.parse("0.375mm").module(dpi)).toList());
|
||||
assertEquals(1, Length.parse("1dot").module(203));
|
||||
assertEquals(1, Length.parse("0.001mm").module(203));
|
||||
assertEquals("LENGTH_NOT_ABSOLUTE", assertThrows(IllegalArgumentException.class, () -> Length.parse("5%").module(203)).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A font size is not rounded: points times dpi over 72.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aFontSizeScalesContinuously()
|
||||
{
|
||||
assertEquals(25.375, Length.parse("9pt").scale(203), 1e-9);
|
||||
assertEquals(3000 * 300 / 25_400.0, Length.parse("3mm").scale(300), 1e-9);
|
||||
assertEquals(300.0, Length.parse("0.5in").scale(600), 1e-9);
|
||||
assertEquals(12.0, Length.parse("12dot").scale(203), 1e-9);
|
||||
assertEquals("LENGTH_NOT_ABSOLUTE", assertThrows(IllegalArgumentException.class, () -> Length.parse("5%").scale(203)).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The three roundings on one value, 2.5 dots: nearest goes away from zero,
|
||||
** down toward it and up away from it, with the sign restored after.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void roundingIsOfTheMagnitude()
|
||||
{
|
||||
Length half = new Length(Length.Unit.PERCENT, 50_000);
|
||||
assertEquals(3, half.dots(203, 5, Length.Rounding.NEAREST));
|
||||
assertEquals(2, half.dots(203, 5, Length.Rounding.DOWN));
|
||||
assertEquals(3, half.dots(203, 5, Length.Rounding.UP));
|
||||
Length negative = new Length(Length.Unit.PERCENT, -50_000);
|
||||
assertEquals(-3, negative.dots(203, 5, Length.Rounding.NEAREST));
|
||||
assertEquals(-2, negative.dots(203, 5, Length.Rounding.DOWN));
|
||||
assertEquals(-3, negative.dots(203, 5, Length.Rounding.UP));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,947 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.SplittableRandom;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.renderer.RendererMain;
|
||||
import com.kingsrook.qbits.renderer.Wire;
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version 2 scenes through the worker's entry point, on the example stock of
|
||||
** the design (4 x 1 in, 3 mm margins: 764 x 155 printable dots at 203 dpi).
|
||||
** Every refusal is asserted as the whole diagnostic the wire carries: code,
|
||||
** element id, pointer, box and numbers.
|
||||
*******************************************************************************/
|
||||
class Scene2Test
|
||||
{
|
||||
private static final String BOX = "{\"id\":\"a\",\"type\":\"box\",\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1pt\"}";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Validate a scene on the example stock at 203 dpi and return its refusal.
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> refused(String source)
|
||||
{
|
||||
return (SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request("VALIDATE", source))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Validate a scene holding these elements and return its refusal.
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> refusedElements(String elements)
|
||||
{
|
||||
return (refused(SceneFixture.scene(elements)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The diagnostic of a place in the scene that is not what the schema says.
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> invalid(String pointer)
|
||||
{
|
||||
return (Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", pointer));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same, inside a declared element.
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> invalid(String element, String pointer)
|
||||
{
|
||||
return (Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", element, "pointer", pointer));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The diagnostic of an object with properties the schema does not declare.
|
||||
***************************************************************************/
|
||||
private static Map<String, Object> unknown(String pointer, int count)
|
||||
{
|
||||
return (Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", pointer, "measured", Map.of("unknownProperties", count), "limit", Map.of("unknownProperties", 0)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The diagnostics of a request that is accepted.
|
||||
***************************************************************************/
|
||||
private static Object accepted(JsonNode request) throws Exception
|
||||
{
|
||||
Map<String, Object> answer = RendererMain.process(request);
|
||||
assertEquals("OK", answer.get("outcome"));
|
||||
return (answer.get("diagnostics"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The ink of a page: left, top, right and bottom, the last two exclusive.
|
||||
***************************************************************************/
|
||||
private static List<Integer> ink(boolean[][] page)
|
||||
{
|
||||
int left = Integer.MAX_VALUE;
|
||||
int top = Integer.MAX_VALUE;
|
||||
int right = -1;
|
||||
int bottom = -1;
|
||||
for(int y = 0; y < page.length; y++)
|
||||
{
|
||||
for(int x = 0; x < page[y].length; x++)
|
||||
{
|
||||
if(page[y][x])
|
||||
{
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x + 1);
|
||||
bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (List.of(left, top, right, bottom));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A scene with boxes, a group, defaults, samples and an override validates
|
||||
** with no findings, and an empty scene is a blank label.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aSceneOfBoxesAndGroupsIsAccepted() throws Exception
|
||||
{
|
||||
String source = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"defaults\":{\"font\":\"qqq-sans-bold\",\"size\":\"9pt\"},"
|
||||
+ "\"samples\":{\"typical\":{\"record\":{\"name\":\"TEST-0001\"}},\"longest\":{}},"
|
||||
+ "\"elements\":[{\"id\":\"frame\",\"type\":\"box\",\"thickness\":\"0.5pt\"},"
|
||||
+ "{\"id\":\"panel\",\"type\":\"group\",\"anchor\":\"center\",\"x\":\"-2mm\",\"width\":\"50%\",\"height\":\"50%\",\"align\":\"center\",\"valign\":\"middle\",\"children\":["
|
||||
+ "{\"id\":\"mark\",\"type\":\"box\",\"anchor\":\"bottom-right\",\"width\":\"25%\",\"height\":\"25%\",\"fill\":true}]}],"
|
||||
+ "\"overrides\":[{\"when\":{\"dpi\":[203,600]},\"set\":{\"frame\":{\"thickness\":\"1dot\"}}}]}";
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", source)));
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("RENDER", source)));
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", SceneFixture.scene(""))));
|
||||
assertEquals("^XA^PW812^LL203^LH0,0^CI28\n^XZ\n", new String(SceneFixture.render(SceneFixture.request("RENDER", SceneFixture.scene(""))), java.nio.charset.StandardCharsets.US_ASCII));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A box prints where its anchor and lengths put it, inside the margins: the
|
||||
** printable area starts 24 dots in at 203 dpi.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aBoxPrintsWhereItsAnchorPutsIt() throws Exception
|
||||
{
|
||||
boolean[][] page = SceneFixture.raster(SceneFixture.render(SceneFixture.request("RENDER", SceneFixture.scene("{\"id\":\"a\",\"type\":\"box\",\"anchor\":\"bottom-right\",\"x\":\"2mm\",\"y\":\"1mm\","
|
||||
+ "\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1pt\"}"))));
|
||||
assertEquals(203, page.length);
|
||||
assertEquals(812, page[0].length);
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 10 mm is 79 dots and 5 mm is 39 dots (down); 2 mm is 16 dots and 1 mm is 8 dots (nearest) //
|
||||
// right edge 24 + 764 - 16 = 772, bottom edge 24 + 155 - 8 = 171; 1 pt is 3 dots //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of(693, 132, 772, 171), ink(page));
|
||||
assertTrue(page[132][693] && page[134][700] && page[170][771] && page[150][695] && page[150][769]);
|
||||
assertFalse(page[135][700] || page[150][696] || page[150][768] || page[167][700]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One design, three resolutions: the same scene at each render target, with
|
||||
** the page and the ink scaled and nothing in the scene changed.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void oneDesignRendersAtEveryTarget() throws Exception
|
||||
{
|
||||
String source = SceneFixture.scene("{\"id\":\"a\",\"type\":\"box\",\"x\":\"10mm\",\"y\":\"5mm\",\"width\":\"20mm\",\"height\":\"10mm\",\"fill\":true}");
|
||||
List<List<Integer>> expected = List.of(List.of(812, 203, 104, 64, 263, 143), List.of(1200, 300, 154, 95, 390, 213), List.of(2400, 600, 307, 189, 779, 425));
|
||||
for(int index = 0; index < 3; index++)
|
||||
{
|
||||
ObjectNode request = SceneFixture.request("RENDER", source).put("targetDpi", Scene2.RESOLUTIONS.get(index));
|
||||
boolean[][] page = SceneFixture.raster(SceneFixture.render(request));
|
||||
List<Integer> actual = new ArrayList<>(List.of(page[0].length, page.length));
|
||||
actual.addAll(ink(page));
|
||||
assertEquals(expected.get(index), actual, "target " + Scene2.RESOLUTIONS.get(index));
|
||||
}
|
||||
ObjectNode native300 = SceneFixture.request("RENDER", SceneFixture.stock(SceneFixture.WIDTH, SceneFixture.HEIGHT, SceneFixture.MARGIN, 300), source);
|
||||
assertEquals(Wire.sha256(SceneFixture.render(SceneFixture.request("RENDER", source).put("targetDpi", 300))), Wire.sha256(SceneFixture.render(native300)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The top level: an object with the declared properties and nothing else.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theTopLevelIsHeldToTheSchema() throws Exception
|
||||
{
|
||||
Diagnostics diagnostics = new Diagnostics(false);
|
||||
assertEquals(invalid(""), SceneFixture.refusal(() -> Scene2.validate(SceneFixture.input(SceneFixture.request("VALIDATE", "[]")), Wire.JSON.readTree("[]"), diagnostics)));
|
||||
assertEquals(invalid("/schema"), SceneFixture.refusal(() -> Scene2.validate(SceneFixture.input(SceneFixture.request("VALIDATE", "{}")), Wire.JSON.readTree("{\"schema\":\"qqq-zpl-scene/3\"}"), diagnostics)));
|
||||
assertEquals(unknown("", 2), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[],\"title\":\"TEST-0001\",\"units\":\"mm\"}"));
|
||||
assertEquals(invalid("/design"), refused("{\"schema\":\"qqq-zpl-scene/2\",\"elements\":[]}"));
|
||||
assertEquals(invalid("/design"), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":\"4in\",\"elements\":[]}"));
|
||||
assertEquals(unknown("/design", 1), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\",\"stock\":\"atLeast\"},\"elements\":[]}"));
|
||||
assertEquals(invalid("/design/width"), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"height\":\"1in\"},\"elements\":[]}"));
|
||||
for(String width : List.of("\"50%\"", "\"812dot\"", "\"0in\"", "\"-4in\"", "\"4\"", "4", "null", "[\"4in\"]"))
|
||||
{
|
||||
assertEquals(invalid("/design/width"), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":" + width + ",\"height\":\"1in\"},\"elements\":[]}"), width);
|
||||
}
|
||||
assertEquals(invalid("/design/height"), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"0mm\"},\"elements\":[]}"));
|
||||
assertEquals(invalid("/elements"), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"}}"));
|
||||
assertEquals(invalid("/elements"), refused("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":{}}"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Defaults and samples are accepted in their declared shape only. Samples
|
||||
** are data for a preview and are ignored by rendering.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void defaultsAndSamplesAreHeldToTheirShape() throws Exception
|
||||
{
|
||||
String head = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[],";
|
||||
for(String defaults : List.of("{}", "{\"font\":\"qqq-mono\"}", "{\"size\":\"3mm\"}", "{\"font\":\"Brand_Sans.Bold-2\",\"size\":\"0.125in\"}"))
|
||||
{
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", head + "\"defaults\":" + defaults + ",\"samples\":{}}")), defaults);
|
||||
}
|
||||
assertEquals(invalid("/defaults"), refused(head + "\"defaults\":[]}"));
|
||||
assertEquals(unknown("/defaults", 1), refused(head + "\"defaults\":{\"colour\":\"black\"}}"));
|
||||
assertEquals(invalid("/defaults/font"), refused(head + "\"defaults\":{\"font\":12}}"));
|
||||
assertEquals(invalid("/defaults/font"), refused(head + "\"defaults\":{\"font\":\"Jane Example\"}}"));
|
||||
assertEquals(invalid("/defaults/size"), refused(head + "\"defaults\":{\"size\":\"9dot\"}}"));
|
||||
assertEquals(invalid("/defaults/size"), refused(head + "\"defaults\":{\"size\":\"0pt\"}}"));
|
||||
assertEquals(invalid("/samples"), refused(head + "\"samples\":[]}"));
|
||||
assertEquals(invalid("/samples"), refused(head + "\"samples\":{\"typical\":\"TEST-0001\"}}"));
|
||||
StringBuilder samples = new StringBuilder();
|
||||
for(int index = 0; index < 17; index++)
|
||||
{
|
||||
samples.append(index == 0 ? "" : ",").append("\"s").append(index).append("\":{}");
|
||||
}
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", "/samples", "measured", Map.of("samples", 17), "limit", Map.of("samples", 16)), refused(head + "\"samples\":{" + samples + "}}"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every element declares a unique id of the fixed pattern and a known type,
|
||||
** before anything else about it is read.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyElementDeclaresAnIdAndAType()
|
||||
{
|
||||
assertEquals(invalid("/elements/0"), refusedElements("\"box\""));
|
||||
assertEquals(invalid("/elements/0/id"), refusedElements("{\"type\":\"box\",\"thickness\":\"1pt\"}"));
|
||||
for(String id : List.of("\"\"", "\"9a\"", "\"two words\"", "\"" + "a".repeat(33) + "\"", "12", "null", "\"Jane Example, 1 Test Street\""))
|
||||
{
|
||||
assertEquals(invalid("/elements/0/id"), refusedElements("{\"id\":" + id + ",\"type\":\"box\",\"thickness\":\"1pt\"}"), id);
|
||||
}
|
||||
assertEquals(invalid("/elements/1/id"), refusedElements(BOX + "," + BOX));
|
||||
assertEquals(invalid("/elements/1/children/0/id"), refusedElements(BOX + ",{\"id\":\"g\",\"type\":\"group\",\"children\":[" + BOX + "]}"));
|
||||
assertEquals(invalid("a", "/elements/0/type"), refusedElements("{\"id\":\"a\"}"));
|
||||
assertEquals(invalid("a", "/elements/0/type"), refusedElements("{\"id\":\"a\",\"type\":\"circle\"}"));
|
||||
assertEquals(invalid("a", "/elements/0/type"), refusedElements("{\"id\":\"a\",\"type\":7}"));
|
||||
assertEquals(invalid("/elements/0/children"), refusedElements("{\"id\":\"g\",\"type\":\"group\"}"));
|
||||
assertEquals(invalid("/elements/0/children"), refusedElements("{\"id\":\"g\",\"type\":\"group\",\"children\":{}}"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The limits: 1000 elements in all, and groups four deep.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void elementsAndDepthAreLimited() throws Exception
|
||||
{
|
||||
StringBuilder many = new StringBuilder();
|
||||
for(int index = 0; index < 999; index++)
|
||||
{
|
||||
many.append("{\"id\":\"b").append(index).append("\",\"type\":\"box\",\"width\":\"2mm\",\"height\":\"2mm\",\"fill\":true},");
|
||||
}
|
||||
String group = "{\"id\":\"g\",\"type\":\"group\",\"children\":[%s]}";
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", SceneFixture.scene(many + String.format(group, "")))));
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", "/elements", "measured", Map.of("elements", 1001), "limit", Map.of("elements", 1000)),
|
||||
refusedElements(many + String.format(group, "{\"id\":\"last\",\"type\":\"box\",\"fill\":true}")));
|
||||
|
||||
String nested = "{\"id\":\"g1\",\"type\":\"group\",\"children\":[{\"id\":\"g2\",\"type\":\"group\",\"children\":[{\"id\":\"g3\",\"type\":\"group\",\"children\":[{\"id\":\"g4\",\"type\":\"group\",\"children\":[%s]}]}]}]}";
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", SceneFixture.scene(String.format(nested, BOX)))));
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "g5", "pointer", "/elements/0/children/0/children/0/children/0/children/0", "measured", Map.of("depth", 5), "limit", Map.of("depth", 4)),
|
||||
refusedElements(String.format(nested, "{\"id\":\"g5\",\"type\":\"group\",\"children\":[]}")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The common placement properties: a word of the fixed set, a length in a
|
||||
** known unit, a negative offset only about the middle, a size above nothing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void placementPropertiesAreHeldToTheSchema() throws Exception
|
||||
{
|
||||
String box = "{\"id\":\"a\",\"type\":\"box\",\"thickness\":\"1pt\",\"width\":\"10mm\",\"height\":\"5mm\",%s}";
|
||||
for(String anchor : List.of("\"centre\"", "\"TOP-LEFT\"", "5", "null"))
|
||||
{
|
||||
assertEquals(invalid("a", "/elements/0/anchor"), refusedElements(String.format(box, "\"anchor\":" + anchor)), anchor);
|
||||
}
|
||||
for(String x : List.of("\"5\"", "5", "\"5px\"", "\"-1mm\"", "\"-1%\"", "\"1.2345mm\"", "true"))
|
||||
{
|
||||
assertEquals(invalid("a", "/elements/0/x"), refusedElements(String.format(box, "\"x\":" + x)), x);
|
||||
assertEquals(invalid("a", "/elements/0/y"), refusedElements(String.format(box, "\"anchor\":\"top-center\",\"y\":" + x)), x);
|
||||
}
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", SceneFixture.scene(String.format(box, "\"anchor\":\"center\",\"x\":\"-1mm\",\"y\":\"-10%\"")))));
|
||||
assertEquals(invalid("a", "/elements/0/x"), refusedElements(String.format(box, "\"anchor\":\"middle-right\",\"x\":\"-1mm\",\"y\":\"-1mm\"")));
|
||||
assertEquals(invalid("a", "/elements/0/y"), refusedElements(String.format(box, "\"anchor\":\"bottom-center\",\"x\":\"-1mm\",\"y\":\"-1mm\"")));
|
||||
for(String size : List.of("\"0mm\"", "\"-1mm\"", "12", "\"12\"", "\"0%\""))
|
||||
{
|
||||
assertEquals(invalid("a", "/elements/0/width"), refusedElements("{\"id\":\"a\",\"type\":\"box\",\"thickness\":\"1pt\",\"width\":" + size + "}"), size);
|
||||
assertEquals(invalid("a", "/elements/0/height"), refusedElements("{\"id\":\"a\",\"type\":\"box\",\"thickness\":\"1pt\",\"height\":" + size + "}"), size);
|
||||
}
|
||||
assertEquals(invalid("a", "/elements/0/align"), refusedElements(String.format(box, "\"align\":\"middle\"")));
|
||||
assertEquals(invalid("a", "/elements/0/valign"), refusedElements(String.format(box, "\"valign\":\"center\"")));
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", SceneFixture.scene(String.format(box, "\"align\":\"right\",\"valign\":\"bottom\"")))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A property the element's type does not read is counted and refused, and a
|
||||
** box needs a thickness above nothing unless it is filled.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aBoxReadsItsThicknessAndFillAndNothingElse() throws Exception
|
||||
{
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "a", "pointer", "/elements/0", "measured", Map.of("unknownProperties", 2), "limit", Map.of("unknownProperties", 0)),
|
||||
refusedElements("{\"id\":\"a\",\"type\":\"box\",\"thickness\":\"1pt\",\"colour\":\"red\",\"rotation\":90}"));
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "g", "pointer", "/elements/0", "measured", Map.of("unknownProperties", 1), "limit", Map.of("unknownProperties", 0)),
|
||||
refusedElements("{\"id\":\"g\",\"type\":\"group\",\"children\":[],\"thickness\":\"1pt\"}"));
|
||||
assertEquals(invalid("a", "/elements/0/thickness"), refusedElements("{\"id\":\"a\",\"type\":\"box\"}"));
|
||||
assertEquals(invalid("a", "/elements/0/thickness"), refusedElements("{\"id\":\"a\",\"type\":\"box\",\"fill\":false}"));
|
||||
for(String thickness : List.of("\"5%\"", "\"0pt\"", "\"-1pt\"", "1", "\"thin\""))
|
||||
{
|
||||
assertEquals(invalid("a", "/elements/0/thickness"), refusedElements("{\"id\":\"a\",\"type\":\"box\",\"thickness\":" + thickness + "}"), thickness);
|
||||
}
|
||||
assertEquals(invalid("a", "/elements/0/fill"), refusedElements("{\"id\":\"a\",\"type\":\"box\",\"fill\":\"yes\"}"));
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", SceneFixture.scene("{\"id\":\"a\",\"type\":\"box\",\"fill\":true},{\"id\":\"b\",\"type\":\"box\",\"fill\":true,\"thickness\":\"1pt\"}"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An override names resolutions and declared elements, cannot change what an
|
||||
** element is, and its values are held to the schema for every resolution,
|
||||
** not only the one being rendered.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void overridesAreHeldToTheSchemaForEveryResolution() throws Exception
|
||||
{
|
||||
String head = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[" + BOX + "],\"overrides\":";
|
||||
assertEquals(invalid("/overrides"), refused(head + "{}}"));
|
||||
assertEquals(invalid("/overrides/0"), refused(head + "[7]}"));
|
||||
assertEquals(unknown("/overrides/0", 1), refused(head + "[{\"when\":{\"dpi\":[203]},\"set\":{\"a\":{\"fill\":true}},\"note\":\"x\"}]}"));
|
||||
assertEquals(invalid("/overrides/0/when"), refused(head + "[{\"set\":{\"a\":{\"fill\":true}}}]}"));
|
||||
assertEquals(unknown("/overrides/0/when", 1), refused(head + "[{\"when\":{\"dpi\":[203],\"model\":\"ZD421\"},\"set\":{\"a\":{\"fill\":true}}}]}"));
|
||||
for(String dpi : List.of("203", "[]", "[72]", "[203,203]", "[\"203\"]", "[203.5]", "[4294967499]", "[203,300,600,203]"))
|
||||
{
|
||||
assertEquals(invalid("/overrides/0/when/dpi"), refused(head + "[{\"when\":{\"dpi\":" + dpi + "},\"set\":{\"a\":{\"fill\":true}}}]}"), dpi);
|
||||
}
|
||||
assertEquals(invalid("/overrides/0/when/dpi"), refused(head + "[{\"when\":{},\"set\":{\"a\":{\"fill\":true}}}]}"));
|
||||
assertEquals(invalid("/overrides/0/set"), refused(head + "[{\"when\":{\"dpi\":[203]}}]}"));
|
||||
assertEquals(invalid("/overrides/0/set"), refused(head + "[{\"when\":{\"dpi\":[203]},\"set\":{}}]}"));
|
||||
assertEquals(invalid("/overrides/0/set"), refused(head + "[{\"when\":{\"dpi\":[203]},\"set\":{\"TEST-0001 Jane Example\":{\"fill\":true}}}]}"));
|
||||
for(String replacement : List.of("true", "{}", "{\"id\":\"b\"}", "{\"type\":\"group\"}", "{\"children\":[]}"))
|
||||
{
|
||||
assertEquals(invalid("a", "/overrides/1/set/a"), refused(head + "[{\"when\":{\"dpi\":[300]},\"set\":{\"a\":{\"fill\":true}}},{\"when\":{\"dpi\":[203]},\"set\":{\"a\":" + replacement + "}}]}"), replacement);
|
||||
}
|
||||
StringBuilder many = new StringBuilder();
|
||||
for(int index = 0; index < 33; index++)
|
||||
{
|
||||
many.append(index == 0 ? "" : ",").append("{\"when\":{\"dpi\":[203]},\"set\":{\"a\":{\"fill\":true}}}");
|
||||
}
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "pointer", "/overrides", "measured", Map.of("overrides", 33), "limit", Map.of("overrides", 32)), refused(head + "[" + many + "]}"));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the request renders at 203 dpi; the mistake is in what a 600 dpi printer would be given //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(invalid("a", "/overrides/0/set/a/thickness"), refused(head + "[{\"when\":{\"dpi\":[600]},\"set\":{\"a\":{\"thickness\":\"thin\"}}}]}"));
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "a", "pointer", "/elements/0", "measured", Map.of("unknownProperties", 1), "limit", Map.of("unknownProperties", 0)),
|
||||
refused(head + "[{\"when\":{\"dpi\":[300]},\"set\":{\"a\":{\"colour\":\"red\"}}}]}"));
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", head + "[{\"when\":{\"dpi\":[203]},\"set\":{\"a\":{\"thickness\":\"2dot\"}}},{\"when\":{\"dpi\":[300,600]},\"set\":{\"a\":{\"fill\":true}}}]}")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An override changes the element at its resolutions only, later entries
|
||||
** winning: a 3 dot outline at 203 dpi, a filled box at 600 dpi.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anOverrideAppliesAtItsResolutionsOnly() throws Exception
|
||||
{
|
||||
String source = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[{\"id\":\"a\",\"type\":\"box\",\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1dot\"}],"
|
||||
+ "\"overrides\":[{\"when\":{\"dpi\":[203,600]},\"set\":{\"a\":{\"thickness\":\"9dot\"}}},{\"when\":{\"dpi\":[203]},\"set\":{\"a\":{\"thickness\":\"3dot\"}}},{\"when\":{\"dpi\":[600]},\"set\":{\"a\":{\"fill\":true}}}]}";
|
||||
boolean[][] coarse = SceneFixture.raster(SceneFixture.render(SceneFixture.request("RENDER", source)));
|
||||
assertTrue(coarse[24 + 2][24 + 40] && !coarse[24 + 3][24 + 40] && coarse[24 + 20][24 + 2] && !coarse[24 + 20][24 + 3]);
|
||||
boolean[][] middle = SceneFixture.raster(SceneFixture.render(SceneFixture.request("RENDER", source).put("targetDpi", 300)));
|
||||
assertTrue(middle[36][36 + 60] && !middle[37][36 + 60]);
|
||||
boolean[][] fine = SceneFixture.raster(SceneFixture.render(SceneFixture.request("RENDER", source).put("targetDpi", 600)));
|
||||
assertTrue(fine[71 + 59][71 + 118] && fine[71][71] && fine[71 + 117][71 + 235] && !fine[71 + 118][71 + 235] && !fine[71 + 117][71 + 236]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A `dot` length outside an override depends on the printer, so the author
|
||||
** is warned once for each place; inside an override it is the intended use.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aDeviceUnitOutsideAnOverrideIsWarnedAbout() throws Exception
|
||||
{
|
||||
String source = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[{\"id\":\"a\",\"type\":\"box\",\"x\":\"5dot\",\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1dot\"},"
|
||||
+ "{\"id\":\"b\",\"type\":\"box\",\"y\":\"8mm\",\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1pt\"}],\"overrides\":[{\"when\":{\"dpi\":[203]},\"set\":{\"b\":{\"thickness\":\"1dot\"}}}]}";
|
||||
List<Map<String, Object>> expected = List.of(Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "a", "pointer", "/elements/0/x"),
|
||||
Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "a", "pointer", "/elements/0/thickness"));
|
||||
assertEquals(expected, accepted(SceneFixture.request("VALIDATE", source)));
|
||||
assertEquals(expected, accepted(SceneFixture.request("RENDER", source)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The stock must be the one the design was drawn for, within half a
|
||||
** millimetre on each side; the refusal reports both sizes.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theStockMustMatchTheDesign() throws Exception
|
||||
{
|
||||
String scene = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"%s\",\"height\":\"%s\"},\"elements\":[]}";
|
||||
assertEquals(Map.of("code", "STOCK_MISMATCH", "severity", "ERROR", "pointer", "/design", "measured", Map.of("widthMicrometres", 101_600, "heightMicrometres", 25_400),
|
||||
"limit", Map.of("widthMicrometres", 101_600, "heightMicrometres", 50_800, "toleranceMicrometres", 500)), refused(String.format(scene, "4in", "2in")));
|
||||
assertEquals(Map.of("code", "STOCK_MISMATCH", "severity", "ERROR", "pointer", "/design", "measured", Map.of("widthMicrometres", 101_600, "heightMicrometres", 25_400),
|
||||
"limit", Map.of("widthMicrometres", 102_101, "heightMicrometres", 25_400, "toleranceMicrometres", 500)), refused(String.format(scene, "102.101mm", "25.4mm")));
|
||||
for(List<String> design : List.of(List.of("101.5mm", "25.4mm"), List.of("102.1mm", "24.9mm"), List.of("101.1mm", "25.9mm"), List.of("288pt", "72pt")))
|
||||
{
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", String.format(scene, design.get(0), design.get(1)))), design.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A box that leaves the printable area is refused with its box and the four
|
||||
** edges against the area's, and one that resolves to no dots at this
|
||||
** resolution is refused with its size rather than vanishing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aBoxMustLieInsideThePrintableAreaAndHaveSize() throws Exception
|
||||
{
|
||||
assertEquals(Map.of("code", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "severity", "ERROR", "element", "wide", "box", List.of(719, 0, 79, 39),
|
||||
"measured", Map.of("leftDots", 719, "topDots", 0, "rightDots", 798, "bottomDots", 39), "limit", Map.of("leftDots", 0, "topDots", 0, "rightDots", 764, "bottomDots", 155)),
|
||||
refusedElements("{\"id\":\"wide\",\"type\":\"box\",\"x\":\"90mm\",\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1pt\"}"));
|
||||
assertEquals(Map.of("code", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "severity", "ERROR", "element", "tall", "box", List.of(342, -22, 79, 199),
|
||||
"measured", Map.of("leftDots", 342, "topDots", -22, "rightDots", 421, "bottomDots", 177), "limit", Map.of("leftDots", 0, "topDots", 0, "rightDots", 764, "bottomDots", 155)),
|
||||
refusedElements("{\"id\":\"tall\",\"type\":\"box\",\"anchor\":\"center\",\"width\":\"10mm\",\"height\":\"25mm\",\"fill\":true}"));
|
||||
assertEquals(Map.of("code", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "severity", "ERROR", "element", "inner", "box", List.of(700, 100, 79, 79),
|
||||
"measured", Map.of("leftDots", 700, "topDots", 100, "rightDots", 779, "bottomDots", 179), "limit", Map.of("leftDots", 0, "topDots", 0, "rightDots", 764, "bottomDots", 155)),
|
||||
refusedElements("{\"id\":\"g\",\"type\":\"group\",\"anchor\":\"bottom-right\",\"width\":\"80dot\",\"height\":\"80dot\",\"children\":[{\"id\":\"inner\",\"type\":\"box\",\"x\":\"16dot\",\"y\":\"25dot\",\"width\":\"10mm\",\"height\":\"10mm\",\"fill\":true}]}"));
|
||||
|
||||
String hairline = SceneFixture.scene("{\"id\":\"thin\",\"type\":\"box\",\"width\":\"0.1mm\",\"height\":\"5mm\",\"fill\":true}");
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "thin", "pointer", "/elements/0", "measured", Map.of("widthDots", 0, "heightDots", 39), "limit", Map.of("widthDots", 1, "heightDots", 1)),
|
||||
refused(hairline));
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", hairline).put("targetDpi", 600)));
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "none", "pointer", "/elements/0", "measured", Map.of("widthDots", -36, "heightDots", 155), "limit", Map.of("widthDots", 1, "heightDots", 1)),
|
||||
refusedElements("{\"id\":\"none\",\"type\":\"box\",\"anchor\":\"top-center\",\"x\":\"50mm\",\"fill\":true}"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A line is a box whose height or width is its thickness, and it does not
|
||||
** vanish: 0.1 mm is less than a dot at 203 dpi and prints as one dot.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aThinLineDoesNotVanish() throws Exception
|
||||
{
|
||||
boolean[][] page = SceneFixture.raster(SceneFixture.render(SceneFixture.request("RENDER", SceneFixture.scene("{\"id\":\"rule\",\"type\":\"box\",\"anchor\":\"bottom-left\",\"width\":\"50%\",\"height\":\"0.1mm\",\"thickness\":\"0.1mm\"},"
|
||||
+ "{\"id\":\"post\",\"type\":\"box\",\"anchor\":\"top-right\",\"width\":\"1pt\",\"height\":\"50%\",\"thickness\":\"1pt\"}"))));
|
||||
assertEquals(List.of(24, 24, 788, 179), ink(page));
|
||||
assertTrue(page[178][24] && page[178][405] && !page[178][406] && !page[177][24]);
|
||||
assertTrue(page[24][785] && page[24][787] && !page[24][784] && page[100][786] && !page[101][786]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Barcodes and images are parsed and placed by this renderer and then
|
||||
** refused by name, with the box they would have had. The design's own
|
||||
** example scene is the case: its barcode is refused, and its caption, a text
|
||||
** bound to the record, is refused for the value the empty record lacks.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anElementThisRendererCannotDrawIsRefusedByName() throws Exception
|
||||
{
|
||||
String example = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"defaults\":{\"font\":\"qqq-sans-bold\",\"size\":\"9pt\"},"
|
||||
+ "\"samples\":{\"typical\":{\"record\":{\"humanReadable\":\"(01)00012345678905(10)L240611A\",\"rendererValue\":\"[01]00012345678905[10]L240611A\"}}},"
|
||||
+ "\"elements\":[{\"id\":\"code\",\"type\":\"barcode\",\"symbology\":\"gs1-128\",\"valuePath\":\"/record/rendererValue\",\"anchor\":\"top-center\",\"y\":\"0.5mm\",\"width\":\"94mm\",\"height\":\"11mm\","
|
||||
+ "\"align\":\"center\",\"valign\":\"middle\",\"minModule\":\"0.25mm\",\"quiet\":10},"
|
||||
+ "{\"id\":\"caption\",\"type\":\"text\",\"valuePath\":\"/record/humanReadable\",\"anchor\":\"bottom-center\",\"y\":\"0.5mm\",\"width\":\"94mm\",\"height\":\"6.5mm\","
|
||||
+ "\"align\":\"center\",\"valign\":\"middle\",\"size\":\"11pt\",\"maxLength\":48,\"fit\":{\"maxLines\":2,\"shrinkTo\":\"7pt\",\"overflow\":\"refuse\"}}],"
|
||||
+ "\"overrides\":[{\"when\":{\"dpi\":[203]},\"set\":{\"caption\":{\"threshold\":144}}}]}";
|
||||
Map<String, Object> code = Map.of("code", "RENDERER_TOO_OLD", "severity", "ERROR", "element", "code", "box", List.of(6, 4, 751, 87));
|
||||
Map<String, Object> caption = Map.of("code", "VALUE_MISSING", "severity", "ERROR", "element", "caption", "box", List.of(6, 100, 751, 51));
|
||||
assertEquals(code, refused(example));
|
||||
assertEquals(code, SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request("RENDER", example))));
|
||||
|
||||
Map<String, Object> answer = RendererMain.process(SceneFixture.request("RENDER", example).put("diagnostics", "COLLECT"));
|
||||
assertEquals("REFUSED", answer.get("outcome"));
|
||||
assertEquals(List.of(code, caption), answer.get("diagnostics"));
|
||||
assertFalse(answer.containsKey("base64"));
|
||||
|
||||
assertEquals(Map.of("code", "RENDERER_TOO_OLD", "severity", "ERROR", "element", "logo", "box", List.of(0, 0, 79, 79)),
|
||||
refusedElements("{\"id\":\"logo\",\"type\":\"image\",\"asset\":\"logo\",\"width\":\"10mm\",\"height\":\"10mm\",\"rotation\":90}"));
|
||||
for(String rotation : List.of("45", "\"90\"", "4294967386", "90.0", "-90"))
|
||||
{
|
||||
assertEquals(invalid("logo", "/elements/0/rotation"), refusedElements("{\"id\":\"logo\",\"type\":\"image\",\"asset\":\"logo\",\"rotation\":" + rotation + "}"), rotation);
|
||||
}
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "t", "pointer", "/elements/0", "measured", Map.of("unknownProperties", 1), "limit", Map.of("unknownProperties", 0)),
|
||||
refusedElements("{\"id\":\"t\",\"type\":\"text\",\"value\":\"TEST-0001\",\"font\":\"qqq-sans\",\"size\":\"9pt\",\"symbology\":\"qr\"}"));
|
||||
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "t", "pointer", "/elements/0", "measured", Map.of("widthDots", 0, "heightDots", 155), "limit", Map.of("widthDots", 1, "heightDots", 1)),
|
||||
refusedElements("{\"id\":\"t\",\"type\":\"text\",\"value\":\"TEST-0001\",\"font\":\"qqq-sans\",\"size\":\"9pt\",\"width\":\"0.1mm\"}"));
|
||||
|
||||
Diagnostics diagnostics = new Diagnostics(false);
|
||||
diagnostics.declare("t", "/elements/0");
|
||||
ElementSource source = new ElementSource("t", "/elements/0", Wire.JSON.readTree("{\"id\":\"t\",\"type\":\"text\",\"fit\":{\"maxLines\":2}}"), Map.of("threshold", Wire.JSON.readTree("144")), Map.of("threshold", "/overrides/0/set/t/threshold"),
|
||||
diagnostics);
|
||||
assertEquals("t", source.id());
|
||||
assertEquals("text", source.type());
|
||||
assertEquals(2, source.value("fit").get("maxLines").intValue());
|
||||
assertEquals(144, source.value("threshold").intValue());
|
||||
assertNull(source.value("size"));
|
||||
assertEquals(new Placement(Placement.Anchor.TOP_LEFT, Length.ZERO, Length.ZERO, null, null, Placement.Align.LEFT, Placement.Valign.TOP), source.placement());
|
||||
SceneElement placeholder = ElementFactory.FACTORIES.get("image").create(source);
|
||||
assertEquals("t", placeholder.id());
|
||||
assertEquals(List.of(), placeholder.children());
|
||||
assertEquals(Map.of("code", "RENDERER_TOO_OLD", "severity", "ERROR", "element", "t", "box", List.of(1, 2, 3, 4)),
|
||||
SceneFixture.refusal(() -> placeholder.paint(new Canvas(8, 8, 203), new Frame(1, 2, 3, 4), null, diagnostics)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Asked to collect, the renderer keeps every element's refusal and goes on
|
||||
** with the rest. The answer is refused and carries no label, whether the
|
||||
** request validated or rendered; the canvas holds the elements that had no
|
||||
** error.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void collectingKeepsEveryRefusalAndTheRestOfTheLabel() throws Exception
|
||||
{
|
||||
String source = SceneFixture.scene("{\"id\":\"good\",\"type\":\"box\",\"x\":\"5dot\",\"width\":\"16dot\",\"height\":\"4dot\",\"fill\":true},"
|
||||
+ "{\"id\":\"wide\",\"type\":\"box\",\"x\":\"90mm\",\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1pt\"},"
|
||||
+ "{\"id\":\"g\",\"type\":\"group\",\"width\":\"0.1mm\",\"children\":[{\"id\":\"unseen\",\"type\":\"barcode\",\"value\":\"TEST-0001\"}]},"
|
||||
+ "{\"id\":\"words\",\"type\":\"barcode\",\"value\":\"TEST-0001\",\"y\":\"10mm\",\"height\":\"5mm\"},"
|
||||
+ "{\"id\":\"also\",\"type\":\"box\",\"anchor\":\"bottom-left\",\"width\":\"8dot\",\"height\":\"2dot\",\"fill\":true}");
|
||||
List<Map<String, Object>> expected = List.of(
|
||||
Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "good", "pointer", "/elements/0/x"),
|
||||
Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "good", "pointer", "/elements/0/width"),
|
||||
Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "good", "pointer", "/elements/0/height"),
|
||||
Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "also", "pointer", "/elements/4/width"),
|
||||
Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "also", "pointer", "/elements/4/height"),
|
||||
Map.of("code", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "severity", "ERROR", "element", "wide", "box", List.of(719, 0, 79, 39),
|
||||
"measured", Map.of("leftDots", 719, "topDots", 0, "rightDots", 798, "bottomDots", 39), "limit", Map.of("leftDots", 0, "topDots", 0, "rightDots", 764, "bottomDots", 155)),
|
||||
Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "g", "pointer", "/elements/2", "measured", Map.of("widthDots", 0, "heightDots", 155), "limit", Map.of("widthDots", 1, "heightDots", 1)),
|
||||
Map.of("code", "RENDERER_TOO_OLD", "severity", "ERROR", "element", "words", "box", List.of(0, 80, 764, 39)));
|
||||
Map<String, Object> validated = RendererMain.process(SceneFixture.request("VALIDATE", source).put("diagnostics", "COLLECT"));
|
||||
assertEquals("REFUSED", validated.get("outcome"));
|
||||
assertEquals(expected, validated.get("diagnostics"));
|
||||
assertFalse(validated.containsKey("base64"));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a refused answer is the same for a render as for a validation: findings and no label, //
|
||||
// so a host that looks for `base64` without reading the outcome has nothing to print //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////
|
||||
ObjectNode request = SceneFixture.request("RENDER", source).put("diagnostics", "COLLECT");
|
||||
Map<String, Object> rendered = RendererMain.process(request);
|
||||
assertEquals("REFUSED", rendered.get("outcome"));
|
||||
assertEquals(expected, rendered.get("diagnostics"));
|
||||
assertEquals(validated.keySet(), rendered.keySet());
|
||||
assertEquals(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "diagnostics"), List.copyOf(rendered.keySet()));
|
||||
assertNull(Scene2.render(SceneFixture.input(request), Wire.JSON.readTree(source), new Diagnostics(true)));
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the canvas of the pass still holds the elements that had no error, for a preview: //
|
||||
// `good` at 5, 0 (16 by 4 dots) and `also` in the bottom left corner (8 by 2 dots) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////
|
||||
Canvas canvas = Scene2.compose(SceneFixture.input(request), Wire.JSON.readTree(source), new Diagnostics(true), true);
|
||||
for(int row = 0; row < canvas.height(); row++)
|
||||
{
|
||||
int first = row < 4 ? 5 : row >= 153 ? 0 : -1;
|
||||
int last = row < 4 ? 20 : row >= 153 ? 7 : -1;
|
||||
assertEquals(List.of(first, last), List.of(canvas.firstInk(row), canvas.lastInk(row)), "row " + row);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a refusal about the scene as a whole ends the pass: nothing is drawn, one finding //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
Map<String, Object> whole = RendererMain.process(SceneFixture.request("RENDER", "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"2in\"},\"elements\":[" + BOX + "]}").put("diagnostics", "COLLECT"));
|
||||
assertEquals("REFUSED", whole.get("outcome"));
|
||||
assertEquals(1, ((List<?>) whole.get("diagnostics")).size());
|
||||
for(String name : List.of("base64", "sha256", "length", "mediaType", "pageCount"))
|
||||
{
|
||||
assertFalse(whole.containsKey(name) || rendered.containsKey(name), name);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
// and a scene with warnings only is not refused: it keeps its label and findings //
|
||||
////////////////////////////////////////////////////////////////////////////////////
|
||||
Map<String, Object> warned = RendererMain.process(SceneFixture.request("RENDER", SceneFixture.scene("{\"id\":\"good\",\"type\":\"box\",\"width\":\"16dot\",\"height\":\"4mm\",\"fill\":true}")).put("diagnostics", "COLLECT"));
|
||||
assertEquals("OK", warned.get("outcome"));
|
||||
assertEquals(1, ((List<?>) warned.get("diagnostics")).size());
|
||||
assertTrue(Base64.getDecoder().decode((String) warned.get("base64")).length > 0);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A length too long for any stock is the scene's mistake wherever it is
|
||||
** written, however long: `SCENE_INVALID` at its own pointer, in the element
|
||||
** or in the override that states it, when validating and when rendering.
|
||||
** 84600 in and 99999 in do not fit an `int` of micrometres; they are refused
|
||||
** like 79 in, and a collecting pass answers with the finding.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aLengthTooLongForAnyStockIsRefusedAtItsPointer() throws Exception
|
||||
{
|
||||
String design = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"%s\",\"height\":\"%s\"},\"elements\":[]}";
|
||||
String override = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[" + BOX + "],\"overrides\":[{\"when\":{\"dpi\":[600]},\"set\":{\"a\":{\"width\":\"%s\"}}}]}";
|
||||
for(String length : List.of("79in", "84547in", "84600in", "99999in", "99999.999in", "2000.001mm", "99999.999mm", "5669.293pt", "99999.999pt"))
|
||||
{
|
||||
assertEquals(invalid("/design/width"), refused(String.format(design, length, "1in")), length);
|
||||
assertEquals(invalid("/design/height"), refused(String.format(design, "4in", length)), length);
|
||||
assertEquals(invalid("/defaults/size"), refused(String.format("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"defaults\":{\"size\":\"%s\"},\"elements\":[]}", length)), length);
|
||||
assertEquals(invalid("a", "/overrides/0/set/a/width"), refused(String.format(override, length)), length);
|
||||
for(String property : List.of("x", "y", "width", "height", "thickness"))
|
||||
{
|
||||
String source = SceneFixture.scene(BOX.replace("}", ",\"" + property + "\":\"" + length + "\"}").replaceFirst("\"" + property + "\":\"[0-9a-z]+\",", ""));
|
||||
assertEquals(invalid("a", "/elements/0/" + property), refused(source), source);
|
||||
assertEquals(invalid("a", "/elements/0/" + property), SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request("RENDER", source))), source);
|
||||
Map<String, Object> collected = RendererMain.process(SceneFixture.request("RENDER", source).put("diagnostics", "COLLECT"));
|
||||
assertEquals("REFUSED", collected.get("outcome"));
|
||||
assertEquals(List.of(invalid("a", "/elements/0/" + property)), collected.get("diagnostics"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The printable area is the stock less its margins rounded down to whole
|
||||
** dots, which is what a box of that width and height resolves to, so a
|
||||
** design that fills the printable area fits at every resolution. The case of
|
||||
** the review: 100 mm stock, 3 mm margins, a box 94 mm wide. The far edge is
|
||||
** floor(97 mm) less ceil(3 mm) at 300 dpi, 1109 dots, one fewer than the box.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aBoxOfExactlyThePrintableAreaFitsAtEveryResolution() throws Exception
|
||||
{
|
||||
String source = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"100mm\",\"height\":\"30mm\"},\"elements\":[{\"id\":\"all\",\"type\":\"box\",\"x\":\"0mm\",\"width\":\"94mm\",\"height\":\"24mm\",\"fill\":true}]}";
|
||||
List<Frame> expected = List.of(new Frame(24, 24, 751, 191), new Frame(36, 36, 1110, 283), new Frame(71, 71, 2220, 566));
|
||||
for(int index = 0; index < 3; index++)
|
||||
{
|
||||
ObjectNode request = SceneFixture.request("RENDER", SceneFixture.stock(100_000, 30_000, 3000, 203), source).put("targetDpi", Scene2.RESOLUTIONS.get(index));
|
||||
Frame printable = Scene2.Page.of(SceneFixture.input(request)).printable();
|
||||
assertEquals(expected.get(index), printable);
|
||||
assertEquals(List.of(printable.x(), printable.y(), printable.right(), printable.bottom()), ink(SceneFixture.raster(SceneFixture.render(request))));
|
||||
assertEquals(List.of(), accepted(request.put("operation", "VALIDATE")));
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// one dot more than the printable area is still refused, on either axis, at every target //
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
for(String larger : List.of(source.replace("94mm", "94.2mm"), source.replace("24mm", "24.2mm")))
|
||||
{
|
||||
assertEquals("ELEMENT_OUTSIDE_PRINTABLE_AREA", SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request("VALIDATE", SceneFixture.stock(100_000, 30_000, 3000, 203), larger).put("targetDpi", dpi))).get("code"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property, over seeded stocks with margins of any fraction of a dot (and
|
||||
** none) at every target: the printable area is as many whole dots as the
|
||||
** stock less its margins holds; it lies on the page; it starts at the left
|
||||
** and top margins rounded up unless the page ends first, and never a whole
|
||||
** dot inside any margin; and a box of exactly the printable width and
|
||||
** height is accepted and inks exactly the printable area.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void thePrintableAreaHoldsABoxOfItsOwnSizeOnAnyStock() throws Exception
|
||||
{
|
||||
SplittableRandom random = new SplittableRandom(20261113L);
|
||||
int moved = 0;
|
||||
for(int iteration = 0; iteration < 150; iteration++)
|
||||
{
|
||||
int width = 20_000 + random.nextInt(90_000);
|
||||
int height = 10_000 + random.nextInt(50_000);
|
||||
int left = random.nextInt(4) == 0 ? 0 : random.nextInt(6000);
|
||||
int right = random.nextInt(4) == 0 ? 0 : random.nextInt(6000);
|
||||
int top = random.nextInt(4) == 0 ? 0 : random.nextInt(4000);
|
||||
int bottom = random.nextInt(4) == 0 ? 0 : random.nextInt(4000);
|
||||
JsonNode stock = Wire.JSON.valueToTree(Map.of("widthMicrons", width, "heightMicrons", height, "topMicrons", top, "rightMicrons", right, "bottomMicrons", bottom, "leftMicrons", left, "dpi", 203));
|
||||
String source = String.format("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"%s\",\"height\":\"%s\"},\"elements\":[{\"id\":\"all\",\"type\":\"box\",\"width\":\"%s\",\"height\":\"%s\",\"fill\":true}]}",
|
||||
millimetres(width), millimetres(height), millimetres(width - left - right), millimetres(height - top - bottom));
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
ObjectNode request = SceneFixture.request(iteration % 3 == 0 ? "RENDER" : "VALIDATE", stock, source).put("targetDpi", dpi);
|
||||
String name = stock + " at " + dpi;
|
||||
Scene2.Page page = Scene2.Page.of(SceneFixture.input(request));
|
||||
Frame printable = page.printable();
|
||||
assertEquals((long) (width - left - right) * dpi / 25_400, printable.width(), name);
|
||||
assertEquals((long) (height - top - bottom) * dpi / 25_400, printable.height(), name);
|
||||
assertTrue(printable.x() >= 0 && printable.y() >= 0 && printable.right() <= page.width() && printable.bottom() <= page.height(), name);
|
||||
long ceilLeft = ((long) left * dpi + 25_399) / 25_400;
|
||||
long ceilTop = ((long) top * dpi + 25_399) / 25_400;
|
||||
assertTrue(printable.x() == ceilLeft || (printable.x() == ceilLeft - 1 && printable.right() == page.width()), name);
|
||||
assertTrue(printable.y() == ceilTop || (printable.y() == ceilTop - 1 && printable.bottom() == page.height()), name);
|
||||
moved += printable.x() == ceilLeft && printable.y() == ceilTop ? 0 : 1;
|
||||
assertTrue((long) printable.right() * 25_400 < (long) (width - right) * dpi + 25_400 && (long) printable.bottom() * 25_400 < (long) (height - bottom) * dpi + 25_400, name);
|
||||
if(iteration % 3 == 0)
|
||||
{
|
||||
assertEquals(List.of(printable.x(), printable.y(), printable.right(), printable.bottom()), ink(SceneFixture.raster(SceneFixture.render(request))), name);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertEquals(List.of(), accepted(request), name);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(moved > 0, "no stock in the campaign had a printable area that would have passed the page edge");
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the move, by hand: 50.5 mm with a 3 mm left margin and none on the right at 300 dpi is //
|
||||
// a page of 596 dots (596.46) and a printable area of 561 (47.5 mm, 561.02); 3 mm is //
|
||||
// 35.43 dots, and 36 + 561 would be one dot past the page //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
JsonNode edge = Wire.JSON.valueToTree(Map.of("widthMicrons", 50_500, "heightMicrons", 25_400, "topMicrons", 0, "rightMicrons", 0, "bottomMicrons", 0, "leftMicrons", 3000, "dpi", 300));
|
||||
Scene2.Page page = Scene2.Page.of(SceneFixture.input(SceneFixture.request("VALIDATE", edge, "{}")));
|
||||
assertEquals(596, page.width());
|
||||
assertEquals(new Frame(35, 0, 561, 300), page.printable());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Micrometres as a length in millimetres.
|
||||
***************************************************************************/
|
||||
private static String millimetres(int micrometres)
|
||||
{
|
||||
return (micrometres / 1000 + "." + String.format("%03d", micrometres % 1000) + "mm");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One scene may paint sixteen times its printable area, an overlap counted
|
||||
** each time. The element that would pass that is refused where it is, with
|
||||
** what the scene would have painted, when validating as when rendering; an
|
||||
** outline counts its bars, and a group counts nothing. So 1000 boxes that
|
||||
** each fill the label cost at most sixteen fills, not 1000.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aSceneMayPaintSixteenTimesItsPrintableAreaAndNoMore() throws Exception
|
||||
{
|
||||
String fill = "{\"id\":\"f%d\",\"type\":\"box\",\"fill\":true}";
|
||||
String outline = "{\"id\":\"o%d\",\"type\":\"box\",\"thickness\":\"1dot\"}";
|
||||
List<String> sixteen = new ArrayList<>();
|
||||
for(int index = 0; index < 16; index++)
|
||||
{
|
||||
sixteen.add(String.format(fill, index));
|
||||
sixteen.add("{\"id\":\"g" + index + "\",\"type\":\"group\",\"children\":[]}");
|
||||
}
|
||||
String most = SceneFixture.scene(String.join(",", sixteen));
|
||||
assertEquals(List.of(), accepted(SceneFixture.request("VALIDATE", most)));
|
||||
assertEquals(List.of(24, 24, 788, 179), ink(SceneFixture.raster(SceneFixture.render(SceneFixture.request("RENDER", most)))));
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// one dot more: a one-dot outline of the 764 by 155 printable area is 2 x (764 + 155) //
|
||||
// dots, 1.55% of it, and the percentage is rounded up //
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
Map<String, Object> outlined = Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "o0", "pointer", "/elements/32", "box", List.of(0, 0, 764, 155),
|
||||
"measured", Map.of("paintedPercent", 1602), "limit", Map.of("paintedPercent", 1600));
|
||||
assertEquals(outlined, refused(SceneFixture.scene(String.join(",", sixteen) + "," + String.format(outline, 0))));
|
||||
Map<String, Object> filled = Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "f16", "pointer", "/elements/32", "box", List.of(0, 0, 764, 155),
|
||||
"measured", Map.of("paintedPercent", 1700), "limit", Map.of("paintedPercent", 1600));
|
||||
String over = SceneFixture.scene(String.join(",", sixteen) + "," + String.format(fill, 16));
|
||||
assertEquals(filled, refused(over));
|
||||
assertEquals(filled, SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request("RENDER", over))));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// outlines are counted as their bars: 500 of the whole label are 7.8 times its area //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
List<String> outlines = new ArrayList<>();
|
||||
for(int index = 0; index < 500; index++)
|
||||
{
|
||||
outlines.add(String.format(outline, index));
|
||||
}
|
||||
assertEquals(500, ((List<?>) accepted(SceneFixture.request("RENDER", SceneFixture.scene(String.join(",", outlines))))).size());
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the worst scene the element limit allows, collected: fifteen fills and a small box are //
|
||||
// painted, and every later fill is refused where it is and costs nothing //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
List<String> thousand = new ArrayList<>();
|
||||
for(int index = 0; index < 999; index++)
|
||||
{
|
||||
thousand.add(String.format(fill, index));
|
||||
}
|
||||
thousand.add(15, "{\"id\":\"small\",\"type\":\"box\",\"width\":\"1mm\",\"height\":\"1mm\",\"fill\":true}");
|
||||
ObjectNode request = SceneFixture.request("RENDER", SceneFixture.stock(400_000, 400_000, 0, 203), "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"400mm\",\"height\":\"400mm\"},\"elements\":[" + String.join(",", thousand) + "]}")
|
||||
.put("targetDpi", 300).put("diagnostics", "COLLECT");
|
||||
Map<String, Object> worst = RendererMain.process(request);
|
||||
assertEquals("REFUSED", worst.get("outcome"));
|
||||
List<?> findings = (List<?>) worst.get("diagnostics");
|
||||
assertEquals(984, findings.size());
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "f15", "pointer", "/elements/16", "box", List.of(0, 0, 4724, 4724), "measured", Map.of("paintedPercent", 1601), "limit", Map.of("paintedPercent", 1600)),
|
||||
findings.get(0));
|
||||
assertEquals(findings, RendererMain.process(request.put("operation", "VALIDATE")).get("diagnostics"));
|
||||
|
||||
Canvas canvas = new Canvas(8, 8, 203);
|
||||
assertTrue(canvas.admit(1000));
|
||||
assertFalse(canvas.admit(25));
|
||||
assertEquals(1602, canvas.paintedPercent(25));
|
||||
assertTrue(canvas.admit(24));
|
||||
assertFalse(canvas.admit(1));
|
||||
assertEquals(1600, canvas.paintedPercent(0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A stock the printer language cannot address is the host's mistake, not the
|
||||
** scene's, and is refused as it is for version 1.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anUnaddressableStockIsRefusedAsForVersionOne()
|
||||
{
|
||||
String source = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[]}";
|
||||
for(ObjectNode stock : List.of(SceneFixture.stock(SceneFixture.WIDTH, SceneFixture.HEIGHT, SceneFixture.MARGIN, 150), SceneFixture.stock(2_000_000, 25_400, 0, 203), SceneFixture.stock(25_400, 2_000_000, 0, 600),
|
||||
SceneFixture.stock(400_000, 1_300_000, 0, 600), SceneFixture.stock(1000, 25_400, 499, 203), SceneFixture.stock(25_400, 1000, 499, 203)))
|
||||
{
|
||||
for(String operation : List.of("VALIDATE", "RENDER"))
|
||||
{
|
||||
IllegalArgumentException refusal = assertThrows(IllegalArgumentException.class, () -> RendererMain.process(SceneFixture.request(operation, stock, source)), stock.toString());
|
||||
assertEquals("UNSUPPORTED_ZPL_STOCK", refusal.getMessage());
|
||||
assertFalse(refusal instanceof Refusal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property, over seeded scenes of boxes and groups at every target: a scene
|
||||
** is accepted and then no ink is outside the printable area, or it is
|
||||
** refused and the refusal's measured values pass its limits.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void resolvedBoxesNeverLeaveThePrintableArea() throws Exception
|
||||
{
|
||||
SplittableRandom random = new SplittableRandom(20261112L);
|
||||
List<String> anchors = List.of("top-left", "top-center", "top-right", "middle-left", "center", "middle-right", "bottom-left", "bottom-center", "bottom-right");
|
||||
int accepted = 0;
|
||||
int outside = 0;
|
||||
int empty = 0;
|
||||
for(int iteration = 0; iteration < 600; iteration++)
|
||||
{
|
||||
int dpi = Scene2.RESOLUTIONS.get(random.nextInt(3));
|
||||
StringBuilder elements = new StringBuilder();
|
||||
for(int index = 0; index < 1 + random.nextInt(4); index++)
|
||||
{
|
||||
String anchor = anchors.get(random.nextInt(9));
|
||||
boolean centred = anchor.contains("center");
|
||||
String x = (centred && random.nextBoolean() ? "-" : "") + random.nextInt(30) + "." + random.nextInt(10) + "mm";
|
||||
String y = random.nextInt(8) + (random.nextBoolean() ? "mm" : "%");
|
||||
String size = random.nextInt(5) == 0 ? "" : ",\"width\":\"" + (random.nextBoolean() ? (1 + random.nextInt(60)) + "mm" : (1 + random.nextInt(100)) + "%") + "\",\"height\":\"" + random.nextInt(20) + "." + (1 + random.nextInt(9)) + "mm\"";
|
||||
String box = "\"type\":\"box\",\"anchor\":\"" + anchor + "\",\"x\":\"" + x + "\",\"y\":\"" + y + "\"" + size + ",\"fill\":" + random.nextBoolean() + ",\"thickness\":\"0.3mm\"";
|
||||
elements.append(index == 0 ? "" : ",");
|
||||
if(random.nextInt(3) == 0)
|
||||
{
|
||||
elements.append("{\"id\":\"g").append(index).append("\",\"type\":\"group\",\"anchor\":\"").append(anchors.get(random.nextInt(9))).append("\",\"width\":\"").append(20 + random.nextInt(80)).append("%\",\"height\":\"")
|
||||
.append(20 + random.nextInt(80)).append("%\",\"children\":[{\"id\":\"e").append(index).append("\",").append(box).append("}]}");
|
||||
}
|
||||
else
|
||||
{
|
||||
elements.append("{\"id\":\"e").append(index).append("\",").append(box).append("}");
|
||||
}
|
||||
}
|
||||
ObjectNode request = SceneFixture.request("RENDER", SceneFixture.scene(elements.toString())).put("targetDpi", dpi);
|
||||
try
|
||||
{
|
||||
boolean[][] page = SceneFixture.raster(SceneFixture.render(request));
|
||||
Scene2.Page area = Scene2.Page.of(SceneFixture.input(request));
|
||||
List<Integer> ink = ink(page);
|
||||
assertTrue(ink.get(0) >= area.printable().x() && ink.get(1) >= area.printable().y() && ink.get(2) <= area.printable().right() && ink.get(3) <= area.printable().bottom(), "ink in a margin: " + elements);
|
||||
accepted++;
|
||||
}
|
||||
catch(Refusal refusal)
|
||||
{
|
||||
Map<String, Integer> measured = refusal.diagnostic().measured();
|
||||
Map<String, Integer> limit = refusal.diagnostic().limit();
|
||||
if("ELEMENT_OUTSIDE_PRINTABLE_AREA".equals(refusal.getMessage()))
|
||||
{
|
||||
assertTrue(measured.get("leftDots") < limit.get("leftDots") || measured.get("topDots") < limit.get("topDots") || measured.get("rightDots") > limit.get("rightDots")
|
||||
|| measured.get("bottomDots") > limit.get("bottomDots"), "refused but inside: " + elements);
|
||||
assertEquals(Scene2.Page.of(SceneFixture.input(request)).printable().width(), limit.get("rightDots"));
|
||||
outside++;
|
||||
}
|
||||
else
|
||||
{
|
||||
assertEquals("SCENE_INVALID", refusal.getMessage(), elements.toString());
|
||||
assertTrue(measured.get("widthDots") < 1 || measured.get("heightDots") < 1, "refused but has size: " + elements);
|
||||
empty++;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(accepted > 50 && outside > 50 && empty > 5, accepted + " accepted, " + outside + " outside, " + empty + " without size");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
import com.kingsrook.qbits.renderer.RendererMain;
|
||||
import com.kingsrook.qbits.renderer.Wire;
|
||||
import org.junit.jupiter.api.function.Executable;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Shared test support for version 2 scenes: requests on a known stock, and a
|
||||
** reader that rebuilds the page bitmap from emitted ZPL, so tests look at what
|
||||
** a printer would be sent rather than at the renderer's own canvas.
|
||||
*******************************************************************************/
|
||||
public final class SceneFixture
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the stock of the design document's example: 4 x 1 in with 3 mm margins. At 203 dpi the //
|
||||
// page is 812 x 203 dots and the printable area is 764 x 155 dots starting at 24, 24 //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
public static final int WIDTH = 101_600;
|
||||
public static final int HEIGHT = 25_400;
|
||||
public static final int MARGIN = 3000;
|
||||
|
||||
private static final Pattern HEADER = Pattern.compile("\\^XA\\^PW([0-9]+)\\^LL([0-9]+)\\^LH0,0\\^CI28\n");
|
||||
private static final Pattern FIELD = Pattern.compile("\\^FO([0-9]+),([0-9]+)\\^GFA,([0-9]+),([0-9]+),([0-9]+),([0-9A-F]*)\\^FS\n");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private SceneFixture()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A stock with the same margin on every side.
|
||||
***************************************************************************/
|
||||
public static ObjectNode stock(int widthMicrons, int heightMicrons, int marginMicrons, int dpi)
|
||||
{
|
||||
return (Wire.JSON.valueToTree(Map.of("widthMicrons", widthMicrons, "heightMicrons", heightMicrons, "topMicrons", marginMicrons, "rightMicrons", marginMicrons,
|
||||
"bottomMicrons", marginMicrons, "leftMicrons", marginMicrons, "dpi", dpi)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One protocol request for a ZPL source on a stock.
|
||||
***************************************************************************/
|
||||
public static ObjectNode request(String operation, JsonNode stock, String source)
|
||||
{
|
||||
ObjectNode request = Wire.JSON.createObjectNode();
|
||||
request.put("protocol", Wire.PROTOCOL).put("requestId", "scene2-test").put("operation", operation).put("format", "ZPL").put("maximumBytes", 8 * 1024 * 1024).put("source", source);
|
||||
request.set("stock", stock);
|
||||
request.putObject("context");
|
||||
request.putArray("assets");
|
||||
return (request);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A request for a scene on the example stock at 203 dpi.
|
||||
***************************************************************************/
|
||||
public static ObjectNode request(String operation, String source)
|
||||
{
|
||||
return (request(operation, stock(WIDTH, HEIGHT, MARGIN, 203), source));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A version 2 scene for the example stock holding these elements.
|
||||
***************************************************************************/
|
||||
public static String scene(String elements)
|
||||
{
|
||||
return ("{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"elements\":[" + elements + "]}");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The validated input of a request, as the worker builds it.
|
||||
***************************************************************************/
|
||||
public static RenderInput input(JsonNode request) throws Exception
|
||||
{
|
||||
return (new RenderInput(request));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Run a request through the worker's entry point and return its label.
|
||||
***************************************************************************/
|
||||
public static byte[] render(JsonNode request) throws Exception
|
||||
{
|
||||
Map<String, Object> answer = RendererMain.process(request);
|
||||
assertEquals("OK", answer.get("outcome"));
|
||||
return (Base64.getDecoder().decode((String) answer.get("base64")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The one diagnostic of the refusal a request ends in, as the wire carries
|
||||
** it.
|
||||
***************************************************************************/
|
||||
public static Map<String, Object> refusal(Executable work)
|
||||
{
|
||||
return (assertThrows(Refusal.class, work).diagnostic().wire());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The page a printer would draw from a label: `[row][column]`, true for ink.
|
||||
** Only the header, uncompressed graphic fields and the end of the label are
|
||||
** understood, which is all a version 2 label may contain.
|
||||
***************************************************************************/
|
||||
public static boolean[][] raster(byte[] zpl)
|
||||
{
|
||||
String text = new String(zpl, StandardCharsets.US_ASCII);
|
||||
Matcher header = HEADER.matcher(text);
|
||||
assertTrue(header.lookingAt(), "label header");
|
||||
boolean[][] page = new boolean[Integer.parseInt(header.group(2))][Integer.parseInt(header.group(1))];
|
||||
int position = header.end();
|
||||
Matcher field = FIELD.matcher(text);
|
||||
while(field.find(position) && field.start() == position)
|
||||
{
|
||||
int x = Integer.parseInt(field.group(1));
|
||||
int y = Integer.parseInt(field.group(2));
|
||||
int stride = Integer.parseInt(field.group(5));
|
||||
String hex = field.group(6);
|
||||
assertEquals(field.group(3), field.group(4));
|
||||
assertEquals(Integer.parseInt(field.group(3)) * 2, hex.length());
|
||||
assertTrue(Integer.parseInt(field.group(3)) <= 99_999 && hex.length() % (stride * 2) == 0, "field size");
|
||||
for(int index = 0; index < hex.length() / 2; index++)
|
||||
{
|
||||
int value = Integer.parseInt(hex.substring(index * 2, index * 2 + 2), 16);
|
||||
for(int bit = 0; bit < 8; bit++)
|
||||
{
|
||||
if((value & (128 >> bit)) != 0)
|
||||
{
|
||||
page[y + index / stride][x + index % stride * 8 + bit] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
position = field.end();
|
||||
}
|
||||
assertEquals("^XZ\n", text.substring(position));
|
||||
return (page);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many graphic fields a label holds.
|
||||
***************************************************************************/
|
||||
public static int fields(byte[] zpl)
|
||||
{
|
||||
Matcher field = FIELD.matcher(new String(zpl, StandardCharsets.US_ASCII));
|
||||
int count = 0;
|
||||
while(field.find())
|
||||
{
|
||||
count++;
|
||||
}
|
||||
return (count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.kingsrook.qbits.renderer.Wire;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Golden labels for the layout cases in `scene2/cases`, at 203, 300 and 600
|
||||
** dpi. Each case has a picture (the page a printer would draw, compared dot
|
||||
** for dot) and the exact ZPL. A box is whole dots of pure ink, so both are
|
||||
** exact on every machine. Text is measured by integer arithmetic and drawn
|
||||
** by the Java runtime's rasteriser from the bundled faces only; the text
|
||||
** goldens were generated on Linux (Temurin 21.0.9, the CI image) and are the
|
||||
** same bytes on Windows (Temurin 21.0.12), and they are compared exactly on
|
||||
** both. A golden changes only with a reviewed change to the layout rules or
|
||||
** the runtime: what this build drew is left in `target/scene2-actual`. The
|
||||
** runtimes, and the FreeType each ships, are recorded beside the goldens
|
||||
** (`scene2/README.md`).
|
||||
*******************************************************************************/
|
||||
class SceneGoldenTest
|
||||
{
|
||||
private static final String ROOT = "/scene2/";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every case at every resolution.
|
||||
***************************************************************************/
|
||||
static List<Arguments> goldens() throws Exception
|
||||
{
|
||||
List<Arguments> goldens = new ArrayList<>();
|
||||
try(Stream<Path> files = Files.list(Path.of(SceneGoldenTest.class.getResource(ROOT + "cases").toURI())))
|
||||
{
|
||||
for(String name : files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".json")).map(name -> name.substring(0, name.length() - 5)).sorted().toList())
|
||||
{
|
||||
Scene2.RESOLUTIONS.forEach(dpi -> goldens.add(Arguments.of(name, dpi)));
|
||||
}
|
||||
}
|
||||
return (goldens);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The label of one case at one render target is the stored ZPL, and the page
|
||||
** rebuilt from it is the stored picture.
|
||||
***************************************************************************/
|
||||
@ParameterizedTest(name = "{0} at {1} dpi")
|
||||
@MethodSource("goldens")
|
||||
void rendersTheGoldenLabel(String name, int dpi) throws Exception
|
||||
{
|
||||
JsonNode definition = Wire.JSON.readTree(resource("cases/" + name + ".json"));
|
||||
ObjectNode request = SceneFixture.request("RENDER", definition.get("stock"), Wire.JSON.writeValueAsString(definition.get("scene"))).put("targetDpi", dpi);
|
||||
byte[] actual = SceneFixture.render(request);
|
||||
boolean[][] page = SceneFixture.raster(actual);
|
||||
BufferedImage picture = new BufferedImage(page[0].length, page.length, BufferedImage.TYPE_BYTE_BINARY);
|
||||
for(int y = 0; y < page.length; y++)
|
||||
{
|
||||
for(int x = 0; x < page[y].length; x++)
|
||||
{
|
||||
picture.setRGB(x, y, page[y][x] ? 0x000000 : 0xFFFFFF);
|
||||
}
|
||||
}
|
||||
byte[] expected = resource("golden/" + name + "-" + dpi + ".zpl");
|
||||
byte[] stored = resource("golden/" + name + "-" + dpi + ".png");
|
||||
if(expected == null || stored == null || !Arrays.equals(expected, actual))
|
||||
{
|
||||
Path directory = Files.createDirectories(Path.of("target", "scene2-actual"));
|
||||
Files.write(directory.resolve(name + "-" + dpi + ".zpl"), actual);
|
||||
ImageIO.write(picture, "png", directory.resolve(name + "-" + dpi + ".png").toFile());
|
||||
}
|
||||
assertNotNull(expected, "No golden ZPL is stored for " + name + " at " + dpi + " dpi");
|
||||
assertNotNull(stored, "No golden picture is stored for " + name + " at " + dpi + " dpi");
|
||||
int difference = Arrays.mismatch(expected, actual);
|
||||
if(difference >= 0)
|
||||
{
|
||||
fail("Scene " + name + " at " + dpi + " dpi no longer renders its golden label: first difference at byte " + difference + " (golden " + expected.length + " bytes, now " + actual.length + ")");
|
||||
}
|
||||
BufferedImage golden = ImageIO.read(new ByteArrayInputStream(stored));
|
||||
assertEquals(picture.getWidth(), golden.getWidth());
|
||||
assertEquals(picture.getHeight(), golden.getHeight());
|
||||
for(int y = 0; y < page.length; y++)
|
||||
{
|
||||
for(int x = 0; x < page[y].length; x++)
|
||||
{
|
||||
if(page[y][x] != ((golden.getRGB(x, y) & 0xFFFFFF) == 0))
|
||||
{
|
||||
fail("Scene " + name + " at " + dpi + " dpi differs from its golden picture at dot " + x + ", " + y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The stored goldens are exactly the cases at the three resolutions, so a
|
||||
** case cannot be dropped and leave its files behind.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void goldensAreExactlyTheCasesAtEveryResolution() throws Exception
|
||||
{
|
||||
List<String> expected = new ArrayList<>();
|
||||
for(Arguments golden : goldens())
|
||||
{
|
||||
expected.add(golden.get()[0] + "-" + golden.get()[1] + ".png");
|
||||
expected.add(golden.get()[0] + "-" + golden.get()[1] + ".zpl");
|
||||
}
|
||||
assertTrue(expected.size() >= 18);
|
||||
try(Stream<Path> files = Files.list(Path.of(SceneGoldenTest.class.getResource(ROOT + "golden").toURI())))
|
||||
{
|
||||
assertEquals(expected.stream().sorted().toList(), files.map(file -> file.getFileName().toString()).sorted().toList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Test resource bytes, or null when there is no such file.
|
||||
***************************************************************************/
|
||||
private static byte[] resource(String path) throws Exception
|
||||
{
|
||||
try(InputStream stream = SceneGoldenTest.class.getResourceAsStream(ROOT + path))
|
||||
{
|
||||
return (stream == null ? null : stream.readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* 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.scene2;
|
||||
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.kingsrook.qbits.renderer.Wire;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The published schema, `renderer/schema/zpl-scene-2.schema.json`, describes
|
||||
** what the parser reads: the same element types, the same properties for each,
|
||||
** the same words and the same length syntax. The parser stays the authority;
|
||||
** this keeps the document an author reads from drifting away from it.
|
||||
*******************************************************************************/
|
||||
class SceneSchemaTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** The schema document.
|
||||
***************************************************************************/
|
||||
private static JsonNode schema() throws Exception
|
||||
{
|
||||
return (Wire.JSON.readTree(Files.readAllBytes(Path.of("schema", "zpl-scene-2.schema.json"))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The property names of a schema object.
|
||||
***************************************************************************/
|
||||
private static Set<String> names(JsonNode properties)
|
||||
{
|
||||
Set<String> names = new TreeSet<>();
|
||||
properties.fieldNames().forEachRemaining(names::add);
|
||||
return (names);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Each element type of the schema is one the parser builds, with exactly the
|
||||
** properties its factory reads beside the common ones.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theSchemaDeclaresTheTypesAndPropertiesTheParserReads() throws Exception
|
||||
{
|
||||
JsonNode schema = schema();
|
||||
assertEquals("qqq-zpl-scene/2", schema.at("/properties/schema/const").textValue());
|
||||
assertEquals(Scene2.SCHEMA, schema.at("/properties/schema/const").textValue());
|
||||
assertEquals(Set.of("schema", "design", "defaults", "samples", "elements", "overrides"), names(schema.at("/properties")));
|
||||
Set<String> common = names(schema.at("/$defs/placement/properties"));
|
||||
common.add("type");
|
||||
assertEquals(new TreeSet<>(ElementFactory.COMMON), common);
|
||||
Set<String> types = new HashSet<>();
|
||||
for(JsonNode element : schema.at("/$defs/element/oneOf"))
|
||||
{
|
||||
String type = element.at("/properties/type/const").textValue();
|
||||
assertTrue(types.add(type), type);
|
||||
Set<String> own = names(element.get("properties"));
|
||||
own.remove("type");
|
||||
assertEquals("group".equals(type) ? Set.of("children") : new TreeSet<>(ElementFactory.FACTORIES.get(type).properties()), own, type);
|
||||
assertFalse(element.get("unevaluatedProperties").booleanValue(), type);
|
||||
}
|
||||
Set<String> expected = new HashSet<>(ElementFactory.FACTORIES.keySet());
|
||||
expected.add("group");
|
||||
assertEquals(expected, types);
|
||||
for(String name : names(schema.at("/properties")))
|
||||
{
|
||||
assertTrue(ElementFactory.VOCABULARY.contains(name), name);
|
||||
}
|
||||
assertEquals(SceneParser.MAXIMUM_OVERRIDES, schema.at("/properties/overrides/maxItems").intValue());
|
||||
assertEquals(SceneParser.MAXIMUM_SAMPLES, schema.at("/properties/samples/maxProperties").intValue());
|
||||
assertEquals(Scene2.RESOLUTIONS, List.of(203, 300, 600));
|
||||
assertEquals("[203,300,600]", schema.at("/properties/overrides/items/properties/when/properties/dpi/items/enum").toString());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The words and patterns of the schema are the parser's.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theSchemaUsesTheWordsAndLengthsOfTheParser() throws Exception
|
||||
{
|
||||
JsonNode schema = schema();
|
||||
for(JsonNode word : schema.at("/$defs/placement/properties/anchor/enum"))
|
||||
{
|
||||
assertNotNull(Placement.Anchor.of(word.textValue()), word.textValue());
|
||||
}
|
||||
assertEquals(Placement.Anchor.values().length, schema.at("/$defs/placement/properties/anchor/enum").size());
|
||||
for(JsonNode word : schema.at("/$defs/placement/properties/align/enum"))
|
||||
{
|
||||
assertNotNull(Placement.Align.of(word.textValue()), word.textValue());
|
||||
}
|
||||
for(JsonNode word : schema.at("/$defs/placement/properties/valign/enum"))
|
||||
{
|
||||
assertNotNull(Placement.Valign.of(word.textValue()), word.textValue());
|
||||
}
|
||||
assertEquals(Diagnostics.ID.pattern(), schema.at("/$defs/id/pattern").textValue().replaceAll("^\\^|\\$$", ""));
|
||||
Pattern length = Pattern.compile(schema.at("/$defs/length/pattern").textValue());
|
||||
Pattern physical = Pattern.compile(schema.at("/$defs/physical/pattern").textValue());
|
||||
for(String text : List.of("12.5mm", "0.5in", "9pt", "25%", "1dot", "-2mm", "0mm", "2000mm", "-100%", "0.001mm"))
|
||||
{
|
||||
assertTrue(length.matcher(text).find(), text);
|
||||
assertNotNull(Length.parse(text), text);
|
||||
}
|
||||
for(String text : List.of("", "12", "mm", "1.2345mm", "1e3mm", " 1mm", "1 mm", "1MM", "01mm", ".5mm", "1.mm", "+1mm", "1px", "1.5dot", "123456mm"))
|
||||
{
|
||||
assertFalse(length.matcher(text).find(), text);
|
||||
}
|
||||
assertTrue(physical.matcher("4in").find() && physical.matcher("101.6mm").find() && physical.matcher("288pt").find());
|
||||
assertFalse(physical.matcher("50%").find() || physical.matcher("812dot").find() || physical.matcher("-4in").find());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The example scene of `renderer/README.md` is a scene: it validates and
|
||||
** renders on the 4 x 1 in stock at every resolution, with no findings.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theReadmeExampleIsAScene() throws Exception
|
||||
{
|
||||
String readme = Files.readString(Path.of("README.md"));
|
||||
int start = readme.lastIndexOf('{', readme.indexOf("\"schema\": \"qqq-zpl-scene/2\""));
|
||||
assertTrue(start > 0, "the README has a version 2 example");
|
||||
String example = readme.substring(start, readme.indexOf("```", start));
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
for(String operation : List.of("VALIDATE", "RENDER"))
|
||||
{
|
||||
java.util.Map<String, Object> answer = com.kingsrook.qbits.renderer.RendererMain.process(SceneFixture.request(operation, example).put("targetDpi", dpi));
|
||||
assertEquals("OK", answer.get("outcome"));
|
||||
assertEquals(List.of(), answer.get("diagnostics"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.awt.Font;
|
||||
import java.awt.Rectangle;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.renderer.scene2.Scene2;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundled faces: what ships, under which licence, with which hash, and
|
||||
** that the numbers layout relies on are the font files' own.
|
||||
*******************************************************************************/
|
||||
class FaceTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** A resource of the renderer jar, or null.
|
||||
***************************************************************************/
|
||||
private static byte[] resource(String path) throws Exception
|
||||
{
|
||||
try(InputStream stream = Face.class.getResourceAsStream(path))
|
||||
{
|
||||
return (stream == null ? null : stream.readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The lowercase hexadecimal SHA-256 of some bytes.
|
||||
***************************************************************************/
|
||||
private static String sha256(byte[] bytes) throws Exception
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every character of the Basic Multilingual Plane a face draws.
|
||||
***************************************************************************/
|
||||
static List<Integer> drawn(Face face)
|
||||
{
|
||||
List<Integer> drawn = new ArrayList<>();
|
||||
for(int codePoint = 0; codePoint <= Character.MAX_VALUE; codePoint++)
|
||||
{
|
||||
if(face.draws(codePoint))
|
||||
{
|
||||
drawn.add(codePoint);
|
||||
}
|
||||
}
|
||||
return (drawn);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The set is the five faces the design names, by the names it gives them,
|
||||
** and no other name is a face.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theBundledSetIsFiveNamedFaces()
|
||||
{
|
||||
assertEquals(List.of("qqq-sans", "qqq-sans-bold", "qqq-condensed-bold", "qqq-mono", "qqq-mono-bold"), Face.all().stream().map(Face::scene).toList());
|
||||
assertEquals(List.of(false, true, true, false, true), Face.all().stream().map(Face::bold).toList());
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
assertEquals(face, Face.of(face.scene()));
|
||||
assertTrue(face.scene().startsWith("qqq-"), "a bundled face has the reserved prefix");
|
||||
}
|
||||
for(String other : List.of("", "qqq-", "qqq-serif", "QQQ-SANS", "qqq-sans ", "Abel", "IBM Plex Sans"))
|
||||
{
|
||||
assertNull(Face.of(other), other);
|
||||
}
|
||||
assertNull(Face.of(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every bundled face ships with its licence text, a provenance note and a
|
||||
** hash, and the bytes in the jar are the bytes that were hashed. The build
|
||||
** fails when any of the three is missing or a file changes.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyBundledFaceHasItsLicenceProvenanceAndAMatchingHash() throws Exception
|
||||
{
|
||||
Map<String, String> sums = new HashMap<>();
|
||||
for(String line : new String(resource("/fonts/SHA256SUMS"), StandardCharsets.US_ASCII).lines().toList())
|
||||
{
|
||||
assertTrue(line.matches("[0-9a-f]{64} [A-Za-z0-9./-]+"), line);
|
||||
assertNull(sums.put(line.substring(66), line.substring(0, 64)), "listed twice: " + line);
|
||||
}
|
||||
Set<String> licences = Set.of("ibm-plex-sans/LICENSE.txt", "ibm-plex-sans-condensed/LICENSE.txt", "jetbrains-mono/OFL.txt");
|
||||
assertEquals(Face.all().size() + licences.size(), sums.size(), "the list is exactly the faces and their licences");
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
String directory = face.file().substring(0, face.file().indexOf('/') + 1);
|
||||
byte[] font = resource("/fonts/" + face.file());
|
||||
assertNotNull(font, face.file());
|
||||
assertTrue(font[0] == 0 && font[1] == 1 && font[2] == 0 && font[3] == 0, "static TrueType outlines: " + face.file());
|
||||
assertEquals(sums.get(face.file()), sha256(font), face.file());
|
||||
|
||||
String licence = licences.stream().filter(name -> name.startsWith(directory)).findFirst().orElseThrow();
|
||||
byte[] text = resource("/fonts/" + licence);
|
||||
assertNotNull(text, licence);
|
||||
assertEquals(sums.get(licence), sha256(text), licence);
|
||||
String words = new String(text, StandardCharsets.UTF_8);
|
||||
assertTrue(words.contains("This Font Software is licensed under the SIL Open Font License, Version 1.1."), licence);
|
||||
assertTrue(words.contains("SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007") && words.contains("PERMISSION & CONDITIONS") && words.contains("TERMINATION"), "the whole licence text: " + licence);
|
||||
|
||||
byte[] provenance = resource("/fonts/" + directory + "SOURCE.md");
|
||||
assertNotNull(provenance, "provenance of " + face.file());
|
||||
String note = new String(provenance, StandardCharsets.UTF_8);
|
||||
String name = face.file().substring(directory.length());
|
||||
assertTrue(note.contains("`" + name + "`") && note.contains(sums.get(face.file())) && note.contains(sums.get(licence)), "the provenance names the file and both hashes: " + name);
|
||||
assertTrue(note.contains("https://github.com/") && note.contains("/releases/download/") && note.contains("Archive SHA-256") && (note.contains("Unmodified") || note.contains("unmodified")), name);
|
||||
assertTrue(note.contains("`" + face.scene() + "`"), "the provenance gives the scene name " + face.scene());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A file that is not a font is a broken build and says so; it is never a
|
||||
** silent fallback to another face.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aFileThatIsNotAFontIsABrokenBuild()
|
||||
{
|
||||
assertEquals("BUNDLED_FONT_UNREADABLE", assertThrows(IllegalStateException.class, () -> Face.load("/fonts/SHA256SUMS")).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The bounds each face states are the font file's: the widest character
|
||||
** (the lowest of those that share the greatest advance, among characters
|
||||
** with ink), and the furthest any character reaches past its advance on the
|
||||
** left and right, above the baseline and below it. They are what a length
|
||||
** is proven against, so they are recomputed here from every character.
|
||||
**
|
||||
** A character with no ink still moves the pen (a no-break space, an em
|
||||
** quad), so the greatest advance is taken over every character the face
|
||||
** draws, inked or not, and the widest inked character must have it: were a
|
||||
** font ever to carry a blank wider than its widest letter, a line of that
|
||||
** blank would be wider than the proof allows, and this fails.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theBoundsOfAFaceAreThoseOfItsFontFile()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
int widest = -1;
|
||||
int advance = 0;
|
||||
int inked = 0;
|
||||
int blanks = 0;
|
||||
int left = 0;
|
||||
int right = 0;
|
||||
int top = 0;
|
||||
int bottom = 0;
|
||||
List<Integer> drawn = drawn(face);
|
||||
for(int codePoint : drawn)
|
||||
{
|
||||
Face.Glyph glyph = face.glyph(codePoint);
|
||||
assertTrue(glyph.advance() >= 0 && glyph.advance() <= 2 * Face.REFERENCE, "advance of " + codePoint);
|
||||
assertEquals(glyph.space(), codePoint == ' ');
|
||||
advance = Math.max(advance, glyph.advance());
|
||||
if(!glyph.ink())
|
||||
{
|
||||
assertEquals(new Face.Glyph(glyph.advance(), 0, 0, 0, 0, false, codePoint == ' '), glyph);
|
||||
blanks++;
|
||||
continue;
|
||||
}
|
||||
if(glyph.advance() > inked)
|
||||
{
|
||||
inked = glyph.advance();
|
||||
widest = codePoint;
|
||||
}
|
||||
left = Math.max(left, -glyph.left());
|
||||
right = Math.max(right, glyph.right() - glyph.advance());
|
||||
top = Math.max(top, glyph.top());
|
||||
bottom = Math.max(bottom, glyph.bottom());
|
||||
}
|
||||
assertTrue(drawn.size() > 800, face.scene() + " draws " + drawn.size() + " characters");
|
||||
assertTrue(blanks >= 2 && face.draws(0xA0) && !face.glyph(0xA0).ink() && face.glyph(0xA0).advance() > 0, face.scene() + " draws " + blanks + " characters with no ink, the no-break space among them");
|
||||
assertEquals(advance, inked, face.scene() + ": no character without ink advances further than the widest with ink");
|
||||
assertEquals(new Face.Glyph(advance, -left, advance + right, top, bottom, true, false), face.worst(), face.scene());
|
||||
assertEquals(widest, face.widest(), face.scene());
|
||||
assertEquals(face.glyph(face.widest()).advance(), face.worst().advance());
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// the faces have 1000 units to the em, so the reference size reads whole numbers //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
assertTrue(face.ascent() > 900 && face.ascent() < 1100 && face.descent() > 200 && face.descent() < 350 && face.leading() == 0, face.scene());
|
||||
assertTrue(face.capHeight() > 650 && face.capHeight() < 760, face.scene());
|
||||
}
|
||||
assertEquals(List.of(1025, 275, 698), List.of(Face.SANS.ascent(), Face.SANS.descent(), Face.SANS.capHeight()));
|
||||
assertEquals(List.of(1020, 300, 730), List.of(Face.MONO_BOLD.ascent(), Face.MONO_BOLD.descent(), Face.MONO_BOLD.capHeight()));
|
||||
assertEquals(new Face.Glyph(600, 0, 0, 0, 0, false, true), Face.MONO.glyph(' '));
|
||||
assertEquals(Set.of(600), drawn(Face.MONO).stream().map(codePoint -> Face.MONO.glyph(codePoint).advance()).collect(Collectors.toSet()), "the monospace has one advance");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A face draws a character only as one glyph of its own, left to right.
|
||||
** Controls, formats (which would print nothing), marks that must be placed
|
||||
** on another letter, scripts that need shaping, and characters outside the
|
||||
** Basic Multilingual Plane are not drawn; nor is a character the face lacks.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aFaceDrawsOnlyWhatItCanDrawFaithfully()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
for(int codePoint = 0x20; codePoint < 0x7F; codePoint++)
|
||||
{
|
||||
assertTrue(face.draws(codePoint), face.scene() + " draws " + codePoint);
|
||||
}
|
||||
assertTrue(face.draws(TextFit.MARK), "the ellipsis that marks a cut");
|
||||
assertTrue(face.draws(0xE9) && face.draws(0xC5) && face.draws(0xA0) && face.draws(0x20AC));
|
||||
for(int codePoint : List.of(0x00, 0x09, 0x0A, 0x0D, 0x7F, 0x85, 0xAD, 0x200B, 0x200D, 0x202E, 0x2028, 0x2029, 0xFEFF, 0x0301, 0x0627, 0x05D0, 0x0915, 0x4E2D, 0xD83D, 0xE0A0, 0xFFFF, 0x1F600, 0x10FFFF))
|
||||
{
|
||||
assertFalse(face.draws(codePoint), face.scene() + " must not draw " + Integer.toHexString(codePoint));
|
||||
}
|
||||
assertEquals(0, face.missing("TEST-0001 (01)00012345678905 café")); // e acute
|
||||
assertEquals(0, face.missing(""));
|
||||
assertEquals(4, face.missing("A\tB́😀C中")); // a tab, a combining acute, one emoji (two units, one character), a CJK ideograph
|
||||
assertEquals(3, face.glyphs("A B").length);
|
||||
assertTrue(face.glyphs("A B")[1].space() && !face.glyphs("A B")[0].space());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The rasteriser keeps every glyph inside the box layout measures for it:
|
||||
** the outline at the reference size, scaled exactly, and `TextFit.SLOP`
|
||||
** dots. Held for every character of every face over the sizes and
|
||||
** resolutions labels use, and at the ends of what the renderer accepts:
|
||||
** 1 pt and 2 pt, where a glyph is a few dots, and 300 pt and 1000 pt,
|
||||
** where it is drawn from its outline. (That the ink itself stays inside is
|
||||
** held where text is drawn: `TextRasterTest`.)
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyGlyphIsRasterisedInsideItsMeasuredBox()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
List<Integer> drawn = drawn(face);
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
for(int milliPoints : List.of(1000, 2000, 5000, 6250, 7000, 8000, 9000, 10_500, 12_000, 18_000, 36_000, 72_000, 300_000, 1_000_000))
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(milliPoints, dpi);
|
||||
Font font = face.font(scale.size());
|
||||
for(int codePoint : drawn)
|
||||
{
|
||||
Face.Glyph glyph = face.glyph(codePoint);
|
||||
Rectangle pixels = font.createGlyphVector(Face.CONTEXT, new char[] {(char) codePoint}).getGlyphPixelBounds(0, Face.CONTEXT, 0, 0);
|
||||
if(!glyph.ink())
|
||||
{
|
||||
assertTrue(pixels.isEmpty(), "ink where none is measured");
|
||||
continue;
|
||||
}
|
||||
boolean inside = pixels.x >= scale.floor(glyph.left()) - TextFit.SLOP && pixels.x + pixels.width <= scale.ceil(glyph.right()) + TextFit.SLOP
|
||||
&& -pixels.y <= scale.ceil(glyph.top()) + TextFit.SLOP && pixels.y + pixels.height <= scale.ceil(glyph.bottom()) + TextFit.SLOP;
|
||||
assertTrue(inside, () -> face.scene() + " U+" + Integer.toHexString(codePoint) + " at " + milliPoints + " millipoints, " + dpi + " dpi: " + pixels + " outside " + glyph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1045
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,871 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import com.kingsrook.qbits.renderer.scene2.Scene2;
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fit rules, by example and as properties over seeded, deterministic
|
||||
** generators (fixed seeds, no clock): text, face, box, size, floor, lines and
|
||||
** resolution.
|
||||
*******************************************************************************/
|
||||
class TextFitTest
|
||||
{
|
||||
static final long[] SEEDS = {20261112L, 7L, 4242L};
|
||||
static final int CASES = 600;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One generated case.
|
||||
***************************************************************************/
|
||||
record Case(TextFit.Request request, String text)
|
||||
{
|
||||
/************************************************************************
|
||||
** The same case with another nominal size and floor, and no cutting.
|
||||
************************************************************************/
|
||||
Case at(int nominal, int floor)
|
||||
{
|
||||
TextFit.Request from = request;
|
||||
return (new Case(new TextFit.Request(from.face(), from.width(), from.height(), from.dpi(), nominal, floor, from.lineHeight(), from.maxLines(), false), text));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The same case for another text.
|
||||
************************************************************************/
|
||||
Case of(String other)
|
||||
{
|
||||
return (new Case(request, other));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Fit it.
|
||||
************************************************************************/
|
||||
TextFit.Block fit()
|
||||
{
|
||||
return (TextFit.fit(request, request.face().glyphs(text), request.face().glyph(TextFit.MARK)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A request in a box, with the face's own line pitch, one line, no cutting.
|
||||
***************************************************************************/
|
||||
static TextFit.Request request(Face face, int width, int height, int nominal, int floor)
|
||||
{
|
||||
return (new TextFit.Request(face, width, height, 203, nominal, floor, null, 1, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same request with more lines, and cutting or not.
|
||||
***************************************************************************/
|
||||
static TextFit.Request lines(TextFit.Request request, int maxLines, boolean truncate)
|
||||
{
|
||||
return (new TextFit.Request(request.face(), request.width(), request.height(), request.dpi(), request.nominal(), request.floor(), request.lineHeight(), maxLines, truncate));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text of a face's own characters.
|
||||
***************************************************************************/
|
||||
static TextFit.Block fit(TextFit.Request request, String text)
|
||||
{
|
||||
return (new Case(request, text).fit());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The characters of each line, with `~` where the ellipsis is.
|
||||
***************************************************************************/
|
||||
static List<String> shown(TextFit.Block block, String text)
|
||||
{
|
||||
return (block.lines().stream().map(line -> text.substring(line.from(), line.to()) + (line.marked() ? "~" : "")).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A generated case: labels are small boxes, short values and type from 5 to
|
||||
** 30 pt. A third of the texts have no space, as identifiers do.
|
||||
***************************************************************************/
|
||||
static Case generate(Random random)
|
||||
{
|
||||
Face face = Face.all().get(random.nextInt(Face.all().size()));
|
||||
int dpi = Scene2.RESOLUTIONS.get(random.nextInt(3));
|
||||
int nominal = 5000 + random.nextInt(101) * 250 + (random.nextInt(4) == 0 ? random.nextInt(250) : 0);
|
||||
int floor = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> nominal;
|
||||
case 1 -> Math.max(1000, nominal * 7 / 10);
|
||||
default -> 1000 + random.nextInt(nominal - 999);
|
||||
};
|
||||
Integer lineHeight = random.nextInt(3) == 0 ? Integer.valueOf(700 + random.nextInt(1500)) : null;
|
||||
int width = (20 + random.nextInt(700)) * dpi / 203;
|
||||
int height = (8 + random.nextInt(160)) * dpi / 203;
|
||||
String alphabet = switch(random.nextInt(3))
|
||||
{
|
||||
case 0 -> "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-()/.";
|
||||
case 1 -> "abcdefghijklmnopqrstuvwxyzWMfjgyÅé‰ "; // A ring, e acute, per mille
|
||||
default -> "il1.,' WM@‰⁄ǺĢ "; // per mille, fraction slash, A ring acute, G cedilla: the widest and furthest-reaching
|
||||
};
|
||||
StringBuilder text = new StringBuilder();
|
||||
int length = random.nextInt(48);
|
||||
boolean spaces = random.nextInt(3) != 0;
|
||||
for(int index = 0; index < length; index++)
|
||||
{
|
||||
text.append(spaces && random.nextInt(6) == 0 ? ' ' : alphabet.charAt(random.nextInt(alphabet.length())));
|
||||
}
|
||||
return (new Case(new TextFit.Request(face, width, height, dpi, nominal, floor, lineHeight, 1 + random.nextInt(4), random.nextInt(3) == 0), text.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The search `fit` must agree with, made in full: every quarter-point step
|
||||
** from the nominal size down to the floor, each laid out between words and
|
||||
** then at any character, the first that fits taken. This is the search the
|
||||
** fit was first written as. It shares with `fit` only the layout at one
|
||||
** size (`TextFit.at`), and none of what `fit` uses to skip sizes.
|
||||
***************************************************************************/
|
||||
static TextFit.Block linear(TextFit.Request request, Face.Glyph[] glyphs, Face.Glyph mark, TextFit.Work work)
|
||||
{
|
||||
int count = glyphs.length;
|
||||
while(count > 0 && glyphs[count - 1].space())
|
||||
{
|
||||
count--;
|
||||
}
|
||||
int size = request.nominal();
|
||||
while(true)
|
||||
{
|
||||
TextFit.Block block = TextFit.at(request, TextFit.Leading.of(request), size, glyphs, count, mark, work);
|
||||
if(block.fits() || size == request.floor())
|
||||
{
|
||||
return (block);
|
||||
}
|
||||
size = Math.max(request.floor(), size - TextFit.STEP_MILLIPOINTS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A generated case over everything a scene may state: type from 1 pt to
|
||||
** 1000 pt with a floor anywhere under it, boxes from a few dots to a large
|
||||
** label, up to 64 lines, any line height, and texts of up to 160
|
||||
** characters, some without a space, some of the widest and
|
||||
** furthest-reaching characters, some led or followed by spaces.
|
||||
***************************************************************************/
|
||||
static Case wide(Random random)
|
||||
{
|
||||
Face face = Face.all().get(random.nextInt(Face.all().size()));
|
||||
int dpi = Scene2.RESOLUTIONS.get(random.nextInt(3));
|
||||
int nominal = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> 1000 + random.nextInt(9001);
|
||||
case 1 -> 5000 + random.nextInt(30_001);
|
||||
case 2 -> 20_000 + random.nextInt(280_001);
|
||||
default -> 1000 + random.nextInt(999_001);
|
||||
};
|
||||
int floor = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> 1000;
|
||||
case 1 -> Math.max(1000, nominal / 10);
|
||||
case 2 -> Math.max(1000, nominal - random.nextInt(4000));
|
||||
default -> 1000 + random.nextInt(nominal - 999);
|
||||
};
|
||||
Integer lineHeight = random.nextInt(3) == 0 ? Integer.valueOf(500 + random.nextInt(4501)) : null;
|
||||
int width = switch(random.nextInt(3))
|
||||
{
|
||||
case 0 -> 1 + random.nextInt(60);
|
||||
case 1 -> 20 + random.nextInt(700);
|
||||
default -> 200 + random.nextInt(6000);
|
||||
};
|
||||
int height = switch(random.nextInt(3))
|
||||
{
|
||||
case 0 -> 1 + random.nextInt(40);
|
||||
case 1 -> 8 + random.nextInt(300);
|
||||
default -> 100 + random.nextInt(4000);
|
||||
};
|
||||
String alphabet = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-()/.";
|
||||
case 1 -> "abcdefghijklmnopqrstuvwxyzWMfjgyÅé‰ "; // A ring, e acute, per mille
|
||||
case 2 -> "il1.,' WM@‰⁄ǺĢ "; // per mille, fraction slash, A ring acute, G cedilla: the widest and furthest-reaching
|
||||
default -> new String(Character.toChars(face.widest())) + "j "; // the widest, a letter that starts left of the pen, a no-break space
|
||||
};
|
||||
StringBuilder text = new StringBuilder(random.nextInt(5) == 0 ? " " : "");
|
||||
int length = random.nextInt(4) == 0 ? random.nextInt(160) : random.nextInt(40);
|
||||
int spaces = random.nextInt(3);
|
||||
for(int index = 0; index < length; index++)
|
||||
{
|
||||
text.append(spaces > 0 && random.nextInt(spaces == 1 ? 6 : 2) == 0 ? ' ' : alphabet.charAt(random.nextInt(alphabet.length())));
|
||||
}
|
||||
return (new Case(new TextFit.Request(face, width, height, dpi, nominal, floor, lineHeight, 1 + random.nextInt(random.nextInt(3) == 0 ? 64 : 4), random.nextInt(3) == 0), text.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Dots are thousandths of the em times the size times the resolution over
|
||||
** 72 000 000, rounded down, up or to the nearest: checked against exact
|
||||
** decimal arithmetic, negative lengths included.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theScaleRoundsExactly()
|
||||
{
|
||||
Random random = new Random(SEEDS[0]);
|
||||
for(int index = 0; index < 5000; index++)
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(1000 + random.nextInt(999_001), Scene2.RESOLUTIONS.get(random.nextInt(3)));
|
||||
long units = random.nextInt(3_000_001) - 1_500_000L;
|
||||
BigDecimal exact = BigDecimal.valueOf(units).multiply(BigDecimal.valueOf(scale.milliPoints())).multiply(BigDecimal.valueOf(scale.dpi())).divide(BigDecimal.valueOf(72_000_000L), 12, RoundingMode.HALF_EVEN);
|
||||
assertEquals(exact.setScale(0, RoundingMode.FLOOR).intValueExact(), scale.floor(units));
|
||||
assertEquals(exact.setScale(0, RoundingMode.CEILING).intValueExact(), scale.ceil(units));
|
||||
assertEquals(exact.add(new BigDecimal("0.5")).setScale(0, RoundingMode.FLOOR).intValueExact(), scale.round(units));
|
||||
}
|
||||
assertEquals(25.375f, new TextFit.Scale(9000, 203).size());
|
||||
assertEquals(100f, new TextFit.Scale(12_000, 600).size());
|
||||
assertEquals(List.of(25, 26, 25), List.of(new TextFit.Scale(9000, 203).floor(1000), new TextFit.Scale(9000, 203).ceil(1000), new TextFit.Scale(9000, 203).round(1000)));
|
||||
assertEquals(List.of(-26, -25, -25), List.of(new TextFit.Scale(9000, 203).floor(-1000), new TextFit.Scale(9000, 203).ceil(-1000), new TextFit.Scale(9000, 203).round(-1000)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The default: one line. A text that fits at its size keeps it; one that
|
||||
** does not is reduced a quarter point at a time to the largest size that
|
||||
** fits; one that does not fit at the floor is refused with what it
|
||||
** measured there.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void oneLineShrinksToTheLargestSizeThatFitsThenRefuses()
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 9 pt at 203 dpi is 25.375 dots to the em and the monospace advances 0.6 em: 12 letters //
|
||||
// are 183 dots, and the line is 2 dots wider on each inked side //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block fits = fit(request(Face.MONO_BOLD, 200, 40, 9000, 6300), "ABCDEFGHIJKL");
|
||||
assertTrue(fits.fits() && !fits.truncated());
|
||||
assertEquals(9000, fits.milliPoints());
|
||||
assertEquals(List.of("ABCDEFGHIJKL"), shown(fits, "ABCDEFGHIJKL"));
|
||||
assertEquals(12, fits.characters());
|
||||
assertTrue(fits.width() >= 183 && fits.width() <= 187, "width " + fits.width());
|
||||
assertEquals(fits.width(), fits.lines().get(0).width());
|
||||
assertEquals(List.of(26, 34), List.of(fits.baseline(), fits.height()), "ascent 1.02 em and descent 0.3 em, each rounded up");
|
||||
|
||||
TextFit.Block shrunk = fit(request(Face.MONO_BOLD, 160, 40, 9000, 6300), "ABCDEFGHIJKL");
|
||||
assertTrue(shrunk.fits() && shrunk.milliPoints() < 9000 && shrunk.milliPoints() >= 6300 && shrunk.milliPoints() % 250 == 0);
|
||||
assertTrue(shrunk.width() <= 160);
|
||||
assertFalse(fit(request(Face.MONO_BOLD, 160, 40, shrunk.milliPoints() + 250, shrunk.milliPoints() + 250), "ABCDEFGHIJKL").fits(), "a quarter point larger does not fit");
|
||||
|
||||
TextFit.Block refused = fit(request(Face.MONO_BOLD, 100, 40, 9000, 6300), "ABCDEFGHIJKL");
|
||||
assertFalse(refused.fits());
|
||||
assertEquals(6300, refused.milliPoints());
|
||||
assertTrue(refused.lines().size() > 1 && refused.width() <= 100, "measured broken at any character: " + refused);
|
||||
assertEquals(12, refused.shown());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the floor need not be on the quarter-point grid from the size: it is the last size //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block floor = fit(request(Face.MONO_BOLD, 134, 40, 9000, 6400), "ABCDEFGHIJKL");
|
||||
assertTrue(floor.fits());
|
||||
assertEquals(6400, floor.milliPoints());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// too tall is refused as too wide is: the box is 20 dots high and the floor needs 25 //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block tall = fit(request(Face.MONO_BOLD, 400, 20, 9000, 6300), "ABC");
|
||||
assertFalse(tall.fits());
|
||||
assertTrue(tall.height() > 20 && tall.lines().size() == 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Nothing to draw always fits: an empty value, or one of spaces only.
|
||||
** Spaces at the end of a text take no room; spaces at its start do.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void spacesAtTheEndTakeNoRoom()
|
||||
{
|
||||
for(String blank : List.of("", " ", " "))
|
||||
{
|
||||
TextFit.Block block = fit(request(Face.SANS, 1, 1, 9000, 9000), blank);
|
||||
assertTrue(block.fits() && block.lines().isEmpty() && !block.truncated());
|
||||
assertEquals(List.of(0, 0, 0, 0), List.of(block.width(), block.height(), block.characters(), block.shown()));
|
||||
}
|
||||
TextFit.Request request = request(Face.SANS_BOLD, 300, 40, 9000, 9000);
|
||||
TextFit.Block plain = fit(request, "AB");
|
||||
assertEquals(plain, fit(request, "AB "));
|
||||
TextFit.Block led = fit(request, " AB");
|
||||
int shift = new TextFit.Scale(9000, 203).round(2L * Face.SANS_BOLD.glyph(' ').advance());
|
||||
assertTrue(shift > 8 && led.width() >= plain.width() + shift - 2 * TextFit.SLOP && led.width() <= plain.width() + shift + 1, plain.width() + " then " + led.width());
|
||||
assertEquals(4, led.characters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** With more lines allowed, a line is broken between words while that fits,
|
||||
** and at any character when it does not: a larger size is preferred to
|
||||
** whole words, and a value with no space wraps.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void linesBreakBetweenWordsAndThenAtAnyCharacter()
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the monospace at 9 pt, 203 dpi advances 15.225 dots: a 100 dot box holds 6 letters //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Request box = request(Face.MONO, 100, 200, 9000, 9000);
|
||||
assertEquals(List.of("AAA", "BBB", "CCC"), shown(fit(lines(box, 3, false), "AAA BBB CCC"), "AAA BBB CCC"));
|
||||
assertEquals(List.of("AAA BB", "CCC"), shown(fit(lines(box, 2, false), "AAA BB CCC"), "AAA BB CCC"), "spaces at a break are dropped");
|
||||
assertEquals(List.of("AAA BB", "B CCC"), shown(fit(lines(box, 2, false), "AAA BBB CCC"), "AAA BBB CCC"), "two lines only when broken at any character");
|
||||
assertEquals(List.of("L24061", "1A-RET", "URNED"), shown(fit(lines(box, 3, false), "L240611A-RETURNED"), "L240611A-RETURNED"));
|
||||
assertEquals(9000, fit(lines(box, 2, false), "AAA BBB CCC").milliPoints());
|
||||
|
||||
TextFit.Block refused = fit(lines(box, 2, false), "L240611A-RETURNED");
|
||||
assertFalse(refused.fits());
|
||||
assertEquals(3, refused.lines().size());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a stated line height sets the distance between baselines; the block is one ascent, //
|
||||
// the pitches between, and one descent //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block natural = fit(lines(box, 3, false), "AAA BBB CCC");
|
||||
assertEquals(List.of(34, 26, 26 + 2 * 34 + 8), List.of(natural.pitch(), natural.baseline(), natural.height()));
|
||||
TextFit.Block tight = TextFit.fit(new TextFit.Request(Face.MONO, 100, 200, 203, 9000, 9000, 1000, 3, false), Face.MONO.glyphs("AAA BBB CCC"), null);
|
||||
assertEquals(List.of(26, 26, 26 + 2 * 26 + 8), List.of(tight.pitch(), tight.baseline(), tight.height()));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a character wider than the box stands alone on a line that does not fit //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block narrow = fit(lines(request(Face.MONO, 10, 200, 9000, 9000), 4, false), "AB");
|
||||
assertFalse(narrow.fits());
|
||||
assertEquals(List.of("A", "B"), shown(narrow, "AB"));
|
||||
assertTrue(narrow.width() > 10);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Cutting happens only when the element asked for it and only when the text
|
||||
** fits at no size down to the floor. The last line that fits ends with the
|
||||
** ellipsis after as many characters as fit beside it; when not even the
|
||||
** ellipsis fits, the text is refused.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void truncationIsOnlyWhenAskedAndAlwaysShowsTheMark()
|
||||
{
|
||||
String text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
TextFit.Request box = request(Face.MONO, 100, 80, 9000, 9000);
|
||||
assertFalse(fit(box, text).fits());
|
||||
assertFalse(fit(lines(box, 2, false), text).truncated());
|
||||
|
||||
TextFit.Block cut = fit(lines(box, 1, true), text);
|
||||
assertTrue(cut.fits() && cut.truncated());
|
||||
assertEquals(List.of("ABCDE~"), shown(cut, text));
|
||||
assertEquals(List.of(26, 5), List.of(cut.characters(), cut.shown()));
|
||||
assertTrue(cut.width() <= 100);
|
||||
|
||||
TextFit.Block two = fit(lines(box, 2, true), text);
|
||||
assertEquals(List.of("ABCDEF", "GHIJK~"), shown(two, text));
|
||||
assertEquals(11, two.shown());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the box is high enough for one line only: the second is given up for the mark //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block low = fit(lines(request(Face.MONO, 100, 40, 9000, 9000), 3, true), text);
|
||||
assertEquals(List.of("ABCDE~"), shown(low, text));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a text that fits is not cut and has no mark, though cutting is allowed; one that fits //
|
||||
// after shrinking is shrunk, not cut //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of("ABCDEF"), shown(fit(lines(box, 1, true), "ABCDEF"), "ABCDEF"));
|
||||
TextFit.Block shrunk = fit(lines(request(Face.MONO, 100, 80, 9000, 6000), 1, true), "ABCDEFGH");
|
||||
assertTrue(shrunk.fits() && !shrunk.truncated() && shrunk.milliPoints() < 9000);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// spaces before the mark are dropped; the mark may stand alone; and where it cannot //
|
||||
// stand at all, or a line is too high, the answer is the refusal //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of("ABC~"), shown(fit(lines(box, 1, true), "ABC DEFGHIJKL"), "ABC DEFGHIJKL"));
|
||||
assertEquals(List.of("~"), shown(fit(lines(request(Face.MONO, 22, 80, 9000, 9000), 1, true), text), text));
|
||||
assertFalse(fit(lines(request(Face.MONO, 12, 80, 9000, 9000), 1, true), text).fits());
|
||||
assertFalse(fit(lines(request(Face.MONO, 100, 20, 9000, 9000), 1, true), text).fits());
|
||||
assertFalse(fit(lines(request(Face.MONO, 400, 20, 9000, 9000), 1, true), "ABC").fits(), "a line that is too high is too high with the mark");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the last line of a text is cut too when the text fits at no size: here one letter is //
|
||||
// wider than the box and the mark is not, and here an accent above the ascent is too //
|
||||
// high for the box and is given up for the mark //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of("~"), shown(fit(lines(request(Face.SANS, 26, 80, 9000, 9000), 1, true), "\u2030"), "\u2030")); // per mille
|
||||
TextFit.Request shallow = lines(request(Face.SANS_BOLD, 200, 36, 9000, 9000), 1, true);
|
||||
assertTrue(fit(shallow, "ABC").fits() && !fit(shallow, "ABC").truncated());
|
||||
assertEquals(List.of("AB~"), shown(fit(shallow, "AB\u01FA"), "AB\u01FA")); // A ring acute
|
||||
assertFalse(fit(lines(shallow, 1, false), "AB\u01FA").fits()); // A ring acute
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// broken at any character, spaces at the start of a text stay on a line of their own //
|
||||
// rather than push the first letter out of the box //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of(" ", "ABCDE"), shown(fit(lines(box, 2, false), " ABCDE"), " ABCDE"));
|
||||
assertEquals(List.of(" ABCD", "E"), shown(fit(lines(box, 2, false), " ABCDE"), " ABCDE"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: the answer is a layout inside the box or a refusal whose
|
||||
** measurement exceeds its limit. The size is never above the nominal and
|
||||
** never below the floor; it is on the quarter-point steps from the nominal
|
||||
** or is the floor. The lines are the text in order, whole unless cut, and
|
||||
** never more than allowed. A text is cut only when asked, only at the
|
||||
** floor, and then its last line carries the mark.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theAnswerIsALayoutInsideTheBoxOrARefusalThatMeasuresMore()
|
||||
{
|
||||
int fitted = 0;
|
||||
int refused = 0;
|
||||
int cut = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
TextFit.Request request = generated.request();
|
||||
TextFit.Block block = generated.fit();
|
||||
String about = "seed " + seed + " case " + index + ": " + request + " for " + generated.text().length() + " characters gave " + block;
|
||||
String trimmed = generated.text().stripTrailing();
|
||||
assertEquals(trimmed.length(), block.characters(), about);
|
||||
assertTrue(block.milliPoints() <= request.nominal() && block.milliPoints() >= request.floor(), about);
|
||||
assertTrue(block.milliPoints() == request.floor() || (request.nominal() - block.milliPoints()) % TextFit.STEP_MILLIPOINTS == 0, about);
|
||||
int next = 0;
|
||||
for(int line = 0; line < block.lines().size(); line++)
|
||||
{
|
||||
TextFit.Line each = block.lines().get(line);
|
||||
assertTrue(trimmed.substring(next, each.from()).isBlank() && each.to() >= each.from() && each.left() <= 0 && each.width() <= block.width(), about);
|
||||
assertTrue(each.to() > each.from() || each.marked(), about);
|
||||
assertTrue(each.to() == each.from() || trimmed.charAt(each.to() - 1) != ' ' || !each.marked(), about + ": a space before the mark");
|
||||
assertEquals(each.marked(), block.truncated() && line == block.lines().size() - 1, about);
|
||||
next = each.to();
|
||||
}
|
||||
assertEquals(block.shown(), next, about);
|
||||
if(block.fits())
|
||||
{
|
||||
fitted++;
|
||||
assertTrue(block.lines().size() <= request.maxLines() && block.width() <= request.width() && block.height() <= request.height(), about);
|
||||
}
|
||||
else
|
||||
{
|
||||
refused++;
|
||||
assertTrue(block.lines().size() > request.maxLines() || block.width() > request.width() || block.height() > request.height(), about);
|
||||
assertEquals(request.floor(), block.milliPoints(), about);
|
||||
assertFalse(block.truncated(), about);
|
||||
}
|
||||
if(block.truncated())
|
||||
{
|
||||
cut++;
|
||||
assertTrue(request.truncate() && block.fits() && block.milliPoints() == request.floor() && block.shown() < block.characters(), about);
|
||||
assertFalse(generated.at(request.floor(), request.floor()).fit().fits(), about);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertEquals(trimmed.length(), block.shown(), about);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(fitted > 300 && refused > 300 && cut > 50, "the cases must reach every outcome: " + fitted + " fitted, " + refused + " refused, " + cut + " cut");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: a text that fits at its nominal size is never shrunk, and the
|
||||
** size chosen is the largest that fits: the step above it does not.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aTextThatFitsIsNeverShrunkAndTheChosenSizeIsTheLargestThatFits()
|
||||
{
|
||||
int kept = 0;
|
||||
int shrunk = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
TextFit.Request request = generated.request();
|
||||
TextFit.Block block = generated.fit();
|
||||
String about = "seed " + seed + " case " + index + ": " + request + " gave " + block;
|
||||
TextFit.Block nominal = generated.at(request.nominal(), request.nominal()).fit();
|
||||
if(nominal.fits())
|
||||
{
|
||||
kept++;
|
||||
assertEquals(nominal, block, about);
|
||||
assertEquals(request.nominal(), block.milliPoints(), about);
|
||||
}
|
||||
else if(block.fits() && !block.truncated())
|
||||
{
|
||||
shrunk++;
|
||||
assertTrue(block.milliPoints() < request.nominal(), about);
|
||||
int steps = (request.nominal() - block.milliPoints() - 1) / TextFit.STEP_MILLIPOINTS;
|
||||
int above = request.nominal() - steps * TextFit.STEP_MILLIPOINTS;
|
||||
assertTrue(above > block.milliPoints() && above - block.milliPoints() <= TextFit.STEP_MILLIPOINTS, about);
|
||||
assertFalse(generated.at(above, above).fit().fits(), about + ", yet it fits at " + above);
|
||||
assertEquals(block, generated.at(block.milliPoints(), block.milliPoints()).fit(), about);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(kept > 200 && shrunk > 100, "the cases must reach both: " + kept + " kept, " + shrunk + " shrunk");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: in the same box a shorter text never gets a smaller size than a
|
||||
** longer one that starts with it, and fits whenever the longer one does.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aShorterTextNeverGetsASmallerSizeThanALongerOne()
|
||||
{
|
||||
int compared = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
if(generated.text().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Case all = generated.at(generated.request().nominal(), generated.request().floor());
|
||||
TextFit.Block longer = all.fit();
|
||||
TextFit.Block shorter = all.of(generated.text().substring(0, random.nextInt(generated.text().length()))).fit();
|
||||
String about = "seed " + seed + " case " + index + ": " + all.request() + " gave " + shorter + " and then " + longer;
|
||||
if(longer.fits())
|
||||
{
|
||||
compared++;
|
||||
assertTrue(shorter.fits() && shorter.milliPoints() >= longer.milliPoints() && shorter.lines().size() <= longer.lines().size(), about);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertTrue(!shorter.fits() || shorter.milliPoints() >= longer.milliPoints(), about);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(compared > 300, "compared " + compared);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: when the longest value is proven to fit (that many of the
|
||||
** face's worst character), no text of up to that many characters is
|
||||
** refused, whatever its characters, with spaces or without, cut or not.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aProvenLengthImpliesNoRefusalForAnyTextUpToThatLength()
|
||||
{
|
||||
int proven = 0;
|
||||
int tried = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
TextFit.Request request = generated.request();
|
||||
Face face = request.face();
|
||||
int maxLength = 1 + random.nextInt(40);
|
||||
Face.Glyph[] worst = new Face.Glyph[maxLength];
|
||||
Arrays.fill(worst, face.worst());
|
||||
TextFit.Block proof = TextFit.fit(request, worst, face.glyph(TextFit.MARK));
|
||||
if(!proof.fits())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
proven++;
|
||||
List<Integer> drawn = FaceTest.drawn(face);
|
||||
for(int attempt = 0; attempt < 12; attempt++)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
int length = attempt == 0 ? maxLength : 1 + random.nextInt(maxLength);
|
||||
for(int character = 0; character < length; character++)
|
||||
{
|
||||
int choice = attempt % 4;
|
||||
text.appendCodePoint(choice == 0 ? face.widest() : choice == 1 && random.nextInt(5) == 0 ? ' ' : choice == 3 ? "⁄Ǻ̉Wj".charAt(random.nextInt(6)) : drawn.get(random.nextInt(drawn.size())));
|
||||
}
|
||||
tried++;
|
||||
TextFit.Block block = generated.of(text.toString()).fit();
|
||||
assertTrue(block.fits(), () -> "seed " + seed + ": " + request + " proves " + maxLength + " characters with " + proof + " yet refuses " + text.length() + " as " + block);
|
||||
assertTrue(block.milliPoints() >= proof.milliPoints(), "a real text is never smaller than the proof");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(proven > 150 && tried > 1500, "proven " + proven + ", tried " + tried);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The proof agrees with rendering for the widest character: the longest
|
||||
** length it proves is drawn, and it is short of the longest that is really
|
||||
** drawn by no more than one character a line, which is the room the proof
|
||||
** keeps for characters that reach past their advance.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theProofAgreesWithTheWidestCharacters()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
for(int maxLines : List.of(1, 2, 3))
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(face, 700 * dpi / 203, 150 * dpi / 203, dpi, 11_000, 7000, null, maxLines, false);
|
||||
int proven = 0;
|
||||
int drawn = 0;
|
||||
for(int length = 1; length <= 200; length++)
|
||||
{
|
||||
Face.Glyph[] worst = new Face.Glyph[length];
|
||||
Arrays.fill(worst, face.worst());
|
||||
proven = TextFit.fit(request, worst, null).fits() ? length : proven;
|
||||
drawn = fit(request, new String(Character.toChars(face.widest())).repeat(length)).fits() ? length : drawn;
|
||||
}
|
||||
String about = face.scene() + " at " + dpi + " dpi on " + maxLines + " lines: proven " + proven + ", drawn " + drawn;
|
||||
assertTrue(proven >= 20 * maxLines && drawn < 200, about);
|
||||
assertTrue(proven <= drawn && drawn - proven <= maxLines, about);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: the answer is exactly the one the search of every size gives:
|
||||
** the same size, the same lines, the same numbers, fitted, refused or cut.
|
||||
** Held over the cases labels use and over everything a scene may state,
|
||||
** and for a proof of the face's worst character as for a text. The search
|
||||
** of every size visits far more characters than `fit` does.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theAnswerIsExactlyThatOfTheSearchOfEverySize()
|
||||
{
|
||||
int fitted = 0;
|
||||
int shrunk = 0;
|
||||
int refused = 0;
|
||||
int cut = 0;
|
||||
int deep = 0;
|
||||
long spent = 0;
|
||||
long searched = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < 2 * CASES; index++)
|
||||
{
|
||||
Case generated = index < CASES ? generate(random) : wide(random);
|
||||
TextFit.Request request = generated.request();
|
||||
Face face = request.face();
|
||||
Face.Glyph[] glyphs = face.glyphs(generated.text());
|
||||
if(index % 7 == 0)
|
||||
{
|
||||
glyphs = new Face.Glyph[1 + random.nextInt(60)];
|
||||
Arrays.fill(glyphs, face.worst());
|
||||
}
|
||||
TextFit.Work work = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Work every = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, face.glyph(TextFit.MARK), work);
|
||||
TextFit.Block expected = linear(request, glyphs, face.glyph(TextFit.MARK), every);
|
||||
int length = glyphs.length;
|
||||
assertEquals(expected, block, () -> "seed " + seed + " case " + request + " for " + length + " characters");
|
||||
assertEquals(block, TextFit.fit(request, glyphs, face.glyph(TextFit.MARK)), "the same with no allowance stated");
|
||||
spent += work.spent();
|
||||
searched += every.spent();
|
||||
fitted += block.fits() && !block.truncated() ? 1 : 0;
|
||||
shrunk += block.fits() && !block.truncated() && block.milliPoints() < request.nominal() ? 1 : 0;
|
||||
deep += block.fits() && request.nominal() - block.milliPoints() > 40 * TextFit.STEP_MILLIPOINTS ? 1 : 0;
|
||||
refused += block.fits() ? 0 : 1;
|
||||
cut += block.truncated() ? 1 : 0;
|
||||
}
|
||||
}
|
||||
assertTrue(fitted > 900 && shrunk > 300 && deep > 100 && refused > 500 && cut > 150, "the cases must reach every outcome: " + fitted + " fitted, " + shrunk + " shrunk, " + deep + " by more than 10 pt, " + refused + " refused, " + cut + " cut");
|
||||
assertTrue(searched > 20L * spent, "the search of every size visited " + searched + " characters, the fit " + spent);
|
||||
System.out.println("3600 fits: " + spent + " characters visited, where the search of every size visits " + searched);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The case an independent review measured at 140 to 250 ms a fit: 1024
|
||||
** characters at 600 dpi, type of 1000 pt that may shrink to 1 pt (four
|
||||
** thousand sizes), in boxes it fits at no size, at a small size, or only
|
||||
** cut. The answer is the one the search of every size gives, for under
|
||||
** forty times the characters of the text where that search visits more
|
||||
** than ten thousand times them. The time is printed; the count is held,
|
||||
** because it is the same on every machine.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theWorstCaseOfTheShrinkSearchIsHeldToAStatedWork()
|
||||
{
|
||||
String wide = "W".repeat(1024);
|
||||
String words = "The quick brown fox jumps over the lazy dog 0123456789 ".repeat(19).substring(0, 1024);
|
||||
long most = 0;
|
||||
long least = Long.MAX_VALUE;
|
||||
long nanoseconds = 0;
|
||||
long linearNanoseconds = 0;
|
||||
int fits = 0;
|
||||
for(int[] box : new int[][] {{20, 20, 64}, {20, 20, 1}, {40, 40, 64}, {200, 30, 1}, {600, 300, 64}, {2400, 1200, 64}, {2400, 1200, 1}, {9000, 30_000, 64}, {9000, 30_000, 2}})
|
||||
{
|
||||
for(String text : List.of(wide, words))
|
||||
{
|
||||
for(boolean truncate : List.of(false, true))
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS, box[0], box[1], 600, 1_000_000, 1000, null, box[2], truncate);
|
||||
Face.Glyph[] glyphs = Face.SANS.glyphs(text);
|
||||
TextFit.Work work = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Work every = new TextFit.Work(Long.MAX_VALUE);
|
||||
long start = System.nanoTime();
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), work);
|
||||
nanoseconds += System.nanoTime() - start;
|
||||
start = System.nanoTime();
|
||||
assertEquals(linear(request, glyphs, Face.SANS.glyph(TextFit.MARK), every), block, () -> "in " + request);
|
||||
linearNanoseconds += System.nanoTime() - start;
|
||||
assertTrue(work.spent() <= 40L * 1024, "in " + request + ": " + work.spent() + " characters visited");
|
||||
most = Math.max(most, work.spent());
|
||||
least = Math.min(least, every.spent());
|
||||
fits += block.fits() ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(least > 10_000L * 1024, "the search of every size visited at least " + least + " characters");
|
||||
assertTrue(fits > 20 && fits < 36, fits + " of 36 fit or are cut");
|
||||
System.out.println("1024 characters, 1000 pt to 1 pt at 600 dpi, 36 fits: at most " + most + " characters visited in a fit and " + nanoseconds / 36_000 + " microseconds a fit; the search of every size visited at least "
|
||||
+ least + " and took " + linearNanoseconds / 36_000 + " microseconds a fit");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** When one line at the floor is higher than the box, no text fits at any
|
||||
** size, and that is known without searching: the text is laid out once at
|
||||
** its size and once at the floor, for the numbers a refusal reports, and
|
||||
** nothing is tried for a cut. The answer is still that of the search of
|
||||
** every size.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aBoxLowerThanOneLineAtTheFloorIsRefusedWithoutASearch()
|
||||
{
|
||||
String text = "The quick brown fox jumps over the lazy dog 0123456789 ".repeat(19).substring(0, 1024);
|
||||
Face.Glyph[] glyphs = Face.MONO.glyphs(text);
|
||||
for(boolean truncate : List.of(false, true))
|
||||
{
|
||||
for(int maxLines : List.of(1, 64))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 1 pt at 600 dpi is 8.3 dots to the em: a line reserves 9 dots above its baseline //
|
||||
// and 3 below, and the box is 11 high //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Request request = new TextFit.Request(Face.MONO, 5000, 11, 600, 1_000_000, 1000, null, maxLines, truncate);
|
||||
TextFit.Work work = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, Face.MONO.glyph(TextFit.MARK), work);
|
||||
assertFalse(block.fits() || block.truncated());
|
||||
assertEquals(List.of(1000, 1024, 1024), List.of(block.milliPoints(), block.characters(), block.shown()));
|
||||
assertTrue(block.height() > 11 && block.width() <= 5000, "measured at the floor: " + block);
|
||||
assertEquals(linear(request, glyphs, Face.MONO.glyph(TextFit.MARK), new TextFit.Work(Long.MAX_VALUE)), block);
|
||||
assertTrue(work.spent() <= 12L * 1024, "two layouts at each of two sizes, each at most three times the text, and no more: " + work.spent());
|
||||
}
|
||||
}
|
||||
assertTrue(fit(new TextFit.Request(Face.MONO, 5000, 12, 600, 1_000_000, 1000, null, 1, true), text).truncated(), "one dot higher holds a line, and so a cut");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fitting counts the characters it visits against the allowance it is
|
||||
** given and ends, by name and with nothing of the text, when the allowance
|
||||
** is passed: at once when it is already passed, however little is asked.
|
||||
** The count is the same every time.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void fittingEndsWhenItsAllowanceIsPassed()
|
||||
{
|
||||
String text = "The quick brown fox jumps over the lazy dog";
|
||||
Face.Glyph[] glyphs = Face.SANS.glyphs(text);
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS, 300, 60, 203, 40_000, 5000, null, 2, true);
|
||||
TextFit.Work measure = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), measure);
|
||||
assertTrue(block.fits() && block.milliPoints() < 40_000 && measure.spent() > 500 && measure.spent() < 2000, measure.spent() + " characters visited for " + block);
|
||||
|
||||
TextFit.Work exact = new TextFit.Work(measure.spent());
|
||||
assertEquals(block, TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), exact));
|
||||
assertEquals(measure.spent(), exact.spent());
|
||||
|
||||
for(long allowance : List.of(0L, 1L, 43L, 200L, measure.spent() - 1))
|
||||
{
|
||||
TextFit.Work work = new TextFit.Work(allowance);
|
||||
TextFit.Exhausted exhausted = assertThrows(TextFit.Exhausted.class, () -> TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), work));
|
||||
assertEquals("TEXT_WORK_EXHAUSTED", exhausted.getMessage());
|
||||
assertEquals(0, exhausted.getStackTrace().length);
|
||||
assertTrue(work.spent() > allowance && work.spent() <= allowance + 2L * text.length() + 1, "passed by no more than one line: " + work.spent() + " of " + allowance);
|
||||
long passed = work.spent();
|
||||
assertThrows(TextFit.Exhausted.class, () -> TextFit.fit(request, Face.SANS.glyphs("A"), null, work));
|
||||
assertThrows(TextFit.Exhausted.class, () -> work.spend(0));
|
||||
assertTrue(work.spent() >= passed);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a cut is counted too: every try of the last line with the mark //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Request narrow = new TextFit.Request(Face.SANS, 120, 40, 203, 9000, 9000, null, 1, true);
|
||||
TextFit.Work cutting = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Work whole = new TextFit.Work(Long.MAX_VALUE);
|
||||
assertTrue(TextFit.fit(narrow, glyphs, Face.SANS.glyph(TextFit.MARK), cutting).truncated());
|
||||
assertFalse(TextFit.fit(lines(narrow, 1, false), glyphs, null, whole).fits());
|
||||
assertTrue(cutting.spent() > whole.spent(), cutting.spent() + " with a cut, " + whole.spent() + " without");
|
||||
assertThrows(TextFit.Exhausted.class, () -> TextFit.fit(narrow, glyphs, Face.SANS.glyph(TextFit.MARK), new TextFit.Work(whole.spent())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
/*
|
||||
* 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.scene2.text;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import com.kingsrook.qbits.renderer.scene2.Canvas;
|
||||
import com.kingsrook.qbits.renderer.scene2.Frame;
|
||||
import com.kingsrook.qbits.renderer.scene2.Layout;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement;
|
||||
import com.kingsrook.qbits.renderer.scene2.Scene2;
|
||||
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 is drawn is what was measured: the ink of every line is inside the
|
||||
** line's measured box, the block is where `align` and `valign` put it, and
|
||||
** nothing is clipped. These read the canvas the renderer composes. Every
|
||||
** text drawn here is also held to have nothing stray from its measure.
|
||||
*******************************************************************************/
|
||||
class TextRasterTest
|
||||
{
|
||||
private static final int MARGIN = 8;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text and draw it in a box that sits `MARGIN` dots inside a canvas,
|
||||
** so that ink outside the box would be seen. The box is given as the text
|
||||
** reads; turned a quarter, it is laid on its side.
|
||||
***************************************************************************/
|
||||
private static Canvas draw(TextFit.Request request, String text, Placement.Align align, Placement.Valign valign, int rotation, int threshold, int bandDots)
|
||||
{
|
||||
TextFit.Block block = TextFit.fit(request, request.face().glyphs(text), request.face().glyph(TextFit.MARK));
|
||||
assertTrue(block.fits(), "the case must fit: " + request);
|
||||
boolean upright = rotation % 180 == 0;
|
||||
Frame frame = new Frame(MARGIN, MARGIN, upright ? request.width() : request.height(), upright ? request.height() : request.width());
|
||||
Canvas canvas = new Canvas(frame.width() + 2 * MARGIN, frame.height() + 2 * MARGIN, request.dpi());
|
||||
TextRaster.Drawing drawing = new TextRaster.Drawing(request.face(), text, block, align, valign, rotation, threshold, true);
|
||||
assertEquals(new TextRaster.Stray(0, 0), TextRaster.paint(canvas, frame, drawing, bandDots), "nothing strays from the measure: " + request);
|
||||
assertEquals(0, TextRaster.outside(drawing, request.dpi()));
|
||||
return (canvas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box of the ink on a canvas: left, top, right and bottom (the last two
|
||||
** exclusive), or null when it is blank.
|
||||
***************************************************************************/
|
||||
private static Frame ink(Canvas canvas)
|
||||
{
|
||||
int left = Integer.MAX_VALUE;
|
||||
int top = -1;
|
||||
int right = -1;
|
||||
int bottom = -1;
|
||||
for(int y = 0; y < canvas.height(); y++)
|
||||
{
|
||||
int first = canvas.firstInk(y);
|
||||
if(first >= 0)
|
||||
{
|
||||
top = top < 0 ? y : top;
|
||||
bottom = y + 1;
|
||||
left = Math.min(left, first);
|
||||
right = Math.max(right, canvas.lastInk(y) + 1);
|
||||
}
|
||||
}
|
||||
return (top < 0 ? null : new Frame(left, top, right - left, bottom - top));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many dots of a canvas hold ink.
|
||||
***************************************************************************/
|
||||
private static int dots(Canvas canvas)
|
||||
{
|
||||
int dots = 0;
|
||||
for(int y = 0; y < canvas.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < canvas.width(); x++)
|
||||
{
|
||||
dots += canvas.get(x, y) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return (dots);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether two canvases hold the same picture.
|
||||
***************************************************************************/
|
||||
private static void assertSamePicture(Canvas expected, Canvas actual, String about)
|
||||
{
|
||||
assertEquals(expected.width(), actual.width(), about);
|
||||
assertEquals(expected.height(), actual.height(), about);
|
||||
for(int y = 0; y < expected.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < expected.width(); x++)
|
||||
{
|
||||
if(expected.get(x, y) != actual.get(x, y))
|
||||
{
|
||||
assertEquals(expected.get(x, y), actual.get(x, y), about + ": dot " + x + ", " + y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every character of every bundled face, at sizes and resolutions labels
|
||||
** use, is drawn with all of its ink inside the box that was measured for
|
||||
** its line. The renderer itself fails a render whose ink leaves that box
|
||||
** (the guard round each line), so drawing them all is the test; the ink is
|
||||
** then also read back from the canvas and held to the line's box.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyCharacterOfEveryFaceIsDrawnInsideItsMeasuredLine()
|
||||
{
|
||||
int[][] sizes = {{203, 6000}, {203, 9000}, {203, 14_250}, {300, 7000}, {300, 11_000}, {600, 5000}, {600, 12_250}};
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
List<Integer> drawn = FaceTest.drawn(face);
|
||||
for(int[] size : sizes)
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(size[1], size[0]);
|
||||
int inked = 0;
|
||||
for(int start = 0; start < drawn.size(); start += 16)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
drawn.subList(start, Math.min(drawn.size(), start + 16)).forEach(text::appendCodePoint);
|
||||
TextFit.Request request = new TextFit.Request(face, scale.ceil(1500) * 16 + 16, scale.ceil(2000), size[0], size[1], size[1], null, 1, false);
|
||||
Canvas canvas = draw(request, text.toString(), Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, TextRaster.BAND_DOTS);
|
||||
TextFit.Block block = TextFit.fit(request, face.glyphs(text.toString()), null);
|
||||
Frame ink = ink(canvas);
|
||||
if(ink != null)
|
||||
{
|
||||
inked++;
|
||||
TextFit.Line line = block.lines().get(0);
|
||||
Frame measured = new Frame(MARGIN, MARGIN + block.baseline() - line.above(), line.width(), line.above() + line.below());
|
||||
assertTrue(measured.contains(ink), () -> face.scene() + " at " + size[1] + " millipoints, " + size[0] + " dpi: ink " + ink + " outside the measured " + measured);
|
||||
assertTrue(new Frame(MARGIN, MARGIN, block.width(), block.height()).contains(measured));
|
||||
}
|
||||
}
|
||||
assertTrue(inked >= drawn.size() / 16 - 4, face.scene() + " drew " + inked + " lines");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: for generated texts, faces, boxes, sizes and resolutions, at
|
||||
** every rotation, no ink is outside the element's box and none is clipped,
|
||||
** and the upright picture turned is the turned picture. Up to 100 dots to
|
||||
** the em, where glyphs are drawn as bitmaps, the picture is also exactly
|
||||
** the same drawn a row at a time, so bands lose and repeat nothing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void inkIsNeverOutsideTheBoxAndNeverClipped()
|
||||
{
|
||||
Random random = new Random(TextFitTest.SEEDS[0]);
|
||||
List<Placement.Align> aligns = List.of(Placement.Align.values());
|
||||
List<Placement.Valign> valigns = List.of(Placement.Valign.values());
|
||||
int drawn = 0;
|
||||
int banded = 0;
|
||||
for(int index = 0; drawn < 160; index++)
|
||||
{
|
||||
TextFitTest.Case generated = TextFitTest.generate(random);
|
||||
if(!generated.fit().fits() || generated.text().isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
drawn++;
|
||||
TextFit.Request request = generated.request();
|
||||
Placement.Align align = aligns.get(random.nextInt(3));
|
||||
Placement.Valign valign = valigns.get(random.nextInt(3));
|
||||
int threshold = 1 + random.nextInt(254);
|
||||
String about = "case " + index + ": " + request;
|
||||
Canvas upright = draw(request, generated.text(), align, valign, 0, threshold, TextRaster.BAND_DOTS);
|
||||
Frame ink = ink(upright);
|
||||
Frame box = new Frame(MARGIN, MARGIN, request.width(), request.height());
|
||||
assertTrue(ink == null || box.contains(ink), about + ": ink " + ink + " outside " + box);
|
||||
if(new TextFit.Scale(generated.fit().milliPoints(), request.dpi()).size() <= 100)
|
||||
{
|
||||
banded++;
|
||||
assertSamePicture(upright, draw(request, generated.text(), align, valign, 0, threshold, 1), about + ", row by row");
|
||||
}
|
||||
for(int rotation : List.of(90, 180, 270))
|
||||
{
|
||||
Canvas turned = draw(request, generated.text(), align, valign, rotation, threshold, TextRaster.BAND_DOTS);
|
||||
Canvas expected = new Canvas(turned.width(), turned.height(), request.dpi());
|
||||
for(int y = 0; y < request.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < request.width(); x++)
|
||||
{
|
||||
if(upright.get(MARGIN + x, MARGIN + y))
|
||||
{
|
||||
int turnedX = rotation == 90 ? request.height() - 1 - y : rotation == 180 ? request.width() - 1 - x : y;
|
||||
int turnedY = rotation == 90 ? x : rotation == 180 ? request.height() - 1 - y : request.width() - 1 - x;
|
||||
expected.set(MARGIN + turnedX, MARGIN + turnedY, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertSamePicture(expected, turned, about + ", turned " + rotation);
|
||||
}
|
||||
}
|
||||
assertTrue(banded > 60, "drawn row by row: " + banded);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The block sits in the box where `align` and `valign` say, a centred block
|
||||
** within one dot of the middle, and each line sits in the block the same
|
||||
** way. Read from the ink of a two-line text.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theBlockIsPlacedByAlignAndValignAndCentredWithinOneDot()
|
||||
{
|
||||
String text = "HHHHHHHH HH";
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS_BOLD, 241, 151, 203, 12_000, 12_000, null, 2, false);
|
||||
TextFit.Block block = TextFit.fit(request, Face.SANS_BOLD.glyphs(text), null);
|
||||
assertEquals(List.of("HHHHHHHH", "HH"), TextFitTest.shown(block, text));
|
||||
TextFit.Line wide = block.lines().get(0);
|
||||
TextFit.Line narrow = block.lines().get(1);
|
||||
for(Placement.Align align : Placement.Align.values())
|
||||
{
|
||||
for(Placement.Valign valign : Placement.Valign.values())
|
||||
{
|
||||
Canvas canvas = draw(request, text, align, valign, 0, 128, TextRaster.BAND_DOTS);
|
||||
Frame content = Layout.align(new Frame(MARGIN, MARGIN, 241, 151), block.width(), block.height(), align, valign);
|
||||
Frame ink = ink(canvas);
|
||||
assertTrue(content.contains(ink), align + " " + valign + ": ink " + ink + " outside the block " + content);
|
||||
int slackLeft = content.x() - MARGIN;
|
||||
int slackRight = MARGIN + 241 - content.right();
|
||||
int slackTop = content.y() - MARGIN;
|
||||
int slackBottom = MARGIN + 151 - content.bottom();
|
||||
switch(align)
|
||||
{
|
||||
case LEFT -> assertEquals(0, slackLeft);
|
||||
case CENTER -> assertTrue(slackLeft >= 0 && Math.abs(slackLeft - slackRight) <= 1);
|
||||
default -> assertEquals(0, slackRight);
|
||||
}
|
||||
switch(valign)
|
||||
{
|
||||
case TOP -> assertEquals(0, slackTop);
|
||||
case MIDDLE -> assertTrue(slackTop >= 0 && Math.abs(slackTop - slackBottom) <= 1);
|
||||
default -> assertEquals(0, slackBottom);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// the ink of each line, read from the rows of its own baseline, is in the line's //
|
||||
// box, and that box is against the same side of the block (or in its middle) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
int capitals = new TextFit.Scale(12_000, 203).floor(Face.SANS_BOLD.capHeight());
|
||||
int first = content.y() + block.baseline() - capitals / 2;
|
||||
int second = first + block.pitch();
|
||||
int narrowLeft = content.x() + align.position().place(block.width(), narrow.width(), 0);
|
||||
assertTrue(canvas.firstInk(first) >= content.x() && canvas.lastInk(first) < content.x() + wide.width(), "the widest line fills the block");
|
||||
assertTrue(canvas.firstInk(second) >= narrowLeft && canvas.lastInk(second) < narrowLeft + narrow.width(), align + ": the short line " + canvas.firstInk(second) + " to " + canvas.lastInk(second));
|
||||
int gapLeft = canvas.firstInk(second) - canvas.firstInk(first);
|
||||
int gapRight = canvas.lastInk(first) - canvas.lastInk(second);
|
||||
switch(align)
|
||||
{
|
||||
case LEFT -> assertTrue(Math.abs(gapLeft) <= 1 && gapRight > 50);
|
||||
case CENTER -> assertTrue(Math.abs(gapLeft - gapRight) <= 2 && gapLeft > 25);
|
||||
default -> assertTrue(Math.abs(gapRight) <= 1 && gapLeft > 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A dot is ink when its grey is darker than the threshold, so a higher
|
||||
** threshold prints every dot a lower one prints and more: heavier strokes,
|
||||
** which is the control for small type on a thermal head.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aHigherThresholdPrintsHeavier()
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS, 500, 60, 203, 14_000, 14_000, null, 1, false);
|
||||
List<Canvas> pictures = new ArrayList<>();
|
||||
for(int threshold : List.of(1, 60, 128, 200, 254))
|
||||
{
|
||||
pictures.add(draw(request, "Lot L240611A 0O1lI", Placement.Align.LEFT, Placement.Valign.TOP, 0, threshold, TextRaster.BAND_DOTS));
|
||||
}
|
||||
for(int index = 1; index < pictures.size(); index++)
|
||||
{
|
||||
Canvas lighter = pictures.get(index - 1);
|
||||
Canvas heavier = pictures.get(index);
|
||||
assertTrue(dots(heavier) > dots(lighter), "threshold step " + index + ": " + dots(lighter) + " then " + dots(heavier));
|
||||
for(int y = 0; y < lighter.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < lighter.width(); x++)
|
||||
{
|
||||
assertFalse(lighter.get(x, y) && !heavier.get(x, y), "a dot printed at the lower threshold is printed at the higher");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(dots(pictures.get(0)) > 100, "full ink is black at every threshold");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The ends of what the renderer accepts. At 1 pt and 2 pt, where a letter
|
||||
** is a few dots, every character of every face is drawn inside its
|
||||
** measured line. At 300 pt and 1000 pt, where a letter is drawn from its
|
||||
** outline in bands, the characters of each face that reach furthest are:
|
||||
** its widest, and those that reach furthest left, right, up and down.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theSmallestAndTheLargestTypeAreDrawnInsideTheirMeasuredLine()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
List<Integer> drawn = FaceTest.drawn(face);
|
||||
for(int[] size : new int[][] {{203, 1000}, {300, 1000}, {600, 1000}, {203, 2000}, {600, 2000}})
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(size[1], size[0]);
|
||||
for(int start = 0; start < drawn.size(); start += 16)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
drawn.subList(start, Math.min(drawn.size(), start + 16)).forEach(text::appendCodePoint);
|
||||
TextFit.Request request = new TextFit.Request(face, scale.ceil(1500) * 16 + 16 * 2 * TextFit.SLOP, scale.ceil(2000) + 2 * TextFit.SLOP, size[0], size[1], size[1], null, 1, false);
|
||||
Canvas canvas = draw(request, text.toString(), Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, TextRaster.BAND_DOTS);
|
||||
TextFit.Line line = TextFit.fit(request, face.glyphs(text.toString()), null).lines().get(0);
|
||||
Frame ink = ink(canvas);
|
||||
assertTrue(ink == null || new Frame(MARGIN, MARGIN, line.width(), request.height()).contains(ink), () -> face.scene() + " at " + size[1] + " millipoints, " + size[0] + " dpi: ink " + ink);
|
||||
}
|
||||
}
|
||||
|
||||
Face.Glyph worst = face.worst();
|
||||
StringBuilder extremes = new StringBuilder().appendCodePoint(face.widest());
|
||||
for(int codePoint : drawn)
|
||||
{
|
||||
Face.Glyph glyph = face.glyph(codePoint);
|
||||
if(glyph.ink() && extremes.length() < 5 && (glyph.left() == worst.left() || glyph.right() - glyph.advance() == worst.right() - worst.advance() || glyph.top() == worst.top() || glyph.bottom() == worst.bottom()))
|
||||
{
|
||||
extremes.appendCodePoint(codePoint);
|
||||
}
|
||||
}
|
||||
assertTrue(extremes.length() >= 3, face.scene() + " has " + extremes.length() + " furthest-reaching characters");
|
||||
for(int milliPoints : List.of(300_000, 1_000_000))
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(milliPoints, 203);
|
||||
for(int index = 0; index < (milliPoints == 300_000 ? extremes.length() : 2); index++)
|
||||
{
|
||||
String text = extremes.substring(index, index + 1);
|
||||
TextFit.Request request = new TextFit.Request(face, scale.ceil(2000), scale.ceil(2000), 203, milliPoints, milliPoints, null, 1, false);
|
||||
Canvas canvas = draw(request, text, Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, TextRaster.BAND_DOTS);
|
||||
Frame ink = ink(canvas);
|
||||
assertTrue(ink.width() > scale.floor(100) && ink.height() > scale.floor(100), () -> face.scene() + " U+" + Integer.toHexString(text.codePointAt(0)) + " at " + milliPoints + " millipoints: ink " + ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ink outside the measured line is never clipped away and never printed,
|
||||
** and a text is never printed in part: what strayed is counted and the
|
||||
** label is left as it was, reverse text included, whose box is not filled.
|
||||
** Forced here with lines whose boxes are stated too small: one that ends in
|
||||
** the gap between two letters, so the second letter is wholly beyond the
|
||||
** guard and only its bounds tell, and two that cut through the letters,
|
||||
** whose ink is found in the guard. The bounds alone, which is what a
|
||||
** validation asks, count the same glyphs. Ink that would fall outside the
|
||||
** element's box is counted too, and none of that text is printed.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void inkOutsideTheMeasuredLineIsCountedAndNothingIsPainted()
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS_BOLD, 200, 60, 203, 12_000, 12_000, null, 1, false);
|
||||
TextFit.Block honest = TextFit.fit(request, Face.SANS_BOLD.glyphs("HH"), null);
|
||||
TextFit.Line line = honest.lines().get(0);
|
||||
Canvas canvas = new Canvas(220, 80, 203);
|
||||
Frame frame = new Frame(10, 10, 200, 60);
|
||||
List<TextFit.Line> wrong = List.of(new TextFit.Line(0, 2, false, line.left(), line.right() / 2, line.above(), line.below()), new TextFit.Line(0, 2, false, line.left(), line.right(), line.above() / 3, line.below()),
|
||||
new TextFit.Line(0, 2, false, line.left(), line.right(), line.above(), -line.above() / 2));
|
||||
List<TextRaster.Stray> strays = new ArrayList<>();
|
||||
for(TextFit.Line each : wrong)
|
||||
{
|
||||
TextFit.Block forged = new TextFit.Block(honest.milliPoints(), List.of(each), each.width(), honest.height(), honest.baseline(), honest.pitch(), 2, 2, true);
|
||||
for(boolean reverse : List.of(false, true))
|
||||
{
|
||||
TextRaster.Drawing drawing = new TextRaster.Drawing(Face.SANS_BOLD, "HH", forged, Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, !reverse);
|
||||
TextRaster.Stray stray = TextRaster.paint(canvas, frame, drawing, TextRaster.BAND_DOTS);
|
||||
assertTrue(stray.any() && stray.glyphs() > 0, "strayed: " + stray);
|
||||
assertEquals(stray.glyphs(), TextRaster.outside(drawing, 203));
|
||||
assertEquals(stray, TextRaster.paint(canvas, frame, drawing, 1 << 10), "the same count drawn in bands");
|
||||
assertEquals(null, ink(canvas), "nothing of a text that strayed is on the label");
|
||||
strays.add(stray);
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(new TextRaster.Stray(1, 0), new TextRaster.Stray(1, 0)), strays.subList(0, 2), "the second letter is wholly beyond the guard: only its bounds tell");
|
||||
assertTrue(strays.get(2).glyphs() == 2 && strays.get(2).dots() > 20 && strays.get(4).glyphs() == 2 && strays.get(4).dots() > 20, "ink in the guard: " + strays);
|
||||
assertFalse(new TextRaster.Stray(0, 0).any());
|
||||
assertTrue(new TextRaster.Stray(0, 1).any() && new TextRaster.Stray(1, 0).any());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a block measured honestly but handed a box too small for it: its ink would fall //
|
||||
// outside the element, to the right and below //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextRaster.Drawing whole = new TextRaster.Drawing(Face.SANS_BOLD, "HH", honest, Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, true);
|
||||
for(Frame small : List.of(new Frame(10, 10, 20, 60), new Frame(10, 10, 200, 12)))
|
||||
{
|
||||
TextRaster.Stray stray = TextRaster.paint(canvas, small, whole, TextRaster.BAND_DOTS);
|
||||
assertTrue(stray.glyphs() == 0 && stray.dots() > 20, "outside the element: " + stray);
|
||||
assertEquals(null, ink(canvas));
|
||||
}
|
||||
|
||||
Canvas clean = new Canvas(220, 80, 203);
|
||||
assertEquals(new TextRaster.Stray(0, 0), TextRaster.paint(clean, frame, whole, TextRaster.BAND_DOTS));
|
||||
assertEquals(0, TextRaster.outside(whole, 203));
|
||||
assertTrue(new Frame(10, 10, honest.width(), honest.height()).contains(ink(clean)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Very large type is drawn in bands of whole rows, so a line never needs
|
||||
** more grey than the band allows. 200 pt at 600 dpi is 1667 dots to the em.
|
||||
** The bands lose no row and repeat none: the picture is the one drawn in
|
||||
** one piece, but for a few edge dots where the rasteriser, given a band,
|
||||
** shades a curve differently: fewer than one ink dot in ten thousand (4 of
|
||||
** 121 thousand, 5 of 265 thousand and 40 of 1.06 million were seen).
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void veryLargeTypeIsDrawnInBands()
|
||||
{
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.CONDENSED_BOLD, 2400 * dpi / 600, 2400 * dpi / 600, dpi, 200_000, 200_000, null, 1, false);
|
||||
Canvas whole = draw(request, "7g", Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, Integer.MAX_VALUE);
|
||||
assertSamePicture(whole, draw(request, "7g", Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, TextRaster.BAND_DOTS), "200 pt at " + dpi + " dpi fits one band");
|
||||
Canvas banded = draw(request, "7g", Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, 1 << 16);
|
||||
int different = 0;
|
||||
for(int y = 0; y < whole.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < whole.width(); x++)
|
||||
{
|
||||
different += whole.get(x, y) == banded.get(x, y) ? 0 : 1;
|
||||
}
|
||||
}
|
||||
assertTrue(different * 10_000L <= dots(whole), "200 pt at " + dpi + " dpi: " + different + " dots differ of " + dots(whole));
|
||||
System.out.println("200 pt at " + dpi + " dpi in bands: " + different + " of " + dots(whole) + " ink dots differ from the picture drawn in one piece");
|
||||
assertEquals(ink(whole), ink(banded));
|
||||
int capitals = new TextFit.Scale(200_000, dpi).floor(Face.CONDENSED_BOLD.capHeight());
|
||||
assertTrue(ink(banded).height() > capitals && ink(banded).height() < 2 * capitals && ink(banded).width() > capitals, "the digits are the size asked: " + ink(banded));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Golden labels of version 2 scenes
|
||||
|
||||
Each case in `cases` has, in `golden`, at 203, 300 and 600 dpi, the exact ZPL the renderer emits
|
||||
(`<case>-<dpi>.zpl`) and the page a printer draws from it (`<case>-<dpi>.png`). `SceneGoldenTest` compares both
|
||||
exactly, on every machine, and holds `golden` to exactly those files, which is why this note is beside it.
|
||||
|
||||
## What the pictures depend on
|
||||
|
||||
A box is whole dots of ink and depends on the scene alone. Text is measured by integer arithmetic on the bundled
|
||||
faces, which is the same everywhere, but its glyph bitmaps are drawn by the Java runtime: the Java 2D rasteriser
|
||||
and the FreeType library that the JDK itself ships (`lib/libfreetype.so` on Linux, `bin/freetype.dll` on
|
||||
Windows). The system's FreeType is not used: in the images below `libfontmanager.so` loads the JDK's own copy.
|
||||
The version of that copy is the one the JDK states in `legal/java.desktop/freetype.md`.
|
||||
|
||||
The text goldens (`text-*`) were generated and compared on these runtimes, all x86-64:
|
||||
|
||||
| Where | JDK | FreeType in the JDK | The goldens there |
|
||||
|---|---|---|---|
|
||||
| CI image `maven:3.9.11-eclipse-temurin-21` (Ubuntu 24.04) | Temurin 21.0.9+10 | 2.13.3 | generated here; compared exactly by the build |
|
||||
| Windows 11 | Temurin 21.0.12.1+1 | 2.14.3 | the same bytes; compared exactly by the build |
|
||||
| The worker image's runtime (`renderer/Dockerfile` requires `jdk-21.0.13+9` on Ubuntu 24.04), as `eclipse-temurin:21-jre-noble` | Temurin 21.0.13+9 | 2.14.3 | the same bytes; the 21 labels were rendered there and compared by hand, not by a build |
|
||||
|
||||
So the same bytes came from two FreeType versions and three JDK builds. That is an observation about these
|
||||
versions, not a promise about the next: a golden that changes with a runtime is a reviewed change.
|
||||
|
||||
## Changing a golden
|
||||
|
||||
A golden changes only with a reviewed change to the layout rules or to the runtime. Run `SceneGoldenTest` in the
|
||||
CI image; what the build drew is left in `renderer/target/scene2-actual`. Copy the files to `golden`, then run the
|
||||
test on Windows as well: it must pass there on the same bytes. Say in the change which goldens changed and why, and
|
||||
update the table above when a runtime changes.
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"stock": {"widthMicrons": 50800, "heightMicrons": 25400, "topMicrons": 2000, "rightMicrons": 2000, "bottomMicrons": 2000, "leftMicrons": 2000, "dpi": 203},
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/2",
|
||||
"design": {"width": "2in", "height": "1in"},
|
||||
"elements": [
|
||||
{"id": "top-left", "type": "box", "anchor": "top-left", "x": "1mm", "y": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"},
|
||||
{"id": "top-center", "type": "box", "anchor": "top-center", "y": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"},
|
||||
{"id": "top-right", "type": "box", "anchor": "top-right", "x": "1mm", "y": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"},
|
||||
{"id": "middle-left", "type": "box", "anchor": "middle-left", "x": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"},
|
||||
{"id": "center", "type": "box", "anchor": "center", "width": "6mm", "height": "3mm", "fill": true},
|
||||
{"id": "left-of-center", "type": "box", "anchor": "center", "x": "-8mm", "y": "-2mm", "width": "4mm", "height": "2mm", "thickness": "0.3mm"},
|
||||
{"id": "middle-right", "type": "box", "anchor": "middle-right", "x": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"},
|
||||
{"id": "bottom-left", "type": "box", "anchor": "bottom-left", "x": "1mm", "y": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"},
|
||||
{"id": "bottom-center", "type": "box", "anchor": "bottom-center", "y": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"},
|
||||
{"id": "bottom-right", "type": "box", "anchor": "bottom-right", "x": "1mm", "y": "1mm", "width": "6mm", "height": "3mm", "thickness": "0.3mm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"stock": {"widthMicrons": 50800, "heightMicrons": 25400, "topMicrons": 2000, "rightMicrons": 2000, "bottomMicrons": 2000, "leftMicrons": 2000, "dpi": 203},
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/2",
|
||||
"design": {"width": "2in", "height": "1in"},
|
||||
"elements": [
|
||||
{"id": "panel", "type": "group", "anchor": "center", "width": "30mm", "height": "12mm", "children": [
|
||||
{"id": "border", "type": "box", "thickness": "0.3mm"},
|
||||
{"id": "inner", "type": "group", "anchor": "bottom-right", "x": "1mm", "y": "1mm", "width": "50%", "height": "50%", "children": [
|
||||
{"id": "dot", "type": "box", "anchor": "center", "width": "50%", "height": "50%", "fill": true},
|
||||
{"id": "corner", "type": "box", "width": "25%", "height": "25%", "thickness": "0.2mm"}
|
||||
]}
|
||||
]},
|
||||
{"id": "tag", "type": "group", "width": "20%", "height": "30%", "children": [
|
||||
{"id": "bar", "type": "box", "anchor": "middle-left", "width": "100%", "height": "1mm", "fill": true}
|
||||
]}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loaded 100 of 147 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user