Compare commits

..
31 Commits
Author SHA1 Message Date
james.maes 595d094ec8 Merge pull request 'feat(renderer): scene 2 core: labels in physical units, one canvas, structured diagnostics (P1)' (#14) from feat/scene2-core into develop
build / maven (push) Successful in 7m15s
maven / build (push) Successful in 7m15s
2026-11-12 17:11:17 +00:00
james.maesandClaude Fable 5.2 58051e7b99 fix(renderer): scene 2 core: the findings of the independent review
build / maven (push) Successful in 11m29s
maven / build (push) Successful in 11m29s
1. A length too long for any stock is a named refusal. Length.parse
   narrowed the micrometres to an int before it applied the 2 m limit, so
   "84600in" or "99999in" threw ArithmeticException: the opaque
   RENDER_FAILED, and an error instead of a REFUSED answer when collecting.
   The limit is now compared in long before narrowing, so it is
   SCENE_INVALID at the pointer of the length.

2. The printable area holds a box of its own size at every resolution. It
   was floor(far edge) less ceil(near margin), which can be one dot smaller
   than a box of the stated printable width: 100 mm stock with 3 mm margins
   and a 94 mm box fitted at 203 and 600 dpi and was refused at 300. It is
   now floor((W - L - R) x dpi) by floor((H - T - B) x dpi), the rounding of
   a box size, starting at ceil(L x dpi), ceil(T x dpi), moved back one dot
   where it would otherwise pass the page edge. New golden case `margins`
   with fractional-dot margins; the three existing goldens at 600 dpi are
   regenerated (the area grew by one dot each way; 203 and 300 unchanged).

3. The version 2 mutation campaign accepts only a Refusal (the package's
   own invariants are IllegalArgumentExceptions too) and renders about one
   input in four, reading the label back and comparing with validation. It
   found nothing at 20000 or 100000 iterations for either seed.

4. A refused answer carries no label bytes: with COLLECT and an error the
   answer had base64 of a label with elements missing. Scene2.render answers
   no label for a refused scene and the answer has no document fields. This
   changes the wire contract of section B.1 of the design, which records it.

5. One scene may paint sixteen times its printable area, an overlap counted
   each time, counted before any ink and in validation too. The element
   that would pass it is SCENE_INVALID with measured.paintedPercent against
   limit.paintedPercent 1600. 1000 fills of a 64 million dot canvas were
   8 GB of byte writes; the worst scene is now 128 MB.

6. The README states what changed in the version 1 answer envelope in
   0.2.0 (diagnostics on every answer; SCENE_INVALID with detail where
   unreadable JSON and a missing or unknown schema were RENDER_FAILED) and
   what the bridge must handle. The version 1 ZPL bytes are unchanged.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:59:20 -06:00
james.maesandClaude Fable 5.2 1992fdf703 perf(renderer): paint a canvas box a byte of dots at a time
build / maven (push) Successful in 13m51s
maven / build (push) Successful in 13m51s
A scene may hold 1000 elements and a canvas 64 million dots, so a scene of
many large filled boxes visited tens of billions of dots one at a time.
Filling and reversing a box now write whole bytes, check the box against
the canvas before any dot changes (a box that leaves the canvas paints
nothing rather than its first rows), and a seeded property test holds the
result to painting each dot, at byte boundaries as everywhere else. The
goldens are unchanged.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:07:48 -06:00
james.maesandClaude Fable 5.2 ad6b0ffa88 feat(renderer): scene 2 core: layout in physical units, one canvas, diagnostics (P1)
A source with schema qqq-zpl-scene/2 is a label layout in millimetres,
inches, points, percentages and dots that renders at 203, 300 or 600 dpi.
The worker routes by the schema alone: version 1 takes exactly the path it
always took (the compatibility corpus is unchanged and green), version 2
takes the new package com.kingsrook.qbits.renderer.scene2, and anything
else is refused by name.

- Length: parse, whole micrometres, dots by the rule of each use
  (position nearest, box size down, thickness nearest and at least one,
  module up, font size not rounded).
- Layout: nine-point anchors, offsets inward from the anchored edge,
  omitted sizes, content alignment, groups four deep, percentages of the
  container. Coordinates start at the top left of the printable area.
- Canvas and ZplEmitter: the whole label on one 1-bit canvas, emitted as
  trimmed ^GFA bands that pass the unchanged ZplOutputPolicy.
- Diagnostics: every refusal is one diagnostic with a code, the element
  id, the box and named integers; SCENE_INVALID carries a JSON pointer.
  A diagnostic names only an id the scene declares and points only
  through schema names, indexes and declared ids.
- box and group elements; overrides by resolution, checked for all three
  resolutions; samples accepted and ignored. Text, barcodes and images
  are parsed and placed and then refused as RENDERER_TOO_OLD by
  placeholder factories in their own sub-packages.
- Renderer 0.2.0: optional targetDpi and "diagnostics": "COLLECT" on the
  request, diagnostics on the answer, detail on a version 2 error, the
  REFUSED outcome, and INSPECT_ASSET and UPGRADE_SCENE routed and refused
  as UNSUPPORTED_OPERATION. A version 1 scene with a target other than
  its stock's dpi is refused as TARGET_REQUIRES_SCENE_2.

Tests: every length and rounding rule; all nine anchors; seeded property
tests (mirrored anchors, boxes never leave the printable area, the bitmap
rebuilt from the emitted fields equals the canvas); goldens at three
resolutions as pictures and as ZPL, the ZPL also checked against the host
gate in bridges/renderer; every refusal asserted as the whole diagnostic;
a mutation campaign over the version 2 parser. The new package is at 100%
line coverage.

Also, from the review of the foundations: the mutation campaigns accept
only declared refusals (a NullPointerException in a parser is now a
finding), git no longer treats the stored corpus as text, and both
compatibility tests pin the digest of the corpus manifest.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:01:21 -06:00
james.maesandClaude Fable 5.2 4cd2f93e37 feat(foundation): a diagnostic carries the pointer of SCENE_INVALID
The design says SCENE_INVALID carries the JSON pointer of the offending
property, and GenerationPorts.Diagnostic had no field for one. It gains an
optional seventh component, pointer, held to the shape of at most sixteen
segments that are array indexes or names; the six-argument constructor
stays, so existing callers compile unchanged. The renderer builds a
pointer only from schema property names, indexes and declared element ids.

Section 8.1 of the design records this decision and the others made while
building the scene 2 core.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 10:01:20 -06:00
james.maes f4342e345b Merge remote-tracking branch 'origin/docs/label-layout-preview-design' into feat/scene2-core 2026-11-12 09:24:59 -06:00
james.maes fda9ad0de6 Merge pull request 'feat: scene 2 foundations: version 1 compatibility corpus, coverage and fuzz gates, preview ports (P0, P13, P6a)' (#13) from feat/scene2-foundations into develop
build / maven (push) Successful in 10m41s
maven / build (push) Successful in 10m41s
2026-11-12 15:15:18 +00:00
james.maesandClaude Fable 5.2 c6aa0bf6b0 feat(foundation): preview, diagnostic and render target ports with registration hooks (P6a)
build / maven (push) Successful in 6m19s
maven / build (push) Successful in 6m19s
The contracts the preview, bridge and render target packages plug into. No
label prints differently and no existing host adapter changes.

GenerationPorts:
- Diagnostic (with Severity): a code from a closed vocabulary, an author
  element id, a box and named integers. It has no field that holds free text.
- PreviewRequest, PreviewPage and Preview, and IsolatedRenderer.preview as a
  default that refuses, so an adapter written before it compiles unchanged.
- 15 version 2 FailureCode values after the 13 released ones, and
  RenderException(FailureCode, Diagnostic) beside the existing constructor.
- Integer targetDpi on RenderRequest (absent, 203, 300 or 600); the
  seven-argument constructor is kept and nothing reads the value yet.

Registration points, each an empty class that is already called:
GenerationPreviewProcesses, GenerationAssetProcesses,
GenerationUpgradeProcesses and GenerationAssetTables from the producer, and
GenerationPreviewWidget.section(table) from GenerationTables.

Bridge: IsolatedRendererBridge maps any FailureCode name from a 0.1.0 worker
to a typed refusal, so growing the enum alone would have widened what such a
worker can name. The 13 released codes are now the set a 0.1.0 worker may
name; BridgeTest asserts the other 15 stay generic.

Tests: record validation, default refusals, source compatibility of the
existing constructors, and that the producer registers exactly the tables,
processes, choices, widgets and template sections it did before (the lists
were captured from the untouched v1.1.0-RC.2 tag).

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:28:43 -06:00
james.maesandClaude Fable 5.2 3ba676106f build(coverage): gate renderer and bridge coverage at baseline; seeded mutation campaigns (P13)
Coverage gates:
- renderer and bridges/renderer gain JaCoCo (prepare-agent, report, check at
  verify) with a bundle rule at the measured baseline, rounded down: renderer
  94% instructions and 91% lines, bridge 85% instructions and 82% lines.
- New code is held to 100% line coverage from its first commit: packages
  com.kingsrook.qbits.renderer.scene2 and below, AssetInspector, ZplRaster,
  and the new foundation classes the label layout design names. The rules
  match nothing until those exist, so later packages do not edit these poms.
- The foundation's 85% instruction and 90% class rule is unchanged.

Fuzz scaffolding:
- MutationCampaign in each module's test tree: a seeded, bounded mutation
  harness with no clock and no unseeded randomness; a finding replays from
  its seed and iteration.
- One campaign per module in the normal test phase: what the version 1 scene
  parser accepts is a version 1 scene (checked by a second parser), and what
  the host printer gate accepts holds only the renderer's command set.
- docs/security/fuzzing.md: targets, properties, limits, seeds and replay.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:28:42 -06:00
james.maesandClaude Fable 5.2 ea776391ca test(renderer): version 1 scene compatibility corpus from v1.1.0-RC.2 (P0)
Version 1 scenes (qqq-zpl-scene/1) are frozen. This adds 42 render requests
and the exact ZPL the released renderer returned for each, so later work can
prove that labels already printing are untouched.

- renderer/src/test/resources/compat-v1: cases, stored ZPL, SHA256SUMS and a
  README with provenance and rules. The bytes were produced by the source of
  tag v1.1.0-RC.2 (e9ffba48), not by this branch, and the same test passed
  against the tag on Temurin 21.0.12 (Windows) and 21.0.9 (the CI image).
- renderer ZplSceneOneCompatibilityTest: fails on the first differing byte,
  on a manifest that does not name exactly the cases, and on a corpus that
  misses a version 1 element type, property or resolution.
- bridges/renderer ZplSceneOneCompatibilityTest: every stored output has its
  manifest hash and passes the unchanged ZplOutputPolicy. It reads the
  renderer's directory in place, so there is one copy of the corpus.

Covered: the four shapes Forge publishes at 203 and 300 dpi (GS1-128 with
fit and quiet, text in font 0, rasterText with fontAsset), the README and
BARCODE-FIT.md examples, and one scene per element and option at 203, 300
and 600 dpi. All values are synthetic.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 17:28:25 -06:00
james.maesandClaude Fable 5.2 22063ccfb1 docs(design): labels in physical units, text and barcode fit, fonts everywhere, preview in the QBit
build / maven (push) Successful in 8m37s
maven / build (push) Successful in 8m37s
Design and work plan only; no code changes. Covers scene version 2 beside a frozen
version 1, bundled and database-stored fonts, whole-dot barcode fitting with
centring, structured diagnostics, a preview decoded from the validated ZPL, the
release order and what GG Forge pins and deletes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 16:42:32 -06:00
CI 9a355670c0 Bump version to 1.4.0-SNAPSHOT [skip ci] 2026-11-10 23:13:57 +00:00
james.maes 228931a937 Merge pull request 'fix(postgres): accept full_page_writes off on Amazon Aurora PostgreSQL only' (#9) from fix/aurora-full-page-writes into develop
build / maven (push) Successful in 16m12s
maven / build (push) Successful in 16m12s
2026-11-10 22:57:56 +00:00
james.maesandClaude Fable 5.2 cfbf0744f9 fix(postgres): accept full_page_writes off on Amazon Aurora PostgreSQL only
build / maven (push) Successful in 6m14s
maven / build (push) Successful in 6m14s
Generation qualification refused every Amazon Aurora PostgreSQL server:
Aurora always reports full_page_writes = off and has no parameter to change
it, because its storage layer does not write torn pages.

The settings query now also returns
to_regprocedure('aurora_version()') IS NOT NULL (true on Aurora, false on
stock PostgreSQL, never an error). full_page_writes = off is accepted only
when that is true. Stock PostgreSQL must still report it on, and fsync on,
synchronous_commit on or remote_apply and every other check are unchanged
on both. The refusal text now says full-page writes are not required on
Aurora.

Tests run on a real postgres:17 engine whose settings are really changed;
only Aurora is simulated, by creating a function named aurora_version() in
the test database.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-09 20:25:55 -06:00
CI 1dc265647d Bump version to 1.3.0-SNAPSHOT [skip ci] 2026-11-09 02:46:35 +00:00
james.maes e762dac5d5 Merge pull request 'build(renderer): build engine and service images from a released version' (#7) from fix/renderer-images into develop
build / maven (push) Successful in 6m43s
maven / build (push) Successful in 6m43s
2026-11-09 02:39:57 +00:00
james.maesandClaude Sonnet 5.5 e19feb01a8 build(renderer): build the engine and service images from a released version
build / maven (push) Successful in 6m2s
maven / build (push) Successful in 6m2s
Both Dockerfiles copied 0.1.0-SNAPSHOT jars that the reactor no longer produces
(version is ${revision}), so neither built a released version. They now resolve the
released qbit-document-renderer and qqq-remote-renderer-transport (and their runtime
libraries) at an exact, required RENDERER_VERSION / TRANSPORT_VERSION from the
QRun-IO Gitea Maven registry and Maven Central, refusing SNAPSHOT and local
(*forge*) versions. The reviewed hardening is unchanged: digest-pinned
BUILD_IMAGE/JAVA_IMAGE, non-root 65532, read-only-root friendly, Zint built from
vendor/. The reactor build context is no longer needed.

Adds IMAGE-PUBLISH.md (repeatable build/scan/publish steps and the 1.0.0 pins) and
image-pins.sh (prints the gg-forge-cd classpath and SHA256 pins from a published
image). Images for 1.0.0 were built, smoke-tested over mTLS and published to GHCR
under gg-engineering by hand; no workflow publishes images.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-08 20:07:14 -06:00
CI d8a57607fd Bump version to 1.2.0-SNAPSHOT [skip ci] 2026-11-07 19:19:02 +00:00
james.maes 889296d8f5 Merge pull request 'build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)' (#6) from deps/align-gitea into develop
build / maven (push) Successful in 11m52s
maven / build (push) Successful in 11m52s
2026-11-07 19:07:22 +00:00
james.maesandClaude Opus 5.5 e085859a25 build(deps): move qqq.version into <properties>
build / maven (push) Successful in 7m0s
maven / build (push) Successful in 7m0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:59:59 +00:00
james.maesandClaude Opus 5.5 4f850c2cf3 build(deps): align with Gitea registry (parent 2.2.1, QQQ 4.1.0-RC.26)
build / maven (push) Failing after 9s
maven / build (push) Failing after 9s
bridges/printing/pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.26
bridges/printing/pom.xml: qbit-record-files.version: 0.1.0-RC.1 -> 0.4.0-RC.1
bridges/printing/pom.xml: qbit-printing.version: 1.0.0 -> 1.2.0-RC.1
bridges/record-files/pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.26
bridges/record-files/pom.xml: qbit-record-files.version: 0.1.0-RC.1 -> 0.4.0-RC.1
bridges/scripting/pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.26
bridges/scripting/pom.xml: qbit-scripting.version: 1.0.0 -> 1.1.0-RC.3
foundation/pom.xml: qqq.version: 4.1.0-RC.22 -> 4.1.0-RC.26
pom.xml: parent -> 2.2.1
pom.xml: added qqq.version=4.1.0-RC.26 (parent BOM import)
pom.xml: revision -> 1.1.0-SNAPSHOT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:59:01 +00:00
CI f9adf1d3d3 Bump version to 0.3.0-SNAPSHOT [skip ci] 2026-11-06 23:35:57 +00:00
james.maes 2dcee6fbea Merge pull request 'ci: pin shared workflow to v1' (#4) from ci/pin-v1-develop into develop
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (push) Successful in 9s
QRun-IO/ci-policy: native / native-result (push) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Waiting to run
build / maven (push) Successful in 19m19s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 19m19s
2026-11-06 23:11:03 +00:00
James MaesandClaude Opus 5.5 34a3666bbf Merge develop: adopt the released Document Generation 1.0 changes
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 7s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Waiting to run
build / maven (push) Successful in 8m5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 8m5s
develop (PR #5) already fixed the renderer BarcodeDeclaredCapabilityTest
gaps, moved the family to QQQ 4.1.0-RC.22 and pinned the released Record
Files 0.1.0-RC.1 and Scripting 1.0.0, which is what resolves the scripting
bridge's "reviewed core isolation marker metadata" errors from run 6360.
Take develop's implementation and docs wholesale so this branch only
carries its own change: pinning the shared Maven workflow to v1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:54:15 -06:00
James MaesandClaude Opus 5.5 6a3153456e build: align the family on QQQ 4.1.0-RC.22
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 9cb8c13124a99d18da83cbcd4faa795859ac9e55] (pull_request) Successful in 6s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 5s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
build / maven (push) Successful in 8m48s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 8m48s
The published qbit-scripting 0.1.0-SNAPSHOT (and its tests jar) is built
against QQQ Core 4.1.0-RC.22, whose Script entity carries the reviewed
executionMode isolation marker. The scripting bridge pinned RC.21 through
dependencyManagement, so ScriptingQBitProducer rejected the core script
table ("Scripting requires the reviewed core isolation marker metadata")
in every PublishedScriptContextAdapterTest. qbit-record-files and
qbit-printing have also moved to RC.22, so move foundation and all bridges
together rather than pinning older sibling snapshots.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:53:07 -06:00
CI 9073e9bf1c Bump version to 0.2.0-SNAPSHOT [skip ci] 2026-11-06 21:07:16 +00:00
james.maes 98eef612d0 Merge pull request 'Document Generation 1.0: fix renderer tests; pin released Record Files, Scripting and Core RC.22' (#5) from kof22/renderer-fix into develop
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: native / native-result (push) Successful in 14s
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / original-business (push) Skipped
QRun-IO/ci-policy: ci / original-voyage (push) Skipped
QRun-IO/ci-policy: ci / original-cli (push) Skipped
QRun-IO/ci-policy: ci / ci-result (push) Successful in 16s
build / maven (push) Successful in 11m33s
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (push) Successful in 12s
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / android-0 (push) Skipped
QRun-IO/ci-policy: ci / original-mcp (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (push) Skipped
maven / build (push) Successful in 11m33s
2026-11-06 20:19:06 +00:00
james.maesandClaude Sonnet 5.5 cb420158ba release: prepare Document Generation 1.0 (released Record Files, Scripting, Core RC.22)
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 10s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 9s
build / maven (push) Successful in 12m21s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Successful in 12m21s
Pin QQQ Core 4.1.0-RC.22, Record Files 0.1.0-RC.1 and Scripting 1.0.0 in place
of the SNAPSHOT placeholders, and replace the unreleased-checkpoint text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 13:11:27 -06:00
james.maesandClaude Sonnet 5.5 10b3a716b5 fix(renderer): refuse barHeight where it has no effect; check the provider at the family version
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 13s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 11s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 12s
build / maven (push) Failing after 16m35s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Failing after 16m35s
barHeight was advertised and accepted for every encoder although the sixteen
fixed-geometry encoders (matrix symbols and postal four-state codes) ignore it.
It is now neither advertised nor accepted for them and is refused with the
closed code UNSUPPORTED_BARCODE_OPTION, as is GS1 DataBar in stacked mode. The
worker maps that code through, and VALIDATE refuses literal unsupported options
before any data exists.

The patched OpenHTML qualification compared against the old literal version;
it now compares against the family version surefire passes in.

All 174 renderer tests pass, including the four that failed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-06 13:11:26 -06:00
James MaesandClaude Opus 5.5 207e3ce034 fix(renderer): make the renderer test suite pass on the shared CI workflow
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 539bec410f7926fec3ef7fabe51934a91e0248be] (pull_request) Successful in 7s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 6s
build / maven (push) Failing after 10m0s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Failing after 9m59s
- BackgroundImageQualification expected the patched OpenHTML provider at the
  old fixed version 1.1.105-qqq-p1, but PR #1 publishes the vendored module at
  the family revision. The exact-GAV check now takes the expected version from
  the build's own ${revision} (surefire system property) and fails closed if
  absent; group, artifact, single-class-definition and companion checks are
  unchanged.
- barHeight was advertised and accepted for every Okapi family but silently
  ignored by fixed-geometry encoders. It is now advertised/accepted only where
  it changes the symbol; the 16 fixed-geometry families and gs1-databar in
  STACKED mode refuse it with UNSUPPORTED_BARCODE_OPTION (catalog metadata
  carries notWithMode=STACKED).
- UNSUPPORTED_BARCODE_OPTION now crosses the worker boundary as its closed
  code (foundation already models it), and PDF VALIDATE checks literal
  (template-free) barcode options of in-process encoders.

Resolves the 3 known BarcodeDeclaredCapabilityTest failures and the
PDF_PROVIDER_GAV_MISMATCH error; no tests skipped or weakened.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 11:37:12 -06:00
james.maesandClaude Opus 5.5 063b1e8136 ci: pin shared workflow to v1
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / Scoped workflow [workflow 1e25bb5a243dfdcd95d4b9a4f780200ff8c8778a] (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 13s
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / android-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / original-business (pull_request) Skipped
QRun-IO/ci-policy: ci / original-mcp (pull_request) Skipped
QRun-IO/ci-policy: ci / original-voyage (pull_request) Skipped
QRun-IO/ci-policy: ci / original-cli (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Failing after 10s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 10s
build / maven (push) Failing after 10m5s
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / shared-pipeline (pull_request) Skipped
maven / build (push) Failing after 10m5s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 15:55:32 +00:00
196 changed files with 13683 additions and 82 deletions

No files matched your search

+4
View File
@@ -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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -1,6 +1,6 @@
# QBit Document Generation
> **Paused, unreleased work in progress — 2026-11-04.** Implementation has stopped apart from the scoped definition-rename addition of 2026-11-06 (see [status](docs/RELEASE-STATUS.md)). This is a source checkpoint, not a production-ready release or a complete installable package set.
> **Version 1.0.** Published to the KOF22 Gitea Maven registry (`com.kingsrook.qbits`). Requires QQQ Core `4.1.0-RC.22`; the bridges need Record Files `0.1.0-RC.1` and Scripting `1.0.0`. See [status](docs/RELEASE-STATUS.md).
Start with [current status and build prerequisites](docs/RELEASE-STATUS.md) and [known issues](docs/KNOWN-ISSUES.md). These documents supersede older release-readiness statements below.
@@ -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
+2 -2
View File
@@ -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.21</qqq.version><!-- Owner-supplied sibling QBit coordinates (not yet published): set to the released version --><qbit-record-files.version>0.1.0-SNAPSHOT</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>
+1 -1
View File
@@ -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.21</qqq.version><!-- Owner-supplied sibling QBit coordinates (not yet published): set to the released version --><qbit-record-files.version>0.1.0-SNAPSHOT</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>
+17 -4
View File
@@ -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>
+17
View File
@@ -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>
@@ -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;
@@ -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());
@@ -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);
}
}
@@ -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");
}
}
}
}
@@ -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);
}
}
@@ -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));
}
}
+1 -1
View File
@@ -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.21</qqq.version><!-- Owner-supplied sibling QBit coordinates (not yet published): set to the released version --><qbit-scripting.version>0.1.0-SNAPSHOT</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>
+1 -1
View File
@@ -26,7 +26,7 @@ Submitted records provide only IDs. The source body is always fetched afresh thr
## Database/deployment constraints
The durable queue supports file-backed H2 with configured and actual WRITE_DELAY=0, or native MySQL 8.4 under the live checks in [MYSQL.md](MYSQL.md), or native PostgreSQL 17 under the equivalent live checks in `GenerationPostgres`. Both require fresh independent writable connections and the owned tables on one backend. Ambient QQQ transactions are refused. Native mapping drift and unqualified physical targets are denied. Owned statements have five-second JDBC deadlines; MySQL row/metadata lock waits must each be 1–5 seconds. H2 tests pin 2.5.252 and close all connection providers before a cold reopen. Actual pinned MySQL 8.4.13 tests cover concurrent claims/edits, physical pooled isolation restoration, weakened durability/settings, index/engine/mapping drift, bounded lock contention and server restart. PostgreSQL/Aurora are not implicitly supported. Schema changes are operator migrations; metadata registration does not execute DDL. Host power-loss and volume/backup restoration remain deployment acceptance work.
The durable queue supports file-backed H2 with configured and actual WRITE_DELAY=0, or native MySQL 8.4 under the live checks in [MYSQL.md](MYSQL.md), or native PostgreSQL 17 under the equivalent live checks in `GenerationPostgres`. Both require fresh independent writable connections and the owned tables on one backend. Ambient QQQ transactions are refused. Native mapping drift and unqualified physical targets are denied. Owned statements have five-second JDBC deadlines; MySQL row/metadata lock waits must each be 1–5 seconds. H2 tests pin 2.5.252 and close all connection providers before a cold reopen. Actual pinned MySQL 8.4.13 tests cover concurrent claims/edits, physical pooled isolation restoration, weakened durability/settings, index/engine/mapping drift, bounded lock contention and server restart. Amazon Aurora PostgreSQL 17 passes through the same PostgreSQL checks with one difference: Aurora always runs with `full_page_writes` off and has no parameter to change it, because its storage layer does not write torn pages, so `GenerationPostgres` accepts that value when the server resolves `aurora_version()` (`to_regprocedure('aurora_version()') IS NOT NULL`, evaluated in the settings query). Stock PostgreSQL must still report `full_page_writes` on, and `fsync`, `synchronous_commit` and every other check are unchanged on Aurora. The test for this rule runs on a real `postgres:17` engine with a simulated `aurora_version()` function; no test in this repository runs on Aurora itself. Aurora MySQL is not qualified (see [MYSQL.md](MYSQL.md)). Schema changes are operator migrations; metadata registration does not execute DDL. Host power-loss and volume/backup restoration remain deployment acceptance work.
No queue/render/file/script code is executed through a trusted host JavaScript VM. The renderer and scripting integrations continue to require qualified Linux/runsc; this workstation has no runsc runtime, so hostile execution remains unavailable. The queue does not weaken those gates.
+4 -8
View File
@@ -1,10 +1,6 @@
# Known issues and unfinished work
# Known issues
Updated: 2026-11-04. These are unresolved items, not a plan currently being executed.
- Renderer verification has **3 failing tests out of 174**: `BarcodeDeclaredCapabilityTest.barHeightIsAdvertisedAcceptedAndEffectiveTogether`, `gs1DatabarBarHeightDependsOnMode`, and `unsupportedOptionIsAClosedWorkerCode`. The imported main implementation and capability tests disagree; do not hide this by skipping tests or advertising unsupported options.
- The Record Files and Scripting bridge dependencies, including their `tests` classifier artifacts, are not available as a completed public release cohort. The complete root reactor is not release-qualified.
- Earlier foundation runs hit local socket exhaustion/PostgreSQL connection resets. The final 225-test run passed; the transport failures remain a reproducibility concern.
- Renderer image deployment, hostile-template containment, production capacity and physical barcode/printing acceptance are not established by the Maven tests. Source-first templates are implemented; a visual designer is outside this version.
- Renderer image deployment, hostile-template containment, production capacity and physical barcode and printing acceptance are not established by the Maven tests. Source-first templates are implemented; a visual designer is outside this version.
- Barcode `barHeight` is accepted only where it changes the output. The sixteen fixed-geometry encoders (matrix symbols and postal four-state codes) neither advertise nor accept it and refuse it with `UNSUPPORTED_BARCODE_OPTION`, as does GS1 DataBar in stacked mode.
- Native Zint and patched OpenHTML retain their own licenses and corresponding source. The checked-in archives and hash files must ship with the relevant distribution.
- Some historical design/verification notes describe earlier private builds. This status and this issue list supersede their release claims.
- Some historical design and verification notes describe earlier private builds; this status and this issue list supersede their release claims.
+2 -4
View File
@@ -1,6 +1,6 @@
# Document Generation — current status
Updated: 2026-11-06. **Unreleased work in progress; not a production 1.0 release.** The 2026-11-04 stop was lifted only for the definition-rename change below; no other implementation is in progress.
**Version 1.0**, built and published by the shared `ci-workflows` Maven pipeline to the KOF22 Gitea Maven registry (`com.kingsrook.qbits`). `mvn deploy` runs every module's tests once. Depends on QQQ Core `4.1.0-RC.22`, Record Files `0.1.0-RC.1` and Scripting `1.0.0`.
## Purpose
@@ -20,6 +20,4 @@ Foundation: 225 tests passed with zero failures/errors/skips, Checkstyle clean a
## Source and release state
The starting source was extracted from Website-Backend commit b6fb23825a87412397dec703b9617148ae7eb601, then adapted for standalone packaging. This repository preserves the resulting source and existing licenses. It does not declare a published artifact cohort, completed deployment or complete acceptance.
See [known issues](KNOWN-ISSUES.md) and the README for the existing API and integration documentation. No further implementation, tests, releases or deployment should be started without explicit user instruction; the definition rename was added under such an instruction so a published release can replace GG Forge's local `0.1.0-forge.ee782b7d6ea8-rename1` pin. The outstanding dependency order is Core, Collaboration, Scripting/Record Files, then the integrated Generation bridges and matching UI.
The starting source was extracted from Website-Backend commit b6fb23825a87412397dec703b9617148ae7eb601, then adapted for standalone packaging; existing licenses are preserved. See [known issues](KNOWN-ISSUES.md) and the README for the API and integration documentation.
File diff suppressed because it is too large. Load diff
+98
View File
@@ -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
View File
@@ -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.21</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>
@@ -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());
}
}
@@ -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
{
}
}
@@ -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());
}
}
@@ -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.
*******************************************************************************/
@@ -38,7 +38,8 @@ import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaDat
/*******************************************************************************
** Qualification of the native QQQ PostgreSQL17 backend (vendor and backend type
** "postgres") and this QBit's owned tables. Observes the live connection only;
** it never changes session or server settings, schema or data.
** it never changes session or server settings, schema or data. Amazon Aurora
** PostgreSQL is the one accepted variant: see durable().
*******************************************************************************/
final class GenerationPostgres
{
@@ -46,7 +47,11 @@ final class GenerationPostgres
private static final String SETTINGS = "SELECT current_setting('server_version_num')::int, current_setting('transaction_read_only'), pg_is_in_recovery(),"
+ " current_setting('synchronous_commit'), current_setting('fsync'), current_setting('full_page_writes'), current_setting('TimeZone'),"
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_schema(), current_database()";
+ " (SELECT setting::bigint FROM pg_settings WHERE name='lock_timeout'), current_setting('transaction_isolation'), current_schema(), current_database(),"
+ " to_regprocedure('aurora_version()') IS NOT NULL";
private static final String REQUIREMENTS = "Generation requires synchronous PostgreSQL commits, full-page writes (except on Amazon Aurora PostgreSQL),"
+ " a primary writable server, READ COMMITTED and UTC sessions";
private static final String TABLE = "SELECT c.relpersistence FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace"
+ " WHERE n.nspname=current_schema() AND c.relname=? AND c.relkind='r'";
@@ -91,7 +96,25 @@ final class GenerationPostgres
}
/***************************************************************************
** Exact server, durability, session and owned-table checks for PostgreSQL 17.
** Commit durability of the observed server. Every server needs
** synchronous_commit on or remote_apply and fsync on. Stock PostgreSQL also
** needs full_page_writes on, which protects crash recovery from torn pages.
** Amazon Aurora PostgreSQL runs with full_page_writes off and offers no
** parameter to change it, because its storage layer does not write torn
** pages; so off is accepted there, and only there. Aurora is detected in the
** settings query by to_regprocedure('aurora_version()') IS NOT NULL, which is
** true on Aurora, false on stock PostgreSQL and never raises an error (calling
** a missing function would abort the transaction).
***************************************************************************/
static boolean durable(String synchronousCommit, String fsync, String fullPageWrites, boolean aurora)
{
return (("on".equals(synchronousCommit) || "remote_apply".equals(synchronousCommit)) && "on".equals(fsync)
&& ("on".equals(fullPageWrites) || aurora && "off".equals(fullPageWrites)));
}
/***************************************************************************
** Exact server, durability, session and owned-table checks for PostgreSQL 17,
** with the single Amazon Aurora PostgreSQL allowance stated on durable().
***************************************************************************/
static void qualify(Connection connection, RDBMSBackendMetaData backend) throws SQLException, QException
{
@@ -105,7 +128,7 @@ final class GenerationPostgres
{
if(!row.next())
{
throw new QException("Generation requires synchronous PostgreSQL commits, full-page writes, a primary writable server, READ COMMITTED and UTC sessions.");
throw new QException(REQUIREMENTS + ".");
}
List<String> weak = new java.util.ArrayList<>();
if(row.getInt(1) / 10000 != 17 || row.getString(10) == null || !Objects.equals(backend.getDatabaseName(), row.getString(11)))
@@ -116,7 +139,7 @@ final class GenerationPostgres
{
weak.add("writable primary");
}
if(!Set.of("on", "remote_apply").contains(row.getString(4)) || !"on".equals(row.getString(5)) || !"on".equals(row.getString(6)))
if(!durable(row.getString(4), row.getString(5), row.getString(6), row.getBoolean(12)))
{
weak.add("synchronous commit");
}
@@ -130,7 +153,7 @@ final class GenerationPostgres
}
if(!weak.isEmpty())
{
throw new QException("Generation requires synchronous PostgreSQL commits, full-page writes, a primary writable server, READ COMMITTED and UTC sessions; not satisfied: " + String.join(", ", weak));
throw new QException(REQUIREMENTS + "; not satisfied: " + String.join(", ", weak));
}
long lockTimeout = row.getLong(8);
if(lockTimeout < 1 || lockTimeout > 5000)
@@ -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
{
}
}
@@ -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);
}
/*******************************************************************************
@@ -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
{
}
}
@@ -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));
}
}
@@ -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")));
}
}
@@ -52,13 +52,24 @@ final class PostgresGenerationDatabase implements AutoCloseable
** Starts only this suite's loopback container with durable settings.
***************************************************************************/
PostgresGenerationDatabase()
{
this(true);
}
/***************************************************************************
** Pinned durability settings come from the server command line, which no
** ALTER SYSTEM can override. An unpinned engine starts with the same values
** (the PostgreSQL defaults) and lets a test weaken them through system().
***************************************************************************/
PostgresGenerationDatabase(boolean pinned)
{
// pgjdbc announces the JVM zone as the session TimeZone; qualification requires UTC.
previousZone = java.util.TimeZone.getDefault();
java.util.TimeZone.setDefault(java.util.TimeZone.getTimeZone("UTC"));
container = new GenericContainer<>(IMAGE)
.withEnv("POSTGRES_PASSWORD", rootPassword).withEnv("POSTGRES_DB", "bootstrap")
.withCommand("postgres", "-c", "fsync=on", "-c", "synchronous_commit=on", "-c", "full_page_writes=on", "-c", "max_connections=60")
.withCommand(pinned ? new String[] { "postgres", "-c", "fsync=on", "-c", "synchronous_commit=on", "-c", "full_page_writes=on", "-c", "max_connections=60" }
: new String[] { "postgres", "-c", "max_connections=60" })
.withExposedPorts(5432)
.withCreateContainerCmdModifier(command -> command.getHostConfig().withMemory(768L * 1024 * 1024).withNanoCPUs(2_000_000_000L)
.withPortBindings(FixtureEndpoint.publishedBinding(5432)))
@@ -143,6 +154,38 @@ final class PostgresGenerationDatabase implements AutoCloseable
}
}
/***************************************************************************
** Change one reloadable server setting of an unpinned engine (ALTER SYSTEM and
** a configuration reload), then wait until a new session actually reports it.
** Fixed fixture names and values only, never application input.
***************************************************************************/
void system(String name, String value) throws Exception
{
ConnectionManager.closeConnectionProviders();
try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement())
{
statement.execute("ALTER SYSTEM SET " + name + " = '" + value + "'");
statement.execute("SELECT pg_reload_conf()");
}
long deadline = System.nanoTime() + java.time.Duration.ofSeconds(30).toNanos();
while(true)
{
try(var connection = DriverManager.getConnection(server + "bootstrap" + OPTIONS, "postgres", rootPassword); var statement = connection.createStatement();
var row = statement.executeQuery("SELECT current_setting('" + name + "')"))
{
if(row.next() && value.equals(row.getString(1)))
{
return;
}
}
if(System.nanoTime() >= deadline)
{
throw new IllegalStateException("PostgreSQL fixture did not apply " + name);
}
Thread.sleep(100);
}
}
/***************************************************************************
** Cold server restart retains only this container's data layer (committed
** data must survive; Docker may publish a different loopback port).
@@ -41,6 +41,9 @@ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest
{
private static PostgresGenerationDatabase postgres;
// The engine the fixture calls of the running test address: the shared pinned one unless that test started its own.
private PostgresGenerationDatabase current;
/***********************************************************************
** One exact disposable engine per suite; all test cases use new databases.
***********************************************************************/
@@ -68,7 +71,7 @@ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest
@Override
protected RDBMSBackendMetaData database(String name, String defaultUrl) throws Exception
{
return (postgres.database(name));
return (engine().database(name));
}
/***********************************************************************
@@ -77,7 +80,7 @@ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest
@Override
protected Connection connection() throws Exception
{
return (postgres.connection());
return (engine().connection());
}
/***********************************************************************
@@ -86,7 +89,15 @@ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest
@Override
protected void sql(String source) throws Exception
{
postgres.sql(source);
engine().sql(source);
}
/***********************************************************************
** The shared pinned engine, or the unpinned one a single test started.
***********************************************************************/
private PostgresGenerationDatabase engine()
{
return (current == null ? postgres : current);
}
/***********************************************************************
@@ -178,6 +189,86 @@ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest
assertThat(queue.status(enqueue("restored"), -1, 10).items()).hasSize(2);
}
/***************************************************************************
** Amazon Aurora PostgreSQL always runs with full_page_writes off (its storage
** layer does not write torn pages), so that one setting is accepted there and
** nowhere else. The engine is a real PostgreSQL 17 whose settings are really
** changed. Only Aurora itself is simulated: a function named aurora_version()
** is created in the test database, so the same to_regprocedure detection that
** is true on Aurora resolves here.
***************************************************************************/
@Test
void fullPageWritesOffIsAcceptedOnlyOnAuroraAndNothingElseIsRelaxed() throws Exception
{
current = new PostgresGenerationDatabase(false);
try
{
// Rebuild the instance, schema and definitions on the unpinned engine (durable defaults, so this qualifies).
cleanup();
setup();
assertThat(queue.advance(enqueue("stock-durable")).state()).isEqualTo("GENERATED");
// Stock PostgreSQL with full_page_writes off is refused, and the refusal states the rule.
current.system("full_page_writes", "off");
assertThatThrownBy(() -> enqueue("stock-torn-pages")).isInstanceOf(QException.class)
.hasMessageContaining("full-page writes (except on Amazon Aurora PostgreSQL)").hasMessageContaining("not satisfied: synchronous commit");
// The same server, now resolving aurora_version(), is accepted and work really commits.
sql("CREATE FUNCTION aurora_version() RETURNS text LANGUAGE sql IMMUTABLE AS $$ SELECT '17.9'::text $$");
ConnectionManager.closeConnectionProviders();
assertThat(queue.advance(enqueue("aurora")).state()).isEqualTo("GENERATED");
// Aurora relaxes nothing else: fsync off is refused.
current.system("fsync", "off");
assertThatThrownBy(() -> enqueue("aurora-no-fsync")).isInstanceOf(QException.class).hasMessageContaining("not satisfied: synchronous commit");
current.system("fsync", "on");
// Aurora relaxes nothing else: asynchronous commit is refused.
sql("ALTER ROLE generation_app SET synchronous_commit='off'");
ConnectionManager.closeConnectionProviders();
assertThatThrownBy(() -> enqueue("aurora-async-commit")).isInstanceOf(QException.class).hasMessageContaining("not satisfied: synchronous commit");
sql("ALTER ROLE generation_app SET synchronous_commit='on'");
ConnectionManager.closeConnectionProviders();
assertThat(enqueue("aurora-restored")).isNotNull();
// Without the function the server is stock PostgreSQL again and is refused again.
sql("DROP FUNCTION aurora_version()");
ConnectionManager.closeConnectionProviders();
assertThatThrownBy(() -> enqueue("stock-again")).isInstanceOf(QException.class).hasMessageContaining("not satisfied: synchronous commit");
}
finally
{
ConnectionManager.closeConnectionProviders();
current.close();
current = null;
}
}
/***************************************************************************
** The durability decision itself, for the combinations the live case does
** not reach: no server and nothing simulated, only the values of one row.
***************************************************************************/
@Test
void durabilityRuleAcceptsOnlyTheAuroraFullPageWritesException()
{
assertThat(GenerationPostgres.durable("on", "on", "on", false)).isTrue();
assertThat(GenerationPostgres.durable("remote_apply", "on", "on", false)).isTrue();
assertThat(GenerationPostgres.durable("on", "on", "on", true)).isTrue();
assertThat(GenerationPostgres.durable("on", "on", "off", true)).isTrue();
assertThat(GenerationPostgres.durable("remote_apply", "on", "off", true)).isTrue();
assertThat(GenerationPostgres.durable("on", "on", "off", false)).isFalse();
assertThat(GenerationPostgres.durable("on", "off", "on", false)).isFalse();
assertThat(GenerationPostgres.durable("on", "off", "off", true)).isFalse();
for(String weak : List.of("off", "local", "remote_write"))
{
assertThat(GenerationPostgres.durable(weak, "on", "on", false)).isFalse();
assertThat(GenerationPostgres.durable(weak, "on", "off", true)).isFalse();
}
assertThat(GenerationPostgres.durable(null, "on", "on", true)).isFalse();
assertThat(GenerationPostgres.durable("on", null, "on", true)).isFalse();
assertThat(GenerationPostgres.durable("on", "on", null, true)).isFalse();
}
/***************************************************************************
** The native identity must be the postgres module on a real PostgreSQL17 server, not a vendor label.
***************************************************************************/
+2 -2
View File
@@ -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
View File
@@ -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"]
+120
View File
@@ -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
View File
@@ -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.
+25
View File
@@ -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'
+21 -1
View File
@@ -32,10 +32,30 @@
</execution></executions>
</plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.1</version><configuration><showWarnings>true</showWarnings><failOnWarning>true</failOnWarning><compilerArgs><arg>-XDcompilePolicy=simple</arg><arg>--should-stop=ifError=FLOW</arg><arg>-XDaddTypeAnnotationsToSymbol=true</arg><arg>-Xplugin:ErrorProne -Xep:UnnecessaryParentheses:OFF -Xep:NotJavadoc:OFF</arg><arg>-Xlint:all</arg><arg>-Werror</arg></compilerArgs><annotationProcessorPaths><path><groupId>com.google.errorprone</groupId><artifactId>error_prone_core</artifactId><version>2.49.0</version></path></annotationProcessorPaths></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version><configuration><dependenciesToScan combine.self="override"/><systemPropertyVariables><java.awt.headless>true</java.awt.headless><pdfbox.fontcache>${project.build.directory}</pdfbox.fontcache></systemPropertyVariables></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-surefire-plugin</artifactId><version>3.5.4</version><configuration><dependenciesToScan combine.self="override"/><systemPropertyVariables><kof22.provider.version>${revision}</kof22.provider.version><java.awt.headless>true</java.awt.headless><pdfbox.fontcache>${project.build.directory}</pdfbox.fontcache></systemPropertyVariables></configuration></plugin>
<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>
+21
View File
@@ -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>
+121
View File
@@ -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");
}
}
@@ -51,6 +51,15 @@ public final class BarcodeCatalog
{
private static final Map<String, Supplier<Symbol>> FACTORIES = factories();
/*******************************************************************************
** Encoders whose geometry is fixed by their specification (matrix symbols and
** postal four-state codes): barHeight changes nothing for them, so it is
** neither advertised nor accepted. BarcodeDeclaredCapabilityTest proves this
** set is exactly the encoders on which barHeight has no effect.
*******************************************************************************/
private static final java.util.Set<String> FIXED_GEOMETRY = java.util.Set.of("australia-post", "aztec", "aztec-rune", "code-one", "code16k",
"code49", "datamatrix", "grid-matrix", "japan-post", "kix", "maxicode", "micro-qr", "qr", "royal-mail", "swiss-qr", "upn-qr");
/*******************************************************************************
** Utility class.
@@ -142,7 +151,14 @@ public final class BarcodeCatalog
{
Symbol symbol = FACTORIES.get(id).get();
Map<String, Object> options = new LinkedHashMap<>();
options.put("barHeight", Map.of("type", "integer", "minimum", 1, "maximum", 1000));
if("gs1-databar".equals(id))
{
options.put("barHeight", Map.of("type", "integer", "minimum", 1, "maximum", 1000, "notWithMode", "STACKED"));
}
else if(!FIXED_GEOMETRY.contains(id))
{
options.put("barHeight", Map.of("type", "integer", "minimum", 1, "maximum", 1000));
}
if(symbol instanceof Coop2Of5)
{
options.put("checkDigit", Map.of("type", "string", "values", List.of("NONE", "ADD", "VALIDATE"), "default", "NONE"));
@@ -380,6 +396,10 @@ public final class BarcodeCatalog
}
Symbol symbol = factory.get();
structuredAppend(type, symbol, options);
if(options.has("barHeight") && (FIXED_GEOMETRY.contains(type) || ("gs1-databar".equals(type) && options.has("mode") && "STACKED".equals(options.get("mode").asText()))))
{
throw new IllegalArgumentException("UNSUPPORTED_BARCODE_OPTION");
}
for(Map.Entry<String, JsonNode> field : options.properties())
{
if(List.of("qr", "datamatrix", "aztec").contains(type) && field.getKey().startsWith("structuredAppend"))
@@ -96,6 +96,36 @@ public final class PdfRenderer
public static void validate(RenderInput input) throws Exception
{
template(input.source);
for(Element element : Jsoup.parse(input.source).select("qqq-barcode"))
{
refuseUnsupportedOptions(element.attr("type"), element.attr("options"));
}
}
/*******************************************************************************
** Literal barcode options are checked against the catalog before any data
** exists, so an option the encoder cannot honour is refused at validation as
** its own closed code. Templated type or options are checked when rendered,
** and any other problem is left for the render to report.
*******************************************************************************/
private static void refuseUnsupportedOptions(String type, String options)
{
if(options.isBlank() || type.contains("${") || type.contains("<#") || options.contains("${") || options.contains("<#"))
{
return;
}
try
{
BarcodeCatalog.create(type, Wire.JSON.readTree(options));
}
catch(Exception exception)
{
if(exception instanceof IllegalArgumentException refusal && "UNSUPPORTED_BARCODE_OPTION".equals(refusal.getMessage()))
{
throw refusal;
}
}
}
@@ -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,17 +98,45 @@ 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",
"SCANNER_BARCODE_PAYLOAD_UNSUPPORTED", "SCANNER_BARCODE_CHECKSUM_REQUIRED", "SCANNER_BARCODE_CHECKSUM_MISMATCH").contains(message))
"SCANNER_BARCODE_PAYLOAD_UNSUPPORTED", "SCANNER_BARCODE_CHECKSUM_REQUIRED", "SCANNER_BARCODE_CHECKSUM_MISMATCH",
"UNSUPPORTED_BARCODE_OPTION").contains(message))
{
return (message);
}
@@ -153,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));
}
}
@@ -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));
}
}
@@ -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");
}
}
@@ -84,16 +84,32 @@ public final class BackgroundImageQualification
cases += 2;
}
return (Map.of("cases", cases, "surfaces", List.of("element", "pseudo", "page"), "exactRgbAlphaAndDimensions", true,
"bothAliasPaintOrders", true, "provider", "com.kingsrook.thirdparty:openhtmltopdf-pdfbox:1.1.105-qqq-p1",
"bothAliasPaintOrders", true, "provider", "com.kingsrook.thirdparty:openhtmltopdf-pdfbox:" + providerVersion(),
"qualification", "trusted-product-fixtures-only"));
}
/***************************************************************************
** The patched OpenHTML provider is published at the family version of this
** reactor, which surefire passes in; there is no second literal to keep in sync.
***************************************************************************/
private static String providerVersion()
{
String version = System.getProperty("kof22.provider.version");
if(version == null || version.isBlank())
{
throw new IllegalArgumentException("PDF_PROVIDER_VERSION_PROPERTY_MISSING");
}
return (version);
}
/***************************************************************************
** One provider class must come from the private patched GAV, with unchanged official companion versions.
***************************************************************************/
static void verifyProvider() throws Exception
{
verifyArtifact(com.openhtmltopdf.pdfboxout.PdfBoxFastOutputDevice.class, "com.kingsrook.thirdparty", "openhtmltopdf-pdfbox", "1.1.105-qqq-p1");
verifyArtifact(com.openhtmltopdf.pdfboxout.PdfBoxFastOutputDevice.class, "com.kingsrook.thirdparty", "openhtmltopdf-pdfbox", providerVersion());
verifyArtifact(com.openhtmltopdf.css.parser.CSSParser.class, "io.github.openhtmltopdf", "openhtmltopdf-core", "1.1.105");
verifyArtifact(org.apache.pdfbox.pdmodel.PDDocument.class, "org.apache.pdfbox", "pdfbox", "3.0.8");
}
@@ -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());
}
}
@@ -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"
}
]
}
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more