Compare commits

..
Author SHA1 Message Date
james.maesandClaude Fable 5.2 363e96f641 fix(preview): only a publication is previewed by revision; the action's widget request carries no record
maven / build (push) Successful in 12m0s
build / maven (push) Successful in 12m0s
Changes after the independent review of the layout preview.

A person who may not author a template cannot obtain the picture of an
unpublished draft by naming a revision. The revision table also holds the
frozen drafts that the retained preview writes; a named revision is now
resolved only when it is a publication, and any other row is answered
"Document definition not found.", with the type and the words of an identity
that does not exist, from the service, from the action before and after its
form, and from the widget. A draft is previewed through its template alone.

The widget of the "Preview layout" action is requested by the identifier of
the run and reads the person's choices from the state of that run. A client
sends every plain process value as a parameter of the widget request, and
the framework's widget route logs the parameters of a request that fails or
takes over a second, so the chosen record's identity and the sample's name
were logged for a slow preview. The check step now holds the accepted
choices as one structured process value, which is not sent, and the widget
no longer reads a revision, record, sample, stock or resolution from any
request parameter.

A source record is read within the channel and credential ceilings the print
path applies to the source table, asked before the record is read.

A label whose picture alone is refused (PREVIEW_TOO_LARGE and no other
refusal) is no longer said to be unprintable: "This label is too dense to
preview; printing is not affected."

The help of the form says that the longest values picture is drawn with
capital W, is indicative, and that the renderer's proof at publication
decides. The README records what a host must configure for label previews:
no-store for widget responses, which neither a widget renderer nor the
widget route sets, and what the request log holds.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:38:15 -06:00
james.maesandClaude Fable 5.2 1db82957c9 fix(bridge): a placeholder of no area draws nothing; name the answer table for what it is
ZplRaster.hatch returns at once for an area of no width or no height. Its
outline and diagonals would otherwise be placed one dot outside the area:
with no width the diagonal's column divides down to -1 and a stray line is
drawn. The guard that the refused page had around its call is now in the
painter itself, so every caller has it. An empty printer font text field did
not reach the painter before this change either: such a field is refused as
no field (RASTER_FIELD), and by the output policy before that; the existing
test for it is unchanged. ZplRaster stays at 100% lines.

ReleasedRendererAnswerTest is renamed VersionOneAnswerTableTest and its
header says what it is: 71 synthetic answers in the shape the released
renderer answers with, and the outcomes of today's bridge for them. They were
not captured at the released tag, unlike the job frame golden, so the table
holds today's behaviour still and does not show equality with the release.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 14:38:14 -06:00
james.maesandClaude Fable 5.2 6aba6ce8d4 docs(preview): the placeholder is outlined and crossed; record the bridge review findings
maven / build (push) Successful in 12m0s
build / maven (push) Successful in 12m0s
The widget's sentences, the authoring guide, the bridge README and the
design follow the bounded painting of the preview: a placeholder is an
outline with its two diagonals, and a page painted more than sixteen
times over is PREVIEW_TOO_LARGE. The design also records that the
version question is asked again after any RENDERER_TOO_OLD and that the
released version 1 job frame is pinned.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:49:54 -06:00
james.maesandClaude Fable 5.2 1828ae9c48 fix(bridge): ask the renderer's version again after any RENDERER_TOO_OLD
The comments said that nothing of a version 2 scene reaches a renderer
that cannot draw it. That is not so: the answer to the version question is
remembered, so after a renderer image is rolled back ONE job reaches the
old (0.1.0) renderer, scene and record values included, and is then
refused as RENDERER_TOO_OLD. The class, field, invoke and probe comments
now say exactly that.

The window stays one job in every case: a job that ends in
RENDERER_TOO_OLD, from render, validate or preview, forgets what was
remembered, so the next job that needs renderer 0.2.0 asks the version
first. An answer from 0.1.0 already did this; a refusal that renderer
0.2.0 names itself, as an error answer or among collected diagnostics, did
not.

FakeWorker can stand for an image that is replaced while a bridge uses it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:49:06 -06:00
james.maesandClaude Fable 5.2 bbb4b60a1b test(bridge): golden of the released version 1 job frame and a table of released answers
Nothing held the version 1 print request to the released bytes, and the
0.1.0 answers were tested at the units and not through render().

- compat-v1/job-frame-render.json is the frame the bridge of tag
  v1.1.0-RC.2 (e9ffba484e) wrote to its
  exchange for one render() with fixed inputs. VersionOneJobFrameTest
  compiles unchanged at that tag and is its own capture tool; the .md
  beside the frame gives the inputs and the commands.
- The frame is compared byte for byte except for the order of the seven
  members of "stock": the released bridge writes them in the iteration
  order of an immutable Map.of, which the JVM changes from run to run (four
  captures at the tag gave four orders and were otherwise identical).
- ReleasedRendererAnswerTest is one row for each answer of
  qqq-renderer-0.1.0 to a version 1 job, accepted, refused by code or
  rejected, every row through render(), with the name the version rule
  gives the same frame.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:49:06 -06:00
james.maesandClaude Fable 5.2 9e5b16d686 fix(bridge): bound preview painting to sixteen times the page
ZplRaster painted a box and a placeholder by testing every dot of the
field's area. The output policy allows 6000 fields, so a label of
page-sized boxes was tens of thousands of millions of dots of work on the
host while an admission slot was held, and the page of a refused scene had
the same cost for each of up to 2048 diagnostic boxes.

- Every painter counts the dots it is about to paint, an overlap each
  time, and the field that would take the page past sixteen times its dots
  is refused as PREVIEW_TOO_LARGE. This is the renderer's own limit for a
  scene. A graphic counts eight dots for each byte.
- A box is the whole area when its border meets itself, else four bars
  that do not overlap, filled whole bytes at a time. The dots are the same
  as before.
- A placeholder (printer font text, a refused element's box) is an outline
  and its two diagonals, so it costs its perimeter and not its area. This
  changes its look: the diagonal hatch every six dots is gone.
- preview() returns the refusal as a result, like the page limit: not
  printable, no page, PREVIEW_TOO_LARGE with limit.paintedPercent 1600. For
  a refused scene it joins the diagnostics the worker sent.

Printing is not changed.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:49:05 -06:00
james.maesandClaude Fable 5.2 ed11495120 feat(preview): layout preview inside the QBit: service, action and record widget (P6b)
A person editing a label template sees the picture that would print, with
its refusals and warnings, before anything is printed, from the module's
own screens and with no host code.

- DocumentPreviewService: authorises by the existing permissions (a draft
  needs configure, a published revision needs view, a record needs
  generate and is read as the person, with their permissions and record
  locks, before any context is built), loads the draft or the published
  snapshot without writing, builds the context as generation does and
  calls IsolatedRenderer.preview. Data is a sample from the scene, the
  longest declared values, or one record. One log line, no content.
- "Preview layout" action on templates and published revisions: data,
  sample, record, stock and resolution; the result is the preview widget.
- "Label preview" section on the template record screen: the draft with
  sample data (the published revision for a person who may only view).
- The widget is fetched, never seeded from a process value: QQQ keeps the
  state of a finished process for hours (in a table with the shared
  provider), so the picture, the context and the diagnostics exist only
  in the answer to the widget request. The process state holds the
  person's choices only.
- Bounded answer: one page, a picture of at most 2 MiB, fifty diagnostics.
- The existing action is relabelled "Generate retained preview file"; its
  process name and behaviour are unchanged.
- The record selector asks for generate alone, so a person who may
  preview a published revision with a record can choose one.
- Help on every new process, step, field, section, widget and choice list.

Tests: DocumentPreviewTest (data sources, permission matrix, unreadable
record, nothing written, process state, PDF refusal, refused scene,
version 1 label, bounds, log line, help coverage, instance validation)
and LayoutPreviewServiceTest in bridges/renderer (through the service and
the bridge, the preview has the hash of the bytes the print path returns
at 203, 300 and 600 dpi). The registration and process tests are updated
for the new wiring and the relabel. bridges/renderer gains the RDBMS
module at test scope for that test.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 13:37:56 -06:00
james.maes bb25d6c46a Merge pull request 'feat(bridge): accept renderer 0.2.0, structured diagnostics, ZplRaster and preview; render target dpi end to end (P5, P7)' (#15) from feat/bridge-preview into develop
maven / build (push) Successful in 10m58s
build / maven (push) Successful in 10m58s
2026-11-12 18:28:12 +00:00
james.maesandClaude Fable 5.2 bf546e16c5 feat(bridge): accept renderer 0.2.0, read its diagnostics, preview a label (P5)
maven / build (push) Successful in 12m32s
build / maven (push) Successful in 12m32s
On develop the renderer speaks qqq-renderer-0.2.0 and the bridge accepted
only 0.1.0, so the bridge refused its own renderer, a label could not be
previewed, and a render target was accepted and then ignored.

The version rule (RendererVersion). The bridge accepts 0.1.0 (the released
fields, the 13 released refusals) and 0.2.0 (the same with diagnostics and
detail, those refusals and the 15 scene refusals); any other version is
rejected. Each set is written out, replacing the temporary enum range. A
job is framed as 0.1.0 reads it unless it needs more. Before a job only
0.2.0 can do (a version 2 scene, a preview) the bridge asks the worker its
version by validating a fixed, empty version 1 scene, so an old image is
refused as RENDERER_TOO_OLD and receives nothing of the scene or the
record; an answer from 0.1.0 to such a job is refused whatever it says.

Diagnostics (WorkerAnswer, SceneFacts). A version 2 refusal is
RenderException(code, diagnostic). The worker is untrusted, so every field
is checked again: the closed vocabulary, whole numbers in bounds, an
element only if the scene that was sent declares it, and a pointer only of
array indexes, schema property names and declared ids. An answer holds at
most 2048 diagnostics in one megabyte beside its label, and no frame
passes the frame of the largest document the deployment admits. A refused
answer that carries any part of a label is rejected.

Render target. targetDpi is sent with a version 2 scene and the label is
held by the output policy to the page at that resolution. A version 1
scene or a PDF asked for another resolution than its stock's is refused as
TARGET_REQUIRES_SCENE_2 before any worker is contacted, because renderer
0.1.0 does not read the field. supportsRenderTarget() is true.

Preview. preview() is the print path stopped before retention: the same
job with collected diagnostics, the same integrity checks and output
policy, and a page decoded by ZplRaster from the accepted label bytes.
ZplRaster is its own strict reader of the policy's grammar and runs the
policy first; it paints ^GFA and ^GB exactly, hatches printer font text
(the page is then not exact) and writes a one-bit PNG itself. Refusals
come back as diagnostics, never as a label. A refused scene is drawn on
the host from the diagnostics' boxes alone: the worker sends no label for
it and the wire gains no field for the partial canvas, so no host can
print a label with elements missing. The design records the decision in
sections 6.1 and B.1, the version question in 11.3 and what the host now
reads from a scene in 8.1.

renderer/qualification.json said qqq-renderer-0.1.0 and qqq-barcodes-1;
it now says what the worker reports, and a test holds it to the answer.

The mutation campaign covers the page decoder and the answer frames. The
new classes are gated at 100% line coverage and the bundle gate rises to
the measured 90% of instructions and 88% of lines. The version 1 corpus is
untouched.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 12:02:54 -06:00
james.maesandClaude Fable 5.2 9b908ac516 feat(foundation): a generation request names its render target (P7)
A request could not say which printer resolution to render a label for:
RenderRequest.targetDpi existed and nothing set it.

DocumentGenerationService.Request gains a trailing targetDpi (absent, 203,
300 or 600; every earlier constructor stays and means none). It is stored
in a new nullable column, target_dpi, on document_generation_request,
compared on retry and lookup beside destination, media and copies, framed
into the intent digest as target-dpi/v1 when stated, and passed to the
renderer. A request without a target has exactly the digest it had, so
earlier rows and their retries are unchanged; the same key with another
target, or with none, is another intent.

Nobody who asks for 300 dpi may silently get another resolution, so a
target is refused before any row exists when it cannot be honoured: for a
PDF, and for any renderer that does not declare the new
IsolatedRenderer.supportsRenderTarget() (false by default, so an adapter
written before the target cannot be handed one and ignore it).

A host applies one statement, from the shipped script for its database:
db/migrations/20261112-render-target-dpi-{h2,mysql,postgres}.sql
(ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER
NULL). The generate process form offers the target and the request status
shows it. Batches, the queue and reprints carry none.

Also from the review of the ports: RenderRequest and PreviewRequest hold
their own unmodifiable copy of the context (null values allowed) and
print only their format and render target (and the request id), never the
record data or the template source. Neither wire form changes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 12:02:38 -06:00
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
maven / build (push) Successful in 7m15s
build / maven (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
maven / build (push) Successful in 11m29s
build / maven (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
maven / build (push) Successful in 13m51s
build / maven (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
maven / build (push) Successful in 10m41s
build / maven (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)
maven / build (push) Successful in 6m19s
build / maven (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
maven / build (push) Successful in 8m37s
build / maven (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
maven / build (push) Successful in 16m12s
build / maven (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
maven / build (push) Successful in 6m14s
build / maven (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
maven / build (push) Successful in 6m43s
build / maven (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
maven / build (push) Successful in 6m2s
build / maven (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
220 changed files with 22131 additions and 210 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
+8 -1
View File
@@ -45,6 +45,13 @@ Every default provider fails closed. No adapter calls the trusted in-process ren
Context contains bounded immutable JSON-like data, including the authorized selected `record`. A script may add context objects but cannot replace that reserved original record. Credentials, QQQ sessions, raw host objects and lazy entities are rejected. Source-record permissions are checked before context preparation, before rendering, before intake and before printing. Asset access is rechecked by the isolated renderer adapter, and retained-version access by retained/printing adapters.
### What a host must configure for label previews
The "Label preview" widget and the "Preview layout" action arrive with the QBit's registration and need no host code. Two things are the host's:
- **Send `Cache-Control: no-store` for widget responses.** The widget's answer holds the picture of a label, which in the action can show the values of a record. QQQ's widget route sets no `Cache-Control` header and a widget renderer cannot set response headers, so the host, or the proxy in front of it, must send `no-store` for widget responses. Nothing in this QBit can.
- **Know what your request log holds.** QQQ's widget route writes the parameters of a widget request that fails or takes longer than one second to the application log. For this widget those parameters hold no record identity and no sample name: from a template's record screen they are the table name and the template's identity, and from the action they are the identifier of the run (`processUUID`) with the plain process values a client adds to it (`previewTemplateId` or `previewRevisionId`, `sourceTable`, and whatever the client itself sent to start the run, which for the standard clients is its selection of the template or revision row). The chosen record, sample, stock and resolution stay in the state of the run and are not sent; this holds for a client that sends plain process values only, as the standard clients do. `DocumentPreviewService` itself logs one line per preview: the actor, the template or revision identity, the kind of data, the resolution, the outcome and the diagnostic codes.
## Known boundaries before release
Native table/record processes and source-editor forms are implemented through standard QQQ metadata. Results include record navigation and download/preview metadata consumed by generic frontend components. Authenticated delivery requires verified bytes and a completed owner receipt before preview. There is no visual designer. Final integrated browser workflow acceptance, a qualified worker deployment, production storage/restore and printing integration remain release work. Service-only fields cannot be edited through unrestricted generic CRUD. Domain actions use validated services.
@@ -72,7 +79,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
+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
@@ -73,6 +73,25 @@ public final class RemoteRendererBridge implements GenerationPorts.IsolatedRende
return (delegate.render(request));
}
/***************************************************************************
** The existing bridge renders, validates the label exactly as for printing
** and decodes the page from it; nothing is retained.
***************************************************************************/
@Override
public GenerationPorts.Preview preview(GenerationPorts.PreviewRequest request) throws QException
{
return (delegate.preview(request));
}
/***************************************************************************
** The existing bridge honours a render target or refuses it by name.
***************************************************************************/
@Override
public boolean supportsRenderTarget()
{
return (delegate.supportsRenderTarget());
}
/***************************************************************************
** The host closes this adapter during its normal shutdown lifecycle.
***************************************************************************/
@@ -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>
+31
View File
@@ -11,6 +11,37 @@ verification (private source-matched reactor, 2026-11-02): bridge 52 tests, rend
167 tests, zero failures/errors/skips, Checkstyle 0; see
`docs/reviews/code-standards-followup-20261102/` in `qbit-documents` for exact commands.
## Renderer versions, render target and preview
The bridge accepts renderer `qqq-renderer-0.1.0` and `qqq-renderer-0.2.0` (`RendererVersion` is the rule):
| Renderer | Answers | Refusals it may name | Version 2 scene, render target, preview |
|---|---|---|---|
| `0.1.0` | the released fields only | the 13 released codes | refused: `RENDERER_TOO_OLD` |
| `0.2.0` | the same, with `diagnostics` (and `detail` on an error) | those and the 15 scene codes | rendered |
Upgrade this library before the renderer image. A job is framed as renderer 0.1.0 reads it unless it needs more,
so version 1 printing is unchanged against either image. Before a job only 0.2.0 can do, the bridge asks the
worker its version with a fixed, empty validation; an old image is refused with `RENDERER_TOO_OLD` and receives
nothing of the scene or the record.
- **Render target.** `RenderRequest.targetDpi` (203, 300 or 600) is sent with a version 2 scene, and the label
is held by the output policy to the page at that resolution. A version 1 scene or a PDF asked for another
resolution than its stock's is refused as `TARGET_REQUIRES_SCENE_2` before any worker is contacted.
`supportsRenderTarget()` is true.
- **Diagnostics.** A version 2 refusal arrives as `RenderException(code, diagnostic)`. The worker's answer is
untrusted: every diagnostic is checked again on the host, including that its element is an id the scene that
was sent declares and that its pointer is made only of array indexes, schema property names and those ids.
An answer holds at most 2048 diagnostics in one megabyte beside its label.
- **Preview.** `preview(PreviewRequest)` is the print path stopped before retention: the same job with collected
diagnostics, the same integrity checks and output policy, and a page decoded by `ZplRaster` from the accepted
label bytes (`PreviewPage` is a one-bit PNG, one pixel for each dot). Refusals come back as diagnostics with
`printable: false`, never as a label: a refused scene has no output, and its page shows only the outlined,
crossed boxes of the refused elements. A page of more than sixteen million dots is `PREVIEW_TOO_LARGE`, and so
is a label (or the boxes of a refused scene) that would paint more than sixteen times its page's dots; such a
label still prints, and the foundation's widget says so. PDF previews
are not provided. The bridge writes and logs nothing for a preview.
Three transports share the same bridge, asset, wire and output policy:
- **Production (default):** `new IsolatedRendererBridge(configuration, assetReader)`
+22
View File
@@ -14,6 +14,8 @@
</exclusions></dependency>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId><version>2.22.4</version></dependency>
<dependency><groupId>org.junit.jupiter</groupId><artifactId>junit-jupiter</artifactId><version>6.0.1</version><scope>test</scope></dependency>
<!-- Test only: the foundation's store names the RDBMS module (optional there), and LayoutPreviewServiceTest drives the foundation's preview service over this bridge. -->
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-rdbms</artifactId><version>${qqq.version}</version><scope>test</scope></dependency>
</dependencies>
<build>
<resources>
@@ -37,6 +39,26 @@
<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 bridge, rounded down (2026-11-12, with the preview classes: 90.9% of instructions and 88.4% of lines; 2026-11-11: 85.6% and 82.6%); raise it when coverage rises and
never lower it. The PACKAGE and CLASS rules hold new code to 100% line coverage from its first commit; a pattern matches nothing until its package or class exists, so the package that adds
it needs no change here. The classes of the bridge preview package (ZplRaster, and WorkerAnswer, SceneFacts and RendererVersion, which read the worker's answers) are listed by name. Nothing is excluded: JaCoCo skips only code annotated as generated, and this build defines no such annotation. There is no skip switch. -->
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.15</version><executions>
<execution><goals><goal>prepare-agent</goal></goals></execution>
<execution><id>report</id><phase>verify</phase><goals><goal>report</goal></goals></execution>
<execution><id>coverage</id><phase>verify</phase><goals><goal>check</goal></goals><configuration><rules>
<rule><element>BUNDLE</element><limits>
<limit><counter>INSTRUCTION</counter><value>COVEREDRATIO</value><minimum>0.90</minimum></limit>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.88</minimum></limit>
</limits></rule>
<rule><element>CLASS</element><includes><include>com.kingsrook.qbits.documentgeneration.renderer.ZplRaster</include><include>com.kingsrook.qbits.documentgeneration.renderer.ZplRaster.*</include>
<include>com.kingsrook.qbits.documentgeneration.renderer.WorkerAnswer</include><include>com.kingsrook.qbits.documentgeneration.renderer.WorkerAnswer.*</include>
<include>com.kingsrook.qbits.documentgeneration.renderer.SceneFacts</include><include>com.kingsrook.qbits.documentgeneration.renderer.SceneFacts.*</include>
<include>com.kingsrook.qbits.documentgeneration.renderer.RendererVersion</include><include>com.kingsrook.qbits.documentgeneration.renderer.RendererVersion.*</include></includes><limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
</limits></rule>
</rules></configuration></execution>
</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>
@@ -34,20 +34,55 @@ import java.util.Set;
import java.util.UUID;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Optional host adapter. All template interpretation remains inside the isolated
** worker; this class authorizes bytes and validates the transport/output contract.
**
** It accepts renderer 0.1.0 and 0.2.0 (`RendererVersion` is the rule). A job
** that only 0.2.0 can do (a version 2 scene, a render target, a preview) is
** refused as `RENDERER_TOO_OLD` when the worker is older: the host asks the
** worker its version with a fixed, empty job before it sends such a scene, and
** an answer from an older worker to such a job is refused whatever it says.
**
** The worker's answer to that question is remembered, so asking first is not
** a promise about every job. After a renderer image is rolled back, ONE such
** job can reach the old (0.1.0) renderer, its scene and its record's values
** with it, before the bridge knows. That job is refused as
** `RENDERER_TOO_OLD`, and a job that ends in `RENDERER_TOO_OLD`, however it
** came to, ends what was remembered: the next such job asks the version first.
*******************************************************************************/
public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRenderer
{
private static final int ENVELOPE_BYTES = 4096;
private static final List<Integer> RESOLUTIONS = List.of(203, 300, 600);
////////////////////////////////////////////////////////////////////////////////////////////////
// the version question: validate an empty version 1 scene on a fixed stock. Every released //
// renderer answers it, it holds nothing of any template or record, and the answer names the //
// renderer's version //
////////////////////////////////////////////////////////////////////////////////////////////////
static final String PROBE_SCENE = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[]}";
static final GenerationPorts.Stock PROBE_STOCK = new GenerationPorts.Stock(101600, 152400, 0, 0, 0, 0, 203);
private final RendererLimits config;
private final AssetReader assets;
private final Exchange exchange;
private final RendererAdmission admission;
////////////////////////////////////////////////////////////////////////////////////////////////
// whether the last verified answer came from a renderer that draws version 2 scenes. It only //
// saves the version question; every answer is still held to the version rule. It is what //
// the bridge last saw, not what the renderer is now: after a renderer image is rolled back, //
// ONE job that only 0.2.0 can do is sent to the old (0.1.0) renderer on the strength of it. //
// That job is refused as RENDERER_TOO_OLD, and every job that ends in RENDERER_TOO_OLD //
// clears this, so the window is that one job and the next asks the version again //
////////////////////////////////////////////////////////////////////////////////////////////////
private volatile boolean structured;
/*******************************************************************************
** Must check current actor, tenant and immutable retained-version access, then
** read at most maximumBytes. Never return a URL or storage credential.
@@ -130,6 +165,18 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
}
}
/***************************************************************************
** This adapter renders a version 2 scene at the request's render target and
** refuses a target it cannot honour: `TARGET_REQUIRES_SCENE_2` for a version
** 1 scene or a PDF asked for another resolution than its stock's, and
** `RENDERER_TOO_OLD` when the worker cannot draw version 2.
***************************************************************************/
@Override
public boolean supportsRenderTarget()
{
return (true);
}
/*******************************************************************************
** Validate each supplied format with the same authorized offline asset snapshot.
*******************************************************************************/
@@ -147,7 +194,7 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
if(source != null && !source.isBlank())
{
found = true;
invoke(UUID.randomUUID().toString(), "VALIDATE", format, revision, Map.of(), resolved, config.maximumBytes());
invoke(UUID.randomUUID().toString(), "VALIDATE", format, revision, Map.of(), resolved, config.maximumBytes(), null, false);
}
}
if(!found)
@@ -175,32 +222,9 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
{
throw new IllegalArgumentException("OUTPUT_LIMIT");
}
JsonNode response = invoke(request.requestId(), "RENDER", request.format(), request.revision(), request.context(), resolve(request.actorId(), request.revision().assets()), maximum);
String encoded = RendererJson.text(response.get("base64"), 4 * ((maximum + 2) / 3));
byte[] bytes = Base64.getDecoder().decode(encoded);
if(bytes.length < 1 || bytes.length > maximum || RendererJson.integer(response.get("length"), 1, maximum) != bytes.length
|| !RendererJson.hash(bytes).equals(RendererJson.text(response.get("sha256"), 64)))
{
throw new IllegalArgumentException("OUTPUT_INTEGRITY");
}
int pages = RendererJson.integer(response.get("pageCount"), 1, config.maximumPages());
if(request.format() == GenerationPorts.Format.ZPL)
{
if(pages != 1 || !"application/vnd.zebra-zpl".equals(response.path("mediaType").asText()))
{
throw new IllegalArgumentException("ZPL_METADATA");
}
ZplOutputPolicy.validate(bytes, request.revision().stock());
}
else
{
if(!"application/pdf".equals(response.path("mediaType").asText()))
{
throw new IllegalArgumentException("PDF_METADATA");
}
pdfEnvelope(bytes);
}
return (new GenerationPorts.Content(bytes));
WorkerAnswer answer = invoke(request.requestId(), "RENDER", request.format(), request.revision(), request.context(), resolve(request.actorId(), request.revision().assets()), maximum,
request.targetDpi(), false);
return (new GenerationPorts.Content(output(answer, request.format(), maximum, at(request.revision().stock(), request.targetDpi()))));
}
catch(Exception failure)
{
@@ -208,11 +232,216 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
}
}
/***************************************************************************
** The print path stopped before retention: the worker renders the same
** revision with the same context, the label passes the same integrity checks
** and the same output policy, and the page is decoded from those accepted
** bytes. The result holds a picture and numbers, never the label, and
** nothing is written, retained or logged. A refusal is not an exception
** here: the layout is reported as not printable with its diagnostics.
**
** A scene with a refused element has no label at all (the worker sends
** none), so there is nothing a host could print by mistake. Its page shows
** only where the refused elements are, outlined and crossed, from the boxes
** in their diagnostics; when no diagnostic has a box there is no page.
**
** Drawing is bounded like the page: a label, or the boxes of a refused
** scene, that would paint more than sixteen times the page's dots is
** `PREVIEW_TOO_LARGE`, a result like any other named refusal.
***************************************************************************/
@Override
@SuppressWarnings("try") // The admission slot is held for its scope only; its value is never read.
public GenerationPorts.Preview preview(GenerationPorts.PreviewRequest request) throws QException
{
if(request.format() != GenerationPorts.Format.ZPL)
{
throw new QException("Preview is available for labels only.");
}
try(var slot = admission.enter())
{
int maximum = request.maximumBytes();
if(maximum > config.maximumBytes())
{
throw new IllegalArgumentException("OUTPUT_LIMIT");
}
GenerationPorts.Stock printed = at(request.revision().stock(), request.targetDpi());
stock(printed);
int width = dots(printed.widthMicrons(), printed.dpi(), 12700);
int height = dots(printed.heightMicrons(), printed.dpi(), 12700);
if(!RESOLUTIONS.contains(printed.dpi()) || width > 9999 || height > 32000)
{
throw new IllegalArgumentException("INVALID_ZPL_STOCK");
}
try
{
if((long) width * height > ZplRaster.MAXIMUM_DOTS)
{
throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.PREVIEW_TOO_LARGE, new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null,
Map.of("widthDots", width, "heightDots", height), Map.of("dots", (int) ZplRaster.MAXIMUM_DOTS)));
}
WorkerAnswer answer = invoke(UUID.randomUUID().toString(), "RENDER", request.format(), request.revision(), request.context(), resolve(request.actorId(), request.revision().assets()),
maximum, request.targetDpi(), true);
if(answer.refused())
{
return (refused(printed, answer.diagnostics()));
}
byte[] bytes = output(answer, request.format(), maximum, printed);
ZplRaster page;
try
{
page = ZplRaster.page(bytes, printed);
}
catch(IllegalArgumentException undrawn)
{
throw (painted(undrawn));
}
List<Diagnostic> diagnostics = new ArrayList<>(answer.diagnostics());
if(!page.exact())
{
diagnostics.add(new Diagnostic("PRINTER_FONT_NOT_SHOWN", Diagnostic.Severity.WARNING, null, null, null, null));
}
return (new GenerationPorts.Preview(List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height())), page.exact(), true, diagnostics, (long) bytes.length,
RendererJson.hash(bytes)));
}
catch(GenerationPorts.RenderException refusal)
{
return (refused(printed, List.of(refusal.diagnostic() != null ? refusal.diagnostic() : new Diagnostic(refusal.code().name(), Diagnostic.Severity.ERROR, null, null, null, null))));
}
}
catch(Exception failure)
{
throw (failure(failure));
}
}
/***************************************************************************
** A preview of a layout that cannot print. There is no output, so its size
** is zero and its hash is the hash of no bytes. The page is drawn on the
** host from the diagnostics' boxes, which are in dots from the top left of
** the printable area: the stock less its margins, placed as the renderer
** places it (the left and top margins rounded up to whole dots and moved
** back only as far as keeps the area on the page). Boxes that would paint
** more than a page may be painted with are not drawn at all: there is no
** page, and `PREVIEW_TOO_LARGE` joins the diagnostics.
***************************************************************************/
private static GenerationPorts.Preview refused(GenerationPorts.Stock printed, List<Diagnostic> diagnostics) throws Exception
{
List<List<Integer>> boxes = diagnostics.stream().filter(diagnostic -> diagnostic.severity() == Diagnostic.Severity.ERROR && diagnostic.box() != null).map(Diagnostic::box).toList();
List<GenerationPorts.PreviewPage> pages = List.of();
List<Diagnostic> findings = diagnostics;
if(!boxes.isEmpty())
{
int dpi = printed.dpi();
int width = dots(printed.widthMicrons(), dpi, 12700);
int height = dots(printed.heightMicrons(), dpi, 12700);
int left = Math.min(dots(printed.leftMicrons(), dpi, 25399), width - dots(printed.widthMicrons() - printed.leftMicrons() - printed.rightMicrons(), dpi, 0));
int top = Math.min(dots(printed.topMicrons(), dpi, 25399), height - dots(printed.heightMicrons() - printed.topMicrons() - printed.bottomMicrons(), dpi, 0));
try
{
ZplRaster page = ZplRaster.refused(width, height, left, top, boxes);
pages = List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height()));
}
catch(IllegalArgumentException undrawn)
{
if(!(painted(undrawn) instanceof GenerationPorts.RenderException large))
{
throw (undrawn);
}
findings = new ArrayList<>(diagnostics);
findings.add(large.diagnostic());
}
}
return (new GenerationPorts.Preview(pages, false, false, findings, 0L, RendererJson.hash(new byte[0])));
}
/***************************************************************************
** What `ZplRaster` would not draw. A page that would be painted with more
** than `ZplRaster.MAXIMUM_COATS` times its dots becomes the named refusal,
** with the limit as a percentage of the page as the renderer states its
** own; anything else it refuses stays what it was, an opaque failure.
***************************************************************************/
private static Exception painted(IllegalArgumentException undrawn)
{
if(!GenerationPorts.FailureCode.PREVIEW_TOO_LARGE.name().equals(undrawn.getMessage()))
{
return (undrawn);
}
return (new GenerationPorts.RenderException(GenerationPorts.FailureCode.PREVIEW_TOO_LARGE, new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, null,
Map.of("paintedPercent", 100 * ZplRaster.MAXIMUM_COATS))));
}
/***************************************************************************
** The document of an accepted answer, checked exactly as for printing: its
** decoded size and hash, its metadata, and for a label the output policy for
** the stock at the resolution it was rendered for.
***************************************************************************/
private byte[] output(WorkerAnswer answer, GenerationPorts.Format format, int maximum, GenerationPorts.Stock printed) throws Exception
{
JsonNode response = answer.fields();
String encoded = RendererJson.text(response.get("base64"), 4 * ((maximum + 2) / 3));
byte[] bytes = Base64.getDecoder().decode(encoded);
if(bytes.length < 1 || bytes.length > maximum || RendererJson.integer(response.get("length"), 1, maximum) != bytes.length
|| !RendererJson.hash(bytes).equals(RendererJson.text(response.get("sha256"), 64)))
{
throw new IllegalArgumentException("OUTPUT_INTEGRITY");
}
int pages = RendererJson.integer(response.get("pageCount"), 1, config.maximumPages());
if(format == GenerationPorts.Format.ZPL)
{
if(pages != 1 || !"application/vnd.zebra-zpl".equals(response.path("mediaType").asText()))
{
throw new IllegalArgumentException("ZPL_METADATA");
}
ZplOutputPolicy.validate(bytes, printed);
}
else
{
if(!"application/pdf".equals(response.path("mediaType").asText()))
{
throw new IllegalArgumentException("PDF_METADATA");
}
pdfEnvelope(bytes);
}
return (bytes);
}
/***************************************************************************
** The stock at the resolution a label is rendered for: its own, or the
** render target.
***************************************************************************/
private static GenerationPorts.Stock at(GenerationPorts.Stock stock, Integer targetDpi)
{
return (targetDpi == null ? stock : new GenerationPorts.Stock(stock.widthMicrons(), stock.heightMicrons(), stock.topMicrons(), stock.rightMicrons(), stock.bottomMicrons(),
stock.leftMicrons(), targetDpi));
}
/***************************************************************************
** Micrometres to dots. The addend chooses the rounding: half an inch of
** micrometres for the nearest dot (a page side, as the output policy
** computes it), one less than an inch to round up and nothing to round down.
***************************************************************************/
private static int dots(int micrometres, int dpi, int addend)
{
return (Math.toIntExact(((long) micrometres * dpi + addend) / 25400));
}
/*******************************************************************************
** Encode pure data only; host objects are rejected before Jackson can reflect.
** The job is framed as renderer 0.1.0 reads it unless it needs more: a render
** target is sent only for a version 2 scene, and collected diagnostics only
** for a preview. A version 1 scene or a PDF asked for another resolution than
** its stock's is refused here, because a renderer would ignore the target.
**
** A job only renderer 0.2.0 can do is sent without the version question when
** the last verified answer came from 0.2.0. If the renderer was replaced by
** an older one since, this one job reaches it and is refused as
** `RENDERER_TOO_OLD` whatever it answers. Any job that ends in
** `RENDERER_TOO_OLD` (the worker is older, or a worker names the refusal
** itself, alone or among collected diagnostics) forgets what was remembered,
** so the next such job asks the version before it sends anything.
*******************************************************************************/
private JsonNode invoke(String id, String operation, GenerationPorts.Format format, GenerationPorts.Revision revision,
Map<String, Object> context, List<Map<String, Object>> resolved, int maximum) throws Exception
private WorkerAnswer invoke(String id, String operation, GenerationPorts.Format format, GenerationPorts.Revision revision,
Map<String, Object> context, List<Map<String, Object>> resolved, int maximum, Integer targetDpi, boolean collect) throws Exception
{
if(id == null || !id.matches("[A-Za-z0-9._:-]{1,128}") || format == null)
{
@@ -223,61 +452,97 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
{
throw new IllegalArgumentException("SOURCE_LIMIT");
}
SceneFacts facts = SceneFacts.of(format, source);
if(targetDpi != null && !facts.version2() && !targetDpi.equals(revision.stock().dpi()))
{
throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.TARGET_REQUIRES_SCENE_2, new Diagnostic("TARGET_REQUIRES_SCENE_2", Diagnostic.Severity.ERROR, null, null,
Map.of("dpi", targetDpi), Map.of("dpi", revision.stock().dpi())));
}
boolean current = facts.version2() || collect;
Map<String, Object> request = request(id, operation, format, source, revision.stock(), new Snapshot().copy(context, 0), resolved, maximum);
if(targetDpi != null && facts.version2())
{
request.put("targetDpi", targetDpi);
}
if(collect)
{
request.put("diagnostics", "COLLECT");
}
byte[] input = (RendererJson.JSON.writeValueAsString(request) + "\n").getBytes(StandardCharsets.UTF_8);
if(input.length > 16 * 1024 * 1024)
{
throw new IllegalArgumentException("FRAME_LIMIT");
}
if(current && !structured)
{
probe();
}
////////////////////////////////////////////////////////////////////////////////////////////////////
// an answer of renderer 0.2.0 to such a job also carries diagnostics. They get their own bounded //
// room, and the whole frame never passes what the deployment's transport was sized for: the //
// frame of the largest document this adapter may ask for //
////////////////////////////////////////////////////////////////////////////////////////////////////
long room = (operation.equals("VALIDATE") ? 0 : 4L * ((maximum + 2) / 3)) + ENVELOPE_BYTES + (current ? WorkerAnswer.DIAGNOSTICS_BYTES : 0);
int maximumFrame = (int) Math.min(room, 4L * ((config.maximumBytes() + 2) / 3) + ENVELOPE_BYTES);
WorkerAnswer answer = WorkerAnswer.read(exchange.invoke(id, input, maximumFrame), maximumFrame, id, facts);
structured = answer.version().structured();
try
{
answer.settle(operation, collect, current);
}
catch(GenerationPorts.RenderException refusal)
{
if(refusal.code() == GenerationPorts.FailureCode.RENDERER_TOO_OLD)
{
structured = false;
}
throw (refusal);
}
if(answer.diagnostics().stream().anyMatch(diagnostic -> GenerationPorts.FailureCode.RENDERER_TOO_OLD.name().equals(diagnostic.code())))
{
structured = false;
}
return (answer);
}
/***************************************************************************
** Ask the worker its version before a job only renderer 0.2.0 can do, so
** that a renderer the bridge does not know to be 0.2.0 is sent nothing of a
** version 2 scene or its record. The question is a constant. It is asked
** when nothing is remembered, not before every job: after a renderer image
** is rolled back, ONE job can reach an old (0.1.0) renderer unasked and is
** then refused as `RENDERER_TOO_OLD`; the job after it asks here first.
***************************************************************************/
private void probe() throws Exception
{
String id = UUID.randomUUID().toString();
byte[] input = (RendererJson.JSON.writeValueAsString(request(id, "VALIDATE", GenerationPorts.Format.ZPL, PROBE_SCENE, PROBE_STOCK, Map.of(), List.of(), ENVELOPE_BYTES)) + "\n")
.getBytes(StandardCharsets.UTF_8);
WorkerAnswer answer = WorkerAnswer.read(exchange.invoke(id, input, ENVELOPE_BYTES), ENVELOPE_BYTES, id, SceneFacts.of(GenerationPorts.Format.ZPL, PROBE_SCENE));
structured = answer.settle("VALIDATE", false, false).version().structured();
if(!structured)
{
throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.RENDERER_TOO_OLD);
}
}
/***************************************************************************
** The fields every job has, in the order every released renderer was sent.
***************************************************************************/
private static Map<String, Object> request(String id, String operation, GenerationPorts.Format format, String source, GenerationPorts.Stock stock, Object context,
List<Map<String, Object>> resolved, int maximum)
{
Map<String, Object> request = new LinkedHashMap<>();
request.put("protocol", RendererJson.PROTOCOL);
request.put("requestId", id);
request.put("operation", operation);
request.put("format", format.name());
request.put("source", source);
request.put("stock", stock(revision.stock()));
request.put("context", new Snapshot().copy(context, 0));
request.put("stock", stock(stock));
request.put("context", context);
request.put("assets", resolved);
request.put("maximumBytes", maximum);
byte[] input = (RendererJson.JSON.writeValueAsString(request) + "\n").getBytes(StandardCharsets.UTF_8);
if(input.length > 16 * 1024 * 1024)
{
throw new IllegalArgumentException("FRAME_LIMIT");
}
int maximumFrame = operation.equals("VALIDATE") ? 4096 : 4 * ((maximum + 2) / 3) + 4096;
byte[] output = exchange.invoke(id, input, maximumFrame);
if(output.length == 0 || output[output.length - 1] != '\n')
{
throw new IllegalArgumentException("FRAME_TRUNCATED");
}
for(int i = 0; i < output.length - 1; i++)
{
if(output[i] == '\n' || output[i] == '\r')
{
throw new IllegalArgumentException("EXTRA_FRAME");
}
}
JsonNode result = RendererJson.response(output, maximumFrame);
if("ERROR".equals(result.path("outcome").asText()))
{
if(result.size() != 6 || !RendererJson.PROTOCOL.equals(result.path("protocol").asText()) || !id.equals(result.path("requestId").asText())
|| !"qqq-renderer-0.1.0".equals(result.path("rendererVersion").asText()) || !"qqq-barcodes-9".equals(result.path("catalogVersion").asText()))
{
throw new IllegalArgumentException("WORKER_ERROR_REJECTED");
}
GenerationPorts.FailureCode code;
try
{
code = GenerationPorts.FailureCode.valueOf(RendererJson.text(result.get("code"), 64));
}
catch(IllegalArgumentException unknown)
{
throw new IllegalArgumentException("WORKER_ERROR_REJECTED");
}
throw new GenerationPorts.RenderException(code);
}
int expectedFields = operation.equals("VALIDATE") ? 5 : 10;
if(result.size() != expectedFields || !RendererJson.PROTOCOL.equals(result.path("protocol").asText()) || !id.equals(result.path("requestId").asText())
|| !"OK".equals(result.path("outcome").asText()) || !"qqq-renderer-0.1.0".equals(result.path("rendererVersion").asText())
|| !"qqq-barcodes-9".equals(result.path("catalogVersion").asText()))
{
throw new IllegalArgumentException("WORKER_RESPONSE_REJECTED");
}
return (result);
return (request);
}
/*******************************************************************************
@@ -21,22 +21,17 @@
package com.kingsrook.qbits.documentgeneration.renderer;
import java.io.ByteArrayInputStream;
import java.io.InputStreamReader;
import java.nio.ByteBuffer;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Set;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
/*******************************************************************************
@@ -77,65 +72,6 @@ final class RendererJson
}
/*******************************************************************************
** Worker success/error responses have known scalar fields, not arbitrary trees.
** Reject unknown/nested values before allocating an attacker-shaped graph.
*******************************************************************************/
static JsonNode response(byte[] bytes, int maximum) throws Exception
{
if(bytes.length > maximum)
{
throw new IllegalArgumentException("RESPONSE_BYTE_LIMIT");
}
Set<String> textFields = Set.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "mediaType", "base64", "sha256", "code");
ObjectNode result = JSON.createObjectNode();
var decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
try(var reader = new InputStreamReader(new ByteArrayInputStream(bytes), decoder); var parser = JSON.getFactory().createParser(reader))
{
if(parser.nextToken() != JsonToken.START_OBJECT)
{
throw new IllegalArgumentException("RESPONSE_OBJECT_REQUIRED");
}
while(parser.nextToken() != JsonToken.END_OBJECT)
{
if(parser.currentToken() != JsonToken.FIELD_NAME)
{
throw new IllegalArgumentException("RESPONSE_FIELD_REQUIRED");
}
String field = parser.currentName();
boolean text = textFields.contains(field);
if(!text && !field.equals("length") && !field.equals("pageCount"))
{
throw new IllegalArgumentException("UNKNOWN_RESPONSE_FIELD");
}
JsonToken token = parser.nextToken();
if(text && token == JsonToken.VALUE_STRING)
{
String value = parser.getText();
if(value.length() > (field.equals("base64") ? maximum : 128))
{
throw new IllegalArgumentException("RESPONSE_TEXT_LIMIT");
}
result.put(field, value);
}
else if(!text && token == JsonToken.VALUE_NUMBER_INT)
{
result.put(field, parser.getIntValue());
}
else
{
throw new IllegalArgumentException("RESPONSE_SCALAR_REQUIRED");
}
}
if(parser.nextToken() != null)
{
throw new IllegalArgumentException("RESPONSE_TRAILING_VALUE");
}
}
return (result);
}
/*******************************************************************************
** Identity is calculated locally from exact bytes, never trusted from worker.
*******************************************************************************/
@@ -0,0 +1,113 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.util.Set;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode;
/*******************************************************************************
** The version rule: the renderers this bridge accepts, and for each one what
** its answers hold and which refusals it may name. Anything else a worker says
** its version is, is rejected.
**
** | Renderer | Answers | Refusals it may name | Version 2 scene, render target, preview |
** |----------|--------------------------------------------|-------------------------------|-----------------------------------------|
** | 0.1.0 | the released fields only | the 13 released codes | refused: `RENDERER_TOO_OLD` |
** | 0.2.0 | the same, with `diagnostics` (and `detail`) | those and the 15 scene codes | rendered |
**
** Each set is written out. A range of the enum would take in whatever is added
** between its ends, and a worker would gain a refusal nobody decided it has.
*******************************************************************************/
enum RendererVersion
{
RELEASED("qqq-renderer-0.1.0", false, Set.of(
FailureCode.BARCODE_FIT_TOO_SMALL, FailureCode.BARCODE_FIT_UNSUPPORTED, FailureCode.BARCODE_FIT_BOX_OUTSIDE_STOCK, FailureCode.BARCODE_FIT_GEOMETRY_MISMATCH,
FailureCode.INVALID_BARCODE_FIT_PROFILE, FailureCode.INVALID_BARCODE_FIT_REQUEST, FailureCode.BARCODE_SYMBOLOGY_NOT_ALLOWED, FailureCode.SYMBOLOGY_ALLOWLIST_REQUIRED,
FailureCode.SCANNER_BARCODE_PROFILE_UNSUPPORTED, FailureCode.SCANNER_BARCODE_PAYLOAD_UNSUPPORTED, FailureCode.SCANNER_BARCODE_CHECKSUM_REQUIRED,
FailureCode.SCANNER_BARCODE_CHECKSUM_MISMATCH, FailureCode.UNSUPPORTED_BARCODE_OPTION)),
CURRENT("qqq-renderer-0.2.0", true, Set.of(
FailureCode.BARCODE_FIT_TOO_SMALL, FailureCode.BARCODE_FIT_UNSUPPORTED, FailureCode.BARCODE_FIT_BOX_OUTSIDE_STOCK, FailureCode.BARCODE_FIT_GEOMETRY_MISMATCH,
FailureCode.INVALID_BARCODE_FIT_PROFILE, FailureCode.INVALID_BARCODE_FIT_REQUEST, FailureCode.BARCODE_SYMBOLOGY_NOT_ALLOWED, FailureCode.SYMBOLOGY_ALLOWLIST_REQUIRED,
FailureCode.SCANNER_BARCODE_PROFILE_UNSUPPORTED, FailureCode.SCANNER_BARCODE_PAYLOAD_UNSUPPORTED, FailureCode.SCANNER_BARCODE_CHECKSUM_REQUIRED,
FailureCode.SCANNER_BARCODE_CHECKSUM_MISMATCH, FailureCode.UNSUPPORTED_BARCODE_OPTION,
FailureCode.SCENE_INVALID, FailureCode.STOCK_MISMATCH, FailureCode.TARGET_REQUIRES_SCENE_2, FailureCode.ELEMENT_OUTSIDE_PRINTABLE_AREA, FailureCode.TEXT_DOES_NOT_FIT,
FailureCode.VALUE_MISSING, FailureCode.VALUE_TOO_LONG, FailureCode.FONT_UNKNOWN, FailureCode.FONT_MISSING_GLYPH, FailureCode.RESERVED_FONT_NAME,
FailureCode.BARCODE_DOES_NOT_FIT, FailureCode.GS1_INVALID, FailureCode.ASSET_UNKNOWN, FailureCode.PREVIEW_TOO_LARGE, FailureCode.RENDERER_TOO_OLD));
private final String wire;
private final boolean structured;
private final Set<FailureCode> refusals;
/***************************************************************************
** One accepted renderer.
***************************************************************************/
RendererVersion(String wire, boolean structured, Set<FailureCode> refusals)
{
this.wire = wire;
this.structured = structured;
this.refusals = refusals;
}
/***************************************************************************
** The accepted renderer an answer names, or null: a version this bridge was
** not built for is never guessed at.
***************************************************************************/
static RendererVersion of(String rendererVersion)
{
for(RendererVersion version : values())
{
if(version.wire.equals(rendererVersion))
{
return (version);
}
}
return (null);
}
/***************************************************************************
** Whether this renderer's answers carry `diagnostics` and `detail`, and so
** whether it renders a version 2 scene, a render target and a preview.
***************************************************************************/
boolean structured()
{
return (structured);
}
/***************************************************************************
** Whether this renderer may name a refusal. A code outside its set is
** rejected like any unknown text.
***************************************************************************/
boolean names(FailureCode code)
{
return (refusals.contains(code));
}
}
@@ -0,0 +1,228 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.io.IOException;
import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;
import com.fasterxml.jackson.core.JsonFactory;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.core.StreamReadConstraints;
import com.fasterxml.jackson.core.StreamReadFeature;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
/*******************************************************************************
** What the host reads from a label source it is about to send, and nothing
** more: whether it says it is a version 2 scene, and the element ids it
** declares. The host does not lay out, validate or draw a scene; the worker
** does. These two facts are what the version rule and the check of a worker's
** diagnostics need: a diagnostic may name only an element this scene declares
** and point only through array indexes, property names of the version 2 schema
** and declared ids.
**
** A source the host cannot read is not a version 2 scene to the host and
** declares nothing. If the worker reads it differently, every disagreement
** fails closed: its diagnostics name ids the host does not know and are
** rejected, and no render target was sent for it.
*******************************************************************************/
final class SceneFacts
{
static final String SCHEMA = "qqq-zpl-scene/2";
///////////////////////////////////////////////////////////////////////////////////////////////
// every property name `renderer/schema/zpl-scene-2.schema.json` declares; a test holds the //
// two equal, so a property added to the schema is added here in the same change //
///////////////////////////////////////////////////////////////////////////////////////////////
static final Set<String> VOCABULARY = Set.of("schema", "design", "defaults", "samples", "elements", "overrides", "when", "dpi", "set", "children",
"id", "type", "anchor", "x", "y", "width", "height", "align", "valign",
"thickness", "fill",
"value", "valuePath", "font", "size", "lineHeight", "fit", "maxLength", "threshold", "reverse", "rotation",
"symbology", "symbologyPath", "allowedSymbologies", "minModule", "maxModule", "quiet", "gs1", "hri", "scannerSymbologyPath", "aimIdentifierPath",
"asset", "mode");
private static final SceneFacts NONE = new SceneFacts(false, Set.of());
private static final Pattern ID = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,31}");
private static final Pattern INDEX = Pattern.compile("0|[1-9][0-9]{0,3}");
private static final int MAXIMUM_IDS = 1000;
///////////////////////////////////////////////////////////////////////////////////////////////
// the worker's own reading limits, so the host and the worker read the same sources //
///////////////////////////////////////////////////////////////////////////////////////////////
private static final JsonFactory JSON = JsonFactory.builder().streamReadConstraints(StreamReadConstraints.builder().maxNestingDepth(24).maxNumberLength(32).build())
.enable(StreamReadFeature.STRICT_DUPLICATE_DETECTION).build();
private final boolean version2;
private final Set<String> ids;
/***************************************************************************
** The facts of one source.
***************************************************************************/
private SceneFacts(boolean version2, Set<String> ids)
{
this.version2 = version2;
this.ids = ids;
}
/***************************************************************************
** Read the facts of the source a job will send. Only a label can be a scene.
***************************************************************************/
static SceneFacts of(GenerationPorts.Format format, String source)
{
if(format != GenerationPorts.Format.ZPL)
{
return (NONE);
}
Set<String> ids = new HashSet<>();
boolean version2 = false;
try(JsonParser parser = JSON.createParser(source))
{
if(parser.nextToken() != JsonToken.START_OBJECT)
{
return (NONE);
}
while(parser.nextToken() == JsonToken.FIELD_NAME)
{
String name = parser.currentName();
JsonToken value = parser.nextToken();
if(name.equals("schema"))
{
version2 = value == JsonToken.VALUE_STRING && SCHEMA.equals(parser.getText());
}
if(name.equals("elements") && value == JsonToken.START_ARRAY)
{
elements(parser, ids);
}
else
{
parser.skipChildren();
}
}
///////////////////////////////////////////////////////////////////////////////////////
// read to the end, as the worker does: text after the scene makes it no scene //
///////////////////////////////////////////////////////////////////////////////////////
if(parser.currentToken() != JsonToken.END_OBJECT || parser.nextToken() != null)
{
return (NONE);
}
}
catch(IOException | IllegalArgumentException unreadable)
{
return (NONE);
}
return (version2 ? new SceneFacts(true, Set.copyOf(ids)) : NONE);
}
/***************************************************************************
** The ids of a list of elements and of the children of each, where the
** worker's parser looks for them and nowhere else: a sample or an override
** cannot declare one.
***************************************************************************/
private static void elements(JsonParser parser, Set<String> ids) throws IOException
{
while(parser.nextToken() != JsonToken.END_ARRAY)
{
if(parser.currentToken() != JsonToken.START_OBJECT)
{
parser.skipChildren();
continue;
}
while(parser.nextToken() == JsonToken.FIELD_NAME)
{
String name = parser.currentName();
JsonToken value = parser.nextToken();
if(name.equals("id") && value == JsonToken.VALUE_STRING && ID.matcher(parser.getText()).matches())
{
ids.add(parser.getText());
if(ids.size() > MAXIMUM_IDS)
{
throw new IllegalArgumentException("SCENE_ELEMENT_LIMIT");
}
}
if(name.equals("children") && value == JsonToken.START_ARRAY)
{
elements(parser, ids);
}
else
{
parser.skipChildren();
}
}
}
}
/***************************************************************************
** Whether the source says it is a version 2 scene, which only renderer 0.2.0
** and later can draw.
***************************************************************************/
boolean version2()
{
return (version2);
}
/***************************************************************************
** Whether the scene declares an element with this id.
***************************************************************************/
boolean declares(String id)
{
return (ids.contains(id));
}
/***************************************************************************
** Whether a diagnostic's pointer could have been made from this scene: the
** empty pointer (the whole scene), or segments that are each an array index,
** a property name of the schema or a declared id.
***************************************************************************/
boolean points(String pointer)
{
if(pointer.isEmpty())
{
return (true);
}
if(!pointer.startsWith("/"))
{
return (false);
}
for(String segment : pointer.substring(1).split("/", -1))
{
if(!(INDEX.matcher(segment).matches() || VOCABULARY.contains(segment) || ids.contains(segment)))
{
return (false);
}
}
return (true);
}
}
@@ -0,0 +1,406 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic;
/*******************************************************************************
** One answer of the worker, read as untrusted bytes. Nothing in it is believed
** until it has been held to the request it answers, to the version rule and,
** for a diagnostic, to the scene the host sent: a diagnostic is a code of the
** closed vocabulary, numbers, an element id that scene declares and a pointer
** made of array indexes, schema property names and declared ids. A worker
** cannot put a sentence, a record value or a made-up name into one.
**
** Reading has two steps. `read` takes the frame apart and checks everything
** that does not depend on what was asked: the frame, the field shapes, the
** protocol, the request id, the catalog and a renderer version this bridge
** accepts. `settle` then holds the answer to the question: a refusal becomes
** the typed exception, anything out of place is rejected by name.
*******************************************************************************/
final class WorkerAnswer
{
static final String CATALOG = "qqq-barcodes-9";
///////////////////////////////////////////////////////////////////////////////////////////////////
// what one answer may hold beyond its label. A scene has at most 1000 elements; each can be //
// refused once and warned about a few times. 2048 findings in one megabyte is far more than a //
// real label makes and bounds what a hostile worker can make the host read and keep //
///////////////////////////////////////////////////////////////////////////////////////////////////
static final int MAXIMUM_DIAGNOSTICS = 2048;
static final int DIAGNOSTICS_BYTES = 1024 * 1024;
private static final Set<String> TEXT = Set.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "mediaType", "base64", "sha256", "code");
private static final Set<String> NUMBERS = Set.of("length", "pageCount");
private static final int MAXIMUM_BOX = 1_000_000;
private final ObjectNode fields;
private final RendererVersion version;
private final List<Diagnostic> diagnostics;
private final Diagnostic detail;
private final boolean detailed;
/***************************************************************************
** An answer whose parts have been read and whose envelope has been checked.
***************************************************************************/
private WorkerAnswer(ObjectNode fields, RendererVersion version, List<Diagnostic> diagnostics, Diagnostic detail, boolean detailed)
{
this.fields = fields;
this.version = version;
this.diagnostics = diagnostics;
this.detail = detail;
this.detailed = detailed;
}
/***************************************************************************
** Read one frame: exactly one line of strict JSON whose fields are the known
** ones in their known shapes, from an accepted renderer, for this request.
***************************************************************************/
static WorkerAnswer read(byte[] output, int maximumFrame, String requestId, SceneFacts facts) throws IOException
{
if(output.length > maximumFrame)
{
throw new IllegalArgumentException("RESPONSE_BYTE_LIMIT");
}
if(output.length == 0 || output[output.length - 1] != '\n')
{
throw new IllegalArgumentException("FRAME_TRUNCATED");
}
for(int index = 0; index < output.length - 1; index++)
{
if(output[index] == '\n' || output[index] == '\r')
{
throw new IllegalArgumentException("EXTRA_FRAME");
}
}
ObjectNode fields = RendererJson.JSON.createObjectNode();
List<Diagnostic> diagnostics = null;
Diagnostic detail = null;
boolean detailed = false;
var decoder = StandardCharsets.UTF_8.newDecoder().onMalformedInput(CodingErrorAction.REPORT).onUnmappableCharacter(CodingErrorAction.REPORT);
try(var reader = new InputStreamReader(new ByteArrayInputStream(output), decoder); JsonParser parser = RendererJson.JSON.getFactory().createParser(reader))
{
if(parser.nextToken() != JsonToken.START_OBJECT)
{
throw new IllegalArgumentException("RESPONSE_OBJECT_REQUIRED");
}
while(parser.nextToken() != JsonToken.END_OBJECT)
{
String field = parser.currentName();
JsonToken token = parser.nextToken();
if(TEXT.contains(field) && token == JsonToken.VALUE_STRING)
{
fields.put(field, text(parser, field.equals("base64") ? maximumFrame : 128));
}
else if(NUMBERS.contains(field) && token == JsonToken.VALUE_NUMBER_INT)
{
fields.put(field, parser.getIntValue());
}
else if(field.equals("diagnostics") && token == JsonToken.START_ARRAY)
{
diagnostics = new ArrayList<>();
while(parser.nextToken() != JsonToken.END_ARRAY)
{
if(diagnostics.size() == MAXIMUM_DIAGNOSTICS)
{
throw new IllegalArgumentException("DIAGNOSTIC_LIMIT");
}
diagnostics.add(diagnostic(parser, facts));
}
}
else if(field.equals("detail") && token == JsonToken.START_OBJECT)
{
detail = diagnostic(parser, facts);
detailed = true;
}
else
{
throw new IllegalArgumentException("RESPONSE_FIELD_REJECTED");
}
}
if(parser.nextToken() != null)
{
throw new IllegalArgumentException("RESPONSE_TRAILING_VALUE");
}
}
RendererVersion version = RendererVersion.of(fields.path("rendererVersion").textValue());
if(version == null || !RendererJson.PROTOCOL.equals(fields.path("protocol").textValue()) || !requestId.equals(fields.path("requestId").textValue())
|| !CATALOG.equals(fields.path("catalogVersion").textValue()) || !Set.of("OK", "REFUSED", "ERROR").contains(fields.path("outcome").asText()))
{
throw new IllegalArgumentException("WORKER_RESPONSE_REJECTED");
}
return (new WorkerAnswer(fields, version, diagnostics == null ? null : List.copyOf(diagnostics), detail, detailed));
}
/***************************************************************************
** Hold the answer to what was asked. `current` says the job needs renderer
** 0.2.0 (a version 2 scene, or collected diagnostics); `collect` says the
** job asked for every diagnostic, which is the only way an answer may be
** `REFUSED`. A refusal the renderer may name leaves as the typed exception
** with its diagnostic; an answer that is returned is `OK` or `REFUSED` and
** has exactly the fields its outcome and renderer allow.
***************************************************************************/
WorkerAnswer settle(String operation, boolean collect, boolean current) throws GenerationPorts.RenderException
{
if(current && !version.structured())
{
////////////////////////////////////////////////////////////////////////////////////////
// whatever an old renderer said about a job it cannot do, it did not do the job //
////////////////////////////////////////////////////////////////////////////////////////
throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.RENDERER_TOO_OLD);
}
String outcome = fields.path("outcome").textValue();
if(outcome.equals("ERROR"))
{
throw refusal();
}
boolean refused = outcome.equals("REFUSED");
int expected = refused || operation.equals("VALIDATE") ? 5 : 10;
boolean errors = diagnostics != null && diagnostics.stream().anyMatch(WorkerAnswer::error);
if(fields.size() != expected || detailed || (diagnostics != null) != version.structured() || (refused && !(collect && errors)) || (!refused && errors))
{
throw new IllegalArgumentException("WORKER_RESPONSE_REJECTED");
}
return (this);
}
/***************************************************************************
** The typed refusal of an error answer, or the rejection of one that is not
** exactly an error answer of its renderer.
***************************************************************************/
private GenerationPorts.RenderException refusal()
{
GenerationPorts.FailureCode code;
try
{
code = GenerationPorts.FailureCode.valueOf(RendererJson.text(fields.get("code"), 64));
}
catch(IllegalArgumentException unknown)
{
throw new IllegalArgumentException("WORKER_ERROR_REJECTED");
}
if(fields.size() != 6 || diagnostics != null || (detailed && !version.structured()) || !version.names(code)
|| (detailed && !(error(detail) && detail.code().equals(code.name()))))
{
throw new IllegalArgumentException("WORKER_ERROR_REJECTED");
}
return (new GenerationPorts.RenderException(code, detail));
}
/***************************************************************************
** The renderer that answered.
***************************************************************************/
RendererVersion version()
{
return (version);
}
/***************************************************************************
** Whether the renderer collected at least one error and so sent no label.
***************************************************************************/
boolean refused()
{
return ("REFUSED".equals(fields.path("outcome").textValue()));
}
/***************************************************************************
** The scalar fields of the answer.
***************************************************************************/
JsonNode fields()
{
return (fields);
}
/***************************************************************************
** The findings of the answer, in the order the renderer found them; none
** for a renderer that has none.
***************************************************************************/
List<Diagnostic> diagnostics()
{
return (diagnostics == null ? List.of() : diagnostics);
}
/***************************************************************************
** Whether a finding stops printing.
***************************************************************************/
private static boolean error(Diagnostic diagnostic)
{
return (diagnostic.severity() == Diagnostic.Severity.ERROR);
}
/***************************************************************************
** One diagnostic object, field by field. The record admits only the closed
** vocabulary and number shapes; the scene admits only its own ids and
** pointers.
***************************************************************************/
private static Diagnostic diagnostic(JsonParser parser, SceneFacts facts) throws IOException
{
if(parser.currentToken() != JsonToken.START_OBJECT)
{
throw new IllegalArgumentException("DIAGNOSTIC_OBJECT_REQUIRED");
}
Map<String, String> words = new LinkedHashMap<>();
List<Integer> box = null;
Map<String, Integer> measured = null;
Map<String, Integer> limit = null;
while(parser.nextToken() != JsonToken.END_OBJECT)
{
String field = parser.currentName();
JsonToken token = parser.nextToken();
if(Set.of("code", "severity", "element").contains(field) && token == JsonToken.VALUE_STRING)
{
words.put(field, text(parser, 64));
}
else if(field.equals("pointer") && token == JsonToken.VALUE_STRING)
{
words.put(field, text(parser, 16 * 33));
}
else if(field.equals("box") && token == JsonToken.START_ARRAY)
{
box = new ArrayList<>();
while(parser.nextToken() != JsonToken.END_ARRAY)
{
int value = integer(parser);
if(box.size() == 4 || Math.abs((long) value) > MAXIMUM_BOX)
{
throw new IllegalArgumentException("DIAGNOSTIC_BOX");
}
box.add(value);
}
}
else if(field.equals("measured") && token == JsonToken.START_OBJECT)
{
measured = numbers(parser);
}
else if(field.equals("limit") && token == JsonToken.START_OBJECT)
{
limit = numbers(parser);
}
else
{
throw new IllegalArgumentException("DIAGNOSTIC_FIELD_REJECTED");
}
}
Diagnostic diagnostic;
try
{
diagnostic = new Diagnostic(words.get("code"), Diagnostic.Severity.valueOf(String.valueOf(words.get("severity"))), words.get("element"), box, measured, limit, words.get("pointer"));
}
catch(IllegalArgumentException outside)
{
/////////////////////////////////////////////////////////////////////////////////////////
// the record's own messages are sentences for a developer; one name is enough here //
/////////////////////////////////////////////////////////////////////////////////////////
throw new IllegalArgumentException("DIAGNOSTIC_OUTSIDE_VOCABULARY");
}
if((diagnostic.element() != null && !facts.declares(diagnostic.element())) || (diagnostic.pointer() != null && !facts.points(diagnostic.pointer())))
{
throw new IllegalArgumentException("DIAGNOSTIC_NOT_OF_THIS_SCENE");
}
return (diagnostic);
}
/***************************************************************************
** At most eight named integers. The record checks the names.
***************************************************************************/
private static Map<String, Integer> numbers(JsonParser parser) throws IOException
{
Map<String, Integer> numbers = new LinkedHashMap<>();
while(parser.nextToken() != JsonToken.END_OBJECT)
{
String name = parser.currentName();
parser.nextToken();
if(numbers.size() == 8 || name.length() > 32)
{
throw new IllegalArgumentException("DIAGNOSTIC_NUMBERS");
}
numbers.put(name, integer(parser));
}
return (numbers);
}
/***************************************************************************
** A whole number that fits an int; nothing is coerced into one.
***************************************************************************/
private static int integer(JsonParser parser) throws IOException
{
if(parser.currentToken() != JsonToken.VALUE_NUMBER_INT)
{
throw new IllegalArgumentException("DIAGNOSTIC_INTEGER");
}
return (parser.getIntValue());
}
/***************************************************************************
** A string no longer than its field allows.
***************************************************************************/
private static String text(JsonParser parser, int maximum) throws IOException
{
if(parser.getTextLength() > maximum)
{
throw new IllegalArgumentException("RESPONSE_TEXT_LIMIT");
}
return (parser.getText());
}
}
@@ -0,0 +1,612 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.zip.CRC32;
import java.util.zip.Deflater;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
/*******************************************************************************
** The page a printer would draw, decoded on the host from the label bytes the
** host would send it. There is no second renderer: the picture is made from
** the printer commands themselves, after the output policy has accepted them,
** so a preview cannot show something other than what prints.
**
** The reader is its own strict parser of the policy's grammar and trusts
** nothing it is handed: a byte that is not the next byte of that grammar, a
** number that is not canonical, a field that leaves the page or a page too
** large to draw is refused by name. Graphic fields (`^GFA`) and boxes (`^GB`)
** are painted exactly. Printer font text (`^A0N`), which only a version 1
** scene emits, cannot be drawn without the printer's font: its estimated area
** is outlined and crossed and the page says it is not exact.
**
** The work is bounded as well as the page. Every field is counted before it
** is painted, an overlap each time, and the field that would take the page
** past sixteen times its dots is refused as `PREVIEW_TOO_LARGE`. Nothing is
** painted by testing every dot of an area: a box is at most four solid bars
** and a placeholder is an outline and two diagonals, so a label of thousands
** of fields costs what it inks, never fields times the page.
**
** No image or font is decoded here. The only image work is writing this
** class's own one-bit bitmap as a PNG.
*******************************************************************************/
final class ZplRaster
{
///////////////////////////////////////////////////////////////////////////////////////////////
// a 4 x 6 in label at 600 dpi is 8.6 million dots; twice that is the most a preview draws //
///////////////////////////////////////////////////////////////////////////////////////////////
static final long MAXIMUM_DOTS = 16_000_000L;
////////////////////////////////////////////////////////////////////////////////////////////////
// what one page may be painted with, in pages: the dots its fields paint, an overlap counted //
// each time, may total sixteen times the page. Sixteen is the renderer's own limit for what //
// a version 2 scene may paint, and the label of such a scene is its one composed canvas, so //
// none comes near it. A version 1 label that painted its page more than sixteen times over //
// would print and not be previewed. The limit holds the 6000 fields the output policy //
// allows to sixteen fills of the page where 6000 were possible //
////////////////////////////////////////////////////////////////////////////////////////////////
static final int MAXIMUM_COATS = 16;
private static final byte[] SIGNATURE = {(byte) 0x89, 'P', 'N', 'G', 13, 10, 26, 10};
private final int width;
private final int height;
private final int stride;
private final byte[] ink;
private boolean exact = true;
private long painted;
/***************************************************************************
** A blank page of whole dots, within what a printer can address and a
** preview may draw.
***************************************************************************/
private ZplRaster(int width, int height)
{
if(width < 1 || width > 9999 || height < 1 || height > 32000)
{
throw new IllegalArgumentException("RASTER_PAGE");
}
if((long) width * height > MAXIMUM_DOTS)
{
throw new IllegalArgumentException("PREVIEW_TOO_LARGE");
}
this.width = width;
this.height = height;
stride = (width + 7) / 8;
ink = new byte[stride * height];
}
/***************************************************************************
** The page of a label for its stock. The output policy runs first, here, so
** no caller can draw bytes it did not accept.
***************************************************************************/
static ZplRaster page(byte[] zpl, GenerationPorts.Stock stock) throws Exception
{
ZplOutputPolicy.validate(zpl, stock);
return (decode(zpl));
}
/***************************************************************************
** Read a label and paint its fields. Fields are combined as a printer
** combines them: ink over ink is ink.
***************************************************************************/
static ZplRaster decode(byte[] zpl)
{
Reader reader = new Reader(zpl);
reader.literal("^XA^PW");
int pageWidth = reader.number();
reader.literal("^LL");
ZplRaster page = new ZplRaster(pageWidth, reader.number());
reader.literal("^LH0,0^CI28\n");
while(!reader.peek("^XZ\n"))
{
reader.literal("^FO");
int x = reader.number();
reader.literal(",");
int y = reader.number();
if(reader.peek("^A0N,"))
{
page.text(reader, x, y);
}
else if(reader.peek("^GB"))
{
page.box(reader, x, y);
}
else
{
page.graphic(reader, x, y);
}
}
reader.literal("^XZ\n");
reader.end();
return (page);
}
/***************************************************************************
** A page that shows only where elements were refused: each box, given in
** dots from the printable area's top left as a diagnostic gives it, outlined
** and crossed at its place on the page and cut to the page. It is drawn from
** numbers alone. No label exists for it, so nothing of it can be printed.
** The boxes are counted like the fields of a label: more of them than the
** page may be painted with is `PREVIEW_TOO_LARGE`.
***************************************************************************/
static ZplRaster refused(int width, int height, int originX, int originY, List<List<Integer>> boxes)
{
ZplRaster page = new ZplRaster(width, height);
page.exact = false;
for(List<Integer> box : boxes)
{
long left = Math.max(0L, (long) originX + box.get(0));
long top = Math.max(0L, (long) originY + box.get(1));
long right = Math.min(width, (long) originX + box.get(0) + box.get(2));
long bottom = Math.min(height, (long) originY + box.get(1) + box.get(3));
page.hatch((int) left, (int) top, (int) Math.max(0L, right - left), (int) Math.max(0L, bottom - top));
}
return (page);
}
/***************************************************************************
** The page width in dots.
***************************************************************************/
int width()
{
return (width);
}
/***************************************************************************
** The page height in dots.
***************************************************************************/
int height()
{
return (height);
}
/***************************************************************************
** Whether every dot is what a printer draws. False when the label holds
** printer font text, or the page shows refused elements instead of a label.
***************************************************************************/
boolean exact()
{
return (exact);
}
/***************************************************************************
** Whether a dot of the page is inked.
***************************************************************************/
boolean ink(int x, int y)
{
return ((ink[y * stride + x / 8] & (128 >> x % 8)) != 0);
}
/***************************************************************************
** The page as a one-bit greyscale PNG: one pixel for each dot, ink black.
***************************************************************************/
byte[] png()
{
byte[] lines = new byte[(stride + 1) * height];
for(int row = 0; row < height; row++)
{
for(int index = 0; index < stride; index++)
{
lines[row * (stride + 1) + 1 + index] = (byte) ~ink[row * stride + index];
}
}
Deflater deflater = new Deflater();
ByteArrayOutputStream data = new ByteArrayOutputStream();
try
{
deflater.setInput(lines);
deflater.finish();
byte[] buffer = new byte[65536];
while(!deflater.finished())
{
data.write(buffer, 0, deflater.deflate(buffer));
}
}
finally
{
deflater.end();
}
ByteArrayOutputStream png = new ByteArrayOutputStream();
png.writeBytes(SIGNATURE);
byte[] header = new byte[13];
number(header, 0, width);
number(header, 4, height);
header[8] = 1;
chunk(png, "IHDR", header);
chunk(png, "IDAT", data.toByteArray());
chunk(png, "IEND", new byte[0]);
return (png.toByteArray());
}
/***************************************************************************
** One PNG chunk: length, type, data and the checksum of type and data.
***************************************************************************/
private static void chunk(ByteArrayOutputStream png, String type, byte[] data)
{
byte[] name = type.getBytes(StandardCharsets.US_ASCII);
byte[] word = new byte[4];
number(word, 0, data.length);
png.writeBytes(word);
png.writeBytes(name);
png.writeBytes(data);
CRC32 checksum = new CRC32();
checksum.update(name);
checksum.update(data);
number(word, 0, (int) checksum.getValue());
png.writeBytes(word);
}
/***************************************************************************
** A big-endian 32-bit number.
***************************************************************************/
private static void number(byte[] target, int at, int value)
{
target[at] = (byte) (value >>> 24);
target[at + 1] = (byte) (value >>> 16);
target[at + 2] = (byte) (value >>> 8);
target[at + 3] = (byte) value;
}
/***************************************************************************
** Printer font text: the area the policy holds it to (one cell of the font
** width for each character, one line of the font height), outlined and
** crossed.
***************************************************************************/
private void text(Reader reader, int x, int y)
{
reader.literal("^A0N,");
int fontHeight = reader.number();
reader.literal(",");
int fontWidth = reader.number();
reader.literal("^FH_^FD");
int characters = 0;
while(reader.peek("_"))
{
reader.literal("_");
int value = reader.hex() * 16 + reader.hex();
characters += (value & 0xC0) == 0x80 ? 0 : 1;
}
reader.literal("^FS\n");
long cells = (long) fontWidth * characters;
inside(x, y, cells, fontHeight);
hatch(x, y, (int) cells, fontHeight);
exact = false;
}
/***************************************************************************
** A box as the printer draws it: a border of the stated thickness inside
** the stated size, which is solid when the border meets itself.
***************************************************************************/
private void box(Reader reader, int x, int y)
{
reader.literal("^GB");
int boxWidth = reader.number();
reader.literal(",");
int boxHeight = reader.number();
reader.literal(",");
int thickness = reader.number();
reader.literal("^FS\n");
inside(x, y, boxWidth, boxHeight);
if(thickness < 1 || thickness > Math.min(boxWidth, boxHeight))
{
throw new IllegalArgumentException("RASTER_FIELD");
}
border(x, y, boxWidth, boxHeight, thickness);
}
/***************************************************************************
** A border inside an area that is known to be on the page: the whole area
** when the border meets itself, else four bars that do not overlap (the top
** and bottom across the whole width, the sides between them).
***************************************************************************/
private void border(int x, int y, int areaWidth, int areaHeight, int thickness)
{
if(2L * thickness >= Math.min(areaWidth, areaHeight))
{
fill(x, y, areaWidth, areaHeight);
return;
}
fill(x, y, areaWidth, thickness);
fill(x, y + areaHeight - thickness, areaWidth, thickness);
fill(x, y + thickness, thickness, areaHeight - 2 * thickness);
fill(x + areaWidth - thickness, y + thickness, thickness, areaHeight - 2 * thickness);
}
/***************************************************************************
** Ink a solid rectangle that is known to be on the page, whole bytes at a
** time: the first and last byte of each row are masked to the rectangle.
***************************************************************************/
private void fill(int x, int y, int areaWidth, int areaHeight)
{
admit((long) areaWidth * areaHeight);
int first = x / 8;
int last = (x + areaWidth - 1) / 8;
int head = 0xFF >> x % 8;
int tail = 0xFF << (7 - (x + areaWidth - 1) % 8);
for(int row = y; row < y + areaHeight; row++)
{
for(int index = first; index <= last; index++)
{
ink[row * stride + index] |= (byte) ((index == first ? head : 0xFF) & (index == last ? tail : 0xFF));
}
}
}
/***************************************************************************
** Count the dots a field is about to paint, or refuse the field that would
** take the page past `MAXIMUM_COATS` times its dots. Every painter counts
** before it paints, so the work a label can cause is bounded by its page
** and not by the number of its fields.
***************************************************************************/
private void admit(long dots)
{
if(painted + dots > MAXIMUM_COATS * (long) width * height)
{
throw new IllegalArgumentException("PREVIEW_TOO_LARGE");
}
painted += dots;
}
/***************************************************************************
** An uncompressed graphic field: rows of whole bytes, most significant bit
** first. Bits that pad the last byte of a row past the page edge must be
** white, as the policy requires.
***************************************************************************/
private void graphic(Reader reader, int x, int y)
{
reader.literal("^GFA,");
int transmitted = reader.number();
reader.literal(",");
int total = reader.number();
reader.literal(",");
int rowBytes = reader.number();
reader.literal(",");
if(total < 1 || total > 99999 || transmitted != total || rowBytes < 1 || rowBytes > 1250 || total % rowBytes != 0)
{
throw new IllegalArgumentException("RASTER_FIELD");
}
inside(x, y, 1, total / rowBytes);
admit(8L * total);
for(int index = 0; index < total; index++)
{
int value = reader.hex() * 16 + reader.hex();
for(int bit = 0; bit < 8; bit++)
{
if((value & (128 >> bit)) != 0)
{
int column = x + index % rowBytes * 8 + bit;
if(column >= width)
{
throw new IllegalArgumentException("RASTER_FIELD");
}
set(column, y + index / rowBytes);
}
}
}
reader.literal("^FS\n");
}
/***************************************************************************
** Refuse a field that is not wholly on the page.
***************************************************************************/
private void inside(int x, int y, long fieldWidth, long fieldHeight)
{
if(fieldWidth < 1 || fieldHeight < 1 || x + fieldWidth > width || y + fieldHeight > height)
{
throw new IllegalArgumentException("RASTER_FIELD");
}
}
/***************************************************************************
** An outline with its two diagonals across it: plainly a placeholder, never
** mistaken for ink the label holds. It costs its perimeter and the length
** of its diagonals, not its area: each diagonal has one dot for each step
** along the longer side. An area of no width or no height has no dots and
** nothing is drawn for it: its outline and diagonals would otherwise be
** placed one dot outside it.
***************************************************************************/
private void hatch(int x, int y, int areaWidth, int areaHeight)
{
if(areaWidth < 1 || areaHeight < 1)
{
return;
}
border(x, y, areaWidth, areaHeight, 1);
int steps = Math.max(areaWidth, areaHeight);
admit(2L * steps);
for(int step = 0; step < steps; step++)
{
int column = (int) ((long) step * (areaWidth - 1) / Math.max(1, steps - 1));
int row = (int) ((long) step * (areaHeight - 1) / Math.max(1, steps - 1));
set(x + column, y + row);
set(x + areaWidth - 1 - column, y + row);
}
}
/***************************************************************************
** Ink one dot that is known to be on the page.
***************************************************************************/
private void set(int x, int y)
{
ink[y * stride + x / 8] |= (byte) (128 >> x % 8);
}
/***************************************************************************
** The bytes of a label and a place in them. Every read is bounded and every
** mismatch ends the reading: nothing is skipped or repaired.
***************************************************************************/
private static final class Reader
{
private final byte[] bytes;
private int position;
/************************************************************************
** Start at the first byte.
************************************************************************/
private Reader(byte[] bytes)
{
if(bytes == null)
{
throw new IllegalArgumentException("RASTER_GRAMMAR");
}
this.bytes = bytes;
}
/************************************************************************
** Whether the next bytes are these.
************************************************************************/
private boolean peek(String literal)
{
if(position + literal.length() > bytes.length)
{
return (false);
}
for(int index = 0; index < literal.length(); index++)
{
if(bytes[position + index] != literal.charAt(index))
{
return (false);
}
}
return (true);
}
/************************************************************************
** Pass these bytes or refuse.
************************************************************************/
private void literal(String literal)
{
if(!peek(literal))
{
throw new IllegalArgumentException("RASTER_GRAMMAR");
}
position += literal.length();
}
/************************************************************************
** An unsigned decimal number without a leading zero, of at most six
** digits: more than any size, place or length a label can state.
************************************************************************/
private int number()
{
int start = position;
int value = 0;
while(position < bytes.length && bytes[position] >= '0' && bytes[position] <= '9' && position - start < 7)
{
value = value * 10 + bytes[position++] - '0';
}
if(position == start || position - start > 6 || (position - start > 1 && bytes[start] == '0'))
{
throw new IllegalArgumentException("RASTER_NUMBER");
}
return (value);
}
/************************************************************************
** One uppercase hexadecimal digit.
************************************************************************/
private int hex()
{
int value = position < bytes.length ? bytes[position] : -1;
position++;
if(value >= '0' && value <= '9')
{
return (value - '0');
}
if(value >= 'A' && value <= 'F')
{
return (value - 'A' + 10);
}
throw new IllegalArgumentException("RASTER_HEX");
}
/************************************************************************
** Nothing may follow the end of the label.
************************************************************************/
private void end()
{
if(position != bytes.length)
{
throw new IllegalArgumentException("RASTER_GRAMMAR");
}
}
}
}
@@ -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());
@@ -381,9 +393,10 @@ class BridgeTest
{
for(String malformed : List.of("{\"base64\":[]}", "{\"base64\":{\"nested\":1}}", "{\"unknown\":1}", "{\"length\":1,\"length\":2}", "{\"length\":2147483648}", "{\"length\":1} {}"))
{
assertThrows(Exception.class, () -> RendererJson.response(malformed.getBytes(StandardCharsets.UTF_8), 4096));
assertThrows(Exception.class, () -> WorkerAnswer.read((malformed + "\n").getBytes(StandardCharsets.UTF_8), 4096, "request-1", SceneFacts.of(GenerationPorts.Format.ZPL, "{}")));
}
assertThrows(Exception.class, () -> RendererJson.response(new byte[] { '{', '"', 'b', 'a', 's', 'e', '6', '4', '"', ':', '"', (byte) 0xff, '"', '}' }, 4096));
assertThrows(Exception.class, () -> WorkerAnswer.read(new byte[] { '{', '"', 'b', 'a', 's', 'e', '6', '4', '"', ':', '"', (byte) 0xff, '"', '}', '\n' }, 4096, "request-1",
SceneFacts.of(GenerationPorts.Format.ZPL, "{}")));
}
/*******************************************************************************
@@ -0,0 +1,284 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
/*******************************************************************************
** A stand-in for the worker, for tests of the host side: it keeps every frame
** the bridge sends and answers as a renderer of a stated version would, or as
** a test tells it to. It answers the bridge's version question by itself, so a
** test writes only the answer to the job it is about. No renderer code runs.
*******************************************************************************/
final class FakeWorker implements IsolatedRendererBridge.Exchange
{
static final String RELEASED = "qqq-renderer-0.1.0";
static final String CURRENT = "qqq-renderer-0.2.0";
static final Path SCENES = Path.of("..", "..", "renderer", "src", "test", "resources", "scene2");
static final String VERSION_ONE_SCENE = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[{\"type\":\"box\",\"x\":2,\"y\":40,\"width\":20,\"height\":10,\"thickness\":1}]}";
final List<JsonNode> jobs = new ArrayList<>();
final List<JsonNode> questions = new ArrayList<>();
final List<Integer> frameLimits = new ArrayList<>();
private final Supplier<String> version;
private final Answer answer;
/***************************************************************************
** What the worker says to one job.
***************************************************************************/
@FunctionalInterface
interface Answer
{
/************************************************************************
** The fields of the answer frame for a job the bridge sent.
************************************************************************/
Map<String, Object> to(JsonNode job) throws Exception;
}
/***************************************************************************
** A worker of a version that answers every job the same way.
***************************************************************************/
FakeWorker(String version, Answer answer)
{
this(() -> version, answer);
}
/***************************************************************************
** A worker whose image can be replaced while a bridge uses it: it answers
** the version question as the renderer that is running at that moment.
***************************************************************************/
FakeWorker(Supplier<String> version, Answer answer)
{
this.version = version;
this.answer = answer;
}
/***************************************************************************
** Keep the frame; answer the version question as this renderer would, and a
** job as the test says.
***************************************************************************/
@Override
public byte[] invoke(String requestId, byte[] input, int maximumOutput) throws Exception
{
JsonNode job = RendererJson.parse(input, 16 * 1024 * 1024);
if(IsolatedRendererBridge.PROBE_SCENE.equals(job.path("source").textValue()))
{
questions.add(job);
return (frame(validated(requestId, version.get())));
}
jobs.add(job);
frameLimits.add(maximumOutput);
return (frame(answer.to(job)));
}
/***************************************************************************
** A bridge over this exchange whose deployment admits documents of four
** megabytes, with no assets to read.
***************************************************************************/
static IsolatedRendererBridge bridge(Path directory, IsolatedRendererBridge.Exchange exchange)
{
RendererConfiguration base = BridgeTest.config(directory);
return (new IsolatedRendererBridge(new RendererConfiguration(base.dockerExecutable(), base.image(), base.imageId(), base.qualificationId(), base.requiredQualificationId(), base.ownerId(),
directory, base.renderDeadline(), 4 * 1024 * 1024, 20), (actor, asset, maximum) -> new byte[0], exchange));
}
/***************************************************************************
** One answer frame.
***************************************************************************/
static byte[] frame(Map<String, Object> fields) throws Exception
{
return ((RendererJson.JSON.writeValueAsString(fields) + "\n").getBytes(StandardCharsets.UTF_8));
}
/***************************************************************************
** The fields every answer has.
***************************************************************************/
static Map<String, Object> envelope(String requestId, String version, String outcome)
{
Map<String, Object> fields = new LinkedHashMap<>();
fields.put("protocol", RendererJson.PROTOCOL);
fields.put("requestId", requestId);
fields.put("outcome", outcome);
fields.put("rendererVersion", version);
fields.put("catalogVersion", WorkerAnswer.CATALOG);
return (fields);
}
/***************************************************************************
** A successful validation, as a renderer of this version answers it.
***************************************************************************/
static Map<String, Object> validated(String requestId, String version)
{
Map<String, Object> fields = envelope(requestId, version, "OK");
if(CURRENT.equals(version))
{
fields.put("diagnostics", List.of());
}
return (fields);
}
/***************************************************************************
** A rendered label, as a renderer of this version answers it; renderer
** 0.2.0 adds the diagnostics.
***************************************************************************/
static Map<String, Object> rendered(String requestId, String version, byte[] label, List<Map<String, Object>> diagnostics) throws Exception
{
Map<String, Object> fields = envelope(requestId, version, "OK");
fields.put("mediaType", "application/vnd.zebra-zpl");
fields.put("base64", Base64.getEncoder().encodeToString(label));
fields.put("sha256", RendererJson.hash(label));
fields.put("length", label.length);
fields.put("pageCount", 1);
if(CURRENT.equals(version))
{
fields.put("diagnostics", diagnostics);
}
return (fields);
}
/***************************************************************************
** A scene whose diagnostics were collected and hold an error: no label.
***************************************************************************/
static Map<String, Object> refused(String requestId, List<Map<String, Object>> diagnostics)
{
Map<String, Object> fields = envelope(requestId, CURRENT, "REFUSED");
fields.put("diagnostics", diagnostics);
return (fields);
}
/***************************************************************************
** An error answer, with its one diagnostic when there is one.
***************************************************************************/
static Map<String, Object> error(String requestId, String version, String code, Map<String, Object> detail)
{
Map<String, Object> fields = envelope(requestId, version, "ERROR");
fields.put("code", code);
if(detail != null)
{
fields.put("detail", detail);
}
return (fields);
}
/***************************************************************************
** One diagnostic as the wire carries it: a code, a severity and, in pairs,
** any of its other parts.
***************************************************************************/
static Map<String, Object> diagnostic(String code, String severity, Object... parts)
{
Map<String, Object> wire = new LinkedHashMap<>();
wire.put("code", code);
wire.put("severity", severity);
for(int index = 0; index < parts.length; index += 2)
{
wire.put((String) parts[index], parts[index + 1]);
}
return (wire);
}
/***************************************************************************
** The source of one of the renderer's version 2 layout cases.
***************************************************************************/
static String scene(String name) throws Exception
{
return (RendererJson.JSON.writeValueAsString(definition(name).path("scene")));
}
/***************************************************************************
** The stock of one of those cases.
***************************************************************************/
static GenerationPorts.Stock stock(String name) throws Exception
{
JsonNode stock = definition(name).path("stock");
return (new GenerationPorts.Stock(stock.path("widthMicrons").intValue(), stock.path("heightMicrons").intValue(), stock.path("topMicrons").intValue(),
stock.path("rightMicrons").intValue(), stock.path("bottomMicrons").intValue(), stock.path("leftMicrons").intValue(), stock.path("dpi").intValue()));
}
/***************************************************************************
** The golden label of a case at a resolution, as the renderer emits it.
***************************************************************************/
static byte[] golden(String name, int dpi) throws Exception
{
return (Files.readAllBytes(SCENES.resolve("golden").resolve(name + "-" + dpi + ".zpl")));
}
/***************************************************************************
** A revision whose label source is this scene on this stock.
***************************************************************************/
static GenerationPorts.Revision revision(String zplSource, GenerationPorts.Stock stock)
{
return (new GenerationPorts.Revision(1, 1, "orders", stock, null, zplSource, null, List.of()));
}
/***************************************************************************
** One case file.
***************************************************************************/
private static JsonNode definition(String name) throws Exception
{
return (RendererJson.JSON.readTree(Files.readAllBytes(SCENES.resolve("cases").resolve(name + ".json"))));
}
}
@@ -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.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.documentgeneration.DocumentDefinitionService;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitConfig;
import com.kingsrook.qbits.documentgeneration.DocumentGenerationQBitProducer;
import com.kingsrook.qbits.documentgeneration.DocumentPreviewService;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.QAuthenticationType;
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.authentication.QAuthenticationMetaData;
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.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.session.QSession;
import com.kingsrook.qqq.backend.core.model.session.QUser;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The layout preview from the module's own service down to this bridge: what a
** person sees is decoded from exactly the bytes the print path would validate
** for the same template, data and resolution.
*******************************************************************************/
class LayoutPreviewServiceTest
{
@TempDir
Path directory;
private final List<GenerationPorts.PreviewRequest> previewed = new ArrayList<>();
private DocumentGenerationQBitConfig config;
private IsolatedRendererBridge bridge;
private FakeWorker worker;
private Integer template;
/***************************************************************************
** The QBit on a memory backend with this bridge as its renderer, and one
** person who may author templates.
***************************************************************************/
@BeforeEach
void setup() throws Exception
{
MemoryRecordStore.getInstance().reset();
QInstance instance = new QInstance().withInstanceDefaultAuthentication(new QAuthenticationMetaData().withName("anonymous").withType(QAuthenticationType.FULLY_ANONYMOUS));
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)));
config = new DocumentGenerationQBitConfig().withBackendName("memory").withSourceTables(List.of("orders")).withMaximumOutputBytes(1024 * 1024)
.withRenderer(new GenerationPorts.IsolatedRenderer()
{
/***************************************************************************
** Publication is not part of this test.
***************************************************************************/
public void validate(String actor, GenerationPorts.Revision revision)
{
}
/***************************************************************************
** The print path of the bridge.
***************************************************************************/
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
{
return (bridge.render(request));
}
/***************************************************************************
** The preview of the bridge, remembering the job the service built.
***************************************************************************/
@Override
public GenerationPorts.Preview preview(GenerationPorts.PreviewRequest request) throws QException
{
previewed.add(request);
return (bridge.preview(request));
}
});
new DocumentGenerationQBitProducer().withQBitConfig(config).produce(instance);
QContext.init(instance, new QSession().withUser(new QUser().withIdReference("author-1")).withPermissions("document-generation.view", "document-generation.configure"));
}
/***************************************************************************
** Release the context and the in-memory records.
***************************************************************************/
@AfterEach
void clear()
{
QContext.clear();
MemoryRecordStore.getInstance().reset();
}
/***************************************************************************
** A draft with this scene on this stock.
***************************************************************************/
private void draft(String scene, GenerationPorts.Stock stock) throws Exception
{
DocumentDefinitionService definitions = new DocumentDefinitionService(config);
Integer type = definitions.createType("label");
Integer stockId = definitions.createStock("stock", stock);
definitions.bind(type, "orders", true, true);
template = definitions.createTemplate("label", type, stockId, "orders");
definitions.saveDraft(template, stockId, new DocumentDefinitionService.Draft(null, scene, null, List.of()));
}
/***************************************************************************
** At each resolution the preview the service returns for a version 2 scene
** with its sample data has the size and hash of exactly the bytes the print
** path returns for the same revision, context and render target, and the
** worker received the same job both times. Nothing is written to disk.
***************************************************************************/
@ParameterizedTest
@ValueSource(ints = {203, 300, 600})
void thePreviewOfAVersionTwoSceneHasTheHashOfTheBytesThePrintPathValidates(int dpi) throws Exception
{
ObjectNode scene = (ObjectNode) RendererJson.JSON.readTree(FakeWorker.scene("margins"));
scene.putObject("samples").putObject("typical").putObject("record").put("name", "A synthetic sample");
draft(RendererJson.JSON.writeValueAsString(scene), FakeWorker.stock("margins"));
worker = new FakeWorker(FakeWorker.CURRENT, job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("margins", dpi), List.of()));
bridge = FakeWorker.bridge(directory, worker);
DocumentPreviewService.Result result = new DocumentPreviewService(config).preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL,
DocumentPreviewService.Data.SAMPLE, null, null, null, dpi));
GenerationPorts.PreviewRequest request = previewed.get(0);
GenerationPorts.Content printed = bridge.render(new GenerationPorts.RenderRequest("request-1", request.actorId(), new GenerationPorts.Source("orders", "1"), request.format(),
request.revision(), request.context(), request.maximumBytes(), request.targetDpi()));
assertTrue(result.preview().printable());
assertTrue(result.preview().exact());
assertEquals(dpi, result.dpi());
assertArrayEquals(FakeWorker.golden("margins", dpi), printed.bytes());
assertEquals(RendererJson.hash(printed.bytes()), result.preview().outputSha256());
assertEquals((long) printed.bytes().length, result.preview().outputBytes());
assertEquals(1, result.preview().pages().size());
assertEquals(2, worker.jobs.size());
ObjectNode previewJob = (ObjectNode) worker.jobs.get(0);
ObjectNode printJob = (ObjectNode) worker.jobs.get(1);
assertEquals("COLLECT", previewJob.remove("diagnostics").textValue());
previewJob.remove("requestId");
printJob.remove("requestId");
assertEquals(printJob, previewJob);
assertEquals("A synthetic sample", previewJob.path("context").path("record").path("name").textValue());
assertEquals(dpi, previewJob.path("targetDpi").intValue());
try(Stream<Path> files = Files.walk(directory))
{
assertEquals(List.of(), files.filter(Files::isRegularFile).toList());
}
}
/***************************************************************************
** A version 1 label comes back through the service as a picture that is not
** exact, with the warning that printer font text is not shown.
***************************************************************************/
@Test
void aVersionOneLabelIsNotExactAndSaysWhy() throws Exception
{
byte[] label = BridgeTest.LABEL.getBytes(StandardCharsets.US_ASCII);
draft(FakeWorker.VERSION_ONE_SCENE, BridgeTest.STOCK);
worker = new FakeWorker(FakeWorker.CURRENT, job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, label, List.of()));
bridge = FakeWorker.bridge(directory, worker);
DocumentPreviewService.Result result = new DocumentPreviewService(config).preview(new DocumentPreviewService.Options(template, null, GenerationPorts.Format.ZPL,
DocumentPreviewService.Data.SAMPLE, null, null, null, null));
assertTrue(result.preview().printable());
assertFalse(result.preview().exact());
assertEquals(List.of(new Diagnostic("PRINTER_FONT_NOT_SHOWN", Diagnostic.Severity.WARNING, null, null, null, null)), result.preview().diagnostics());
assertEquals(RendererJson.hash(label), result.preview().outputSha256());
assertEquals(Map.of(), previewed.get(0).context());
assertEquals(BridgeTest.STOCK.dpi(), result.dpi());
}
}
@@ -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,404 @@
/*
* 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.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
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 three
** bounded campaigns run in the normal test phase over what the host reads from
** the worker: the printer gate, the page decoder behind it, and the answer
** frames with their diagnostics. Seeds, limits and the replay command:
** `docs/security/fuzzing.md`.
*******************************************************************************/
class MutationCampaignTest
{
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");
private static final List<String> RELEASED = List.of("box-203", "text-font0-203", "image-203", "barcode-scale-203", "raster-text-rotation-203");
private static final List<String> TOKENS = List.of("^XA", "^XZ\n", "^FO", "^GFA,", "^GB", "^FS\n", "^A0N,", "^FH_^FD", "^PW", "^LL", "^LH0,0", "^CI28\n",
"~DG", "~JR", "^XG", "^DF", "^JU", "^FX", "^FB", "99999", "0", ",", "\n", "_5E", "_7E");
private static final Pattern FIELD = Pattern.compile("\\^FO([0-9]+),([0-9]+)\\^(?:GFA,[0-9]+,[0-9]+,([0-9]+),([0-9A-F]*)|GB([0-9]+),([0-9]+),([0-9]+))\\^FS\n");
private static final String REQUEST = "request-1";
private static final List<String> DECLARED = List.of("top-left", "top-center", "top-right", "middle-left", "center", "left-of-center", "middle-right", "bottom-left", "bottom-center",
"bottom-right");
/***************************************************************************
** 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
{
MutationCampaign campaign = new MutationCampaign(released(), TOKENS);
int iterations = MutationCampaign.iterations(ITERATIONS);
for(long seed : MutationCampaign.seeds(SEEDS))
{
MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::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 released labels the printer campaigns mutate, each accepted by the
** gate for the stock it was rendered for.
***************************************************************************/
private static List<byte[]> released() throws Exception
{
List<byte[]> corpus = new ArrayList<>();
for(String name : RELEASED)
{
byte[] released = Files.readAllBytes(CORPUS.resolve(name + ".zpl"));
ZplOutputPolicy.validate(released, STOCK);
corpus.add(released);
}
return (corpus);
}
/***************************************************************************
** Property: the page decoder never fails on a label the printer gate
** accepts, and what it draws for one is exactly the label's graphic fields
** and boxes as a second, independent reading draws them. Handed bytes the
** gate refuses, as it never is in production, it draws or refuses by its
** own IllegalArgumentException and does nothing else. Any other failure is
** a finding.
***************************************************************************/
@Test
void thePageDecoderDrawsExactlyWhatThePrinterGateAccepts() throws Exception
{
MutationCampaign campaign = new MutationCampaign(released(), TOKENS);
int iterations = MutationCampaign.iterations(ITERATIONS);
for(long seed : MutationCampaign.seeds(SEEDS))
{
MutationCampaign.Result result = campaign.run(seed, iterations, MutationCampaignTest::page,
failure -> failure instanceof IllegalArgumentException || failure instanceof CharacterCodingException);
assertEquals(List.of(), result.findings(), "replay with -Dfuzz.seed=" + seed + " -Dfuzz.iterations=" + iterations);
assertFalse(result.accepted() == 0 || result.refused() == 0, "the campaign must reach both acceptance and refusal: " + result);
}
}
/***************************************************************************
** The decoder beside the gate, and an independent drawing of what the gate
** let through.
***************************************************************************/
private static void page(byte[] zpl) throws Exception
{
try
{
ZplOutputPolicy.validate(zpl, STOCK);
}
catch(IllegalArgumentException | CharacterCodingException refused)
{
try
{
ZplRaster page = ZplRaster.decode(zpl);
assertTrue((long) page.width() * page.height() <= ZplRaster.MAXIMUM_DOTS, "drew a page past the limit");
}
catch(IllegalArgumentException alsoRefused)
{
// The decoder's own refusal of bytes the gate refused.
}
throw refused;
}
ZplRaster page;
try
{
page = ZplRaster.decode(zpl);
}
catch(IllegalArgumentException refused)
{
throw new AssertionError("the decoder refused a label the gate accepts: " + refused.getMessage());
}
assertEquals(609, page.width());
assertEquals(406, page.height());
String text = new String(zpl, StandardCharsets.US_ASCII);
assertEquals(!text.contains("^A0N,"), page.exact());
if(page.exact())
{
boolean[][] expected = new boolean[406][609];
Matcher field = FIELD.matcher(text);
while(field.find())
{
int x = Integer.parseInt(field.group(1));
int y = Integer.parseInt(field.group(2));
if(field.group(3) != null)
{
int stride = Integer.parseInt(field.group(3));
byte[] raster = HexFormat.of().parseHex(field.group(4));
for(int index = 0; index < raster.length * 8; index++)
{
if((raster[index / 8] & (128 >> index % 8)) != 0)
{
expected[y + index / 8 / stride][x + index / 8 % stride * 8 + index % 8] = true;
}
}
}
else
{
int width = Integer.parseInt(field.group(5));
int height = Integer.parseInt(field.group(6));
int thickness = Integer.parseInt(field.group(7));
for(int row = 0; row < height; row++)
{
for(int column = 0; column < width; column++)
{
expected[y + row][x + column] |= row < thickness || column < thickness || row >= height - thickness || column >= width - thickness;
}
}
}
}
for(int y = 0; y < 406; y++)
{
for(int x = 0; x < 609; x++)
{
if(expected[y][x] != page.ink(x, y))
{
throw new AssertionError("the decoder and the independent drawing differ at dot " + x + ", " + y);
}
}
}
}
}
/***************************************************************************
** Property: whatever the answer reader accepts for a collected render of a
** version 2 scene is an answer of renderer 0.2.0 that is either a label with
** no error or a refusal with no label, and every diagnostic it carries names
** only an element the scene declares and points only through array indexes,
** schema property names and declared ids, as a second and plain JSON reader
** sees it. A typed refusal is held to the same rule. Everything else is
** rejected with the reader's IllegalArgumentException or the JSON reader's
** IOException; any other failure is a finding.
***************************************************************************/
@Test
void theAnswerReaderBelievesOnlyDiagnosticsOfTheSceneThatWasSent() throws Exception
{
byte[] label = "^XA^PW406^LL203^LH0,0^CI28\n^FO2,40^GFA,2,2,1,AA80^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII);
Map<String, Object> error = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "pointer", "/elements/4", "box", List.of(171, 68, 47, 23));
Map<String, Object> warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "top-left", "pointer", "/overrides/0/set/top-left/x", "measured", Map.of("widthDots", 902, "lines", 2),
"limit", Map.of("widthDots", 764));
List<byte[]> corpus = List.of(FakeWorker.frame(FakeWorker.rendered(REQUEST, FakeWorker.CURRENT, label, List.of(warning))), FakeWorker.frame(FakeWorker.refused(REQUEST, List.of(warning, error))),
FakeWorker.frame(FakeWorker.error(REQUEST, FakeWorker.CURRENT, "RENDERER_TOO_OLD", error)), FakeWorker.frame(FakeWorker.rendered(REQUEST, FakeWorker.CURRENT, label, List.of())),
FakeWorker.frame(FakeWorker.rendered(REQUEST, FakeWorker.RELEASED, label, List.of())), FakeWorker.frame(FakeWorker.error(REQUEST, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", null)));
MutationCampaign campaign = new MutationCampaign(corpus, List.of("\"diagnostics\":", "\"detail\":", "\"REFUSED\"", "\"OK\"", "\"ERROR\"", "\"WARNING\"", "\"element\":\"", "\"pointer\":\"/",
"/elements/0", "/set/", "\"box\":[", "\"measured\":{", "\"limit\":{", "\"base64\":\"", "\"code\":\"", "\"severity\":\"", "top-left", "center", "undeclared", "secret", "qqq-renderer-0.1.0",
"qqq-renderer-0.2.0", "RENDER_FAILED", "TEXT_DOES_NOT_FIT", "SCENE_INVALID", "[", "]", "{", "}", ",", ":", "\"", "\n", "0", "-1", "2147483648"));
SceneFacts facts = SceneFacts.of(GenerationPorts.Format.ZPL, FakeWorker.scene("anchors"));
DECLARED.forEach(id -> assertTrue(facts.declares(id), id));
int iterations = MutationCampaign.iterations(ITERATIONS);
for(long seed : MutationCampaign.seeds(SEEDS))
{
MutationCampaign.Result result = campaign.run(seed, iterations, input -> answer(input, facts),
failure -> failure instanceof IllegalArgumentException || failure instanceof java.io.IOException || failure instanceof GenerationPorts.RenderException);
assertEquals(List.of(), result.findings(), "replay with -Dfuzz.seed=" + seed + " -Dfuzz.iterations=" + iterations);
assertFalse(result.accepted() == 0 || result.refused() == 0, "the campaign must reach both acceptance and refusal: " + result);
}
}
/***************************************************************************
** The reader, and an independent reading of what it let through.
***************************************************************************/
private static void answer(byte[] frame, SceneFacts facts) throws Exception
{
WorkerAnswer answer;
try
{
answer = WorkerAnswer.read(frame, 65536, REQUEST, facts).settle("RENDER", true, true);
}
catch(GenerationPorts.RenderException refusal)
{
if(refusal.diagnostic() != null)
{
ofTheScene(refusal.diagnostic().element(), refusal.diagnostic().pointer());
}
throw refusal;
}
JsonNode tree = new ObjectMapper().readTree(frame);
assertEquals("qqq-renderer-0.2.0", tree.path("rendererVersion").textValue(), "accepted an answer of another renderer");
boolean refused = tree.path("outcome").textValue().equals("REFUSED");
assertTrue(refused || tree.path("outcome").textValue().equals("OK"), "accepted an outcome it does not know");
assertEquals(refused, answer.refused());
boolean errors = false;
for(JsonNode diagnostic : tree.path("diagnostics"))
{
errors |= diagnostic.path("severity").textValue().equals("ERROR");
diagnostic.fieldNames().forEachRemaining(name -> assertTrue(Set.of("code", "severity", "element", "pointer", "box", "measured", "limit").contains(name), "accepted a diagnostic field: " + name));
ofTheScene(diagnostic.path("element").textValue(), diagnostic.path("pointer").textValue());
}
assertEquals(refused, errors, "an answer is refused exactly when it holds an error");
for(String part : List.of("mediaType", "base64", "sha256", "length", "pageCount"))
{
assertEquals(!refused, tree.has(part), "a refused answer carries no label and a label is whole: " + part);
}
assertEquals(tree.path("diagnostics").size(), answer.diagnostics().size());
}
/***************************************************************************
** An element is one the scene declares, and a pointer is made of array
** indexes, property names of the schema and declared ids.
***************************************************************************/
private static void ofTheScene(String element, String pointer)
{
assertTrue(element == null || DECLARED.contains(element), "accepted an element the scene does not declare: " + element);
if(pointer != null && !pointer.isEmpty())
{
assertTrue(pointer.startsWith("/"), pointer);
for(String segment : pointer.substring(1).split("/", -1))
{
assertTrue(segment.matches("0|[1-9][0-9]{0,3}") || SceneFacts.VOCABULARY.contains(segment) || DECLARED.contains(segment), "accepted a pointer segment: " + segment);
}
}
}
/***************************************************************************
** The target, and an independent reading of what it let through.
***************************************************************************/
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,435 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The preview is the print path stopped before retention: the same job, the
** same checks on the label, and a picture decoded from the accepted bytes. A
** layout that cannot print comes back as diagnostics, with a page drawn from
** their boxes and no output at all.
*******************************************************************************/
class PreviewTest
{
private static final String SECRET = "a-record-value-that-must-not-travel";
private static final String NOTHING = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
private static final int MAXIMUM = 1024 * 1024;
@TempDir
Path directory;
/***************************************************************************
** A preview job for a case of the renderer's version 2 goldens.
***************************************************************************/
private static GenerationPorts.PreviewRequest preview(String name, Integer targetDpi) throws Exception
{
return (new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene(name), FakeWorker.stock(name)), Map.of("name", SECRET), targetDpi,
MAXIMUM));
}
/***************************************************************************
** A worker of renderer 0.2.0 that answers every job with these fields.
***************************************************************************/
private static FakeWorker answering(FakeWorker.Answer answer)
{
return (new FakeWorker(FakeWorker.CURRENT, answer));
}
/***************************************************************************
** The files under the adapter's state directory.
***************************************************************************/
private List<Path> files() throws Exception
{
try(Stream<Path> files = Files.walk(directory))
{
return (files.filter(Files::isRegularFile).toList());
}
}
/***************************************************************************
** At each resolution the preview of a layout that prints is the golden
** picture, dot for dot, with the size and hash of exactly the bytes the
** print path returns for the same job. The two jobs differ only in their
** request id and in asking for collected diagnostics. Nothing is written.
***************************************************************************/
@ParameterizedTest
@ValueSource(ints = {203, 300, 600})
void aPreviewIsThePrintPathStoppedBeforeRetention(int dpi) throws Exception
{
Map<String, Object> warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "side", "pointer", "/elements/4/x");
FakeWorker worker = answering(job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("margins", dpi),
job.has("diagnostics") ? List.of(warning) : List.of()));
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
GenerationPorts.PreviewRequest request = preview("margins", dpi);
GenerationPorts.Preview preview = bridge.preview(request);
GenerationPorts.Content printed = bridge.render(new GenerationPorts.RenderRequest("request-1", request.actorId(), new GenerationPorts.Source("orders", "1"), request.format(), request.revision(),
request.context(), request.maximumBytes(), request.targetDpi()));
assertTrue(preview.printable());
assertTrue(preview.exact());
assertEquals(List.of(new Diagnostic("DEVICE_UNIT_USED", Diagnostic.Severity.WARNING, "side", null, null, null, "/elements/4/x")), preview.diagnostics());
assertArrayEquals(FakeWorker.golden("margins", dpi), printed.bytes());
assertEquals((long) printed.bytes().length, preview.outputBytes());
assertEquals(RendererJson.hash(printed.bytes()), preview.outputSha256());
BufferedImage golden = ImageIO.read(FakeWorker.SCENES.resolve("golden").resolve("margins-" + dpi + ".png").toFile());
assertEquals(1, preview.pages().size());
assertEquals(golden.getWidth(), preview.pages().get(0).widthDots());
assertEquals(golden.getHeight(), preview.pages().get(0).heightDots());
BufferedImage page = ImageIO.read(new ByteArrayInputStream(preview.pages().get(0).png()));
for(int y = 0; y < golden.getHeight(); y++)
{
for(int x = 0; x < golden.getWidth(); x++)
{
assertEquals(golden.getRGB(x, y) & 0xFFFFFF, page.getRGB(x, y) & 0xFFFFFF);
}
}
assertEquals(2, worker.jobs.size());
ObjectNode previewed = (ObjectNode) worker.jobs.get(0);
ObjectNode rendered = (ObjectNode) worker.jobs.get(1);
assertEquals("COLLECT", previewed.remove("diagnostics").textValue());
assertTrue(previewed.remove("requestId").textValue().matches("[0-9a-f-]{36}"));
assertEquals("request-1", rendered.remove("requestId").textValue());
assertEquals(rendered, previewed);
assertEquals("RENDER", previewed.path("operation").textValue());
assertEquals(dpi, previewed.path("targetDpi").intValue());
assertEquals(SECRET, previewed.path("context").path("name").textValue());
assertEquals(List.of(), files());
}
/***************************************************************************
** A scene with a refused element has no label: the worker sends none and
** the preview has no output. Its page is drawn on the host from the boxes of
** the error diagnostics alone, placed from the printable area's corner as
** the renderer places it (the corner of the golden label at each
** resolution), cut to the page, outlined and crossed.
***************************************************************************/
@ParameterizedTest
@ValueSource(ints = {203, 300, 600})
void aRefusedSceneIsDrawnFromItsDiagnosticsAndHasNoOutput(int dpi) throws Exception
{
Matcher corner = Pattern.compile("\\^FO([0-9]+),([0-9]+)\\^GFA").matcher(new String(FakeWorker.golden("margins", dpi), StandardCharsets.US_ASCII));
assertTrue(corner.find());
int left = Integer.parseInt(corner.group(1));
int top = Integer.parseInt(corner.group(2));
List<Map<String, Object>> diagnostics = List.of(
FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "first", "box", List.of(0, 0, 16, 12)),
FakeWorker.diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", "ERROR", "element", "last", "box", List.of(-100, 30, 120, 100_000)),
FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "side", "box", List.of(100, 0, 50, 50)),
FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "middle", "pointer", "/elements/3/thickness"));
FakeWorker worker = answering(job -> FakeWorker.refused(job.path("requestId").textValue(), diagnostics));
GenerationPorts.Preview preview = FakeWorker.bridge(directory, worker).preview(preview("margins", dpi));
assertFalse(preview.printable());
assertFalse(preview.exact());
assertEquals(0L, preview.outputBytes());
assertEquals(NOTHING, preview.outputSha256());
assertEquals(4, preview.diagnostics().size());
assertEquals(new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, "first", List.of(0, 0, 16, 12), null, null), preview.diagnostics().get(0));
assertEquals(1, preview.pages().size());
BufferedImage golden = ImageIO.read(FakeWorker.SCENES.resolve("golden").resolve("margins-" + dpi + ".png").toFile());
BufferedImage page = ImageIO.read(new ByteArrayInputStream(preview.pages().get(0).png()));
assertEquals(golden.getWidth(), page.getWidth());
assertEquals(golden.getHeight(), page.getHeight());
for(int y = 0; y < page.getHeight(); y++)
{
for(int x = 0; x < page.getWidth(); x++)
{
boolean first = ZplRasterTest.crossed(x - left, y - top, 16, 12);
boolean last = ZplRasterTest.crossed(x, y - top - 30, left + 20, page.getHeight() - top - 30);
assertEquals(first || last, (page.getRGB(x, y) & 0xFFFFFF) == 0, x + ", " + y);
}
}
assertEquals("COLLECT", worker.jobs.get(0).path("diagnostics").textValue());
assertEquals(List.of(), files());
}
/***************************************************************************
** A refusal is a result of a preview, not an exception: whatever the print
** path would refuse by name comes back as the diagnostic, not printable,
** with no output, and with no page when no diagnostic has a box.
***************************************************************************/
@Test
void aRefusalComesBackAsADiagnostic() throws Exception
{
FakeWorker invalid = answering(job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "SCENE_INVALID", FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", "/design")));
GenerationPorts.Preview refused = FakeWorker.bridge(directory, invalid).preview(preview("anchors", null));
assertFalse(refused.printable());
assertEquals(List.of(new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, null, null, null, null, "/design")), refused.diagnostics());
assertEquals(List.of(), refused.pages());
assertEquals(0L, refused.outputBytes());
assertEquals(NOTHING, refused.outputSha256());
FakeWorker fit = answering(job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "BARCODE_FIT_TOO_SMALL", null));
var versionOne = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, FakeWorker.stock("anchors")), Map.of(), null, MAXIMUM);
assertEquals(List.of(new Diagnostic("BARCODE_FIT_TOO_SMALL", Diagnostic.Severity.ERROR, null, null, null, null)), FakeWorker.bridge(directory, fit).preview(versionOne).diagnostics());
///////////////////////////////////////////////////////////////////////////////////////////
// a version 1 scene previews only at its stock's resolution; no worker is contacted //
///////////////////////////////////////////////////////////////////////////////////////////
var elsewhere = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, versionOne.revision(), Map.of(), 300, MAXIMUM);
GenerationPorts.Preview target = FakeWorker.bridge(directory, fit).preview(elsewhere);
assertEquals(List.of(new Diagnostic("TARGET_REQUIRES_SCENE_2", Diagnostic.Severity.ERROR, null, null, Map.of("dpi", 300), Map.of("dpi", 203))), target.diagnostics());
assertFalse(target.printable());
assertEquals(1, fit.jobs.size());
///////////////////////////////////////////////////////////////////////////////////////////
// a refused scene whose diagnostics have no box has no page //
///////////////////////////////////////////////////////////////////////////////////////////
FakeWorker whole = answering(job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(FakeWorker.diagnostic("STOCK_MISMATCH", "ERROR", "pointer", "/design"))));
assertEquals(List.of(), FakeWorker.bridge(directory, whole).preview(preview("anchors", null)).pages());
}
/***************************************************************************
** An old image cannot preview: the answer is `RENDERER_TOO_OLD`, and the
** worker was asked its version and sent nothing else, for a version 2 scene
** and for a version 1 scene alike.
***************************************************************************/
@Test
void anOldRendererCannotPreviewAndIsSentNothing() throws Exception
{
FakeWorker old = new FakeWorker(FakeWorker.RELEASED, job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.RELEASED, FakeWorker.golden("anchors", 203), List.of()));
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, old);
var versionOne = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, FakeWorker.stock("anchors")), Map.of("name", SECRET),
null, MAXIMUM);
for(GenerationPorts.PreviewRequest request : List.of(preview("anchors", null), preview("anchors", 300), versionOne))
{
GenerationPorts.Preview preview = bridge.preview(request);
assertFalse(preview.printable());
assertEquals(List.of(new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, null, null, null, null)), preview.diagnostics());
assertEquals(List.of(), preview.pages());
}
assertEquals(List.of(), old.jobs);
assertEquals(3, old.questions.size());
assertFalse(old.questions.toString().contains(SECRET));
}
/***************************************************************************
** A page of more than sixteen million dots is refused before any worker is
** contacted, with its size and the limit.
***************************************************************************/
@Test
void aPreviewTooLargeToDrawIsRefusedBeforeRendering() throws Exception
{
FakeWorker worker = answering(job -> FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT));
var large = new GenerationPorts.Stock(203200, 304800, 0, 0, 0, 0, 203);
var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), large), Map.of(), 600, MAXIMUM);
GenerationPorts.Preview preview = FakeWorker.bridge(directory, worker).preview(request);
assertFalse(preview.printable());
assertEquals(List.of(new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, Map.of("widthDots", 4800, "heightDots", 7200), Map.of("dots", 16_000_000))), preview.diagnostics());
assertEquals(List.of(), preview.pages());
assertEquals(List.of(), worker.jobs);
assertEquals(List.of(), worker.questions);
}
/***************************************************************************
** A label the output policy accepts can still hold thousands of boxes that
** each cover the page. It is not drawn dot by dot while an admission slot is
** held: a label that would paint more than sixteen times its page comes
** back at once as `PREVIEW_TOO_LARGE`, a result with the limit and no page.
** Printing the same label is not changed by it.
***************************************************************************/
@Test
void aLabelThatWouldPaintTooMuchIsRefusedAsAResult() throws Exception
{
GenerationPorts.Stock stock = new GenerationPorts.Stock(101600, 152400, 0, 0, 0, 0, 600);
byte[] label = ("^XA^PW2400^LL3600^LH0,0^CI28\n" + "^FO0,0^GB2400,3600,1200^FS\n".repeat(6000) + "^XZ\n").getBytes(StandardCharsets.US_ASCII);
FakeWorker worker = answering(job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, label, List.of()));
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, stock), Map.of(), null, MAXIMUM);
GenerationPorts.Preview preview = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> bridge.preview(request));
assertFalse(preview.printable());
assertFalse(preview.exact());
assertEquals(List.of(new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, null, Map.of("paintedPercent", 1600))), preview.diagnostics());
assertEquals(List.of(), preview.pages());
assertEquals(0L, preview.outputBytes());
assertEquals(NOTHING, preview.outputSha256());
assertEquals(1, worker.jobs.size());
assertArrayEquals(label, bridge.render(new GenerationPorts.RenderRequest("request-1", "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, request.revision(), Map.of(),
MAXIMUM)).bytes());
}
/***************************************************************************
** The page of a refused scene is bounded too. On a narrow stock of 8 x
** 15984 dots a box of the whole page costs 63948 dots of outline and
** diagonals, so 31 such boxes are within sixteen times the page and are
** drawn; with 32 there is no page, and `PREVIEW_TOO_LARGE` joins the
** diagnostics the worker sent.
***************************************************************************/
@Test
void aRefusedSceneWithTooManyLargeBoxesHasNoPage() throws Exception
{
GenerationPorts.Stock narrow = new GenerationPorts.Stock(1000, 2000000, 0, 0, 0, 0, 203);
var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), narrow), Map.of(), null, MAXIMUM);
Map<String, Object> whole = FakeWorker.diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", "ERROR", "box", List.of(0, 0, 8, 15984));
Diagnostic read = new Diagnostic("ELEMENT_OUTSIDE_PRINTABLE_AREA", Diagnostic.Severity.ERROR, null, List.of(0, 0, 8, 15984), null, null);
GenerationPorts.Preview drawn = FakeWorker.bridge(directory, answering(job -> FakeWorker.refused(job.path("requestId").textValue(), Collections.nCopies(31, whole)))).preview(request);
assertEquals(1, drawn.pages().size());
assertEquals(8, drawn.pages().get(0).widthDots());
assertEquals(15984, drawn.pages().get(0).heightDots());
assertEquals(Collections.nCopies(31, read), drawn.diagnostics());
GenerationPorts.Preview undrawn = FakeWorker.bridge(directory, answering(job -> FakeWorker.refused(job.path("requestId").textValue(), Collections.nCopies(32, whole)))).preview(request);
List<Diagnostic> expected = new ArrayList<>(Collections.nCopies(32, read));
expected.add(new Diagnostic("PREVIEW_TOO_LARGE", Diagnostic.Severity.ERROR, null, null, null, Map.of("paintedPercent", 1600)));
assertEquals(expected, undrawn.diagnostics());
assertEquals(List.of(), undrawn.pages());
assertFalse(undrawn.printable());
assertFalse(undrawn.exact());
assertEquals(0L, undrawn.outputBytes());
assertEquals(NOTHING, undrawn.outputSha256());
}
/***************************************************************************
** A version 1 label previews with its graphics and boxes exact and each
** printer font field as a placeholder: the page is not exact and says why.
***************************************************************************/
@Test
void aVersionOneLabelIsPreviewedWithPlaceholdersForPrinterFontText() throws Exception
{
byte[] label = BridgeTest.LABEL.getBytes(StandardCharsets.US_ASCII);
FakeWorker worker = answering(job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, label, List.of()));
var request = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, BridgeTest.STOCK), Map.of(), null, MAXIMUM);
GenerationPorts.Preview preview = FakeWorker.bridge(directory, worker).preview(request);
assertTrue(preview.printable());
assertFalse(preview.exact());
assertEquals(List.of(new Diagnostic("PRINTER_FONT_NOT_SHOWN", Diagnostic.Severity.WARNING, null, null, null, null)), preview.diagnostics());
assertEquals(812, preview.pages().get(0).widthDots());
assertEquals(1218, preview.pages().get(0).heightDots());
assertEquals(RendererJson.hash(label), preview.outputSha256());
assertFalse(worker.jobs.get(0).has("targetDpi"));
}
/***************************************************************************
** Only a label that passed every check of the print path is drawn. An answer
** that is not what the version rule allows, a label the output policy
** refuses, an opaque failure and a job outside the adapter's limits are all
** the same generic refusal, never a picture.
***************************************************************************/
@Test
void onlyAnAcceptedLabelBecomesAPicture() throws Exception
{
byte[] golden = FakeWorker.golden("anchors", 203);
Map<String, Object> error = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "box", List.of(0, 0, 10, 10));
List<FakeWorker.Answer> answers = List.of(
job ->
{
Map<String, Object> partial = FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, golden, List.of(error));
partial.put("outcome", "REFUSED");
return (partial);
},
job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, golden, List.of(error)),
job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, (new String(golden, StandardCharsets.US_ASCII) + "~JR").getBytes(StandardCharsets.US_ASCII), List.of()),
job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 300), List.of()),
job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "RENDER_FAILED", null),
job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "undeclared", "box", List.of(0, 0, 10, 10)))),
job -> FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT));
for(FakeWorker.Answer answer : answers)
{
QException failure = assertThrows(QException.class, () -> FakeWorker.bridge(directory, answering(answer)).preview(preview("anchors", null)));
assertEquals(QException.class, failure.getClass());
assertEquals("Isolated renderer rejected or could not complete the request.", failure.getMessage());
}
///////////////////////////////////////////////////////////////////////////////////////////
// the print path, which never collects, rejects a refused answer outright //
///////////////////////////////////////////////////////////////////////////////////////////
IsolatedRendererBridge printing = FakeWorker.bridge(directory, answering(job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(error))));
var render = new GenerationPorts.RenderRequest("request-1", "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, preview("anchors", null).revision(), Map.of(), MAXIMUM);
assertEquals(QException.class, assertThrows(QException.class, () -> printing.render(render)).getClass());
FakeWorker unused = answering(job -> FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT));
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, unused);
var revision = preview("anchors", null).revision();
var page = new GenerationPorts.Revision(1, 1, "orders", revision.stock(), "<p>a page</p>", null, null, List.of());
assertEquals("Preview is available for labels only.",
assertThrows(QException.class, () -> bridge.preview(new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, page, Map.of(), null, MAXIMUM))).getMessage());
var coarse = new GenerationPorts.Stock(50800, 25400, 0, 0, 0, 0, 150);
var tall = new GenerationPorts.Stock(25400, 1600000, 0, 0, 0, 0, 203);
var negative = new GenerationPorts.Stock(50800, 25400, -1, 0, 0, 0, 203);
for(GenerationPorts.PreviewRequest outside : List.of(
new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, revision, Map.of(), null, 4 * 1024 * 1024 + 1),
new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), coarse), Map.of(), null, MAXIMUM),
new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), tall), Map.of(), 600, MAXIMUM),
new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), negative), Map.of(), null, MAXIMUM),
new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, FakeWorker.revision(FakeWorker.scene("anchors"), revision.stock()), Map.of("object", Path.of("private")), null, MAXIMUM)))
{
assertEquals("Isolated renderer rejected or could not complete the request.", assertThrows(QException.class, () -> bridge.preview(outside)).getMessage());
}
assertEquals(List.of(), unused.jobs);
assertEquals(List.of(), unused.questions);
}
}
@@ -0,0 +1,177 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** What the host reads from a label source (its version and the ids it
** declares), and the version rule's two written-out sets of refusals.
*******************************************************************************/
class SceneFactsTest
{
private static final String SCENE = "{\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"samples\":{\"default\":{\"id\":\"sampled\",\"elements\":[{\"id\":\"nested\"}]}},"
+ "\"elements\":[7,{\"id\":\"caption\",\"type\":\"text\",\"fit\":{\"id\":\"inside\"}},{\"id\":\"panel\",\"type\":\"group\",\"children\":[{\"id\":\"inner\",\"type\":\"box\"},[]]},"
+ "{\"id\":7},{\"id\":\"not an id\"},{\"children\":\"none\"}],\"overrides\":[{\"when\":{\"dpi\":[203]},\"set\":{\"overridden\":{\"id\":\"renamed\"}}}],\"schema\":\"qqq-zpl-scene/2\"}";
/***************************************************************************
** The property names the host admits in a pointer are exactly the names the
** version 2 schema declares, wherever in the schema they are declared.
***************************************************************************/
@Test
void theVocabularyIsThePropertyNamesOfTheSchema() throws Exception
{
Set<String> declared = new TreeSet<>();
names(RendererJson.JSON.readTree(Files.readAllBytes(Path.of("..", "..", "renderer", "schema", "zpl-scene-2.schema.json"))), declared);
assertEquals(declared, new TreeSet<>(SceneFacts.VOCABULARY));
assertEquals(43, SceneFacts.VOCABULARY.size());
}
/***************************************************************************
** Every key of every `properties` object in a schema.
***************************************************************************/
private static void names(JsonNode node, Set<String> names)
{
for(Map.Entry<String, JsonNode> property : node.properties())
{
if(property.getKey().equals("properties") && property.getValue().isObject())
{
property.getValue().fieldNames().forEachRemaining(names::add);
}
names(property.getValue(), names);
}
if(node.isArray())
{
node.forEach(item -> names(item, names));
}
}
/***************************************************************************
** A version 2 scene declares the ids of its elements and of their children,
** and nothing else: not a sample's, an override's or a property's `id`, not
** an id of the wrong shape.
***************************************************************************/
@Test
void aSceneDeclaresTheIdsOfItsElementsAndTheirChildrenOnly()
{
SceneFacts facts = SceneFacts.of(GenerationPorts.Format.ZPL, SCENE);
assertTrue(facts.version2());
for(String id : List.of("caption", "panel", "inner"))
{
assertTrue(facts.declares(id), id);
}
for(String id : List.of("sampled", "nested", "inside", "overridden", "renamed", "7", "not an id", "Caption", ""))
{
assertFalse(facts.declares(id), id);
}
assertTrue(facts.points(""));
assertTrue(facts.points("/elements/2/children/0/width"));
assertTrue(facts.points("/overrides/0/set/caption/size"));
assertTrue(facts.points("/elements/9999"));
for(String pointer : List.of("elements", "/elements/sampled", "/elements/0/", "//", "/elements/10000", "/elements/0/Width", "/elements/-1"))
{
assertFalse(facts.points(pointer), pointer);
}
}
/***************************************************************************
** Anything the host cannot read as a version 2 scene is not one to the host
** and declares nothing: another schema, a PDF, text that is not one JSON
** object, a repeated property, too deep a nesting, too many elements.
***************************************************************************/
@Test
void aSourceThatIsNotAReadableVersionTwoSceneDeclaresNothing()
{
StringBuilder crowded = new StringBuilder("{\"schema\":\"qqq-zpl-scene/2\",\"elements\":[");
for(int index = 0; index < 1001; index++)
{
crowded.append(index == 0 ? "" : ",").append("{\"id\":\"e").append(index).append("\"}");
}
String full = crowded + "]}";
for(String source : List.of(SCENE.replace("qqq-zpl-scene/2", "qqq-zpl-scene/1"), SCENE.replace("\"qqq-zpl-scene/2\"", "2"), "{\"elements\":[{\"id\":\"caption\"}]}", "{}", "[]", "",
"not json", SCENE + "{}", SCENE.substring(0, SCENE.length() - 1), SCENE.replace("\"schema\"", "\"design\""), "{\"schema\":\"qqq-zpl-scene/2\",\"elements\":" + "[".repeat(30) + "]".repeat(30) + "}",
full))
{
SceneFacts facts = SceneFacts.of(GenerationPorts.Format.ZPL, source);
assertFalse(facts.version2(), source);
assertFalse(facts.declares("caption"), source);
assertFalse(facts.declares("e1"), source);
}
assertFalse(SceneFacts.of(GenerationPorts.Format.PDF, SCENE).version2());
assertTrue(SceneFacts.of(GenerationPorts.Format.ZPL, full.replace(",{\"id\":\"e1000\"}", "")).declares("e999"));
assertTrue(SceneFacts.of(GenerationPorts.Format.ZPL, "{\"schema\":\"qqq-zpl-scene/2\"}").version2());
}
/***************************************************************************
** The version rule's sets are written out: the released renderer names the
** thirteen codes it shipped with, the current one those and every scene
** refusal. A code added to the port later is in neither until someone
** decides which renderer may name it, and this test says so.
***************************************************************************/
@Test
void eachRendererNamesExactlyItsOwnRefusals()
{
List<String> released = List.of("BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_UNSUPPORTED", "BARCODE_FIT_BOX_OUTSIDE_STOCK", "BARCODE_FIT_GEOMETRY_MISMATCH", "INVALID_BARCODE_FIT_PROFILE",
"INVALID_BARCODE_FIT_REQUEST", "BARCODE_SYMBOLOGY_NOT_ALLOWED", "SYMBOLOGY_ALLOWLIST_REQUIRED", "SCANNER_BARCODE_PROFILE_UNSUPPORTED", "SCANNER_BARCODE_PAYLOAD_UNSUPPORTED",
"SCANNER_BARCODE_CHECKSUM_REQUIRED", "SCANNER_BARCODE_CHECKSUM_MISMATCH", "UNSUPPORTED_BARCODE_OPTION");
assertEquals(13, released.size());
for(FailureCode code : FailureCode.values())
{
assertEquals(released.contains(code.name()), RendererVersion.RELEASED.names(code), code.name());
assertTrue(RendererVersion.CURRENT.names(code), code.name());
}
assertSame(RendererVersion.RELEASED, RendererVersion.of("qqq-renderer-0.1.0"));
assertSame(RendererVersion.CURRENT, RendererVersion.of("qqq-renderer-0.2.0"));
assertFalse(RendererVersion.RELEASED.structured());
assertTrue(RendererVersion.CURRENT.structured());
for(String other : List.of("qqq-renderer-0.3.0", "qqq-renderer-0.2.0 ", "qqq-renderer-0.1", "0.2.0", ""))
{
assertNull(RendererVersion.of(other), other);
}
assertNull(RendererVersion.of(null));
}
}
@@ -0,0 +1,378 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.junit.jupiter.api.io.TempDir;
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.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;
/*******************************************************************************
** What today's bridge does with an answer to a version 1 print job: one row
** for each answer, every row through `render`.
**
** What this is, and what it is not. The answers are synthetic: the fake worker
** of these tests builds each one in the shape the released renderer
** (`qqq-renderer-0.1.0`) answers with, or in that shape with one stated
** change. None was captured from the released renderer. The expected outcome
** of each row is what the bridge of this branch does with that answer, written
** down when the table was made. No row was run against the released bridge
** (tag `v1.1.0-RC.2`), so the table does not show that today's bridge treats
** an answer as the released one did. It holds today's behaviour still: a
** change to what the bridge accepts, refuses or rejects fails a row. The one
** artefact captured at the released tag is the job frame, which
** `VersionOneJobFrameTest` compares.
**
** `render` has three outcomes and no other: the label, a typed refusal with a
** code of the closed vocabulary, or the one generic rejection, which names
** nothing. Each row states which. Beside it the row states the name the
** version rule (`WorkerAnswer`) gives the same frame for the same job, or `-`
** when the rule passes it; a row that the rule passes and `render` rejects was
** rejected by the checks on the document that follow the rule (its size and
** hash, its metadata, the output policy), whose names never leave the bridge.
*******************************************************************************/
class VersionOneAnswerTableTest
{
private static final String ACCEPTED = "ACCEPTED";
private static final String REJECTED = "REJECTED";
private static final String PASSES = "-";
private static final String REQUEST_ID = "request-1";
private static final int MAXIMUM = 4096;
private static final byte[] LABEL = "^XA^PW812^LL1218^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] ELSEWHERE = "^XA^PW406^LL203^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] COMMAND = "^XA^PW812^LL1218^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n~JR".getBytes(StandardCharsets.US_ASCII);
@TempDir
Path directory;
/***************************************************************************
** The frame a worker answers a request with.
***************************************************************************/
@FunctionalInterface
interface Frame
{
/************************************************************************
** The bytes of the answer to the request of this id.
************************************************************************/
byte[] to(String requestId) throws Exception;
}
/***************************************************************************
** A row whose answer is the well-formed answer of the released renderer
** with one change to its fields.
***************************************************************************/
private static Arguments changed(String name, String outcome, String rule, Consumer<Map<String, Object>> change)
{
Frame frame = id ->
{
Map<String, Object> fields = FakeWorker.rendered(id, FakeWorker.RELEASED, LABEL, List.of());
change.accept(fields);
return (FakeWorker.frame(fields));
};
return (Arguments.of(name, frame, outcome, rule));
}
/***************************************************************************
** A row whose answer is the well-formed answer for another document.
***************************************************************************/
private static Arguments document(String name, String outcome, String rule, byte[] label)
{
return (Arguments.of(name, (Frame) id -> FakeWorker.frame(FakeWorker.rendered(id, FakeWorker.RELEASED, label, List.of())), outcome, rule));
}
/***************************************************************************
** A row whose answer is an error answer of the released renderer, with one
** change to its fields.
***************************************************************************/
private static Arguments error(String name, String outcome, String rule, String code, Consumer<Map<String, Object>> change)
{
Frame frame = id ->
{
Map<String, Object> fields = FakeWorker.error(id, FakeWorker.RELEASED, code, null);
change.accept(fields);
return (FakeWorker.frame(fields));
};
return (Arguments.of(name, frame, outcome, rule));
}
/***************************************************************************
** A row whose answer is the well-formed frame with its bytes changed.
***************************************************************************/
private static Arguments bytes(String name, String outcome, String rule, UnaryOperator<String> change)
{
Frame frame = id -> change.apply(new String(FakeWorker.frame(FakeWorker.rendered(id, FakeWorker.RELEASED, LABEL, List.of())), StandardCharsets.UTF_8)).getBytes(StandardCharsets.UTF_8);
return (Arguments.of(name, frame, outcome, rule));
}
/***************************************************************************
** No change: the row's answer is sent as its renderer writes it.
***************************************************************************/
private static void unchanged(Map<String, Object> fields)
{
////////////////////////////////////////////
// nothing is added, removed or reordered //
////////////////////////////////////////////
}
/***************************************************************************
** The fields of an answer in the opposite order.
***************************************************************************/
private static void reverse(Map<String, Object> fields)
{
List<Map.Entry<String, Object>> entries = new ArrayList<>(fields.entrySet().stream().map(entry -> Map.entry(entry.getKey(), entry.getValue())).toList());
fields.clear();
for(Map.Entry<String, Object> entry : entries.reversed())
{
fields.put(entry.getKey(), entry.getValue());
}
}
/***************************************************************************
** The table. Its rows follow the rule as it is written in `WorkerAnswer`,
** `RendererVersion` and the bridge's checks on the document.
***************************************************************************/
static Stream<Arguments> answers() throws Exception
{
Map<String, Object> warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING");
Map<String, Object> fit = FakeWorker.diagnostic("BARCODE_FIT_TOO_SMALL", "ERROR");
String other = RendererJson.hash(ELSEWHERE);
return (Stream.of(
///////////////////////////////////////////////////////////////////////////////
// accepted: the released answer, whatever the order and spacing of its JSON //
///////////////////////////////////////////////////////////////////////////////
changed("the released answer", ACCEPTED, PASSES, VersionOneAnswerTableTest::unchanged),
changed("its fields in another order", ACCEPTED, PASSES, VersionOneAnswerTableTest::reverse),
bytes("spaces between its tokens", ACCEPTED, PASSES, frame -> frame.replace("\":", "\" : ").replace(",\"", " , \"")),
bytes("spaces after its object", ACCEPTED, PASSES, frame -> frame.replace("}\n", "} \n")),
changed("base64 without its padding", ACCEPTED, PASSES, fields -> fields.put("base64", ((String) fields.get("base64")).replace("=", ""))),
/////////////////////////////////////////////////////////////////////////////
// typed refusals: an error answer that names one of the 13 released codes //
/////////////////////////////////////////////////////////////////////////////
error("an error with a released code", "BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_TOO_SMALL", VersionOneAnswerTableTest::unchanged),
error("an error with the last released code", "UNSUPPORTED_BARCODE_OPTION", "UNSUPPORTED_BARCODE_OPTION", "UNSUPPORTED_BARCODE_OPTION", VersionOneAnswerTableTest::unchanged),
/////////////////////////////////////////////////
// rejected: what only renderer 0.2.0 may send //
/////////////////////////////////////////////////
changed("with empty diagnostics", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("diagnostics", List.of())),
changed("with a warning in diagnostics", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("diagnostics", List.of(warning))),
changed("with a detail", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("detail", fit)),
changed("with the outcome REFUSED", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("outcome", "REFUSED")),
error("a bare REFUSED answer", REJECTED, "WORKER_RESPONSE_REJECTED", "BARCODE_FIT_TOO_SMALL", fields ->
{
fields.put("outcome", "REFUSED");
fields.remove("code");
}),
error("a REFUSED answer with an error in diagnostics", REJECTED, "WORKER_RESPONSE_REJECTED", "BARCODE_FIT_TOO_SMALL", fields ->
{
fields.put("outcome", "REFUSED");
fields.remove("code");
fields.put("diagnostics", List.of(fit));
}),
error("an error with a detail", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.put("detail", fit)),
error("an error with diagnostics", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.put("diagnostics", List.of(fit))),
error("an error with a version 2 scene code", REJECTED, "WORKER_ERROR_REJECTED", "SCENE_INVALID", VersionOneAnswerTableTest::unchanged),
error("an error that says the renderer is too old", REJECTED, "WORKER_ERROR_REJECTED", "RENDERER_TOO_OLD", VersionOneAnswerTableTest::unchanged),
error("an error that says the preview is too large", REJECTED, "WORKER_ERROR_REJECTED", "PREVIEW_TOO_LARGE", VersionOneAnswerTableTest::unchanged),
changed("renderer 0.2.0 named on the released fields", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", FakeWorker.CURRENT)),
///////////////////////////////////////////////////////
// rejected: an error answer that is not exactly one //
///////////////////////////////////////////////////////
error("an error with an unknown code", REJECTED, "WORKER_ERROR_REJECTED", "NOT_A_CODE", VersionOneAnswerTableTest::unchanged),
error("an error with a code in lower case", REJECTED, "WORKER_ERROR_REJECTED", "barcode_fit_too_small", VersionOneAnswerTableTest::unchanged),
error("an error with no code", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.remove("code")),
error("an error with a field too many", REJECTED, "WORKER_ERROR_REJECTED", "BARCODE_FIT_TOO_SMALL", fields -> fields.put("pageCount", 1)),
changed("the released answer with a code", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("code", "BARCODE_FIT_TOO_SMALL")),
///////////////////////////////////////////////////////////////////////////////////
// rejected: the envelope is not this protocol, this request, this catalog or an //
// accepted renderer //
///////////////////////////////////////////////////////////////////////////////////
changed("another protocol", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("protocol", "qbit-document-renderer/2")),
changed("no protocol", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("protocol")),
changed("another request", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("requestId", "request-2")),
changed("another catalog", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("catalogVersion", "qqq-barcodes-8")),
changed("an outcome that is not one", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("outcome", "DONE")),
changed("no outcome", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("outcome")),
changed("a later patch version", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "qqq-renderer-0.1.1")),
changed("a later renderer", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "qqq-renderer-0.3.0")),
changed("the version number alone", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "0.1.0")),
changed("the version in upper case", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "QQQ-RENDERER-0.1.0")),
changed("the version with a space after it", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.put("rendererVersion", "qqq-renderer-0.1.0 ")),
changed("no version", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("rendererVersion")),
changed("a version longer than a version", REJECTED, "RESPONSE_TEXT_LIMIT", fields -> fields.put("rendererVersion", "qqq-renderer-0.1.0" + "0".repeat(111))),
//////////////////////////////////////////////////////////////////////
// rejected: the fields are not the released fields in their shapes //
//////////////////////////////////////////////////////////////////////
changed("a field missing", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.remove("pageCount")),
changed("the answer to a validation", REJECTED, "WORKER_RESPONSE_REJECTED", fields -> fields.keySet().retainAll(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion"))),
changed("an unknown field", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("note", "x")),
changed("a render target", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("targetDpi", 203)),
changed("the length as text", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("length", String.valueOf(LABEL.length))),
changed("the length as a fraction", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("length", (double) LABEL.length)),
changed("the media type as a number", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("mediaType", 1)),
changed("a null field", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("sha256", null)),
changed("diagnostics that are not a list", REJECTED, "RESPONSE_FIELD_REJECTED", fields -> fields.put("diagnostics", "none")),
/////////////////////////////////////////////////////////////////////////////
// rejected: the frame is not one line of one JSON object within its bound //
/////////////////////////////////////////////////////////////////////////////
bytes("no line end", REJECTED, "FRAME_TRUNCATED", frame -> frame.substring(0, frame.length() - 1)),
bytes("nothing", REJECTED, "FRAME_TRUNCATED", frame -> ""),
bytes("two frames", REJECTED, "EXTRA_FRAME", frame -> frame + frame),
bytes("a carriage return", REJECTED, "EXTRA_FRAME", frame -> frame.replace("}\n", "}\r\n")),
bytes("a list", REJECTED, "RESPONSE_OBJECT_REQUIRED", frame -> "[" + frame.trim() + "]\n"),
bytes("a second value", REJECTED, "RESPONSE_TRAILING_VALUE", frame -> frame.trim() + "{}\n"),
bytes("a field twice", REJECTED, "JsonParseException", frame -> frame.replace("{\"protocol\"", "{\"pageCount\":1,\"protocol\"")),
bytes("cut short", REJECTED, "JsonEOFException", frame -> frame.substring(0, frame.length() - 2) + "\n"),
document("a document past the frame's bound", REJECTED, "RESPONSE_BYTE_LIMIT", new byte[8000]),
/////////////////////////////////////////////////////////////////////////////////////////
// rejected by the checks on the document, after the version rule has passed the frame //
/////////////////////////////////////////////////////////////////////////////////////////
changed("another hash", REJECTED, PASSES, fields -> fields.put("sha256", other)),
changed("the hash in upper case", REJECTED, PASSES, fields -> fields.put("sha256", ((String) fields.get("sha256")).toUpperCase(Locale.ROOT))),
changed("a hash longer than a hash", REJECTED, PASSES, fields -> fields.put("sha256", fields.get("sha256") + "0")),
changed("a length one more", REJECTED, PASSES, fields -> fields.put("length", LABEL.length + 1)),
changed("a length of nothing", REJECTED, PASSES, fields -> fields.put("length", 0)),
changed("a length past the limit", REJECTED, PASSES, fields -> fields.put("length", MAXIMUM + 1)),
changed("another document under the same hash", REJECTED, PASSES, fields -> fields.put("base64", Base64.getEncoder().encodeToString(ELSEWHERE))),
changed("base64 that is not base64", REJECTED, PASSES, fields -> fields.put("base64", "****")),
changed("no document", REJECTED, PASSES, fields -> fields.put("base64", "")),
changed("the media type of a page", REJECTED, PASSES, fields -> fields.put("mediaType", "application/pdf")),
changed("the media type in upper case", REJECTED, PASSES, fields -> fields.put("mediaType", "APPLICATION/VND.ZEBRA-ZPL")),
changed("two pages", REJECTED, PASSES, fields -> fields.put("pageCount", 2)),
changed("no pages", REJECTED, PASSES, fields -> fields.put("pageCount", 0)),
document("a label for another stock", REJECTED, PASSES, ELSEWHERE),
document("a label with a device command after it", REJECTED, PASSES, COMMAND),
document("a document past the job's limit", REJECTED, PASSES, new byte[MAXIMUM + 1])));
}
/***************************************************************************
** What the version rule says of a frame as the answer to the version 1
** print job of these tests: the name it rejects the frame by, the code of
** the refusal it reads from it, or `-` when it passes the frame on.
***************************************************************************/
private static String rule(byte[] frame, int maximumFrame) throws Exception
{
try
{
WorkerAnswer.read(frame, maximumFrame, REQUEST_ID, SceneFacts.of(GenerationPorts.Format.ZPL, FakeWorker.VERSION_ONE_SCENE)).settle("RENDER", false, false);
return (PASSES);
}
catch(GenerationPorts.RenderException refusal)
{
return (refusal.code().name());
}
catch(IllegalArgumentException rejected)
{
return (rejected.getMessage());
}
catch(IOException unreadable)
{
return (unreadable.getClass().getSimpleName());
}
}
/***************************************************************************
** One row: the job is sent once, as the released job, with no version
** question before it and no second attempt after it, and `render` ends as
** the row says.
***************************************************************************/
@ParameterizedTest(name = "{0}")
@MethodSource("answers")
void anAnswerInTheReleasedShapeIsHeldToTheRule(String name, Frame frame, String outcome, String rule) throws Exception
{
List<Integer> bounds = new ArrayList<>();
List<String> sent = new ArrayList<>();
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, (id, input, maximum) ->
{
RendererJson.parse(input, 16 * 1024 * 1024).fieldNames().forEachRemaining(sent::add);
bounds.add(maximum);
return (frame.to(id));
});
var request = new GenerationPorts.RenderRequest(REQUEST_ID, "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL,
FakeWorker.revision(FakeWorker.VERSION_ONE_SCENE, BridgeTest.STOCK), Map.of(), MAXIMUM);
if(outcome.equals(ACCEPTED))
{
assertArrayEquals(LABEL, bridge.render(request).bytes(), name);
}
else if(outcome.equals(REJECTED))
{
QException failure = assertThrows(QException.class, () -> bridge.render(request), name);
assertEquals(QException.class, failure.getClass(), name);
assertEquals("Isolated renderer rejected or could not complete the request.", failure.getMessage(), name);
assertNull(failure.getCause(), name);
}
else
{
GenerationPorts.RenderException refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(request), name);
assertEquals(outcome, refusal.code().name(), name);
assertEquals(outcome, refusal.getMessage(), name);
assertNull(refusal.diagnostic(), name);
}
assertEquals(List.of(4 * ((MAXIMUM + 2) / 3) + 4096), bounds, name);
assertEquals(List.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), sent, name);
assertEquals(rule, rule(frame.to(REQUEST_ID), bounds.get(0)), name);
}
}
@@ -0,0 +1,247 @@
/*
* 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.math.BigDecimal;
import java.math.BigInteger;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The job a version 1 scene is printed with is the released job, byte for
** byte: the frame `render` writes to the exchange for fixed inputs is compared
** with the frame the released bridge wrote for the same inputs, which is
** stored in this test's resources and was captured from the release tag by
** this same class (`compat-v1/job-frame-render.md` says how).
**
** One part of the frame has no fixed bytes to compare, in the release or
** since: the seven members of `stock` are written in the iteration order of an
** immutable map, which the JVM changes from one run to the next (four captures
** at the tag gave four orders and were otherwise identical). Those members are
** therefore compared as the same seven texts in any order; every other byte of
** the frame, before and after them, must be the stored byte.
**
** The class uses nothing a later bridge added, so that it compiles and runs
** unchanged in a checkout of that tag. With no stored frame, or a different
** one, it fails and leaves the frame it saw in `target/compat-v1-actual`.
*******************************************************************************/
class VersionOneJobFrameTest
{
private static final Path GOLDEN = Path.of("src", "test", "resources", "compat-v1", "job-frame-render.json");
private static final Path ACTUAL = Path.of("target", "compat-v1-actual", "job-frame-render.json");
///////////////////////////////////////////////////////////////////////////////////////////
// the SHA-256 of the stored frame, so it cannot be rewritten together with the code and //
// still pass //
///////////////////////////////////////////////////////////////////////////////////////////
private static final String GOLDEN_SHA256 = "f7fc3faa862dfd06875ad034174315012cd945580872b4d64df12fefc9b52e10";
private static final String REQUEST_ID = "compat-v1-job-0001";
private static final String ACTOR = "actor-1";
private static final int MAXIMUM = 4096;
private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(101600, 152400, 1000, 2000, 3000, 4000, 203);
private static final byte[] LABEL = "^XA^PW812^LL1218^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII);
/////////////////////////////////////////////////////////////////////////////////////////////
// a version 1 scene of every element type, with line breaks, a tab and characters outside //
// ASCII in its text, because the source travels as one JSON string //
/////////////////////////////////////////////////////////////////////////////////////////////
private static final String SCENE = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[\n"
+ "\t{\"type\":\"text\",\"x\":20,\"y\":20,\"width\":12,\"height\":24,\"valuePath\":\"/order/id\"},\n"
+ "\t{\"type\":\"box\",\"x\":20,\"y\":60,\"width\":500,\"height\":2,\"thickness\":2},\n"
+ "\t{\"type\":\"image\",\"x\":20,\"y\":90,\"asset\":\"mark\"},\n"
+ "\t{\"type\":\"rasterText\",\"x\":20,\"y\":150,\"fontAsset\":\"face\",\"fontSize\":24,\"width\":400,\"height\":40,\"value\":\"Café TEST-0001 €\"},\n"
+ "\t{\"type\":\"barcode\",\"x\":20,\"y\":220,\"symbology\":\"qr\",\"scale\":4,\"quiet\":4,\"value\":\"TEST-0001\"}]}\n";
@TempDir
Path directory;
/***************************************************************************
** The bytes of an asset: synthetic, every byte value in turn from a start.
** The bridge reads no image or font; it hashes and encodes what it is given.
***************************************************************************/
private static byte[] content(int length, int start)
{
byte[] bytes = new byte[length];
for(int index = 0; index < length; index++)
{
bytes[index] = (byte) (start + index);
}
return (bytes);
}
/***************************************************************************
** The context of the job, in a stated order: text that JSON must escape,
** every kind of number the bridge admits, both booleans, null, and nested
** and empty maps and lists. Every value is synthetic.
***************************************************************************/
private static Map<String, Object> context()
{
Map<String, Object> first = new LinkedHashMap<>();
first.put("sku", "TEST-0001");
first.put("quantity", 3);
first.put("weight", new BigDecimal("12.50"));
Map<String, Object> second = new LinkedHashMap<>();
second.put("sku", "TEST-0002");
second.put("quantity", 0);
second.put("weight", new BigDecimal("0.125"));
Map<String, Object> order = new LinkedHashMap<>();
order.put("id", "ORD-TEST-0001");
order.put("lines", List.of(first, second));
order.put("shipped", true);
order.put("cancelled", false);
order.put("note", null);
Map<String, Object> context = new LinkedHashMap<>();
context.put("order", order);
context.put("text", "quote \" backslash \\ slash / tab \t line \n return \r control \u0001 e-acute é euro € separator 
 pair 😀 markup <b>&</b>");
context.put("count", 2147483647);
context.put("total", -9007199254740993L);
context.put("big", new BigInteger("123456789012345678901234567890"));
context.put("ratio", 0.1);
context.put("small", -2.5E-7);
context.put("half", 1.5f);
context.put("byte", (byte) -8);
context.put("short", (short) 12345);
context.put("empty", new LinkedHashMap<>());
context.put("none", List.of());
return (context);
}
/***************************************************************************
** A well-formed answer of the released renderer, so the job completes.
***************************************************************************/
private static byte[] answer(String requestId) throws Exception
{
Map<String, Object> fields = new LinkedHashMap<>();
fields.put("protocol", RendererJson.PROTOCOL);
fields.put("requestId", requestId);
fields.put("outcome", "OK");
fields.put("rendererVersion", "qqq-renderer-0.1.0");
fields.put("catalogVersion", "qqq-barcodes-9");
fields.put("mediaType", "application/vnd.zebra-zpl");
fields.put("base64", Base64.getEncoder().encodeToString(LABEL));
fields.put("sha256", RendererJson.hash(LABEL));
fields.put("length", LABEL.length);
fields.put("pageCount", 1);
return ((RendererJson.JSON.writeValueAsString(fields) + "\n").getBytes(StandardCharsets.UTF_8));
}
/***************************************************************************
** One `render` of the fixed job, and what the exchange was handed for it.
***************************************************************************/
private byte[] sent() throws Exception
{
Map<String, byte[]> contents = Map.of("mark", content(256, 0), "face", content(5, 251));
List<GenerationPorts.Asset> assets = List.of(
new GenerationPorts.Asset("mark", "file-version-0001", RendererJson.hash(contents.get("mark")), "image/png"),
new GenerationPorts.Asset("face", "file-version-0002", RendererJson.hash(contents.get("face")), "font/ttf", "Test Face", 700, "italic"));
List<byte[]> frames = new ArrayList<>();
List<String> exchanged = new ArrayList<>();
IsolatedRendererBridge bridge = new IsolatedRendererBridge(BridgeTest.config(directory), (actor, asset, maximum) ->
{
assertEquals(ACTOR, actor);
return (contents.get(asset.name()).clone());
}, (id, input, maximum) ->
{
frames.add(input.clone());
exchanged.add(id + " " + maximum);
return (answer(id));
});
GenerationPorts.Revision revision = new GenerationPorts.Revision(7, 3, "orders", STOCK, null, SCENE, null, assets);
GenerationPorts.RenderRequest request = new GenerationPorts.RenderRequest(REQUEST_ID, ACTOR, new GenerationPorts.Source("orders", "1001"), GenerationPorts.Format.ZPL, revision, context(), MAXIMUM);
assertArrayEquals(LABEL, bridge.render(request).bytes());
///////////////////////////////////////////////////////////////////////////////////////////
// one exchange and no other: the job itself, under its own id, with the released bound //
// on its answer (the base64 of the largest label and 4096 bytes of envelope) //
///////////////////////////////////////////////////////////////////////////////////////////
assertEquals(List.of(REQUEST_ID + " " + (4 * ((MAXIMUM + 2) / 3) + 4096)), exchanged);
return (frames.get(0));
}
/***************************************************************************
** A frame with the members of its `stock` in sorted order and every other
** byte where it was. Each byte is one character here, so nothing is decoded
** or normalised; a frame with no `stock` object is left as it is.
***************************************************************************/
private static String ordered(byte[] frame)
{
String bytes = new String(frame, StandardCharsets.ISO_8859_1);
int start = bytes.indexOf("\"stock\":{") + 9;
int end = bytes.indexOf('}', start);
if(start < 9 || end < 0)
{
return (bytes);
}
String[] members = bytes.substring(start, end).split(",");
Arrays.sort(members);
return (bytes.substring(0, start) + String.join(",", members) + bytes.substring(end));
}
/***************************************************************************
** The frame is the stored one. On any difference the frame that was sent is
** left in `target/compat-v1-actual` and the first differing byte is named
** (counted with the members of `stock` in sorted order in both frames).
***************************************************************************/
@Test
void theVersionOneRenderJobIsTheReleasedFrame() throws Exception
{
byte[] sent = sent();
byte[] golden = Files.exists(GOLDEN) ? Files.readAllBytes(GOLDEN) : new byte[0];
String expected = ordered(golden);
String actual = ordered(sent);
if(!expected.equals(actual))
{
Files.createDirectories(ACTUAL.getParent());
Files.write(ACTUAL, sent);
}
assertEquals(GOLDEN_SHA256, RendererJson.hash(golden), "The stored frame was regenerated or edited");
int first = Arrays.mismatch(expected.toCharArray(), actual.toCharArray());
assertTrue(first < 0, "The version 1 job frame differs from the released frame at byte " + first + " (the frame that was sent is in " + ACTUAL + ")");
assertEquals(golden.length, sent.length);
}
}
@@ -0,0 +1,491 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.concurrent.atomic.AtomicReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The version rule and the render target, through the bridge: renderer 0.1.0
** and 0.2.0 against version 1 and version 2 scenes, what is sent to each, and
** that no job asked for one resolution is ever answered with another.
*******************************************************************************/
class VersionRuleTest
{
private static final String SECRET = "a-record-value-that-must-not-travel";
private static final byte[] VERSION_ONE_LABEL = "^XA^PW406^LL203^LH0,0^CI28\n^FO2,40^GB20,10,1^FS\n^XZ\n".getBytes(StandardCharsets.US_ASCII);
private static final int MAXIMUM = 1024 * 1024;
private static final int LABEL_FRAME = 4 * ((MAXIMUM + 2) / 3) + 4096;
@TempDir
Path directory;
/***************************************************************************
** A render job for a scene on the stock of the `anchors` case (2 x 1 in at
** 203 dpi), with a record value in its context.
***************************************************************************/
private static GenerationPorts.RenderRequest job(String scene, Integer targetDpi) throws Exception
{
return (new GenerationPorts.RenderRequest("request-1", "actor-1", new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL,
FakeWorker.revision(scene, FakeWorker.stock("anchors")), Map.of("name", SECRET), MAXIMUM, targetDpi));
}
/***************************************************************************
** A worker of a version that renders the right label for whatever it is
** asked: the golden of the `anchors` case at the job's resolution for a
** version 2 scene, the fixed label for a version 1 scene.
***************************************************************************/
private static FakeWorker renderer(String version)
{
return (new FakeWorker(version, job ->
{
String id = job.path("requestId").textValue();
if(job.path("operation").textValue().equals("VALIDATE"))
{
return (FakeWorker.validated(id, version));
}
boolean version2 = job.path("source").textValue().contains("qqq-zpl-scene/2");
int dpi = job.has("targetDpi") ? job.path("targetDpi").intValue() : job.path("stock").path("dpi").intValue();
return (FakeWorker.rendered(id, version, version2 ? FakeWorker.golden("anchors", dpi) : VERSION_ONE_LABEL, List.of()));
}));
}
/***************************************************************************
** The names of the fields of a frame.
***************************************************************************/
private static Set<String> names(JsonNode frame)
{
Set<String> names = new TreeSet<>();
frame.fieldNames().forEachRemaining(names::add);
return (names);
}
/***************************************************************************
** Renderer 0.1.0 with a version 1 scene: printing continues exactly as
** released. The job has the released fields and nothing else, no version
** question is asked, and the frame bound is the released one.
***************************************************************************/
@Test
void theReleasedRendererStillPrintsVersionOne() throws Exception
{
FakeWorker worker = renderer(FakeWorker.RELEASED);
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, null)).bytes());
bridge.validate("actor-1", job(FakeWorker.VERSION_ONE_SCENE, null).revision());
assertEquals(List.of(), worker.questions);
assertEquals(2, worker.jobs.size());
assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(worker.jobs.get(0)));
assertEquals(List.of(LABEL_FRAME, 4096), worker.frameLimits);
}
/***************************************************************************
** Renderer 0.2.0 with a version 1 scene: the same job, and the answer with
** its (empty) diagnostics is accepted.
***************************************************************************/
@Test
void theCurrentRendererPrintsVersionOne() throws Exception
{
FakeWorker worker = renderer(FakeWorker.CURRENT);
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, null)).bytes());
bridge.validate("actor-1", job(FakeWorker.VERSION_ONE_SCENE, null).revision());
assertEquals(List.of(), worker.questions);
assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(worker.jobs.get(0)));
assertEquals(List.of(LABEL_FRAME, 4096), worker.frameLimits);
}
/***************************************************************************
** Renderer 0.1.0 with a version 2 scene: refused as `RENDERER_TOO_OLD`
** before anything of the scene or the record is sent. All the worker ever
** receives is the version question, which is a constant.
***************************************************************************/
@Test
void theReleasedRendererIsNeverSentAVersionTwoScene() throws Exception
{
FakeWorker worker = renderer(FakeWorker.RELEASED);
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
String scene = FakeWorker.scene("anchors");
var refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null)));
assertEquals(FailureCode.RENDERER_TOO_OLD, refusal.code());
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, 300))).code());
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.validate("actor-1", job(scene, null).revision())).code());
assertEquals(List.of(), worker.jobs);
assertEquals(3, worker.questions.size());
for(JsonNode question : worker.questions)
{
assertEquals(IsolatedRendererBridge.PROBE_SCENE, question.path("source").textValue());
assertEquals("VALIDATE", question.path("operation").textValue());
assertEquals("{}", question.path("context").toString());
assertEquals("[]", question.path("assets").toString());
assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(question));
assertFalse(question.toString().contains(SECRET) || question.toString().contains("qqq-zpl-scene/2") || question.toString().contains("top-left"));
}
///////////////////////////////////////////////////////////////////////////////////////////
// version 1 printing goes on beside it, on the same adapter //
///////////////////////////////////////////////////////////////////////////////////////////
assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, null)).bytes());
}
/***************************************************************************
** Renderer 0.2.0 with a version 2 scene: rendered. The version question is
** asked once; after a verified answer of 0.2.0 it is not asked again.
***************************************************************************/
@Test
void theCurrentRendererPrintsVersionTwo() throws Exception
{
FakeWorker worker = renderer(FakeWorker.CURRENT);
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
String scene = FakeWorker.scene("anchors");
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
bridge.validate("actor-1", job(scene, null).revision());
assertEquals(1, worker.questions.size());
assertEquals(3, worker.jobs.size());
assertEquals(scene, worker.jobs.get(0).path("source").textValue());
assertEquals(SECRET, worker.jobs.get(0).path("context").path("name").textValue());
assertEquals(Set.of("protocol", "requestId", "operation", "format", "source", "stock", "context", "assets", "maximumBytes"), names(worker.jobs.get(0)));
///////////////////////////////////////////////////////////////////////////////////////////
// the answers to a version 2 job have room for diagnostics, which is bounded //
///////////////////////////////////////////////////////////////////////////////////////////
assertEquals(List.of(LABEL_FRAME + WorkerAnswer.DIAGNOSTICS_BYTES, LABEL_FRAME + WorkerAnswer.DIAGNOSTICS_BYTES, 4096 + WorkerAnswer.DIAGNOSTICS_BYTES), worker.frameLimits);
}
/***************************************************************************
** A renderer that is replaced by an older one is noticed on its first
** answer: that answer is refused whatever it says, and the next job asks
** the version again and sends nothing.
***************************************************************************/
@Test
void aRendererReplacedByAnOlderOneIsRefused() throws Exception
{
String scene = FakeWorker.scene("anchors");
List<String> versions = new ArrayList<>(List.of(FakeWorker.CURRENT, FakeWorker.CURRENT, FakeWorker.RELEASED, FakeWorker.RELEASED));
List<String> sent = new ArrayList<>();
IsolatedRendererBridge changing = FakeWorker.bridge(directory, (id, input, maximum) ->
{
JsonNode job = RendererJson.parse(input, 16 * 1024 * 1024);
String version = versions.remove(0);
sent.add(job.path("source").textValue().equals(IsolatedRendererBridge.PROBE_SCENE) ? "question" : "job");
return (FakeWorker.frame(job.path("operation").textValue().equals("VALIDATE") ? FakeWorker.validated(id, version) : FakeWorker.rendered(id, version, FakeWorker.golden("anchors", 203), List.of())));
});
assertArrayEquals(FakeWorker.golden("anchors", 203), changing.render(job(scene, null)).bytes());
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> changing.render(job(scene, null))).code());
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> changing.render(job(scene, null))).code());
assertEquals(List.of("question", "job", "job", "question"), sent);
}
/***************************************************************************
** A renderer image that is rolled back while a bridge uses it. What the
** bridge remembered sends ONE version 2 job to the old renderer, scene and
** record value included, and that job is refused as `RENDERER_TOO_OLD`.
** Every job after it (a render, a validation, a preview) asks the version
** first and is refused with nothing of the scene or the record sent. When
** the image is rolled forward again the next job asks, is rendered, and the
** question is not asked again.
***************************************************************************/
@Test
void afterARollbackOneJobReachesTheOldRendererAndTheNextAsksFirst() throws Exception
{
AtomicReference<String> image = new AtomicReference<>(FakeWorker.CURRENT);
FakeWorker worker = new FakeWorker(image::get, job ->
{
String id = job.path("requestId").textValue();
return (job.path("operation").textValue().equals("VALIDATE") ? FakeWorker.validated(id, image.get()) : FakeWorker.rendered(id, image.get(), FakeWorker.golden("anchors", 203), List.of()));
});
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
String scene = FakeWorker.scene("anchors");
var preview = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, job(scene, null).revision(), Map.of("name", SECRET), null, MAXIMUM);
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertEquals(List.of(1, 1), List.of(worker.questions.size(), worker.jobs.size()));
image.set(FakeWorker.RELEASED);
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))).code());
///////////////////////////////////////////////////////////////////////////////////////////
// the window: this one job was sent unasked, with the scene and the record value //
///////////////////////////////////////////////////////////////////////////////////////////
assertEquals(List.of(1, 2), List.of(worker.questions.size(), worker.jobs.size()));
assertEquals(scene, worker.jobs.get(1).path("source").textValue());
assertEquals(SECRET, worker.jobs.get(1).path("context").path("name").textValue());
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))).code());
assertEquals(List.of(2, 2), List.of(worker.questions.size(), worker.jobs.size()));
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.validate("actor-1", job(scene, null).revision())).code());
assertEquals(List.of(3, 2), List.of(worker.questions.size(), worker.jobs.size()));
assertEquals(List.of(new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, null, null, null, null)), bridge.preview(preview).diagnostics());
assertEquals(List.of(4, 2), List.of(worker.questions.size(), worker.jobs.size()));
for(JsonNode question : worker.questions)
{
assertEquals(IsolatedRendererBridge.PROBE_SCENE, question.path("source").textValue());
assertFalse(question.toString().contains(SECRET) || question.toString().contains("qqq-zpl-scene/2"));
}
image.set(FakeWorker.CURRENT);
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertEquals(List.of(5, 3), List.of(worker.questions.size(), worker.jobs.size()));
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertEquals(List.of(5, 4), List.of(worker.questions.size(), worker.jobs.size()));
}
/***************************************************************************
** A job that ends in `RENDERER_TOO_OLD` ends what the bridge remembered,
** however the job came to it. Renderer 0.2.0 may name that refusal itself
** (an element it cannot draw yet), as an error answer or among collected
** diagnostics, and then its version alone says nothing new: the next job
** asks the version again, after a render, a validation and a preview
** alike. Any other refusal, and any job that is done, leaves the bridge
** remembering.
***************************************************************************/
@Test
void anyJobThatEndsAsTooOldMakesTheNextJobAskTheVersion() throws Exception
{
Map<String, Object> unavailable = FakeWorker.diagnostic("RENDERER_TOO_OLD", "ERROR", "element", "center", "box", List.of(0, 0, 10, 10));
FakeWorker.Answer done = job -> job.path("operation").textValue().equals("VALIDATE") ? FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT)
: FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 203), List.of());
FakeWorker.Answer tooOld = job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "RENDERER_TOO_OLD", unavailable);
FakeWorker.Answer invalid = job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "SCENE_INVALID", null);
FakeWorker.Answer collected = job -> FakeWorker.refused(job.path("requestId").textValue(), List.of(unavailable));
AtomicReference<FakeWorker.Answer> next = new AtomicReference<>(done);
FakeWorker worker = new FakeWorker(FakeWorker.CURRENT, job -> next.get().to(job));
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
String scene = FakeWorker.scene("anchors");
var preview = new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.ZPL, job(scene, null).revision(), Map.of(), null, MAXIMUM);
Diagnostic read = new Diagnostic("RENDERER_TOO_OLD", Diagnostic.Severity.ERROR, "center", List.of(0, 0, 10, 10), null, null);
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertEquals(1, worker.questions.size());
///////////////////////////////////////////////////////////////////////////////////////////
// another refusal, a validation and a preview that are done: nothing is asked again //
///////////////////////////////////////////////////////////////////////////////////////////
next.set(invalid);
assertEquals(FailureCode.SCENE_INVALID, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null))).code());
next.set(done);
bridge.validate("actor-1", job(scene, null).revision());
assertTrue(bridge.preview(preview).printable());
assertEquals(1, worker.questions.size());
///////////////////////////////////////////////////////////////////////////////////////////
// a render that the renderer refuses as too old: the job after it asks first //
///////////////////////////////////////////////////////////////////////////////////////////
next.set(tooOld);
var refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(scene, null)));
assertEquals(FailureCode.RENDERER_TOO_OLD, refusal.code());
assertEquals(read, refusal.diagnostic());
assertEquals(1, worker.questions.size());
next.set(done);
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertEquals(2, worker.questions.size());
///////////////////////////////////////////////////////////////////////////////////////////
// a validation that ends the same way //
///////////////////////////////////////////////////////////////////////////////////////////
next.set(tooOld);
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> bridge.validate("actor-1", job(scene, null).revision())).code());
next.set(done);
bridge.validate("actor-1", job(scene, null).revision());
assertEquals(3, worker.questions.size());
///////////////////////////////////////////////////////////////////////////////////////////
// a preview whose answer is that error, and one whose collected diagnostics hold it //
///////////////////////////////////////////////////////////////////////////////////////////
next.set(tooOld);
assertEquals(List.of(read), bridge.preview(preview).diagnostics());
next.set(done);
assertTrue(bridge.preview(preview).printable());
assertEquals(4, worker.questions.size());
next.set(collected);
GenerationPorts.Preview refused = bridge.preview(preview);
assertFalse(refused.printable());
assertEquals(List.of(read), refused.diagnostics());
assertEquals(4, worker.questions.size());
next.set(done);
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertEquals(5, worker.questions.size());
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertEquals(5, worker.questions.size());
}
/***************************************************************************
** A renderer of a version this bridge was not built for is rejected, and so
** is a refusal a renderer may not name; a refusal renderer 0.2.0 may name
** arrives typed, with its diagnostic.
***************************************************************************/
@Test
void onlyAcceptedRenderersAndTheirOwnRefusalsAreBelieved() throws Exception
{
IsolatedRendererBridge unknown = FakeWorker.bridge(directory, renderer("qqq-renderer-0.3.0"));
assertEquals(QException.class, assertThrows(QException.class, () -> unknown.render(job(FakeWorker.VERSION_ONE_SCENE, null))).getClass());
IsolatedRendererBridge early = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.RELEASED, job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.RELEASED, "SCENE_INVALID", null)));
assertEquals(QException.class, assertThrows(QException.class, () -> early.render(job(FakeWorker.VERSION_ONE_SCENE, null))).getClass());
IsolatedRendererBridge current = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.CURRENT, job -> FakeWorker.error(job.path("requestId").textValue(), FakeWorker.CURRENT, "SCENE_INVALID",
FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "center", "pointer", "/elements/4/fill"))));
var refusal = assertThrows(GenerationPorts.RenderException.class, () -> current.render(job(FakeWorker.scene("anchors"), null)));
assertEquals(FailureCode.SCENE_INVALID, refusal.code());
assertEquals(new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, "center", null, null, null, "/elements/4/fill"), refusal.diagnostic());
assertEquals(FailureCode.SCENE_INVALID, assertThrows(GenerationPorts.RenderException.class, () -> current.validate("actor-1", job(FakeWorker.scene("anchors"), null).revision())).code());
///////////////////////////////////////////////////////////////////////////////////////////
// the same diagnostic about a scene that does not declare `center` is not believed //
///////////////////////////////////////////////////////////////////////////////////////////
assertEquals(QException.class, assertThrows(QException.class, () -> current.render(job(FakeWorker.scene("rules"), null))).getClass());
}
/***************************************************************************
** A render target is sent with a version 2 scene, and the label that comes
** back is held to the page at that resolution. A worker that renders at
** another resolution than it was asked for is refused, in either direction.
***************************************************************************/
@Test
void aVersionTwoSceneIsRenderedAtItsTargetOrNotAtAll() throws Exception
{
FakeWorker worker = renderer(FakeWorker.CURRENT);
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
String scene = FakeWorker.scene("anchors");
assertTrue(bridge.supportsRenderTarget());
for(int dpi : List.of(203, 300, 600))
{
assertArrayEquals(FakeWorker.golden("anchors", dpi), bridge.render(job(scene, dpi)).bytes());
assertEquals(dpi, worker.jobs.getLast().path("targetDpi").intValue());
assertEquals(203, worker.jobs.getLast().path("stock").path("dpi").intValue());
}
assertArrayEquals(FakeWorker.golden("anchors", 203), bridge.render(job(scene, null)).bytes());
assertFalse(worker.jobs.getLast().has("targetDpi"));
IsolatedRendererBridge ignoring = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.CURRENT,
job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 203), List.of())));
assertArrayEquals(FakeWorker.golden("anchors", 203), ignoring.render(job(scene, null)).bytes());
assertEquals(QException.class, assertThrows(QException.class, () -> ignoring.render(job(scene, 300))).getClass());
IsolatedRendererBridge eager = FakeWorker.bridge(directory, new FakeWorker(FakeWorker.CURRENT,
job -> FakeWorker.rendered(job.path("requestId").textValue(), FakeWorker.CURRENT, FakeWorker.golden("anchors", 300), List.of())));
assertEquals(QException.class, assertThrows(QException.class, () -> eager.render(job(scene, null))).getClass());
assertEquals(QException.class, assertThrows(QException.class, () -> eager.render(job(scene, 600))).getClass());
}
/***************************************************************************
** A version 1 scene and a PDF have one resolution, their stock's. Asked for
** another they are refused by name before any worker is contacted, because
** a renderer would ignore the target; asked for their own they are rendered
** as they always were, with no target on the wire.
***************************************************************************/
@Test
void aVersionOneSceneOrAPdfIsNeverRenderedForAnotherResolution() throws Exception
{
for(String version : List.of(FakeWorker.RELEASED, FakeWorker.CURRENT))
{
FakeWorker worker = renderer(version);
IsolatedRendererBridge bridge = FakeWorker.bridge(directory, worker);
for(int dpi : List.of(300, 600))
{
var refusal = assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job(FakeWorker.VERSION_ONE_SCENE, dpi)));
assertEquals(FailureCode.TARGET_REQUIRES_SCENE_2, refusal.code());
assertEquals(new Diagnostic("TARGET_REQUIRES_SCENE_2", Diagnostic.Severity.ERROR, null, null, Map.of("dpi", dpi), Map.of("dpi", 203)), refusal.diagnostic());
var zpl = job(FakeWorker.VERSION_ONE_SCENE, dpi);
var page = new GenerationPorts.Revision(1, 1, "orders", zpl.revision().stock(), "<p>a page</p>", null, null, List.of());
var pdf = new GenerationPorts.RenderRequest(zpl.requestId(), zpl.actorId(), zpl.source(), GenerationPorts.Format.PDF, page, zpl.context(), zpl.maximumBytes(), dpi);
assertEquals(FailureCode.TARGET_REQUIRES_SCENE_2, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(pdf)).code());
////////////////////////////////////////////////////////////////////////////////////////
// text that is not a scene the host can read is treated the same way //
////////////////////////////////////////////////////////////////////////////////////////
assertEquals(FailureCode.TARGET_REQUIRES_SCENE_2, assertThrows(GenerationPorts.RenderException.class, () -> bridge.render(job("not a scene", dpi))).code());
}
assertEquals(List.of(), worker.jobs);
assertEquals(List.of(), worker.questions);
assertArrayEquals(VERSION_ONE_LABEL, bridge.render(job(FakeWorker.VERSION_ONE_SCENE, 203)).bytes());
assertFalse(worker.jobs.getLast().has("targetDpi"));
}
}
/***************************************************************************
** The frame of an answer never passes what the deployment was sized for,
** and an answer longer than its bound is rejected.
***************************************************************************/
@Test
void anAnswerIsBoundedByTheDeployment() throws Exception
{
FakeWorker worker = new FakeWorker(FakeWorker.CURRENT, job ->
{
List<Map<String, Object>> many = new ArrayList<>();
for(int index = 0; index < 150; index++)
{
many.add(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "center", "measured", Map.of("index", index)));
}
Map<String, Object> answer = FakeWorker.validated(job.path("requestId").textValue(), FakeWorker.CURRENT);
answer.put("diagnostics", many);
return (answer);
});
var revision = job(FakeWorker.scene("anchors"), null).revision();
FakeWorker.bridge(directory, worker).validate("actor-1", revision);
///////////////////////////////////////////////////////////////////////////////////////////
// a deployment that admits documents of 4096 bytes has frames of 9560 bytes at most //
///////////////////////////////////////////////////////////////////////////////////////////
IsolatedRendererBridge small = new IsolatedRendererBridge(BridgeTest.config(directory), (actor, asset, maximum) -> new byte[0], worker);
assertEquals(QException.class, assertThrows(QException.class, () -> small.validate("actor-1", revision)).getClass());
assertEquals(List.of(4096 + WorkerAnswer.DIAGNOSTICS_BYTES, 4 * ((4096 + 2) / 3) + 4096), worker.frameLimits);
}
}
@@ -0,0 +1,420 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.Diagnostic;
import com.kingsrook.qbits.documentgeneration.GenerationPorts.FailureCode;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The worker's answer is untrusted. These are the answers a compromised or
** defective worker could send, each held to the name it is rejected by, beside
** the answers that are accepted and what they become.
*******************************************************************************/
class WorkerAnswerTest
{
private static final String ID = "request-1";
private static final String SCENE = "{\"schema\":\"qqq-zpl-scene/2\",\"design\":{\"width\":\"4in\",\"height\":\"1in\"},\"samples\":{\"default\":{\"id\":\"sampled\"}},"
+ "\"elements\":[{\"id\":\"caption\",\"type\":\"text\"},{\"id\":\"panel\",\"type\":\"group\",\"children\":[{\"id\":\"inner\",\"type\":\"box\"}]}],"
+ "\"overrides\":[{\"when\":{\"dpi\":[203]},\"set\":{\"caption\":{\"size\":\"7pt\"}}}]}";
private static final SceneFacts FACTS = SceneFacts.of(GenerationPorts.Format.ZPL, SCENE);
private static final byte[] LABEL = "^XA^PW812^LL203^LH0,0^CI28\n^XZ\n".getBytes(StandardCharsets.US_ASCII);
private static final int FRAME = 4 * 1024 * 1024;
/***************************************************************************
** Read a frame for the scene above.
***************************************************************************/
private static WorkerAnswer read(Map<String, Object> fields) throws Exception
{
return (WorkerAnswer.read(FakeWorker.frame(fields), FRAME, ID, FACTS));
}
/***************************************************************************
** The name an answer is rejected by, when reading it or when holding it to a
** render that collected diagnostics for a version 2 scene.
***************************************************************************/
private static String rejection(Map<String, Object> fields)
{
return (assertThrows(IllegalArgumentException.class, () -> read(fields).settle("RENDER", true, true)).getMessage());
}
/***************************************************************************
** A refused answer holding these diagnostics.
***************************************************************************/
private static Map<String, Object> refused(Map<String, Object> diagnostic)
{
return (FakeWorker.refused(ID, List.of(diagnostic)));
}
/***************************************************************************
** A full diagnostic is read into the port's record, part for part, and a
** refused answer gives them in the worker's order.
***************************************************************************/
@Test
void aDiagnosticOfTheSceneIsReadPartForPart() throws Exception
{
Map<String, Object> error = FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption", "pointer", "/elements/0/fit", "box", List.of(24, -3, 764, 56),
"measured", Map.of("widthDots", 902, "lines", 2), "limit", Map.of("widthDots", 764));
Map<String, Object> warning = FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "inner", "pointer", "/elements/1/children/0/width");
Map<String, Object> override = FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "caption", "pointer", "/overrides/0/set/caption/size");
Map<String, Object> whole = FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", "");
WorkerAnswer answer = read(FakeWorker.refused(ID, List.of(error, warning, override, whole))).settle("RENDER", true, true);
assertTrue(answer.refused());
assertSame(RendererVersion.CURRENT, answer.version());
assertEquals(List.of(
new Diagnostic("TEXT_DOES_NOT_FIT", Diagnostic.Severity.ERROR, "caption", List.of(24, -3, 764, 56), Map.of("widthDots", 902, "lines", 2), Map.of("widthDots", 764), "/elements/0/fit"),
new Diagnostic("DEVICE_UNIT_USED", Diagnostic.Severity.WARNING, "inner", null, null, null, "/elements/1/children/0/width"),
new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, "caption", null, null, null, "/overrides/0/set/caption/size"),
new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, null, null, null, null, "")), answer.diagnostics());
assertNull(answer.fields().get("base64"));
}
/***************************************************************************
** A pointer may hold only array indexes, property names of the schema and
** ids the scene declares; an element must be declared by the scene. An id
** that appears only in a sample, a record-like value, a path upward or an
** empty segment is not one of those.
***************************************************************************/
@Test
void aDiagnosticThatIsNotOfTheSceneTheHostSentIsRejected()
{
for(String pointer : List.of("/elements/0/secret", "/overrides/0/set/undeclared", "/elements/0/sampled", "/elements/0/ACME-12345"))
{
assertEquals("DIAGNOSTIC_NOT_OF_THIS_SCENE", rejection(refused(FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", pointer))), pointer);
}
for(String pointer : List.of("elements/0", "/elements//0", "/elements/0/", "/elements/..", "/elements/0/a b", "/elements/0/caf%", "/elements/10000", "/elements/01", "/" + "a/".repeat(16) + "a"))
{
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", pointer))), pointer);
}
for(String element : List.of("undeclared", "sampled", "Caption"))
{
assertEquals("DIAGNOSTIC_NOT_OF_THIS_SCENE", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", element))), element);
}
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "a customer's name"))));
///////////////////////////////////////////////////////////////////////////////////////////
// a source the host cannot read as version 2 declares nothing, so nothing can be named //
///////////////////////////////////////////////////////////////////////////////////////////
SceneFacts none = SceneFacts.of(GenerationPorts.Format.ZPL, "{}");
assertEquals("DIAGNOSTIC_NOT_OF_THIS_SCENE", assertThrows(IllegalArgumentException.class,
() -> WorkerAnswer.read(FakeWorker.frame(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption"))), FRAME, ID, none)).getMessage());
}
/***************************************************************************
** A diagnostic is its closed vocabulary and bounded numbers: an unknown code
** or severity, a sentence, a wrong shape or a number that is not a whole
** int is rejected.
***************************************************************************/
@Test
void aDiagnosticOutsideItsShapeIsRejected()
{
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("RENDER_FAILED", "ERROR"))));
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_SHRUNK", "ERROR"))));
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "FATAL"))));
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(Map.of("code", "TEXT_DOES_NOT_FIT"))));
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(Map.of("severity", "ERROR"))));
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3)))));
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, -3, 4)))));
assertEquals("DIAGNOSTIC_OUTSIDE_VOCABULARY", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", Map.of("Order number", 1)))));
assertEquals("DIAGNOSTIC_BOX", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3, 4, 5)))));
assertEquals("DIAGNOSTIC_BOX", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3, 1_000_001)))));
assertEquals("DIAGNOSTIC_BOX", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(-1_000_001, 2, 3, 4)))));
assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", List.of(1, 2, 3, "4")))));
assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", Map.of("widthDots", 1.5)))));
assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", Map.of("widthDots", "wide")))));
assertEquals("DIAGNOSTIC_INTEGER", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", Map.of("widthDots", List.of(1))))));
assertEquals("DIAGNOSTIC_NUMBERS", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", Map.of("n" + "a".repeat(32), 1)))));
Map<String, Object> nine = new LinkedHashMap<>();
for(int index = 0; index < 9; index++)
{
nine.put("n" + index, index);
}
assertEquals("DIAGNOSTIC_NUMBERS", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", nine))));
for(Map<String, Object> shape : List.of(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "message", "free text"), FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", 7),
FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "pointer", List.of()), FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "box", "1,2,3,4"),
FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "measured", List.of()), FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "limit", 4), Map.<String, Object>of("code", "TEXT_DOES_NOT_FIT", "severity", 1)))
{
assertEquals("DIAGNOSTIC_FIELD_REJECTED", rejection(refused(shape)), shape.toString());
}
assertEquals("RESPONSE_TEXT_LIMIT", rejection(refused(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "a".repeat(65)))));
assertEquals("RESPONSE_TEXT_LIMIT", rejection(refused(FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "pointer", "/a".repeat(265)))));
Map<String, Object> list = FakeWorker.envelope(ID, FakeWorker.CURRENT, "REFUSED");
list.put("diagnostics", List.of("TEXT_DOES_NOT_FIT"));
assertEquals("DIAGNOSTIC_OBJECT_REQUIRED", rejection(list));
}
/***************************************************************************
** An answer is bounded: its frame, its texts and how many diagnostics it
** holds.
***************************************************************************/
@Test
void anOversizedAnswerIsRejected() throws Exception
{
List<Map<String, Object>> many = new ArrayList<>();
for(int index = 0; index < WorkerAnswer.MAXIMUM_DIAGNOSTICS; index++)
{
many.add(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING", "element", "caption", "measured", Map.of("index", index)));
}
many.add(FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption"));
assertEquals("DIAGNOSTIC_LIMIT", rejection(FakeWorker.refused(ID, many)));
many.remove(0);
assertEquals(WorkerAnswer.MAXIMUM_DIAGNOSTICS, read(FakeWorker.refused(ID, many)).settle("RENDER", true, true).diagnostics().size());
byte[] frame = FakeWorker.frame(FakeWorker.refused(ID, many));
assertEquals("RESPONSE_BYTE_LIMIT", assertThrows(IllegalArgumentException.class, () -> WorkerAnswer.read(frame, frame.length - 1, ID, FACTS)).getMessage());
assertEquals(WorkerAnswer.MAXIMUM_DIAGNOSTICS, WorkerAnswer.read(frame, frame.length, ID, FACTS).diagnostics().size());
Map<String, Object> text = FakeWorker.envelope(ID, FakeWorker.CURRENT, "OK");
text.put("mediaType", "x".repeat(129));
assertEquals("RESPONSE_TEXT_LIMIT", rejection(text));
Map<String, Object> number = FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of());
number.put("length", 2147483648L);
assertThrows(java.io.IOException.class, () -> read(number));
}
/***************************************************************************
** One frame is one line of strict JSON whose fields are the known ones.
***************************************************************************/
@Test
void aMalformedFrameIsRejected() throws Exception
{
String valid = new String(FakeWorker.frame(FakeWorker.validated(ID, FakeWorker.CURRENT)), StandardCharsets.UTF_8);
for(Map.Entry<String, String> malformed : Map.of("", "FRAME_TRUNCATED", valid.strip(), "FRAME_TRUNCATED", valid + valid, "EXTRA_FRAME", valid.replace(",", ",\r"), "EXTRA_FRAME",
"[]\n", "RESPONSE_OBJECT_REQUIRED", valid.strip() + " {}\n", "RESPONSE_TRAILING_VALUE", valid.replace("\"diagnostics\":[]", "\"diagnostics\":{}"), "RESPONSE_FIELD_REJECTED",
valid.replace("\"diagnostics\":[]", "\"detail\":[]"), "RESPONSE_FIELD_REJECTED", valid.replace("\"diagnostics\":[]", "\"unknown\":1"), "RESPONSE_FIELD_REJECTED",
valid.replace("\"OK\"", "7"), "RESPONSE_FIELD_REJECTED").entrySet())
{
assertEquals(malformed.getValue(), assertThrows(IllegalArgumentException.class,
() -> WorkerAnswer.read(malformed.getKey().getBytes(StandardCharsets.UTF_8), FRAME, ID, FACTS)).getMessage(), malformed.getKey());
}
for(String broken : List.of(valid.replace("\"diagnostics\":[]", "\"outcome\":\"OK\""), valid.replace("}", "")))
{
assertThrows(java.io.IOException.class, () -> WorkerAnswer.read(broken.getBytes(StandardCharsets.UTF_8), FRAME, ID, FACTS), broken);
}
byte[] bytes = valid.getBytes(StandardCharsets.UTF_8);
bytes[10] = (byte) 0xFF;
assertThrows(java.io.IOException.class, () -> WorkerAnswer.read(bytes, FRAME, ID, FACTS));
}
/***************************************************************************
** An answer is for this request, this protocol and catalog, from a renderer
** this bridge accepts, with an outcome it knows.
***************************************************************************/
@Test
void anAnswerOutsideItsEnvelopeIsRejected() throws Exception
{
for(String field : List.of("protocol", "requestId", "rendererVersion", "catalogVersion", "outcome"))
{
Map<String, Object> changed = FakeWorker.validated(ID, FakeWorker.CURRENT);
changed.put(field, "qqq-renderer-0.3.0");
assertEquals("WORKER_RESPONSE_REJECTED", rejection(changed), field);
Map<String, Object> missing = FakeWorker.validated(ID, FakeWorker.CURRENT);
missing.remove(field);
assertEquals("WORKER_RESPONSE_REJECTED", rejection(missing), field);
}
assertSame(RendererVersion.RELEASED, read(FakeWorker.validated(ID, FakeWorker.RELEASED)).version());
}
/***************************************************************************
** A refused answer carries no label. One that does, in any of its parts, is
** rejected, so nothing downstream can find bytes of a label with elements
** missing. An answer is refused only when diagnostics were collected and one
** of them is an error; an accepted answer has no error.
***************************************************************************/
@Test
void aRefusedAnswerThatCarriesLabelBytesIsRejected() throws Exception
{
Map<String, Object> error = FakeWorker.diagnostic("TEXT_DOES_NOT_FIT", "ERROR", "element", "caption");
Map<String, Object> label = FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of(error));
label.put("outcome", "REFUSED");
assertEquals("WORKER_RESPONSE_REJECTED", rejection(label));
for(String part : List.of("mediaType", "base64", "sha256", "length", "pageCount"))
{
Map<String, Object> partial = refused(error);
partial.put(part, label.get(part));
assertEquals("WORKER_RESPONSE_REJECTED", rejection(partial), part);
}
assertEquals("WORKER_RESPONSE_REJECTED", rejection(FakeWorker.refused(ID, List.of())));
assertEquals("WORKER_RESPONSE_REJECTED", rejection(refused(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING"))));
Map<String, Object> bare = FakeWorker.envelope(ID, FakeWorker.CURRENT, "REFUSED");
assertEquals("WORKER_RESPONSE_REJECTED", rejection(bare));
Map<String, Object> detailed = refused(error);
detailed.put("detail", error);
assertEquals("WORKER_RESPONSE_REJECTED", rejection(detailed));
///////////////////////////////////////////////////////////////////////////////////////////
// the print path never collects, so to it a refused answer is always a rejected answer //
///////////////////////////////////////////////////////////////////////////////////////////
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(refused(error)).settle("RENDER", false, true)).getMessage());
assertEquals("WORKER_RESPONSE_REJECTED", rejection(FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of(error))));
assertFalse(read(FakeWorker.rendered(ID, FakeWorker.CURRENT, LABEL, List.of(FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING")))).settle("RENDER", true, true).refused());
}
/***************************************************************************
** An answer has exactly the fields of its operation, outcome and renderer:
** none missing, none added, and diagnostics only from the renderer that has
** them.
***************************************************************************/
@Test
void aWrongNumberOfFieldsIsRejected() throws Exception
{
for(String version : List.of(FakeWorker.RELEASED, FakeWorker.CURRENT))
{
assertEquals(List.of(), read(FakeWorker.rendered(ID, version, LABEL, List.of())).settle("RENDER", false, false).diagnostics());
read(FakeWorker.validated(ID, version)).settle("VALIDATE", false, false);
for(String part : List.of("mediaType", "base64", "sha256", "length", "pageCount"))
{
Map<String, Object> missing = FakeWorker.rendered(ID, version, LABEL, List.of());
missing.remove(part);
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(missing).settle("RENDER", false, false)).getMessage(), part);
}
Map<String, Object> coded = FakeWorker.rendered(ID, version, LABEL, List.of());
coded.put("code", "BARCODE_FIT_TOO_SMALL");
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(coded).settle("RENDER", false, false)).getMessage());
Map<String, Object> label = FakeWorker.rendered(ID, version, LABEL, List.of());
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(label).settle("VALIDATE", false, false)).getMessage());
Map<String, Object> none = FakeWorker.validated(ID, version);
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(none).settle("RENDER", false, false)).getMessage());
}
Map<String, Object> early = FakeWorker.validated(ID, FakeWorker.RELEASED);
early.put("diagnostics", List.of());
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(early).settle("VALIDATE", false, false)).getMessage());
Map<String, Object> late = FakeWorker.validated(ID, FakeWorker.CURRENT);
late.remove("diagnostics");
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(late).settle("VALIDATE", false, false)).getMessage());
Map<String, Object> detailed = FakeWorker.validated(ID, FakeWorker.CURRENT);
detailed.put("detail", FakeWorker.diagnostic("SCENE_INVALID", "ERROR"));
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(detailed).settle("VALIDATE", false, false)).getMessage());
}
/***************************************************************************
** An error answer becomes the typed refusal with its diagnostic. The code
** must be one its renderer may name, the detail must be that same error,
** and an error answer has no other parts.
***************************************************************************/
@Test
void anErrorAnswerIsTheTypedRefusalOrIsRejected() throws Exception
{
Map<String, Object> detail = FakeWorker.diagnostic("SCENE_INVALID", "ERROR", "element", "caption", "pointer", "/elements/0/fit");
var refusal = assertThrows(GenerationPorts.RenderException.class, () -> read(FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", detail)).settle("RENDER", false, true));
assertEquals(FailureCode.SCENE_INVALID, refusal.code());
assertEquals(new Diagnostic("SCENE_INVALID", Diagnostic.Severity.ERROR, "caption", null, null, null, "/elements/0/fit"), refusal.diagnostic());
var bare = assertThrows(GenerationPorts.RenderException.class, () -> read(FakeWorker.error(ID, FakeWorker.CURRENT, "STOCK_MISMATCH", null)).settle("VALIDATE", false, true));
assertEquals(FailureCode.STOCK_MISMATCH, bare.code());
assertNull(bare.diagnostic());
assertEquals(FailureCode.BARCODE_FIT_TOO_SMALL, assertThrows(GenerationPorts.RenderException.class,
() -> read(FakeWorker.error(ID, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", null)).settle("RENDER", false, false)).code());
List<Map<String, Object>> rejected = new ArrayList<>();
rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "RENDER_FAILED", null));
rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "A sentence about a customer.", null));
rejected.add(FakeWorker.error(ID, FakeWorker.RELEASED, "SCENE_INVALID", null));
rejected.add(FakeWorker.error(ID, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", FakeWorker.diagnostic("BARCODE_FIT_TOO_SMALL", "ERROR")));
rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", FakeWorker.diagnostic("STOCK_MISMATCH", "ERROR")));
rejected.add(FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", FakeWorker.diagnostic("DEVICE_UNIT_USED", "WARNING")));
Map<String, Object> listed = FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", null);
listed.put("diagnostics", List.of());
rejected.add(listed);
Map<String, Object> labelled = FakeWorker.error(ID, FakeWorker.CURRENT, "SCENE_INVALID", null);
labelled.put("mediaType", "application/vnd.zebra-zpl");
rejected.add(labelled);
Map<String, Object> uncoded = FakeWorker.envelope(ID, FakeWorker.CURRENT, "ERROR");
rejected.add(uncoded);
Map<String, Object> numbered = FakeWorker.envelope(ID, FakeWorker.CURRENT, "ERROR");
numbered.put("length", 1);
rejected.add(numbered);
for(Map<String, Object> answer : rejected)
{
assertEquals("WORKER_ERROR_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(answer).settle("RENDER", false, false)).getMessage(), answer.toString());
}
}
/***************************************************************************
** A job that needs renderer 0.2.0 was not done by an older renderer,
** whatever its answer says: success, a refusal or an unknown error.
***************************************************************************/
@Test
void anOlderRendererNeverAnswersAJobThatNeedsTheNewOne() throws Exception
{
for(Map<String, Object> answer : List.of(FakeWorker.rendered(ID, FakeWorker.RELEASED, LABEL, List.of()), FakeWorker.validated(ID, FakeWorker.RELEASED),
FakeWorker.error(ID, FakeWorker.RELEASED, "RENDER_FAILED", null), FakeWorker.error(ID, FakeWorker.RELEASED, "BARCODE_FIT_TOO_SMALL", null)))
{
for(boolean collect : List.of(true, false))
{
assertEquals(FailureCode.RENDERER_TOO_OLD, assertThrows(GenerationPorts.RenderException.class, () -> read(answer).settle("RENDER", collect, true)).code());
}
}
}
/***************************************************************************
** A refused answer may name only the errors its renderer may name. Every
** error code is one renderer 0.2.0 may name today, so the rule is shown on
** the released renderer, which has no refused answer at all.
***************************************************************************/
@Test
void aRefusedAnswerIsOnlyEverFromTheRendererThatCollects() throws Exception
{
Map<String, Object> old = FakeWorker.envelope(ID, FakeWorker.RELEASED, "REFUSED");
assertEquals("WORKER_RESPONSE_REJECTED", assertThrows(IllegalArgumentException.class, () -> read(old).settle("RENDER", true, false)).getMessage());
for(FailureCode code : FailureCode.values())
{
assertTrue(read(refused(FakeWorker.diagnostic(code.name(), "ERROR"))).settle("RENDER", true, true).refused(), code.name());
}
}
}
@@ -0,0 +1,545 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration.renderer;
import javax.imageio.ImageIO;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.documentgeneration.GenerationPorts;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTimeoutPreemptively;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** The page decoded from label bytes is the page that was drawn: every golden
** label of the renderer's version 2 cases decodes, dot for dot, to the golden
** picture of the canvas it was emitted from, at 203, 300 and 600 dpi. Every
** released version 1 label decodes too. A label that is not exactly the
** policy's grammar, or is too large to draw, is refused by name, and so is
** one whose fields would paint more than sixteen times its page: the work of
** drawing is bounded by the page, never by the number of fields.
*******************************************************************************/
class ZplRasterTest
{
private static final Path CORPUS = Path.of("..", "..", "renderer", "src", "test", "resources", "compat-v1");
private static final GenerationPorts.Stock STOCK = new GenerationPorts.Stock(25400, 12700, 0, 0, 0, 0, 203);
private static final String HEADER = "^XA^PW203^LL102^LH0,0^CI28\n";
/////////////////////////////////////////////////////////////////////////////////////////////
// what the one-inch page of these tests may be painted with: sixteen times its 20706 dots //
/////////////////////////////////////////////////////////////////////////////////////////////
private static final long BUDGET = 16L * 203 * 102;
/***************************************************************************
** The stored version 2 labels, named `<case>-<dpi>.zpl`.
***************************************************************************/
static List<String> goldens() throws Exception
{
return (ZplSceneTwoPolicyTest.labels());
}
/***************************************************************************
** The stored version 1 labels, by case name.
***************************************************************************/
static List<String> released() throws Exception
{
try(Stream<Path> files = Files.list(CORPUS.resolve("zpl")))
{
List<String> names = files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".zpl")).map(name -> name.substring(0, name.length() - 4)).sorted().toList();
assertEquals(42, names.size());
return (names);
}
}
/***************************************************************************
** A small label on the one-inch stock of these tests.
***************************************************************************/
private static byte[] label(String fields)
{
return ((HEADER + fields + "^XZ\n").getBytes(StandardCharsets.US_ASCII));
}
/***************************************************************************
** The name a label is refused by.
***************************************************************************/
private static String refusal(String label)
{
return (assertThrows(IllegalArgumentException.class, () -> ZplRaster.decode(label.getBytes(StandardCharsets.US_ASCII))).getMessage());
}
/***************************************************************************
** A PNG read back with the platform's own decoder, never this class's.
***************************************************************************/
private static BufferedImage picture(byte[] png) throws Exception
{
return (ImageIO.read(new ByteArrayInputStream(png)));
}
/***************************************************************************
** The round trip: the picture decoded from the emitted label equals the
** golden picture of the canvas that was emitted, and the PNG this class
** writes holds exactly those dots.
***************************************************************************/
@ParameterizedTest(name = "{0}")
@MethodSource("goldens")
void aVersionTwoLabelDecodesToTheCanvasThatWasEmitted(String label) throws Exception
{
String name = label.substring(0, label.length() - 8);
int dpi = Integer.parseInt(label.substring(label.length() - 7, label.length() - 4));
GenerationPorts.Stock stock = FakeWorker.stock(name);
GenerationPorts.Stock printed = new GenerationPorts.Stock(stock.widthMicrons(), stock.heightMicrons(), stock.topMicrons(), stock.rightMicrons(), stock.bottomMicrons(), stock.leftMicrons(), dpi);
ZplRaster page = ZplRaster.page(FakeWorker.golden(name, dpi), printed);
BufferedImage golden = ImageIO.read(FakeWorker.SCENES.resolve("golden").resolve(name + "-" + dpi + ".png").toFile());
BufferedImage written = picture(page.png());
assertTrue(page.exact());
assertEquals(golden.getWidth(), page.width());
assertEquals(golden.getHeight(), page.height());
assertEquals(golden.getWidth(), written.getWidth());
assertEquals(golden.getHeight(), written.getHeight());
int inked = 0;
for(int y = 0; y < page.height(); y++)
{
for(int x = 0; x < page.width(); x++)
{
boolean ink = (golden.getRGB(x, y) & 0xFFFFFF) == 0;
inked += ink ? 1 : 0;
if(page.ink(x, y) != ink || ((written.getRGB(x, y) & 0xFFFFFF) == 0) != ink)
{
throw new AssertionError(label + " differs from its golden picture at dot " + x + ", " + y);
}
}
}
assertTrue(inked > 0, label);
}
/***************************************************************************
** Every released version 1 label decodes for the stock it was rendered for.
** It is exact unless it holds printer font text, and its PNG is its page.
***************************************************************************/
@ParameterizedTest(name = "{0}")
@MethodSource("released")
void aReleasedVersionOneLabelDecodes(String name) throws Exception
{
byte[] bytes = Files.readAllBytes(CORPUS.resolve("zpl").resolve(name + ".zpl"));
JsonNode stock = RendererJson.JSON.readTree(Files.readAllBytes(CORPUS.resolve("cases").resolve(name + ".json"))).path("stock");
ZplRaster page = ZplRaster.page(bytes, new GenerationPorts.Stock(stock.path("widthMicrons").intValue(), stock.path("heightMicrons").intValue(), stock.path("topMicrons").intValue(),
stock.path("rightMicrons").intValue(), stock.path("bottomMicrons").intValue(), stock.path("leftMicrons").intValue(), stock.path("dpi").intValue()));
String text = new String(bytes, StandardCharsets.US_ASCII);
assertEquals(!text.contains("^A0N,"), page.exact(), name);
BufferedImage written = picture(page.png());
assertEquals(page.width(), written.getWidth());
assertEquals(page.height(), written.getHeight());
int inked = 0;
for(int y = 0; y < page.height(); y++)
{
for(int x = 0; x < page.width(); x++)
{
inked += page.ink(x, y) ? 1 : 0;
assertEquals(page.ink(x, y), (written.getRGB(x, y) & 0xFFFFFF) == 0);
}
}
assertTrue(inked > 0, name);
}
/***************************************************************************
** A graphic field is its bits, most significant first, row after row; a box
** is a border inside its size, solid when the border meets itself; fields
** that overlap combine as ink.
***************************************************************************/
@Test
void graphicsAndBoxesArePaintedExactly()
{
ZplRaster page = ZplRaster.decode(label("^FO8,2^GFA,4,4,2,A0018001^FS\n^FO20,10^GB6,5,1^FS\n^FO40,10^GB6,5,3^FS\n^FO9,2^GB1,2,1^FS\n"));
assertTrue(page.exact());
assertEquals(203, page.width());
assertEquals(102, page.height());
Map<Integer, List<Integer>> expected = Map.of(2, List.of(8, 9, 10, 23), 3, List.of(8, 9, 23));
for(int y = 0; y < 9; y++)
{
for(int x = 0; x < 32; x++)
{
assertEquals(expected.getOrDefault(y, List.of()).contains(x), page.ink(x, y), x + ", " + y);
}
}
for(int y = 9; y < 16; y++)
{
for(int x = 19; x < 47; x++)
{
boolean outline = x >= 20 && x < 26 && y >= 10 && y < 15 && (x == 20 || x == 25 || y == 10 || y == 14);
boolean solid = x >= 40 && x < 46 && y >= 10 && y < 15;
assertEquals(outline || solid, page.ink(x, y), x + ", " + y);
}
}
}
/***************************************************************************
** Printer font text cannot be drawn on the host. Its area (one cell for each
** character, counted as characters and not as bytes) is outlined and
** crossed by its two diagonals, and the page says it is not exact.
***************************************************************************/
@Test
void printerFontTextIsACrossedPlaceholderAndThePageIsNotExact()
{
ZplRaster page = ZplRaster.decode(label("^FO10,20^A0N,12,7^FH_^FD_41_C3_A9_E2_82_AC^FS\n"));
assertFalse(page.exact());
for(int y = 18; y < 34; y++)
{
for(int x = 8; x < 34; x++)
{
assertEquals(crossed(x - 10, y - 20, 21, 12), page.ink(x, y), x + ", " + y);
}
}
}
/***************************************************************************
** Strips whose lengths, digits or places are wrong, and anything that is not
** the next byte of the grammar, are refused: nothing is skipped or guessed.
***************************************************************************/
@Test
void malformedStripsAreRefused()
{
String strip = "^FO8,2^GFA,4,4,2,A0018001^FS\n";
assertEquals(4, ZplRaster.decode(label(strip)).width() - 199);
for(Map.Entry<String, String> malformed : Map.ofEntries(
Map.entry(strip.replace("A0018001", "A00180"), "RASTER_HEX"), Map.entry(strip.replace("A0018001", "A001800100"), "RASTER_GRAMMAR"),
Map.entry(strip.replace("A0018001", "a0018001"), "RASTER_HEX"), Map.entry(strip.replace("A0018001", "A001800G"), "RASTER_HEX"),
Map.entry(strip.replace("A0018001", "A001800"), "RASTER_HEX"), Map.entry(strip.replace(",4,4,2,", ",4,5,2,"), "RASTER_FIELD"),
Map.entry(strip.replace(",4,4,2,", ",3,3,2,"), "RASTER_FIELD"), Map.entry(strip.replace(",4,4,2,", ",0,0,1,"), "RASTER_FIELD"),
Map.entry(strip.replace(",4,4,2,", ",4,4,0,"), "RASTER_FIELD"), Map.entry(strip.replace(",4,4,2,", ",100000,100000,2,"), "RASTER_FIELD"),
Map.entry("^FO0,0^GFA,1251,1251,1251," + "00".repeat(1251) + "^FS\n", "RASTER_FIELD"), Map.entry(strip.replace("^FO8,2", "^FO8,101"), "RASTER_FIELD"),
Map.entry(strip.replace("^FO8,2", "^FO203,2"), "RASTER_FIELD"), Map.entry(strip.replace("^FO8,2", "^FO196,2"), "RASTER_FIELD"),
Map.entry(strip.replace("^FO8,2", "^FO999999,2"), "RASTER_FIELD"), Map.entry(strip.replace("^FO8,2", "^FO08,2"), "RASTER_NUMBER"),
Map.entry(strip.replace("^FO8,2", "^FO1000000,2"), "RASTER_NUMBER"), Map.entry(strip.replace("^FO8,2", "^FO,2"), "RASTER_NUMBER"),
Map.entry(strip.replace("^FO8,2", "^FO-8,2"), "RASTER_NUMBER"), Map.entry(strip.replace("^FS\n", "^FS"), "RASTER_GRAMMAR"),
Map.entry(strip.replace("^GFA", "^GFB"), "RASTER_GRAMMAR"), Map.entry(strip.replace("^GFA,4,4,2,A0018001", "^XGR:LOGO.GRF,1,1"), "RASTER_GRAMMAR"),
Map.entry(strip + "~JR", "RASTER_GRAMMAR"), Map.entry("^FO20,10^GB6,5,6^FS\n", "RASTER_FIELD"), Map.entry("^FO20,10^GB6,5,0^FS\n", "RASTER_FIELD"),
Map.entry("^FO200,10^GB6,5,1^FS\n", "RASTER_FIELD"), Map.entry("^FO20,100^GB6,5,1^FS\n", "RASTER_FIELD"), Map.entry("^FO20,10^GB0,5,1^FS\n", "RASTER_FIELD"),
Map.entry("^FO20,10^GB6,5^FS\n", "RASTER_GRAMMAR"), Map.entry("^FO10,20^A0N,12,7^FH_^FD^FS\n", "RASTER_FIELD"), Map.entry("^FO10,20^A0N,12,7^FH_^FD_4^FS\n", "RASTER_HEX"),
Map.entry("^FO10,20^A0N,12,70^FH_^FD_41_42_43^FS\n", "RASTER_FIELD"), Map.entry("^FO10,95^A0N,12,7^FH_^FD_41^FS\n", "RASTER_FIELD"),
Map.entry("^FO10,20^A0N,12,7^FDplain^FS\n", "RASTER_GRAMMAR"), Map.entry("^FO10,20^A0R,12,7^FH_^FD_41^FS\n", "RASTER_GRAMMAR")).entrySet())
{
assertEquals(malformed.getValue(), refusal(HEADER + malformed.getKey() + "^XZ\n"), malformed.getKey());
}
String valid = HEADER + strip + "^XZ\n";
for(Map.Entry<String, String> malformed : Map.of(valid + valid, "RASTER_GRAMMAR", valid + " ", "RASTER_GRAMMAR", valid.replace("^XZ\n", "^XZ"), "RASTER_GRAMMAR", "", "RASTER_GRAMMAR",
valid.replace("^XA", "^XA^MMT"), "RASTER_GRAMMAR", valid.replace("^LH0,0", "^LH1,0"), "RASTER_GRAMMAR", valid.replace("^PW203", "^PW"), "RASTER_NUMBER",
valid.substring(0, 48), "RASTER_HEX", valid.substring(0, 10), "RASTER_GRAMMAR").entrySet())
{
assertEquals(malformed.getValue(), refusal(malformed.getKey()), malformed.getKey());
}
assertEquals("RASTER_GRAMMAR", assertThrows(IllegalArgumentException.class, () -> ZplRaster.decode(null)).getMessage());
}
/***************************************************************************
** A preview draws at most sixteen million dots, and only a page a printer
** can address.
***************************************************************************/
@Test
void aPageTooLargeToDrawIsRefused()
{
assertEquals(4000, ZplRaster.decode("^XA^PW4000^LL4000^LH0,0^CI28\n^XZ\n".getBytes(StandardCharsets.US_ASCII)).height());
assertEquals("PREVIEW_TOO_LARGE", refusal("^XA^PW4000^LL4001^LH0,0^CI28\n^XZ\n"));
assertEquals("PREVIEW_TOO_LARGE", refusal("^XA^PW9999^LL32000^LH0,0^CI28\n^XZ\n"));
for(String page : List.of("^XA^PW0^LL100^LH0,0^CI28\n^XZ\n", "^XA^PW100^LL0^LH0,0^CI28\n^XZ\n", "^XA^PW10000^LL1^LH0,0^CI28\n^XZ\n", "^XA^PW1^LL32001^LH0,0^CI28\n^XZ\n"))
{
assertEquals("RASTER_PAGE", refusal(page), page);
}
}
/***************************************************************************
** Only bytes the output policy accepts are drawn: the policy runs first, and
** a label it refuses is refused with the policy's own name.
***************************************************************************/
@Test
void onlyALabelTheOutputPolicyAcceptsIsDrawn() throws Exception
{
byte[] accepted = label("^FO8,2^GFA,4,4,2,A0018001^FS\n");
assertTrue(ZplRaster.page(accepted, STOCK).ink(8, 2));
GenerationPorts.Stock other = new GenerationPorts.Stock(25400, 12700, 0, 0, 0, 0, 300);
assertEquals("ZPL_STOCK_MISMATCH", assertThrows(IllegalArgumentException.class, () -> ZplRaster.page(accepted, other)).getMessage());
///////////////////////////////////////////////////////////////////////////////////////////
// whole white bytes past the page edge draw nothing, but the policy does not allow them //
///////////////////////////////////////////////////////////////////////////////////////////
byte[] padded = label("^FO196,2^GFA,2,2,2,8000^FS\n");
assertTrue(ZplRaster.decode(padded).ink(196, 2));
assertEquals("ZPL_GRAPHIC_OUTSIDE_STOCK", assertThrows(IllegalArgumentException.class, () -> ZplRaster.page(padded, STOCK)).getMessage());
}
/***************************************************************************
** The page of a refused scene shows only where its refused elements are:
** each diagnostic box, moved from the printable area to the page, cut to the
** page, outlined and crossed. It is never exact, and a box off the page
** draws nothing.
***************************************************************************/
@Test
void theRefusedPageIsDrawnFromBoxesAlone()
{
ZplRaster page = ZplRaster.refused(60, 40, 10, 5, List.of(List.of(0, 0, 13, 7), List.of(45, 30, 20, 20), List.of(-20, -20, 22, 23), List.of(100, 0, 5, 5), List.of(0, -50, 5, 5),
List.of(20, 20, 0, 4)));
assertFalse(page.exact());
for(int y = 0; y < 40; y++)
{
for(int x = 0; x < 60; x++)
{
boolean first = crossed(x - 10, y - 5, 13, 7);
boolean cut = crossed(x - 55, y - 35, 5, 5);
boolean corner = crossed(x, y, 12, 8);
assertEquals(first || cut || corner, page.ink(x, y), x + ", " + y);
}
}
assertEquals("PREVIEW_TOO_LARGE", assertThrows(IllegalArgumentException.class, () -> ZplRaster.refused(4000, 4001, 0, 0, List.of())).getMessage());
}
/***************************************************************************
** A placeholder of no width or no height has no dots, so nothing is drawn
** for it: no outline, and no diagonal one dot outside it. An empty printer
** font text field does not come this far: it is refused as no field.
***************************************************************************/
@Test
void aPlaceholderOfNoWidthOrNoHeightDrawsNothing()
{
ZplRaster page = ZplRaster.refused(60, 40, 0, 0, List.of(List.of(20, 10, 0, 12), List.of(20, 10, 12, 0), List.of(20, 10, 0, 0), List.of(70, 10, 5, 5), List.of(-9, 10, 5, 5),
List.of(20, 45, 5, 5), List.of(2147483647, 10, 5, 5)));
assertFalse(page.exact());
for(int y = 0; y < 40; y++)
{
for(int x = 0; x < 60; x++)
{
assertFalse(page.ink(x, y), x + ", " + y);
}
}
assertEquals("RASTER_FIELD", refusal(HEADER + "^FO10,20^A0N,12,7^FH_^FD^FS\n^XZ\n"));
}
/***************************************************************************
** Whether a dot is ink in a placeholder of this size whose corner is the
** origin of `column` and `row`: its outline, and its two diagonals, which
** have one dot in each row of a tall area and in each column of a wide one.
** Written from that definition, not from the way the page draws it.
***************************************************************************/
static boolean crossed(int column, int row, int width, int height)
{
if(column < 0 || column >= width || row < 0 || row >= height)
{
return (false);
}
if(column == 0 || row == 0 || column == width - 1 || row == height - 1)
{
return (true);
}
if(height >= width)
{
int across = row * (width - 1) / (height - 1);
return (column == across || column == width - 1 - across);
}
return (row == column * (height - 1) / (width - 1) || row == (width - 1 - column) * (height - 1) / (width - 1));
}
/***************************************************************************
** Filled boxes that paint exactly this many dots of the one-inch page: whole
** pages, then whole rows, then part of a row.
***************************************************************************/
private static String fills(long dots)
{
StringBuilder fields = new StringBuilder("^FO0,0^GB203,102,102^FS\n".repeat((int) (dots / (203 * 102))));
int rest = (int) (dots % (203 * 102));
if(rest >= 203)
{
fields.append("^FO0,0^GB203,").append(rest / 203).append(',').append(rest / 203).append("^FS\n");
}
if(rest % 203 > 0)
{
fields.append("^FO0,0^GB").append(rest % 203).append(",1,1^FS\n");
}
return (fields.toString());
}
/***************************************************************************
** A box is the same dots as painting it dot by dot, which is how it was
** painted before it was painted as bars: thin, thick, solid where the
** border meets itself, a single dot, a line, against the page's edges and
** across byte boundaries. The expected dots are computed here, one at a
** time, from the printer's rule.
***************************************************************************/
@Test
void aBoxIsTheSameDotsAsPaintingItDotByDot()
{
int boxes = 0;
for(int width = 1; width <= 40; width++)
{
for(int height = 1; height <= 12; height++)
{
for(int thickness = 1; thickness <= Math.min(width, height); thickness++)
{
for(int[] place : new int[][] {{0, 0}, {40 - width, 30 - height}, {Math.min(3, 40 - width), Math.min(2, 30 - height)}, {Math.min(8, 40 - width), Math.min(17, 30 - height)}})
{
int left = place[0];
int top = place[1];
String box = "^FO" + left + "," + top + "^GB" + width + "," + height + "," + thickness + "^FS\n";
ZplRaster page = ZplRaster.decode(("^XA^PW40^LL30^LH0,0^CI28\n" + box + "^XZ\n").getBytes(StandardCharsets.US_ASCII));
for(int y = 0; y < 30; y++)
{
for(int x = 0; x < 40; x++)
{
int column = x - left;
int row = y - top;
boolean inside = column >= 0 && column < width && row >= 0 && row < height;
boolean ink = inside && (row < thickness || row >= height - thickness || column < thickness || column >= width - thickness);
if(page.ink(x, y) != ink)
{
throw new AssertionError(box.trim() + " differs from the box painted dot by dot at " + x + ", " + y);
}
}
}
boxes++;
}
}
}
}
assertEquals(4 * 2834, boxes);
}
/***************************************************************************
** Thousands of boxes that each cover the page are valid output: the policy
** allows 6000 fields. Painted dot by dot, 6000 solid boxes of a 4 x 6 in
** label at 600 dpi were 52 thousand million dots of work while an admission
** slot was held. The page is painted at most sixteen times over, so the
** seventeenth is refused by name at once; and 6000 outlines, which ink only
** their bars, are drawn for what they ink.
***************************************************************************/
@Test
void thousandsOfPageSizedBoxesAreRefusedOrCostOnlyTheirInk() throws Exception
{
GenerationPorts.Stock stock = new GenerationPorts.Stock(101600, 152400, 0, 0, 0, 0, 600);
String header = "^XA^PW2400^LL3600^LH0,0^CI28\n";
byte[] solid = (header + "^FO0,0^GB2400,3600,1200^FS\n".repeat(6000) + "^XZ\n").getBytes(StandardCharsets.US_ASCII);
ZplOutputPolicy.validate(solid, stock);
assertEquals("PREVIEW_TOO_LARGE", assertTimeoutPreemptively(Duration.ofSeconds(10), () -> assertThrows(IllegalArgumentException.class, () -> ZplRaster.page(solid, stock)).getMessage()));
byte[] outlines = (header + "^FO0,0^GB2400,3600,1^FS\n".repeat(6000) + "^XZ\n").getBytes(StandardCharsets.US_ASCII);
ZplRaster page = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> ZplRaster.page(outlines, stock));
assertTrue(page.ink(0, 0) && page.ink(2399, 3599) && page.ink(0, 1800) && page.ink(1200, 3599));
assertFalse(page.ink(1, 1) || page.ink(1200, 1800));
}
/***************************************************************************
** The limit is exact, and every painter counts against it what it paints: a
** solid box its area, an outline its four bars, a graphic eight dots for
** each byte, a placeholder its outline and its two diagonals. A field that
** takes the page to exactly sixteen times its dots is drawn; the same field
** after one more dot is refused.
***************************************************************************/
@Test
void thePaintedBudgetIsExactForEveryPainter()
{
assertTrue(ZplRaster.decode(label(fills(BUDGET))).ink(202, 101));
assertEquals("PREVIEW_TOO_LARGE", refusal(HEADER + fills(BUDGET + 1) + "^XZ\n"));
for(Map.Entry<String, Integer> field : Map.of(
"^FO0,0^GB1,1,1^FS\n", 1,
"^FO20,10^GB6,5,3^FS\n", 30,
"^FO20,10^GB6,5,1^FS\n", 6 + 6 + 3 + 3,
"^FO8,2^GFA,4,4,2,A0018001^FS\n", 32,
"^FO10,20^A0N,12,7^FH_^FD_41^FS\n", 7 + 7 + 10 + 10 + 12 + 12,
"^FO10,20^A0N,2,30^FH_^FD_41^FS\n", 60 + 30 + 30).entrySet())
{
assertTrue(ZplRaster.decode(label(fills(BUDGET - field.getValue()) + field.getKey())).ink(0, 0), field.getKey());
assertEquals("PREVIEW_TOO_LARGE", refusal(HEADER + fills(BUDGET - field.getValue() + 1) + field.getKey() + "^XZ\n"), field.getKey());
}
}
/***************************************************************************
** The page of a refused scene is bounded the same way. A worker may send
** 2048 diagnostics; hatched dot by dot, 2048 boxes of a sixteen million dot
** page were 32 thousand million dots of work. Each now costs its outline
** and its diagonals, and boxes that would paint more than sixteen times the
** page are refused by name.
***************************************************************************/
@Test
void theRefusedPageIsBoundedHoweverManyBoxesItHas()
{
ZplRaster page = assertTimeoutPreemptively(Duration.ofSeconds(10), () -> ZplRaster.refused(4000, 4000, 0, 0, Collections.nCopies(2048, List.of(0, 0, 4000, 4000))));
assertTrue(page.ink(0, 0) && page.ink(3999, 2000) && page.ink(2000, 2000) && page.ink(1000, 2999));
assertFalse(page.ink(2000, 1000) || page.ink(1, 2));
///////////////////////////////////////////////////////////////////////////////////////////
// on a page of 8 x 32000 dots a box of the whole page costs 64012 dots of outline and //
// 64000 of diagonals; 31 of them are within sixteen times the page and 32 are not //
///////////////////////////////////////////////////////////////////////////////////////////
assertTrue(ZplRaster.refused(8, 32000, 0, 0, Collections.nCopies(31, List.of(0, 0, 8, 32000))).ink(7, 31999));
assertEquals("PREVIEW_TOO_LARGE", assertThrows(IllegalArgumentException.class, () -> ZplRaster.refused(8, 32000, 0, 0, Collections.nCopies(32, List.of(0, 0, 8, 32000)))).getMessage());
}
}
@@ -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));
}
}
@@ -0,0 +1 @@
{"protocol":"qbit-document-renderer/1","requestId":"compat-v1-job-0001","operation":"RENDER","format":"ZPL","source":"{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[\n\t{\"type\":\"text\",\"x\":20,\"y\":20,\"width\":12,\"height\":24,\"valuePath\":\"/order/id\"},\n\t{\"type\":\"box\",\"x\":20,\"y\":60,\"width\":500,\"height\":2,\"thickness\":2},\n\t{\"type\":\"image\",\"x\":20,\"y\":90,\"asset\":\"mark\"},\n\t{\"type\":\"rasterText\",\"x\":20,\"y\":150,\"fontAsset\":\"face\",\"fontSize\":24,\"width\":400,\"height\":40,\"value\":\"Café TEST-0001 €\"},\n\t{\"type\":\"barcode\",\"x\":20,\"y\":220,\"symbology\":\"qr\",\"scale\":4,\"quiet\":4,\"value\":\"TEST-0001\"}]}\n","stock":{"rightMicrons":2000,"bottomMicrons":3000,"dpi":203,"heightMicrons":152400,"leftMicrons":4000,"topMicrons":1000,"widthMicrons":101600},"context":{"order":{"id":"ORD-TEST-0001","lines":[{"sku":"TEST-0001","quantity":3,"weight":12.50},{"sku":"TEST-0002","quantity":0,"weight":0.125}],"shipped":true,"cancelled":false,"note":null},"text":"quote \" backslash \\ slash / tab \t line \n return \r control \u0001 e-acute é euro € separator 
 pair 😀 markup <b>&</b>","count":2147483647,"total":-9007199254740993,"big":123456789012345678901234567890,"ratio":0.1,"small":-2.5E-7,"half":1.5,"byte":-8,"short":12345,"empty":{},"none":[]},"assets":[{"name":"mark","sha256":"40aff2e9d2d8922e47afd4648e6967497158785fbd1da870e7110266bf944880","mediaType":"image/png","base64":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w=="},{"name":"face","sha256":"93a2541056fb33566545ab2dec2ef36467ff8ca7ffdc40efbd966b4bfd16128b","mediaType":"font/ttf","base64":"+/z9/v8=","fontFamily":"Test Face","fontWeight":700,"fontStyle":"italic"}],"maximumBytes":4096}
@@ -0,0 +1,74 @@
# Released version 1 job frame
`job-frame-render.json` is the request the released bridge wrote to its exchange for one `render` of a version 1
scene: 2021 bytes, one line of JSON ending in a line feed, exactly as the exchange received it.
SHA-256: `f7fc3faa862dfd06875ad034174315012cd945580872b4d64df12fefc9b52e10` (pinned in `VersionOneJobFrameTest`).
## Where the bytes came from
They were written by the source of tag `v1.1.0-RC.2` (commit `e9ffba484e8d97f9853cb071cdac14f3bfd7acda`), not by
the branch that added this file. `VersionOneJobFrameTest.java` is its own capture tool: it uses only what that tag
has (`BridgeTest.config`, `RendererJson` and the bridge constructor that takes an exchange), so it was copied
unchanged into a checkout of the tag, with no stored frame, and run there:
```
git worktree add --detach <directory> v1.1.0-RC.2
cp bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/VersionOneJobFrameTest.java \
<directory>/bridges/renderer/src/test/java/com/kingsrook/qbits/documentgeneration/renderer/
cd <directory>
./mvnw --batch-mode -pl bridges/renderer -am test -Dtest=VersionOneJobFrameTest -Dsurefire.failIfNoSpecifiedTests=false
```
The test fails in that state and leaves the frame that the released `IsolatedRendererBridge.render` handed to the
exchange in `bridges/renderer/target/compat-v1-actual/job-frame-render.json`. That file, from the first of four
such runs, is the stored frame. With the stored frame copied into the checkout as well, the test passed against
the tag three times in a row. The runs were on Temurin 21.0.12 on Windows with Maven 3.9.11. Nothing else was
added to the checkout and nothing of it was committed.
The four capture runs used the class as it first was: the same inputs and the same capturing exchange, a strict
comparison of every byte and no pinned hash. Those runs showed that the order of the members of `stock` is not
fixed (below); the comparison was then written as it is now and the hash pinned, and that is the class which
passed at the tag.
## The inputs
All of them are constants of the test; every value is synthetic.
| Input | Value |
|---|---|
| Request id | `compat-v1-job-0001` |
| Actor | `actor-1` |
| Source record | table `orders`, record `1001` (not part of a job) |
| Format | `ZPL` |
| Output limit | 4096 bytes, which is also the deployment's limit (`BridgeTest.config`) |
| Revision | id 7, template 3, source table `orders`, no page source, no script |
| Stock | 101600 x 152400 micrometres, margins top 1000, right 2000, bottom 3000, left 4000, 203 dpi |
| Scene | the constant `SCENE`: one `text`, `box`, `image`, `rasterText` and `barcode` element, with line feeds, tabs and two characters outside ASCII in its text |
| Asset `mark` | `image/png`, file version `file-version-0001`, the 256 bytes 0 to 255 |
| Asset `face` | `font/ttf`, file version `file-version-0002`, family `Test Face`, weight 700, style `italic`, the 5 bytes 251 to 255 |
| Context | the map `context()` builds, in its insertion order: a nested order with two lines, text that JSON must escape (quote, backslash, tab, line feed, carriage return, a control character, characters outside ASCII and outside the basic plane), an `Integer`, a `Long`, a `BigInteger`, a `BigDecimal` with a trailing zero, a `Double`, a `Float`, a `Byte`, a `Short`, both booleans, null, an empty map and an empty list |
| Answer | a well-formed answer of `qqq-renderer-0.1.0` with a one-box label for the stock, so that `render` completes |
The asset bytes are not an image or a font. The bridge reads neither: it hashes the bytes and encodes them.
## What is compared
Every byte of the frame, with one exception that the released bridge itself makes. It writes the seven members of
`stock` in the iteration order of an immutable `Map.of`, and the JVM changes that order from one run to the next:
the four captures at the tag were four different files that differed in the order of those members and in
nothing else. The stored file has the order of the first run. The test therefore compares the members of `stock`
as the same seven `"name":value` texts in any order, and everything before and after them byte for byte. No
stricter comparison is true of the release.
The test also holds the job to one exchange, under its own request id, with the released bound on the answer
(the base64 of 4096 bytes and 4096 bytes of envelope: 9560).
Not covered: `validate` (its request id is random), a page (PDF) job, and any job only renderer 0.2.0 can do.
## Rules
- Never regenerate or edit `job-frame-render.json`. If `VersionOneJobFrameTest` fails, the code changed what a
version 1 print job sends; fix the code. The failure names the first differing byte and the frame that was sent
is in `bridges/renderer/target/compat-v1-actual`.
- A new frame may be added only with bytes captured the same way, from a released tag.
+3 -1
View File
@@ -26,7 +26,9 @@ Source editing displays the included names and preserves their manifest; submitt
TrueType assets expose CSS family, integer weight100–900 and normal/italic style. Older four-field font references keep their name as family and400/normal defaults, including the canonical hash of already queued revisions. Explicit nondefault faces participate in that hash. Images cannot carry font attributes. Existing databases need the additive nullable font columns; `db/h2/002_asset_font_faces.sql` is an H2 upgrade, not a MySQL deployment migration. Published revisions pin asset identities, script identity, sources, stock and margins.
**Generate retained draft preview** accepts one source record identity and PDF/ZPL selection. It creates an immutable PREVIEW revision, executes the ordinary source authorization, isolated rendering, strict audit and Record Files intake workflow, and leaves the active published revision unchanged. The preview has a normal generation request and retained attachment. Retrying its process request key returns the same request; it does not regenerate bytes. Quarantine rules still apply. Results expose native request/file links and, when an authorized download provider is installed and release completed, explicit preview/download actions. Authenticated delivery uses verified response bytes and a durable completion receipt; its URL is never rendered as an ordinary browser link or iframe. PDF display uses the shared canvas preview component. The resulting preview remains a retained, audited file.
**Preview layout** shows a label as it would print, before anything is printed and without saving anything. It is an action on a template (its current draft, for a person with `document-generation.configure`) and on a published revision (for a person with `document-generation.view`), and the template record screen shows the same picture in its **Label preview** section. The person chooses the data (a sample written in the scene under `samples`, the same sample with every bound text filled with capital W to the `maxLength` it declares, or one source record), optionally another stock, and a resolution of 203, 300 or 600 dpi. A record preview also needs `document-generation.generate`; the record is read with the person's own permissions and record locks, and one they cannot read is refused as not found. The longest values picture is indicative: W is not the widest character of every font, and the renderer's own proof of the declared length at publication decides whether every value fits. A revision row that is not a publication (the frozen draft a retained preview writes) is not previewed: it is answered as not found, and a draft is previewed from its template. The picture is decoded from the bytes the print path validated, one pixel per printer dot, and is shown with whether it is exact, whether the label can print, its size in dots and millimetres, and each refusal, warning and note with the element it concerns. A version 1 scene is drawn with its printer font text as outlined, crossed areas and is marked as not exact; a label that would paint more than a preview draws is said to be too dense to preview and prints all the same; a page (PDF) document is not previewed this way. No revision, request, audit event or file is written, and the picture and the record's values are not kept in the process state: the client fetches the preview widget by the identifier of the run, the widget reads the person's choices from the state of that run (so the record is not a parameter of the request), and the picture exists only in that answer. Samples must be synthetic. See the [design](design/LABEL-LAYOUT-FONTS-PREVIEW.md), section 6.
**Generate retained preview file** (the action formerly labelled "Preview draft") accepts one source record identity and PDF/ZPL selection. It creates an immutable PREVIEW revision, executes the ordinary source authorization, isolated rendering, strict audit and Record Files intake workflow, and leaves the active published revision unchanged. The preview has a normal generation request and retained attachment. Retrying its process request key returns the same request; it does not regenerate bytes. Quarantine rules still apply. Results expose native request/file links and, when an authorized download provider is installed and release completed, explicit preview/download actions. Authenticated delivery uses verified response bytes and a durable completion receipt; its URL is never rendered as an ordinary browser link or iframe. PDF display uses the shared canvas preview component. The resulting preview remains a retained, audited file.
**Barcode catalog and template examples** displays the optional renderer's bounded packaged catalog plus editable FreeMarker HTML and restricted ZPL JSON examples. The renderer owns the catalog and its supported options; the foundation copies no encoder registry and does not evaluate source to display help. An unavailable catalog provider fails explicitly. Renderer version qualification and physical barcode acceptance remain separate.
+31
View File
@@ -102,6 +102,37 @@ test uses an explicitly capable fixture; the actual legacy Printing bridge is
separately tested to refuse selected media. No real-owner selected-media support,
new renderer image, device observation or physical output is claimed by this gate.
## Render target: 2026-11-12
`DocumentGenerationService.Request` gains a trailing `Integer targetDpi`: the resolution a label is rendered at.
It is absent (the stock's own resolution, as before) or 203, 300 or 600; any other value throws
`IllegalArgumentException("A render target is 203, 300 or 600 dpi.")` when the request is built. Every earlier
constructor stays and means no target. `GenerationPorts.RenderRequest.targetDpi` carries it to the renderer.
- **Persistence.** One nullable `target_dpi` column on `document_generation_request`. A host applies exactly
one statement before adopting this version, from the shipped script for its database:
`20261112-render-target-dpi-h2.sql`, `20261112-render-target-dpi-mysql.sql` or
`20261112-render-target-dpi-postgres.sql`
(`ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER NULL`). Earlier rows stay null and
read as no target. Nothing else in the schema changes; the bootstrap scripts of `DocumentGenerationSchema`
include the column for a new database.
- **Idempotent retry and lookup.** The stored target is compared with the request's in the same check as
destination, media and copies, so the same request key with another target, or with none, is refused as a
different generation intent, for `generate` and `lookupAcceptedPrint`.
- **Digest.** A request with no target (including every earlier row) keeps the exact earlier intent digest; a
stated target adds a framed `target-dpi/v1` domain after `copies/v1`, also when it equals the stock's
resolution.
- **Refusals before any row, render or submit.** A target for a PDF is refused ("A render target applies to
ZPL output only."). A target is refused with `RENDER_TARGET_UNSUPPORTED` unless the configured renderer
declares `IsolatedRenderer.supportsRenderTarget()`, which defaults to false: an adapter that does not read
the field would print at another resolution than was asked for. The renderer bridge declares it.
- **Refusals by the renderer.** The bridge refuses a version 1 scene asked for another resolution than its
stock's as `TARGET_REQUIRES_SCENE_2`, and a version 2 scene on an old image as `RENDERER_TOO_OLD`. Both are
typed: the request is `INCOMPLETE` with that `errorCode` and a retry throws it again without rendering.
- **Not changed.** Reprints reuse retained bytes and carry no target. Queued and batch generation render at the
stock's resolution. The draft preview that retains its output (`preview(Request, fingerprint)`) refuses a
target. The request status process shows the stored `targetDpi`, and the generate process form offers it.
## Copies per print: source candidate, 2026-11-02
An owner-handoff source candidate, not a deployed or public API release. Nothing here
File diff suppressed because it is too large. Load diff
+115
View File
@@ -0,0 +1,115 @@
# 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` |
| Page decoder for previews, `ZplRaster.decode` | `bridges/renderer` | ZPL bytes returned by the worker | `MutationCampaignTest.thePageDecoderDrawsExactlyWhatThePrinterGateAccepts` |
| Answer reader, `WorkerAnswer.read` and `settle` | `bridges/renderer` | The worker's answer frame, with its diagnostics | `MutationCampaignTest.theAnswerReaderBelievesOnlyDiagnosticsOfTheSceneThatWasSent` |
The harness is `MutationCampaign`, one copy in each module's test tree (the modules share no test code). It
mutates valid inputs from the version 1 compatibility corpus (`renderer/src/test/resources/compat-v1`) and the
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.
- **Page decoder.** The same mutated labels go to the gate and to the decoder. A label the gate accepts must
decode, and for a label without printer font text the page must equal, dot for dot, a second drawing of its
graphic fields and boxes made by a regular expression and none of the decoder's code. A label the gate refuses
(which production never hands the decoder) must be drawn within the dot limit or refused with the decoder's
own `IllegalArgumentException`. Any other failure is a finding.
- **Answer reader.** The corpus is answer frames of both accepted renderers for a version 2 scene: a label with
a warning, a refused answer, an error with its detail. An accepted input, read again by a plain JSON reader,
is an answer of renderer 0.2.0 that is a whole label with no error or a refusal with no part of a label, and
each of its diagnostics has only the known fields, names an element the scene declares and points only
through array indexes, schema property names and declared ids. A typed refusal's diagnostic is held to the
same rule. A rejection is the reader's `IllegalArgumentException` or the JSON reader's `IOException`; any
other failure is a finding.
Each campaign also asserts that it reached both acceptance and refusal, so a campaign that only ever refuses
cannot pass.
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.
## 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`).
- **2026-11-12, bridge preview:** the page decoder and answer reader campaigns were added with their classes.
Both seeds at 20000 iterations found nothing.
- The first run of the printer gate campaign showed the `CharacterCodingException` refusal, which the property
had not declared. It is a refusal, not a defect; the property now names it.
- Not yet done: a weekly long campaign and the `docs/security/assurance.json` record that K22-TEST-25 also asks
for. The long campaign is the manual command above until a scheduled job exists.
- Reassess the targets when a parser boundary changes.
+16
View File
@@ -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>
@@ -33,6 +33,7 @@ import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
@@ -599,6 +600,44 @@ public class DocumentDefinitionService
return (snapshot(id, row.getValueInteger("templateId"), row, row));
}
/*******************************************************************************
** A published snapshot named by a person, for a layout preview. A revision
** holds either a publication or the frozen draft of a retained preview, and
** only a publication may be shown to a person who may not author: any other
** row is answered exactly as an identity that does not exist. A row written
** before revisions recorded their purpose is a publication, as everywhere
** else a revision is resolved.
*******************************************************************************/
GenerationPorts.Revision publishedSnapshot(Integer id) throws QException
{
QRecord row = GenerationStore.get(GenerationTables.REVISION, id);
if(row.getValueString("purpose") != null && !"PUBLISHED".equals(row.getValueString("purpose")))
{
throw (new QNotFoundException("Document definition not found."));
}
return (snapshot(id, row.getValueInteger("templateId"), row, row));
}
/*******************************************************************************
** The current draft as the renderer would receive it, for a layout preview.
** Nothing is frozen or written; the caller has already checked authority.
*******************************************************************************/
GenerationPorts.Revision draft(Integer templateId) throws QException
{
QRecord row = GenerationStore.get(GenerationTables.TEMPLATE, templateId);
return (snapshot(null, templateId, row, GenerationStore.get(GenerationTables.STOCK, row.getValueInteger("stockId"))));
}
/*******************************************************************************
** A stored stock by identity, held to the same physical limits as publication.
*******************************************************************************/
GenerationPorts.Stock stock(Integer stockId) throws QException
{
GenerationPorts.Stock stock = readStock(GenerationStore.get(GenerationTables.STOCK, stockId));
validateStock(stock);
return (stock);
}
/*******************************************************************************
** Current binding policy is checked independently from revision pinning.
*******************************************************************************/
@@ -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());
}
}
@@ -51,18 +51,30 @@ public class DocumentGenerationService
/*******************************************************************************
** Explicit generation intent, with optional immediate queue submission. Copies
** is how many copies the one queued job prints (1 through 100); it only has a
** meaning with a destination.
** meaning with a destination. The render target is the resolution a label is
** rendered at (203, 300 or 600 dpi); absent means the stock's own resolution.
*******************************************************************************/
public record Request(Integer templateId, GenerationPorts.Source source, GenerationPorts.Format format, String requestKey, String destination, Expected expected,
GenerationPorts.MediaProfile mediaProfile, Integer copies)
GenerationPorts.MediaProfile mediaProfile, Integer copies, Integer targetDpi)
{
/***************************************************************************
** Absent means one; anything outside 1 through 100 is refused when built,
** so an invalid count can never reach admission, rendering or submission.
** A render target other than 203, 300 or 600 is refused the same way.
***************************************************************************/
public Request
{
copies = GenerationPorts.PrintRequest.normalizeCopies(copies);
GenerationPorts.renderTarget(targetDpi);
}
/***************************************************************************
** Existing copies callers render at the stock's own resolution.
***************************************************************************/
public Request(Integer templateId, GenerationPorts.Source source, GenerationPorts.Format format, String requestKey, String destination, Expected expected,
GenerationPorts.MediaProfile mediaProfile, Integer copies)
{
this(templateId, source, format, requestKey, destination, expected, mediaProfile, copies, null);
}
/***************************************************************************
@@ -169,7 +181,8 @@ public class DocumentGenerationService
String actor = GenerationSecurity.require("generate");
GenerationSecurity.require("configure");
noAmbientTransaction();
if(request == null || request.destination() != null || request.format() == null || request.expected() != null || request.mediaProfile() != null || request.copies() != 1)
if(request == null || request.destination() != null || request.format() == null || request.expected() != null || request.mediaProfile() != null || request.copies() != 1
|| request.targetDpi() != null)
{
throw new QBadRequestException("Preview requires a source record and format without a print destination.");
}
@@ -425,9 +438,11 @@ public class DocumentGenerationService
QRecord selected = GenerationSecurity.sourceForMutation(requested.source());
String canonicalId = selected.getValueString(QContext.getQInstance().getTable(requested.source().tableName()).getPrimaryKeyField());
Request request = new Request(requested.templateId(), new GenerationPorts.Source(requested.source().tableName(), canonicalId),
requested.format(), requested.requestKey(), requested.destination(), requested.expected(), requested.mediaProfile(), requested.copies());
requested.format(), requested.requestKey(), requested.destination(), requested.expected(), requested.mediaProfile(), requested.copies(), requested.targetDpi());
// Before any row, render or submission: a count the owner cannot honor is refused, not silently reduced.
requireCopiesSupport(request);
// The same for a render target: a renderer that would ignore it never gets the job.
requireTargetSupport(request);
Admission admission = admit(actor, request, pinned);
if(admission.existing())
{
@@ -462,7 +477,7 @@ public class DocumentGenerationService
GenerationSecurity.require("generate");
GenerationSecurity.sourceForMutation(request.source());
GenerationPorts.Content content = config.getRenderer().render(new GenerationPorts.RenderRequest(row.getValueString("operationId"), actor,
request.source(), request.format(), revision, snapshot, config.getMaximumOutputBytes()));
request.source(), request.format(), revision, snapshot, config.getMaximumOutputBytes(), target(row)));
if(content == null)
{
throw (new QException("Renderer returned no output."));
@@ -593,6 +608,7 @@ public class DocumentGenerationService
GenerationSecurity.requireRead(request.source() == null ? null : request.source().tableName());
requireMediaSupport(request.mediaProfile());
requireCopiesSupport(request);
requireTargetSupport(request);
QRecord row = existing(actor, request, "GENERATE");
if(row == null)
{
@@ -684,7 +700,9 @@ public class DocumentGenerationService
|| !Objects.equals(row.getValueString("format"), request.format().name()) || !Objects.equals(row.getValueString("destination"), request.destination())
|| !Objects.equals(row.getValueString("kind"), kind) || !Objects.equals(media(row), request.mediaProfile())
// Rows stored before copies existed have no count; compare them as one so a legacy default-one retry still matches.
|| !Objects.equals(copies(row), request.copies()))
|| !Objects.equals(copies(row), request.copies())
// Rows stored before the render target existed have none, which is what a request without one asks for.
|| !Objects.equals(target(row), request.targetDpi()))
{
throw (new QBadRequestException("Request key already belongs to a different generation intent."));
}
@@ -719,7 +737,7 @@ public class DocumentGenerationService
.withValue("format", request.format().name()).withValue("destination", request.destination()).withValue("state", "STARTED").withValue("kind", kind)
.withValue("mediaProfileId", request.mediaProfile() == null ? null : request.mediaProfile().id())
.withValue("mediaProfileRevision", request.mediaProfile() == null ? null : request.mediaProfile().revision())
.withValue("copies", request.copies())));
.withValue("copies", request.copies()).withValue("targetDpi", request.targetDpi())));
}
/*******************************************************************************
@@ -815,6 +833,11 @@ public class DocumentGenerationService
{
identity.addAll(List.of("copies/v1", String.valueOf(copies(row))));
}
// No render target (including every earlier row) keeps the digest it always had; a stated target is part of the identity.
if(target(row) != null)
{
identity.addAll(List.of("target-dpi/v1", String.valueOf(target(row))));
}
String digest = hash(identity.stream().map(value -> value.length() + ":" + value).collect(java.util.stream.Collectors.joining())
.getBytes(java.nio.charset.StandardCharsets.UTF_8));
return (new Result(row.getValueInteger("id"), row.getValueString("state"), new GenerationPorts.Receipt(row.getValueString("fileId"),
@@ -859,6 +882,45 @@ public class DocumentGenerationService
}
}
/***************************************************************************
** Rows stored before the render target existed carry none, which means the
** stock's own resolution; a stored value that is not a resolution a printer
** can have is corrupt and refuses rather than rendering at another one.
***************************************************************************/
private Integer target(QRecord row) throws QException
{
Integer target = row.getValueInteger("targetDpi");
try
{
GenerationPorts.renderTarget(target);
}
catch(IllegalArgumentException invalid)
{
throw new QException("Accepted render target is unavailable.");
}
return (target);
}
/***************************************************************************
** A render target is a label's resolution, and only a renderer that declares
** it renders at the target may be given one; refuse before creating work.
***************************************************************************/
private void requireTargetSupport(Request request) throws QException
{
if(request.targetDpi() == null)
{
return;
}
if(request.format() != GenerationPorts.Format.ZPL)
{
throw new QBadRequestException("A render target applies to ZPL output only.");
}
if(!config.getRenderer().supportsRenderTarget())
{
throw new QBadRequestException("RENDER_TARGET_UNSUPPORTED");
}
}
/***************************************************************************
** More than one copy needs a destination to print to and an owner that
** declares it prints them; refuse before creating work or any print call.
@@ -0,0 +1,414 @@
/*
* 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.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import static com.kingsrook.qqq.backend.core.logging.LogUtils.logPair;
/*******************************************************************************
** A layout preview: the print path stopped before retention. The configured
** isolated renderer draws a template's draft or a published revision with
** sample data, its longest declared values or one record the person may read,
** and the answer is a picture with its diagnostics.
**
** Nothing is written: no revision, request, event or file. This service logs
** one line that names the actor, the template, the target and the outcome,
** and never any content or record identity.
**
** What a host's own request log holds is outside this service. The "Preview
** layout" action therefore keeps the person's choices in the state of their
** run, and the picture is requested by the run's identifier alone, so a record
** identity is not a parameter of that request (`GenerationPreviewProcesses`).
*******************************************************************************/
public final class DocumentPreviewService
{
static final int MAXIMUM_SAMPLES = 16;
static final int MAXIMUM_LENGTH = 4096;
private static final QLogger LOG = QLogger.getLogger(DocumentPreviewService.class);
private static final ObjectMapper JSON = new ObjectMapper();
private final DocumentGenerationQBitConfig config;
private final DocumentDefinitionService definitions;
/***************************************************************************
** Where the values of a preview come from.
***************************************************************************/
public enum Data
{
/////////////////////////////////////////////////////////////////////////////////////
// a named synthetic context the author wrote in the scene; the first when unnamed //
/////////////////////////////////////////////////////////////////////////////////////
SAMPLE,
/////////////////////////////////////////////////////////////////////////////////////
// the first sample, with every bound text that declares a maximum length filled //
// to that length, so overflow shows //
/////////////////////////////////////////////////////////////////////////////////////
LONGEST,
/////////////////////////////////////////////////////////////////////////////////////
// one source record, read as the person asking //
/////////////////////////////////////////////////////////////////////////////////////
RECORD
}
/***************************************************************************
** What to preview. Exactly one of the template (its current draft) and the
** revision (a published snapshot) is given. The stock and the render target
** are optional: absent, the template's or revision's own stock and that
** stock's resolution are used.
***************************************************************************/
public record Options(Integer templateId, Integer revisionId, GenerationPorts.Format format, Data data, String sampleName, String sourceRecordId, Integer stockId,
Integer targetDpi)
{
}
/***************************************************************************
** A preview and the resolution its pages were drawn at.
***************************************************************************/
public record Result(GenerationPorts.Preview preview, Integer dpi)
{
}
/***************************************************************************
** What a form needs before the person chooses: the source table of the
** template and the names of the samples its scene declares.
***************************************************************************/
public record Choices(String sourceTable, List<String> sampleNames)
{
}
/*******************************************************************************
** Use the host's configured renderer and context script execution.
*******************************************************************************/
public DocumentPreviewService(DocumentGenerationQBitConfig config)
{
this.config = config;
this.definitions = new DocumentDefinitionService(config);
}
/*******************************************************************************
** Draw the preview. A layout that cannot print is a result, not an exception:
** the preview says so and carries the diagnostics.
*******************************************************************************/
public Result preview(Options options) throws QException
{
String actor = authorize(options);
GenerationPorts.Revision revision = revision(options);
Map<String, Object> context = context(actor, revision, options);
Integer dpi = options.targetDpi() == null ? revision.stock().dpi() : options.targetDpi();
try
{
GenerationPorts.Preview preview = config.getRenderer().preview(new GenerationPorts.PreviewRequest(actor, options.format(), revision, context, options.targetDpi(),
config.getMaximumOutputBytes()));
log(actor, options, dpi, preview.printable() ? "PRINTABLE" : "REFUSED", preview.diagnostics().stream().map(GenerationPorts.Diagnostic::code).distinct().sorted().toList());
return (new Result(preview, dpi));
}
catch(QException failure)
{
log(actor, options, dpi, "FAILED", List.of());
throw (foreign(failure));
}
}
/*******************************************************************************
** Everything a preview checks before it renders: the permissions, the
** template or revision, the stock, and that the chosen record can be read
** or the chosen sample exists. A form calls this to refuse at once.
*******************************************************************************/
public void check(Options options) throws QException
{
authorize(options);
GenerationPorts.Revision revision = revision(options);
if(options.data() == Data.RECORD)
{
GenerationSecurity.requireRead(revision.sourceTable());
GenerationSecurity.source(new GenerationPorts.Source(revision.sourceTable(), options.sourceRecordId()));
}
else
{
sample(scene(revision.zplSource()), options.sampleName());
}
}
/*******************************************************************************
** The source table and sample names of a draft or a published revision, under
** the permission a sample preview of it needs.
*******************************************************************************/
public Choices choices(Integer templateId, Integer revisionId) throws QException
{
Options sample = new Options(templateId, revisionId, GenerationPorts.Format.ZPL, Data.SAMPLE, null, null, null, null);
authorize(sample);
GenerationPorts.Revision revision = revision(sample);
JsonNode samples = scene(revision.zplSource()).path("samples");
List<String> names = new ArrayList<>();
for(Iterator<String> name = samples.fieldNames(); name.hasNext() && names.size() < MAXIMUM_SAMPLES; )
{
String candidate = name.next();
if(samples.get(candidate).isObject() && candidate.matches("[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}"))
{
names.add(candidate);
}
}
return (new Choices(revision.sourceTable(), List.copyOf(names)));
}
/*******************************************************************************
** The published revision of a template, for a person who may view it but not
** author it; absent when the template has never been published.
*******************************************************************************/
public Integer publishedRevision(Integer templateId) throws QException
{
GenerationSecurity.require("view");
return (GenerationStore.get(GenerationTables.TEMPLATE, templateId).getValueInteger("publishedRevisionId"));
}
/*******************************************************************************
** Who may preview what. A draft needs `configure`; a published revision needs
** `view`; a real record needs `generate` (beside `configure` for a draft, and
** in place of `view` for a published revision) and is then read as the
** person. No permission is new. A draft is reached through its template
** alone: a revision named here is a publication or it is not found
** (`revision`), so neither `view` nor `generate` shows a draft.
*******************************************************************************/
private String authorize(Options options) throws QException
{
if(options == null || options.data() == null || options.format() == null || (options.templateId() == null) == (options.revisionId() == null)
|| (options.targetDpi() != null && !List.of(203, 300, 600).contains(options.targetDpi())))
{
throw new QBadRequestException("A preview needs a template draft or a published revision, a data source and a resolution of 203, 300 or 600 dpi.");
}
boolean record = options.data() == Data.RECORD;
String actor = GenerationSecurity.require(options.revisionId() == null ? "configure" : record ? "generate" : "view");
if(record)
{
GenerationSecurity.require("generate");
if(options.sourceRecordId() == null || options.sourceRecordId().isBlank())
{
throw new QBadRequestException("Choose the record to preview.");
}
}
if(options.format() != GenerationPorts.Format.ZPL)
{
throw new QBadRequestException("Preview is available for labels only.");
}
return (actor);
}
/*******************************************************************************
** The draft as it stands or the published snapshot, on the chosen stock.
** Neither is written anywhere. A revision that is not a publication (the
** frozen draft of a retained preview) is not found, in the words and with
** the type of an identity that does not exist.
*******************************************************************************/
private GenerationPorts.Revision revision(Options options) throws QException
{
GenerationPorts.Revision revision = options.revisionId() == null ? definitions.draft(options.templateId()) : definitions.publishedSnapshot(options.revisionId());
if(revision.zplSource() == null || revision.zplSource().isBlank())
{
throw new QBadRequestException("This template has no label (ZPL) source to preview.");
}
if(options.stockId() == null)
{
return (revision);
}
return (new GenerationPorts.Revision(revision.id(), revision.templateId(), revision.sourceTable(), definitions.stock(options.stockId()), revision.pdfSource(),
revision.zplSource(), revision.scriptArtifact(), revision.assets(), revision.outputBaseName(), revision.outputNameContextKey()));
}
/*******************************************************************************
** The values the renderer receives. A record is read as the person, with
** their permissions and record locks and within what their channel and
** credential may read (the ceiling printing applies to the source table),
** before any context is built, and the context is then built exactly as
** generation builds it, including the pinned context script. The record is
** read again after the script, as generation does, so access lost meanwhile
** still refuses.
*******************************************************************************/
private Map<String, Object> context(String actor, GenerationPorts.Revision revision, Options options) throws QException
{
if(options.data() != Data.RECORD)
{
JsonNode scene = scene(revision.zplSource());
Map<String, Object> sample = sample(scene, options.sampleName());
if(options.data() == Data.LONGEST)
{
longest(scene.path("elements"), sample, 0);
}
return (SafeContext.copy(sample));
}
GenerationSecurity.requireRead(revision.sourceTable());
QRecord selected = GenerationSecurity.source(new GenerationPorts.Source(revision.sourceTable(), options.sourceRecordId()));
GenerationPorts.Source source = new GenerationPorts.Source(revision.sourceTable(),
selected.getValueString(QContext.getQInstance().getTable(revision.sourceTable()).getPrimaryKeyField()));
Map<String, Object> record = SafeContext.copy(selected.getValues());
Map<String, Object> context = new LinkedHashMap<>();
if(revision.scriptArtifact() != null)
{
try
{
context.putAll(SafeContext.copy(config.getContextExecution().prepare(new GenerationPorts.ContextRequest(actor, revision.scriptArtifact(), source, record))));
}
catch(QException failure)
{
throw (foreign(failure));
}
GenerationSecurity.require("generate");
GenerationSecurity.requireRead(source.tableName());
GenerationSecurity.source(source);
}
context.put("record", record);
return (SafeContext.copy(context));
}
/*******************************************************************************
** A failure of the host's renderer or context script keeps its message for
** the caller of this service, but loses any type that a screen would show to
** a person as an explanation: such a message could quote record data, and
** only this service's own fixed sentences are shown. A named render refusal
** is closed vocabulary and stays as it is.
*******************************************************************************/
private static QException foreign(QException failure)
{
return (failure instanceof GenerationPorts.RenderException || failure.getClass() == QException.class ? failure : new QException(failure.getMessage()));
}
/*******************************************************************************
** The scene as JSON, read only for its samples and declared lengths. A source
** this host cannot read declares none; the renderer reports what is wrong.
*******************************************************************************/
private static JsonNode scene(String source)
{
try
{
return (JSON.readTree(source));
}
catch(JsonProcessingException unreadable)
{
return (JSON.missingNode());
}
}
/*******************************************************************************
** The named sample, or the first when no name is given. A scene without
** samples gives an empty context; a name it does not declare is refused.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static Map<String, Object> sample(JsonNode scene, String name) throws QBadRequestException
{
JsonNode samples = scene.path("samples");
boolean named = name != null && !name.isBlank();
int seen = 0;
for(Iterator<String> candidate = samples.fieldNames(); candidate.hasNext() && seen < MAXIMUM_SAMPLES; seen++)
{
String key = candidate.next();
if(samples.get(key).isObject() && (!named || name.equals(key)))
{
return (JSON.convertValue(samples.get(key), LinkedHashMap.class));
}
}
if(named)
{
throw new QBadRequestException("The scene declares no sample of that name.");
}
return (new LinkedHashMap<>());
}
/*******************************************************************************
** Fill every bound element that declares a maximum length with a value of
** that length. The host has no fonts, so it cannot know a face's widest
** glyph: a capital W is wide but is not the widest glyph of every face, so
** the picture is indicative. A preview is a render of these values and
** proves nothing about other values; whether every value of the declared
** length fits is decided by the renderer when it validates the template for
** publication.
*******************************************************************************/
private static void longest(JsonNode elements, Map<String, Object> context, int depth)
{
for(JsonNode element : elements)
{
JsonNode path = element.path("valuePath");
JsonNode length = element.path("maxLength");
if(path.isTextual() && length.isInt() && length.intValue() >= 1 && length.intValue() <= MAXIMUM_LENGTH)
{
put(context, path.textValue().split("/", -1), "W".repeat(length.intValue()));
}
if(depth < 16)
{
longest(element.path("children"), context, depth + 1);
}
}
}
/*******************************************************************************
** Set one value by its JSON pointer, creating the objects on the way. A path
** that is not a pointer into objects is left alone.
*******************************************************************************/
@SuppressWarnings("unchecked")
private static void put(Map<String, Object> context, String[] segments, String value)
{
if(segments.length < 2 || segments.length > 17 || !segments[0].isEmpty())
{
return;
}
Map<String, Object> at = context;
for(int index = 1; index < segments.length - 1; index++)
{
Object next = at.get(key(segments[index]));
if(!(next instanceof Map<?, ?>))
{
next = new LinkedHashMap<String, Object>();
at.put(key(segments[index]), next);
}
at = (Map<String, Object>) next;
}
at.put(key(segments[segments.length - 1]), value);
}
/*******************************************************************************
** One pointer segment as the property name it stands for.
*******************************************************************************/
private static String key(String segment)
{
return (segment.replace("~1", "/").replace("~0", "~"));
}
/*******************************************************************************
** The one line this service logs for a preview: identities, the target and
** the outcome. No source, sample, record identity or record value is in it.
*******************************************************************************/
private static void log(String actor, Options options, Integer dpi, String outcome, List<String> codes)
{
LOG.info("Document layout preview", logPair("actor", actor), logPair("templateId", options.templateId()), logPair("revisionId", options.revisionId()),
logPair("data", options.data()), logPair("dpi", dpi), logPair("outcome", outcome), logPair("diagnostics", String.join(",", codes)));
}
}
@@ -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());
}
}
@@ -133,7 +133,7 @@ public final class GenerationAuthoringProcesses
}
register(instance, GenerationTables.TEMPLATE, "naming", "Edit output filename");
register(instance, GenerationTables.TEMPLATE, "publish", "Validate and publish draft");
register(instance, GenerationTables.TEMPLATE, "preview", "Preview draft");
register(instance, GenerationTables.TEMPLATE, "preview", "Generate retained preview file");
register(instance, GenerationTables.TEMPLATE, "asset", "Select retained asset for draft");
register(instance, GenerationTables.TEMPLATE, "removeAsset", "Remove included draft asset");
register(instance, GenerationTables.TEMPLATE, "replaceAsset", "Replace included draft asset");
@@ -38,6 +38,17 @@ public final class GenerationPorts
{
}
/***************************************************************************
** A render target is absent or one of the resolutions the renderer supports.
***************************************************************************/
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,129 @@ 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
}
/***************************************************************************
** A caller's context, held apart from the map the caller keeps: the entries
** are copied (a value may be null) and the copy cannot be changed. Values are
** not copied; the renderer adapter snapshots them when it frames the job.
***************************************************************************/
private static Map<String, Object> held(Map<String, Object> context)
{
return (context == null ? null : java.util.Collections.unmodifiableMap(new java.util.LinkedHashMap<>(context)));
}
/***************************************************************************
** One renderer finding about a layout: a code from a closed vocabulary, the
** element id the template author wrote, numbers, and for a finding about the
** 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 +236,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 +268,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 +379,121 @@ 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);
context = held(context);
}
/***************************************************************************
** The context is record data and the revision is template source; neither
** belongs in a log line, so only the job's identity is shown.
***************************************************************************/
@Override
public String toString()
{
return ("RenderRequest[requestId=" + requestId + ", format=" + format + ", targetDpi=" + targetDpi + "]");
}
/***************************************************************************
** 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);
context = held(context);
}
/***************************************************************************
** The context is record data and the revision is template source; neither
** belongs in a log line, so only the format and the render target are shown.
***************************************************************************/
@Override
public String toString()
{
return ("PreviewRequest[format=" + format + ", targetDpi=" + targetDpi + "]");
}
}
/***************************************************************************
** 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 +663,28 @@ 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.");
}
/***********************************************************************
** Declare that this renderer renders a version 2 scene at the request's
** render target and refuses, by name, a job it cannot render there. An
** adapter that does not read `RenderRequest.targetDpi` keeps the default,
** and generation then refuses a render target before creating any work:
** a caller who asks for 300 dpi never silently gets another resolution.
***********************************************************************/
default boolean supportsRenderTarget()
{
return (false);
}
/*******************************************************************************
** Authorize assets and validate user source outside the application JVM.
*******************************************************************************/
@@ -0,0 +1,331 @@
/*
* 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.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.function.UnaryOperator;
import java.util.stream.Stream;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessMetaDataAdjustment;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessState;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
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.help.QHelpContent;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.processes.QRecordListMetaData;
/*******************************************************************************
** The "Preview layout" action on a template (its current draft) and on a
** published revision: a form for the data, the stock and the resolution, and
** a result that is the preview widget.
**
** The process keeps the person's choices and nothing else. The picture, the
** values it was drawn from and its diagnostics are never process values: the
** result step names the widget, the client fetches it, and the picture exists
** only in that answer. QQQ keeps the state of a finished process for hours
** (and, with a shared state provider, in a table), so a picture of record
** data must not be put there.
**
** The widget is fetched by the identifier of the run, not by the choices. A
** client sends every plain process value (a text, a number, a boolean) as a
** parameter of the widget request, and QQQ's widget route writes the
** parameters of a request that fails or takes over a second to the
** application log. So once the form is accepted its choices, the record among
** them, are held as one structured process value (`CHOICES`), which is not a
** plain value and so not a request parameter, and the widget reads them from
** the state of the person's own run.
*******************************************************************************/
final class GenerationPreviewProcesses
{
static final String DATA = "document_generation_preview_data";
static final String RESOLUTION = "document_generation_preview_resolution";
static final String CHOICES = "document_generation_preview_choices";
private static final List<String> FORM = List.of("dataSource", "sampleName", "sourceRecordId", "stockId", "targetDpi");
private static final String RUN = "[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}";
/*******************************************************************************
** Metadata namespace.
*******************************************************************************/
private GenerationPreviewProcesses()
{
}
/*******************************************************************************
** Called once by the producer after the generation and authoring processes.
*******************************************************************************/
static void register(QInstance instance, DocumentGenerationQBitConfig config) throws QException
{
instance.addPossibleValueSource(new QPossibleValueSource().withName(DATA).withLabel("Preview data").withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.STRING)
.withEnumValues(List.of(new QPossibleValue<>("SAMPLE", "Sample data written in the scene"), new QPossibleValue<>("LONGEST", "Longest values"),
new QPossibleValue<>("RECORD", "A record I choose")))
.withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help("Where the values of a layout preview come from: a sample the author wrote in the scene, the same sample "
+ "with every bound text filled with capital W to the maximum length it declares, or one source record the person may read. The longest values picture is indicative: "
+ "W is not the widest character of every font, and the renderer's own proof of the declared length, made when the template is published, decides whether every value fits.")));
instance.addPossibleValueSource(new QPossibleValueSource().withName(RESOLUTION).withLabel("Preview resolution").withType(QPossibleValueSourceType.ENUM).withIdType(QFieldType.INTEGER)
.withEnumValues(List.of(new QPossibleValue<>(203, "203 dpi"), new QPossibleValue<>(300, "300 dpi"), new QPossibleValue<>(600, "600 dpi")))
.withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help("The printer resolutions a label can be drawn for. A version 1 scene is drawn only at the resolution of its stock.")));
instance.addWidget(GenerationPreviewWidget.metaData());
boolean selector = instance.getPossibleValueSource(GenerationAuthoringProcesses.PREVIEW_SOURCE) != null;
for(String table : List.of(GenerationTables.TEMPLATE, GenerationTables.REVISION))
{
boolean draft = GenerationTables.TEMPLATE.equals(table);
QFrontendStepMetaData options = new QFrontendStepMetaData().withName("options").withLabel("Preview layout")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM))
.withHelpContent(GenerationTables.help("Choose what the label is drawn with. Sample data and longest values come from the scene and involve no record. "
+ "A record is read with your own permissions. Nothing is saved, and no file is created."))
.withFormField(new QFieldMetaData("dataSource", QFieldType.STRING).withLabel("Data to draw with").withIsRequired(true).withPossibleValueSourceName(DATA)
.withHelpContent(GenerationTables.help("Sample data uses a sample written in the scene. Longest values fills every bound text with capital W to the maximum length "
+ "it declares, so overflow shows. That picture is indicative: W is not the widest character of every font, and the renderer's own proof of the declared length, "
+ "made when the template is published, decides whether every value fits. A record uses one source record you can read.")))
.withFormField(new QFieldMetaData("sampleName", QFieldType.STRING).withLabel("Sample (blank = the first in the scene)").withMaxLength(64)
.withHelpContent(GenerationTables.help("The name of a sample the scene declares under samples. Samples are written by the template's author and must be synthetic: "
+ "never copy real record data into a scene.")));
if(selector)
{
options.withFormField(new QFieldMetaData("sourceTable", QFieldType.STRING).withLabel("Records this template belongs to").withIsEditable(false)
.withPossibleValueSourceName(GenerationAuthoringProcesses.SOURCES)
.withHelpContent(GenerationTables.help("The table whose records this template is generated for. It is set on the template and cannot be changed here.")));
}
QFieldMetaData record = new QFieldMetaData("sourceRecordId", QFieldType.STRING).withLabel("Record (only for a record preview)").withMaxLength(255)
.withHelpContent(GenerationTables.help("The record to draw the label with. Only records you can read are offered, and one you cannot read is refused as not found. "
+ "Its values are used for this one picture and are not stored or logged."));
if(selector)
{
record.setPossibleValueSourceName(GenerationAuthoringProcesses.PREVIEW_SOURCE);
}
options.withFormField(record)
.withFormField(new QFieldMetaData("stockId", QFieldType.INTEGER).withLabel("Stock (blank = " + (draft ? "the template's own)" : "the revision's own)"))
.withPossibleValueSourceName(GenerationTables.STOCK)
.withHelpContent(GenerationTables.help("Draw the label on another stock to see how the layout behaves there. The template is not changed.")))
.withFormField(new QFieldMetaData("targetDpi", QFieldType.INTEGER).withLabel("Resolution (blank = the stock's own)").withPossibleValueSourceName(RESOLUTION)
.withHelpContent(GenerationTables.help("The printer resolution to draw for. A version 2 scene can be drawn at 203, 300 or 600 dpi; "
+ "a version 1 scene only at the resolution of its stock.")));
instance.addProcess(new QProcessMetaData().withName(name(table)).withTableName(table).withLabel("Preview layout").withIcon(new QIcon("image"))
.withMinInputRecords(1).withMaxInputRecords(1)
.withHelpContent(QHelpContent.SLOT_HELP, GenerationTables.help((draft ? "Draw the current draft of this template" : "Draw this published revision")
+ " as the label that would print, with its warnings and refusals, before anything is printed. Nothing is saved and no file is created."))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(Permission.class)))
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class))
.withInputData(new QFunctionInputMetaData().withRecordListMetaData(new QRecordListMetaData().withTableName(table))))
.withStep(options)
.withStep(new QBackendStepMetaData().withName("check").withCode(new QCodeReference(Check.class)))
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Layout preview")
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.WIDGET).withValue("widgetName", GenerationPreviewWidget.NAME))
.withHelpContent(GenerationTables.help("The picture is decoded from the exact bytes that would be sent to the printer. The first line says whether it is exact "
+ "and whether the label can print; the lines below the picture give its size and each refusal, warning and note with the element it concerns."))));
}
}
/*******************************************************************************
** One stable name per table: the draft on a template, the snapshot on a revision.
*******************************************************************************/
static String name(String table)
{
return (table + "_preview_layout");
}
/*******************************************************************************
** The choices an accepted form left in the state of the person's own run,
** for the widget of the result step. QQQ answers only the person who started
** the run, in the session that started it, and only while they may still run
** the action; a run that is unknown, has expired or holds no choices is one
** refusal. The choices are authorised again by the preview service.
*******************************************************************************/
static DocumentPreviewService.Options choices(String processUUID) throws QException
{
Optional<ProcessState> state = processUUID != null && processUUID.matches(RUN) ? RunProcessAction.getStateForUser(processUUID, null) : Optional.empty();
if(state.isEmpty() || !(state.get().getValues().get(CHOICES) instanceof Map<?, ?> chosen))
{
throw new QBadRequestException("This preview is no longer available. Run Preview layout again.");
}
return (options(name -> chosen.get(name) == null ? null : String.valueOf(chosen.get(name))));
}
/*******************************************************************************
** The choices of the form, as the form submits them or as the state of the
** run holds them. Anything that is not a choice the form offers is refused.
*******************************************************************************/
static DocumentPreviewService.Options options(UnaryOperator<String> value) throws QBadRequestException
{
try
{
return (new DocumentPreviewService.Options(integer(value.apply("previewTemplateId")), integer(value.apply("previewRevisionId")), GenerationPorts.Format.ZPL,
DocumentPreviewService.Data.valueOf(value.apply("dataSource")), text(value.apply("sampleName")), text(value.apply("sourceRecordId")),
integer(value.apply("stockId")), integer(value.apply("targetDpi"))));
}
catch(RuntimeException invalid)
{
throw new QBadRequestException("Choose the data, the stock and the resolution of the preview from the form.");
}
}
/*******************************************************************************
** An absent or blank value is no choice.
*******************************************************************************/
private static String text(String value)
{
return (value == null || value.isBlank() ? null : value);
}
/*******************************************************************************
** A whole number, or no choice.
*******************************************************************************/
private static Integer integer(String value)
{
return (text(value) == null ? null : Integer.valueOf(value.trim()));
}
/*******************************************************************************
** Who may open the action: an author on a template, a viewer on a revision.
** A record preview also needs `generate`, which the service checks when a
** record is chosen.
*******************************************************************************/
public static class Permission implements CustomPermissionChecker
{
/*******************************************************************************
** Discovery and direct execution both apply the rule.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
GenerationSecurity.require(name(GenerationTables.TEMPLATE).equals(metadata.getName()) ? "configure" : "view");
}
}
/*******************************************************************************
** Before the form: which template or revision, its source table, and the
** samples its scene declares.
*******************************************************************************/
public static class Prepare implements BackendStep
{
/*******************************************************************************
** Only the selected record's identity is used; everything else is read
** again under the person's authority.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new Permission().checkPermissionsThrowing(input, input.getProcess());
if(input.getRecords() == null || input.getRecords().size() != 1)
{
throw new QBadRequestException("Select one template or one published revision.");
}
Integer id = input.getRecords().getFirst().getValueInteger("id");
boolean draft = name(GenerationTables.TEMPLATE).equals(input.getProcess().getName());
QInstance instance = QContext.getQInstance();
DocumentPreviewService.Choices choices = new DocumentPreviewService(GenerationProcesses.config(instance)).choices(draft ? id : null, draft ? null : id);
output.addValue(draft ? "previewTemplateId" : "previewRevisionId", id);
output.addValue("sourceTable", choices.sourceTable());
output.addValue("dataSource", DocumentPreviewService.Data.SAMPLE.name());
ProcessMetaDataAdjustment adjustment = new ProcessMetaDataAdjustment();
if(!choices.sampleNames().isEmpty())
{
List<QPossibleValue<?>> names = new ArrayList<>();
choices.sampleNames().forEach(name -> names.add(new QPossibleValue<>(name, name)));
QFieldMetaData sample = field(input, "sampleName");
sample.setInlinePossibleValueSource(new QPossibleValueSource().withName("document_generation_preview_samples").withType(QPossibleValueSourceType.ENUM)
.withIdType(QFieldType.STRING).withEnumValues(names));
adjustment.withUpdatedField(sample);
}
var source = instance.getTable(choices.sourceTable());
var selector = instance.getPossibleValueSource(GenerationAuthoringProcesses.sourceRecordChoices(choices.sourceTable()));
if(source != null && selector != null)
{
QFieldMetaData record = field(input, "sourceRecordId");
record.setType(source.getField(source.getPrimaryKeyField()).getType());
record.setLabel("Record (only for a record preview): " + Objects.requireNonNullElse(source.getLabel(), source.getName()));
record.setPossibleValueSourceName(selector.getName());
adjustment.withUpdatedField(record);
}
output.setProcessMetaDataAdjustment(adjustment);
}
/*******************************************************************************
** A copy of one form field, to adjust for this run only.
*******************************************************************************/
private static QFieldMetaData field(RunBackendStepInput input, String name)
{
return (input.getProcess().getFrontendStep("options").getFormFields().stream().filter(candidate -> name.equals(candidate.getName())).findFirst().orElseThrow().clone());
}
}
/*******************************************************************************
** After the form: refuse at once what the preview would refuse, so the
** person sees the reason on the form. Nothing is drawn, and nothing is kept
** but the choices.
*******************************************************************************/
public static class Check implements BackendStep
{
/*******************************************************************************
** The same checks the widget's request makes again when it draws. The
** accepted choices then leave the plain process values, which a client
** sends with the widget request, for the one structured value the widget
** reads from the state of the run.
*******************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new Permission().checkPermissionsThrowing(input, input.getProcess());
new DocumentPreviewService(GenerationProcesses.config(QContext.getQInstance())).check(options(input::getValueString));
LinkedHashMap<String, String> chosen = new LinkedHashMap<>();
for(String name : Stream.concat(Stream.of("previewTemplateId", "previewRevisionId"), FORM.stream()).toList())
{
String value = input.getValueString(name);
if(value != null)
{
chosen.put(name, value);
}
}
FORM.forEach(output.getValues()::remove);
output.addValue(CHOICES, chosen);
}
}
}
@@ -63,7 +63,11 @@ public class GenerationPreviewSources implements QCustomPossibleValueProvider<Se
@Override
public List<QPossibleValue<Serializable>> search(SearchPossibleValueSourceInput input) throws QException
{
GenerationSecurity.require("configure");
/////////////////////////////////////////////////////////////////////////////////////////////
// a record is chosen to generate from or to preview with, which both need `generate`; an //
// author previews a draft and a viewer previews a published revision, so `configure` is //
// not required of someone who may only generate. Every record is still read as the caller //
/////////////////////////////////////////////////////////////////////////////////////////////
GenerationSecurity.require("generate");
var instance = QContext.getQInstance();
var source = instance.getPossibleValueSource(input.getPossibleValueSourceName());
@@ -0,0 +1,444 @@
/*
* 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.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageBlockData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageStyles;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageValues;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextStyles;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextValues;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
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.model.metadata.tables.Tier;
/*******************************************************************************
** The label preview as a standard composite widget: a line that says whether
** the picture is what prints, the picture itself (the page's PNG as a data
** URL, at the label's physical size), the facts, and one line per diagnostic.
**
** It appears in two places with no host code. On the template record screen
** it is the "Label preview" section, drawn with sample data only, so opening
** a template never renders record data. In the "Preview layout" action it is
** the result step, drawn from the choices the person made. Either way the
** client fetches the widget, the picture is drawn for that request and
** returned in its answer, and nothing of it is kept on the server: not in a
** table, not in a file and not in the process's state.
**
** The request names a template record (the record screen) or a run of the
** action (the result step) and nothing else is read from it: no revision,
** record, sample, stock or resolution is taken from a request parameter. QQQ's
** widget route logs the parameters of a request that fails or takes over a
** second, so what a request carries is what that log can hold: the widget's
** name with a table name and a template identity, or with the identifier of a
** run and the plain process values a client adds to it. After the form those
** are the template or revision identity, the source table's name and whatever
** the client itself sent to start the run (its selection of the template or
** revision row); no choice of the form is among them.
**
** A widget renderer cannot set response headers and the widget route sets no
** `Cache-Control`, so the host or its proxy must send `no-store` for widget
** responses (see the README).
*******************************************************************************/
final class GenerationPreviewWidget
{
static final String NAME = "document_generation_preview";
static final String SECTION = "labelPreview";
static final int MAXIMUM_IMAGE_BYTES = 2 * 1024 * 1024;
static final int MAXIMUM_LINES = 50;
////////////////////////////////////////////////////////////////////////////////////////////////
// the closed set of verdicts for a label whose picture is not drawn although nothing says it //
// cannot print: a preview paints at most sixteen times the page and draws at most sixteen //
// million dots, and neither limit is a limit of printing //
////////////////////////////////////////////////////////////////////////////////////////////////
static final String TOO_DENSE = "This label is too dense to preview; printing is not affected.";
static final String TOO_LARGE = "This label is too large to preview; printing is not affected.";
////////////////////////////////////////////////////////////////////////////////////////////////
// one plain sentence for each code of the closed vocabulary that a preview can report; a //
// code without one is shown by its name, element and numbers alone //
////////////////////////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> MEANINGS = Map.ofEntries(
Map.entry("SCENE_INVALID", "The scene is not valid at the place named."),
Map.entry("STOCK_MISMATCH", "The scene was drawn for another stock size."),
Map.entry("TARGET_REQUIRES_SCENE_2", "Only a version 2 scene can be drawn at a resolution other than its stock's."),
Map.entry("ELEMENT_OUTSIDE_PRINTABLE_AREA", "The element reaches outside the printable area."),
Map.entry("TEXT_DOES_NOT_FIT", "The text does not fit its box."),
Map.entry("VALUE_MISSING", "The data has no value for this element."),
Map.entry("VALUE_TOO_LONG", "The value is longer than the maximum length the element declares."),
Map.entry("FONT_UNKNOWN", "The font is not available."),
Map.entry("FONT_MISSING_GLYPH", "The font has no shape for a character of the value."),
Map.entry("RESERVED_FONT_NAME", "An included font uses a name reserved for the bundled fonts."),
Map.entry("BARCODE_DOES_NOT_FIT", "The barcode does not fit its box at the smallest module allowed."),
Map.entry("GS1_INVALID", "The GS1 data is not valid."),
Map.entry("ASSET_UNKNOWN", "The scene names an asset the template does not include."),
Map.entry("PREVIEW_TOO_LARGE", "The picture would be too large to draw."),
Map.entry("RENDERER_TOO_OLD", "The renderer in use cannot draw this yet."),
Map.entry("TEXT_TRUNCATED", "The text was cut short."),
Map.entry("TEXT_SMALL_FOR_DPI", "The text is small for this resolution."),
Map.entry("TEXT_LIGHT_FACE", "A light face may print faintly at this size."),
Map.entry("FIT_NOT_PROVEN", "No maximum length is declared, so the fit is not proven for every value."),
Map.entry("BARCODE_MODULE_BELOW_RECOMMENDED", "The barcode's narrow bar is below the recommended width."),
Map.entry("QUIET_ZONE_INTRUDED", "Something intrudes on the barcode's quiet zone."),
Map.entry("ELEMENTS_OVERLAP", "Elements overlap."),
Map.entry("IMAGE_UPSCALED", "The image was enlarged and may look coarse."),
Map.entry("DEVICE_UNIT_USED", "A device unit (dots) is used, so the layout depends on the resolution."),
Map.entry("PRINTER_FONT_NOT_SHOWN", "Printer font text cannot be drawn; its estimated area is outlined and crossed."),
Map.entry("TEXT_SHRUNK", "The text was reduced to fit."),
Map.entry("TEXT_WRAPPED", "The text was wrapped."),
Map.entry("BARCODE_MODULE", "The width of the barcode's narrow bar."),
Map.entry("IMAGE_SCALED", "The image was scaled."));
/*******************************************************************************
** Metadata namespace.
*******************************************************************************/
private GenerationPreviewWidget()
{
}
/*******************************************************************************
** Called once for the template table, after its standard sections: the
** preview goes directly below the overview.
*******************************************************************************/
static void section(QTableMetaData table)
{
List<QFieldSection> sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections());
sections.add(Math.min(1, sections.size()), new QFieldSection(SECTION, new QIcon("image"), Tier.T2).withLabel("Label preview").withWidgetName(NAME)
.withHelpContent(GenerationTables.help("The label as it would print, drawn from the current draft with the first sample the scene declares, at the stock's own resolution. "
+ "It is drawn each time the record opens and nothing is saved. Use Preview layout to choose other data, another stock or another resolution.")));
table.setSections(sections);
}
/*******************************************************************************
** The widget a host gets by registering the QBit. Its renderer decides what
** each person may see, so the rule here is only that the person holds one of
** the existing permissions a preview can be drawn under.
*******************************************************************************/
static QWidgetMetaData metaData()
{
return (new QWidgetMetaData().withName(NAME).withType(WidgetType.COMPOSITE.getType()).withLabel("Label preview").withIsCard(true).withGridColumns(12)
.withShowReloadButton(true).withCodeReference(new QCodeReference(Renderer.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(Permission.class)))
.withHelpContent("label", GenerationTables.help("The picture is decoded from the exact bytes that would be sent to the printer, one pixel per printer dot. "
+ "Refusals mean the label cannot print as it stands; warnings do not stop printing. Reload after editing the layout.")));
}
/*******************************************************************************
** A person with none of the document permissions never reaches the renderer.
*******************************************************************************/
public static class Permission implements CustomPermissionChecker
{
/*******************************************************************************
** Discovery and direct execution both apply the rule.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
var session = QContext.getQSession();
if(session == null || Stream.of("view", "configure", "generate").noneMatch(suffix -> session.hasPermission("document-generation." + suffix)))
{
throw new QPermissionDeniedException("Document Generation permission denied.");
}
}
}
/*******************************************************************************
** The standard widget route: the request names a template record or a run of
** the action, and what it leads to is authorised again by the preview service.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Draw for this request only; the answer is not stored.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(widget(GenerationProcesses.config(QContext.getQInstance()), input.getQueryParams() == null ? Map.of() : input.getQueryParams())));
}
}
/*******************************************************************************
** The whole widget for one request. A refusal this module states in its own
** words (no source to draw, an unreadable record, an unknown sample) is one
** line; a failure of the renderer is a fixed sentence, never its message; a
** label too dense to draw is said to be so, and to print all the same; a
** missing permission stays a denial.
*******************************************************************************/
static CompositeWidgetData widget(DocumentGenerationQBitConfig config, Map<String, String> parameters) throws QException
{
CompositeWidgetData data = new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN);
try
{
DocumentPreviewService service = new DocumentPreviewService(config);
DocumentPreviewService.Options options = options(service, parameters);
blocks(data, options, service.preview(options));
}
catch(QPermissionDeniedException denied)
{
throw (denied);
}
catch(QBadRequestException | QNotFoundException refused)
{
data.addBlock(line(refused.getMessage(), TextStyles.StandardColor.WARNING, true));
}
catch(GenerationPorts.RenderException named)
{
boolean dense = named.code() == GenerationPorts.FailureCode.PREVIEW_TOO_LARGE;
data.addBlock(line(dense ? TOO_DENSE : "The preview could not be drawn (" + named.code() + ").", dense ? TextStyles.StandardColor.WARNING : TextStyles.StandardColor.ERROR, true));
}
catch(QException failure)
{
data.addBlock(line("The preview could not be drawn. A label that takes its values from a record needs sample data in its scene, or use Preview layout with a record.",
TextStyles.StandardColor.ERROR, true));
}
return (data);
}
/*******************************************************************************
** On a record screen the client sends the table and the record: the draft
** for a person who may author it, the published revision for a person who
** may only view it, always with sample data. In the action the client sends
** the identifier of the run, and the choices of the form are read from the
** state of that run; whatever else the request carries is not read.
*******************************************************************************/
private static DocumentPreviewService.Options options(DocumentPreviewService service, Map<String, String> parameters) throws QException
{
if(parameters.containsKey("processUUID"))
{
return (GenerationPreviewProcesses.choices(parameters.get("processUUID")));
}
if(!GenerationTables.TEMPLATE.equals(parameters.get("tableName")))
{
throw new QBadRequestException("The label preview belongs to a document template.");
}
DocumentPreviewService.Options screen = GenerationPreviewProcesses.options(name -> switch(name)
{
case "previewTemplateId" -> parameters.get("id");
case "dataSource" -> "SAMPLE";
default -> null;
});
if(QContext.getQSession().hasPermission("document-generation.configure"))
{
return (screen);
}
Integer published = service.publishedRevision(screen.templateId());
if(published == null)
{
throw new QBadRequestException("This template has no published revision to preview.");
}
return (new DocumentPreviewService.Options(null, published, screen.format(), screen.data(), null, null, null, null));
}
/*******************************************************************************
** The verdict, what was drawn, the picture, the facts and the diagnostics.
*******************************************************************************/
private static void blocks(CompositeWidgetData data, DocumentPreviewService.Options options, DocumentPreviewService.Result result)
{
GenerationPorts.Preview preview = result.preview();
boolean page = !preview.pages().isEmpty();
List<GenerationPorts.Diagnostic> refusals = preview.diagnostics().stream().filter(diagnostic -> diagnostic.severity() == GenerationPorts.Diagnostic.Severity.ERROR).toList();
if(!page && !refusals.isEmpty() && refusals.stream().allMatch(refusal -> GenerationPorts.FailureCode.PREVIEW_TOO_LARGE.name().equals(refusal.code())))
{
/////////////////////////////////////////////////////////////////////////////////////
// the only thing refused is the picture: the label was not found unprintable, it //
// would paint or measure more than a preview draws //
/////////////////////////////////////////////////////////////////////////////////////
data.addBlock(line(refusals.getFirst().limit().containsKey("paintedPercent") ? TOO_DENSE : TOO_LARGE, TextStyles.StandardColor.WARNING, true));
}
else if(!preview.printable())
{
data.addBlock(line("This layout cannot print. " + (page ? "The picture shows only where the refused elements are." : "There is nothing to draw."),
TextStyles.StandardColor.ERROR, true));
}
else if(preview.exact())
{
data.addBlock(line("This is the picture that would print.", TextStyles.StandardColor.SUCCESS, true));
}
else
{
data.addBlock(line("This picture is not exact: what the printer draws itself is shown as an outlined, crossed area.", TextStyles.StandardColor.WARNING, true));
}
String values = options.data() == DocumentPreviewService.Data.SAMPLE ? "sample data" : options.data() == DocumentPreviewService.Data.LONGEST ? "longest values" : "one record";
data.addBlock(line((options.revisionId() == null ? "Current draft" : "Published revision " + options.revisionId()) + ", " + values + ", " + result.dpi() + " dpi.", null, false));
if(page)
{
GenerationPorts.PreviewPage first = preview.pages().getFirst();
byte[] png = first.png();
if(png.length <= MAXIMUM_IMAGE_BYTES)
{
data.addBlock(new ImageBlockData().withValues(new ImageValues().withPath("data:image/png;base64," + Base64.getEncoder().encodeToString(png))
.withAlt("Label preview, " + first.widthDots() + " by " + first.heightDots() + " dots"))
.withStyles(new ImageStyles().withWidth("min(100%, " + millimetres(first.widthDots(), result.dpi()) + "mm)")));
}
else
{
data.addBlock(line("The picture is " + png.length + " bytes, which is too large to show here.", TextStyles.StandardColor.WARNING, false));
}
data.addBlock(line("Size: " + first.widthDots() + " x " + first.heightDots() + " dots at " + result.dpi() + " dpi (" + millimetres(first.widthDots(), result.dpi()) + " x "
+ millimetres(first.heightDots(), result.dpi()) + " mm). Output: " + preview.outputBytes() + " bytes, SHA-256 " + preview.outputSha256() + ".", null, false));
}
List<GenerationPorts.Diagnostic> ordered = preview.diagnostics().stream().sorted(Comparator.comparing(GenerationPorts.Diagnostic::severity)).toList();
ordered.stream().limit(MAXIMUM_LINES).forEach(diagnostic -> data.addBlock(line(sentence(diagnostic, result.dpi()),
TextStyles.StandardColor.valueOf(diagnostic.severity().name()), false)));
if(ordered.size() > MAXIMUM_LINES)
{
data.addBlock(line("And " + (ordered.size() - MAXIMUM_LINES) + " more.", null, false));
}
if(ordered.isEmpty())
{
data.addBlock(line("No refusals and no warnings.", null, false));
}
}
/*******************************************************************************
** One diagnostic as a sentence: a refusal, a warning or a note, its code, the
** element the author named, what it means, and the measured values against
** the limits in dots and in physical units. A diagnostic holds identifiers
** and integers only, so no record value can appear here.
*******************************************************************************/
static String sentence(GenerationPorts.Diagnostic diagnostic, int dpi)
{
GenerationPorts.Diagnostic.Severity severity = diagnostic.severity();
StringBuilder text = new StringBuilder(severity == GenerationPorts.Diagnostic.Severity.ERROR ? "Refusal" : severity == GenerationPorts.Diagnostic.Severity.WARNING ? "Warning" : "Note")
.append(": ").append(diagnostic.code());
if(diagnostic.element() != null)
{
text.append(", element '").append(diagnostic.element()).append('\'');
}
if(diagnostic.pointer() != null)
{
text.append(", at ").append(diagnostic.pointer().isEmpty() ? "the scene" : diagnostic.pointer());
}
text.append('.');
if(MEANINGS.containsKey(diagnostic.code()))
{
text.append(' ').append(MEANINGS.get(diagnostic.code()));
}
if(!diagnostic.measured().isEmpty())
{
text.append(" Measured ").append(numbers(diagnostic.measured(), dpi)).append(diagnostic.limit().isEmpty() ? "." : " against a limit of " + numbers(diagnostic.limit(), dpi) + ".");
}
else if(!diagnostic.limit().isEmpty())
{
text.append(" Limit ").append(numbers(diagnostic.limit(), dpi)).append('.');
}
return (text.toString());
}
/*******************************************************************************
** Named integers in a fixed order, each with its unit.
*******************************************************************************/
private static String numbers(Map<String, Integer> values, int dpi)
{
return (String.join(", ", new TreeMap<>(values).entrySet().stream().map(entry -> number(entry.getKey(), entry.getValue(), dpi)).toList()));
}
/*******************************************************************************
** A value by the unit its name ends with: dots also in millimetres at the
** resolution drawn, hundredths of a point as points, micrometres as
** millimetres, a percentage as such, and anything else as the bare number.
*******************************************************************************/
private static String number(String name, int value, int dpi)
{
if(name.endsWith("Dots"))
{
return (words(name, 4) + " " + value + " dots (" + millimetres(value, dpi) + " mm)");
}
if(name.endsWith("Centipoints"))
{
return (words(name, 11) + " " + BigDecimal.valueOf(value, 2).stripTrailingZeros().toPlainString() + " pt");
}
if(name.endsWith("Micrometres"))
{
return (words(name, 11) + " " + BigDecimal.valueOf(value, 3).stripTrailingZeros().toPlainString() + " mm");
}
if(name.endsWith("Percent"))
{
return (words(name, 7) + " " + value + "%");
}
return (words(name, 0) + " " + value);
}
/*******************************************************************************
** A value's name without its unit, as lower-case words.
*******************************************************************************/
private static String words(String name, int unit)
{
return (name.substring(0, name.length() - unit).replaceAll("([a-z0-9])([A-Z])", "$1 $2").toLowerCase(Locale.ROOT));
}
/*******************************************************************************
** Dots as millimetres at a resolution, to a tenth.
*******************************************************************************/
private static String millimetres(int dots, int dpi)
{
return (BigDecimal.valueOf(dots * 254L).divide(BigDecimal.valueOf(dpi * 10L), 1, RoundingMode.HALF_UP).toPlainString());
}
/*******************************************************************************
** One line of text, coloured by what it reports and boxed when it is the
** verdict.
*******************************************************************************/
private static TextBlockData line(String text, TextStyles.StandardColor color, boolean verdict)
{
TextStyles styles = new TextStyles();
if(color != null)
{
styles.withColor(color);
}
if(verdict)
{
styles.withFormat(TextStyles.StandardFormat.ALERT).withWeight(TextStyles.StandardWeight.BOLD);
}
return (new TextBlockData().withValues(new TextValues(text)).withStyles(styles));
}
}
@@ -128,6 +128,10 @@ public final class GenerationProcesses
form.withFormField(new QFieldMetaData("templateId", QFieldType.INTEGER).withLabel("Published template").withPossibleValueSourceName(GenerationTables.TEMPLATE));
form.withFormField(new QFieldMetaData("format", QFieldType.STRING).withLabel("Output format").withPossibleValueSourceName(GenerationAuthoringProcesses.FORMAT));
}
if("generate".equals(operation))
{
form.withFormField(new QFieldMetaData("targetDpi", QFieldType.INTEGER).withLabel("Render target in dpi: 203, 300 or 600 (optional, ZPL only; default is the stock's resolution)"));
}
if(List.of("generate", "enqueue", "reprint").contains(operation))
{
form.withFormField(new QFieldMetaData("destination", QFieldType.STRING).withLabel("Print destination (optional for generation)").withMaxLength(255));
@@ -261,7 +265,7 @@ public final class GenerationProcesses
throw new QBadRequestException("Select PDF or ZPL output.");
}
result = "generate".equals(operation)
? service.generate(new DocumentGenerationService.Request(input.getValueInteger("templateId"), sources.getFirst(), format, input.getValueString("requestKey"), input.getValueString("destination")))
? service.generate(request(input, sources.getFirst(), format))
: new GenerationQueue(config).enqueue(input.getValueInteger("templateId"), sources, format, input.getValueString("requestKey"), input.getValueString("destination"));
}
else
@@ -335,6 +339,24 @@ public final class GenerationProcesses
}
}
/*******************************************************************************
** The generate action's intent: one record, with the optional render target.
** A target that is not a resolution a printer can have is refused here, as a
** bad request, before the service sees it.
*******************************************************************************/
private static DocumentGenerationService.Request request(RunBackendStepInput input, GenerationPorts.Source source, GenerationPorts.Format format) throws QException
{
try
{
return (new DocumentGenerationService.Request(input.getValueInteger("templateId"), source, format, input.getValueString("requestKey"), input.getValueString("destination"),
null, null, 1, input.getValueInteger("targetDpi")));
}
catch(IllegalArgumentException invalid)
{
throw new QBadRequestException("A render target is 203, 300 or 600 dpi.");
}
}
/*******************************************************************************
** Status exposes only the currently authorized request, never hidden context.
*******************************************************************************/
@@ -344,7 +366,7 @@ public final class GenerationProcesses
QRecord row = GenerationStore.get(GenerationTables.REQUEST, id);
GenerationRetainedAccess.require(row, false);
java.util.Map<String, java.io.Serializable> result = new java.util.LinkedHashMap<>();
for(String field : List.of("id", "state", "format", "tableName", "recordId", "revisionId", "fileId", "versionId", "sha256", "length", "available", "printJobId", "errorCode", "mediaProfileId", "mediaProfileRevision"))
for(String field : List.of("id", "state", "format", "tableName", "recordId", "revisionId", "fileId", "versionId", "sha256", "length", "available", "printJobId", "errorCode", "mediaProfileId", "mediaProfileRevision", "targetDpi"))
{
result.put(field, row.getValue(field));
}
@@ -90,7 +90,7 @@ public final class GenerationTables
.withField(string("actorId", 255)).withField(string("tableName", 128)).withField(string("recordId", 255))
.withField(integer("templateId")).withField(integer("revisionId")).withField(string("format", 16))
.withField(string("destination", 255)).withField(string("state", 32)).withField(string("fileId", 255))
.withField(string("mediaProfileId", 128)).withField(string("mediaProfileRevision", 64)).withField(integer("copies"))
.withField(string("mediaProfileId", 128)).withField(string("mediaProfileRevision", 64)).withField(integer("copies")).withField(integer("targetDpi"))
.withField(string("versionId", 255)).withField(string("sha256", 64)).withField(new QFieldMetaData("length", QFieldType.LONG))
.withField(string("printJobId", 255)).withField(string("errorCode", 64)).withField(string("resolvedFileName", 184))
.withUniqueKey(new UniqueKey("actorId", "requestKey"))
@@ -173,15 +173,18 @@ public final class GenerationTables
table.getField("id").setLabel("Template ID");
table.withSection(new QFieldSection("overview", new QIcon("description"), Tier.T1,
List.of("name", "typeId", "stockId", "tableName", "publishedRevisionId")).withLabel("Overview")
.withHelpContent(help("Choose Preview draft from this record's actions, select a source record and PDF or ZPL, then open Preview or Download on the result. Preview uses the current draft and does not publish it.")));
.withHelpContent(help("Choose Preview layout from this record's actions to see the label that would print, with sample data, its longest values or a record; nothing is saved. "
+ "Generate retained preview file creates an audited file (PDF or ZPL) from a source record and the current draft, to open or download; it does not publish the draft.")));
table.withSection(new QFieldSection("layout", new QIcon("code"), Tier.T2, List.of("pdfSource", "zplSource")).withLabel("Layout")
.withCollapsible(CollapsibleMetaData.INITIALLY_CLOSED)
.withHelpContent(help("Use Edit layout to change PDF HTML or the ZPL scene. Preview draft checks the result against a source record; Validate and publish draft makes an immutable revision available for generation.")));
.withHelpContent(help("Use Edit layout to change PDF HTML or the ZPL scene. Preview layout shows the label and its warnings before anything is printed; "
+ "Validate and publish draft makes an immutable revision available for generation.")));
table.withSection(new QFieldSection("context", new QIcon("data_object"), Tier.T2, List.of("outputBaseName", "outputNameContextKey")).withLabel("Context & output")
.withHelpContent(help("Choose Select context script to select a published, context-tested script or explicitly clear it. Without a script, templates use the authorized source record under record. Use Edit output filename to configure naming.")));
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,4 @@
-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0
-- Apply once before adopting render-target-aware Generation metadata.
-- Existing requests keep a null target, which is read as the stock's own resolution: their identity digests and retries are unchanged.
ALTER TABLE document_generation_request ADD COLUMN target_dpi INTEGER NULL;
@@ -0,0 +1,4 @@
-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0
-- Apply once before adopting render-target-aware Generation metadata.
-- Existing requests keep a null target, which is read as the stock's own resolution: their identity digests and retries are unchanged.
ALTER TABLE document_generation_request ADD COLUMN target_dpi INT NULL;
@@ -0,0 +1,5 @@
-- Copyright (c) 2026 Kingsrook, LLC. SPDX-License-Identifier: Apache-2.0
-- Apply once before adopting render-target-aware Generation metadata.
-- Existing requests keep a null target, which is read as the stock's own resolution: their identity digests and retries are unchanged.
-- PostgreSQL17: re-runnable via IF NOT EXISTS, no rewrite of existing rows.
ALTER TABLE document_generation_request ADD COLUMN IF NOT EXISTS target_dpi INTEGER NULL;
@@ -453,6 +453,239 @@ class GenerationExpectedIntentTest
assertThat(submissions).hasValue(1);
}
/***************************************************************************
** Publish a label on the fixture's template and install a renderer that
** declares it renders at the request's target and records each target.
***************************************************************************/
private List<Integer> labelRenderer() throws Exception
{
List<Integer> targets = new java.util.ArrayList<>();
fixture.config.withRenderer(new GenerationPorts.IsolatedRenderer()
{
/***************************************************************************
** The fixture renders at the target, as the renderer bridge declares.
***************************************************************************/
@Override
public boolean supportsRenderTarget()
{
return (true);
}
/***************************************************************************
** Accept every revision.
***************************************************************************/
@Override
public void validate(String actor, GenerationPorts.Revision revision)
{
}
/***************************************************************************
** Record the target and return bytes that differ by it.
***************************************************************************/
@Override
public GenerationPorts.Content render(GenerationPorts.RenderRequest request)
{
targets.add(request.targetDpi());
return (new GenerationPorts.Content(("label at " + request.targetDpi()).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
}
});
definitions.saveDraft(fixture.template, fixture.stock, new DocumentDefinitionService.Draft("page", "{\"schema\":\"qqq-zpl-scene/2\"}", null, List.of()));
definitions.publish(fixture.template);
return (targets);
}
/***************************************************************************
** A label intent for the first order, with or without a render target.
***************************************************************************/
private DocumentGenerationService.Request label(String key, Integer targetDpi)
{
return (new DocumentGenerationService.Request(fixture.template, new GenerationPorts.Source("orders", "1"), GenerationPorts.Format.ZPL, key, null, null, null, 1, targetDpi));
}
/***************************************************************************
** The intent digest as it was framed before the render target existed, with
** the target's own frame added when one is given. Written out here so the
** test does not borrow the service's arithmetic.
***************************************************************************/
private String digest(DocumentGenerationService.Result result, String key, Integer targetDpi) throws Exception
{
List<String> identity = new java.util.ArrayList<>(List.of("document-generation-intent/v1", "alice", key, "GENERATE", "6:orders1:1", String.valueOf(result.accepted().templateId()),
String.valueOf(result.accepted().revisionId()), result.accepted().stock().toString(), "ZPL", ""));
if(targetDpi != null)
{
identity.addAll(List.of("target-dpi/v1", String.valueOf(targetDpi)));
}
StringBuilder framed = new StringBuilder();
identity.forEach(value -> framed.append(value.length()).append(':').append(value));
return (java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(framed.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8))));
}
/***************************************************************************
** A render target travels from the request to the row and to the renderer,
** and is part of the intent: the same key with another target, or with
** none, is another intent and is refused. A request without a target has
** exactly the digest it had before the target existed.
***************************************************************************/
@Test
void aRenderTargetIsCarriedPersistedAndPartOfTheIntent() throws Exception
{
List<Integer> targets = labelRenderer();
var none = service.generate(label("label:target-none", null));
var three = service.generate(label("label:target-300", 300));
var six = service.generate(label("label:target-600", 600));
assertThat(targets).containsExactly(null, 300, 600);
assertThat(service.generate(label("label:target-300", 300))).isEqualTo(three);
assertThat(service.generate(label("label:target-none", null))).isEqualTo(none);
assertThat(targets).hasSize(3);
for(var other : List.of(label("label:target-300", null), label("label:target-300", 203), label("label:target-300", 600), label("label:target-none", 203), label("label:target-none", 300)))
{
assertThatThrownBy(() -> service.generate(other)).hasMessageContaining("different generation intent");
}
assertThat(targets).hasSize(3);
assertThat(none.intentSha256()).isEqualTo(digest(none, "label:target-none", null));
assertThat(three.intentSha256()).isEqualTo(digest(three, "label:target-300", 300)).isNotEqualTo(digest(three, "label:target-300", null)).isNotEqualTo(digest(three, "label:target-300", 600));
assertThat(six.intentSha256()).isEqualTo(digest(six, "label:target-600", 600));
try(var connection = fixture.connection(); var statement = connection.createStatement();
var rows = statement.executeQuery("SELECT request_key, target_dpi FROM document_generation_request ORDER BY id"))
{
for(Integer expected : new Integer[] {null, 300, 600})
{
assertThat(rows.next()).isTrue();
int stored = rows.getInt(2);
assertThat(rows.wasNull() ? null : Integer.valueOf(stored)).isEqualTo(expected);
}
assertThat(rows.next()).isFalse();
}
///////////////////////////////////////////////////////////////////////////////////////////
// a stored target that no printer has is corrupt: it refuses, and renders at no dpi //
///////////////////////////////////////////////////////////////////////////////////////////
fixture.sql("UPDATE document_generation_request SET target_dpi = 250 WHERE request_key = 'label:target-300'");
assertThatThrownBy(() -> service.generate(label("label:target-300", 300))).hasMessageContaining("Accepted render target is unavailable");
assertThat(targets).hasSize(3);
}
/***************************************************************************
** A target that cannot be honoured is refused before any row, render or
** event: a value that is not a printer resolution, a target for a PDF, and
** a renderer that does not declare it renders at a target (it would ignore
** the value and print at another resolution). Without a target that same
** renderer works as it always did.
***************************************************************************/
@Test
void aRenderTargetThatCannotBeHonouredIsRefusedBeforeAnyEffect() throws Exception
{
for(int invalid : new int[] {0, 72, 200, 204, 1200, -300})
{
assertThatThrownBy(() -> label("label:invalid-target", invalid)).isInstanceOf(IllegalArgumentException.class).hasMessageContaining("203, 300 or 600");
}
var page = request("label:pdf-target");
var pdf = new DocumentGenerationService.Request(page.templateId(), page.source(), page.format(), page.requestKey(), null, null, null, 1, 300);
assertThatThrownBy(() -> service.generate(pdf)).hasMessageContaining("A render target applies to ZPL output only");
assertThatThrownBy(() -> service.generate(label("label:unsupported-target", 300))).hasMessageContaining("RENDER_TARGET_UNSUPPORTED");
assertThatThrownBy(() -> service.generate(label("label:unsupported-target", 203))).hasMessageContaining("RENDER_TARGET_UNSUPPORTED");
var withDestination = new DocumentGenerationService.Request(page.templateId(), page.source(), GenerationPorts.Format.ZPL, page.requestKey(), page.destination(), page.expected(), null, 1, 300);
assertThatThrownBy(() -> service.lookupAcceptedPrint(withDestination)).hasMessageContaining("RENDER_TARGET_UNSUPPORTED");
assertThatThrownBy(() -> service.preview(label("label:preview-target", 300), "0".repeat(64))).hasMessageContaining("Preview requires");
assertThat(count("document_generation_request")).isZero();
assertThat(count("document_generation_event")).isZero();
assertThat(submissions).hasValue(0);
assertThat(service.generate(page).state()).isEqualTo("QUEUED");
assertThat(count("document_generation_request")).isEqualTo(1);
}
/***************************************************************************
** A renderer that refuses the target by name (a version 1 scene asked for
** another resolution than its stock's) leaves a typed, replayable refusal
** and no output.
***************************************************************************/
@Test
void aRefusedRenderTargetIsTypedAndIsNotRenderedAgain() throws Exception
{
labelRenderer();
AtomicInteger attempts = new AtomicInteger();
fixture.config.withRenderer(new GenerationPorts.IsolatedRenderer()
{
/***************************************************************************
** The fixture reads the target and refuses it, as the bridge does.
***************************************************************************/
@Override
public boolean supportsRenderTarget()
{
return (true);
}
/***************************************************************************
** Accept every revision.
***************************************************************************/
@Override
public void validate(String actor, GenerationPorts.Revision revision)
{
}
/***************************************************************************
** Refuse a target other than the stock's resolution, by name.
***************************************************************************/
@Override
public GenerationPorts.Content render(GenerationPorts.RenderRequest request) throws QException
{
attempts.incrementAndGet();
throw new GenerationPorts.RenderException(GenerationPorts.FailureCode.TARGET_REQUIRES_SCENE_2);
}
});
for(int attempt = 0; attempt < 2; attempt++)
{
assertThatThrownBy(() -> service.generate(label("label:refused-target", 300))).isInstanceOf(GenerationPorts.RenderException.class).hasMessage("TARGET_REQUIRES_SCENE_2");
}
assertThat(attempts).hasValue(1);
try(var connection = fixture.connection(); var statement = connection.createStatement();
var rows = statement.executeQuery("SELECT state, error_code, target_dpi, version_id FROM document_generation_request"))
{
assertThat(rows.next()).isTrue();
assertThat(rows.getString(1)).isEqualTo("INCOMPLETE");
assertThat(rows.getString(2)).isEqualTo("TARGET_REQUIRES_SCENE_2");
assertThat(rows.getInt(3)).isEqualTo(300);
assertThat(rows.getString(4)).isNull();
}
}
/***************************************************************************
** The shipped additive migration leaves earlier rows without a target: they
** keep their receipt and digest, still match a retry without a target, and
** never match a retry that names one.
***************************************************************************/
@Test
void renderTargetMigrationKeepsEarlierRowsAndTheirDigests() throws Exception
{
labelRenderer();
var before = service.generate(label("label:legacy-target", null));
int events = count("document_generation_event");
fixture.sql("ALTER TABLE document_generation_request DROP COLUMN target_dpi");
for(String statement : java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/migrations/20261112-render-target-dpi-h2.sql")).split(";"))
{
if(!statement.isBlank())
{
fixture.sql(statement);
}
}
try(var connection = fixture.connection(); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT target_dpi FROM document_generation_request"))
{
assertThat(rows.next()).isTrue();
rows.getInt(1);
assertThat(rows.wasNull()).isTrue();
}
assertThat(service.generate(label("label:legacy-target", null))).isEqualTo(before);
assertThat(before.intentSha256()).isEqualTo(digest(before, "label:legacy-target", null));
assertThatThrownBy(() -> service.generate(label("label:legacy-target", 203))).hasMessageContaining("different generation intent");
assertThat(count("document_generation_event")).isEqualTo(events);
assertThat(count("document_generation_request")).isEqualTo(1);
for(String dialect : List.of("mysql", "postgres"))
{
assertThat(java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/migrations/20261112-render-target-dpi-" + dialect + ".sql")))
.contains("ALTER TABLE document_generation_request ADD COLUMN").contains("target_dpi IN").contains("NULL;");
}
}
/***************************************************************************
** A closed worker refusal is persisted and replayed without repeated render,
** while arbitrary exception messages remain the generic operational code.
@@ -0,0 +1,363 @@
/*
* 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());
assertEquals(false, adapter.supportsRenderTarget());
}
/***************************************************************************
** A job holds its own copy of the context, which may hold a null value and
** cannot be changed through the job or through the map the caller kept. A
** job written to a log shows its format and render target and nothing of
** the record or the template source.
***************************************************************************/
@Test
void aJobHoldsItsOwnContextAndNeverPrintsIt()
{
Map<String, Object> context = new HashMap<>();
context.put("name", "PRIVATE_SENTINEL");
context.put("absent", null);
var secret = new GenerationPorts.Revision(1, 2, "orders", STOCK, "<p>SOURCE_SENTINEL</p>", "{\"text\":\"SOURCE_SENTINEL\"}", null, List.of());
var preview = new GenerationPorts.PreviewRequest("actor-PRIVATE", GenerationPorts.Format.ZPL, secret, context, 300, 1024);
var render = new GenerationPorts.RenderRequest("request-1", "actor-PRIVATE", SOURCE, GenerationPorts.Format.ZPL, secret, context, 1024, 600);
context.put("name", "changed");
context.put("added", "later");
for(Map<String, Object> held : List.of(preview.context(), render.context()))
{
assertEquals("PRIVATE_SENTINEL", held.get("name"));
assertEquals(2, held.size());
assertNull(held.get("absent"));
assertEquals(true, held.containsKey("absent"));
assertThrows(UnsupportedOperationException.class, () -> held.put("more", "x"));
assertThrows(UnsupportedOperationException.class, () -> held.remove("name"));
}
assertEquals("PreviewRequest[format=ZPL, targetDpi=300]", preview.toString());
assertEquals("RenderRequest[requestId=request-1, format=ZPL, targetDpi=600]", render.toString());
assertEquals("PreviewRequest[format=PDF, targetDpi=null]", new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, secret, null, null, 1).toString());
assertNull(new GenerationPorts.PreviewRequest("actor-1", GenerationPorts.Format.PDF, secret, null, null, 1).context());
assertNull(new GenerationPorts.RenderRequest("request-1", "actor-1", SOURCE, GenerationPorts.Format.PDF, secret, null, 1).context());
assertEquals(preview, new GenerationPorts.PreviewRequest("actor-PRIVATE", GenerationPorts.Format.ZPL, secret, preview.context(), 300, 1024));
}
/***************************************************************************
** 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));
}
}
@@ -76,19 +76,21 @@ class GenerationProcessesTest
void templateRecordSeparatesAuthoringFromAdvancedIdentities()
{
var table = instance.getTable(GenerationTables.TEMPLATE);
assertThat(table.getSections()).extracting(section -> section.getName()).containsExactly("overview", "layout", "context", "advanced");
assertThat(table.getSections()).extracting(section -> section.getName()).containsExactly("overview", "labelPreview", "layout", "context", "advanced");
assertThat(table.getSections().get(1).getWidgetName()).isEqualTo(GenerationPreviewWidget.NAME);
assertThat(table.getSections().get(1).getFieldNames()).isNullOrEmpty();
assertThat(table.getSections().getFirst().getFieldNames()).contains("typeId", "stockId", "publishedRevisionId")
.doesNotContain("pdfSource", "zplSource", "scriptArtifact", "assetIds");
assertThat(table.getSections().get(1).getCollapsible().getInitiallyOpen()).isFalse();
assertThat(table.getSections().get(3).getCollapsible().getInitiallyOpen()).isFalse();
assertThat(table.getSections().stream().flatMap(section -> section.getFieldNames().stream()).toList())
assertThat(table.getSections().get(2).getCollapsible().getInitiallyOpen()).isFalse();
assertThat(table.getSections().get(4).getCollapsible().getInitiallyOpen()).isFalse();
assertThat(table.getSections().stream().filter(section -> section.getWidgetName() == null).flatMap(section -> section.getFieldNames().stream()).toList())
.containsExactlyInAnyOrderElementsOf(table.getFields().keySet());
assertThat(table.getField("typeId").getPossibleValueSourceName()).isEqualTo(GenerationTables.TYPE);
assertThat(table.getField("stockId").getPossibleValueSourceName()).isEqualTo(GenerationTables.STOCK);
assertThat(table.getField("publishedRevisionId").getPossibleValueSourceName()).isEqualTo(GenerationTables.REVISION);
assertThat(table.getField("scriptArtifact").getPossibleValueSourceName()).isNull();
var process = instance.getProcess(GenerationAuthoringProcesses.name(GenerationTables.TEMPLATE, "preview"));
assertThat(process.getLabel()).isEqualTo("Preview draft");
assertThat(process.getLabel()).isEqualTo("Generate retained preview file");
assertThat(process.getFrontendStep("result").getLabel()).contains("open or download");
assertThat(process.getFrontendStep("result").getViewFields()).anyMatch(field -> "downloadUrl".equals(field.getName()));
}
@@ -1283,6 +1285,41 @@ class GenerationProcessesTest
assertThatThrownBy(() -> execute(input)).hasMessageContaining("PDF or ZPL");
}
/***************************************************************************
** The generate action offers a render target, carries it to the service and
** refuses one that is not a printer resolution; queueing has no such option.
** The request status names the stored target.
***************************************************************************/
@Test
void generateActionCarriesTheRenderTargetOrRefusesIt() throws Exception
{
var options = (com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData) instance.getProcess(GenerationProcesses.name("orders", "generate")).getStep("options");
assertThat(options.getFormFields().stream().map(QFieldMetaData::getName)).contains("targetDpi");
var queued = (com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData) instance.getProcess(GenerationProcesses.name("orders", "enqueue")).getStep("options");
assertThat(queued.getFormFields().stream().map(QFieldMetaData::getName)).doesNotContain("targetDpi");
for(int invalid : new int[] {150, 0, 601})
{
var input = prepared("orders", "generate", 1);
input.addValue("templateId", template);
input.addValue("targetDpi", invalid);
assertThatThrownBy(() -> execute(input)).hasMessageContaining("A render target is 203, 300 or 600 dpi.");
}
var page = prepared("orders", "generate", 1);
page.addValue("templateId", template);
page.addValue("targetDpi", 300);
assertThatThrownBy(() -> execute(page)).hasMessageContaining("A render target applies to ZPL output only");
///////////////////////////////////////////////////////////////////////////////////////////
// the fixture renderer does not declare that it renders at a target, so the service, //
// which was given the value, refuses it //
///////////////////////////////////////////////////////////////////////////////////////////
var label = prepared("orders", "generate", 1);
label.addValue("templateId", template);
label.addValue("format", "ZPL");
label.addValue("targetDpi", 300);
assertThatThrownBy(() -> execute(label)).hasMessageContaining("RENDER_TARGET_UNSUPPORTED");
assertThat(new ObjectMapper().readTree(execute(prepared(GenerationTables.REQUEST, "status", generate()))).has("targetDpi")).isTrue();
}
/***************************************************************************
** Reject substituted retained versions and unsafe download links before exposing delivery metadata.
***************************************************************************/
@@ -173,6 +173,41 @@ class GenerationQueueJdbcTest
queue = new GenerationQueue(config,clock,1,30);
}
/***************************************************************************
** The database this suite runs on, as the shipped migration scripts name it.
***************************************************************************/
protected String dialect()
{
return ("h2");
}
/***************************************************************************
** The shipped render target migration for this database adds one nullable
** column to the request table: a request made before it reads as having no
** target, and generation goes on afterwards.
***************************************************************************/
@Test
void renderTargetMigrationAddsOneNullableColumn() throws Exception
{
assertThat(queue.advance(enqueue("before-target")).state()).isEqualTo("GENERATED");
sql("ALTER TABLE document_generation_request DROP COLUMN target_dpi");
for(String statement : Files.readString(Path.of("src/main/resources/db/migrations/20261112-render-target-dpi-" + dialect() + ".sql")).split(";"))
{
if(!statement.isBlank())
{
sql(statement);
}
}
try(var connection = connection(); var statement = connection.createStatement(); var rows = statement.executeQuery("SELECT target_dpi FROM document_generation_request"))
{
assertThat(rows.next()).isTrue();
rows.getInt(1);
assertThat(rows.wasNull()).isTrue();
assertThat(rows.next()).isFalse();
}
assertThat(queue.advance(enqueue("after-target")).state()).isEqualTo("GENERATED");
}
/***************************************************************************
** Clear native context, pooled connections and memory records after each database scenario.
***************************************************************************/
@@ -0,0 +1,125 @@
/*
* 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. The preview package has filled its own: a host that registers
** the QBit gets the "Preview layout" action on templates and revisions, its
** two choice lists, the preview widget and the "Label preview" section, and
** otherwise exactly what it got before. The asset and upgrade points still
** add nothing.
*******************************************************************************/
class GenerationRegistrationHooksTest
{
/***************************************************************************
** The complete default wiring: what the released foundation registered,
** plus the layout preview.
***************************************************************************/
@Test
void theProducerRegistersWhatItDidBeforeTheHooksAndTheLayoutPreview() 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_revision_preview_layout", "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_template_preview_layout", "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_data",
"document_generation_preview_resolution", "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("document_generation_preview"),
List.copyOf(new TreeSet<>(instance.getWidgets().keySet())), "widgets");
assertEquals(List.of("overview", "labelPreview", "layout", "context", "advanced"),
instance.getTable(GenerationTables.TEMPLATE).getSections().stream().map(QFieldSection::getName).toList(), "template sections");
}
/***************************************************************************
** The asset and upgrade points, called directly, change nothing. The
** preview point, called on a host that registered no record selectors,
** still defines the action, without the source table field.
***************************************************************************/
@Test
void theAssetAndUpgradePointsAddNothingAndThePreviewPointStandsAlone() throws Exception
{
DocumentGenerationQBitConfig config = config();
assertEquals(List.of(), GenerationAssetTables.tables(config));
QInstance instance = instance();
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()));
GenerationPreviewProcesses.register(instance, config);
assertEquals(List.of("document_generation_revision_preview_layout", "document_generation_template_preview_layout"), List.copyOf(new TreeSet<>(instance.getProcesses().keySet())));
assertEquals(List.of("dataSource", "sampleName", "sourceRecordId", "stockId", "targetDpi"), instance.getProcess("document_generation_template_preview_layout")
.getFrontendStep("options").getFormFields().stream().map(QFieldMetaData::getName).toList());
assertEquals(List.of("document_generation_preview"), List.copyOf(instance.getWidgets().keySet()));
assertEquals(List.of("orders"), List.copyOf(instance.getTables().keySet()));
QTableMetaData table = new QTableMetaData().withName("template");
GenerationPreviewWidget.section(table);
assertEquals(List.of("labelPreview"), table.getSections().stream().map(QFieldSection::getName).toList());
assertEquals("document_generation_preview", table.getSections().getFirst().getWidgetName());
}
/***************************************************************************
** 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")));
}
}
@@ -97,6 +97,15 @@ class MySqlGenerationQueueTest extends GenerationQueueJdbcTest
mysql.sql(source);
}
/***********************************************************************
** The shipped migration scripts for this engine.
***********************************************************************/
@Override
protected String dialect()
{
return ("mysql");
}
/***********************************************************************
** Exercise the shipped native schema, including binary idempotency keys.
***********************************************************************/
@@ -100,6 +100,15 @@ class PostgresGenerationQueueTest extends GenerationQueueJdbcTest
return (current == null ? postgres : current);
}
/***********************************************************************
** The shipped migration scripts for this engine.
***********************************************************************/
@Override
protected String dialect()
{
return ("postgres");
}
/***********************************************************************
** Exercise the shipped native schema without the H2 timestamp rewrite.
***********************************************************************/
+1 -1
View File
@@ -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>1.1.0-RC.2</revision><project.build.sourceEncoding>UTF-8</project.build.sourceEncoding><enforcer.skip>true</enforcer.skip><qqq.version>4.1.0-RC.26</qqq.version></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'
+20
View File
@@ -36,6 +36,26 @@
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-dependency-plugin</artifactId><version>3.9.0</version><executions><execution><id>runtime-libraries</id><phase>package</phase><goals><goal>copy-dependencies</goal></goals><configuration><includeScope>runtime</includeScope><outputDirectory>${project.build.directory}/lib</outputDirectory></configuration></execution></executions></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-jar-plugin</artifactId><version>3.4.2</version><configuration><archive><manifest><mainClass>com.kingsrook.qbits.renderer.RendererMain</mainClass><addClasspath>true</addClasspath><classpathPrefix>lib/</classpathPrefix></manifest></archive></configuration></plugin>
<plugin><groupId>org.apache.maven.plugins</groupId><artifactId>maven-checkstyle-plugin</artifactId><version>3.3.1</version><dependencies><dependency><groupId>com.puppycrawl.tools</groupId><artifactId>checkstyle</artifactId><version>10.12.6</version></dependency></dependencies><configuration><configLocation>${project.basedir}/checkstyle/config.xml</configLocation><propertyExpansion>checkstyle.reactor.root=${project.basedir}</propertyExpansion><includeTestSourceDirectory>true</includeTestSourceDirectory><consoleOutput>true</consoleOutput><violationSeverity>warning</violationSeverity><failOnViolation>true</failOnViolation></configuration><executions><execution><phase>validate</phase><goals><goal>check</goal></goals></execution></executions></plugin>
<plugin>
<!-- Coverage gates. The BUNDLE rule is the measured baseline of the existing renderer, rounded down (2026-11-11: 94.0% of instructions and 91.3% of lines); raise it when coverage rises and
never lower it. The PACKAGE and CLASS rules hold new code to 100% line coverage from its first commit; a pattern matches nothing until its package or class exists, so the package that adds
it needs no change here. Nothing is excluded: JaCoCo skips only code annotated as generated, and this build defines no such annotation. There is no skip switch. -->
<groupId>org.jacoco</groupId><artifactId>jacoco-maven-plugin</artifactId><version>0.8.15</version><executions>
<execution><goals><goal>prepare-agent</goal></goals></execution>
<execution><id>report</id><phase>verify</phase><goals><goal>report</goal></goals></execution>
<execution><id>coverage</id><phase>verify</phase><goals><goal>check</goal></goals><configuration><rules>
<rule><element>BUNDLE</element><limits>
<limit><counter>INSTRUCTION</counter><value>COVEREDRATIO</value><minimum>0.94</minimum></limit>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>0.91</minimum></limit>
</limits></rule>
<rule><element>PACKAGE</element><includes><include>com.kingsrook.qbits.renderer.scene2</include><include>com.kingsrook.qbits.renderer.scene2.*</include></includes><limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
</limits></rule>
<rule><element>CLASS</element><includes><include>com.kingsrook.qbits.renderer.AssetInspector</include><include>com.kingsrook.qbits.renderer.AssetInspector.*</include></includes><limits>
<limit><counter>LINE</counter><value>COVEREDRATIO</value><minimum>1.00</minimum></limit>
</limits></rule>
</rules></configuration></execution>
</executions></plugin>
<plugin>
<!-- Publish a self-contained POM: ${revision} is resolved at build time, so a consumer needs no -Drevision -->
<groupId>org.codehaus.mojo</groupId><artifactId>flatten-maven-plugin</artifactId><version>1.7.3</version>
+2 -2
View File
@@ -3,9 +3,9 @@
"qualified": false,
"qualificationId": null,
"imageDigest": null,
"rendererVersion": "qqq-renderer-0.1.0",
"rendererVersion": "qqq-renderer-0.2.0",
"protocol": "qbit-document-renderer/1",
"catalogVersion": "qqq-barcodes-1",
"catalogVersion": "qqq-barcodes-9",
"javaVersion": "21.0.13",
"requiredRuntime": "runsc",
"requiredHostOs": "linux",
+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");
}
}
@@ -47,6 +47,15 @@ public final class RenderInput
public final Stock stock;
public final Map<String, Asset> assets = new LinkedHashMap<>();
/////////////////////////////////////////////////////////////////////////////////////////////////
// the render target of a version 2 scene, or null for the stock's own resolution; and whether //
// the caller asked for every diagnostic rather than the first refusal //
/////////////////////////////////////////////////////////////////////////////////////////////////
public final Integer targetDpi;
public final boolean collectDiagnostics;
public static final String RESERVED_ASSET_PREFIX = "qqq-";
/*******************************************************************************
** Physical stock uses exact integer microns; dots are checked separately.
@@ -95,6 +104,16 @@ public final class RenderInput
validateData(context, 0, new int[2]);
maximumBytes = Wire.integer(root.get("maximumBytes"), 1, 100 * 1024 * 1024);
stock = stock(root.path("stock"));
targetDpi = root.has("targetDpi") ? Integer.valueOf(Wire.integer(root.get("targetDpi"), 203, 600)) : null;
if(targetDpi != null && !java.util.List.of(203, 300, 600).contains(targetDpi))
{
throw new IllegalArgumentException("INVALID_TARGET_DPI");
}
collectDiagnostics = root.has("diagnostics");
if(collectDiagnostics && !"COLLECT".equals(root.get("diagnostics").textValue()))
{
throw new IllegalArgumentException("INVALID_DIAGNOSTICS_MODE");
}
JsonNode supplied = root.path("assets");
if(!supplied.isArray() || supplied.size() > 100)
{
@@ -135,6 +154,17 @@ public final class RenderInput
}
/*******************************************************************************
** The supplied assets whose names begin with the prefix reserved for the faces
** bundled in the renderer. A version 2 scene may not carry one; the text
** package refuses it as `RESERVED_FONT_NAME`. Version 1 never had the rule.
*******************************************************************************/
public java.util.List<String> reservedAssetNames()
{
return (assets.keySet().stream().filter(name -> name.startsWith(RESERVED_ASSET_PREFIX)).toList());
}
/*******************************************************************************
** Shared immutable physical stock parser for rendering and fit measurement.
*******************************************************************************/
@@ -29,8 +29,14 @@ import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.renderer.scene2.Diagnostics;
import com.kingsrook.qbits.renderer.scene2.Refusal;
import com.kingsrook.qbits.renderer.scene2.Scene2;
import com.kingsrook.qbits.renderer.scene2.upgrade.SceneUpgrade;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
@@ -41,7 +47,7 @@ import org.apache.pdfbox.pdmodel.PDDocument;
*******************************************************************************/
public final class RendererMain
{
public static final String VERSION = "qqq-renderer-0.1.0";
public static final String VERSION = "qqq-renderer-0.2.0";
/*******************************************************************************
@@ -84,8 +90,7 @@ public final class RendererMain
catch(Exception expected)
{
// A failed invocation exposes no template/context text or stack trace.
output.write(Wire.JSON.writeValueAsBytes(Map.of("protocol", Wire.PROTOCOL, "requestId", requestId, "outcome", "ERROR",
"rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION, "code", errorCode(expected))));
output.write(Wire.JSON.writeValueAsBytes(error(requestId, expected)));
output.write('\n');
}
}
@@ -93,13 +98,40 @@ public final class RendererMain
/*******************************************************************************
** Only fixed public fit refusals cross the process boundary. Encoder messages
** and all other failures remain opaque because they can contain source data.
** The error answer. A version 2 refusal adds its one diagnostic as `detail`:
** a code, a declared element id, a pointer of schema names and numbers.
*******************************************************************************/
static Map<String, Object> error(String requestId, Exception failure)
{
Map<String, Object> answer = new LinkedHashMap<>();
answer.put("protocol", Wire.PROTOCOL);
answer.put("requestId", requestId);
answer.put("outcome", "ERROR");
answer.put("rendererVersion", VERSION);
answer.put("catalogVersion", Barcodes.VERSION);
answer.put("code", errorCode(failure));
if(failure instanceof Refusal refusal)
{
answer.put("detail", refusal.diagnostic().wire());
}
return (answer);
}
/*******************************************************************************
** Only fixed public refusals cross the process boundary: the fit refusals, an
** operation this renderer does not perform, and the code of a version 2
** diagnostic. Encoder messages and all other failures remain opaque because
** they can contain source data.
*******************************************************************************/
static String errorCode(Exception failure)
{
String message = failure.getMessage();
if(failure instanceof IllegalArgumentException && message != null && java.util.Set.of(
if(failure instanceof Refusal refusal)
{
return (refusal.diagnostic().code());
}
if(failure instanceof IllegalArgumentException && message != null && java.util.Set.of("UNSUPPORTED_OPERATION",
"BARCODE_FIT_TOO_SMALL", "BARCODE_FIT_UNSUPPORTED", "BARCODE_FIT_BOX_OUTSIDE_STOCK",
"BARCODE_FIT_GEOMETRY_MISMATCH", "INVALID_BARCODE_FIT_PROFILE", "INVALID_BARCODE_FIT_REQUEST",
"BARCODE_SYMBOLOGY_NOT_ALLOWED", "SYMBOLOGY_ALLOWLIST_REQUIRED", "SCANNER_BARCODE_PROFILE_UNSUPPORTED",
@@ -154,29 +186,100 @@ public final class RendererMain
return (Map.of("protocol", Wire.PROTOCOL, "requestId", Wire.text(root.get("requestId"), 128), "outcome", "OK",
"rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION, "fit", BarcodeFit.fit(request).metadata()));
}
RenderInput input = new RenderInput(root);
if("VALIDATE".equals(input.operation))
String operation = root.path("operation").asText();
if("INSPECT_ASSET".equals(operation) || "UPGRADE_SCENE".equals(operation))
{
if("PDF".equals(input.format))
if(!Wire.PROTOCOL.equals(root.path("protocol").asText()))
{
PdfRenderer.validate(input);
throw new IllegalArgumentException("INVALID_PROTOCOL");
}
else
{
ZplRenderer.validate(input);
}
return (Map.of("protocol", Wire.PROTOCOL, "requestId", input.requestId, "outcome", "OK", "rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION));
return ("INSPECT_ASSET".equals(operation) ? AssetInspector.inspect(root) : SceneUpgrade.upgrade(root));
}
byte[] bytes = "PDF".equals(input.format) ? PdfRenderer.render(input) : ZplRenderer.render(input);
RenderInput input = new RenderInput(root);
Diagnostics diagnostics = new Diagnostics(input.collectDiagnostics);
byte[] bytes = null;
int pageCount = 1;
if("PDF".equals(input.format))
if("ZPL".equals(input.format))
{
bytes = label(input, diagnostics);
}
else if("VALIDATE".equals(input.operation))
{
PdfRenderer.validate(input);
}
else
{
bytes = PdfRenderer.render(input);
try(PDDocument document = Loader.loadPDF(bytes))
{
pageCount = document.getNumberOfPages();
}
}
return (Map.of("protocol", Wire.PROTOCOL, "requestId", input.requestId, "outcome", "OK", "rendererVersion", VERSION, "catalogVersion", Barcodes.VERSION,
"mediaType", "PDF".equals(input.format) ? "application/pdf" : "application/vnd.zebra-zpl", "base64", Base64.getEncoder().encodeToString(bytes), "sha256", Wire.sha256(bytes), "length", bytes.length, "pageCount", pageCount));
Map<String, Object> answer = new LinkedHashMap<>();
answer.put("protocol", Wire.PROTOCOL);
answer.put("requestId", input.requestId);
answer.put("outcome", diagnostics.refused() ? "REFUSED" : "OK");
answer.put("rendererVersion", VERSION);
answer.put("catalogVersion", Barcodes.VERSION);
////////////////////////////////////////////////////////////////////////////////////////////
// a refused answer carries no document: a host that looks for `base64` without reading //
// the outcome must find nothing it could print //
////////////////////////////////////////////////////////////////////////////////////////////
if(bytes != null && !diagnostics.refused())
{
answer.put("mediaType", "PDF".equals(input.format) ? "application/pdf" : "application/vnd.zebra-zpl");
answer.put("base64", Base64.getEncoder().encodeToString(bytes));
answer.put("sha256", Wire.sha256(bytes));
answer.put("length", bytes.length);
answer.put("pageCount", pageCount);
}
answer.put("diagnostics", diagnostics.wire());
return (answer);
}
/*******************************************************************************
** Route a label by the `schema` of its scene alone. Version 1 takes exactly
** the path it always took and prints at the stock's own resolution; version 2
** takes the scene 2 package; anything else is refused by name. The answer is
** the label, or null when the request only validates or a version 2 scene
** was refused while its diagnostics were collected.
*******************************************************************************/
private static byte[] label(RenderInput input, Diagnostics diagnostics) throws Exception
{
boolean validate = "VALIDATE".equals(input.operation);
JsonNode scene;
try
{
scene = Wire.JSON.readTree(input.source);
}
catch(JsonProcessingException expected)
{
throw diagnostics.refuse("SCENE_INVALID", null, "", null, null, null);
}
String schema = scene.path("schema").textValue();
if(Scene2.SCHEMA.equals(schema))
{
if(validate)
{
Scene2.validate(input, scene, diagnostics);
return (null);
}
return (Scene2.render(input, scene, diagnostics));
}
if(!"qqq-zpl-scene/1".equals(schema))
{
throw diagnostics.refuse("SCENE_INVALID", null, scene.isObject() ? "/schema" : "", null, null, null);
}
if(input.targetDpi != null && input.targetDpi != input.stock.dpi())
{
throw diagnostics.refuse("TARGET_REQUIRES_SCENE_2", null, null, null, Map.of("dpi", input.targetDpi), Map.of("dpi", input.stock.dpi()));
}
if(validate)
{
ZplRenderer.validate(input);
return (null);
}
return (ZplRenderer.render(input));
}
}
@@ -0,0 +1,156 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.Set;
import com.kingsrook.qbits.renderer.RenderInput;
/*******************************************************************************
** A rectangle: an outline of a stated thickness, or filled. A line is a box
** whose width or height is its thickness, and on that axis it takes the
** thickness rule (nearest dot, never less than one) so a thin rule does not
** vanish; every other size is rounded down.
*******************************************************************************/
final class BoxElement implements SceneElement
{
private final String id;
private final Placement placement;
private final Length thickness;
private final boolean fill;
/***************************************************************************
** The factory for `type: "box"`.
***************************************************************************/
static final class Factory implements ElementFactory
{
/************************************************************************
** A box reads its outline thickness and whether it is filled.
************************************************************************/
@Override
public Set<String> properties()
{
return (Set.of("thickness", "fill"));
}
/************************************************************************
** Build one box.
************************************************************************/
@Override
public SceneElement create(ElementSource source)
{
return (new BoxElement(source));
}
}
/***************************************************************************
** An outline needs a thickness that is more than nothing; a filled box
** needs none.
***************************************************************************/
private BoxElement(ElementSource source)
{
id = source.id();
placement = source.placement();
fill = source.flag("fill", false);
thickness = source.length("thickness", null, ElementSource.ABSOLUTE);
if(thickness == null ? !fill : thickness.thousandths() <= 0)
{
throw source.invalid("thickness");
}
}
/***************************************************************************
** The id the scene declares for the box.
***************************************************************************/
@Override
public String id()
{
return (id);
}
/***************************************************************************
** The standing rules, except on an axis where the box is a line.
***************************************************************************/
@Override
public Frame place(Layout layout)
{
int width = thickness != null && thickness.equals(placement.width()) ? thickness.thickness(layout.dpi()) : layout.width(placement);
int height = thickness != null && thickness.equals(placement.height()) ? thickness.thickness(layout.dpi()) : layout.height(placement);
return (layout.frame(placement.anchor(), layout.x(placement), layout.y(placement), width, height));
}
/***************************************************************************
** The whole box when it is painted whole, else its four bars; the corners,
** where two bars cross, are painted twice and counted twice.
***************************************************************************/
@Override
public long paintedDots(Frame frame, int dpi)
{
int bar = bar(frame, dpi);
return (bar < 0 ? SceneElement.super.paintedDots(frame, dpi) : 2L * bar * (frame.width() + frame.height()));
}
/***************************************************************************
** The thickness of the outline in dots, or -1 when the box is painted whole:
** it is filled, or its bars would meet.
***************************************************************************/
private int bar(Frame frame, int dpi)
{
int bar = fill ? frame.width() : thickness.thickness(dpi);
return (bar * 2 >= Math.min(frame.width(), frame.height()) ? -1 : bar);
}
/***************************************************************************
** Four bars inside the box, or the whole box when it is filled or when the
** bars would meet.
***************************************************************************/
@Override
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
{
int bar = bar(frame, canvas.dpi());
if(bar < 0)
{
canvas.fill(frame, true);
return;
}
canvas.fill(new Frame(frame.x(), frame.y(), frame.width(), bar), true);
canvas.fill(new Frame(frame.x(), frame.bottom() - bar, frame.width(), bar), true);
canvas.fill(new Frame(frame.x(), frame.y(), bar, frame.height()), true);
canvas.fill(new Frame(frame.right() - bar, frame.y(), bar, frame.height()), true);
}
}
@@ -0,0 +1,262 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
/*******************************************************************************
** The whole label as one 1-bit picture at the target resolution: a dot is ink
** or it is not. Elements paint in list order, so a later element covers an
** earlier one, white as well as black. Painting outside the canvas is an error
** in the caller, never a silent clip, so ink cannot be lost at an edge.
*******************************************************************************/
public final class Canvas
{
public static final long MAXIMUM_DOTS = 64_000_000L;
/////////////////////////////////////////////////////////////////////////////////////////////////
// what one scene may paint, in canvases: the dots its elements paint, an overlap counted each //
// time, may total sixteen times the canvas. A real label paints less than two (a solid //
// ground, reversed panels, rules, text and codes), so sixteen refuses no layered design, and //
// it holds the worst scene to 16 x 64 million dots, 128 MB of byte writes, where the 1000 //
// full-canvas boxes the element limit allows were 8 GB //
/////////////////////////////////////////////////////////////////////////////////////////////////
public static final int MAXIMUM_COATS = 16;
private final int width;
private final int height;
private final int dpi;
private final int stride;
private final byte[] bits;
private long painted;
/***************************************************************************
** A blank canvas the size of the printable area, in dots.
***************************************************************************/
public Canvas(int width, int height, int dpi)
{
if(width < 1 || height < 1 || (long) width * height > MAXIMUM_DOTS)
{
throw new IllegalArgumentException("CANVAS_LIMIT");
}
this.width = width;
this.height = height;
this.dpi = dpi;
stride = (width + 7) / 8;
bits = new byte[stride * height];
}
/***************************************************************************
** Columns of dots.
***************************************************************************/
public int width()
{
return (width);
}
/***************************************************************************
** Rows of dots.
***************************************************************************/
public int height()
{
return (height);
}
/***************************************************************************
** The resolution the canvas was composed at, in dots per inch.
***************************************************************************/
public int dpi()
{
return (dpi);
}
/***************************************************************************
** Count the dots an element is about to paint. When they would take the
** scene past `MAXIMUM_COATS` canvases nothing is counted and the answer is
** no, so that element is refused and a later, smaller one may still paint.
***************************************************************************/
public boolean admit(long dots)
{
if(painted + dots > MAXIMUM_COATS * (long) width * height)
{
return (false);
}
painted += dots;
return (true);
}
/***************************************************************************
** What the scene would have painted with `dots` more, as a percentage of
** the canvas, rounded up.
***************************************************************************/
public int paintedPercent(long dots)
{
long area = (long) width * height;
return (Math.toIntExact(((painted + dots) * 100 + area - 1) / area));
}
/***************************************************************************
** Whether one dot holds ink.
***************************************************************************/
public boolean get(int x, int y)
{
return ((bits[index(x, y)] & (128 >> (x & 7))) != 0);
}
/***************************************************************************
** Put ink on one dot, or take it off.
***************************************************************************/
public void set(int x, int y, boolean ink)
{
int index = index(x, y);
int mask = 128 >> (x & 7);
bits[index] = (byte) (ink ? bits[index] | mask : bits[index] & ~mask);
}
/***************************************************************************
** Paint a box black, or white to clear what an earlier element painted.
***************************************************************************/
public void fill(Frame area, boolean ink)
{
paint(area, ink ? Paint.BLACK : Paint.WHITE);
}
/***************************************************************************
** Reverse a box: black becomes white and white becomes black.
***************************************************************************/
public void invert(Frame area)
{
paint(area, Paint.REVERSE);
}
/***************************************************************************
** What painting does to the dots of a box.
***************************************************************************/
private enum Paint
{
BLACK, WHITE, REVERSE
}
/***************************************************************************
** Paint a box a byte of dots at a time, so that a scene of many large filled
** boxes costs memory writes rather than a visit to every dot. A box with no
** dots paints nothing.
***************************************************************************/
private void paint(Frame area, Paint paint)
{
if(area.width() < 1 || area.height() < 1)
{
return;
}
index(area.x(), area.y());
index(area.right() - 1, area.bottom() - 1);
for(int y = area.y(); y < area.bottom(); y++)
{
int x = area.x();
while(x < area.right())
{
int first = x & 7;
int count = Math.min(8 - first, area.right() - x);
int mask = (0xFF >> first) & (0xFF << (8 - first - count));
int index = y * stride + (x >> 3);
int painted = switch(paint)
{
case BLACK -> bits[index] | mask;
case WHITE -> bits[index] & ~mask;
case REVERSE -> bits[index] ^ mask;
};
bits[index] = (byte) painted;
x += count;
}
}
}
/***************************************************************************
** The first inked column of a row, or -1 when the row is blank.
***************************************************************************/
public int firstInk(int row)
{
for(int x = 0; x < width; x++)
{
if(get(x, row))
{
return (x);
}
}
return (-1);
}
/***************************************************************************
** The last inked column of a row, or -1 when the row is blank.
***************************************************************************/
public int lastInk(int row)
{
for(int x = width - 1; x >= 0; x--)
{
if(get(x, row))
{
return (x);
}
}
return (-1);
}
/***************************************************************************
** The byte that holds a dot, after refusing a dot that is not on the canvas.
***************************************************************************/
private int index(int x, int y)
{
if(x < 0 || y < 0 || x >= width || y >= height)
{
throw new IllegalArgumentException("CANVAS_BOUNDS");
}
return (y * stride + (x >> 3));
}
}
@@ -0,0 +1,213 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.regex.Pattern;
/*******************************************************************************
** One finding about a version 2 scene, in a closed vocabulary: a code, a
** severity, the id of an element the scene declares, a pointer made of schema
** names, indexes and declared ids, a box in dots, and named integers. It has no
** field that can hold a sentence, so no record value, context text or exception
** message can leave the renderer in it. Only `Diagnostics` creates one, because
** only the collector knows which ids the scene declares.
*******************************************************************************/
public final class Diagnostic
{
private static final Set<String> ERRORS = Set.of("SCENE_INVALID", "STOCK_MISMATCH", "TARGET_REQUIRES_SCENE_2", "ELEMENT_OUTSIDE_PRINTABLE_AREA", "TEXT_DOES_NOT_FIT", "VALUE_MISSING",
"VALUE_TOO_LONG", "FONT_UNKNOWN", "FONT_MISSING_GLYPH", "RESERVED_FONT_NAME", "BARCODE_DOES_NOT_FIT", "GS1_INVALID", "ASSET_UNKNOWN", "PREVIEW_TOO_LARGE", "RENDERER_TOO_OLD");
private static final Set<String> WARNINGS = Set.of("TEXT_TRUNCATED", "TEXT_SMALL_FOR_DPI", "TEXT_LIGHT_FACE", "FIT_NOT_PROVEN", "BARCODE_MODULE_BELOW_RECOMMENDED", "QUIET_ZONE_INTRUDED",
"ELEMENTS_OVERLAP", "IMAGE_UPSCALED", "DEVICE_UNIT_USED", "PRINTER_FONT_NOT_SHOWN");
private static final Set<String> INFORMATION = Set.of("TEXT_SHRUNK", "TEXT_WRAPPED", "BARCODE_MODULE", "IMAGE_SCALED");
private static final Pattern NAME = Pattern.compile("[a-z][A-Za-z0-9]{0,31}");
private final String code;
private final Severity severity;
private final String element;
private final String pointer;
private final Frame box;
private final Map<String, Integer> measured;
private final Map<String, Integer> limit;
/***************************************************************************
** An error stops printing; a warning and information do not.
***************************************************************************/
public enum Severity
{
ERROR, WARNING, INFO
}
/***************************************************************************
** Admit only the vocabulary. A mistake here is a defect in the renderer, not
** in the scene, so it is an illegal state and stays opaque to the host.
***************************************************************************/
Diagnostic(String code, Severity severity, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
{
Set<String> codes = switch(severity)
{
case ERROR -> ERRORS;
case WARNING -> WARNINGS;
case INFO -> INFORMATION;
};
if(code == null || !codes.contains(code) || (box != null && (box.width() < 0 || box.height() < 0)))
{
throw new IllegalStateException("DIAGNOSTIC_OUTSIDE_VOCABULARY");
}
this.code = code;
this.severity = severity;
this.element = element;
this.pointer = pointer;
this.box = box;
this.measured = numbers(measured);
this.limit = numbers(limit);
}
/***************************************************************************
** The code, from the closed vocabulary of its severity.
***************************************************************************/
public String code()
{
return (code);
}
/***************************************************************************
** Whether the finding stops printing.
***************************************************************************/
public Severity severity()
{
return (severity);
}
/***************************************************************************
** The declared id of the element the finding is about, or null.
***************************************************************************/
public String element()
{
return (element);
}
/***************************************************************************
** The JSON pointer of the offending property in the scene, or null.
***************************************************************************/
public String pointer()
{
return (pointer);
}
/***************************************************************************
** The element's resolved box in dots from the printable area's top left, or
** null.
***************************************************************************/
public Frame box()
{
return (box);
}
/***************************************************************************
** What was measured, by name.
***************************************************************************/
public Map<String, Integer> measured()
{
return (measured);
}
/***************************************************************************
** What the measured values were held against, by name.
***************************************************************************/
public Map<String, Integer> limit()
{
return (limit);
}
/***************************************************************************
** The diagnostic as the renderer's answer carries it. Absent parts are left
** out, and names are in a fixed order so equal findings are equal bytes.
***************************************************************************/
public Map<String, Object> wire()
{
Map<String, Object> wire = new LinkedHashMap<>();
wire.put("code", code);
wire.put("severity", severity.name());
if(element != null)
{
wire.put("element", element);
}
if(pointer != null)
{
wire.put("pointer", pointer);
}
if(box != null)
{
wire.put("box", box.box());
}
if(!measured.isEmpty())
{
wire.put("measured", measured);
}
if(!limit.isEmpty())
{
wire.put("limit", limit);
}
return (wire);
}
/***************************************************************************
** Freeze at most eight named integers; a name is a short identifier.
***************************************************************************/
private static Map<String, Integer> numbers(Map<String, Integer> values)
{
Map<String, Integer> numbers = new TreeMap<>(values == null ? Map.of() : values);
if(numbers.size() > 8 || numbers.keySet().stream().anyMatch(name -> !NAME.matcher(name).matches()) || numbers.containsValue(null))
{
throw new IllegalStateException("DIAGNOSTIC_OUTSIDE_VOCABULARY");
}
return (Collections.unmodifiableMap(numbers));
}
}
@@ -0,0 +1,215 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/*******************************************************************************
** The collector of one render or validation, and the only maker of
** diagnostics. It knows the element ids the scene declares, so it is where the
** rule is enforced that a diagnostic names only a declared id and points only
** through schema names, indexes and declared ids: a data value has no way in.
**
** By default the first error ends the work as a `Refusal`. When the caller asks
** to collect, errors are kept and the work goes on as far as it can, which is
** what a preview wants. The findings of one scene are bounded by the scene: at
** most one error for each element and one warning for each property.
*******************************************************************************/
public final class Diagnostics
{
public static final Pattern ID = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,31}");
private static final Pattern INDEX = Pattern.compile("0|[1-9][0-9]{0,3}");
private final boolean collect;
private final Map<String, String> declared = new LinkedHashMap<>();
private final List<Diagnostic> findings = new ArrayList<>();
private final Set<Map<String, Object>> seen = new HashSet<>();
private boolean refused;
/***************************************************************************
** A collector that stops at the first error, or one that keeps them.
***************************************************************************/
public Diagnostics(boolean collect)
{
this.collect = collect;
}
/***************************************************************************
** Record an id the scene declares and where its element is. From here on a
** diagnostic may name it.
***************************************************************************/
public void declare(String id, String pointer)
{
if(id == null || !ID.matcher(id).matches() || declared.containsKey(id))
{
throw new IllegalStateException("ELEMENT_ID_NOT_DECLARABLE");
}
declared.put(id, pointer);
}
/***************************************************************************
** Whether the scene declares this id.
***************************************************************************/
public boolean declared(String id)
{
return (declared.containsKey(id));
}
/***************************************************************************
** The pointer of a declared element, or null.
***************************************************************************/
public String pointer(String id)
{
return (declared.get(id));
}
/***************************************************************************
** An error, as the exception to throw. Every part is optional but the code.
***************************************************************************/
public Refusal refuse(String code, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
{
return (new Refusal(create(code, Diagnostic.Severity.ERROR, element, pointer, box, measured, limit)));
}
/***************************************************************************
** A finding that does not stop printing. Reporting the same one twice keeps
** one.
***************************************************************************/
public void warn(String code, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
{
add(create(code, Diagnostic.Severity.WARNING, element, pointer, box, measured, limit));
}
/***************************************************************************
** What the renderer chose, for the author to see: a module size, a shrunk
** type size.
***************************************************************************/
public void inform(String code, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
{
add(create(code, Diagnostic.Severity.INFO, element, pointer, box, measured, limit));
}
/***************************************************************************
** What to do with a refusal that was caught where work can continue: keep it
** when collecting, otherwise let it end the work.
***************************************************************************/
public void keep(Refusal refusal)
{
if(!collect)
{
throw refusal;
}
refused = true;
add(refusal.diagnostic());
}
/***************************************************************************
** Whether an error was kept, so the label must not print.
***************************************************************************/
public boolean refused()
{
return (refused);
}
/***************************************************************************
** Every finding so far, in the order found.
***************************************************************************/
public List<Diagnostic> findings()
{
return (List.copyOf(findings));
}
/***************************************************************************
** The findings as the renderer's answer carries them.
***************************************************************************/
public List<Map<String, Object>> wire()
{
return (findings.stream().map(Diagnostic::wire).toList());
}
/***************************************************************************
** The gate: the element is declared, and every segment of the pointer is an
** index, a property name of the schema or a declared id.
***************************************************************************/
private Diagnostic create(String code, Diagnostic.Severity severity, String element, String pointer, Frame box, Map<String, Integer> measured, Map<String, Integer> limit)
{
boolean known = element == null || declared.containsKey(element);
if(pointer != null && !pointer.isEmpty())
{
known &= pointer.startsWith("/");
for(String segment : pointer.substring(1).split("/", -1))
{
known &= INDEX.matcher(segment).matches() || ElementFactory.VOCABULARY.contains(segment) || declared.containsKey(segment);
}
}
if(!known)
{
throw new IllegalStateException("DIAGNOSTIC_NOT_DECLARED");
}
return (new Diagnostic(code, severity, element, pointer, box, measured, limit));
}
/***************************************************************************
** Keep a finding unless an equal one is already there.
***************************************************************************/
private void add(Diagnostic diagnostic)
{
if(seen.add(diagnostic.wire()))
{
findings.add(diagnostic);
}
}
}
@@ -0,0 +1,74 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.kingsrook.qbits.renderer.scene2.barcode.BarcodeFactory;
import com.kingsrook.qbits.renderer.scene2.graphics.GraphicsFactory;
import com.kingsrook.qbits.renderer.scene2.text.TextFactory;
/*******************************************************************************
** Builds the elements of one `type`. The core owns `box` and `group`; text,
** barcodes and images each live in their own sub-package and are registered
** here by class, so the package that fills one in edits only its own file.
*******************************************************************************/
public interface ElementFactory
{
/***************************************************************************
** Properties every element may state. The parser reads them into the
** element's `Placement` before any factory runs.
***************************************************************************/
Set<String> COMMON = Set.of("id", "type", "anchor", "x", "y", "width", "height", "align", "valign");
/***************************************************************************
** The factories by scene `type`. A `group` is built by the parser itself,
** because its children are elements.
***************************************************************************/
Map<String, ElementFactory> FACTORIES = Map.of("box", new BoxElement.Factory(), "text", new TextFactory(), "barcode", new BarcodeFactory(), "image", new GraphicsFactory());
/***************************************************************************
** Every property name the version 2 schema declares. A diagnostic's pointer
** is made only of these, of array indexes and of declared element ids.
***************************************************************************/
Set<String> VOCABULARY = Stream.concat(Stream.of("schema", "design", "defaults", "font", "size", "samples", "elements", "overrides", "when", "dpi", "set", "children"),
Stream.concat(COMMON.stream(), FACTORIES.values().stream().flatMap(factory -> factory.properties().stream()))).collect(Collectors.toUnmodifiableSet());
/***************************************************************************
** The properties this type reads beyond the common ones. The parser refuses
** an element that states any other.
***************************************************************************/
Set<String> properties();
/***************************************************************************
** Build one element from its source, refusing a property value it cannot use
** with `source.invalid(name)`.
***************************************************************************/
SceneElement create(ElementSource source);
}
@@ -0,0 +1,272 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import com.fasterxml.jackson.databind.JsonNode;
/*******************************************************************************
** One element as the scene states it for one resolution: its own properties
** with the matching overrides applied, and its common placement already read.
** A factory reads its own properties through the typed readers here, which
** refuse a bad value as `SCENE_INVALID` at the property's own pointer (in the
** element, or in the override that set it).
*******************************************************************************/
public final class ElementSource
{
public static final Set<Length.Unit> PHYSICAL = Set.of(Length.Unit.MM, Length.Unit.IN, Length.Unit.PT);
public static final Set<Length.Unit> ABSOLUTE = Set.of(Length.Unit.MM, Length.Unit.IN, Length.Unit.PT, Length.Unit.DOT);
public static final Set<Length.Unit> ANY = Set.of(Length.Unit.values());
private final String id;
private final String pointer;
private final Diagnostics diagnostics;
private final Map<String, JsonNode> values = new LinkedHashMap<>();
private final Map<String, String> pointers = new LinkedHashMap<>();
private final Placement placement;
/***************************************************************************
** The element at `pointer`, whose id the collector already holds, with the
** overrides for this resolution: property name to its value and to the
** pointer of the override that states it.
***************************************************************************/
ElementSource(String id, String pointer, JsonNode element, Map<String, JsonNode> overrides, Map<String, String> overridePointers, Diagnostics diagnostics)
{
this.id = id;
this.pointer = pointer;
this.diagnostics = diagnostics;
element.fieldNames().forEachRemaining(name -> values.put(name, element.get(name)));
values.putAll(overrides);
pointers.putAll(overridePointers);
Placement.Anchor anchor = choice("anchor", Placement.Anchor.TOP_LEFT, Placement.Anchor::of);
placement = new Placement(anchor, offset("x", anchor.horizontal()), offset("y", anchor.vertical()), extent("width"), extent("height"),
choice("align", Placement.Align.LEFT, Placement.Align::of), choice("valign", Placement.Valign.TOP, Placement.Valign::of));
}
/***************************************************************************
** The id the scene declares for the element.
***************************************************************************/
public String id()
{
return (id);
}
/***************************************************************************
** The element's `type`.
***************************************************************************/
public String type()
{
return (values.get("type").textValue());
}
/***************************************************************************
** Anchor, offsets, box and content alignment, as the scene states them.
***************************************************************************/
public Placement placement()
{
return (placement);
}
/***************************************************************************
** A property's value as JSON, or null when the element does not state it.
***************************************************************************/
public JsonNode value(String name)
{
return (values.get(name));
}
/***************************************************************************
** A length in one of the allowed units, or the fallback when the element
** does not state it. A `dot` length stated outside an override depends on
** the printer, which the author is told.
***************************************************************************/
public Length length(String name, Length fallback, Set<Length.Unit> units)
{
JsonNode value = values.get(name);
if(value == null)
{
return (fallback);
}
Length length;
try
{
length = Length.parse(value.textValue());
}
catch(IllegalArgumentException expected)
{
throw invalid(name);
}
if(!units.contains(length.unit()))
{
throw invalid(name);
}
if(length.unit() == Length.Unit.DOT && !pointers.containsKey(name))
{
diagnostics.warn("DEVICE_UNIT_USED", id, pointer(name), null, null, null);
}
return (length);
}
/***************************************************************************
** A true or false property, or the fallback when the element does not state
** it.
***************************************************************************/
public boolean flag(String name, boolean fallback)
{
JsonNode value = values.get(name);
if(value == null)
{
return (fallback);
}
if(!value.isBoolean())
{
throw invalid(name);
}
return (value.booleanValue());
}
/***************************************************************************
** One word of a fixed set, or the fallback when the element does not state
** it. `lookup` answers null for a word outside the set.
***************************************************************************/
public <T> T choice(String name, T fallback, Function<String, T> lookup)
{
JsonNode value = values.get(name);
if(value == null)
{
return (fallback);
}
T choice = lookup.apply(value.textValue());
if(choice == null)
{
throw invalid(name);
}
return (choice);
}
/***************************************************************************
** Clockwise rotation of text, a barcode or an image: 0, 90, 180 or 270.
***************************************************************************/
public int rotation()
{
JsonNode value = values.get("rotation");
if(value == null)
{
return (0);
}
if(!value.isIntegralNumber() || !value.canConvertToInt() || !Set.of(0, 90, 180, 270).contains(value.intValue()))
{
throw invalid("rotation");
}
return (value.intValue());
}
/***************************************************************************
** The refusal for a property the element states wrongly, or lacks.
***************************************************************************/
public Refusal invalid(String name)
{
return (diagnostics.refuse("SCENE_INVALID", id, pointer(name), null, null, null));
}
/***************************************************************************
** Refuse an element that states a property its type does not read. The
** names themselves are not reported: only declared names leave the renderer.
***************************************************************************/
void only(Set<String> properties)
{
int unknown = Math.toIntExact(values.keySet().stream().filter(name -> !ElementFactory.COMMON.contains(name) && !properties.contains(name)).count());
if(unknown > 0)
{
throw diagnostics.refuse("SCENE_INVALID", id, pointer, null, Map.of("unknownProperties", unknown), Map.of("unknownProperties", 0));
}
}
/***************************************************************************
** Where a property is stated: in the override that set it, else in the
** element.
***************************************************************************/
private String pointer(String name)
{
return (pointers.getOrDefault(name, pointer + "/" + name));
}
/***************************************************************************
** A distance inward from the anchored edge. Only about the middle may it be
** negative, where it shifts left or up.
***************************************************************************/
private Length offset(String name, Placement.Position position)
{
Length offset = length(name, Length.ZERO, ANY);
if(offset.thousandths() < 0 && position != Placement.Position.CENTER)
{
throw invalid(name);
}
return (offset);
}
/***************************************************************************
** A box width or height, more than nothing; null when the scene leaves it
** to the rest of the container.
***************************************************************************/
private Length extent(String name)
{
Length extent = length(name, null, ANY);
if(extent != null && extent.thousandths() <= 0)
{
throw invalid(name);
}
return (extent);
}
}
@@ -0,0 +1,70 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.List;
/*******************************************************************************
** A resolved box in whole dots. The origin is the top left of the printable
** area, so a frame at 0, 0 touches the margins and never enters them.
*******************************************************************************/
public record Frame(int x, int y, int width, int height)
{
/***************************************************************************
** The first column to the right of the box.
***************************************************************************/
public int right()
{
return (x + width);
}
/***************************************************************************
** The first row below the box.
***************************************************************************/
public int bottom()
{
return (y + height);
}
/***************************************************************************
** Whether every dot of the other box is a dot of this one.
***************************************************************************/
public boolean contains(Frame other)
{
return (other.x >= x && other.y >= y && other.right() <= right() && other.bottom() <= bottom());
}
/***************************************************************************
** The box as a diagnostic carries it: x, y, width and height.
***************************************************************************/
public List<Integer> box()
{
return (List.of(x, y, width, height));
}
}
@@ -0,0 +1,105 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.List;
import com.kingsrook.qbits.renderer.RenderInput;
/*******************************************************************************
** A box with its own anchor, position and size whose children are placed
** relative to it. It moves a block as a unit and gives percentages something
** smaller than the label to refer to. It paints nothing itself.
*******************************************************************************/
final class GroupElement implements SceneElement
{
private final String id;
private final Placement placement;
private final List<SceneElement> children;
/***************************************************************************
** A group and the elements inside it, in paint order.
***************************************************************************/
GroupElement(ElementSource source, List<SceneElement> children)
{
id = source.id();
placement = source.placement();
this.children = List.copyOf(children);
}
/***************************************************************************
** The id the scene declares for the group.
***************************************************************************/
@Override
public String id()
{
return (id);
}
/***************************************************************************
** The group's box, by the standing rules.
***************************************************************************/
@Override
public Frame place(Layout layout)
{
return (layout.place(placement));
}
/***************************************************************************
** A group paints nothing, whatever the size of its box.
***************************************************************************/
@Override
public long paintedDots(Frame frame, int dpi)
{
return (0);
}
/***************************************************************************
** A group has no ink of its own.
***************************************************************************/
@Override
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
{
// The scene paints the children after their group, inside its box.
}
/***************************************************************************
** The elements inside the group.
***************************************************************************/
@Override
public List<SceneElement> children()
{
return (children);
}
}
@@ -0,0 +1,166 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
/*******************************************************************************
** Anchor resolution inside one container at one resolution: lengths in, whole
** dots out. The container is the printable area or a group, and percentages
** refer to it. Integer arithmetic only.
*******************************************************************************/
public final class Layout
{
private final Frame container;
private final int dpi;
/***************************************************************************
** A container, in dots from the top left of the printable area, and the
** resolution every length resolves at.
***************************************************************************/
public Layout(Frame container, int dpi)
{
this.container = container;
this.dpi = dpi;
}
/***************************************************************************
** The box positions and percentages refer to.
***************************************************************************/
public Frame container()
{
return (container);
}
/***************************************************************************
** The render target, in dots per inch.
***************************************************************************/
public int dpi()
{
return (dpi);
}
/***************************************************************************
** The same resolution inside a smaller box, for the children of a group.
***************************************************************************/
public Layout within(Frame frame)
{
return (new Layout(frame, dpi));
}
/***************************************************************************
** The element's box by the standing rules: position to the nearest dot, size
** rounded down.
***************************************************************************/
public Frame place(Placement placement)
{
return (frame(placement.anchor(), x(placement), y(placement), width(placement), height(placement)));
}
/***************************************************************************
** The horizontal distance inward from the anchored edge, or the shift right
** on a centred axis.
***************************************************************************/
public int x(Placement placement)
{
return (placement.x().position(dpi, container.width()));
}
/***************************************************************************
** The vertical distance inward from the anchored edge, or the shift down on
** a centred axis.
***************************************************************************/
public int y(Placement placement)
{
return (placement.y().position(dpi, container.height()));
}
/***************************************************************************
** The box width, or the rest of the container when the scene states none.
***************************************************************************/
public int width(Placement placement)
{
return (extent(placement.width(), x(placement), placement.anchor().horizontal(), container.width()));
}
/***************************************************************************
** The box height, or the rest of the container when the scene states none.
***************************************************************************/
public int height(Placement placement)
{
return (extent(placement.height(), y(placement), placement.anchor().vertical(), container.height()));
}
/***************************************************************************
** A box of known size on an anchor: the same point of the box and of the
** container coincide, moved by the offsets.
***************************************************************************/
public Frame frame(Placement.Anchor anchor, int x, int y, int width, int height)
{
return (new Frame(container.x() + anchor.horizontal().place(container.width(), width, x), container.y() + anchor.vertical().place(container.height(), height, y), width, height));
}
/***************************************************************************
** Content of a measured size inside its box: text lines, a fitted barcode, a
** scaled image.
***************************************************************************/
public static Frame align(Frame box, int width, int height, Placement.Align align, Placement.Valign valign)
{
return (new Frame(box.x() + align.position().place(box.width(), width, 0), box.y() + valign.position().place(box.height(), height, 0), width, height));
}
/***************************************************************************
** A stated size is rounded down. An omitted one is what the offset leaves:
** from an edge, the container less the offset; about the middle, the
** container less the shift on both sides, so the box stays centred on it.
***************************************************************************/
private int extent(Length size, int offset, Placement.Position position, int available)
{
if(size != null)
{
return (size.size(dpi, available));
}
return (available - Math.abs(offset) * (position == Placement.Position.CENTER ? 2 : 1));
}
}
@@ -0,0 +1,250 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/*******************************************************************************
** A length as a version 2 scene writes it: a number of up to three decimals and
** a unit, such as `12.5mm`, `0.5in`, `9pt`, `25%` or `1dot`. There is no ambient
** unit, so a value read out of context is never ambiguous. A physical length
** becomes whole micrometres first (the unit the stock uses) and then dots by
** the rule its use states; no floating point takes part in a position or size.
*******************************************************************************/
public record Length(Unit unit, long thousandths)
{
public static final Length ZERO = new Length(Unit.MM, 0);
private static final Pattern SYNTAX = Pattern.compile("(-?)(0|[1-9][0-9]{0,4})(?:\\.([0-9]{1,3}))?(mm|in|pt|%|dot)");
private static final int MAXIMUM_MICROMETRES = 2_000_000;
private static final int MAXIMUM_DOTS = 32_000;
private static final int MICROMETRES_PER_INCH = 25_400;
private static final Map<String, Unit> UNITS = Map.of("mm", Unit.MM, "in", Unit.IN, "pt", Unit.PT, "%", Unit.PERCENT, "dot", Unit.DOT);
/***************************************************************************
** The five units of a scene. `%` is a share of the container and `dot` is one
** printer dot; the other three are physical.
***************************************************************************/
public enum Unit
{
MM, IN, PT, PERCENT, DOT
}
/***************************************************************************
** How a fraction of a dot is resolved. The magnitude is rounded and the sign
** restored, so a length and its negative are mirror images.
***************************************************************************/
public enum Rounding
{
NEAREST, DOWN, UP
}
/***************************************************************************
** Read one length. Anything else, including a bare number, an exponent, a
** fourth decimal, a fraction of a dot, more than the whole container or more
** than the largest stock, is refused.
***************************************************************************/
public static Length parse(String text)
{
Matcher matcher = SYNTAX.matcher(text == null ? "" : text);
if(!matcher.matches())
{
throw new IllegalArgumentException("INVALID_LENGTH");
}
String fraction = matcher.group(3) == null ? "" : matcher.group(3);
long magnitude = Long.parseLong(matcher.group(2)) * 1000 + Long.parseLong((fraction + "000").substring(0, 3));
Length length = new Length(UNITS.get(matcher.group(4)), matcher.group(1).isEmpty() ? magnitude : -magnitude);
long limit = switch(length.unit)
{
case PERCENT -> 100_000L;
case DOT -> MAXIMUM_DOTS * 1000L;
default -> Long.MAX_VALUE;
};
if(magnitude > limit || (length.unit == Unit.DOT && !fraction.isEmpty()) || (length.physical() && length.magnitude() > MAXIMUM_MICROMETRES))
{
throw new IllegalArgumentException("INVALID_LENGTH");
}
return (length);
}
/***************************************************************************
** Millimetres, inches and points are physical: the same on every printer.
***************************************************************************/
public boolean physical()
{
return (unit == Unit.MM || unit == Unit.IN || unit == Unit.PT);
}
/***************************************************************************
** Whole micrometres, nearest, with a half rounded away from zero. One inch
** is 25400 micrometres and one point is a 72nd of an inch.
***************************************************************************/
public int micrometres()
{
long micrometres = magnitude();
return (Math.toIntExact(thousandths < 0 ? -micrometres : micrometres));
}
/***************************************************************************
** The micrometres of the magnitude, not yet narrowed, so that the largest
** number the syntax allows (99999.999 of any unit) is compared with the limit
** as it is and refused by name, never as an arithmetic overflow.
***************************************************************************/
private long magnitude()
{
long magnitude = Math.abs(thousandths);
return (switch(unit)
{
case MM -> magnitude;
case IN -> (magnitude * 254 + 5) / 10;
case PT -> (magnitude * 127 + 180) / 360;
default -> throw new IllegalArgumentException("LENGTH_NOT_PHYSICAL");
});
}
/***************************************************************************
** Whole dots at a resolution. A percentage is a share of `container` dots;
** a `dot` length is itself; a physical length goes through micrometres.
***************************************************************************/
public int dots(int dpi, int container, Rounding rounding)
{
long numerator;
long denominator;
switch(unit)
{
case PERCENT ->
{
numerator = Math.abs(thousandths) * container;
denominator = 100_000;
}
case DOT ->
{
numerator = Math.abs(thousandths);
denominator = 1000;
}
default ->
{
numerator = (long) Math.abs(micrometres()) * dpi;
denominator = MICROMETRES_PER_INCH;
}
}
long magnitude = switch(rounding)
{
case NEAREST -> (numerator + denominator / 2) / denominator;
case DOWN -> numerator / denominator;
case UP -> (numerator + denominator - 1) / denominator;
};
return (Math.toIntExact(thousandths < 0 ? -magnitude : magnitude));
}
/***************************************************************************
** A position: the nearest dot, the rule the stock already uses, so the error
** is symmetric.
***************************************************************************/
public int position(int dpi, int container)
{
return (dots(dpi, container, Rounding.NEAREST));
}
/***************************************************************************
** A box width or height: rounded down, so content never leaves its designed
** box.
***************************************************************************/
public int size(int dpi, int container)
{
return (dots(dpi, container, Rounding.DOWN));
}
/***************************************************************************
** A line or box thickness: the nearest dot and never less than one, so a
** thin rule does not vanish.
***************************************************************************/
public int thickness(int dpi)
{
return (Math.max(1, absolute().dots(dpi, 0, Rounding.NEAREST)));
}
/***************************************************************************
** A barcode module from its stated minimum: up to a whole dot, because bars
** are whole dots and may not be narrower than the minimum.
***************************************************************************/
public int module(int dpi)
{
return (Math.max(1, absolute().dots(dpi, 0, Rounding.UP)));
}
/***************************************************************************
** A font size in dots, not rounded: a face is scaled by points times dpi
** over 72, so type scales continuously.
***************************************************************************/
public double scale(int dpi)
{
return (switch(absolute().unit)
{
case MM -> thousandths * (double) dpi / MICROMETRES_PER_INCH;
case IN -> thousandths * (double) dpi / 1000;
case PT -> thousandths * (double) dpi / 72_000;
default -> thousandths / 1000.0;
});
}
/***************************************************************************
** A thickness, a module and a font size have no container to be a share of.
***************************************************************************/
private Length absolute()
{
if(unit == Unit.PERCENT)
{
throw new IllegalArgumentException("LENGTH_NOT_ABSOLUTE");
}
return (this);
}
}
@@ -0,0 +1,231 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
/*******************************************************************************
** What every element states about where it sits: the point it is anchored to,
** its distances inward from that point, its box, and where content goes inside
** the box. `width` and `height` are null when the scene leaves them out, which
** means the rest of the container on that axis.
*******************************************************************************/
public record Placement(Anchor anchor, Length x, Length y, Length width, Length height, Align align, Valign valign)
{
/***************************************************************************
** One axis, three places: against the start, in the middle or against the end.
***************************************************************************/
public enum Position
{
START, CENTER, END;
/************************************************************************
** Where `size` dots begin within `available` dots. From an edge, `offset`
** runs inward. In the middle the slack is split with integer division, so
** a centred box is off by at most one dot, and `offset` shifts it right or
** down.
************************************************************************/
public int place(int available, int size, int offset)
{
return (switch(this)
{
case START -> offset;
case CENTER -> Math.floorDiv(available - size, 2) + offset;
case END -> available - size - offset;
});
}
}
/***************************************************************************
** Horizontal placement of content in its box.
***************************************************************************/
public enum Align
{
LEFT("left", Position.START), CENTER("center", Position.CENTER), RIGHT("right", Position.END);
private final String word;
private final Position position;
/************************************************************************
** The word a scene writes and the place it means.
************************************************************************/
Align(String word, Position position)
{
this.word = word;
this.position = position;
}
/************************************************************************
** The place on the horizontal axis.
************************************************************************/
public Position position()
{
return (position);
}
/************************************************************************
** The value a scene word names, or null.
************************************************************************/
public static Align of(String word)
{
for(Align align : values())
{
if(align.word.equals(word))
{
return (align);
}
}
return (null);
}
}
/***************************************************************************
** Vertical placement of content in its box.
***************************************************************************/
public enum Valign
{
TOP("top", Position.START), MIDDLE("middle", Position.CENTER), BOTTOM("bottom", Position.END);
private final String word;
private final Position position;
/************************************************************************
** The word a scene writes and the place it means.
************************************************************************/
Valign(String word, Position position)
{
this.word = word;
this.position = position;
}
/************************************************************************
** The place on the vertical axis.
************************************************************************/
public Position position()
{
return (position);
}
/************************************************************************
** The value a scene word names, or null.
************************************************************************/
public static Valign of(String word)
{
for(Valign valign : values())
{
if(valign.word.equals(word))
{
return (valign);
}
}
return (null);
}
}
/***************************************************************************
** The nine points of a box. The same point of the element is placed on that
** point of its container.
***************************************************************************/
public enum Anchor
{
TOP_LEFT("top-left", Position.START, Position.START),
TOP_CENTER("top-center", Position.CENTER, Position.START),
TOP_RIGHT("top-right", Position.END, Position.START),
MIDDLE_LEFT("middle-left", Position.START, Position.CENTER),
CENTER("center", Position.CENTER, Position.CENTER),
MIDDLE_RIGHT("middle-right", Position.END, Position.CENTER),
BOTTOM_LEFT("bottom-left", Position.START, Position.END),
BOTTOM_CENTER("bottom-center", Position.CENTER, Position.END),
BOTTOM_RIGHT("bottom-right", Position.END, Position.END);
private final String word;
private final Position horizontal;
private final Position vertical;
/************************************************************************
** The word a scene writes and the place it means on each axis.
************************************************************************/
Anchor(String word, Position horizontal, Position vertical)
{
this.word = word;
this.horizontal = horizontal;
this.vertical = vertical;
}
/************************************************************************
** The place on the horizontal axis.
************************************************************************/
public Position horizontal()
{
return (horizontal);
}
/************************************************************************
** The place on the vertical axis.
************************************************************************/
public Position vertical()
{
return (vertical);
}
/************************************************************************
** The value a scene word names, or null.
************************************************************************/
public static Anchor of(String word)
{
for(Anchor anchor : values())
{
if(anchor.word.equals(word))
{
return (anchor);
}
}
return (null);
}
}
}
@@ -0,0 +1,55 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
/*******************************************************************************
** The renderer saying no to a version 2 scene, with the one error diagnostic
** that says where and by how much. Its message is the diagnostic's code, never
** anything from the scene or the record.
*******************************************************************************/
public final class Refusal extends IllegalArgumentException
{
private static final long serialVersionUID = 1L;
private final transient Diagnostic diagnostic;
/***************************************************************************
** Only the collector raises a refusal, from a diagnostic it has checked.
***************************************************************************/
Refusal(Diagnostic diagnostic)
{
super(diagnostic.code());
this.diagnostic = diagnostic;
}
/***************************************************************************
** The error this refusal is.
***************************************************************************/
public Diagnostic diagnostic()
{
return (diagnostic);
}
}
@@ -0,0 +1,213 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.io.IOException;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.kingsrook.qbits.renderer.RenderInput;
/*******************************************************************************
** A version 2 scene from source to label: one design in physical units,
** resolved to whole dots at the render target, composed on one canvas and
** emitted as graphic fields. Coordinates start at the top left of the
** printable area and every box is held inside it, so nothing a scene says
** can print in a margin (`Page` states the rule to the dot).
*******************************************************************************/
public final class Scene2
{
public static final String SCHEMA = "qqq-zpl-scene/2";
public static final List<Integer> RESOLUTIONS = List.of(203, 300, 600);
static final int STOCK_TOLERANCE_MICROMETRES = 500;
private static final int MICROMETRES_PER_INCH = 25_400;
/***************************************************************************
** Utility class.
***************************************************************************/
private Scene2()
{
}
/***************************************************************************
** The page and its printable area in dots at the render target: the request's
** target when it names one, else the stock's own resolution.
**
** The printable area is the stock less its margins, in micrometres, rounded
** down to whole dots: exactly the size a box of the stated printable width
** and height resolves to, so a design that fills the printable area fits at
** every resolution. It starts at the left and top margins rounded up, and is
** moved back only as far as keeps it on the page. The left and top margins
** are therefore never entered unless the page itself ends first; the far
** edge may pass the right or bottom margin by less than one dot.
***************************************************************************/
record Page(int dpi, int width, int height, Frame printable)
{
/************************************************************************
** Resolve the stock, or refuse one the printer language cannot address.
************************************************************************/
static Page of(RenderInput input)
{
RenderInput.Stock stock = input.stock;
int dpi = input.targetDpi == null ? stock.dpi() : input.targetDpi;
if(!RESOLUTIONS.contains(dpi))
{
throw new IllegalArgumentException("UNSUPPORTED_ZPL_STOCK");
}
int pageWidth = dots(stock.widthMicrons(), dpi, MICROMETRES_PER_INCH / 2);
int pageHeight = dots(stock.heightMicrons(), dpi, MICROMETRES_PER_INCH / 2);
int width = dots(stock.widthMicrons() - stock.leftMicrons() - stock.rightMicrons(), dpi, 0);
int height = dots(stock.heightMicrons() - stock.topMicrons() - stock.bottomMicrons(), dpi, 0);
int left = Math.min(dots(stock.leftMicrons(), dpi, MICROMETRES_PER_INCH - 1), pageWidth - width);
int top = Math.min(dots(stock.topMicrons(), dpi, MICROMETRES_PER_INCH - 1), pageHeight - height);
Frame printable = new Frame(left, top, width, height);
Page page = new Page(dpi, pageWidth, pageHeight, printable);
if(page.width > 9999 || page.height > 32000 || printable.width() < 1 || printable.height() < 1 || (long) printable.width() * printable.height() > Canvas.MAXIMUM_DOTS)
{
throw new IllegalArgumentException("UNSUPPORTED_ZPL_STOCK");
}
return (page);
}
/************************************************************************
** Micrometres to dots. The addend chooses the rounding: half an inch of
** micrometres for the nearest dot (the page, as the host gate computes
** it), one less than an inch to round up (the left and top margins) and
** nothing to round down (the size of the printable area, as the size of
** a box is rounded).
************************************************************************/
private static int dots(int micrometres, int dpi, int addend)
{
return (Math.toIntExact(((long) micrometres * dpi + addend) / MICROMETRES_PER_INCH));
}
}
/***************************************************************************
** Check a scene without drawing it: its grammar for every resolution, and
** its layout on this stock at the render target.
***************************************************************************/
public static void validate(RenderInput input, JsonNode scene, Diagnostics diagnostics)
{
compose(input, scene, diagnostics, false);
}
/***************************************************************************
** Draw a scene and return the label, or null when the collector kept an
** error: a refused scene has no label, so one with elements missing cannot
** be printed by mistake. The canvas of such a scene, holding the elements
** that had no error, is what `compose` answers.
***************************************************************************/
public static byte[] render(RenderInput input, JsonNode scene, Diagnostics diagnostics) throws IOException
{
Page page = Page.of(input);
Canvas canvas = compose(input, scene, diagnostics, true);
return (diagnostics.refused() ? null : ZplEmitter.emit(canvas, page.width(), page.height(), page.printable().x(), page.printable().y(), input.maximumBytes));
}
/***************************************************************************
** Parse, lay out and, when asked, paint. A refusal about the scene as a
** whole ends the pass; a refusal about one element ends that element.
***************************************************************************/
static Canvas compose(RenderInput input, JsonNode root, Diagnostics diagnostics, boolean paint)
{
Page page = Page.of(input);
Canvas canvas = new Canvas(page.printable().width(), page.printable().height(), page.dpi());
try
{
SceneParser.Scene scene = SceneParser.parse(root, page.dpi(), diagnostics);
RenderInput.Stock stock = input.stock;
if(Math.abs(stock.widthMicrons() - scene.width().micrometres()) > STOCK_TOLERANCE_MICROMETRES || Math.abs(stock.heightMicrons() - scene.height().micrometres()) > STOCK_TOLERANCE_MICROMETRES)
{
throw diagnostics.refuse("STOCK_MISMATCH", null, "/design", null, Map.of("widthMicrometres", stock.widthMicrons(), "heightMicrometres", stock.heightMicrons()),
Map.of("widthMicrometres", scene.width().micrometres(), "heightMicrometres", scene.height().micrometres(), "toleranceMicrometres", STOCK_TOLERANCE_MICROMETRES));
}
walk(scene.elements(), new Layout(new Frame(0, 0, canvas.width(), canvas.height()), page.dpi()), canvas, paint, input, diagnostics);
}
catch(Refusal refusal)
{
diagnostics.keep(refusal);
}
return (canvas);
}
/***************************************************************************
** Elements in list order: place, hold the box to the printable area, check,
** count the dots it would paint against what one scene may paint, paint
** when asked, then the children inside the box. The dots are counted when a
** scene is only validated too, so validation refuses what rendering would.
***************************************************************************/
private static void walk(List<SceneElement> elements, Layout layout, Canvas canvas, boolean paint, RenderInput input, Diagnostics diagnostics)
{
Frame printable = new Frame(0, 0, canvas.width(), canvas.height());
for(SceneElement element : elements)
{
try
{
Frame frame = element.place(layout);
if(frame.width() < 1 || frame.height() < 1)
{
throw diagnostics.refuse("SCENE_INVALID", element.id(), diagnostics.pointer(element.id()), null, Map.of("widthDots", frame.width(), "heightDots", frame.height()),
Map.of("widthDots", 1, "heightDots", 1));
}
if(!printable.contains(frame))
{
throw diagnostics.refuse("ELEMENT_OUTSIDE_PRINTABLE_AREA", element.id(), null, frame,
Map.of("leftDots", frame.x(), "topDots", frame.y(), "rightDots", frame.right(), "bottomDots", frame.bottom()),
Map.of("leftDots", 0, "topDots", 0, "rightDots", printable.right(), "bottomDots", printable.bottom()));
}
element.check(frame, input, diagnostics);
long dots = element.paintedDots(frame, layout.dpi());
if(!canvas.admit(dots))
{
throw diagnostics.refuse("SCENE_INVALID", element.id(), diagnostics.pointer(element.id()), frame, Map.of("paintedPercent", canvas.paintedPercent(dots)),
Map.of("paintedPercent", Canvas.MAXIMUM_COATS * 100));
}
if(paint)
{
element.paint(canvas, frame, input, diagnostics);
}
walk(element.children(), layout.within(frame), canvas, paint, input, diagnostics);
}
catch(Refusal refusal)
{
diagnostics.keep(refusal);
}
}
}
}
@@ -0,0 +1,85 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.List;
import com.kingsrook.qbits.renderer.RenderInput;
/*******************************************************************************
** One element of a version 2 scene after parsing. The scene walks its elements
** in list order: place, check that the box is inside the printable area, check
** the element itself, paint, then the same for its children inside its box.
*******************************************************************************/
public interface SceneElement
{
/***************************************************************************
** The id the scene declares for the element.
***************************************************************************/
String id();
/***************************************************************************
** The element's box, in dots, inside the layout's container.
***************************************************************************/
Frame place(Layout layout);
/***************************************************************************
** Checks that need no ink, run when a scene is validated and again before it
** is painted. An element that cannot be drawn refuses here.
***************************************************************************/
default void check(Frame frame, RenderInput input, Diagnostics diagnostics)
{
}
/***************************************************************************
** The dots the element paints in this box, counted against what one scene
** may paint (`Canvas.MAXIMUM_COATS`) before any ink. An element paints only
** inside its box, so the box is the answer unless the element knows less.
***************************************************************************/
default long paintedDots(Frame frame, int dpi)
{
return ((long) frame.width() * frame.height());
}
/***************************************************************************
** Draw the element in its box.
***************************************************************************/
void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics);
/***************************************************************************
** The elements positioned relative to this one's box; only a group has any.
***************************************************************************/
default List<SceneElement> children()
{
return (List.of());
}
}
@@ -0,0 +1,374 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
import com.fasterxml.jackson.databind.JsonNode;
/*******************************************************************************
** The parser of `qqq-zpl-scene/2`, a format written by template authors and so
** untrusted. It reads only the documented grammar: an unknown property, a
** wrong type, a bad length or a limit passed is `SCENE_INVALID` at the JSON
** pointer of the offending place. It evaluates nothing and reads no record
** data.
**
** The whole structure is checked first, which declares every element id, and
** the elements are then built once for each resolution a printer can have, so
** an override meant for another printer cannot hide a mistake until that
** printer is used. The elements of the requested resolution are returned.
*******************************************************************************/
final class SceneParser
{
static final int MAXIMUM_ELEMENTS = 1000;
static final int MAXIMUM_DEPTH = 4;
static final int MAXIMUM_OVERRIDES = 32;
static final int MAXIMUM_SAMPLES = 16;
private static final Set<String> TOP = Set.of("schema", "design", "defaults", "samples", "elements", "overrides");
private static final Set<String> FIXED = Set.of("id", "type", "children");
private static final Pattern FONT = Pattern.compile("[A-Za-z0-9_-][A-Za-z0-9_.-]{0,99}");
private final JsonNode root;
private final Diagnostics diagnostics;
private int elements;
/***************************************************************************
** What a scene says once parsed: the stock it was drawn for and its
** elements for one resolution, in paint order.
***************************************************************************/
record Scene(Length width, Length height, List<SceneElement> elements)
{
}
/***************************************************************************
** One scene and the collector that will hold its ids and findings.
***************************************************************************/
private SceneParser(JsonNode root, Diagnostics diagnostics)
{
this.root = root;
this.diagnostics = diagnostics;
}
/***************************************************************************
** Parse a scene for one of the three resolutions.
***************************************************************************/
static Scene parse(JsonNode root, int dpi, Diagnostics diagnostics)
{
SceneParser parser = new SceneParser(root, diagnostics);
parser.structure();
Length width = parser.physical(root.get("design").get("width"), "/design/width");
Length height = parser.physical(root.get("design").get("height"), "/design/height");
Scene scene = new Scene(width, height, parser.build(root.get("elements"), "/elements", dpi));
for(int other : Scene2.RESOLUTIONS)
{
if(other != dpi)
{
parser.build(root.get("elements"), "/elements", other);
}
}
return (scene);
}
/***************************************************************************
** Everything that does not depend on the resolution.
***************************************************************************/
private void structure()
{
object(root, "", TOP);
if(!Scene2.SCHEMA.equals(root.path("schema").textValue()))
{
throw invalid(null, "/schema");
}
object(root.get("design"), "/design", Set.of("width", "height"));
JsonNode defaults = root.get("defaults");
if(defaults != null)
{
object(defaults, "/defaults", Set.of("font", "size"));
String font = defaults.path("font").textValue();
if(defaults.has("font") && (font == null || !FONT.matcher(font).matches()))
{
throw invalid(null, "/defaults/font");
}
if(defaults.has("size"))
{
physical(defaults.get("size"), "/defaults/size");
}
}
JsonNode samples = root.get("samples");
if(samples != null)
{
object(samples, "/samples", null);
if(samples.size() > MAXIMUM_SAMPLES)
{
throw diagnostics.refuse("SCENE_INVALID", null, "/samples", null, Map.of("samples", samples.size()), Map.of("samples", MAXIMUM_SAMPLES));
}
samples.forEach(sample -> object(sample, "/samples", null));
}
declare(root.get("elements"), "/elements", 1);
JsonNode overrides = root.get("overrides");
if(overrides != null)
{
array(overrides, "/overrides");
if(overrides.size() > MAXIMUM_OVERRIDES)
{
throw diagnostics.refuse("SCENE_INVALID", null, "/overrides", null, Map.of("overrides", overrides.size()), Map.of("overrides", MAXIMUM_OVERRIDES));
}
for(int index = 0; index < overrides.size(); index++)
{
override(overrides.get(index), "/overrides/" + index);
}
}
}
/***************************************************************************
** Declare every element id, depth first, before anything refers to one. An
** id is the only author text a diagnostic may carry, so it is held to its
** pattern here and must be unique.
***************************************************************************/
private void declare(JsonNode list, String pointer, int depth)
{
array(list, pointer);
for(int index = 0; index < list.size(); index++)
{
JsonNode element = list.get(index);
String at = pointer + "/" + index;
if(++elements > MAXIMUM_ELEMENTS)
{
throw diagnostics.refuse("SCENE_INVALID", null, "/elements", null, Map.of("elements", elements), Map.of("elements", MAXIMUM_ELEMENTS));
}
object(element, at, null);
String id = element.path("id").textValue();
if(id == null || !Diagnostics.ID.matcher(id).matches() || diagnostics.declared(id))
{
throw invalid(null, at + "/id");
}
diagnostics.declare(id, at);
String type = element.path("type").textValue();
if("group".equals(type))
{
if(depth > MAXIMUM_DEPTH)
{
throw diagnostics.refuse("SCENE_INVALID", id, at, null, Map.of("depth", depth), Map.of("depth", MAXIMUM_DEPTH));
}
declare(element.get("children"), at + "/children", depth + 1);
}
else if(type == null || !ElementFactory.FACTORIES.containsKey(type))
{
throw invalid(id, at + "/type");
}
}
}
/***************************************************************************
** One override: the resolutions it is for, and replacement properties by
** element id. It may not change what an element is.
***************************************************************************/
private void override(JsonNode override, String pointer)
{
object(override, pointer, Set.of("when", "set"));
JsonNode when = override.get("when");
JsonNode set = override.get("set");
object(when, pointer + "/when", Set.of("dpi"));
array(when.get("dpi"), pointer + "/when/dpi");
Set<Integer> resolutions = new HashSet<>();
for(JsonNode dpi : when.get("dpi"))
{
if(!dpi.isIntegralNumber() || !dpi.canConvertToInt() || !Scene2.RESOLUTIONS.contains(dpi.intValue()) || !resolutions.add(dpi.intValue()))
{
throw invalid(null, pointer + "/when/dpi");
}
}
if(resolutions.isEmpty())
{
throw invalid(null, pointer + "/when/dpi");
}
object(set, pointer + "/set", null);
if(set.isEmpty())
{
throw invalid(null, pointer + "/set");
}
for(Map.Entry<String, JsonNode> entry : set.properties())
{
if(!diagnostics.declared(entry.getKey()))
{
throw invalid(null, pointer + "/set");
}
JsonNode replacement = entry.getValue();
if(!replacement.isObject() || replacement.isEmpty() || FIXED.stream().anyMatch(replacement::has))
{
throw invalid(entry.getKey(), pointer + "/set/" + entry.getKey());
}
}
}
/***************************************************************************
** Build a list of elements for one resolution, with the overrides for it.
***************************************************************************/
private List<SceneElement> build(JsonNode list, String pointer, int dpi)
{
List<SceneElement> built = new ArrayList<>();
for(int index = 0; index < list.size(); index++)
{
JsonNode element = list.get(index);
String at = pointer + "/" + index;
String id = element.get("id").textValue();
Map<String, JsonNode> overrides = new LinkedHashMap<>();
Map<String, String> pointers = new LinkedHashMap<>();
JsonNode all = root.path("overrides");
for(int entry = 0; entry < all.size(); entry++)
{
JsonNode replacement = all.get(entry).get("set").get(id);
if(replacement != null && applies(all.get(entry), dpi))
{
String from = "/overrides/" + entry + "/set/" + id + "/";
replacement.properties().forEach(property ->
{
overrides.put(property.getKey(), property.getValue());
pointers.put(property.getKey(), from + property.getKey());
});
}
}
ElementSource source = new ElementSource(id, at, element, overrides, pointers, diagnostics);
if("group".equals(source.type()))
{
source.only(Set.of("children"));
built.add(new GroupElement(source, build(element.get("children"), at + "/children", dpi)));
}
else
{
ElementFactory factory = ElementFactory.FACTORIES.get(source.type());
source.only(factory.properties());
built.add(factory.create(source));
}
}
return (built);
}
/***************************************************************************
** Whether an override is for a resolution.
***************************************************************************/
private static boolean applies(JsonNode override, int dpi)
{
for(JsonNode resolution : override.get("when").get("dpi"))
{
if(resolution.intValue() == dpi)
{
return (true);
}
}
return (false);
}
/***************************************************************************
** A physical length that is more than nothing: a stock side or a type size.
***************************************************************************/
private Length physical(JsonNode value, String pointer)
{
try
{
Length length = Length.parse(value == null ? null : value.textValue());
if(length.physical() && length.thousandths() > 0)
{
return (length);
}
}
catch(IllegalArgumentException expected)
{
// Refused below, with the pointer; the cause holds nothing more.
}
throw invalid(null, pointer);
}
/***************************************************************************
** A JSON object whose properties are all among `names`, when names are
** given. Unknown names are counted, never repeated.
***************************************************************************/
private void object(JsonNode node, String pointer, Set<String> names)
{
if(node == null || !node.isObject())
{
throw invalid(null, pointer);
}
if(names != null)
{
int unknown = 0;
for(Map.Entry<String, JsonNode> property : node.properties())
{
unknown += names.contains(property.getKey()) ? 0 : 1;
}
if(unknown > 0)
{
throw diagnostics.refuse("SCENE_INVALID", null, pointer, null, Map.of("unknownProperties", unknown), Map.of("unknownProperties", 0));
}
}
}
/***************************************************************************
** A JSON array; each caller holds it to its own bound.
***************************************************************************/
private void array(JsonNode node, String pointer)
{
if(node == null || !node.isArray())
{
throw invalid(null, pointer);
}
}
/***************************************************************************
** The refusal for a place in the scene that is not what the schema says.
***************************************************************************/
private Refusal invalid(String element, String pointer)
{
return (diagnostics.refuse("SCENE_INVALID", element, pointer, null, null, null));
}
}
@@ -0,0 +1,94 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import com.kingsrook.qbits.renderer.RenderInput;
/*******************************************************************************
** An element the schema declares but this renderer cannot draw yet. It is
** parsed and placed like any other, so its box is checked and reported, and it
** is then refused by name: a scene that needs it needs a newer renderer. It is
** never drawn as something else and never skipped.
*******************************************************************************/
public final class UnavailableElement implements SceneElement
{
private final String id;
private final Placement placement;
/***************************************************************************
** The placeholder for one element; its rotation is read so a bad one is
** still refused as the scene's mistake.
***************************************************************************/
public UnavailableElement(ElementSource source)
{
id = source.id();
placement = source.placement();
source.rotation();
}
/***************************************************************************
** The id the scene declares for the element.
***************************************************************************/
@Override
public String id()
{
return (id);
}
/***************************************************************************
** The element's box, by the standing rules.
***************************************************************************/
@Override
public Frame place(Layout layout)
{
return (layout.place(placement));
}
/***************************************************************************
** Refused when the scene is validated and before any ink.
***************************************************************************/
@Override
public void check(Frame frame, RenderInput input, Diagnostics diagnostics)
{
throw diagnostics.refuse("RENDERER_TOO_OLD", id, null, frame, null, null);
}
/***************************************************************************
** Never reached through the scene, which checks first; refused all the same.
***************************************************************************/
@Override
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
{
throw diagnostics.refuse("RENDERER_TOO_OLD", id, null, frame, null, null);
}
}
@@ -0,0 +1,130 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer.scene2;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import com.kingsrook.qbits.renderer.Wire;
/*******************************************************************************
** A canvas as printer commands: the header version 1 writes, then uncompressed
** `^GFA` graphic fields, then the end of the label. Nothing else is emitted,
** so the host's output policy accepts a version 2 label with no change.
**
** Only ink is sent. Rows are cut into bands wherever at least `BLANK_ROWS`
** blank rows separate ink, each band is trimmed to its inked columns, and a
** band taller than one field may hold is continued in the next field. A band
** needs at least one inked row and is followed by a gap, so a label of the
** greatest height has far fewer fields than the policy allows.
*******************************************************************************/
final class ZplEmitter
{
static final int BLANK_ROWS = 8;
static final int MAXIMUM_FIELD_BYTES = 99_999;
/***************************************************************************
** Utility class.
***************************************************************************/
private ZplEmitter()
{
}
/***************************************************************************
** The label for a page of `pageWidth` by `pageHeight` dots whose printable
** area, the canvas, starts at `originX`, `originY`.
***************************************************************************/
static byte[] emit(Canvas canvas, int pageWidth, int pageHeight, int originX, int originY, int maximumBytes) throws IOException
{
Wire.Bytes output = new Wire.Bytes(maximumBytes);
write(output, "^XA^PW" + pageWidth + "^LL" + pageHeight + "^LH0,0^CI28\n");
int row = 0;
while(row < canvas.height())
{
if(canvas.firstInk(row) < 0)
{
row++;
continue;
}
int last = row;
int left = canvas.width();
int right = 0;
for(int next = row; next < canvas.height() && next - last <= BLANK_ROWS; next++)
{
int first = canvas.firstInk(next);
if(first >= 0)
{
last = next;
left = Math.min(left, first);
right = Math.max(right, canvas.lastInk(next));
}
}
int stride = (right - left + 8) / 8;
int rowsPerField = MAXIMUM_FIELD_BYTES / stride;
for(int first = row; first <= last; first += rowsPerField)
{
field(output, canvas, left, right, first, Math.min(rowsPerField, last + 1 - first), originX, originY);
}
row = last + 1;
}
write(output, "^XZ\n");
return (output.result());
}
/***************************************************************************
** One graphic field: `rows` rows of the canvas from column `left` to column
** `right`, most significant bit first, with the padding bits left white.
***************************************************************************/
private static void field(Wire.Bytes output, Canvas canvas, int left, int right, int top, int rows, int originX, int originY) throws IOException
{
int stride = (right - left + 8) / 8;
byte[] raster = new byte[stride * rows];
for(int row = 0; row < rows; row++)
{
for(int column = left; column <= right; column++)
{
if(canvas.get(column, top + row))
{
raster[row * stride + (column - left) / 8] |= (byte) (128 >> (column - left) % 8);
}
}
}
write(output, "^FO" + (originX + left) + "," + (originY + top) + "^GFA," + raster.length + "," + raster.length + "," + stride + "," + HexFormat.of().withUpperCase().formatHex(raster) + "^FS\n");
}
/***************************************************************************
** All output is generated ASCII.
***************************************************************************/
private static void write(Wire.Bytes output, String value) throws IOException
{
output.write(value.getBytes(StandardCharsets.US_ASCII));
}
}
@@ -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");
}
}
@@ -0,0 +1,124 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.renderer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.kingsrook.qbits.renderer.scene2.SceneFixture;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** What the host side relies on from this worker and cannot test against it,
** because the host bridge shares no code with the renderer: the version it
** reports is the one its qualification record names, the bridge's version
** question is answered, and a preview's label is the label that prints.
*******************************************************************************/
class HostContractTest
{
///////////////////////////////////////////////////////////////////////////////////////////////
// the scene of the host bridge's version question (`IsolatedRendererBridge.PROBE_SCENE` in //
// `bridges/renderer`), on its stock: 4 x 6 in with no margins at 203 dpi //
///////////////////////////////////////////////////////////////////////////////////////////////
private static final String VERSION_QUESTION = "{\"schema\":\"qqq-zpl-scene/1\",\"elements\":[]}";
/***************************************************************************
** `qualification.json` names the renderer version, protocol and barcode
** catalog that this build's answers carry.
***************************************************************************/
@Test
void theQualificationRecordNamesWhatTheWorkerReports() throws Exception
{
JsonNode record = Wire.JSON.readTree(Files.readAllBytes(Path.of("qualification.json")));
Map<String, Object> answer = RendererMain.process(SceneFixture.request("VALIDATE", VERSION_QUESTION));
for(String field : List.of("rendererVersion", "protocol", "catalogVersion"))
{
assertEquals(answer.get(field), record.path(field).textValue(), field);
}
assertEquals(RendererMain.VERSION, record.path("rendererVersion").textValue());
assertEquals(Wire.PROTOCOL, record.path("protocol").textValue());
assertEquals(Barcodes.VERSION, record.path("catalogVersion").textValue());
assertFalse(record.path("qualified").booleanValue());
}
/***************************************************************************
** The host asks a worker its version by validating an empty version 1 scene
** before it sends a version 2 scene. The answer is the plain validation
** answer, naming the version.
***************************************************************************/
@Test
void theHostVersionQuestionIsAnswered() throws Exception
{
ObjectNode question = SceneFixture.request("VALIDATE", SceneFixture.stock(101_600, 152_400, 0, 203), VERSION_QUESTION).put("maximumBytes", 4096);
Map<String, Object> answer = RendererMain.process(question);
assertEquals(List.of("protocol", "requestId", "outcome", "rendererVersion", "catalogVersion", "diagnostics"), List.copyOf(answer.keySet()));
assertEquals("OK", answer.get("outcome"));
assertEquals(RendererMain.VERSION, answer.get("rendererVersion"));
assertEquals(List.of(), answer.get("diagnostics"));
}
/***************************************************************************
** A preview asks for collected diagnostics. For a scene with no error that
** changes nothing in the label: every golden case, at every resolution, is
** byte for byte the golden label either way.
***************************************************************************/
@Test
void collectingDiagnosticsDoesNotChangeALabelThatPrints() throws Exception
{
Path scenes = Path.of("src", "test", "resources", "scene2");
List<String> names;
try(Stream<Path> files = Files.list(scenes.resolve("cases")))
{
names = files.map(file -> file.getFileName().toString()).filter(name -> name.endsWith(".json")).map(name -> name.substring(0, name.length() - 5)).sorted().toList();
}
assertTrue(names.size() >= 4);
for(String name : names)
{
JsonNode definition = Wire.JSON.readTree(Files.readAllBytes(scenes.resolve("cases").resolve(name + ".json")));
for(int dpi : List.of(203, 300, 600))
{
ObjectNode request = SceneFixture.request("RENDER", definition.get("stock"), Wire.JSON.writeValueAsString(definition.get("scene"))).put("targetDpi", dpi);
byte[] printed = SceneFixture.render(request);
Map<String, Object> previewed = RendererMain.process(request.put("diagnostics", "COLLECT"));
assertEquals("OK", previewed.get("outcome"), name);
assertTrue(Arrays.equals(printed, Base64.getDecoder().decode((String) previewed.get("base64"))), name + " at " + dpi);
assertTrue(Arrays.equals(Files.readAllBytes(scenes.resolve("golden").resolve(name + "-" + dpi + ".zpl")), printed), name + " at " + dpi);
}
}
}
}
@@ -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());
}
}
}
Loaded 100 of 220 files, more files were not shown because too many files have changed in this diff. Show more