Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
595d094ec8
|
||
|
|
58051e7b99
|
||
|
|
1992fdf703
|
||
|
|
ad6b0ffa88
|
||
|
|
4cd2f93e37
|
||
|
|
f4342e345b
|
||
|
|
fda9ad0de6
|
||
|
|
c6aa0bf6b0
|
||
|
|
3ba676106f
|
||
|
|
ea776391ca
|
||
|
|
22063ccfb1
|
||
|
|
9a355670c0 | ||
|
|
228931a937
|
||
|
|
cfbf0744f9
|
||
|
|
1dc265647d | ||
|
|
e762dac5d5
|
||
|
|
e19feb01a8
|
||
|
|
d8a57607fd | ||
|
|
889296d8f5
|
||
|
|
e085859a25
|
||
|
|
4f850c2cf3
|
||
|
|
f9adf1d3d3 | ||
|
|
2dcee6fbea
|
||
|
|
34a3666bbf | ||
|
|
6a3153456e | ||
|
|
9073e9bf1c | ||
|
|
207e3ce034 | ||
|
|
063b1e8136
|
No files matched your search
@@ -12,3 +12,7 @@
|
||||
*.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
|
||||
@@ -3,5 +3,5 @@ on:
|
||||
push:
|
||||
jobs:
|
||||
maven:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@main
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/maven.yaml@v1
|
||||
secrets: inherit
|
||||
@@ -72,7 +72,7 @@ The root `pom.xml` is an aggregator only and lists every module, so one build pr
|
||||
|
||||
Run `./mvnw verify` (`.\mvnw.cmd verify` on Windows) at the repository root. The foundation's tests include disposable MySQL 8.4 and PostgreSQL suites started with Testcontainers, so Docker access is required. They verify publication concurrency/commit/rollback, physical pooled-connection restoration, mapping/settings drift refusal, bounded lock contention, current-authorized context selection and immutable pins, audit/receipt failures, restart and incomplete results. They do not qualify production storage, hostile workers or physical printer output.
|
||||
|
||||
The build enforces Checkstyle plus 85% instruction/90% class coverage in the foundation. The published `qbit-build-parent` POMs leave `${revision}` in their own version, which causes a Maven parent-resolution warning despite a successful build. That upstream publication issue remains open; it is neither suppressed nor patched.
|
||||
The build enforces Checkstyle plus 85% instruction/90% class coverage in the foundation, and holds the renderer (94% instructions, 91% lines) and its bridge (85% instructions, 82% lines) to their measured baselines; the new packages and classes named in each `pom.xml` must reach 100% line coverage. Bounded mutation campaigns over the scene parser and the host printer gate run in the test phase; see [docs/security/fuzzing.md](docs/security/fuzzing.md). The published `qbit-build-parent` POMs leave `${revision}` in their own version, which causes a Maven parent-resolution warning despite a successful build. That upstream publication issue remains open; it is neither suppressed nor patched.
|
||||
|
||||
## Maven publication
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
<parent><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-reactor</artifactId><version>${revision}</version><relativePath>../../pom.xml</relativePath></parent>
|
||||
<groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-printing</artifactId><version>${revision}</version>
|
||||
<name>Document Generation Printing Bridge</name>
|
||||
<properties><qqq.version>4.1.0-RC.22</qqq.version><!-- Released sibling QBit coordinates from the Gitea Maven registry --><qbit-record-files.version>0.1.0-RC.1</qbit-record-files.version><maven.compiler.release>21</maven.compiler.release><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<properties><qqq.version>4.1.0-RC.26</qqq.version><!-- Released sibling QBit coordinates from the Gitea Maven registry --><qbit-record-files.version>0.4.0-RC.1</qbit-record-files.version><maven.compiler.release>21</maven.compiler.release><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<!-- Released Printing 1.0.0: RetainedPrintFacade.Acceptance carries title/optionsJson and PrintingService enforces options.mediaProfile id+revision. -->
|
||||
<qbit-printing.version>1.0.0</qbit-printing.version><enforcer.skip>true</enforcer.skip></properties>
|
||||
<qbit-printing.version>1.2.0-RC.1</qbit-printing.version><enforcer.skip>true</enforcer.skip></properties>
|
||||
<dependencyManagement><dependencies><dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId><version>${qqq.version}</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.version}</version></dependency>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<parent><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-reactor</artifactId><version>${revision}</version><relativePath>../../pom.xml</relativePath></parent>
|
||||
<groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-record-files</artifactId><version>${revision}</version>
|
||||
<name>Document Generation Record Files Bridge</name>
|
||||
<properties><qqq.version>4.1.0-RC.22</qqq.version><!-- Released sibling QBit coordinates from the Gitea Maven registry --><qbit-record-files.version>0.1.0-RC.1</qbit-record-files.version><maven.compiler.release>21</maven.compiler.release><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>true</enforcer.skip></properties>
|
||||
<properties><qqq.version>4.1.0-RC.26</qqq.version><!-- Released sibling QBit coordinates from the Gitea Maven registry --><qbit-record-files.version>0.4.0-RC.1</qbit-record-files.version><maven.compiler.release>21</maven.compiler.release><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>true</enforcer.skip></properties>
|
||||
<dependencyManagement><dependencies><dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId><version>${qqq.version}</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.version}</version></dependency>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
# Build only on an operator-approved, immutable existing renderer image.
|
||||
# This file does not build/download the engine or confer isolation qualification.
|
||||
# Build only on an operator-approved, immutable existing renderer image (RENDERER_IMAGE, the engine image from
|
||||
# renderer/Dockerfile). This file does not build the engine or confer isolation qualification.
|
||||
# The service is the RELEASED transport artifact, resolved at the exact TRANSPORT_VERSION from the QRun-IO Gitea Maven
|
||||
# registry and Maven Central (no SNAPSHOT, no local build, no default). BUILD_IMAGE must be an immutable digest.
|
||||
# docker build --build-arg BUILD_IMAGE=... --build-arg RENDERER_IMAGE=... --build-arg TRANSPORT_VERSION=1.0.0 bridges/remote-renderer/
|
||||
ARG BUILD_IMAGE
|
||||
ARG RENDERER_IMAGE
|
||||
FROM ${BUILD_IMAGE} AS release
|
||||
ARG TRANSPORT_VERSION
|
||||
WORKDIR /release
|
||||
COPY release-image/pom.xml ./pom.xml
|
||||
RUN test -n "${TRANSPORT_VERSION}" && case "${TRANSPORT_VERSION}" in *SNAPSHOT*|*forge*) echo "TRANSPORT_VERSION must be a released version" >&2; exit 1;; esac \
|
||||
&& mvn -B -Dtransport.version="${TRANSPORT_VERSION}" -Dmaven.repo.local=/release/.m2 package \
|
||||
&& mv "target/lib/qqq-remote-renderer-transport-${TRANSPORT_VERSION}.jar" target/service.jar \
|
||||
&& test -f target/service.jar && ls target/lib
|
||||
|
||||
FROM ${RENDERER_IMAGE}
|
||||
COPY transport/target/qqq-remote-renderer-transport-0.1.0-SNAPSHOT.jar /remote/service.jar
|
||||
COPY transport/target/lib/ /remote/lib/
|
||||
COPY --from=release /release/target/service.jar /remote/service.jar
|
||||
COPY --from=release /release/target/lib/ /remote/lib/
|
||||
COPY LICENSE NOTICE /usr/share/doc/qqq-remote-renderer/
|
||||
USER 65532:65532
|
||||
EXPOSE 18445
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Image-assembly project, not a published module. It resolves the RELEASED remote renderer transport service and its runtime
|
||||
libraries from the QRun-IO Gitea Maven registry (and Maven Central) at one exact version, so the service image
|
||||
carries release bytes and not a local build. transport.version has no default: pass -Dtransport.version=<released>. -->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.kingsrook.qbits</groupId><artifactId>qqq-remote-renderer-image</artifactId><version>0</version><packaging>pom</packaging>
|
||||
<repositories>
|
||||
<repository><id>central</id><url>https://repo.maven.apache.org/maven2</url><snapshots><enabled>false</enabled></snapshots></repository>
|
||||
<repository><id>qrun-io</id><url>https://git.kof22.com/api/packages/QRun-IO/maven</url><snapshots><enabled>false</enabled></snapshots><releases><checksumPolicy>fail</checksumPolicy></releases></repository>
|
||||
</repositories>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qqq-remote-renderer-transport</artifactId><version>${transport.version}</version></dependency>
|
||||
</dependencies>
|
||||
<build><plugins>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.9.0</version>
|
||||
<executions><execution><id>release-libraries</id><phase>package</phase><goals><goal>copy-dependencies</goal></goals>
|
||||
<configuration><includeScope>runtime</includeScope><outputDirectory>${project.build.directory}/lib</outputDirectory></configuration>
|
||||
</execution></executions></plugin>
|
||||
</plugins></build>
|
||||
</project>
|
||||
@@ -37,6 +37,23 @@
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version><configuration><dependenciesToScan combine.self="override"/></configuration></plugin>
|
||||
<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
|
||||
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. -->
|
||||
<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>
|
||||
</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>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
|
||||
</limits></rule>
|
||||
</rules></configuration></execution>
|
||||
</executions></plugin>
|
||||
<plugin>
|
||||
<!-- Publish a self-contained POM: ${revision}/property versions are resolved at build time, so a consumer needs no -Drevision -->
|
||||
<groupId>org.codehaus.mojo</groupId><artifactId>flatten-maven-plugin</artifactId><version>1.7.3</version>
|
||||
|
||||
+11
@@ -43,6 +43,13 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
*******************************************************************************/
|
||||
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 final RendererLimits config;
|
||||
private final AssetReader assets;
|
||||
private final Exchange exchange;
|
||||
@@ -268,6 +275,10 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
{
|
||||
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;
|
||||
|
||||
+13
-1
@@ -183,13 +183,25 @@ class BridgeTest
|
||||
@Test
|
||||
void exactWorkerErrorsPreserveClosedFailureCodes() throws Exception
|
||||
{
|
||||
assertEquals(13, GenerationPorts.FailureCode.values().length);
|
||||
var released = java.util.EnumSet.range(GenerationPorts.FailureCode.BARCODE_FIT_TOO_SMALL, GenerationPorts.FailureCode.UNSUPPORTED_BARCODE_OPTION);
|
||||
assertEquals(13, released.size());
|
||||
assertEquals(28, GenerationPorts.FailureCode.values().length);
|
||||
for(var code : GenerationPorts.FailureCode.values())
|
||||
{
|
||||
var bridge = new IsolatedRendererBridge(config(directory), (actor, asset, maximum) -> new byte[0], (id, input, maximum) ->
|
||||
(RendererJson.JSON.writeValueAsString(Map.of("protocol", RendererJson.PROTOCOL, "requestId", id, "outcome", "ERROR",
|
||||
"rendererVersion", "qqq-renderer-0.1.0", "catalogVersion", "qqq-barcodes-9", "code", code.name())) + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
var request = request(Map.of(), List.of());
|
||||
if(!released.contains(code))
|
||||
{
|
||||
// A version 2 scene refusal named by a 0.1.0 worker stays as unknown as it was before the vocabulary grew.
|
||||
for(var generic : List.of(assertThrows(QException.class, () -> bridge.render(request)), assertThrows(QException.class, () -> bridge.validate("actor-1", request.revision()))))
|
||||
{
|
||||
assertEquals(QException.class, generic.getClass(), code.name());
|
||||
assertEquals("Isolated renderer rejected or could not complete the request.", generic.getMessage());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
var failure = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(request));
|
||||
assertEquals(code, failure.code());
|
||||
assertEquals(code.name(), failure.getMessage());
|
||||
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.SplittableRandom;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seeded, bounded mutation campaign for a parser of untrusted input. A seed, an
|
||||
** iteration number, the corpus and the dictionary fully determine every input:
|
||||
** there is no clock and no unseeded randomness, so a finding replays exactly.
|
||||
** Targets, limits and the replay command are in `docs/security/fuzzing.md`. The
|
||||
** same class is kept in the test tree of `renderer`.
|
||||
*******************************************************************************/
|
||||
final class MutationCampaign
|
||||
{
|
||||
static final int MAXIMUM_ITERATIONS = 100_000;
|
||||
static final int MAXIMUM_INPUT_BYTES = 1024 * 1024;
|
||||
|
||||
private static final byte[] INTERESTING = {0, 9, 10, 13, ' ', '"', '\\', '^', '~', '_', ',', '{', '}', '[', ']', '0', '9', '-', (byte) 0x7F, (byte) 0x80, (byte) 0xC0, (byte) 0xFF};
|
||||
|
||||
private final List<byte[]> corpus;
|
||||
private final List<byte[]> dictionary;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The parser under test. Returning normally means the input was accepted; a
|
||||
** target asserts its own property on what it accepts.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface Target
|
||||
{
|
||||
/************************************************************************
|
||||
** Parse one generated input.
|
||||
************************************************************************/
|
||||
void accept(byte[] input) throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An input that was neither accepted nor refused in the declared way, with
|
||||
** the coordinates that regenerate it: `input(seed, iteration)`.
|
||||
***************************************************************************/
|
||||
record Finding(long seed, int iteration, String failure)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What a campaign saw. A campaign that accepted or refused nothing has not
|
||||
** exercised both sides of the parser, so callers assert on the counts too.
|
||||
***************************************************************************/
|
||||
record Result(int accepted, int refused, List<Finding> findings)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Valid inputs to start from, and tokens of the format to splice in.
|
||||
***************************************************************************/
|
||||
MutationCampaign(List<byte[]> corpus, List<String> dictionary)
|
||||
{
|
||||
if(corpus.isEmpty() || corpus.stream().anyMatch(seedInput -> seedInput.length > MAXIMUM_INPUT_BYTES))
|
||||
{
|
||||
throw new IllegalArgumentException("A campaign needs a corpus of inputs within the input limit.");
|
||||
}
|
||||
this.corpus = corpus.stream().map(byte[]::clone).toList();
|
||||
this.dictionary = dictionary.stream().map(token -> token.getBytes(StandardCharsets.UTF_8)).toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The seeds for this run: the fixed ones, or only `-Dfuzz.seed=<n>` when a
|
||||
** finding is replayed or a longer campaign is run by hand.
|
||||
***************************************************************************/
|
||||
static long[] seeds(long... fixed)
|
||||
{
|
||||
Long replay = Long.getLong("fuzz.seed");
|
||||
return (replay == null ? fixed : new long[] {replay});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The iteration count for this run: `-Dfuzz.iterations=<n>`, never unbounded.
|
||||
***************************************************************************/
|
||||
static int iterations(int fallback)
|
||||
{
|
||||
int iterations = Integer.getInteger("fuzz.iterations", fallback);
|
||||
if(iterations < 1 || iterations > MAXIMUM_ITERATIONS)
|
||||
{
|
||||
throw new IllegalArgumentException("fuzz.iterations must be between 1 and " + MAXIMUM_ITERATIONS);
|
||||
}
|
||||
return (iterations);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The input of one iteration: a corpus entry with one to four mutations.
|
||||
***************************************************************************/
|
||||
byte[] input(long seed, int iteration)
|
||||
{
|
||||
SplittableRandom random = new SplittableRandom(seed + 0x9E3779B97F4A7C15L * (iteration + 1L));
|
||||
byte[] value = corpus.get(random.nextInt(corpus.size())).clone();
|
||||
int steps = 1 + random.nextInt(4);
|
||||
for(int step = 0; step < steps; step++)
|
||||
{
|
||||
value = mutate(value, random);
|
||||
}
|
||||
return (value.length > MAXIMUM_INPUT_BYTES ? Arrays.copyOf(value, MAXIMUM_INPUT_BYTES) : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Run iterations 0 to `iterations - 1`. A thrown failure that `refusal`
|
||||
** recognises is the parser saying no; any other, including a failed assertion
|
||||
** in the target and a stack overflow, is a finding.
|
||||
***************************************************************************/
|
||||
Result run(long seed, int iterations, Target target, Predicate<Throwable> refusal)
|
||||
{
|
||||
if(iterations < 1 || iterations > MAXIMUM_ITERATIONS)
|
||||
{
|
||||
throw new IllegalArgumentException("A campaign runs between 1 and " + MAXIMUM_ITERATIONS + " iterations.");
|
||||
}
|
||||
int accepted = 0;
|
||||
int refused = 0;
|
||||
List<Finding> findings = new ArrayList<>();
|
||||
for(int iteration = 0; iteration < iterations; iteration++)
|
||||
{
|
||||
try
|
||||
{
|
||||
target.accept(input(seed, iteration));
|
||||
accepted++;
|
||||
}
|
||||
catch(Exception | AssertionError | StackOverflowError failure)
|
||||
{
|
||||
if(refusal.test(failure))
|
||||
{
|
||||
refused++;
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(new Finding(seed, iteration, failure.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Result(accepted, refused, List.copyOf(findings)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One structural or byte-level change at a random position.
|
||||
***************************************************************************/
|
||||
private byte[] mutate(byte[] value, SplittableRandom random)
|
||||
{
|
||||
int at = value.length == 0 ? 0 : random.nextInt(value.length);
|
||||
int span = value.length == 0 ? 0 : 1 + random.nextInt(Math.min(16, value.length - at));
|
||||
byte[] noise = new byte[1 + random.nextInt(8)];
|
||||
random.nextBytes(noise);
|
||||
return (switch(random.nextInt(8))
|
||||
{
|
||||
case 0 -> splice(value, at, span, new byte[0]);
|
||||
case 1 -> splice(value, at, 0, Arrays.copyOfRange(value, at, at + span));
|
||||
case 2 -> Arrays.copyOf(value, at);
|
||||
case 3 -> splice(value, at, 0, dictionary.isEmpty() ? noise : dictionary.get(random.nextInt(dictionary.size())));
|
||||
case 4 -> splice(value, at, 0, noise);
|
||||
case 5 -> splice(value, at, Math.min(1, value.length), new byte[] {INTERESTING[random.nextInt(INTERESTING.length)]});
|
||||
case 6 -> splice(value, at, Math.min(1, value.length), new byte[] {noise[0]});
|
||||
default -> splice(value, at, Math.min(1, value.length), value.length == 0 ? noise : new byte[] {(byte) (value[at] ^ (1 << random.nextInt(8)))});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Replace `removed` bytes at `at` with `inserted`.
|
||||
***************************************************************************/
|
||||
private static byte[] splice(byte[] value, int at, int removed, byte[] inserted)
|
||||
{
|
||||
byte[] result = new byte[value.length - removed + inserted.length];
|
||||
System.arraycopy(value, 0, result, 0, at);
|
||||
System.arraycopy(inserted, 0, result, at, inserted.length);
|
||||
System.arraycopy(value, at + removed, result, at + inserted.length, value.length - at - removed);
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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.CharacterCodingException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
|
||||
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.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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`.
|
||||
*******************************************************************************/
|
||||
class MutationCampaignTest
|
||||
{
|
||||
private static final long[] SEEDS = {20261111L, 7919L};
|
||||
private static final int ITERATIONS = 20_000;
|
||||
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");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A seed and an iteration name one input, on every run and every machine.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aSeedAndAnIterationAlwaysGiveTheSameInput()
|
||||
{
|
||||
MutationCampaign campaign = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB"));
|
||||
MutationCampaign again = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB"));
|
||||
Set<String> distinct = new HashSet<>();
|
||||
for(int iteration = 0; iteration < 200; iteration++)
|
||||
{
|
||||
byte[] input = campaign.input(42L, iteration);
|
||||
assertArrayEquals(input, again.input(42L, iteration));
|
||||
assertTrue(input.length <= MutationCampaign.MAXIMUM_INPUT_BYTES);
|
||||
distinct.add(HexFormat.of().formatHex(input));
|
||||
}
|
||||
assertTrue(distinct.size() > 100, "mutations should rarely repeat: " + distinct.size());
|
||||
assertNotEquals(HexFormat.of().formatHex(campaign.input(42L, 0)), HexFormat.of().formatHex(campaign.input(43L, 0)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The harness fails when it should: a crash and an unsafe acceptance are both
|
||||
** findings, each carrying the coordinates that regenerate its input.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aCrashAndAnUnsafeAcceptanceAreFindingsThatReplay()
|
||||
{
|
||||
MutationCampaign campaign = new MutationCampaign(List.of("0123456789".getBytes(StandardCharsets.US_ASCII)), List.of());
|
||||
MutationCampaign.Result result = campaign.run(42L, 300, input ->
|
||||
{
|
||||
if(input.length % 3 == 0)
|
||||
{
|
||||
throw new IllegalArgumentException("REFUSED");
|
||||
}
|
||||
if(input.length % 3 == 1)
|
||||
{
|
||||
throw new IllegalStateException("crash");
|
||||
}
|
||||
assertTrue(input.length < 11, "accepted an input it should have refused");
|
||||
}, IllegalArgumentException.class::isInstance);
|
||||
assertEquals(300, result.accepted() + result.refused() + result.findings().size());
|
||||
assertTrue(result.accepted() > 0 && result.refused() > 0);
|
||||
assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().startsWith("java.lang.IllegalStateException")));
|
||||
assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().contains("AssertionFailedError")));
|
||||
for(MutationCampaign.Finding finding : result.findings())
|
||||
{
|
||||
int length = campaign.input(finding.seed(), finding.iteration()).length;
|
||||
assertTrue(length % 3 == 1 || (length % 3 == 2 && length >= 11), finding.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A campaign is always bounded and always has something to mutate.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void campaignsAreBounded()
|
||||
{
|
||||
MutationCampaign campaign = new MutationCampaign(List.of(new byte[0]), List.of());
|
||||
assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, 0, input -> {}, failure -> true));
|
||||
assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, MutationCampaign.MAXIMUM_ITERATIONS + 1, input -> {}, failure -> true));
|
||||
assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(), List.of()));
|
||||
assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(new byte[MutationCampaign.MAXIMUM_INPUT_BYTES + 1]), List.of()));
|
||||
assertEquals(50, campaign.run(1L, 50, input -> {}, failure -> false).accepted());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: whatever the host printer gate accepts holds only the commands
|
||||
** the renderer may emit, and no tilde command. Every other input is refused
|
||||
** with the gate's own IllegalArgumentException, or with the strict decoder's
|
||||
** CharacterCodingException for field text that is not UTF-8; any other
|
||||
** failure is a finding.
|
||||
***************************************************************************/
|
||||
@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"));
|
||||
int iterations = MutationCampaign.iterations(ITERATIONS);
|
||||
for(long seed : MutationCampaign.seeds(SEEDS))
|
||||
{
|
||||
MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::gate,
|
||||
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 target, and an independent reading of what it let through.
|
||||
***************************************************************************/
|
||||
private static void gate(byte[] zpl) throws Exception
|
||||
{
|
||||
ZplOutputPolicy.validate(zpl, STOCK);
|
||||
for(int index = 0; index < zpl.length; index++)
|
||||
{
|
||||
assertNotEquals('~', (char) zpl[index], "accepted a tilde command");
|
||||
if(zpl[index] == '^')
|
||||
{
|
||||
assertTrue(index + 2 < zpl.length && COMMANDS.contains(new String(zpl, index + 1, 2, StandardCharsets.US_ASCII)), "accepted a command outside the renderer set");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* 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.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
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.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The released version 1 bytes, as the host receives them: each stored ZPL of
|
||||
** the renderer's `compat-v1` corpus is exactly the bytes its manifest names and
|
||||
** passes the unchanged host printer gate for the stock it was rendered for. The
|
||||
** corpus has one copy, in the renderer module, so the two tests cannot drift.
|
||||
*******************************************************************************/
|
||||
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";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One manifest line per stored output: hash, two spaces, path.
|
||||
***************************************************************************/
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A released label that the host gate refused could never have printed.
|
||||
***************************************************************************/
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("outputs")
|
||||
void releasedBytesAreIntactAndPassTheHostPrinterGate(String line) throws Exception
|
||||
{
|
||||
assertTrue(line.matches("[0-9a-f]{64} zpl/[a-z0-9-]+\\.zpl"), line);
|
||||
String name = line.substring(70, line.length() - 4);
|
||||
byte[] bytes = Files.readAllBytes(CORPUS.resolve("zpl").resolve(name + ".zpl"));
|
||||
assertEquals(line.substring(0, 64), HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), name);
|
||||
JsonNode stock = RendererJson.JSON.readTree(Files.readAllBytes(CORPUS.resolve("cases").resolve(name + ".json"))).path("stock");
|
||||
GenerationPorts.Stock printed = 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());
|
||||
assertDoesNotThrow(() -> ZplOutputPolicy.validate(bytes, printed), name);
|
||||
}
|
||||
}
|
||||
+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));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
<groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-generation-scripting</artifactId><version>${revision}</version>
|
||||
<name>Document Generation Scripting Bridge</name>
|
||||
<licenses><license><name>GNU Affero General Public License v3.0</name><url>https://www.gnu.org/licenses/agpl-3.0-standalone.html</url><distribution>repo</distribution></license></licenses>
|
||||
<properties><qqq.version>4.1.0-RC.22</qqq.version><!-- Released sibling QBit coordinates from the Gitea Maven registry --><qbit-scripting.version>1.0.0</qbit-scripting.version><maven.compiler.release>21</maven.compiler.release><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>true</enforcer.skip></properties>
|
||||
<properties><qqq.version>4.1.0-RC.26</qqq.version><!-- Released sibling QBit coordinates from the Gitea Maven registry --><qbit-scripting.version>1.1.0-RC.3</qbit-scripting.version><maven.compiler.release>21</maven.compiler.release><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>true</enforcer.skip></properties>
|
||||
<dependencyManagement><dependencies>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-core</artifactId><version>${qqq.version}</version></dependency>
|
||||
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.version}</version></dependency>
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,98 @@
|
||||
# Parser fuzzing
|
||||
|
||||
## Scope and properties
|
||||
|
||||
K22-TEST-25 applies: this repository parses untrusted input at two boundaries. Templates are written by users,
|
||||
and the isolated renderer is treated as compromisable, so its output is untrusted to the host.
|
||||
|
||||
| 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` |
|
||||
|
||||
The harness is `MutationCampaign`, one copy in each module's test tree (the modules share no test code). It
|
||||
mutates valid inputs from the version 1 compatibility corpus (`renderer/src/test/resources/compat-v1`) and the
|
||||
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:
|
||||
|
||||
- **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` and `group` elements with only their declared properties
|
||||
(the other types 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. 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). 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. 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.
|
||||
|
||||
Each campaign also asserts that it reached both acceptance and refusal, so a campaign that only ever refuses
|
||||
cannot pass.
|
||||
|
||||
This is seeded generated-input fuzzing, not coverage-guided instrumentation. Do not claim OSS-Fuzz or
|
||||
ClusterFuzzLite enrolment.
|
||||
|
||||
## Running and replaying
|
||||
|
||||
The campaigns run in the normal test phase, so every `./mvnw verify`, and therefore every CI build of a pull
|
||||
request or a push, runs them. No network, clock or unseeded randomness is used: a seed and an iteration
|
||||
number determine the input.
|
||||
|
||||
```sh
|
||||
# What CI runs: seeds 20261111 and 7919, 20000 iterations each, per target.
|
||||
./mvnw -B -pl renderer,bridges/renderer -am verify
|
||||
|
||||
# Replay a finding, or run a longer campaign before a MINOR or MAJOR release (both seeds at 100000).
|
||||
./mvnw -B -pl renderer -am test -Dtest=MutationCampaignTest -Dsurefire.failIfNoSpecifiedTests=false -Dfuzz.seed=20261111 -Dfuzz.iterations=100000
|
||||
./mvnw -B -pl bridges/renderer -am test -Dtest=MutationCampaignTest -Dsurefire.failIfNoSpecifiedTests=false -Dfuzz.seed=20261111 -Dfuzz.iterations=100000
|
||||
```
|
||||
|
||||
A failure lists each finding as `Finding[seed, iteration, failure]`. `MutationCampaign.input(seed, iteration)`
|
||||
returns that exact input against the same checkout. Reduce it to a named regression test beside the parser's
|
||||
other tests, fix the parser, and rerun both seeds. Report a suspected vulnerability privately rather than in a
|
||||
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. 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. `ZplRaster` is the next target.
|
||||
|
||||
## Review record
|
||||
|
||||
- **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`).
|
||||
- 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
|
||||
for. The long campaign is the manual command above until a scheduled job exists.
|
||||
- Reassess the targets when a parser boundary changes.
|
||||
+17
-1
@@ -7,7 +7,7 @@
|
||||
<version>${revision}</version>
|
||||
<name>QBit Document Generation</name>
|
||||
<description>User-defined document templates and generation coordination for QQQ</description>
|
||||
<properties><qqq.version>4.1.0-RC.22</qqq.version><maven.compiler.source>21</maven.compiler.source><maven.compiler.target>21</maven.compiler.target><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>false</enforcer.skip></properties>
|
||||
<properties><qqq.version>4.1.0-RC.26</qqq.version><maven.compiler.source>21</maven.compiler.source><maven.compiler.target>21</maven.compiler.target><maven.compiler.proc>none</maven.compiler.proc><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>false</enforcer.skip></properties>
|
||||
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url><distribution>repo</distribution></license></licenses>
|
||||
<dependencyManagement><dependencies><dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-bom-pom</artifactId><version>${qqq.version}</version><type>pom</type><scope>import</scope></dependency></dependencies></dependencyManagement>
|
||||
<dependencies>
|
||||
@@ -48,6 +48,22 @@
|
||||
<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>CLASS</counter><value>COVEREDRATIO</value><minimum>0.90</minimum></limit>
|
||||
</limits></rule>
|
||||
<!-- Classes added since the label layout design (section 10.7) are held to 100% line coverage from their first commit. A pattern matches nothing until its class exists,
|
||||
so a later package only adds its class here if the design did not already name it. The BUNDLE rule above is unchanged and is never lowered. -->
|
||||
<rule><element>CLASS</element><includes>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationPorts.Preview*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationPreviewProcesses*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationPreviewWidget*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationAssetProcesses*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationAssetTables*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationAssetStore*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.GenerationUpgradeProcesses*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.DocumentPreviewService*</include>
|
||||
<include>com.kingsrook.qbits.documentgeneration.DocumentAvailability*</include>
|
||||
</includes><limits>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
|
||||
</limits></rule></rules></configuration></execution>
|
||||
</executions></plugin>
|
||||
<plugin>
|
||||
|
||||
+5
-1
@@ -24,6 +24,7 @@ package com.kingsrook.qbits.documentgeneration;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Properties;
|
||||
import java.util.stream.Stream;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
@@ -138,7 +139,7 @@ public class DocumentGenerationQBitProducer implements QBitMetaDataProducer<Docu
|
||||
QBitMetaData bit = getQBitMetaData();
|
||||
instance.addQBit(bit);
|
||||
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
|
||||
GenerationTables.tables(config).forEach(table ->
|
||||
Stream.concat(GenerationTables.tables(config).stream(), GenerationAssetTables.tables(config).stream()).forEach(table ->
|
||||
{
|
||||
table.setSourceQBitName(bit.getName());
|
||||
output.add(table);
|
||||
@@ -146,6 +147,9 @@ public class DocumentGenerationQBitProducer implements QBitMetaDataProducer<Docu
|
||||
output.addSelfToInstance(instance);
|
||||
GenerationProcesses.register(instance, config.getSourceTables());
|
||||
GenerationAuthoringProcesses.register(instance, config);
|
||||
GenerationPreviewProcesses.register(instance, config);
|
||||
GenerationAssetProcesses.register(instance, config);
|
||||
GenerationUpgradeProcesses.register(instance, config);
|
||||
return (new MetaDataProducerMultiOutput());
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Processes that upload and manage built-in template assets such as fonts.
|
||||
** The producer already calls this registration point; it registers nothing yet.
|
||||
*******************************************************************************/
|
||||
final class GenerationAssetProcesses
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata namespace.
|
||||
*******************************************************************************/
|
||||
private GenerationAssetProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called once by the producer after the generation and authoring processes.
|
||||
*******************************************************************************/
|
||||
static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException
|
||||
{
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** Tables of the built-in asset store. The producer already registers whatever
|
||||
** this returns, with the QBit's own tables; it returns none yet.
|
||||
*******************************************************************************/
|
||||
final class GenerationAssetTables
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata namespace.
|
||||
*******************************************************************************/
|
||||
private GenerationAssetTables()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Complete table metadata, on the configured backend, for the producer to add.
|
||||
*******************************************************************************/
|
||||
static List<QTableMetaData> tables(DocumentGenerationQBitConfig config)
|
||||
{
|
||||
return (List.of());
|
||||
}
|
||||
}
|
||||
+250
-2
@@ -38,6 +38,17 @@ public final class GenerationPorts
|
||||
{
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A render target is absent or one of the resolutions the renderer supports.
|
||||
***************************************************************************/
|
||||
private static void renderTarget(Integer dpi)
|
||||
{
|
||||
if(dpi != null && !List.of(203, 300, 600).contains(dpi))
|
||||
{
|
||||
throw new IllegalArgumentException("A render target is 203, 300 or 600 dpi.");
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Initial output contracts; new formats require a qualified renderer adapter.
|
||||
*******************************************************************************/
|
||||
@@ -93,7 +104,119 @@ public final class GenerationPorts
|
||||
SCANNER_BARCODE_PAYLOAD_UNSUPPORTED,
|
||||
SCANNER_BARCODE_CHECKSUM_REQUIRED,
|
||||
SCANNER_BARCODE_CHECKSUM_MISMATCH,
|
||||
UNSUPPORTED_BARCODE_OPTION
|
||||
UNSUPPORTED_BARCODE_OPTION,
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// version 2 scene refusals; no released renderer names these for a version 1 scene //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
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
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** 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");
|
||||
|
||||
/************************************************************************
|
||||
** An error stops printing; a warning and information do not.
|
||||
************************************************************************/
|
||||
public enum Severity
|
||||
{
|
||||
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,
|
||||
** 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.");
|
||||
}
|
||||
if(element != null && !element.matches("[A-Za-z][A-Za-z0-9_-]{0,31}"))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid diagnostic element id.");
|
||||
}
|
||||
if(box != null && (box.size() != 4 || box.stream().anyMatch(java.util.Objects::isNull) || box.get(2) < 0 || box.get(3) < 0))
|
||||
{
|
||||
throw new IllegalArgumentException("A diagnostic box is x, y, width and height in dots.");
|
||||
}
|
||||
box = box == null ? null : List.copyOf(box);
|
||||
measured = numbers(measured);
|
||||
limit = numbers(limit);
|
||||
}
|
||||
|
||||
/************************************************************************
|
||||
** The closed vocabulary, by severity.
|
||||
************************************************************************/
|
||||
private static boolean known(String code, Severity severity)
|
||||
{
|
||||
return (switch(severity)
|
||||
{
|
||||
case ERROR -> java.util.Arrays.stream(FailureCode.values()).anyMatch(failure -> failure.name().equals(code));
|
||||
case WARNING -> WARNINGS.contains(code);
|
||||
case INFO -> INFORMATION.contains(code);
|
||||
});
|
||||
}
|
||||
|
||||
/************************************************************************
|
||||
** Freeze named integers; a name is a short identifier, never a sentence.
|
||||
************************************************************************/
|
||||
private static Map<String, Integer> numbers(Map<String, Integer> values)
|
||||
{
|
||||
if(values == null)
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
if(values.size() > 8 || values.entrySet().stream().anyMatch(entry -> entry.getKey() == null || !entry.getKey().matches("[a-z][A-Za-z0-9]{0,31}") || entry.getValue() == null))
|
||||
{
|
||||
throw new IllegalArgumentException("Diagnostic values are at most eight named integers.");
|
||||
}
|
||||
return (Map.copyOf(values));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
@@ -103,14 +226,29 @@ public final class GenerationPorts
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
private final FailureCode code;
|
||||
private final transient Diagnostic diagnostic;
|
||||
|
||||
/***************************************************************************
|
||||
** Construct a native process error whose public message is the safe code.
|
||||
***************************************************************************/
|
||||
public RenderException(FailureCode code)
|
||||
{
|
||||
this(code, null);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A refusal that also says where and by how much: the diagnostic, when
|
||||
** present, is the error with this same code. The message stays the code.
|
||||
***************************************************************************/
|
||||
public RenderException(FailureCode code, Diagnostic diagnostic)
|
||||
{
|
||||
super(java.util.Objects.requireNonNull(code).name());
|
||||
if(diagnostic != null && (diagnostic.severity() != Diagnostic.Severity.ERROR || !code.name().equals(diagnostic.code())))
|
||||
{
|
||||
throw new IllegalArgumentException("A refusal's diagnostic must be the error with the same code.");
|
||||
}
|
||||
this.code = code;
|
||||
this.diagnostic = diagnostic;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
@@ -120,6 +258,14 @@ public final class GenerationPorts
|
||||
{
|
||||
return (code);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** The detail the worker gave with the refusal, or null when it gave none.
|
||||
***************************************************************************/
|
||||
public Diagnostic diagnostic()
|
||||
{
|
||||
return (diagnostic);
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -223,7 +369,99 @@ public final class GenerationPorts
|
||||
** Bounded worker job with all revision inputs captured before dispatch.
|
||||
*******************************************************************************/
|
||||
public record RenderRequest(String requestId, String actorId, Source source, Format format, Revision revision,
|
||||
Map<String, Object> context, Integer maximumBytes) {}
|
||||
Map<String, Object> context, Integer maximumBytes, Integer targetDpi)
|
||||
{
|
||||
/***************************************************************************
|
||||
** The render target is absent (the stock's own resolution) or a resolution
|
||||
** the renderer supports.
|
||||
***************************************************************************/
|
||||
public RenderRequest
|
||||
{
|
||||
renderTarget(targetDpi);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Existing callers render at the stock's own resolution.
|
||||
***************************************************************************/
|
||||
public RenderRequest(String requestId, String actorId, Source source, Format format, Revision revision,
|
||||
Map<String, Object> context, Integer maximumBytes)
|
||||
{
|
||||
this(requestId, actorId, source, format, revision, context, maximumBytes, null);
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A layout preview job: the print path stopped before retention. It has no
|
||||
** request id and no source record because nothing is written for it.
|
||||
***************************************************************************/
|
||||
public record PreviewRequest(String actorId, Format format, Revision revision, Map<String, Object> context, Integer targetDpi, Integer maximumBytes)
|
||||
{
|
||||
/***************************************************************************
|
||||
** Refuse a job that names no format or content, an unsupported render
|
||||
** target or no output bound.
|
||||
***************************************************************************/
|
||||
public PreviewRequest
|
||||
{
|
||||
if(format == null || revision == null || maximumBytes == null || maximumBytes < 1)
|
||||
{
|
||||
throw new IllegalArgumentException("A preview needs a format, a revision and an output limit.");
|
||||
}
|
||||
renderTarget(targetDpi);
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** One preview page: a PNG whose pixels are printer dots at the render target.
|
||||
***************************************************************************/
|
||||
public record PreviewPage(byte[] png, Integer widthDots, Integer heightDots)
|
||||
{
|
||||
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10};
|
||||
|
||||
/***************************************************************************
|
||||
** Only a PNG with a stated size is a page; the bytes cannot change later.
|
||||
***************************************************************************/
|
||||
public PreviewPage
|
||||
{
|
||||
if(png == null || png.length < SIGNATURE.length || !java.util.Arrays.equals(png, 0, SIGNATURE.length, SIGNATURE, 0, SIGNATURE.length)
|
||||
|| widthDots == null || widthDots < 1 || heightDots == null || heightDots < 1)
|
||||
{
|
||||
throw new IllegalArgumentException("A preview page is a PNG with its size in dots.");
|
||||
}
|
||||
png = png.clone();
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Never expose the stored byte array to a consumer.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public byte[] png()
|
||||
{
|
||||
return (png.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** What a preview shows: the pages, whether they are exactly what prints,
|
||||
** whether the layout can print at all, every diagnostic, and the size and
|
||||
** hash of the output the pages were decoded from.
|
||||
***************************************************************************/
|
||||
public record Preview(List<PreviewPage> pages, Boolean exact, Boolean printable, List<Diagnostic> diagnostics, Long outputBytes, String outputSha256)
|
||||
{
|
||||
/***************************************************************************
|
||||
** Freeze the lists and refuse a contradiction: a layout with an error
|
||||
** diagnostic is not printable.
|
||||
***************************************************************************/
|
||||
public Preview
|
||||
{
|
||||
pages = List.copyOf(pages);
|
||||
diagnostics = List.copyOf(diagnostics);
|
||||
if(exact == null || printable == null || outputBytes == null || outputBytes < 0 || outputSha256 == null || !outputSha256.matches("[a-f0-9]{64}")
|
||||
|| (printable && diagnostics.stream().anyMatch(diagnostic -> diagnostic.severity() == Diagnostic.Severity.ERROR)))
|
||||
{
|
||||
throw new IllegalArgumentException("Invalid preview result.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Temporary bounded worker result, discarded after retained intake.
|
||||
@@ -393,6 +631,16 @@ public final class GenerationPorts
|
||||
throw new QException("Renderer barcode reference is unavailable.");
|
||||
}
|
||||
|
||||
/***********************************************************************
|
||||
** Optional layout preview: the print path stopped before retention, with
|
||||
** the pages decoded from the validated output. A renderer that does not
|
||||
** provide it refuses, so an existing adapter is unchanged.
|
||||
***********************************************************************/
|
||||
default Preview preview(PreviewRequest request) throws QException
|
||||
{
|
||||
throw new QException("Renderer preview is unavailable.");
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Authorize assets and validate user source outside the application JVM.
|
||||
*******************************************************************************/
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** Layout preview processes for a template: sample, stress and record data.
|
||||
** The producer already calls this registration point; it registers nothing yet.
|
||||
*******************************************************************************/
|
||||
final class GenerationPreviewProcesses
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata namespace.
|
||||
*******************************************************************************/
|
||||
private GenerationPreviewProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called once by the producer after the generation and authoring processes.
|
||||
*******************************************************************************/
|
||||
static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException
|
||||
{
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
|
||||
/*******************************************************************************
|
||||
** The "Label preview" section of the template record screen. The template
|
||||
** table already offers itself here after its own sections; nothing is added yet.
|
||||
*******************************************************************************/
|
||||
final class GenerationPreviewWidget
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata namespace.
|
||||
*******************************************************************************/
|
||||
private GenerationPreviewWidget()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called once for the template table, after its standard sections.
|
||||
*******************************************************************************/
|
||||
static void section(QTableMetaData table)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -182,6 +182,7 @@ public final class GenerationTables
|
||||
table.withSection(new QFieldSection("advanced", new QIcon("settings"), Tier.T3, List.of("id", "createDate", "scriptArtifact", "assetIds")).withLabel("Advanced references")
|
||||
.withCollapsible(CollapsibleMetaData.INITIALLY_CLOSED)
|
||||
.withHelpContent(help("These are exact stored identities for support and provenance. Change scripts and included assets through the named actions; do not edit integrity references.")));
|
||||
GenerationPreviewWidget.section(table);
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
|
||||
/*******************************************************************************
|
||||
** The authoring action that converts a version 1 scene to version 2.
|
||||
** The producer already calls this registration point; it registers nothing yet.
|
||||
*******************************************************************************/
|
||||
final class GenerationUpgradeProcesses
|
||||
{
|
||||
/*******************************************************************************
|
||||
** Metadata namespace.
|
||||
*******************************************************************************/
|
||||
private GenerationUpgradeProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called once by the producer after the generation and authoring processes.
|
||||
*******************************************************************************/
|
||||
static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException
|
||||
{
|
||||
}
|
||||
}
|
||||
+328
@@ -0,0 +1,328 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic;
|
||||
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic.Severity;
|
||||
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.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.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/*******************************************************************************
|
||||
** The preview, diagnostic and render target ports: what each value admits, what
|
||||
** the defaults refuse, and that every existing constructor and adapter still
|
||||
** compiles and means what it did.
|
||||
*******************************************************************************/
|
||||
class GenerationPortsContractTest
|
||||
{
|
||||
private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(101600, 25400, 3000, 3000, 3000, 3000, 203);
|
||||
private static final GenerationPorts.Revision REVISION = new GenerationPorts.Revision(1, 2, "orders", STOCK, null, "{}", null, List.of());
|
||||
private static final GenerationPorts.Source SOURCE = new GenerationPorts.Source("orders", "1");
|
||||
private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10, 0};
|
||||
private static final String SHA = "0".repeat(64);
|
||||
|
||||
/***************************************************************************
|
||||
** An adapter written before the preview port: it implements what it always
|
||||
** did, and this class compiling is the source compatibility proof.
|
||||
***************************************************************************/
|
||||
private static final class ExistingAdapter implements GenerationPorts.IsolatedRenderer
|
||||
{
|
||||
/************************************************************************
|
||||
** Accept every revision.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public void validate(String actorId, GenerationPorts.Revision revision)
|
||||
{
|
||||
}
|
||||
|
||||
/************************************************************************
|
||||
** Return fixed bytes.
|
||||
************************************************************************/
|
||||
@Override
|
||||
public GenerationPorts.Content render(GenerationPorts.RenderRequest request)
|
||||
{
|
||||
return (new GenerationPorts.Content(new byte[] {1}));
|
||||
}
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** The seven-argument request and the one-argument refusal are unchanged, and
|
||||
** an existing adapter refuses the optional operations it never had.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void existingConstructorsAndAdaptersMeanWhatTheyDid() throws Exception
|
||||
{
|
||||
var request = new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of("record", Map.of()), 1024);
|
||||
assertNull(request.targetDpi());
|
||||
assertEquals(request, new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of("record", Map.of()), 1024, null));
|
||||
var refusal = new GenerationPorts.RenderException(FailureCode.BARCODE_FIT_TOO_SMALL);
|
||||
assertEquals(FailureCode.BARCODE_FIT_TOO_SMALL, refusal.code());
|
||||
assertEquals("BARCODE_FIT_TOO_SMALL", refusal.getMessage());
|
||||
assertNull(refusal.diagnostic());
|
||||
assertThrows(NullPointerException.class, () -> new GenerationPorts.RenderException(null));
|
||||
var adapter = new ExistingAdapter();
|
||||
assertArrayEquals(new byte[] {1}, adapter.render(request).bytes());
|
||||
adapter.validate("actor-1", REVISION);
|
||||
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());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Stored error codes are read back by name, so the released names keep their
|
||||
** order and the version 2 refusals follow them.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void failureCodesKeepTheReleasedNamesThenAddTheSceneTwoRefusals()
|
||||
{
|
||||
List<String> names = Arrays.stream(FailureCode.values()).map(Enum::name).toList();
|
||||
assertEquals(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"), names.subList(0, 13));
|
||||
assertEquals(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"), names.subList(13, names.size()));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A render target is absent or a resolution the renderer supports, for a
|
||||
** render and for a preview alike.
|
||||
***************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {0, 72, 200, 204, 301, 1200, -203})
|
||||
void anUnsupportedRenderTargetIsRefused(int dpi)
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of(), 1024, dpi));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), dpi, 1024));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Each supported resolution is carried unchanged.
|
||||
***************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {203, 300, 600})
|
||||
void aSupportedRenderTargetIsCarried(int dpi)
|
||||
{
|
||||
assertEquals(dpi, new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.ZPL, REVISION, Map.of(), 1024, dpi).targetDpi());
|
||||
assertEquals(dpi, new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), dpi, 1024).targetDpi());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A preview job names its format, its content and an output bound.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aPreviewRequestNeedsAFormatARevisionAndALimit()
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", null, REVISION, Map.of(), null, 1024));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, null, Map.of(), null, 1024));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, REVISION, Map.of(), null, 0));
|
||||
assertEquals(REVISION, new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, REVISION, Map.of(), null, 1).revision());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A page is a PNG with a size, and neither the caller nor a consumer can
|
||||
** change its bytes afterwards.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aPreviewPageIsAnImmutablePngWithItsSize()
|
||||
{
|
||||
byte[] supplied = PNG.clone();
|
||||
var page = new GenerationPorts.PreviewPage(supplied, 812, 203);
|
||||
supplied[8] = 9;
|
||||
page.png()[8] = 9;
|
||||
assertArrayEquals(PNG, page.png());
|
||||
assertEquals(812, page.widthDots());
|
||||
assertEquals(203, page.heightDots());
|
||||
byte[] jpeg = PNG.clone();
|
||||
jpeg[1] = 'J';
|
||||
for(byte[] bytes : new byte[][] {null, new byte[0], Arrays.copyOf(PNG, 7), jpeg})
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(bytes, 812, 203));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, null, 203));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, 0, 203));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, 812, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.PreviewPage(PNG, 812, 0));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A preview freezes its lists, states its output exactly, and cannot call a
|
||||
** layout with an error printable.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aPreviewIsFrozenAndCannotContradictItsDiagnostics()
|
||||
{
|
||||
var page = new GenerationPorts.PreviewPage(PNG, 812, 203);
|
||||
var error = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", null, null, null);
|
||||
var warning = new Diagnostic("PRINTER_FONT_NOT_SHOWN", Severity.WARNING, null, null, null, null);
|
||||
List<GenerationPorts.PreviewPage> pages = new ArrayList<>(List.of(page));
|
||||
List<Diagnostic> diagnostics = new ArrayList<>(List.of(warning));
|
||||
var preview = new GenerationPorts.Preview(pages, false, true, diagnostics, 1234L, SHA);
|
||||
pages.clear();
|
||||
diagnostics.clear();
|
||||
assertEquals(List.of(page), preview.pages());
|
||||
assertEquals(List.of(warning), preview.diagnostics());
|
||||
assertThrows(UnsupportedOperationException.class, () -> preview.pages().clear());
|
||||
assertThrows(UnsupportedOperationException.class, () -> preview.diagnostics().clear());
|
||||
assertEquals(false, new GenerationPorts.Preview(List.of(), true, false, List.of(error), 0L, SHA).printable());
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(warning, error), 1234L, SHA));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), null, true, List.of(), 1234L, SHA));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, null, List.of(), 1234L, SHA));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), null, SHA));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), -1L, SHA));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), 1234L, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, List.of(), 1234L, SHA.toUpperCase().replace('0', 'A')));
|
||||
assertThrows(NullPointerException.class, () -> new GenerationPorts.Preview(null, true, true, List.of(), 1234L, SHA));
|
||||
assertThrows(NullPointerException.class, () -> new GenerationPorts.Preview(List.of(page), true, true, null, 1234L, SHA));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** The design's example diagnostic, and every code of the first release under
|
||||
** its own severity only.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aDiagnosticAdmitsOnlyItsClosedVocabulary()
|
||||
{
|
||||
var example = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", List.of(24, 131, 764, 56),
|
||||
Map.of("widthDots", 902, "sizeCentipoints", 700, "lines", 2), Map.of("widthDots", 764, "lines", 2));
|
||||
assertEquals(List.of(24, 131, 764, 56), example.box());
|
||||
assertEquals(902, example.measured().get("widthDots"));
|
||||
assertEquals(2, example.limit().get("lines"));
|
||||
for(FailureCode code : FailureCode.values())
|
||||
{
|
||||
assertEquals(code.name(), new Diagnostic(code.name(), Severity.ERROR, null, null, null, null).code());
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code.name(), Severity.WARNING, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code.name(), Severity.INFO, 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"))
|
||||
{
|
||||
assertEquals(Severity.WARNING, new Diagnostic(code, Severity.WARNING, null, null, null, null).severity());
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.ERROR, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.INFO, null, null, null, null));
|
||||
}
|
||||
for(String code : List.of("TEXT_SHRUNK", "TEXT_WRAPPED", "BARCODE_MODULE", "IMAGE_SCALED"))
|
||||
{
|
||||
assertEquals(Severity.INFO, new Diagnostic(code, Severity.INFO, null, null, null, null).severity());
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.ERROR, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic(code, Severity.WARNING, null, null, null, null));
|
||||
}
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("RENDER_FAILED", Severity.ERROR, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("Customer Jane Example could not be printed", Severity.ERROR, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic(null, Severity.ERROR, null, null, null, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("TEXT_DOES_NOT_FIT", null, null, null, null, null));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Nothing but an author-written id and numbers fits beside the code: a
|
||||
** sentence, a record value or a message has no field that will hold it.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aDiagnosticCannotHoldFreeText()
|
||||
{
|
||||
assertEquals("order_id-2", new Diagnostic("VALUE_MISSING", Severity.ERROR, "order_id-2", null, null, null).element());
|
||||
for(String element : List.of("", "9caption", "two words", "Jane Example, 1 Test Street", "a".repeat(33), "caption\n", "/record/name"))
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("VALUE_MISSING", Severity.ERROR, element, null, null, null), element);
|
||||
}
|
||||
for(List<Integer> box : List.of(List.<Integer>of(), List.of(1, 2, 3), List.of(1, 2, 3, 4, 5), List.of(1, 2, -3, 4), List.of(1, 2, 3, -4), Arrays.asList(1, null, 3, 4)))
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", Severity.ERROR, "caption", box, null, null), String.valueOf(box));
|
||||
}
|
||||
assertEquals(List.of(-12, -4, 0, 0), new Diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", Severity.ERROR, "caption", List.of(-12, -4, 0, 0), null, null).box());
|
||||
Map<String, Integer> nullValue = new HashMap<>();
|
||||
nullValue.put("widthDots", null);
|
||||
Map<String, Integer> nullName = new HashMap<>();
|
||||
nullName.put(null, 1);
|
||||
Map<String, Integer> tooMany = new HashMap<>();
|
||||
for(int index = 0; index < 9; index++)
|
||||
{
|
||||
tooMany.put("value" + index, index);
|
||||
}
|
||||
for(Map<String, Integer> values : 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), nullValue, nullName, tooMany))
|
||||
{
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", null, values, null));
|
||||
assertThrows(IllegalArgumentException.class, () -> new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", null, null, values));
|
||||
}
|
||||
List<Integer> box = new ArrayList<>(List.of(1, 2, 3, 4));
|
||||
Map<String, Integer> measured = new HashMap<>(Map.of("widthDots", 902));
|
||||
var diagnostic = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", box, measured, null);
|
||||
box.set(0, 99);
|
||||
measured.put("widthDots", 1);
|
||||
assertEquals(List.of(1, 2, 3, 4), diagnostic.box());
|
||||
assertEquals(Map.of("widthDots", 902), diagnostic.measured());
|
||||
assertEquals(Map.of(), diagnostic.limit());
|
||||
assertThrows(UnsupportedOperationException.class, () -> diagnostic.box().set(0, 99));
|
||||
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.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aRefusalCarriesOnlyItsOwnErrorDiagnostic()
|
||||
{
|
||||
var detail = new Diagnostic("TEXT_DOES_NOT_FIT", Severity.ERROR, "caption", List.of(24, 131, 764, 56), Map.of("widthDots", 902), Map.of("widthDots", 764));
|
||||
var refusal = new GenerationPorts.RenderException(FailureCode.TEXT_DOES_NOT_FIT, detail);
|
||||
assertEquals(FailureCode.TEXT_DOES_NOT_FIT, refusal.code());
|
||||
assertEquals(detail, refusal.diagnostic());
|
||||
assertEquals("TEXT_DOES_NOT_FIT", refusal.getMessage());
|
||||
assertNull(new GenerationPorts.RenderException(FailureCode.TEXT_DOES_NOT_FIT, null).diagnostic());
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.RenderException(FailureCode.VALUE_MISSING, detail));
|
||||
assertThrows(IllegalArgumentException.class, () -> new GenerationPorts.RenderException(FailureCode.TEXT_DOES_NOT_FIT, new Diagnostic("TEXT_SHRUNK", Severity.INFO, "caption", null, null, null)));
|
||||
assertThrows(NullPointerException.class, () -> new GenerationPorts.RenderException(null, detail));
|
||||
}
|
||||
}
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.TreeSet;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/*******************************************************************************
|
||||
** The registration points for the preview, asset and upgrade packages exist and
|
||||
** are called, and add nothing: a host that registers the QBit gets exactly the
|
||||
** tables, processes, choices, widgets and template sections it got before.
|
||||
*******************************************************************************/
|
||||
class GenerationRegistrationHooksTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** The complete default wiring, as the released foundation registers it.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theProducerRegistersExactlyWhatItDidBeforeTheHooks() throws Exception
|
||||
{
|
||||
QInstance instance = instance();
|
||||
new DocumentGenerationQBitProducer().withQBitConfig(config()).produce(instance);
|
||||
assertEquals(List.of("document_generation_asset", "document_generation_batch", "document_generation_binding", "document_generation_control", "document_generation_event",
|
||||
"document_generation_item", "document_generation_queue", "document_generation_request", "document_generation_revision", "document_generation_stock",
|
||||
"document_generation_template", "document_generation_type", "orders"),
|
||||
List.copyOf(new TreeSet<>(instance.getTables().keySet())), "tables");
|
||||
assertEquals(List.of("document_generation_binding_author_create", "document_generation_queue_document_advance", "document_generation_queue_document_cancel",
|
||||
"document_generation_queue_document_reconcileItem", "document_generation_queue_document_recover", "document_generation_queue_document_status",
|
||||
"document_generation_request_document_download", "document_generation_request_document_reconcile", "document_generation_request_document_reprint",
|
||||
"document_generation_request_document_status", "document_generation_stock_author_create", "document_generation_stock_author_rename",
|
||||
"document_generation_template_author_asset", "document_generation_template_author_catalog", "document_generation_template_author_context",
|
||||
"document_generation_template_author_create", "document_generation_template_author_edit", "document_generation_template_author_naming",
|
||||
"document_generation_template_author_packingSlip", "document_generation_template_author_preview", "document_generation_template_author_publish",
|
||||
"document_generation_template_author_removeAsset", "document_generation_template_author_rename", "document_generation_template_author_replaceAsset",
|
||||
"document_generation_type_author_create", "document_generation_type_author_rename", "orders_document_enqueue", "orders_document_generate"),
|
||||
List.copyOf(new TreeSet<>(instance.getProcesses().keySet())), "processes");
|
||||
assertEquals(List.of("document_generation_asset", "document_generation_context_publications", "document_generation_formats", "document_generation_preview_source",
|
||||
"document_generation_preview_source_orders", "document_generation_retained_assets", "document_generation_revision",
|
||||
"document_generation_source_tables", "document_generation_stock", "document_generation_template", "document_generation_type"),
|
||||
List.copyOf(new TreeSet<>(instance.getPossibleValueSources().keySet())), "possible value sources");
|
||||
assertEquals(List.of(),
|
||||
List.copyOf(new TreeSet<>(instance.getWidgets().keySet())), "widgets");
|
||||
assertEquals(List.of("overview", "layout", "context", "advanced"),
|
||||
instance.getTable(GenerationTables.TEMPLATE).getSections().stream().map(QFieldSection::getName).toList(), "template sections");
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Each registration point, called directly, changes nothing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void eachRegistrationPointAddsNothingYet() throws Exception
|
||||
{
|
||||
DocumentGenerationQBitConfig config = config();
|
||||
assertEquals(List.of(), GenerationAssetTables.tables(config));
|
||||
QInstance instance = instance();
|
||||
GenerationPreviewProcesses.register(instance, config);
|
||||
GenerationAssetProcesses.register(instance, config);
|
||||
GenerationUpgradeProcesses.register(instance, config);
|
||||
assertEquals(List.of(), List.copyOf(instance.getProcesses().keySet()));
|
||||
assertEquals(List.of("orders"), List.copyOf(instance.getTables().keySet()));
|
||||
QTableMetaData table = new QTableMetaData().withName("template");
|
||||
GenerationPreviewWidget.section(table);
|
||||
assertEquals(new QTableMetaData().withName("template").getSections(), table.getSections());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** The smallest host: one backend and one source table.
|
||||
***************************************************************************/
|
||||
private static QInstance instance()
|
||||
{
|
||||
QInstance instance = new QInstance();
|
||||
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
|
||||
instance.addTable(new QTableMetaData().withName("orders").withBackendName("memory").withPrimaryKeyField("id").withField(new QFieldMetaData("id", QFieldType.INTEGER)));
|
||||
return (instance);
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Default limits, with one source table so the source choices are registered.
|
||||
***************************************************************************/
|
||||
private static DocumentGenerationQBitConfig config()
|
||||
{
|
||||
return (new DocumentGenerationQBitConfig().withBackendName("memory").withSourceTables(List.of("orders")));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
no -Drevision, so a module that declared its own revision would stay at the SNAPSHOT. The root takes qbit-build-parent 2.1.1 (the version foundation already used);
|
||||
modules that had no parent now inherit its defaults and this Apache-2.0 license (the vendored OpenHTML module keeps its explicit LGPL-2.1+; bridges/scripting keeps
|
||||
its previously inherited AGPL-3.0 explicitly). Each module still keeps its own dependency boundary and builds on its own through its relative parent. -->
|
||||
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.1.1</version><relativePath/></parent>
|
||||
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.2.1</version><relativePath/></parent>
|
||||
<groupId>com.kingsrook.qbits</groupId>
|
||||
<artifactId>qbit-document-generation-reactor</artifactId>
|
||||
<version>${revision}</version>
|
||||
@@ -14,7 +14,7 @@
|
||||
<url>https://github.com/Kingsrook/qbit-bom</url>
|
||||
<scm><connection>scm:git:https://github.com/Kingsrook/qbit-bom.git</connection><developerConnection>scm:git:ssh://git@github.com/Kingsrook/qbit-bom.git</developerConnection><url>https://github.com/Kingsrook/qbit-bom</url><tag>HEAD</tag></scm>
|
||||
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url><distribution>repo</distribution></license></licenses>
|
||||
<properties><revision>0.1.0-SNAPSHOT</revision><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>true</enforcer.skip></properties>
|
||||
<properties><revision>1.4.0-SNAPSHOT</revision><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>true</enforcer.skip><qqq.version>4.1.0-RC.26</qqq.version></properties>
|
||||
<!-- Every module of the repository, so one release builds and publishes every GAV. Maven orders the reactor by dependency (the patched OpenHTML
|
||||
module before the renderer; foundation before the bridges). Build a subset with -pl <module> -am. -->
|
||||
<modules>
|
||||
|
||||
+16
-15
@@ -10,18 +10,18 @@ COPY Dockerfile ./
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends gcc g++ libc6-dev make cmake patch \
|
||||
&& sh ./build-zint.sh
|
||||
|
||||
# The renderer's version comes from its parent, the repository reactor POM (../pom.xml). Build from this directory with that file as a named BuildKit context:
|
||||
# docker build --build-context reactor=<repository root> ... (the reactor POM is copied to /pom.xml, where ../pom.xml resolves for /renderer/pom.xml)
|
||||
FROM ${BUILD_IMAGE} AS build
|
||||
COPY --from=reactor pom.xml /pom.xml
|
||||
WORKDIR /renderer
|
||||
COPY vendor/openhtml-1.1.105-qqq-p1 ./vendor/openhtml-1.1.105-qqq-p1
|
||||
RUN sh ./vendor/openhtml-1.1.105-qqq-p1/build-openhtml.sh /renderer/openhtml-build /renderer/.m2
|
||||
COPY pom.xml ./
|
||||
COPY LICENSE NOTICE ./
|
||||
COPY checkstyle ./checkstyle
|
||||
COPY src ./src
|
||||
RUN mvn -B -Dmaven.repo.local=/renderer/.m2 verify
|
||||
# The engine and its libraries are the RELEASED artifacts, resolved at the exact RENDERER_VERSION from the QRun-IO
|
||||
# Gitea Maven registry and Maven Central (Maven checks each registry checksum). No SNAPSHOT, no local build and no
|
||||
# local-cache coordinate: the version is a required build argument with no default.
|
||||
# docker build --build-arg BUILD_IMAGE=... --build-arg JAVA_IMAGE=... --build-arg RENDERER_VERSION=1.0.0 renderer/
|
||||
FROM ${BUILD_IMAGE} AS release
|
||||
ARG RENDERER_VERSION
|
||||
WORKDIR /release
|
||||
COPY release-image/pom.xml ./pom.xml
|
||||
RUN test -n "${RENDERER_VERSION}" && case "${RENDERER_VERSION}" in *SNAPSHOT*|*forge*) echo "RENDERER_VERSION must be a released version" >&2; exit 1;; esac \
|
||||
&& mvn -B -Drenderer.version="${RENDERER_VERSION}" -Dmaven.repo.local=/release/.m2 package \
|
||||
&& mv "target/lib/qbit-document-renderer-${RENDERER_VERSION}.jar" target/renderer.jar \
|
||||
&& test -f target/renderer.jar && ls target/lib
|
||||
|
||||
FROM ${JAVA_IMAGE}
|
||||
# Reviewed runtime family: Temurin 21.0.13+9 on Ubuntu noble. The approved digest
|
||||
@@ -37,8 +37,9 @@ RUN . /etc/os-release \
|
||||
WORKDIR /renderer
|
||||
COPY --from=zint-build /zint-build/build/frontend/zint /usr/local/bin/zint
|
||||
COPY --from=zint-build /zint-build/corresponding-source /usr/share/doc/qqq-zint
|
||||
COPY --from=build /renderer/openhtml-build/corresponding-source /usr/share/doc/qqq-openhtml
|
||||
COPY --from=build /renderer/target/qbit-document-renderer-0.1.0-SNAPSHOT.jar ./renderer.jar
|
||||
COPY --from=build /renderer/target/lib ./lib
|
||||
# LGPL corresponding source for the vendored OpenHTML module that the released openhtmltopdf-pdfbox jar was built from.
|
||||
COPY vendor/openhtml-1.1.105-qqq-p1 /usr/share/doc/qqq-openhtml
|
||||
COPY --from=release /release/target/renderer.jar ./renderer.jar
|
||||
COPY --from=release /release/target/lib ./lib
|
||||
USER 65532:65532
|
||||
ENTRYPOINT ["java", "-Xmx256m", "-XX:ActiveProcessorCount=1", "-Djava.awt.headless=true", "-Dpdfbox.fontcache=/tmp", "-jar", "/renderer/renderer.jar"]
|
||||
@@ -0,0 +1,120 @@
|
||||
# Renderer container images: build from a released version and publish
|
||||
|
||||
Two images, built in this order. Both are built from **released** Maven artifacts (QRun-IO Gitea registry
|
||||
`https://git.kof22.com/api/packages/QRun-IO/maven` and Maven Central); there is no SNAPSHOT, local-cache or reactor
|
||||
input. The version is a required build argument with no default, and the Dockerfiles refuse a `SNAPSHOT` or `forge`
|
||||
version.
|
||||
|
||||
| Image | Dockerfile (context) | Version argument | Contents |
|
||||
|---|---|---|---|
|
||||
| `ghcr.io/gg-engineering/qqq-document-renderer` | `renderer/Dockerfile` (`renderer/`) | `RENDERER_VERSION` (`qbit-document-renderer`) | engine, its libraries, patched Zint built from `renderer/vendor`, Temurin runtime |
|
||||
| `ghcr.io/gg-engineering/qqq-remote-renderer` | `bridges/remote-renderer/Dockerfile` (`bridges/remote-renderer/`) | `TRANSPORT_VERSION` (`qqq-remote-renderer-transport`), `RENDERER_IMAGE` | the mTLS service, on top of the engine image |
|
||||
|
||||
The service image is the one Forge deploys. The adapter (`qbit-document-generation-remote-renderer`) is a client-side
|
||||
library and is not in either image.
|
||||
|
||||
## Version choice
|
||||
|
||||
`1.0.0` is the released, non-RC version and the one `gg-forge` pins for the document-generation family. `1.1.0-RC.1`
|
||||
contains the same `renderer/` and `bridges/remote-renderer/` sources as `1.0.0` (only POM/CI alignment differs), so it
|
||||
adds nothing for the renderer and is a release candidate. Build the version the host library pins.
|
||||
|
||||
## Reviewed base images
|
||||
|
||||
`BUILD_IMAGE` and `JAVA_IMAGE` must be immutable digests. `JAVA_IMAGE` is the reviewed runtime in `DEPLOYMENT.md`.
|
||||
`BUILD_IMAGE` only resolves artifacts; no layer of it reaches the final image.
|
||||
|
||||
```sh
|
||||
BUILD_IMAGE=maven@sha256:99e61abcff91a9b1333463bd8451fb18495d6eba9250ac66a338b518f8278320 # maven:3.9-eclipse-temurin-21
|
||||
JAVA_IMAGE=eclipse-temurin@sha256:db724162e4ff5f71fa0a2815026314d9e5e0e9a139f49be1d4717e6930e93341 # 21.0.13_9-jre-noble (amd64)
|
||||
VERSION=1.0.0
|
||||
```
|
||||
|
||||
## Build, smoke, push
|
||||
|
||||
Disable BuildKit provenance and SBOM attestations so each tag is a single OCI image manifest (the digest the chart pins).
|
||||
|
||||
```sh
|
||||
docker build --provenance=false --sbom=false --build-arg BUILD_IMAGE=$BUILD_IMAGE --build-arg JAVA_IMAGE=$JAVA_IMAGE --build-arg RENDERER_VERSION=$VERSION -t qqq-document-renderer:$VERSION renderer
|
||||
docker build --provenance=false --sbom=false --build-arg BUILD_IMAGE=$BUILD_IMAGE --build-arg RENDERER_IMAGE=qqq-document-renderer:$VERSION --build-arg TRANSPORT_VERSION=$VERSION -t qqq-remote-renderer:$VERSION bridges/remote-renderer
|
||||
|
||||
# Optional scan of the final image
|
||||
docker save qqq-remote-renderer:$VERSION -o svc.tar
|
||||
docker run --rm -v "$PWD/svc.tar:/svc.tar:ro" aquasec/trivy:latest image --input /svc.tar --severity HIGH,CRITICAL --scanners vuln
|
||||
|
||||
# Publish (docker must be logged in to ghcr.io with a token that has write:packages)
|
||||
for n in qqq-document-renderer qqq-remote-renderer; do
|
||||
docker tag $n:$VERSION ghcr.io/gg-engineering/$n:$VERSION
|
||||
docker push ghcr.io/gg-engineering/$n:$VERSION # prints the manifest digest
|
||||
done
|
||||
```
|
||||
|
||||
New GHCR packages are private. Package access is per package and is managed in the GitHub UI (Package settings,
|
||||
Manage access); the REST API cannot grant it. The credential behind `ghcr-pull-secret` must be able to read both packages.
|
||||
|
||||
## Pins for gg-forge-cd
|
||||
|
||||
`sh renderer/image-pins.sh ghcr.io/gg-engineering/qqq-remote-renderer@sha256:<manifest digest>` pulls the published
|
||||
image and prints the `echoRenderer.*` image, classpath and file-hash pins from the image itself. `imageId` is
|
||||
`docker image inspect --format '{{.Id}}'`; with the containerd image store this equals the manifest digest, otherwise
|
||||
it is the config digest. Whatever value is chosen must be the same on Forge and the service.
|
||||
|
||||
Values for `1.0.0` as published (the java, zint and patchset hashes match the 2026-11-05 local qualification; the
|
||||
engine JAR and the OpenHTML JAR differ because they are the released artifacts):
|
||||
|
||||
```yaml
|
||||
echoRenderer:
|
||||
image:
|
||||
repository: ghcr.io/gg-engineering/qqq-remote-renderer
|
||||
digest: sha256:4152a4253e543068bedfd438775ed5327807bdddf679bc5a8e6eab26855e09ea
|
||||
imageId: sha256:4152a4253e543068bedfd438775ed5327807bdddf679bc5a8e6eab26855e09ea
|
||||
classpath:
|
||||
- /renderer/renderer.jar
|
||||
- /renderer/lib/commons-logging-1.4.0.jar
|
||||
- /renderer/lib/fontbox-3.0.8.jar
|
||||
- /renderer/lib/freemarker-2.3.35.jar
|
||||
- /renderer/lib/graphics2d-3.0.1.jar
|
||||
- /renderer/lib/jackson-annotations-2.22.jar
|
||||
- /renderer/lib/jackson-core-2.22.4.jar
|
||||
- /renderer/lib/jackson-databind-2.22.4.jar
|
||||
- /renderer/lib/jsoup-1.23.2.jar
|
||||
- /renderer/lib/okapibarcode-0.5.7.jar
|
||||
- /renderer/lib/openhtmltopdf-core-1.1.105.jar
|
||||
- /renderer/lib/openhtmltopdf-pdfbox-1.0.0.jar
|
||||
- /renderer/lib/pdfbox-3.0.8.jar
|
||||
- /renderer/lib/pdfbox-io-3.0.8.jar
|
||||
- /renderer/lib/slf4j-api-2.0.17.jar
|
||||
- /renderer/lib/slf4j-nop-2.0.17.jar
|
||||
- /renderer/lib/xmpbox-3.0.8.jar
|
||||
fileHashes:
|
||||
/opt/java/openjdk/bin/java: 7afc28801f744050aeba646b20e7314265279f76f9d0273b21a2b66411e1bf6d
|
||||
/renderer/renderer.jar: 0061e1bce631e468b0d7d08735bb764ba2b6d85260c79c144beabad7ffbc19db
|
||||
/usr/local/bin/zint: ea8dc40c917abd7e8ccc824ef0adafed245a2425abbdfac6a30faeb8e4642233
|
||||
/usr/share/doc/qqq-zint/patchset-id.txt: a287946580d5eeb8691ac1d98d6a7f50bf22dcc5dc0ef039d7033e76e15c52d2
|
||||
/renderer/lib/commons-logging-1.4.0.jar: d175dbd751dd782a63bde28c7a039520e971f25e84b79c19b8435edc3603e0dc
|
||||
/renderer/lib/fontbox-3.0.8.jar: a1915c24e3edbe0ecec93896dfbf6d41427810b663ade97bd4e8bae86ec3fdab
|
||||
/renderer/lib/freemarker-2.3.35.jar: 0fac87dddd78f1223139e8ef88e819c7f483c0a3835cdf5982ad5e4576d1d896
|
||||
/renderer/lib/graphics2d-3.0.1.jar: 4849aaa6f536d75dea1b835a71086702216787036f42716adfad438c6e7928c0
|
||||
/renderer/lib/jackson-annotations-2.22.jar: 21ddb598807d3a51a876704eb979d9296e1c6a6f47ab1826ff88c6d6a127a2d0
|
||||
/renderer/lib/jackson-core-2.22.4.jar: 2d30c375cddea9657a1e1ef97547a2694b663b355facb4de53a097782b312d1a
|
||||
/renderer/lib/jackson-databind-2.22.4.jar: d4cbf8947087f305869826801913abdc50735a1fab2074a1993e7bc67ca974a3
|
||||
/renderer/lib/jsoup-1.23.2.jar: e9d8c856856680427f096d156f02a414289985c930998fcb519ce5be39b42003
|
||||
/renderer/lib/okapibarcode-0.5.7.jar: f33b7d17b7040aa1d9c23b1f3bb274e492b786c340b02d9557bcc027ac874355
|
||||
/renderer/lib/openhtmltopdf-core-1.1.105.jar: d5951a8407c3be41108698066e6d67cf952e526be39d4e49b0b9fbcee761de7d
|
||||
/renderer/lib/openhtmltopdf-pdfbox-1.0.0.jar: ec2865bfa45513e63adaa1a3c6387eb6bbd481e6b23f4d68a33cfedfc753cfe8
|
||||
/renderer/lib/pdfbox-3.0.8.jar: 97647cfbde61ebcfc06b4cf8c9b0ffcaaee073396eceb4a7f6836a9b9128903c
|
||||
/renderer/lib/pdfbox-io-3.0.8.jar: 36a0e04001010b4c764857817412b96339930b19755e728959805cc0352061b2
|
||||
/renderer/lib/slf4j-api-2.0.17.jar: 7b751d952061954d5abfed7181c1f645d336091b679891591d63329c622eb832
|
||||
/renderer/lib/slf4j-nop-2.0.17.jar: 3716f83649ec66161a2edefd4f49df34d1dd1c51cdcf941996c6987260f0a829
|
||||
/renderer/lib/xmpbox-3.0.8.jar: f044d2e316e57714a0adf272ad8abf4a863b8978fd17dc5b86dcfa8135469b10
|
||||
```
|
||||
|
||||
Not covered by these pins: `identity.*`, `certificateNamespaces`, TLS identities, and chart switches.
|
||||
|
||||
## What was and was not verified (1.0.0 build)
|
||||
|
||||
* Verified: `renderer` (174) and transport (19) tests and Checkstyle pass on this source (same sources as the `v1.0.0` tag);
|
||||
both images build; the service image runs read-only, non-root, all capabilities dropped, and served CATALOG, PDF
|
||||
(Code128, GS1-128, Zint DotCode) and ZPL renders over a real mutual-TLS handshake with ephemeral certificates.
|
||||
* Not verified: no cluster run, no handshake against Forge, no gVisor or hostile-input qualification (the service is
|
||||
`TRUSTED_LAB` only), no byte-for-byte rebuild of the OpenHTML jar from `vendor/`.
|
||||
+39
-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,42 @@ 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`, `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.
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# Print the gg-forge-cd echoRenderer.* pins (non-secret) for a PUBLISHED service image, read from the image itself:
|
||||
# sh renderer/image-pins.sh ghcr.io/gg-engineering/qqq-remote-renderer@sha256:<manifest digest>
|
||||
# The classpath is /renderer/renderer.jar followed by /renderer/lib/*.jar in byte order; hashes are SHA256 of the files in the image.
|
||||
set -eu
|
||||
image=${1:?image reference by digest required}
|
||||
case "$image" in *@sha256:*) ;; *) echo 'Reference the image by @sha256: digest' >&2; exit 2;; esac
|
||||
repository=${image%@*}
|
||||
digest=${image#*@}
|
||||
docker pull "$image" >/dev/null
|
||||
image_id=$(docker image inspect "$image" --format '{{.Id}}')
|
||||
echo "echoRenderer:"
|
||||
echo " image:"
|
||||
echo " repository: $repository"
|
||||
echo " digest: $digest"
|
||||
echo " imageId: $image_id"
|
||||
docker run --rm --pull never --network none --user 65532:65532 --entrypoint sh "$image" -c '
|
||||
set -eu
|
||||
echo " classpath:"
|
||||
echo " - /renderer/renderer.jar"
|
||||
for f in $(LC_ALL=C ls /renderer/lib/*.jar); do echo " - $f"; done
|
||||
echo " fileHashes:"
|
||||
for f in /opt/java/openjdk/bin/java /renderer/renderer.jar /usr/local/bin/zint /usr/share/doc/qqq-zint/patchset-id.txt $(LC_ALL=C ls /renderer/lib/*.jar); do
|
||||
printf " %s: %s\n" "$f" "$(sha256sum "$f" | cut -d" " -f1)"
|
||||
done'
|
||||
@@ -36,6 +36,26 @@
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.9.0</version><executions><execution><id>runtime-libraries</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-jar-plugin</artifactId><version>3.4.2</version><configuration><archive><manifest><mainClass>com.kingsrook.qbits.renderer.RendererMain</mainClass><addClasspath>true</addClasspath><classpathPrefix>lib/</classpathPrefix></manifest></archive></configuration></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 renderer, rounded down (2026-11-11: 94.0% of instructions and 91.3% of lines); 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. -->
|
||||
<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.94</minimum></limit>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.91</minimum></limit>
|
||||
</limits></rule>
|
||||
<rule><element>PACKAGE</element><includes><include>com.kingsrook.qbits.renderer.scene2</include><include>com.kingsrook.qbits.renderer.scene2.*</include></includes><limits>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
|
||||
</limits></rule>
|
||||
<rule><element>CLASS</element><includes><include>com.kingsrook.qbits.renderer.AssetInspector</include><include>com.kingsrook.qbits.renderer.AssetInspector.*</include></includes><limits>
|
||||
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
|
||||
</limits></rule>
|
||||
</rules></configuration></execution>
|
||||
</executions></plugin>
|
||||
<plugin>
|
||||
<!-- Publish a self-contained POM: ${revision} is resolved at build time, so a consumer needs no -Drevision -->
|
||||
<groupId>org.codehaus.mojo</groupId><artifactId>flatten-maven-plugin</artifactId><version>1.7.3</version>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Image-assembly project, not a published module. It resolves the RELEASED renderer engine and its runtime
|
||||
libraries from the QRun-IO Gitea Maven registry (and Maven Central) at one exact version, so the renderer image
|
||||
carries release bytes and not a local build. renderer.version has no default: pass -Drenderer.version=<released>. -->
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-renderer-image</artifactId><version>0</version><packaging>pom</packaging>
|
||||
<repositories>
|
||||
<repository><id>central</id><url>https://repo.maven.apache.org/maven2</url><snapshots><enabled>false</enabled></snapshots></repository>
|
||||
<repository><id>qrun-io</id><url>https://git.kof22.com/api/packages/QRun-IO/maven</url><snapshots><enabled>false</enabled></snapshots><releases><checksumPolicy>fail</checksumPolicy></releases></repository>
|
||||
</repositories>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.kingsrook.qbits</groupId><artifactId>qbit-document-renderer</artifactId><version>${renderer.version}</version></dependency>
|
||||
</dependencies>
|
||||
<build><plugins>
|
||||
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.9.0</version>
|
||||
<executions><execution><id>release-libraries</id><phase>package</phase><goals><goal>copy-dependencies</goal></goals>
|
||||
<configuration><includeScope>runtime</includeScope><outputDirectory>${project.build.directory}/lib</outputDirectory></configuration>
|
||||
</execution></executions></plugin>
|
||||
</plugins></build>
|
||||
</project>
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"$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": "Defaults for text elements.",
|
||||
"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. Parsed and placed by renderer 0.2.0 and refused as RENDERER_TOO_OLD until the text package; the values of these properties are not yet read.",
|
||||
"unevaluatedProperties": false,
|
||||
"properties": {"type": {"const": "text"}, "value": {}, "valuePath": {}, "font": {}, "size": {}, "lineHeight": {}, "fit": {}, "maxLength": {}, "threshold": {}, "reverse": {}, "rotation": {"enum": [0, 90, 180, 270]}}
|
||||
},
|
||||
{
|
||||
"$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,74 @@
|
||||
/*
|
||||
* 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. 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"),
|
||||
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,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.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.
|
||||
***************************************************************************/
|
||||
public Refusal invalid(String name)
|
||||
{
|
||||
return (diagnostics.refuse("SCENE_INVALID", 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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Where a property is stated: in the override that set it, else in the
|
||||
** element.
|
||||
***************************************************************************/
|
||||
private String pointer(String name)
|
||||
{
|
||||
return (pointers.getOrDefault(name, pointer + "/" + name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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,374 @@
|
||||
/*
|
||||
* 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());
|
||||
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,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.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;
|
||||
import com.kingsrook.qbits.renderer.scene2.UnavailableElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Text: a TrueType face rasterised by the renderer, with size, fit and legibility
|
||||
** rules.
|
||||
**
|
||||
** This is the placeholder of the scene 2 core: a `text` 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 TextFactory implements ElementFactory
|
||||
{
|
||||
/***************************************************************************
|
||||
** The property names the design gives a `text` element. Their values are
|
||||
** not read yet.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Set<String> properties()
|
||||
{
|
||||
return (Set.of("value", "valuePath", "font", "size", "lineHeight", "fit", "maxLength", "threshold", "reverse", "rotation"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element, placed and then refused by name.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public SceneElement create(ElementSource source)
|
||||
{
|
||||
return (new UnavailableElement(source));
|
||||
}
|
||||
}
|
||||
@@ -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,218 @@
|
||||
/*
|
||||
* 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.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.SplittableRandom;
|
||||
import java.util.function.Predicate;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Seeded, bounded mutation campaign for a parser of untrusted input. A seed, an
|
||||
** iteration number, the corpus and the dictionary fully determine every input:
|
||||
** there is no clock and no unseeded randomness, so a finding replays exactly.
|
||||
** Targets, limits and the replay command are in `docs/security/fuzzing.md`. The
|
||||
** same class is kept in the test tree of `bridges/renderer`.
|
||||
*******************************************************************************/
|
||||
final class MutationCampaign
|
||||
{
|
||||
static final int MAXIMUM_ITERATIONS = 100_000;
|
||||
static final int MAXIMUM_INPUT_BYTES = 1024 * 1024;
|
||||
|
||||
private static final byte[] INTERESTING = {0, 9, 10, 13, ' ', '"', '\\', '^', '~', '_', ',', '{', '}', '[', ']', '0', '9', '-', (byte) 0x7F, (byte) 0x80, (byte) 0xC0, (byte) 0xFF};
|
||||
|
||||
private final List<byte[]> corpus;
|
||||
private final List<byte[]> dictionary;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The parser under test. Returning normally means the input was accepted; a
|
||||
** target asserts its own property on what it accepts.
|
||||
***************************************************************************/
|
||||
@FunctionalInterface
|
||||
interface Target
|
||||
{
|
||||
/************************************************************************
|
||||
** Parse one generated input.
|
||||
************************************************************************/
|
||||
void accept(byte[] input) throws Exception;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An input that was neither accepted nor refused in the declared way, with
|
||||
** the coordinates that regenerate it: `input(seed, iteration)`.
|
||||
***************************************************************************/
|
||||
record Finding(long seed, int iteration, String failure)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What a campaign saw. A campaign that accepted or refused nothing has not
|
||||
** exercised both sides of the parser, so callers assert on the counts too.
|
||||
***************************************************************************/
|
||||
record Result(int accepted, int refused, List<Finding> findings)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Valid inputs to start from, and tokens of the format to splice in.
|
||||
***************************************************************************/
|
||||
MutationCampaign(List<byte[]> corpus, List<String> dictionary)
|
||||
{
|
||||
if(corpus.isEmpty() || corpus.stream().anyMatch(seedInput -> seedInput.length > MAXIMUM_INPUT_BYTES))
|
||||
{
|
||||
throw new IllegalArgumentException("A campaign needs a corpus of inputs within the input limit.");
|
||||
}
|
||||
this.corpus = corpus.stream().map(byte[]::clone).toList();
|
||||
this.dictionary = dictionary.stream().map(token -> token.getBytes(StandardCharsets.UTF_8)).toList();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The seeds for this run: the fixed ones, or only `-Dfuzz.seed=<n>` when a
|
||||
** finding is replayed or a longer campaign is run by hand.
|
||||
***************************************************************************/
|
||||
static long[] seeds(long... fixed)
|
||||
{
|
||||
Long replay = Long.getLong("fuzz.seed");
|
||||
return (replay == null ? fixed : new long[] {replay});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The iteration count for this run: `-Dfuzz.iterations=<n>`, never unbounded.
|
||||
***************************************************************************/
|
||||
static int iterations(int fallback)
|
||||
{
|
||||
int iterations = Integer.getInteger("fuzz.iterations", fallback);
|
||||
if(iterations < 1 || iterations > MAXIMUM_ITERATIONS)
|
||||
{
|
||||
throw new IllegalArgumentException("fuzz.iterations must be between 1 and " + MAXIMUM_ITERATIONS);
|
||||
}
|
||||
return (iterations);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The input of one iteration: a corpus entry with one to four mutations.
|
||||
***************************************************************************/
|
||||
byte[] input(long seed, int iteration)
|
||||
{
|
||||
SplittableRandom random = new SplittableRandom(seed + 0x9E3779B97F4A7C15L * (iteration + 1L));
|
||||
byte[] value = corpus.get(random.nextInt(corpus.size())).clone();
|
||||
int steps = 1 + random.nextInt(4);
|
||||
for(int step = 0; step < steps; step++)
|
||||
{
|
||||
value = mutate(value, random);
|
||||
}
|
||||
return (value.length > MAXIMUM_INPUT_BYTES ? Arrays.copyOf(value, MAXIMUM_INPUT_BYTES) : value);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Run iterations 0 to `iterations - 1`. A thrown failure that `refusal`
|
||||
** recognises is the parser saying no; any other, including a failed assertion
|
||||
** in the target and a stack overflow, is a finding.
|
||||
***************************************************************************/
|
||||
Result run(long seed, int iterations, Target target, Predicate<Throwable> refusal)
|
||||
{
|
||||
if(iterations < 1 || iterations > MAXIMUM_ITERATIONS)
|
||||
{
|
||||
throw new IllegalArgumentException("A campaign runs between 1 and " + MAXIMUM_ITERATIONS + " iterations.");
|
||||
}
|
||||
int accepted = 0;
|
||||
int refused = 0;
|
||||
List<Finding> findings = new ArrayList<>();
|
||||
for(int iteration = 0; iteration < iterations; iteration++)
|
||||
{
|
||||
try
|
||||
{
|
||||
target.accept(input(seed, iteration));
|
||||
accepted++;
|
||||
}
|
||||
catch(Exception | AssertionError | StackOverflowError failure)
|
||||
{
|
||||
if(refusal.test(failure))
|
||||
{
|
||||
refused++;
|
||||
}
|
||||
else
|
||||
{
|
||||
findings.add(new Finding(seed, iteration, failure.toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Result(accepted, refused, List.copyOf(findings)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One structural or byte-level change at a random position.
|
||||
***************************************************************************/
|
||||
private byte[] mutate(byte[] value, SplittableRandom random)
|
||||
{
|
||||
int at = value.length == 0 ? 0 : random.nextInt(value.length);
|
||||
int span = value.length == 0 ? 0 : 1 + random.nextInt(Math.min(16, value.length - at));
|
||||
byte[] noise = new byte[1 + random.nextInt(8)];
|
||||
random.nextBytes(noise);
|
||||
return (switch(random.nextInt(8))
|
||||
{
|
||||
case 0 -> splice(value, at, span, new byte[0]);
|
||||
case 1 -> splice(value, at, 0, Arrays.copyOfRange(value, at, at + span));
|
||||
case 2 -> Arrays.copyOf(value, at);
|
||||
case 3 -> splice(value, at, 0, dictionary.isEmpty() ? noise : dictionary.get(random.nextInt(dictionary.size())));
|
||||
case 4 -> splice(value, at, 0, noise);
|
||||
case 5 -> splice(value, at, Math.min(1, value.length), new byte[] {INTERESTING[random.nextInt(INTERESTING.length)]});
|
||||
case 6 -> splice(value, at, Math.min(1, value.length), new byte[] {noise[0]});
|
||||
default -> splice(value, at, Math.min(1, value.length), value.length == 0 ? noise : new byte[] {(byte) (value[at] ^ (1 << random.nextInt(8)))});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Replace `removed` bytes at `at` with `inserted`.
|
||||
***************************************************************************/
|
||||
private static byte[] splice(byte[] value, int at, int removed, byte[] inserted)
|
||||
{
|
||||
byte[] result = new byte[value.length - removed + inserted.length];
|
||||
System.arraycopy(value, 0, result, 0, at);
|
||||
System.arraycopy(inserted, 0, result, at, inserted.length);
|
||||
System.arraycopy(value, at + removed, result, at + inserted.length, value.length - at - removed);
|
||||
return (result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/*
|
||||
* 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.io.InputStream;
|
||||
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.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;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** 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
|
||||
{
|
||||
private static final long[] SEEDS = {20261111L, 7919L};
|
||||
private static final int ITERATIONS = 20_000;
|
||||
private static final Set<String> ELEMENTS = Set.of("text", "rasterText", "box", "image", "barcode");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a second, differently configured parser, so the acceptance check does not reuse the target //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
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}");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A seed and an iteration name one input, on every run and every machine.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aSeedAndAnIterationAlwaysGiveTheSameInput()
|
||||
{
|
||||
MutationCampaign campaign = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB"));
|
||||
MutationCampaign again = new MutationCampaign(List.of("^XA^FO10,20^GB30,40,2^FS^XZ".getBytes(StandardCharsets.US_ASCII)), List.of("^FO", "^GB"));
|
||||
Set<String> distinct = new java.util.HashSet<>();
|
||||
for(int iteration = 0; iteration < 200; iteration++)
|
||||
{
|
||||
byte[] input = campaign.input(42L, iteration);
|
||||
assertArrayEquals(input, again.input(42L, iteration));
|
||||
assertTrue(input.length <= MutationCampaign.MAXIMUM_INPUT_BYTES);
|
||||
distinct.add(Wire.sha256(input));
|
||||
}
|
||||
assertTrue(distinct.size() > 100, "mutations should rarely repeat: " + distinct.size());
|
||||
assertNotEquals(Wire.sha256(campaign.input(42L, 0)), Wire.sha256(campaign.input(43L, 0)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The harness fails when it should: a crash and an unsafe acceptance are both
|
||||
** findings, each carrying the coordinates that regenerate its input.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aCrashAndAnUnsafeAcceptanceAreFindingsThatReplay()
|
||||
{
|
||||
MutationCampaign campaign = new MutationCampaign(List.of("0123456789".getBytes(StandardCharsets.US_ASCII)), List.of());
|
||||
MutationCampaign.Result result = campaign.run(42L, 300, input ->
|
||||
{
|
||||
if(input.length % 3 == 0)
|
||||
{
|
||||
throw new IllegalArgumentException("REFUSED");
|
||||
}
|
||||
if(input.length % 3 == 1)
|
||||
{
|
||||
throw new IllegalStateException("crash");
|
||||
}
|
||||
assertTrue(input.length < 11, "accepted an input it should have refused");
|
||||
}, IllegalArgumentException.class::isInstance);
|
||||
assertEquals(300, result.accepted() + result.refused() + result.findings().size());
|
||||
assertTrue(result.accepted() > 0 && result.refused() > 0);
|
||||
assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().startsWith("java.lang.IllegalStateException")));
|
||||
assertTrue(result.findings().stream().anyMatch(finding -> finding.failure().contains("AssertionFailedError")));
|
||||
for(MutationCampaign.Finding finding : result.findings())
|
||||
{
|
||||
int length = campaign.input(finding.seed(), finding.iteration()).length;
|
||||
assertTrue(length % 3 == 1 || (length % 3 == 2 && length >= 11), finding.toString());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A campaign is always bounded and always has something to mutate.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void campaignsAreBounded()
|
||||
{
|
||||
MutationCampaign campaign = new MutationCampaign(List.of(new byte[0]), List.of());
|
||||
assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, 0, input -> {}, failure -> true));
|
||||
assertThrows(IllegalArgumentException.class, () -> campaign.run(1L, MutationCampaign.MAXIMUM_ITERATIONS + 1, input -> {}, failure -> true));
|
||||
assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(), List.of()));
|
||||
assertThrows(IllegalArgumentException.class, () -> new MutationCampaign(List.of(new byte[MutationCampaign.MAXIMUM_INPUT_BYTES + 1]), List.of()));
|
||||
assertEquals(50, campaign.run(1L, 50, input -> {}, failure -> false).accepted());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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 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
|
||||
{
|
||||
List<byte[]> corpus = new ArrayList<>();
|
||||
for(String name : List.of("text-font0-203", "box-203", "barcode-scale-203", "barcode-fit-matrix-203", "barcode-fit-linear-203"))
|
||||
{
|
||||
try(InputStream stream = MutationCampaignTest.class.getResourceAsStream("/compat-v1/cases/" + name + ".json"))
|
||||
{
|
||||
corpus.add(Wire.JSON.writeValueAsBytes(Wire.JSON.readTree(stream).get("scene")));
|
||||
}
|
||||
}
|
||||
MutationCampaign campaign = new MutationCampaign(corpus, List.of("\"type\"", "\"barcode\"", "\"rasterText\"", "\"fit\"", "\"valuePath\"", "\"symbologyPath\"", "\"allowedSymbologies\"",
|
||||
"\"qqq-zpl-scene/1\"", "\"qqq-zpl-scene/2\"", "{", "}", "[", "]", ":", ",", "99999999999", "-1", "null", "\"/\"", "\\u0000"));
|
||||
int iterations = MutationCampaign.iterations(ITERATIONS);
|
||||
for(long seed : MutationCampaign.seeds(SEEDS))
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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 and groups (the types this renderer
|
||||
** draws), groups at most four deep, every length a number and a unit, 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. 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"))
|
||||
{
|
||||
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",
|
||||
"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)
|
||||
{
|
||||
if(render)
|
||||
{
|
||||
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);
|
||||
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)
|
||||
{
|
||||
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), () -> "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")
|
||||
: 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The target: static validation of one scene on a stock all corpus scenes fit.
|
||||
***************************************************************************/
|
||||
private static void validateScene(byte[] scene) throws Exception
|
||||
{
|
||||
String source = new String(scene, StandardCharsets.UTF_8);
|
||||
ObjectNode request = Wire.JSON.createObjectNode();
|
||||
request.put("protocol", Wire.PROTOCOL).put("requestId", "mutation-campaign").put("operation", "VALIDATE").put("format", "ZPL").put("maximumBytes", 1024 * 1024).put("source", source);
|
||||
request.set("stock", Wire.JSON.valueToTree(Map.of("widthMicrons", 76200, "heightMicrons", 152400, "topMicrons", 2000, "rightMicrons", 2000, "bottomMicrons", 2000, "leftMicrons", 2000, "dpi", 203)));
|
||||
request.putObject("context");
|
||||
request.putArray("assets");
|
||||
ZplRenderer.validate(new RenderInput(request));
|
||||
JsonNode accepted = INDEPENDENT.readTree(source);
|
||||
assertEquals("qqq-zpl-scene/1", accepted.path("schema").textValue());
|
||||
assertTrue(accepted.path("elements").isArray() && accepted.path("elements").size() <= 1000);
|
||||
for(JsonNode element : accepted.path("elements"))
|
||||
{
|
||||
assertTrue(ELEMENTS.contains(element.path("type").textValue()), () -> "accepted element type " + element.path("type"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
* 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.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
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.TreeMap;
|
||||
import java.util.TreeSet;
|
||||
import java.util.stream.Stream;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
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.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.fail;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Version 1 scenes are frozen. Every scene of the corpus in `compat-v1` must
|
||||
** render to exactly the bytes the released renderer (tag `v1.1.0-RC.2`) produced
|
||||
** for it. The stored bytes are never regenerated: a difference here is a change
|
||||
** to labels that are already printing, and is fixed in the code, not the corpus.
|
||||
*******************************************************************************/
|
||||
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 //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<String, Set<String>> OPTIONS = Map.of(
|
||||
"text", Set.of("x", "y", "width", "height", "value", "valuePath"),
|
||||
"rasterText", Set.of("x", "y", "fontAsset", "fontSize", "width", "height", "rotation", "align", "lineHeight", "threshold", "value", "valuePath"),
|
||||
"box", Set.of("x", "y", "width", "height", "thickness"),
|
||||
"image", Set.of("x", "y", "asset"),
|
||||
"barcode", Set.of("x", "y", "symbology", "symbologyPath", "allowedSymbologies", "value", "valuePath", "scale", "quiet", "options", "payloadEncoding",
|
||||
"fit", "scannerSymbologyPath", "aimIdentifierPath"));
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Each case file is one render request: a stock, a context, assets and a scene.
|
||||
***************************************************************************/
|
||||
static List<String> cases() throws Exception
|
||||
{
|
||||
try(Stream<Path> files = Files.list(Path.of(ZplSceneOneCompatibilityTest.class.getResource(ROOT + "cases").toURI())))
|
||||
{
|
||||
return (files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".json")).map(name -> name.substring(0, name.length() - 5)).sorted().toList());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The released bytes, or the first position at which this build differs. What
|
||||
** this build produced is left in `target/compat-v1-actual` for inspection.
|
||||
***************************************************************************/
|
||||
@ParameterizedTest(name = "{0}")
|
||||
@MethodSource("cases")
|
||||
void rendersExactlyTheReleasedBytes(String name) throws Exception
|
||||
{
|
||||
byte[] actual = render(name);
|
||||
byte[] expected = resource("zpl/" + name + ".zpl");
|
||||
if(expected == null || !Arrays.equals(expected, actual))
|
||||
{
|
||||
Path directory = Files.createDirectories(Path.of("target", "compat-v1-actual"));
|
||||
Files.write(directory.resolve(name + ".zpl"), actual);
|
||||
}
|
||||
assertNotNull(expected, "No released bytes are stored for " + name);
|
||||
int difference = Arrays.mismatch(expected, actual);
|
||||
if(difference >= 0)
|
||||
{
|
||||
fail("Version 1 scene " + name + " no longer renders the released bytes: first difference at byte " + difference
|
||||
+ " (released " + expected.length + " bytes, now " + actual.length + ")");
|
||||
}
|
||||
assertEquals(manifest().get(name), Wire.sha256(actual));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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());
|
||||
for(Map.Entry<String, String> entry : manifest.entrySet())
|
||||
{
|
||||
byte[] stored = resource("zpl/" + entry.getKey() + ".zpl");
|
||||
assertNotNull(stored, entry.getKey());
|
||||
assertEquals(entry.getValue(), Wire.sha256(stored), entry.getKey());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The corpus is only a proof if it reaches every element type and every
|
||||
** property version 1 reads, at each resolution the renderer accepts.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void corpusUsesEveryVersionOneElementOptionAndResolution() throws Exception
|
||||
{
|
||||
Map<String, Set<String>> used = new TreeMap<>();
|
||||
Set<Integer> resolutions = new TreeSet<>();
|
||||
for(String name : cases())
|
||||
{
|
||||
JsonNode definition = definition(name);
|
||||
resolutions.add(definition.path("stock").path("dpi").intValue());
|
||||
assertEquals("qqq-zpl-scene/1", definition.path("scene").path("schema").textValue(), name);
|
||||
for(JsonNode element : definition.path("scene").path("elements"))
|
||||
{
|
||||
Set<String> properties = used.computeIfAbsent(element.path("type").textValue(), type -> new HashSet<>());
|
||||
element.fieldNames().forEachRemaining(properties::add);
|
||||
}
|
||||
}
|
||||
assertEquals(Set.of(203, 300, 600), resolutions);
|
||||
assertEquals(OPTIONS.keySet(), used.keySet());
|
||||
for(Map.Entry<String, Set<String>> entry : OPTIONS.entrySet())
|
||||
{
|
||||
assertTrue(used.get(entry.getKey()).containsAll(entry.getValue()), () -> entry.getKey() + " is missing one of " + entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render through the public protocol entry point, as the isolated worker does.
|
||||
***************************************************************************/
|
||||
private static byte[] render(String name) throws Exception
|
||||
{
|
||||
JsonNode definition = definition(name);
|
||||
ObjectNode request = Wire.JSON.createObjectNode();
|
||||
request.put("protocol", Wire.PROTOCOL).put("requestId", "compat-v1-" + name).put("operation", "RENDER").put("format", "ZPL").put("maximumBytes", 8 * 1024 * 1024);
|
||||
request.set("stock", definition.get("stock"));
|
||||
request.set("context", definition.get("context"));
|
||||
request.put("source", Wire.JSON.writeValueAsString(definition.get("scene")));
|
||||
var assets = request.putArray("assets");
|
||||
for(JsonNode declared : definition.path("assets"))
|
||||
{
|
||||
byte[] bytes;
|
||||
try(InputStream stream = ZplSceneOneCompatibilityTest.class.getResourceAsStream(declared.path("resource").textValue()))
|
||||
{
|
||||
bytes = java.util.Objects.requireNonNull(stream, declared.path("resource").textValue()).readAllBytes();
|
||||
}
|
||||
ObjectNode asset = ((ObjectNode) declared.deepCopy()).put("base64", Base64.getEncoder().encodeToString(bytes)).put("sha256", Wire.sha256(bytes));
|
||||
asset.remove("resource");
|
||||
assets.add(asset);
|
||||
}
|
||||
Map<String, Object> response = RendererMain.process(request);
|
||||
assertEquals("OK", response.get("outcome"), name);
|
||||
return (Base64.getDecoder().decode((String) response.get("base64")));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One case definition.
|
||||
***************************************************************************/
|
||||
private static JsonNode definition(String name) throws Exception
|
||||
{
|
||||
return (Wire.JSON.readTree(java.util.Objects.requireNonNull(resource("cases/" + name + ".json"), name)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** `SHA256SUMS` in the form `sha256sum` writes and checks: hash, two spaces, path.
|
||||
***************************************************************************/
|
||||
private static Map<String, String> manifest() throws Exception
|
||||
{
|
||||
Map<String, String> hashes = new TreeMap<>();
|
||||
byte[] sums = resource("SHA256SUMS");
|
||||
for(String line : sums == null ? new String[0] : new String(sums, StandardCharsets.US_ASCII).split("\n"))
|
||||
{
|
||||
assertTrue(line.matches("[0-9a-f]{64} zpl/[a-z0-9-]+\\.zpl"), line);
|
||||
hashes.put(line.substring(70, line.length() - 4), line.substring(0, 64));
|
||||
}
|
||||
return (hashes);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Corpus bytes, or null when the corpus has no such file.
|
||||
***************************************************************************/
|
||||
private static byte[] resource(String path) throws Exception
|
||||
{
|
||||
try(InputStream stream = ZplSceneOneCompatibilityTest.class.getResourceAsStream(ROOT + path))
|
||||
{
|
||||
return (stream == null ? null : stream.readAllBytes());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,946 @@
|
||||
/*
|
||||
* 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]);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Text, 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.
|
||||
***************************************************************************/
|
||||
@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", "RENDERER_TOO_OLD", "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\",\"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\",\"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("text").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\":\"text\",\"value\":\"TEST-0001\"}]},"
|
||||
+ "{\"id\":\"words\",\"type\":\"text\",\"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,162 @@
|
||||
/*
|
||||
* 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. A golden changes only with a reviewed change to the
|
||||
** layout rules: what this build drew is left in `target/scene2-actual`.
|
||||
*******************************************************************************/
|
||||
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,47 @@
|
||||
# Version 1 compatibility corpus
|
||||
|
||||
Scenes with schema `qqq-zpl-scene/1` are frozen: a label that prints today must print the same bytes after any
|
||||
later change. This directory holds 42 render requests and the exact ZPL the released renderer produced for each.
|
||||
|
||||
| Path | Content |
|
||||
|---|---|
|
||||
| `cases/<name>.json` | One render request: `stock` (with its `dpi`), `context`, `assets` and the `scene`. |
|
||||
| `zpl/<name>.zpl` | The bytes the released renderer returned for that request. |
|
||||
| `SHA256SUMS` | The SHA-256 of every stored output, in the form `sha256sum -c` reads. |
|
||||
| `assets/test-mark.png` | A synthetic 48 x 24 image: a frame, a grey ramp and a transparency ramp. |
|
||||
|
||||
Fonts come from the existing test resources (`/fonts/Abel-Regular.ttf`, `/fonts/jetbrains-mono/`).
|
||||
|
||||
## What it covers
|
||||
|
||||
- The shapes Forge publishes, at the 203 and 300 dpi its printers report: the plain Echo label (dynamic symbology
|
||||
from an allowlist, fitted), the scanner-fidelity label (scanner label and AIM identifier), the compact GS1-128
|
||||
label (`fit` and `quiet`, with the human-readable line as `text` in font 0) and the centred GS1-128 document
|
||||
(`rasterText` with `fontAsset`; its barcode carries an `align` key, which version 1 ignores).
|
||||
- The examples of `renderer/README.md` and `renderer/BARCODE-FIT.md`, verbatim.
|
||||
- One scene per element type and option at 203, 300 and 600 dpi: `text`, `box`, `image`, `rasterText` (alignment,
|
||||
rotation, line height, threshold, wrapping, three faces) and `barcode` (scale, options, bound values, and all 13
|
||||
fit-qualified symbologies).
|
||||
|
||||
Every value is synthetic (`TEST-0001`, `(01)00012345678905(11)260101(21)TEST0001`, `example.invalid`).
|
||||
|
||||
Not covered, because they need the native Zint provider that exists only in the renderer image: `payloadEncoding`
|
||||
`BASE64` and the Zint-backed symbologies. The image qualifications cover those.
|
||||
|
||||
## Where the bytes came from
|
||||
|
||||
They were produced by the source of tag `v1.1.0-RC.2` (commit `e9ffba484e8d97f9853cb071cdac14f3bfd7acda`,
|
||||
renderer `qqq-renderer-0.1.0`, catalog `qqq-barcodes-9`), not by the branch that added this directory:
|
||||
`ZplSceneOneCompatibilityTest` and `cases/` were copied into a checkout of that tag and run there with no stored
|
||||
outputs. The test fails in that state and leaves what it rendered in `target/compat-v1-actual`; those files are
|
||||
`zpl/`. The run was on Temurin 21.0.12 on Windows, and the same test then passed against the same tag on Temurin
|
||||
21.0.9 on Linux (the CI image), so the `rasterText` pixels do not depend on either platform.
|
||||
|
||||
## Rules
|
||||
|
||||
- Never regenerate `zpl/` or `SHA256SUMS`, and never edit a case. If `ZplSceneOneCompatibilityTest` fails, the
|
||||
code changed what a version 1 scene prints; fix the code. The first differing byte is in the failure message
|
||||
and the new bytes are in `renderer/target/compat-v1-actual`.
|
||||
- A new case may be added only with bytes produced the same way, from a tag that is in production.
|
||||
- `bridges/renderer` reads this directory in place (its own `ZplSceneOneCompatibilityTest` proves each stored
|
||||
output passes `ZplOutputPolicy`), so there is one copy.
|
||||
@@ -0,0 +1,42 @@
|
||||
ff0e556b910d9cf3a62cec78f388788c7a466942d8d43462d6cc8ff93d8a961c zpl/barcode-fit-linear-203.zpl
|
||||
29f10d73be75d3c50d6f4b6a034a5d3819173593effa13e043d62f8a9b49e342 zpl/barcode-fit-linear-300.zpl
|
||||
6ba73d75d91b5b7a204b2d8cc0c691438a0608872a0904ba8aa3fc3542c4a04e zpl/barcode-fit-linear-600.zpl
|
||||
7c38e5f1d3816375b002f34a97473d543b57f538d91a6b93aec56f7fad96fc8c zpl/barcode-fit-matrix-203.zpl
|
||||
dfaecb170fabd6a9d0214075f24f11c374edfd36a657e7d1acc0fe9fb480b365 zpl/barcode-fit-matrix-300.zpl
|
||||
c2f397bc2503f087b342038c455f7aff9f279139dabce79f12d2e03f3a8ed242 zpl/barcode-fit-matrix-600.zpl
|
||||
bde75c5c99ef064df4159385686c0a068ae0a82310a31b7703f16d8473d25f26 zpl/barcode-scale-203.zpl
|
||||
02b16ec29d0cb9452cc787ca970181e53f6d88a5fa495aa16b6e131309c02b5f zpl/barcode-scale-300.zpl
|
||||
7ce8d401428ff1c1ccf7587d1b36e5810a0c8762ffb5d217cf7e787b3a4d5806 zpl/barcode-scale-600.zpl
|
||||
d24c7334422a6268566f6fc5826ed1a6f746a3c06fee15f251eabae93b15b0c5 zpl/box-203.zpl
|
||||
97ee29fa76f9359351101c58aa9a72ca81b5e355cc609e42d1780af817f37562 zpl/box-300.zpl
|
||||
d6874a29d5459c3d2f4b027108c9829cb9b0d5bdff7878fc8c4a5da8c55d5c72 zpl/box-600.zpl
|
||||
89fe7d52804801fae99e53bfabada63eb08f85222f599abaa4bb4aea26869f64 zpl/forge-gs1-compact-203.zpl
|
||||
5ac73d061da31c6a45da37e91eecb092803a5036cd8e39b96af26f109e2310f9 zpl/forge-gs1-compact-300.zpl
|
||||
90065471a443d35c30a9426f9d0ac62773114ff57d484bea373b444e8ef3ee9a zpl/forge-gs1-raster-203.zpl
|
||||
205a0ac35ce6df151b8c7feac081e8b1cab3fbdc8ca6a685e601a2782ddf3869 zpl/forge-gs1-raster-300.zpl
|
||||
a626755d7fe9499b5b9b2dbb4bc64bdb60ba5857f267115b4762ab32276e8b30 zpl/forge-plain-code128-203.zpl
|
||||
40552f1f04f58b67d69464e1b58a2ac3eeda98ac5af6d1a96a8b1b128637add5 zpl/forge-plain-code128-300.zpl
|
||||
8d5391528d92f8afe8d6fb62df9f9ce3b6baa6bf3bfdf4133a7cfe60399b74e1 zpl/forge-plain-qr-203.zpl
|
||||
a626755d7fe9499b5b9b2dbb4bc64bdb60ba5857f267115b4762ab32276e8b30 zpl/forge-scanner-code128-203.zpl
|
||||
b5ea4f928c670ee06027b05406d45ab829c29d1676b05ca34728b350dfa60582 zpl/forge-scanner-ean13-203.zpl
|
||||
b2b41c26316c843ef2156179a57e145c57f5006bcdc8f1497d2d263c743b948d zpl/forge-scanner-ean13-300.zpl
|
||||
e45cb088bd56c470aa2ad0f7904e7cf97a75ada21b2cf0075679accc0a0456b6 zpl/image-203.zpl
|
||||
b17fdf526032eb4fe6892f7737cb6c7a1069305c4ff85a9a4d902ebf29a1d778 zpl/image-300.zpl
|
||||
0ae0dde4208c8696bfd36dd427e8f7735dbce3961572b6225f5cf4868ae55b2c zpl/image-600.zpl
|
||||
0b100f9a37a78ceede0dbaaec9ba1e9bbf352b7675bd8900ede3faa5dc855ce7 zpl/raster-text-layout-203.zpl
|
||||
ba4a8eea59afcfd67dbcf2982f70cc3b7083dd3259c439793d79f4cae78f6483 zpl/raster-text-layout-300.zpl
|
||||
d7682aecebdbcc80fd2ade6cb2108cccd991fe05af35a28554111b00cdceb442 zpl/raster-text-layout-600.zpl
|
||||
44231f071dc6131a13d9624454dcb796a7355d167040dfdc68ca7acefb12e06d zpl/raster-text-rotation-203.zpl
|
||||
2a3a8a2529279a2ffe79771ea2f18aa66b08357a5ef5e49d52eaba60d3b2e781 zpl/raster-text-rotation-300.zpl
|
||||
5e68955f87763277798413c26b9486f4ef5a60f3458c734612acbe6e9de813c3 zpl/raster-text-rotation-600.zpl
|
||||
d4a61011f45eb4057dcbd5e2f7ee44e911b1c6ba9bba512a94ed45906143cce8 zpl/readme-barcode-fit-203.zpl
|
||||
fc4fe98d71292b4cedb2bc15343c6b0ae1e71f3ad606422cd197429cf7d8658e zpl/readme-barcode-fit-300.zpl
|
||||
4ff078a28668055a8a480fe909185c13977fdb665ab5d2acac882267e72a6fea zpl/readme-raster-text-203.zpl
|
||||
850b76e86ca32fbee963052b3df69b431f021f76080b192b2954e11c9efde84a zpl/readme-raster-text-300.zpl
|
||||
d094fd9bebc3d2131b7df1a5cb5b0fafcaefb226045f42985b91261e39e27ca4 zpl/readme-raster-text-600.zpl
|
||||
f4ce3ef8723bc893784b1dbcb93ff831f22bdc0959b9500b0644a9ecdf9ce781 zpl/readme-zpl-scene-203.zpl
|
||||
198762809d8d21e49c71dbcfcefb2f61315e5c182b0992ed136b10bb3968b5b0 zpl/readme-zpl-scene-300.zpl
|
||||
48271003bc87ec1c14f53ccdb6810f810d08b63079169258915080a0f89a0688 zpl/readme-zpl-scene-600.zpl
|
||||
2fd2eb3b5c9d761946b5c74656fc88838ed6f79848ea5eb296171539e25cd2e6 zpl/text-font0-203.zpl
|
||||
dbd3697539e011d6c9d4c34185b849184fc22d5b836819df200d98478720ce06 zpl/text-font0-300.zpl
|
||||
9ac70a9aa2bb8a5d41c13143617d338f1c3a50b0fea0f01d5054869c0bc3f31c zpl/text-font0-600.zpl
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 333 B |
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"description": "Every fit-qualified linear symbology in a fixed physical box.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 152400,
|
||||
"topMicrons": 2000,
|
||||
"rightMicrons": 2000,
|
||||
"bottomMicrons": 2000,
|
||||
"leftMicrons": 2000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 16,
|
||||
"symbology": "code128",
|
||||
"value": "TEST-0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 144,
|
||||
"symbology": "code39",
|
||||
"value": "TEST 0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 272,
|
||||
"symbology": "code93",
|
||||
"value": "TEST-0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 400,
|
||||
"symbology": "itf",
|
||||
"value": "0012345678",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 528,
|
||||
"symbology": "gs1-128",
|
||||
"value": "[01]00012345678905[11]260101[21]TEST0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 656,
|
||||
"symbology": "ean13",
|
||||
"value": "001234567890",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 784,
|
||||
"symbology": "ean8",
|
||||
"value": "0012345",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 912,
|
||||
"symbology": "upca",
|
||||
"value": "01234567890",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 1040,
|
||||
"symbology": "upce",
|
||||
"value": "0123456",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"description": "Every fit-qualified linear symbology in a fixed physical box.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 152400,
|
||||
"topMicrons": 2000,
|
||||
"rightMicrons": 2000,
|
||||
"bottomMicrons": 2000,
|
||||
"leftMicrons": 2000,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 24,
|
||||
"symbology": "code128",
|
||||
"value": "TEST-0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 213,
|
||||
"symbology": "code39",
|
||||
"value": "TEST 0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 402,
|
||||
"symbology": "code93",
|
||||
"value": "TEST-0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 591,
|
||||
"symbology": "itf",
|
||||
"value": "0012345678",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 780,
|
||||
"symbology": "gs1-128",
|
||||
"value": "[01]00012345678905[11]260101[21]TEST0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 969,
|
||||
"symbology": "ean13",
|
||||
"value": "001234567890",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 1158,
|
||||
"symbology": "ean8",
|
||||
"value": "0012345",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 1347,
|
||||
"symbology": "upca",
|
||||
"value": "01234567890",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 1536,
|
||||
"symbology": "upce",
|
||||
"value": "0123456",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
{
|
||||
"description": "Every fit-qualified linear symbology in a fixed physical box.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 152400,
|
||||
"topMicrons": 2000,
|
||||
"rightMicrons": 2000,
|
||||
"bottomMicrons": 2000,
|
||||
"leftMicrons": 2000,
|
||||
"dpi": 600
|
||||
},
|
||||
"context": {},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 48,
|
||||
"symbology": "code128",
|
||||
"value": "TEST-0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 426,
|
||||
"symbology": "code39",
|
||||
"value": "TEST 0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 804,
|
||||
"symbology": "code93",
|
||||
"value": "TEST-0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 1182,
|
||||
"symbology": "itf",
|
||||
"value": "0012345678",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 1560,
|
||||
"symbology": "gs1-128",
|
||||
"value": "[01]00012345678905[11]260101[21]TEST0001",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 1938,
|
||||
"symbology": "ean13",
|
||||
"value": "001234567890",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 2316,
|
||||
"symbology": "ean8",
|
||||
"value": "0012345",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 2694,
|
||||
"symbology": "upca",
|
||||
"value": "01234567890",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 3072,
|
||||
"symbology": "upce",
|
||||
"value": "0123456",
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 60000,
|
||||
"heightMicrons": 12000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"description": "Every fit-qualified matrix and stacked symbology in a fixed physical box, with the minimum quiet zone.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 152400,
|
||||
"topMicrons": 2000,
|
||||
"rightMicrons": 2000,
|
||||
"bottomMicrons": 2000,
|
||||
"leftMicrons": 2000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"value": "TEST-0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 16,
|
||||
"symbology": "qr",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 160,
|
||||
"symbology": "datamatrix",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 304,
|
||||
"symbology": "aztec",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 16,
|
||||
"y": 448,
|
||||
"symbology": "pdf417",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 40000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"description": "Every fit-qualified matrix and stacked symbology in a fixed physical box, with the minimum quiet zone.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 152400,
|
||||
"topMicrons": 2000,
|
||||
"rightMicrons": 2000,
|
||||
"bottomMicrons": 2000,
|
||||
"leftMicrons": 2000,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"value": "TEST-0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 24,
|
||||
"symbology": "qr",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 237,
|
||||
"symbology": "datamatrix",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 450,
|
||||
"symbology": "aztec",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 24,
|
||||
"y": 663,
|
||||
"symbology": "pdf417",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 40000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"description": "Every fit-qualified matrix and stacked symbology in a fixed physical box, with the minimum quiet zone.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 152400,
|
||||
"topMicrons": 2000,
|
||||
"rightMicrons": 2000,
|
||||
"bottomMicrons": 2000,
|
||||
"leftMicrons": 2000,
|
||||
"dpi": 600
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"value": "TEST-0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 48,
|
||||
"symbology": "qr",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 474,
|
||||
"symbology": "datamatrix",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 900,
|
||||
"symbology": "aztec",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 15000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 48,
|
||||
"y": 1326,
|
||||
"symbology": "pdf417",
|
||||
"valuePath": "/record/value",
|
||||
"quiet": 4,
|
||||
"fit": {
|
||||
"widthMicrons": 40000,
|
||||
"heightMicrons": 15000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"description": "Barcodes at an explicit scale: typed options, the explicit UTF8 payload encoding, a bound value and a matrix symbology.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"url": "https://example.invalid/test/0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 20,
|
||||
"y": 20,
|
||||
"symbology": "code128",
|
||||
"scale": 2,
|
||||
"quiet": 10,
|
||||
"value": "TEST-0001",
|
||||
"options": {
|
||||
"barHeight": 40
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 20,
|
||||
"y": 130,
|
||||
"symbology": "code39",
|
||||
"scale": 1,
|
||||
"quiet": 10,
|
||||
"value": "TEST 0001",
|
||||
"payloadEncoding": "UTF8"
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 20,
|
||||
"y": 230,
|
||||
"symbology": "qr",
|
||||
"scale": 3,
|
||||
"quiet": 4,
|
||||
"valuePath": "/record/url"
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 200,
|
||||
"y": 230,
|
||||
"symbology": "datamatrix",
|
||||
"scale": 4,
|
||||
"quiet": 4,
|
||||
"value": "TEST-0001"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"description": "Barcodes at an explicit scale: typed options, the explicit UTF8 payload encoding, a bound value and a matrix symbology.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"url": "https://example.invalid/test/0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
"symbology": "code128",
|
||||
"scale": 3,
|
||||
"quiet": 10,
|
||||
"value": "TEST-0001",
|
||||
"options": {
|
||||
"barHeight": 40
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 30,
|
||||
"y": 192,
|
||||
"symbology": "code39",
|
||||
"scale": 1,
|
||||
"quiet": 10,
|
||||
"value": "TEST 0001",
|
||||
"payloadEncoding": "UTF8"
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 30,
|
||||
"y": 340,
|
||||
"symbology": "qr",
|
||||
"scale": 4,
|
||||
"quiet": 4,
|
||||
"valuePath": "/record/url"
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 296,
|
||||
"y": 340,
|
||||
"symbology": "datamatrix",
|
||||
"scale": 6,
|
||||
"quiet": 4,
|
||||
"value": "TEST-0001"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"description": "Barcodes at an explicit scale: typed options, the explicit UTF8 payload encoding, a bound value and a matrix symbology.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 600
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"url": "https://example.invalid/test/0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 59,
|
||||
"y": 59,
|
||||
"symbology": "code128",
|
||||
"scale": 6,
|
||||
"quiet": 10,
|
||||
"value": "TEST-0001",
|
||||
"options": {
|
||||
"barHeight": 40
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 59,
|
||||
"y": 384,
|
||||
"symbology": "code39",
|
||||
"scale": 3,
|
||||
"quiet": 10,
|
||||
"value": "TEST 0001",
|
||||
"payloadEncoding": "UTF8"
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 59,
|
||||
"y": 680,
|
||||
"symbology": "qr",
|
||||
"scale": 9,
|
||||
"quiet": 4,
|
||||
"valuePath": "/record/url"
|
||||
},
|
||||
{
|
||||
"type": "barcode",
|
||||
"x": 591,
|
||||
"y": 680,
|
||||
"symbology": "datamatrix",
|
||||
"scale": 12,
|
||||
"quiet": 4,
|
||||
"value": "TEST-0001"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"description": "Boxes: an outline, a filled square, a horizontal rule and a vertical rule.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "box",
|
||||
"x": 20,
|
||||
"y": 20,
|
||||
"width": 200,
|
||||
"height": 100,
|
||||
"thickness": 3
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 240,
|
||||
"y": 20,
|
||||
"width": 60,
|
||||
"height": 60,
|
||||
"thickness": 30
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 20,
|
||||
"y": 140,
|
||||
"width": 500,
|
||||
"height": 2,
|
||||
"thickness": 2
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 560,
|
||||
"y": 20,
|
||||
"width": 2,
|
||||
"height": 200,
|
||||
"thickness": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"description": "Boxes: an outline, a filled square, a horizontal rule and a vertical rule.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "box",
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
"width": 296,
|
||||
"height": 148,
|
||||
"thickness": 4
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 355,
|
||||
"y": 30,
|
||||
"width": 89,
|
||||
"height": 89,
|
||||
"thickness": 44
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 30,
|
||||
"y": 207,
|
||||
"width": 739,
|
||||
"height": 3,
|
||||
"thickness": 3
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 828,
|
||||
"y": 30,
|
||||
"width": 3,
|
||||
"height": 296,
|
||||
"thickness": 3
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"description": "Boxes: an outline, a filled square, a horizontal rule and a vertical rule.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 600
|
||||
},
|
||||
"context": {},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "box",
|
||||
"x": 59,
|
||||
"y": 59,
|
||||
"width": 591,
|
||||
"height": 296,
|
||||
"thickness": 9
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 709,
|
||||
"y": 59,
|
||||
"width": 177,
|
||||
"height": 177,
|
||||
"thickness": 89
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 59,
|
||||
"y": 414,
|
||||
"width": 1478,
|
||||
"height": 6,
|
||||
"thickness": 6
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"x": 1655,
|
||||
"y": 59,
|
||||
"width": 6,
|
||||
"height": 591,
|
||||
"thickness": 6
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"description": "Forge compact GS1-128 label: fitted bars with quiet zone and the human-readable line in printer font 0.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererValue": "[01]00012345678905[11]260101[21]TEST0001",
|
||||
"humanReadable": "(01)00012345678905(11)260101(21)TEST0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbology": "gs1-128",
|
||||
"valuePath": "/record/rendererValue",
|
||||
"quiet": 10,
|
||||
"x": 32,
|
||||
"y": 32,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 12400
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"valuePath": "/record/humanReadable",
|
||||
"x": 32,
|
||||
"y": 144,
|
||||
"height": 24,
|
||||
"width": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"description": "Forge compact GS1-128 label: fitted bars with quiet zone and the human-readable line in printer font 0.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererValue": "[01]00012345678905[11]260101[21]TEST0001",
|
||||
"humanReadable": "(01)00012345678905(11)260101(21)TEST0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbology": "gs1-128",
|
||||
"valuePath": "/record/rendererValue",
|
||||
"quiet": 10,
|
||||
"x": 48,
|
||||
"y": 48,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 12400
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"valuePath": "/record/humanReadable",
|
||||
"x": 48,
|
||||
"y": 212,
|
||||
"height": 36,
|
||||
"width": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"description": "The Forge centred GS1-128 document drawn for a 203 dpi printer.",
|
||||
"stock": {
|
||||
"widthMicrons": 103504,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererValue": "[01]00012345678905[11]260101[21]TEST0001",
|
||||
"humanReadable": "(01)00012345678905(11)260101(21)TEST0001"
|
||||
}
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"name": "forge-echo-abel-regular",
|
||||
"resource": "/fonts/Abel-Regular.ttf",
|
||||
"mediaType": "font/ttf",
|
||||
"fontFamily": "Abel",
|
||||
"fontWeight": 400,
|
||||
"fontStyle": "normal"
|
||||
}
|
||||
],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbology": "gs1-128",
|
||||
"valuePath": "/record/rendererValue",
|
||||
"quiet": 10,
|
||||
"x": 32,
|
||||
"y": 32,
|
||||
"fit": {
|
||||
"widthMicrons": 95504,
|
||||
"heightMicrons": 16000
|
||||
},
|
||||
"align": "center"
|
||||
},
|
||||
{
|
||||
"type": "rasterText",
|
||||
"valuePath": "/record/humanReadable",
|
||||
"x": 32,
|
||||
"y": 162,
|
||||
"fontAsset": "forge-echo-abel-regular",
|
||||
"fontSize": 30,
|
||||
"width": 763,
|
||||
"height": 40,
|
||||
"align": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"description": "Forge centred GS1-128 document: fitted bars carrying an align key that version 1 ignores, and the human-readable line as rasterText in an uploaded face.",
|
||||
"stock": {
|
||||
"widthMicrons": 103504,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererValue": "[01]00012345678905[11]260101[21]TEST0001",
|
||||
"humanReadable": "(01)00012345678905(11)260101(21)TEST0001"
|
||||
}
|
||||
},
|
||||
"assets": [
|
||||
{
|
||||
"name": "forge-echo-abel-regular",
|
||||
"resource": "/fonts/Abel-Regular.ttf",
|
||||
"mediaType": "font/ttf",
|
||||
"fontFamily": "Abel",
|
||||
"fontWeight": 400,
|
||||
"fontStyle": "normal"
|
||||
}
|
||||
],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbology": "gs1-128",
|
||||
"valuePath": "/record/rendererValue",
|
||||
"quiet": 10,
|
||||
"x": 36,
|
||||
"y": 36,
|
||||
"fit": {
|
||||
"widthMicrons": 95504,
|
||||
"heightMicrons": 16000
|
||||
},
|
||||
"align": "center"
|
||||
},
|
||||
{
|
||||
"type": "rasterText",
|
||||
"valuePath": "/record/humanReadable",
|
||||
"x": 36,
|
||||
"y": 206,
|
||||
"fontAsset": "forge-echo-abel-regular",
|
||||
"fontSize": 44,
|
||||
"width": 1128,
|
||||
"height": 58,
|
||||
"align": "center"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"description": "Forge plain Echo label: dynamic symbology from an allowlist, fitted to the whole label less its inset.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererSymbology": "code128",
|
||||
"barcode": "TEST-0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbologyPath": "/record/rendererSymbology",
|
||||
"allowedSymbologies": [
|
||||
"code128",
|
||||
"qr",
|
||||
"code39",
|
||||
"datamatrix",
|
||||
"pdf417",
|
||||
"aztec"
|
||||
],
|
||||
"valuePath": "/record/barcode",
|
||||
"x": 32,
|
||||
"y": 32,
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 17400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"description": "Forge plain Echo label: dynamic symbology from an allowlist, fitted to the whole label less its inset.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererSymbology": "code128",
|
||||
"barcode": "TEST-0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbologyPath": "/record/rendererSymbology",
|
||||
"allowedSymbologies": [
|
||||
"code128",
|
||||
"qr",
|
||||
"code39",
|
||||
"datamatrix",
|
||||
"pdf417",
|
||||
"aztec"
|
||||
],
|
||||
"valuePath": "/record/barcode",
|
||||
"x": 48,
|
||||
"y": 48,
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 17400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"description": "Forge plain Echo label resolving a matrix symbology from the same allowlist.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererSymbology": "qr",
|
||||
"barcode": "TEST-0001"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbologyPath": "/record/rendererSymbology",
|
||||
"allowedSymbologies": [
|
||||
"code128",
|
||||
"qr",
|
||||
"code39",
|
||||
"datamatrix",
|
||||
"pdf417",
|
||||
"aztec"
|
||||
],
|
||||
"valuePath": "/record/barcode",
|
||||
"x": 32,
|
||||
"y": 32,
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 17400
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"description": "Forge scanner-fidelity Echo label for a symbology whose payload is passed through unchanged.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererSymbology": "code128",
|
||||
"barcode": "TEST-0001",
|
||||
"symbology": "CODE128",
|
||||
"aimIdentifier": "]C0"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbologyPath": "/record/rendererSymbology",
|
||||
"allowedSymbologies": [
|
||||
"code128",
|
||||
"qr",
|
||||
"code39",
|
||||
"datamatrix",
|
||||
"pdf417",
|
||||
"aztec",
|
||||
"ean13",
|
||||
"ean8",
|
||||
"upca",
|
||||
"upce"
|
||||
],
|
||||
"valuePath": "/record/barcode",
|
||||
"x": 32,
|
||||
"y": 32,
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 17400
|
||||
},
|
||||
"scannerSymbologyPath": "/record/symbology",
|
||||
"aimIdentifierPath": "/record/aimIdentifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"description": "Forge scanner-fidelity Echo label: the recorded scanner label and AIM identifier are verified and an EAN-13 is reproduced.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererSymbology": "ean13",
|
||||
"barcode": "0012345678905",
|
||||
"symbology": "LABEL-TYPE-EAN13",
|
||||
"aimIdentifier": "]E0"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbologyPath": "/record/rendererSymbology",
|
||||
"allowedSymbologies": [
|
||||
"code128",
|
||||
"qr",
|
||||
"code39",
|
||||
"datamatrix",
|
||||
"pdf417",
|
||||
"aztec",
|
||||
"ean13",
|
||||
"ean8",
|
||||
"upca",
|
||||
"upce"
|
||||
],
|
||||
"valuePath": "/record/barcode",
|
||||
"x": 32,
|
||||
"y": 32,
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 17400
|
||||
},
|
||||
"scannerSymbologyPath": "/record/symbology",
|
||||
"aimIdentifierPath": "/record/aimIdentifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"description": "Forge scanner-fidelity Echo label: the recorded scanner label and AIM identifier are verified and an EAN-13 is reproduced.",
|
||||
"stock": {
|
||||
"widthMicrons": 101600,
|
||||
"heightMicrons": 25400,
|
||||
"topMicrons": 3000,
|
||||
"rightMicrons": 3000,
|
||||
"bottomMicrons": 3000,
|
||||
"leftMicrons": 3000,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {
|
||||
"record": {
|
||||
"rendererSymbology": "ean13",
|
||||
"barcode": "0012345678905",
|
||||
"symbology": "LABEL-TYPE-EAN13",
|
||||
"aimIdentifier": "]E0"
|
||||
}
|
||||
},
|
||||
"assets": [],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "barcode",
|
||||
"symbologyPath": "/record/rendererSymbology",
|
||||
"allowedSymbologies": [
|
||||
"code128",
|
||||
"qr",
|
||||
"code39",
|
||||
"datamatrix",
|
||||
"pdf417",
|
||||
"aztec",
|
||||
"ean13",
|
||||
"ean8",
|
||||
"upca",
|
||||
"upce"
|
||||
],
|
||||
"valuePath": "/record/barcode",
|
||||
"x": 48,
|
||||
"y": 48,
|
||||
"quiet": 10,
|
||||
"fit": {
|
||||
"widthMicrons": 93600,
|
||||
"heightMicrons": 17400
|
||||
},
|
||||
"scannerSymbologyPath": "/record/symbology",
|
||||
"aimIdentifierPath": "/record/aimIdentifier"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "Image assets print at their pixel size; grey and partly transparent pixels are thresholded.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 203
|
||||
},
|
||||
"context": {},
|
||||
"assets": [
|
||||
{
|
||||
"name": "logo",
|
||||
"resource": "/compat-v1/assets/test-mark.png",
|
||||
"mediaType": "image/png"
|
||||
}
|
||||
],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "image",
|
||||
"x": 20,
|
||||
"y": 20,
|
||||
"asset": "logo"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"x": 100,
|
||||
"y": 60,
|
||||
"asset": "logo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "Image assets print at their pixel size; grey and partly transparent pixels are thresholded.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 300
|
||||
},
|
||||
"context": {},
|
||||
"assets": [
|
||||
{
|
||||
"name": "logo",
|
||||
"resource": "/compat-v1/assets/test-mark.png",
|
||||
"mediaType": "image/png"
|
||||
}
|
||||
],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "image",
|
||||
"x": 30,
|
||||
"y": 30,
|
||||
"asset": "logo"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"x": 148,
|
||||
"y": 89,
|
||||
"asset": "logo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"description": "Image assets print at their pixel size; grey and partly transparent pixels are thresholded.",
|
||||
"stock": {
|
||||
"widthMicrons": 76200,
|
||||
"heightMicrons": 50800,
|
||||
"topMicrons": 0,
|
||||
"rightMicrons": 0,
|
||||
"bottomMicrons": 0,
|
||||
"leftMicrons": 0,
|
||||
"dpi": 600
|
||||
},
|
||||
"context": {},
|
||||
"assets": [
|
||||
{
|
||||
"name": "logo",
|
||||
"resource": "/compat-v1/assets/test-mark.png",
|
||||
"mediaType": "image/png"
|
||||
}
|
||||
],
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/1",
|
||||
"elements": [
|
||||
{
|
||||
"type": "image",
|
||||
"x": 59,
|
||||
"y": 59,
|
||||
"asset": "logo"
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"x": 296,
|
||||
"y": 177,
|
||||
"asset": "logo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user