Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
363e96f641
|
||
|
|
1db82957c9
|
||
|
|
6aba6ce8d4
|
||
|
|
1828ae9c48
|
||
|
|
bbb4b60a1b
|
||
|
|
9e5b16d686
|
||
|
|
ed11495120
|
No files matched your search
@@ -16,6 +16,3 @@
|
||||
# Stored renderer outputs are compared byte for byte: git must never rewrite them.
|
||||
renderer/src/test/resources/compat-v1/** -text
|
||||
renderer/src/test/resources/scene2/golden/** -text
|
||||
|
||||
# Bundled fonts and their licence texts are shipped exactly as released and are hashed: git must never rewrite them.
|
||||
renderer/src/main/resources/fonts/** -text
|
||||
@@ -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.
|
||||
|
||||
@@ -36,8 +36,10 @@ nothing of the scene or the record.
|
||||
- **Preview.** `preview(PreviewRequest)` is the print path stopped before retention: the same job with collected
|
||||
diagnostics, the same integrity checks and output policy, and a page decoded by `ZplRaster` from the accepted
|
||||
label bytes (`PreviewPage` is a one-bit PNG, one pixel for each dot). Refusals come back as diagnostics with
|
||||
`printable: false`, never as a label: a refused scene has no output, and its page shows only the hatched
|
||||
boxes of the refused elements. A page of more than sixteen million dots is `PREVIEW_TOO_LARGE`. PDF previews
|
||||
`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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -47,6 +47,13 @@ import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
** 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
|
||||
{
|
||||
@@ -68,7 +75,11 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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 //
|
||||
// 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;
|
||||
|
||||
@@ -231,8 +242,12 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
**
|
||||
** A scene with a refused element has no label at all (the worker sends
|
||||
** none), so there is nothing a host could print by mistake. Its page shows
|
||||
** only where the refused elements are, hatched, from the boxes in their
|
||||
** diagnostics; when no diagnostic has a box there is no page.
|
||||
** 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.
|
||||
@@ -271,7 +286,15 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
return (refused(printed, answer.diagnostics()));
|
||||
}
|
||||
byte[] bytes = output(answer, request.format(), maximum, printed);
|
||||
ZplRaster page = ZplRaster.page(bytes, 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())
|
||||
{
|
||||
@@ -297,12 +320,15 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
** 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).
|
||||
** 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();
|
||||
@@ -310,10 +336,38 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
int height = dots(printed.heightMicrons(), dpi, 12700);
|
||||
int left = Math.min(dots(printed.leftMicrons(), dpi, 25399), width - dots(printed.widthMicrons() - printed.leftMicrons() - printed.rightMicrons(), dpi, 0));
|
||||
int top = Math.min(dots(printed.topMicrons(), dpi, 25399), height - dots(printed.heightMicrons() - printed.topMicrons() - printed.bottomMicrons(), dpi, 0));
|
||||
ZplRaster page = ZplRaster.refused(width, height, left, top, boxes);
|
||||
pages = List.of(new GenerationPorts.PreviewPage(page.png(), page.width(), page.height()));
|
||||
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, diagnostics, 0L, RendererJson.hash(new byte[0])));
|
||||
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))));
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
@@ -377,6 +431,14 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
** 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 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
|
||||
@@ -424,13 +486,32 @@ public final class IsolatedRendererBridge implements GenerationPorts.IsolatedRen
|
||||
int maximumFrame = (int) Math.min(room, 4L * ((config.maximumBytes() + 2) / 3) + ENVELOPE_BYTES);
|
||||
WorkerAnswer answer = WorkerAnswer.read(exchange.invoke(id, input, maximumFrame), maximumFrame, id, facts);
|
||||
structured = answer.version().structured();
|
||||
return (answer.settle(operation, collect, current));
|
||||
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 nothing of a version 2 scene or its record reaches a renderer that
|
||||
** cannot draw it. The question is a constant.
|
||||
** 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
|
||||
{
|
||||
|
||||
@@ -58,7 +58,7 @@ final class SceneFacts
|
||||
static final Set<String> VOCABULARY = Set.of("schema", "design", "defaults", "samples", "elements", "overrides", "when", "dpi", "set", "children",
|
||||
"id", "type", "anchor", "x", "y", "width", "height", "align", "valign",
|
||||
"thickness", "fill",
|
||||
"value", "valuePath", "font", "size", "lineHeight", "fit", "maxLines", "shrinkTo", "overflow", "maxLength", "threshold", "reverse", "rotation",
|
||||
"value", "valuePath", "font", "size", "lineHeight", "fit", "maxLength", "threshold", "reverse", "rotation",
|
||||
"symbology", "symbologyPath", "allowedSymbologies", "minModule", "maxModule", "quiet", "gs1", "hri", "scannerSymbologyPath", "aimIdentifierPath",
|
||||
"asset", "mode");
|
||||
|
||||
|
||||
@@ -41,7 +41,14 @@ import com.kingsrook.qbits.documentgeneration.GenerationPorts;
|
||||
** large to draw is refused by name. Graphic fields (`^GFA`) and boxes (`^GB`)
|
||||
** are painted exactly. Printer font text (`^A0N`), which only a version 1
|
||||
** scene emits, cannot be drawn without the printer's font: its estimated area
|
||||
** is hatched and the page says it is not exact.
|
||||
** 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.
|
||||
@@ -53,7 +60,16 @@ final class ZplRaster
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final long MAXIMUM_DOTS = 16_000_000L;
|
||||
|
||||
private static final int HATCH = 6;
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 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;
|
||||
@@ -61,6 +77,7 @@ final class ZplRaster
|
||||
private final int stride;
|
||||
private final byte[] ink;
|
||||
private boolean exact = true;
|
||||
private long painted;
|
||||
|
||||
|
||||
|
||||
@@ -138,9 +155,11 @@ final class ZplRaster
|
||||
|
||||
/***************************************************************************
|
||||
** A page that shows only where elements were refused: each box, given in
|
||||
** dots from the printable area's top left as a diagnostic gives it, hatched
|
||||
** at its place on the page and cut to the page. It is drawn from numbers
|
||||
** alone. No label exists for it, so nothing of it can be printed.
|
||||
** 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)
|
||||
{
|
||||
@@ -152,10 +171,7 @@ final class ZplRaster
|
||||
long top = Math.max(0L, (long) originY + box.get(1));
|
||||
long right = Math.min(width, (long) originX + box.get(0) + box.get(2));
|
||||
long bottom = Math.min(height, (long) originY + box.get(1) + box.get(3));
|
||||
if(left < right && top < bottom)
|
||||
{
|
||||
page.hatch((int) left, (int) top, (int) (right - left), (int) (bottom - top));
|
||||
}
|
||||
page.hatch((int) left, (int) top, (int) Math.max(0L, right - left), (int) Math.max(0L, bottom - top));
|
||||
}
|
||||
return (page);
|
||||
}
|
||||
@@ -281,7 +297,8 @@ final class ZplRaster
|
||||
|
||||
/***************************************************************************
|
||||
** Printer font text: the area the policy holds it to (one cell of the font
|
||||
** width for each character, one line of the font height), hatched.
|
||||
** width for each character, one line of the font height), outlined and
|
||||
** crossed.
|
||||
***************************************************************************/
|
||||
private void text(Reader reader, int x, int y)
|
||||
{
|
||||
@@ -324,21 +341,70 @@ final class ZplRaster
|
||||
{
|
||||
throw new IllegalArgumentException("RASTER_FIELD");
|
||||
}
|
||||
for(int row = 0; row < boxHeight; row++)
|
||||
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))
|
||||
{
|
||||
boolean border = row < thickness || row >= boxHeight - thickness;
|
||||
for(int column = 0; column < boxWidth; column++)
|
||||
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++)
|
||||
{
|
||||
if(border || column < thickness || column >= boxWidth - thickness)
|
||||
{
|
||||
set(x + column, y + row);
|
||||
}
|
||||
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
|
||||
@@ -358,6 +424,7 @@ final class ZplRaster
|
||||
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();
|
||||
@@ -393,20 +460,28 @@ final class ZplRaster
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** An outline with diagonal lines across it: plainly a placeholder, never
|
||||
** mistaken for ink the label holds.
|
||||
** 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)
|
||||
{
|
||||
for(int row = 0; row < areaHeight; row++)
|
||||
if(areaWidth < 1 || areaHeight < 1)
|
||||
{
|
||||
for(int column = 0; column < areaWidth; column++)
|
||||
{
|
||||
if(row == 0 || column == 0 || row == areaHeight - 1 || column == areaWidth - 1 || (row + column) % HATCH == 0)
|
||||
{
|
||||
set(x + column, y + row);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ 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;
|
||||
|
||||
@@ -49,7 +50,7 @@ final class FakeWorker implements IsolatedRendererBridge.Exchange
|
||||
final List<JsonNode> jobs = new ArrayList<>();
|
||||
final List<JsonNode> questions = new ArrayList<>();
|
||||
final List<Integer> frameLimits = new ArrayList<>();
|
||||
private final String version;
|
||||
private final Supplier<String> version;
|
||||
private final Answer answer;
|
||||
|
||||
|
||||
@@ -72,6 +73,17 @@ final class FakeWorker implements IsolatedRendererBridge.Exchange
|
||||
** 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;
|
||||
@@ -90,7 +102,7 @@ final class FakeWorker implements IsolatedRendererBridge.Exchange
|
||||
if(IsolatedRendererBridge.PROBE_SCENE.equals(job.path("source").textValue()))
|
||||
{
|
||||
questions.add(job);
|
||||
return (frame(validated(requestId, version)));
|
||||
return (frame(validated(requestId, version.get())));
|
||||
}
|
||||
jobs.add(job);
|
||||
frameLimits.add(maximumOutput);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,9 @@ 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;
|
||||
@@ -44,6 +47,7 @@ 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;
|
||||
|
||||
|
||||
@@ -157,7 +161,7 @@ class PreviewTest
|
||||
** the preview has no output. Its page is drawn on the host from the boxes of
|
||||
** the error diagnostics alone, placed from the printable area's corner as
|
||||
** the renderer places it (the corner of the golden label at each
|
||||
** resolution), cut to the page and hatched.
|
||||
** resolution), cut to the page, outlined and crossed.
|
||||
***************************************************************************/
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {203, 300, 600})
|
||||
@@ -190,8 +194,8 @@ class PreviewTest
|
||||
{
|
||||
for(int x = 0; x < page.getWidth(); x++)
|
||||
{
|
||||
boolean first = hatched(x - left, y - top, 16, 12);
|
||||
boolean last = hatched(x, y - top - 30, left + 20, page.getHeight() - top - 30);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -201,17 +205,6 @@ class PreviewTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a dot is ink in a hatched area of this size whose corner is the
|
||||
** origin of `column` and `row`.
|
||||
***************************************************************************/
|
||||
private static boolean hatched(int column, int row, int width, int height)
|
||||
{
|
||||
return (column >= 0 && column < width && row >= 0 && row < height && (column == 0 || row == 0 || column == width - 1 || row == height - 1 || (column + row) % 6 == 0));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A refusal is a result of a preview, not an exception: whatever the print
|
||||
** path would refuse by name comes back as the diagnostic, not printable,
|
||||
@@ -296,6 +289,69 @@ class PreviewTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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.
|
||||
|
||||
@@ -60,7 +60,7 @@ class SceneFactsTest
|
||||
Set<String> declared = new TreeSet<>();
|
||||
names(RendererJson.JSON.readTree(Files.readAllBytes(Path.of("..", "..", "renderer", "schema", "zpl-scene-2.schema.json"))), declared);
|
||||
assertEquals(declared, new TreeSet<>(SceneFacts.VOCABULARY));
|
||||
assertEquals(46, SceneFacts.VOCABULARY.size());
|
||||
assertEquals(43, SceneFacts.VOCABULARY.size());
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ 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;
|
||||
@@ -229,6 +230,142 @@ class VersionRuleTest
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 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
|
||||
|
||||
@@ -27,6 +27,8 @@ 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;
|
||||
@@ -38,6 +40,7 @@ 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;
|
||||
|
||||
|
||||
@@ -46,7 +49,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
** 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.
|
||||
** 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
|
||||
{
|
||||
@@ -54,6 +59,11 @@ class ZplRasterTest
|
||||
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;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
@@ -217,10 +227,10 @@ class ZplRasterTest
|
||||
/***************************************************************************
|
||||
** Printer font text cannot be drawn on the host. Its area (one cell for each
|
||||
** character, counted as characters and not as bytes) is outlined and
|
||||
** hatched, and the page says it is not exact.
|
||||
** crossed by its two diagonals, and the page says it is not exact.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void printerFontTextIsAHatchedPlaceholderAndThePageIsNotExact()
|
||||
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());
|
||||
@@ -228,10 +238,7 @@ class ZplRasterTest
|
||||
{
|
||||
for(int x = 8; x < 34; x++)
|
||||
{
|
||||
int column = x - 10;
|
||||
int row = y - 20;
|
||||
boolean inside = column >= 0 && column < 21 && row >= 0 && row < 12;
|
||||
assertEquals(inside && (column == 0 || row == 0 || column == 20 || row == 11 || (column + row) % 6 == 0), page.ink(x, y), x + ", " + y);
|
||||
assertEquals(crossed(x - 10, y - 20, 21, 12), page.ink(x, y), x + ", " + y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -321,7 +328,8 @@ class ZplRasterTest
|
||||
/***************************************************************************
|
||||
** The page of a refused scene shows only where its refused elements are:
|
||||
** each diagnostic box, moved from the printable area to the page, cut to the
|
||||
** page and hatched. It is never exact, and a box off the page draws nothing.
|
||||
** page, outlined and crossed. It is never exact, and a box off the page
|
||||
** draws nothing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theRefusedPageIsDrawnFromBoxesAlone()
|
||||
@@ -333,9 +341,9 @@ class ZplRasterTest
|
||||
{
|
||||
for(int x = 0; x < 60; x++)
|
||||
{
|
||||
boolean first = hatched(x - 10, y - 5, 13, 7);
|
||||
boolean cut = hatched(x - 55, y - 35, 5, 5);
|
||||
boolean corner = hatched(x, y, 12, 8);
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -345,11 +353,193 @@ class ZplRasterTest
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a dot is ink in a hatched area of this size whose corner is the
|
||||
** origin of `column` and `row`.
|
||||
** 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.
|
||||
***************************************************************************/
|
||||
private static boolean hatched(int column, int row, int width, int height)
|
||||
@Test
|
||||
void aPlaceholderOfNoWidthOrNoHeightDrawsNothing()
|
||||
{
|
||||
return (column >= 0 && column < width && row >= 0 && row < height && (column == 0 || row == 0 || column == width - 1 || row == height - 1 || (column + row) % 6 == 0));
|
||||
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 @@
|
||||
{"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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -346,89 +346,6 @@ Legibility on thermal heads:
|
||||
- Guidance for the authoring guide: at 203 dpi use a bold face below 10 pt, keep text at or above 6 pt, prefer the
|
||||
condensed bold over shrinking.
|
||||
|
||||
**Decided while building text (P2).** The code is `scene2/text`; the renderer README states the rules for authors.
|
||||
|
||||
- **The set as shipped.** `qqq-sans` and `qqq-sans-bold` are IBM Plex Sans Regular and Bold (release
|
||||
`@ibm/plex-sans@1.1.0`), `qqq-condensed-bold` is IBM Plex Sans Condensed Bold (`@ibm/plex-sans-condensed@2.0.0`),
|
||||
`qqq-mono` and `qqq-mono-bold` are JetBrains Mono Regular and Bold (`v2.304`). Every licence was confirmed as SIL
|
||||
OFL 1.1 from the release archive's own licence file. A scene names a face with `font`; the name carries family
|
||||
and weight, and there is no separate weight or style property (the set has no italic). Only bundled faces are
|
||||
drawn: a supplied `font/ttf` asset named by a version 2 text is `FONT_UNKNOWN` until the asset package (P8).
|
||||
- **One glyph for each character, left to right.** No kerning, ligatures, mark placement or bidirectional
|
||||
reordering, which is what version 1 raster text does too and what an identifier wants (JetBrains Mono would
|
||||
otherwise join `->` and `!=`). A character that cannot be drawn that way (a control or format character, a
|
||||
combining mark, a script that needs shaping, anything outside the Basic Multilingual Plane, or a character the
|
||||
face lacks) is `FONT_MISSING_GLYPH` with the count; the value prints whole or not at all.
|
||||
- **Measurement is integer arithmetic.** The faces have 1000 units to the em. Advances and outline boxes are
|
||||
read once at a reference size and scaled as `units x millipoints x dpi / 72 000 000`, so the same value is the
|
||||
same width on every machine. Each character sits at a whole-dot pen position. A line's box is its advance,
|
||||
widened to the ink of its characters plus two dots on each inked side: the rasteriser fits an outline to the
|
||||
dot grid within 1.4 dots of its exact scale (measured over every character of the five faces, from 1 pt to
|
||||
1000 pt at the three resolutions). A text whose ink would leave the measured box is neither clipped nor printed
|
||||
outside it nor printed in part: its dots are gathered first and reach the label only when none strayed, and
|
||||
otherwise the one element is refused as `TEXT_DOES_NOT_FIT` with `glyphsOutsideMeasure` and
|
||||
`dotsOutsideMeasure`. A literal is held to the glyph bounds when the scene is validated.
|
||||
- **The size is found without trying every size.** The answer is defined as the one a search of every
|
||||
quarter-point step from the size down to the floor gives, and a property test holds the implementation to that
|
||||
search exactly. The implementation first asks, by halving, for the largest step at which the text could fit at
|
||||
all: a count that fills lines at any character with a width no real line can be under, so it never refuses a
|
||||
size that fits and, once it allows a size, allows every smaller one. Only the few steps from there are laid
|
||||
out. When one line at the floor is higher than the box nothing is searched. The case of 1024 characters from
|
||||
1000 pt to 1 pt, which took about 200 ms a fit, takes under 1 ms.
|
||||
- **The work of a scene's text is bounded.** Every character visited while fitting is one step, every character
|
||||
to be drawn 16, and every glyph a scene draws for the first time at a size 4096 (the runtime rasterises it
|
||||
once). One scene may take 64 million steps, about a second of a worker; the element that passes it, and every
|
||||
text after it, is `SCENE_INVALID` at the element with `textWorkSteps`. The count is integer arithmetic, so the
|
||||
refusal is the same on every machine and the same whether the scene is validated or rendered. A text is
|
||||
fitted once: what the check composed is what is painted.
|
||||
- **Wrapping.** At each size a line is broken between words while the text then fits, and at any character when
|
||||
it does not. So a larger size is preferred to whole words, a value with no space wraps (the example's GS1 line
|
||||
has none), and a length is provable: n characters need no more lines than n of the widest.
|
||||
- **Truncation.** At the floor size, as many lines as the box holds; the last ends with the ellipsis after as
|
||||
many of its characters as fit with it, down to the ellipsis alone. A cut text is also reported as shrunk.
|
||||
- **The proof of `maxLength`** lays out that many copies of a worst character: the face's greatest advance,
|
||||
reaching as far left, right, above and below as the furthest-reaching character of the face (in IBM Plex Sans
|
||||
Bold the per mille sign is 1.382 em wide, the fraction slash reaches 0.245 em past its advance on each side, and
|
||||
a capital A with ring and acute rises to 1.15 em against an ascent of 1.025). It is therefore sound for any
|
||||
value, and stricter than a typical value needs. The example scene of section 2.1 does not pass it: two lines at
|
||||
the 7 pt floor need 59 dots at 203 dpi (53 from rounding the ascent, the line pitch and the descent up to whole
|
||||
dots, 6 for the accent and the descender) and the 6.5 mm caption box has 51. A box of exactly `lines x line
|
||||
height` is not proven; 7.5 mm is. The proof is for the render target of the request, so a host that prints
|
||||
one revision at several resolutions validates at each (P5 or P6b). The proof never cuts: a cut value is not a
|
||||
value that fits. An element with `overflow: truncate` whose `maxLength` is not proven stays valid, because
|
||||
cutting is what its author chose, and carries the warning `FIT_NOT_PROVEN` at every validation with
|
||||
`provenCharacters`, the longest length proven to fit whole, against the `characters` stated.
|
||||
- **Validation reads no record.** A bound value is laid out only when rendering; `FIT_NOT_PROVEN` is raised for
|
||||
a bound element without `maxLength` in both operations. When a bound element is validated, legibility is
|
||||
judged at the size its `maxLength` is proven at, which is the smallest any value is printed at (the floor when
|
||||
the proof fails and the element cuts), and at the stated size when it has no `maxLength`; when it is rendered,
|
||||
at the size chosen.
|
||||
- **A bound number** is printed in plain decimal digits: no exponent and no fraction that is only zeros
|
||||
(`1.0E10` prints `10000000000`, `2.50` prints `2.5`, `12.0` prints `12`). A number that is not finite is
|
||||
`VALUE_MISSING`.
|
||||
- **Bounds.** A size from 1 pt to 1000 pt, `shrinkTo` from 1 pt up to the size, `lineHeight` from 0.5 to 5 with
|
||||
three decimals, 64 lines, 1024 characters.
|
||||
- **The members of `fit` are schema properties.** The published schema declares `maxLines`, `shrinkTo` and
|
||||
`overflow` under `fit` with `additionalProperties: false`, so a host can check them by machine, and a member
|
||||
that is wrong is reported at the member (`/elements/1/fit/maxLines`, or in the override that states `fit`).
|
||||
The three names are in the host's list (`SceneFacts.VOCABULARY`, held to the schema by a test in the bridge)
|
||||
and in the renderer's (`ElementFactory.VOCABULARY`). A member `fit` does not have is reported at `fit`,
|
||||
because its name is not one the schema declares.
|
||||
- **Rotation and reverse.** A text turned a quarter is fitted along the box's height, and `align` and `valign`
|
||||
follow the direction of reading. Reverse text fills its own box black and clears the letters; it counts twice
|
||||
against the painted-area cap.
|
||||
- **`RESERVED_FONT_NAME`** is raised by a text element (with the count of supplied assets under `qqq-`), not for
|
||||
the scene as a whole: a scene with no text has no face to shadow.
|
||||
- **Determinism (section 10.2).** No tolerance was needed. The goldens are generated in the CI image (Linux,
|
||||
Temurin 21.0.9) and are the same bytes on Windows (Temurin 21.0.12), at 203, 300 and 600 dpi, for the glyph
|
||||
bitmaps (up to 100 dots to the em) and for the outline path above that; they are compared exactly on both.
|
||||
Glyph bitmaps come from the FreeType each JDK ships; the versions are recorded beside the goldens
|
||||
(`renderer/src/test/resources/scene2/README.md`).
|
||||
One case depends on more than the scene: a line whose strip is over 8 million dots (type of several hundred
|
||||
points) is drawn in bands of rows, and the rasteriser may shade a few edge dots at a band's border differently
|
||||
than in one piece (fewer than 1 in 10 000 ink dots). Where the bands fall is a function of the line's box, so
|
||||
the result is still the same on every machine.
|
||||
|
||||
## 4. Barcodes
|
||||
|
||||
### 4.1 Symbologies
|
||||
@@ -647,6 +564,92 @@ for it.
|
||||
**A host application** gets all of the above by registering the QBit as it does now. Forge adds help entries for
|
||||
the new process, fields and widget (its help coverage test requires them) and nothing else.
|
||||
|
||||
**Decided while building the preview (P6b).**
|
||||
|
||||
- **The spike.** A composite widget whose `IMAGE` block has a data URL as its `path` renders as an `<img>` in the
|
||||
frontend a host serves, both in a process step (seeded from the process value of the widget's name, and
|
||||
fetched when no such value exists) and in a record screen section that names the widget. This was run as
|
||||
component tests against the frontend's released source in a simulated browser document, with the payload shape
|
||||
the backend's block classes serialise to (asserted in this repository's tests); it was not run in a real
|
||||
browser against a running host. The `FILE_PREVIEW` fallback was not needed.
|
||||
- **The result step fetches the widget; it is not seeded.** QQQ stores the state of a process after every
|
||||
backend step and when it stops at a screen, and never removes it when the process ends: the in-memory provider
|
||||
drops an entry five hours after the process started (a sweep every thirty minutes), and the shared table
|
||||
provider keeps a row (encrypted when a key is configured) for its configured lifetime, twelve hours by default. A seeded widget would therefore put the picture of a real
|
||||
record in memory for hours, or in a database table, which section 9 forbids. So the process state holds the
|
||||
person's choices only (which template or revision, the data source, the sample name, the source table, the
|
||||
record's identity, the stock and the resolution). The result step names the widget, the client requests it,
|
||||
the request is authorised and drawn like any other, and the picture, the values and the diagnostics exist
|
||||
only in that answer. The record's identity stays in the process state for the lifetime above; no value of the
|
||||
record does.
|
||||
- **The widget is requested by the run, not by the choices (independent review).** 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. A render in an
|
||||
isolated worker often takes that long, so the record's identity would have been logged. Once the form is
|
||||
accepted, its choices are therefore held as one structured process value, which is not a plain value and is
|
||||
not sent; the widget is given the run's `processUUID` and reads the choices from the state of the person's own
|
||||
run (QQQ answers only the session that started it). The widget no longer reads a revision, record, sample,
|
||||
stock or resolution from any request parameter.
|
||||
- **One widget, two places.** The record screen sends the table and the template's identity; the action sends
|
||||
the identifier of its run. The widget never trusts either: every request goes through
|
||||
`DocumentPreviewService`, which applies the table of section 6.3. On the record screen an author sees the
|
||||
draft and a person who may only view sees the published revision, both with sample data.
|
||||
- **A revision that is named must be a publication (independent review).** The revision table also holds the
|
||||
frozen drafts that the retained preview writes (`purpose` is `PREVIEW`). On the revision path the service
|
||||
resolves only a publication; any other row is "Document definition not found.", the answer for an identity
|
||||
that does not exist, from the action and from the widget alike. A draft is previewed through its template
|
||||
alone, which needs `configure`. An earlier publication that a later one replaced is still a publication.
|
||||
- **A record is read within the ceiling printing applies (independent review).** Before a source record is read
|
||||
the service asks what the print path asks of the source table: that the person's channel and credential may
|
||||
read it. The enforced read that follows is unchanged.
|
||||
- **Too dense to preview is not "cannot print" (independent review).** When the only thing refused is the
|
||||
picture (`PREVIEW_TOO_LARGE` and no other refusal), the widget says "This label is too dense to preview;
|
||||
printing is not affected." (or "too large", for a page of more than sixteen million dots) in place of the
|
||||
verdict that the layout cannot print.
|
||||
- **No `Cache-Control` from the widget route.** A widget renderer cannot set a response header and QQQ's widget
|
||||
route sets none, so a host or its proxy must send `no-store` for widget responses (README, "What a host must
|
||||
configure for label previews").
|
||||
- **Published revision with a record needs `generate` and not `view`**, as the table says. The record selector
|
||||
(`GenerationPreviewSources`) therefore asks for `generate` alone; it asked for `configure` as well, which
|
||||
would have kept a person who may generate but not author from choosing a record. Every record it offers is
|
||||
still read as that person.
|
||||
- **No draft fingerprint.** The service signature of section 6.3 named one. Nothing is saved against a preview,
|
||||
so there is nothing a stale draft could overwrite; the preview is always of the draft as it stands.
|
||||
- **Samples.** The schema allows sixteen samples where section 6.2 says eight; the schema is followed. The form
|
||||
offers the first sixteen whose names are plain words. A version 1 scene has no `samples`, so its sample
|
||||
preview has an empty context; a label bound to record values then cannot be drawn without a record, and the
|
||||
widget says so.
|
||||
- **Longest values on the host.** The host has no fonts and cannot know a face's widest glyph. It fills each
|
||||
bound element that declares `maxLength` (1 to 4096) with that many capital W, starting from the first sample
|
||||
so that barcodes and unbounded values keep their data. A capital W is not the widest glyph of every face, so
|
||||
the picture is indicative, and the help of the form says so. A preview is a render of the values it is given;
|
||||
it proves nothing about other values. The renderer's own worst-case layout at publication stays the proof of
|
||||
fit.
|
||||
- **What reaches the browser is bounded:** the first page only, a picture of at most two mebibytes (larger, the
|
||||
facts and diagnostics are shown without it), and fifty diagnostics with a count of the rest.
|
||||
- **Painting is bounded as well as the page (review of P5).** `ZplRaster` counts the dots every field paints, an
|
||||
overlap each time, and refuses the field that would take the page past sixteen times its dots as
|
||||
`PREVIEW_TOO_LARGE`, the renderer's own limit for a scene; a refused scene whose boxes would pass it has no
|
||||
page and gains that diagnostic. A box is at most four solid bars and a placeholder is a one-dot outline with
|
||||
its two diagonals, so the work a label can cause is what it inks and not its fields times its page. Where
|
||||
this document says "hatched", read "outlined and crossed". A version 1 label that painted its page more than
|
||||
sixteen times over would print and not be previewed.
|
||||
- **The version question is asked again after any `RENDERER_TOO_OLD` (review of P5).** What the bridge remembers
|
||||
about the renderer is what it last saw: after an image rollback one job that only 0.2.0 can do reaches the old
|
||||
renderer and is refused. Any job that ends in that refusal clears what was remembered, so the window is one
|
||||
job.
|
||||
- **The released version 1 job frame is pinned (review of P5).** A golden captured at `v1.1.0-RC.2` is compared
|
||||
with what `render()` sends today, byte for byte except the order of the seven members of `stock`, which the
|
||||
released bridge already wrote in an order that varies from run to run.
|
||||
- **A failure's own words are never shown.** The widget shows the fixed sentences of this module and the closed
|
||||
code of a named render refusal. A message from the host's renderer adapter or context script, which could
|
||||
quote record data, reaches a caller of the service but not the screen.
|
||||
- **The existing action stays** beside the new one, relabelled "Generate retained preview file"; its behaviour
|
||||
and its process name are unchanged.
|
||||
- **Help** is carried by the metadata itself (process, steps, fields, section, widget and the two choice
|
||||
lists), as this repository does for its other screens; it has no help bundle file. A host's coverage check
|
||||
that counts metadata help needs no entries of its own for these.
|
||||
|
||||
### 6.5 Page (PDF) templates
|
||||
|
||||
- Today's retained preview keeps working for PDF and stays the way to obtain the exact file.
|
||||
@@ -797,9 +800,10 @@ the server sends, so this needs no scanner or app release. The client's own word
|
||||
pixel limits, SVG still refused. Assets are addressed by hash; identical bytes are stored once.
|
||||
- **Bundled fonts.** Unmodified, each with licence, provenance and hash, listed in `THIRD-PARTY.md` and `NOTICE`.
|
||||
- **No preview is stored.** No revision, request, event, retained file or log content. The PNG of a real-record
|
||||
preview exists in the response and, when shown through a process step, in that process's server-side state until
|
||||
it expires, as any process screen showing record data does. (That store's retention was not verified.) The record
|
||||
screen widget uses sample data only, so opening a template never renders customer data.
|
||||
preview exists in the answer to the widget request and nowhere else: it is never a process value. (Decided in
|
||||
P6b, section 6.4, once the retention of process state had been read: hours in memory, or a table row with the
|
||||
shared provider.) The record screen widget uses sample data only, so opening a template never renders customer
|
||||
data.
|
||||
- **Samples** are synthetic by rule, stated in the field help and the authoring guide.
|
||||
- **Diagnostics and logs** carry element ids and numbers only.
|
||||
- **Permissions** are the existing five; section 6.3 gives the mapping. Uploading and converting need `configure`.
|
||||
|
||||
@@ -28,25 +28,16 @@ crashes:
|
||||
- **Version 2 scene parser.** The target is the worker's own routing, so a mutated source may also become a
|
||||
version 1 scene, which is then held to the version 1 property. An accepted version 2 scene, read by the
|
||||
second parser, has only the declared top-level properties, a physical design size, at most 1000 elements
|
||||
with unique ids of the fixed pattern, only `box`, `group` and `text` elements with only their declared
|
||||
properties (barcodes and images are refused until their packages exist), groups at most four deep, every
|
||||
length a number and a unit, and overrides only for the three resolutions and for declared ids. A `text`
|
||||
element is read as it stands at each resolution (the defaults, then the element, then the overrides for that
|
||||
resolution): it names a bundled face, has a physical size from 1 to 1000 pt, exactly one of a literal value
|
||||
and a pointer into the record, and only fit, length, threshold, rotation, reverse and line height settings of
|
||||
the documented kinds and ranges. The corpus holds the three text cases (`text-faces`, `text-fit`,
|
||||
`text-turned`) beside the layout cases, and the spliced tokens include the text properties, a combining
|
||||
mark, a tab, half of a surrogate pair, a size of `1000pt` and the number `1e400`. About one version 2 input in
|
||||
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). The one difference allowed is `VALUE_MISSING`: a text bound to
|
||||
the record has no value in the campaign's empty record, and validation reads no record. A refusal is the scene's own `Refusal`, the exception that
|
||||
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; so is
|
||||
the text package's `IllegalStateException` for ink outside a measured line (`TEXT_INK_OUTSIDE_MEASURE`), and
|
||||
any arithmetic or number-format failure. The
|
||||
(`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.
|
||||
|
||||
@@ -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.
|
||||
*******************************************************************************/
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -21,15 +21,76 @@
|
||||
|
||||
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;
|
||||
|
||||
/*******************************************************************************
|
||||
** Layout preview processes for a template: sample, stress and record data.
|
||||
** The producer already calls this registration point; it registers nothing yet.
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -42,5 +103,229 @@ final class GenerationPreviewProcesses
|
||||
*******************************************************************************/
|
||||
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());
|
||||
|
||||
@@ -21,14 +21,122 @@
|
||||
|
||||
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" section of the template record screen. The template
|
||||
** table already offers itself here after its own sections; nothing is added yet.
|
||||
** 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.
|
||||
*******************************************************************************/
|
||||
@@ -37,9 +145,300 @@ final class GenerationPreviewWidget
|
||||
}
|
||||
|
||||
/*******************************************************************************
|
||||
** Called once for the template table, after its standard sections.
|
||||
** 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));
|
||||
}
|
||||
}
|
||||
@@ -173,10 +173,12 @@ 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")
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -34,16 +34,20 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/*******************************************************************************
|
||||
** The registration points for the preview, asset and upgrade packages exist and
|
||||
** are called, and add nothing: a host that registers the QBit gets exactly the
|
||||
** tables, processes, choices, widgets and template sections it got before.
|
||||
** 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, as the released foundation registers it.
|
||||
** The complete default wiring: what the released foundation registered,
|
||||
** plus the layout preview.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theProducerRegistersExactlyWhatItDidBeforeTheHooks() throws Exception
|
||||
void theProducerRegistersWhatItDidBeforeTheHooksAndTheLayoutPreview() throws Exception
|
||||
{
|
||||
QInstance instance = instance();
|
||||
new DocumentGenerationQBitProducer().withQBitConfig(config()).produce(instance);
|
||||
@@ -54,40 +58,50 @@ class GenerationRegistrationHooksTest
|
||||
assertEquals(List.of("document_generation_binding_author_create", "document_generation_queue_document_advance", "document_generation_queue_document_cancel",
|
||||
"document_generation_queue_document_reconcileItem", "document_generation_queue_document_recover", "document_generation_queue_document_status",
|
||||
"document_generation_request_document_download", "document_generation_request_document_reconcile", "document_generation_request_document_reprint",
|
||||
"document_generation_request_document_status", "document_generation_stock_author_create", "document_generation_stock_author_rename",
|
||||
"document_generation_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_type_author_create", "document_generation_type_author_rename", "orders_document_enqueue", "orders_document_generate"),
|
||||
"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_source",
|
||||
"document_generation_preview_source_orders", "document_generation_retained_assets", "document_generation_revision",
|
||||
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(),
|
||||
assertEquals(List.of("document_generation_preview"),
|
||||
List.copyOf(new TreeSet<>(instance.getWidgets().keySet())), "widgets");
|
||||
assertEquals(List.of("overview", "layout", "context", "advanced"),
|
||||
assertEquals(List.of("overview", "labelPreview", "layout", "context", "advanced"),
|
||||
instance.getTable(GenerationTables.TEMPLATE).getSections().stream().map(QFieldSection::getName).toList(), "template sections");
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** Each registration point, called directly, changes nothing.
|
||||
** 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 eachRegistrationPointAddsNothingYet() throws Exception
|
||||
void theAssetAndUpgradePointsAddNothingAndThePreviewPointStandsAlone() throws Exception
|
||||
{
|
||||
DocumentGenerationQBitConfig config = config();
|
||||
assertEquals(List.of(), GenerationAssetTables.tables(config));
|
||||
QInstance instance = instance();
|
||||
GenerationPreviewProcesses.register(instance, config);
|
||||
GenerationAssetProcesses.register(instance, config);
|
||||
GenerationUpgradeProcesses.register(instance, config);
|
||||
assertEquals(List.of(), List.copyOf(instance.getProcesses().keySet()));
|
||||
assertEquals(List.of("orders"), List.copyOf(instance.getTables().keySet()));
|
||||
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(new QTableMetaData().withName("template").getSections(), table.getSections());
|
||||
assertEquals(List.of("labelPreview"), table.getSections().stream().map(QFieldSection::getName).toList());
|
||||
assertEquals("document_generation_preview", table.getSections().getFirst().getWidgetName());
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
|
||||
@@ -26,12 +26,3 @@ The encoder and prime-field ECC helper retain the MIT license. See
|
||||
META-INF/licenses/bwipp-code93i-MIT.txt and Code93i.java. Pinned corresponding
|
||||
upstream source, helper, tests and provenance are retained under
|
||||
src/test/resources/barcodes/code93i.
|
||||
|
||||
Bundled fonts (2026-11-12). This distribution contains, unmodified and as data,
|
||||
IBM Plex Sans Regular and Bold and IBM Plex Sans Condensed Bold, Copyright (c)
|
||||
2017 IBM Corp. with Reserved Font Name "Plex", and JetBrains Mono Regular and
|
||||
Bold, Copyright 2020 The JetBrains Mono Project Authors
|
||||
(https://github.com/JetBrains/JetBrainsMono). They are licensed under the SIL
|
||||
Open Font License, Version 1.1, separately from the renderer's Apache license,
|
||||
which does not replace it. Each font is in /fonts of the renderer JAR beside
|
||||
the full license text and a provenance note; /fonts/SHA256SUMS lists the hashes.
|
||||
@@ -124,12 +124,7 @@ A source with schema `qqq-zpl-scene/2` is a layout in physical units that render
|
||||
- **Coordinates** start at the top left of the printable area, the stock less its margins. In dots the printable area is `floor((width - left - right) x dpi)` wide and `floor((height - top - bottom) x dpi)` high, which is how a box size is rounded, and it starts at `ceil(left x dpi)`, `ceil(top x dpi)`, moved back by one dot where it would otherwise pass the page edge. So a box of exactly the printable width and height fits at 203, 300 and 600 dpi alike. No ink is ever a whole dot inside a margin; the right and bottom edges may pass theirs by a fraction of a dot (less than 0.125 mm at 203 dpi). `design` states the stock the layout was drawn for; the request's stock must match it within 0.5 mm on each side (`STOCK_MISMATCH`).
|
||||
- **Every element** has a unique `id` (`[A-Za-z][A-Za-z0-9_-]{0,31}`), a `type`, an `anchor` (one of nine points, default `top-left`: the same point of the box is placed on that point of its container), `x` and `y` (distances inward from the anchored edge; on a centred axis a shift that may be negative), `width` and `height` (omitted: the rest of the container), and `align` and `valign` (where content sits in the box).
|
||||
- **`box`** has `thickness` and optional `fill`; a line is a box whose width or height is its thickness. **`group`** has `children`, placed relative to its box; groups nest four deep. A scene holds at most 1000 elements, and its elements may paint at most sixteen times its printable area in all, an overlap counted each time: a filled box counts its area, an outline its four bars, a group nothing. The element that would pass that is refused as `SCENE_INVALID` at its pointer, with its box and `measured.paintedPercent` against `limit.paintedPercent` of 1600. A real label paints less than twice its area; the limit holds the worst scene to sixteen fills of the canvas where 1000 were possible.
|
||||
- **`text`** draws one value in a face bundled with the renderer: `qqq-sans`, `qqq-sans-bold` (IBM Plex Sans), `qqq-condensed-bold` (IBM Plex Sans Condensed) and `qqq-mono`, `qqq-mono-bold` (JetBrains Mono). There is no printer font in version 2 and no installed font is used; any other `font` is `FONT_UNKNOWN`, and a request that supplies an asset whose name begins `qqq-` is refused as `RESERVED_FONT_NAME`. `font` and `size` may come from the scene's `defaults`. The value is `value` or, read from the record when rendering, `valuePath` (text as it is, `true` or `false`, or a number in plain decimal digits with no exponent and no fraction that is only zeros: `1.0E10` prints `10000000000`, `2.50` prints `2.5`; anything else is `VALUE_MISSING`); at most 1024 characters, each of which the face must draw as one glyph from left to right (no control or format characters, combining marks, shaping or right-to-left scripts: `FONT_MISSING_GLYPH`, with the count).
|
||||
- **Size and measurement.** `size` is the em as a physical length from `1pt` to `1000pt`, not rounded to dots. A text is measured before it is drawn, by integer arithmetic on the face's own numbers, so the measurement is the same on every machine. A line's box is its pen advance widened to hold the ink of every character in it, plus two dots on each inked side for the rasteriser. A text whose ink the rasteriser puts outside that box is not printed in part and does not fail the render: the one element is refused as `TEXT_DOES_NOT_FIT` with `measured.glyphsOutsideMeasure` and `measured.dotsOutsideMeasure`, when the scene is validated if its value is a literal. No bundled face does so at any size from 1 pt to 1000 pt. The block of lines is placed in the element's box by `align` and `valign`, and each line in the block by `align`. `lineHeight` is the distance between baselines as a multiple of the size (default: the face's own). `rotation` turns the text clockwise in a box given as it appears on the label; `reverse` prints white letters on the box filled black.
|
||||
- **Fit.** In this order: lay the value out at `size` within `fit.maxLines` (default 1); if it does not fit, take the largest size, in steps of 0.25 pt down to `fit.shrinkTo`, at which it does (`TEXT_SHRUNK`); if none does, apply `fit.overflow`. `shrinkTo` defaults to 70% of the size and not below 5 pt. `overflow` is `refuse` (the default: `TEXT_DOES_NOT_FIT`, with the measured width, height, lines and size against the box) or `truncate`, which cuts at a character, ends with an ellipsis and warns `TEXT_TRUNCATED`. At each size a line is broken between words while that fits and at any character when it does not, so a value with no space wraps and a larger size is preferred to whole words (`TEXT_WRAPPED`). A member of `fit` that is wrong is `SCENE_INVALID` at that member (`/elements/3/fit/maxLines`). The size chosen is exactly the one a search of every quarter-point step gives, found without making that search, and all the text of one scene is held to a stated amount of work: 64 million steps, where a character visited while fitting is one step, a character to be drawn is 16 and a glyph the scene draws for the first time at a size is 4096. A scene that passes it is refused as `SCENE_INVALID` at the element that did, with `measured.textWorkSteps` against `limit.textWorkSteps`, the same whether it is validated or rendered and on every machine. That is about a second of a worker; a label of a hundred values takes a few percent of it.
|
||||
- **Proving the fit.** `maxLength` is the longest value in characters. When a scene is validated, that many of the face's widest character must fit whole, each taken to reach as far above, below and beside its advance as the furthest-reaching character of the face; otherwise the scene is refused as `TEXT_DOES_NOT_FIT`. The proof never cuts. An element with `overflow: truncate` whose stated length is not proven is still valid, since cutting is what its author chose, but every validation warns `FIT_NOT_PROVEN` with `measured.provenCharacters`, the longest length that is proven to fit whole, against `limit.characters`: longer values may be cut. A scene that passes cannot be refused at that render target because a value of up to that length does not fit, whichever of the face's characters it holds. The proof is therefore stricter than a typical value needs, and a box that is exactly `lines x lineHeight` high is not proven: leave room for an accent above a capital. When printing, a longer value is `VALUE_TOO_LONG` before any layout. A bound element without `maxLength` warns `FIT_NOT_PROVEN` with no number. Validation reads no record. The proof is for the render target of the request; validate at each resolution the document prints at.
|
||||
- **Legibility.** Glyphs are drawn antialiased and a dot is ink when its grey is darker than `threshold` (1 to 254, default 128), so a higher value prints heavier. `TEXT_SMALL_FOR_DPI` warns when the capitals are under 12 dots high at the render target (about 6 pt at 203 dpi), `TEXT_LIGHT_FACE` when a regular weight is used with capitals under 20 dots (about 10 pt at 203 dpi). A literal is judged at the size it is drawn at; a bound value, when validated, at the size its `maxLength` is proven at, which is the smallest any value is printed at (the floor when the proof fails and the element cuts), and at its stated size when it has no `maxLength`. An override is how a scene answers for one resolution: a bold face or a higher `threshold` at 203 dpi.
|
||||
- **`barcode` and `image`** are parsed and placed by renderer `0.2.0` and then refused as `RENDERER_TOO_OLD`, with the box they would have had, until their packages are in the renderer.
|
||||
- **`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.
|
||||
|
||||
@@ -58,32 +58,3 @@ the Java file preserves the full license and identifies the dated port.
|
||||
Unmodified source, tests, hashes and maintenance policy are retained in
|
||||
`src/test/resources/barcodes/coop2of5`. No interpreter or additional runtime
|
||||
dependency is introduced. See [the capability contract](BARCODES.md#coop-2-of-5).
|
||||
|
||||
## Bundled fonts for version 2 label text (2026-11-12)
|
||||
|
||||
Version 2 label scenes draw text in five TrueType faces shipped inside the renderer JAR under `/fonts`. They are
|
||||
data, not code: unmodified files taken from each project's official release archive, each beside its licence text
|
||||
and a provenance note, with their SHA-256 in `/fonts/SHA256SUMS`. A test (`FaceTest`) fails the build when a
|
||||
licence, a provenance note or a hash is missing or a file's bytes change.
|
||||
|
||||
| Scene name | Family and style | File | Release | SHA-256 | Licence |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `qqq-sans` | IBM Plex Sans Regular | `ibm-plex-sans/IBMPlexSans-Regular.ttf` | [`@ibm/plex-sans@1.1.0`](https://github.com/IBM/plex/releases/download/%40ibm/plex-sans%401.1.0/ibm-plex-sans.zip), font version 3.005 | `975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5` | SIL OFL 1.1 |
|
||||
| `qqq-sans-bold` | IBM Plex Sans Bold | `ibm-plex-sans/IBMPlexSans-Bold.ttf` | the same archive, font version 3.005 | `9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f` | SIL OFL 1.1 |
|
||||
| `qqq-condensed-bold` | IBM Plex Sans Condensed Bold | `ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf` | [`@ibm/plex-sans-condensed@2.0.0`](https://github.com/IBM/plex/releases/download/%40ibm/plex-sans-condensed%402.0.0/ibm-plex-sans-condensed.zip), font version 3.000 | `723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7` | SIL OFL 1.1 |
|
||||
| `qqq-mono` | JetBrains Mono Regular | `jetbrains-mono/JetBrainsMono-Regular.ttf` | [`v2.304`](https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip), font version 2.304 | `a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f` | SIL OFL 1.1 |
|
||||
| `qqq-mono-bold` | JetBrains Mono Bold | `jetbrains-mono/JetBrainsMono-Bold.ttf` | the same archive, font version 2.304 | `5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb` | SIL OFL 1.1 |
|
||||
|
||||
Each licence was confirmed from the release archive itself and not from memory: the archive's own licence file
|
||||
(`LICENSE.txt` for the two IBM Plex archives, `OFL.txt` for JetBrains Mono) states the SIL Open Font License,
|
||||
Version 1.1 and carries its full text, the licence field of every font's name table says the same, and every
|
||||
font's embedding bits are 0 (installable embedding). The licence files are shipped byte for byte. IBM Plex
|
||||
declares the Reserved Font Name "Plex"; the files are neither modified nor renamed, and the scene names
|
||||
(`qqq-sans` and so on) are names of the renderer's faces, not of derived fonts.
|
||||
|
||||
The SIL Open Font License 1.1 is not on the allowlist of `K22-DEP-02`. The maintainer approved one recorded
|
||||
exception for unmodified OFL 1.1 fonts shipped as data on 2026-11-12. The register is kept in the standards
|
||||
repository (`exceptions/register.md`), not here; the entry was proposed with the pull request that added the
|
||||
fonts. The OFL permits bundling and redistribution with software provided the copyright and
|
||||
licence notices accompany the fonts, the fonts are not sold by themselves and a Reserved Font Name is not used
|
||||
for a modified version; it does not apply to documents made with the fonts.
|
||||
@@ -15,7 +15,7 @@
|
||||
"properties": {"width": {"$ref": "#/$defs/physical"}, "height": {"$ref": "#/$defs/physical"}}
|
||||
},
|
||||
"defaults": {
|
||||
"description": "The face and size of a text element that states none.",
|
||||
"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"}}
|
||||
@@ -98,31 +98,9 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/placement",
|
||||
"description": "Text in a face bundled with the renderer, measured before it is drawn and placed in its box by `align` and `valign`. `font` and `size` may come from `defaults`. The renderer is the authority: it also enforces a size from 1pt to 1000pt, a `shrinkTo` from 1pt up to the size, at most three decimals in `lineHeight`, at most 1024 characters, only characters the face draws as one glyph each from left to right (FONT_MISSING_GLYPH), no supplied asset whose name begins `qqq-` (RESERVED_FONT_NAME), and a stated amount of work for all the text of one scene (SCENE_INVALID with `textWorkSteps`).",
|
||||
"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,
|
||||
"oneOf": [{"required": ["value"]}, {"required": ["valuePath"]}],
|
||||
"properties": {
|
||||
"type": {"const": "text"},
|
||||
"value": {"type": "string", "maxLength": 1024, "description": "The text itself."},
|
||||
"valuePath": {"type": "string", "pattern": "^/", "maxLength": 256, "description": "A JSON pointer into the record. Text is printed as it is; true and false as those words; a number in plain decimal digits, with no exponent and no fraction that is only zeros (`1.0E10` prints `10000000000`, `2.50` prints `2.5`, `12.0` prints `12`). Anything else, a number that is not finite among them, is VALUE_MISSING. No record is read when a scene is validated."},
|
||||
"font": {"enum": ["qqq-sans", "qqq-sans-bold", "qqq-condensed-bold", "qqq-mono", "qqq-mono-bold"], "description": "A bundled face: IBM Plex Sans Regular and Bold, IBM Plex Sans Condensed Bold, JetBrains Mono Regular and Bold. Any other name is FONT_UNKNOWN."},
|
||||
"size": {"$ref": "#/$defs/physical", "description": "The em size. Type scales continuously: the size is not rounded to dots."},
|
||||
"lineHeight": {"type": "number", "minimum": 0.5, "maximum": 5, "description": "The distance between baselines as a multiple of the size. Default: the face's own ascent, descent and line gap."},
|
||||
"fit": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "What happens when the value does not fit the box. In order: lay out at `size` within `maxLines`; else the largest size, in steps of 0.25pt down to `shrinkTo`, that fits; else `overflow`. At each size a line is broken between words while that fits and at any character when it does not. A member that is wrong is SCENE_INVALID at that member (`/fit/maxLines`); a member `fit` does not have is SCENE_INVALID at `fit`.",
|
||||
"properties": {
|
||||
"maxLines": {"type": "integer", "minimum": 1, "maximum": 64, "default": 1, "description": "The most lines the value may take."},
|
||||
"shrinkTo": {"$ref": "#/$defs/physical", "description": "The smallest size the text may be reduced to: from 1pt up to `size`, which the renderer enforces. Default: 70% of `size` and not below 5pt, or `size` itself when that is smaller."},
|
||||
"overflow": {"enum": ["refuse", "truncate"], "default": "refuse", "description": "What is done with a value that fits at no size: `refuse` (TEXT_DOES_NOT_FIT), or `truncate` (cut at a character, end with an ellipsis, warn TEXT_TRUNCATED)."}
|
||||
}
|
||||
},
|
||||
"maxLength": {"type": "integer", "minimum": 1, "maximum": 1024, "description": "The longest value, in characters. Validation proves that this many of the face's widest character fit whole, never cut, so a length refusal happens when the scene is published; a longer value is VALUE_TOO_LONG when printing. When the proof fails and `fit.overflow` is `truncate` the scene stands and warns FIT_NOT_PROVEN with the longest length that is proven (`provenCharacters`): longer values may be cut. Without `maxLength` a bound element warns FIT_NOT_PROVEN."},
|
||||
"threshold": {"type": "integer", "minimum": 1, "maximum": 254, "default": 128, "description": "Glyphs are drawn antialiased; a dot is ink when its grey is darker than this. Higher prints heavier."},
|
||||
"reverse": {"type": "boolean", "default": false, "description": "White letters on the element's box filled black."},
|
||||
"rotation": {"enum": [0, 90, 180, 270], "description": "Clockwise. The box is given as it appears on the label; a text turned a quarter is fitted along the box's height."}
|
||||
}
|
||||
"properties": {"type": {"const": "text"}, "value": {}, "valuePath": {}, "font": {}, "size": {}, "lineHeight": {}, "fit": {}, "maxLength": {}, "threshold": {}, "reverse": {}, "rotation": {"enum": [0, 90, 180, 270]}}
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/placement",
|
||||
|
||||
@@ -50,11 +50,10 @@ public interface ElementFactory
|
||||
Map<String, ElementFactory> FACTORIES = Map.of("box", new BoxElement.Factory(), "text", new TextFactory(), "barcode", new BarcodeFactory(), "image", new GraphicsFactory());
|
||||
|
||||
/***************************************************************************
|
||||
** Every property name the version 2 schema declares, the members of a text
|
||||
** element's `fit` among them. A diagnostic's pointer is made only of these,
|
||||
** of array indexes and of declared element ids.
|
||||
** 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", "maxLines", "shrinkTo", "overflow"),
|
||||
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());
|
||||
|
||||
|
||||
|
||||
@@ -205,23 +205,11 @@ public final class ElementSource
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The refusal for a property the element states wrongly, or lacks. A member
|
||||
** of a property that is an object is named with a slash: `fit/maxLines`.
|
||||
** The refusal for a property the element states wrongly, or lacks.
|
||||
***************************************************************************/
|
||||
public Refusal invalid(String name)
|
||||
{
|
||||
return (refuse("SCENE_INVALID", name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The refusal, by its own code, for a property that is well formed but names
|
||||
** something this renderer does not have, such as a font.
|
||||
***************************************************************************/
|
||||
public Refusal refuse(String code, String name)
|
||||
{
|
||||
return (diagnostics.refuse(code, id, pointer(name), null, null, null));
|
||||
return (diagnostics.refuse("SCENE_INVALID", id, pointer(name), null, null, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -242,33 +230,12 @@ public final class ElementSource
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Give the element the scene's `defaults` for the properties its type reads
|
||||
** and it does not state. A default is reported where the scene states it.
|
||||
***************************************************************************/
|
||||
void inherit(JsonNode defaults, Set<String> properties)
|
||||
{
|
||||
for(Map.Entry<String, JsonNode> property : defaults.properties())
|
||||
{
|
||||
if(properties.contains(property.getKey()) && !values.containsKey(property.getKey()))
|
||||
{
|
||||
values.put(property.getKey(), property.getValue());
|
||||
pointers.put(property.getKey(), "/defaults/" + property.getKey());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Where a property is stated: in the override that set it, in the scene's
|
||||
** defaults, else in the element. A member of a property that is an object
|
||||
** (`fit/maxLines`) is in that property, wherever the property is stated.
|
||||
** Where a property is stated: in the override that set it, else in the
|
||||
** element.
|
||||
***************************************************************************/
|
||||
private String pointer(String name)
|
||||
{
|
||||
int member = name.indexOf('/');
|
||||
String property = member < 0 ? name : name.substring(0, member);
|
||||
return (pointers.getOrDefault(property, pointer + "/" + property) + (member < 0 ? "" : name.substring(member)));
|
||||
return (pointers.getOrDefault(name, pointer + "/" + name));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -278,7 +278,6 @@ final class SceneParser
|
||||
{
|
||||
ElementFactory factory = ElementFactory.FACTORIES.get(source.type());
|
||||
source.only(factory.properties());
|
||||
source.inherit(root.path("defaults"), factory.properties());
|
||||
built.add(factory.create(source));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,345 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.renderer.scene2.text;
|
||||
|
||||
|
||||
import java.awt.Font;
|
||||
import java.awt.FontFormatException;
|
||||
import java.awt.font.FontRenderContext;
|
||||
import java.awt.font.GlyphVector;
|
||||
import java.awt.font.LineMetrics;
|
||||
import java.awt.geom.Rectangle2D;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The faces bundled with the renderer, by the name a scene writes. Each is an
|
||||
** unmodified TrueType file under the SIL Open Font License 1.1, shipped in the
|
||||
** jar beside its licence, provenance and hash (`/fonts`). No installed font is
|
||||
** ever used and there is no fallback between faces.
|
||||
**
|
||||
** Every number here is in thousandths of the em, read once from the face at a
|
||||
** reference size of 1000, where the bundled faces (1000 units to the em) give
|
||||
** whole numbers. Layout multiplies those integers by the size and never asks
|
||||
** the font for a measurement at the size it draws, so a measurement is the
|
||||
** same on every machine.
|
||||
**
|
||||
** The four numbers after the widest character are the most any character of
|
||||
** the face reaches past its own advance on the left and on the right, above
|
||||
** the baseline and below it. They bound every character, so a value that was
|
||||
** never seen can be proven to fit; a test recomputes them from the font file.
|
||||
*******************************************************************************/
|
||||
public final class Face
|
||||
{
|
||||
public static final Face SANS = new Face("qqq-sans", "ibm-plex-sans/IBMPlexSans-Regular.ttf", false, 0x2030, 203, 203, 1119, 245);
|
||||
public static final Face SANS_BOLD = new Face("qqq-sans-bold", "ibm-plex-sans/IBMPlexSans-Bold.ttf", true, 0x2030, 245, 245, 1150, 275);
|
||||
public static final Face CONDENSED_BOLD = new Face("qqq-condensed-bold", "ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf", true, 0x2030, 241, 241, 1150, 275);
|
||||
public static final Face MONO = new Face("qqq-mono", "jetbrains-mono/JetBrainsMono-Regular.ttf", false, 0x21, 165, 165, 1120, 400);
|
||||
public static final Face MONO_BOLD = new Face("qqq-mono-bold", "jetbrains-mono/JetBrainsMono-Bold.ttf", true, 0x21, 165, 165, 1120, 400);
|
||||
|
||||
static final FontRenderContext CONTEXT = new FontRenderContext(null, true, true);
|
||||
static final int REFERENCE = 1000;
|
||||
|
||||
private static final List<Face> ALL = List.of(SANS, SANS_BOLD, CONDENSED_BOLD, MONO, MONO_BOLD);
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// characters that cannot be drawn one glyph each from left to right: controls and formats //
|
||||
// (which would print nothing), marks (which need placing on the letter before) and the like //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final Set<Integer> UNDRAWN = Set.of((int) Character.CONTROL, (int) Character.FORMAT, (int) Character.SURROGATE, (int) Character.PRIVATE_USE, (int) Character.UNASSIGNED,
|
||||
(int) Character.LINE_SEPARATOR, (int) Character.PARAGRAPH_SEPARATOR, (int) Character.NON_SPACING_MARK, (int) Character.ENCLOSING_MARK, (int) Character.COMBINING_SPACING_MARK);
|
||||
|
||||
private final String scene;
|
||||
private final String file;
|
||||
private final boolean bold;
|
||||
private final int widest;
|
||||
private final int left;
|
||||
private final int right;
|
||||
private final int top;
|
||||
private final int bottom;
|
||||
private final Map<Integer, Glyph> glyphs = new ConcurrentHashMap<>();
|
||||
private Font font;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One character of a face: how far the pen moves, and the box its outline
|
||||
** fills, from the pen (`left`, `right`) and from the baseline (`top` above,
|
||||
** `bottom` below), rounded outward. A space has no ink; only U+0020 is a
|
||||
** `space`, the one place a line may be broken between words.
|
||||
***************************************************************************/
|
||||
public record Glyph(int advance, int left, int right, int top, int bottom, boolean ink, boolean space)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A face, where its file is, and the bounds a test holds it to.
|
||||
***************************************************************************/
|
||||
private Face(String scene, String file, boolean bold, int widest, int left, int right, int top, int bottom)
|
||||
{
|
||||
this.scene = scene;
|
||||
this.file = file;
|
||||
this.bold = bold;
|
||||
this.widest = widest;
|
||||
this.left = left;
|
||||
this.right = right;
|
||||
this.top = top;
|
||||
this.bottom = bottom;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The bundled set: a proportional sans in regular and bold, a condensed bold
|
||||
** for long values on narrow labels, and a monospace in regular and bold.
|
||||
***************************************************************************/
|
||||
public static List<Face> all()
|
||||
{
|
||||
return (ALL);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The face a scene names, or null.
|
||||
***************************************************************************/
|
||||
public static Face of(String scene)
|
||||
{
|
||||
for(Face face : ALL)
|
||||
{
|
||||
if(face.scene.equals(scene))
|
||||
{
|
||||
return (face);
|
||||
}
|
||||
}
|
||||
return (null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The name a scene writes.
|
||||
***************************************************************************/
|
||||
public String scene()
|
||||
{
|
||||
return (scene);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The font file, under `/fonts` in the jar.
|
||||
***************************************************************************/
|
||||
public String file()
|
||||
{
|
||||
return (file);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the strokes are bold. A regular face prints light when small.
|
||||
***************************************************************************/
|
||||
public boolean bold()
|
||||
{
|
||||
return (bold);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The character with the greatest advance, the lowest when several share it.
|
||||
***************************************************************************/
|
||||
public int widest()
|
||||
{
|
||||
return (widest);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A character no real one exceeds in any direction: the greatest advance and
|
||||
** the greatest reach on each side. A line of these is at least as wide and
|
||||
** as tall as a line of the same number of any characters of the face.
|
||||
***************************************************************************/
|
||||
public Glyph worst()
|
||||
{
|
||||
int advance = glyph(widest).advance();
|
||||
return (new Glyph(advance, -left, advance + right, top, bottom, true, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The face at a size in dots, for drawing only.
|
||||
***************************************************************************/
|
||||
Font font(float size)
|
||||
{
|
||||
return (reference().deriveFont(size));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Height above the baseline that a line reserves.
|
||||
***************************************************************************/
|
||||
public int ascent()
|
||||
{
|
||||
return (Math.round(metrics().getAscent()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Depth below the baseline that a line reserves.
|
||||
***************************************************************************/
|
||||
public int descent()
|
||||
{
|
||||
return (Math.round(metrics().getDescent()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Extra space the face asks for between lines.
|
||||
***************************************************************************/
|
||||
public int leading()
|
||||
{
|
||||
return (Math.round(metrics().getLeading()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The height of a capital letter, which is what legibility is judged by.
|
||||
***************************************************************************/
|
||||
public int capHeight()
|
||||
{
|
||||
return (glyph('H').top());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether the face can draw a character as one glyph, left to right.
|
||||
***************************************************************************/
|
||||
public boolean draws(int codePoint)
|
||||
{
|
||||
return (codePoint <= Character.MAX_VALUE && !UNDRAWN.contains(Character.getType(codePoint)) && reference().canDisplay(codePoint)
|
||||
&& !Font.textRequiresLayout(new char[] {(char) codePoint}, 0, 1));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many characters of a text the face cannot draw.
|
||||
***************************************************************************/
|
||||
public int missing(String text)
|
||||
{
|
||||
return (Math.toIntExact(text.codePoints().filter(codePoint -> !draws(codePoint)).count()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The measurements of one character the face draws.
|
||||
***************************************************************************/
|
||||
public Glyph glyph(int codePoint)
|
||||
{
|
||||
return (glyphs.computeIfAbsent(codePoint, this::measure));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The measurements of a text the face draws, one for each character.
|
||||
***************************************************************************/
|
||||
public Glyph[] glyphs(String text)
|
||||
{
|
||||
return (text.codePoints().mapToObj(this::glyph).toArray(Glyph[]::new));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read one character from the face at the reference size.
|
||||
***************************************************************************/
|
||||
private Glyph measure(int codePoint)
|
||||
{
|
||||
GlyphVector vector = reference().createGlyphVector(CONTEXT, new char[] {(char) codePoint});
|
||||
int advance = Math.round(vector.getGlyphMetrics(0).getAdvanceX());
|
||||
Rectangle2D bounds = vector.getGlyphOutline(0).getBounds2D();
|
||||
if(bounds.isEmpty())
|
||||
{
|
||||
return (new Glyph(advance, 0, 0, 0, 0, false, codePoint == ' '));
|
||||
}
|
||||
return (new Glyph(advance, (int) Math.floor(bounds.getMinX()), (int) Math.ceil(bounds.getMaxX()), (int) Math.ceil(-bounds.getMinY()), (int) Math.ceil(bounds.getMaxY()), true, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The line metrics of the face at the reference size.
|
||||
***************************************************************************/
|
||||
private LineMetrics metrics()
|
||||
{
|
||||
return (reference().getLineMetrics("H", CONTEXT));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The face at the reference size, read from the jar the first time.
|
||||
***************************************************************************/
|
||||
private synchronized Font reference()
|
||||
{
|
||||
if(font == null)
|
||||
{
|
||||
font = load("/fonts/" + file);
|
||||
}
|
||||
return (font);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read a TrueType file from the jar. A file that is not a font is a broken
|
||||
** build, not a mistake in a scene, so it is an illegal state.
|
||||
***************************************************************************/
|
||||
static Font load(String resource)
|
||||
{
|
||||
try(InputStream stream = Face.class.getResourceAsStream(resource))
|
||||
{
|
||||
return (Font.createFont(Font.TRUETYPE_FONT, stream).deriveFont((float) REFERENCE));
|
||||
}
|
||||
catch(IOException | FontFormatException broken)
|
||||
{
|
||||
throw new IllegalStateException("BUNDLED_FONT_UNREADABLE", broken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,565 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.renderer.scene2.text;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.WeakHashMap;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
|
||||
import com.kingsrook.qbits.renderer.RenderInput;
|
||||
import com.kingsrook.qbits.renderer.scene2.Canvas;
|
||||
import com.kingsrook.qbits.renderer.scene2.Diagnostics;
|
||||
import com.kingsrook.qbits.renderer.scene2.ElementSource;
|
||||
import com.kingsrook.qbits.renderer.scene2.Frame;
|
||||
import com.kingsrook.qbits.renderer.scene2.Layout;
|
||||
import com.kingsrook.qbits.renderer.scene2.Length;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement;
|
||||
import com.kingsrook.qbits.renderer.scene2.SceneElement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** A `text` element: one value in one bundled face, measured before it is
|
||||
** drawn, placed in its box by `align` and `valign`, and fitted by the rule the
|
||||
** element states (`TextFit`). The value is a literal (`value`) or is read from
|
||||
** the record by a JSON pointer (`valuePath`).
|
||||
**
|
||||
** Every refusal names the element and carries numbers only: a width, a line
|
||||
** count, a size, a count of characters. The value itself never enters a
|
||||
** diagnostic or an exception.
|
||||
**
|
||||
** A bound value is not known when a scene is validated, so validation reads no
|
||||
** record. With `maxLength` it proves instead that the longest value fits whole:
|
||||
** that many of the face's widest character, each taken to reach as far in
|
||||
** every direction as the furthest-reaching character of the face. The proof
|
||||
** never cuts. When it fails the scene is refused, unless the element asked for
|
||||
** cutting: then the scene stands, with the warning `FIT_NOT_PROVEN` and the
|
||||
** longest length that is proven. Without `maxLength` it warns `FIT_NOT_PROVEN`
|
||||
** with no number. When rendering, a value longer than `maxLength` is refused
|
||||
** before any layout.
|
||||
**
|
||||
** The text of one scene is held to a stated amount of work (`SCENE_WORK_STEPS`):
|
||||
** every character visited while fitting counts one step, every character of
|
||||
** a text that is to be drawn counts `TextRaster.PEN_STEPS`, and every glyph
|
||||
** the scene draws for the first time at a size, which the runtime must
|
||||
** rasterise, counts `TextRaster.GLYPH_STEPS`. A scene that passes it is
|
||||
** refused by name at the element that did, and so is every text after it.
|
||||
** The count is integer arithmetic, the same on every machine, and the same
|
||||
** whether the scene is validated or rendered.
|
||||
*******************************************************************************/
|
||||
final class TextElement implements SceneElement
|
||||
{
|
||||
static final int MAXIMUM_CHARACTERS = 1024;
|
||||
static final int MAXIMUM_LINES = 64;
|
||||
static final int MINIMUM_MILLIPOINTS = 1000;
|
||||
static final int MAXIMUM_MILLIPOINTS = 1_000_000;
|
||||
static final int DEFAULT_FLOOR_PERCENT = 70;
|
||||
static final int DEFAULT_FLOOR_MILLIPOINTS = 5000;
|
||||
static final int DEFAULT_THRESHOLD = 128;
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// capital heights in dots below which thermal print is hard to read: any face under 12 (about //
|
||||
// 6 pt at 203 dpi), a regular weight under 20 (about 10 pt at 203 dpi). Starting values, to //
|
||||
// be confirmed on the printed specimen //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final int SMALL_CAPITAL_DOTS = 12;
|
||||
static final int LIGHT_CAPITAL_DOTS = 20;
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the work the text of one scene may take, in steps. A value of forty characters costs //
|
||||
// about a hundred thousand at a size its scene has not drawn, nearly all of it for the //
|
||||
// glyphs the runtime must rasterise, and under two thousand at a size it has. The //
|
||||
// allowance is fifteen thousand glyphs rasterised, or three thousand fits of the longest //
|
||||
// value over the widest range of sizes: about a second of the worker, where one hostile //
|
||||
// scene took minutes //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final int SCENE_WORK_STEPS = 64_000_000;
|
||||
|
||||
private static final Set<String> FIT = Set.of("maxLines", "shrinkTo", "overflow");
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the work of each scene, by the collector of its one render or validation, which is all a //
|
||||
// text element is given that belongs to the scene; it is forgotten with the collector //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
private static final Map<Diagnostics, Scene> SCENES = Collections.synchronizedMap(new WeakHashMap<>());
|
||||
|
||||
private final String id;
|
||||
private final Placement placement;
|
||||
private final int rotation;
|
||||
private final boolean reverse;
|
||||
private final Face face;
|
||||
private final int size;
|
||||
private final int floor;
|
||||
private final Integer lineHeight;
|
||||
private final int maxLines;
|
||||
private final boolean truncate;
|
||||
private final Integer maxLength;
|
||||
private final int threshold;
|
||||
private final String value;
|
||||
private final String path;
|
||||
private Composed composed;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the text of one scene has cost so far: the steps counted, and the
|
||||
** glyphs it has drawn, each a face, a size and a character in one number.
|
||||
** There are never more of those than the steps allow.
|
||||
***************************************************************************/
|
||||
private record Scene(TextFit.Work work, Set<Long> glyphs)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What `check` composed, kept for the `paint` of the same pass, so a text
|
||||
** is fitted once.
|
||||
***************************************************************************/
|
||||
private record Composed(Frame frame, RenderInput input, Diagnostics diagnostics, TextRaster.Drawing drawing)
|
||||
{
|
||||
/************************************************************************
|
||||
** Whether this is the answer for the same box of the same pass.
|
||||
************************************************************************/
|
||||
boolean of(Frame other, RenderInput request, Diagnostics collector)
|
||||
{
|
||||
return (frame.equals(other) && input == request && diagnostics == collector);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Read the element. A property that is not what the schema says is
|
||||
** `SCENE_INVALID` at that property, a member of `fit` at that member; a
|
||||
** member `fit` does not have is reported at `fit`, since only names the
|
||||
** schema declares leave the renderer. A face this renderer does not bundle
|
||||
** is `FONT_UNKNOWN`.
|
||||
***************************************************************************/
|
||||
TextElement(ElementSource source)
|
||||
{
|
||||
id = source.id();
|
||||
placement = source.placement();
|
||||
rotation = source.rotation();
|
||||
reverse = source.flag("reverse", false);
|
||||
JsonNode font = source.value("font");
|
||||
if(font == null || !font.isTextual())
|
||||
{
|
||||
throw source.invalid("font");
|
||||
}
|
||||
face = Face.of(font.textValue());
|
||||
if(face == null)
|
||||
{
|
||||
throw source.refuse("FONT_UNKNOWN", "font");
|
||||
}
|
||||
Length stated = source.length("size", null, ElementSource.PHYSICAL);
|
||||
size = stated == null ? 0 : milliPoints(stated);
|
||||
if(size < MINIMUM_MILLIPOINTS || size > MAXIMUM_MILLIPOINTS)
|
||||
{
|
||||
throw source.invalid("size");
|
||||
}
|
||||
lineHeight = lineHeight(source);
|
||||
threshold = integer(source, "threshold", source.value("threshold"), 1, 254, DEFAULT_THRESHOLD);
|
||||
maxLength = source.value("maxLength") == null ? null : Integer.valueOf(integer(source, "maxLength", source.value("maxLength"), 1, MAXIMUM_CHARACTERS, 0));
|
||||
JsonNode fit = source.value("fit") == null ? JsonNodeFactory.instance.objectNode() : source.value("fit");
|
||||
if(!fit.isObject() || fit.properties().stream().anyMatch(property -> !FIT.contains(property.getKey())))
|
||||
{
|
||||
throw source.invalid("fit");
|
||||
}
|
||||
maxLines = integer(source, "fit/maxLines", fit.get("maxLines"), 1, MAXIMUM_LINES, 1);
|
||||
floor = floor(source, fit.get("shrinkTo"));
|
||||
String overflow = fit.has("overflow") ? fit.get("overflow").textValue() : "refuse";
|
||||
if(!"refuse".equals(overflow) && !"truncate".equals(overflow))
|
||||
{
|
||||
throw source.invalid("fit/overflow");
|
||||
}
|
||||
truncate = "truncate".equals(overflow);
|
||||
JsonNode literal = source.value("value");
|
||||
JsonNode pointer = source.value("valuePath");
|
||||
if((literal == null) == (pointer == null))
|
||||
{
|
||||
throw source.invalid("value");
|
||||
}
|
||||
if(literal != null && (!literal.isTextual() || literal.textValue().codePointCount(0, literal.textValue().length()) > MAXIMUM_CHARACTERS))
|
||||
{
|
||||
throw source.invalid("value");
|
||||
}
|
||||
if(pointer != null && (!pointer.isTextual() || pointer.textValue().length() > 256 || !pointer.textValue().startsWith("/")))
|
||||
{
|
||||
throw source.invalid("valuePath");
|
||||
}
|
||||
value = literal == null ? null : literal.textValue();
|
||||
path = pointer == null ? null : pointer.textValue();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A type size in thousandths of a point. A point is a 72nd of an inch and an
|
||||
** inch is 25400 micrometres, so a micrometre is 360/127 of a millipoint.
|
||||
***************************************************************************/
|
||||
private static int milliPoints(Length length)
|
||||
{
|
||||
long thousandths = length.thousandths();
|
||||
return (Math.toIntExact(switch(length.unit())
|
||||
{
|
||||
case IN -> thousandths * 72;
|
||||
case MM -> Math.floorDiv(thousandths * 360 + 63, 127);
|
||||
default -> thousandths;
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A whole number in a range, or the fallback when the scene states none.
|
||||
***************************************************************************/
|
||||
private static int integer(ElementSource source, String name, JsonNode stated, int minimum, int maximum, int fallback)
|
||||
{
|
||||
if(stated == null)
|
||||
{
|
||||
return (fallback);
|
||||
}
|
||||
if(!stated.isIntegralNumber() || !stated.canConvertToInt() || stated.intValue() < minimum || stated.intValue() > maximum)
|
||||
{
|
||||
throw source.invalid(name);
|
||||
}
|
||||
return (stated.intValue());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The distance between baselines as a multiple of the size, in thousandths:
|
||||
** a number from 0.5 to 5 with at most three decimals. Null is the face's own.
|
||||
** A number too large to be a number at all (`1e400`) is refused like any
|
||||
** other, not left to fail as arithmetic.
|
||||
***************************************************************************/
|
||||
private static Integer lineHeight(ElementSource source)
|
||||
{
|
||||
JsonNode stated = source.value("lineHeight");
|
||||
if(stated == null)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
BigDecimal thousandths = stated.isNumber() && Double.isFinite(stated.doubleValue()) ? stated.decimalValue().movePointRight(3) : BigDecimal.ZERO;
|
||||
if(thousandths.stripTrailingZeros().scale() > 0 || thousandths.compareTo(BigDecimal.valueOf(500)) < 0 || thousandths.compareTo(BigDecimal.valueOf(5000)) > 0)
|
||||
{
|
||||
throw source.invalid("lineHeight");
|
||||
}
|
||||
return (thousandths.intValueExact());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The smallest size the text may be reduced to. Stated, it is a physical
|
||||
** length from one point up to the size. Otherwise it is 70% of the size and
|
||||
** not below 5 pt, or the size itself when that is smaller still.
|
||||
***************************************************************************/
|
||||
private int floor(ElementSource source, JsonNode stated)
|
||||
{
|
||||
if(stated == null)
|
||||
{
|
||||
return (Math.min(size, Math.max(size * DEFAULT_FLOOR_PERCENT / 100, DEFAULT_FLOOR_MILLIPOINTS)));
|
||||
}
|
||||
try
|
||||
{
|
||||
Length length = Length.parse(stated.textValue());
|
||||
if(length.physical() && milliPoints(length) >= MINIMUM_MILLIPOINTS && milliPoints(length) <= size)
|
||||
{
|
||||
return (milliPoints(length));
|
||||
}
|
||||
}
|
||||
catch(IllegalArgumentException expected)
|
||||
{
|
||||
// Refused below, at the property; the cause holds nothing more.
|
||||
}
|
||||
throw source.invalid("fit/shrinkTo");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The id the scene declares for the text.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public String id()
|
||||
{
|
||||
return (id);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The element's box, by the standing rules.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Frame place(Layout layout)
|
||||
{
|
||||
return (layout.place(placement));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Everything that can refuse the text, with no ink: run when a scene is
|
||||
** validated and before it is painted. What it composed is kept for the
|
||||
** paint that follows.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void check(Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
composed = null;
|
||||
composed = new Composed(frame, input, diagnostics, compose(frame, input, diagnostics));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Reverse text paints its box and then clears its letters, so it is counted
|
||||
** twice against what one scene may paint.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public long paintedDots(Frame frame, int dpi)
|
||||
{
|
||||
return ((reverse ? 2L : 1L) * frame.width() * frame.height());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Draw the text as `check` fitted it, or not at all: a text whose ink the
|
||||
** rasteriser put outside what was measured is refused and leaves the label
|
||||
** as it was. Reverse text is white letters on its own box filled black.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void paint(Canvas canvas, Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
Composed checked = composed;
|
||||
composed = null;
|
||||
TextRaster.Drawing drawing = checked != null && checked.of(frame, input, diagnostics) ? checked.drawing() : compose(frame, input, diagnostics);
|
||||
measured(diagnostics, id, frame, TextRaster.paint(canvas, frame, drawing, TextRaster.BAND_DOTS), drawing.block().milliPoints());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Refuse a text whose ink is not where it was measured: the element, by
|
||||
** name, with how many glyphs and dots strayed and at what size. The label
|
||||
** holds none of it.
|
||||
***************************************************************************/
|
||||
static void measured(Diagnostics diagnostics, String id, Frame frame, TextRaster.Stray stray, int milliPoints)
|
||||
{
|
||||
if(stray.any())
|
||||
{
|
||||
throw diagnostics.refuse("TEXT_DOES_NOT_FIT", id, null, frame, Map.of("glyphsOutsideMeasure", stray.glyphs(), "dotsOutsideMeasure", stray.dots(), "sizeCentipoints", milliPoints / 10),
|
||||
Map.of("glyphsOutsideMeasure", 0, "dotsOutsideMeasure", 0));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Compose the text within the work its scene may take, or refuse the
|
||||
** element by name with the steps counted against the steps allowed.
|
||||
***************************************************************************/
|
||||
private TextRaster.Drawing compose(Frame frame, RenderInput input, Diagnostics diagnostics)
|
||||
{
|
||||
Scene scene = SCENES.computeIfAbsent(diagnostics, collector -> new Scene(new TextFit.Work(SCENE_WORK_STEPS), new HashSet<>()));
|
||||
try
|
||||
{
|
||||
return (compose(frame, input, diagnostics, scene));
|
||||
}
|
||||
catch(TextFit.Exhausted exhausted)
|
||||
{
|
||||
throw diagnostics.refuse("SCENE_INVALID", id, diagnostics.pointer(id), frame, Map.of("textWorkSteps", Math.toIntExact(Math.min(scene.work().spent(), Integer.MAX_VALUE))), Map.of("textWorkSteps", SCENE_WORK_STEPS));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the value, hold it to its length and to the face, and fit it. The
|
||||
** answer is what to draw, or null when a bound value is only being
|
||||
** validated. A text that is only validated is also held to what the
|
||||
** rasteriser would do with it, glyph by glyph, with no ink.
|
||||
***************************************************************************/
|
||||
private TextRaster.Drawing compose(Frame frame, RenderInput input, Diagnostics diagnostics, Scene scene)
|
||||
{
|
||||
TextFit.Work work = scene.work();
|
||||
int reserved = input.reservedAssetNames().size();
|
||||
if(reserved > 0)
|
||||
{
|
||||
throw diagnostics.refuse("RESERVED_FONT_NAME", id, null, frame, Map.of("reservedAssets", reserved), Map.of("reservedAssets", 0));
|
||||
}
|
||||
int dpi = input.targetDpi == null ? input.stock.dpi() : input.targetDpi;
|
||||
boolean upright = rotation % 180 == 0;
|
||||
boolean validate = "VALIDATE".equals(input.operation);
|
||||
TextFit.Request request = new TextFit.Request(face, upright ? frame.width() : frame.height(), upright ? frame.height() : frame.width(), dpi, size, floor, lineHeight, maxLines, truncate);
|
||||
if(path != null && maxLength == null)
|
||||
{
|
||||
diagnostics.warn("FIT_NOT_PROVEN", id, null, frame, null, null);
|
||||
}
|
||||
if(path != null && validate)
|
||||
{
|
||||
legibility(maxLength == null ? size : proven(request, frame, diagnostics, work), dpi, frame, diagnostics);
|
||||
return (null);
|
||||
}
|
||||
String text = path == null ? value : bound(input.context.at(path), frame, diagnostics);
|
||||
int characters = text.codePointCount(0, text.length());
|
||||
int longest = maxLength == null ? MAXIMUM_CHARACTERS : maxLength;
|
||||
if(characters > longest)
|
||||
{
|
||||
throw diagnostics.refuse("VALUE_TOO_LONG", id, null, frame, Map.of("characters", characters), Map.of("characters", longest));
|
||||
}
|
||||
int missing = face.missing(text);
|
||||
if(missing > 0)
|
||||
{
|
||||
throw diagnostics.refuse("FONT_MISSING_GLYPH", id, null, frame, Map.of("missingGlyphs", missing, "characters", characters), Map.of("missingGlyphs", 0));
|
||||
}
|
||||
TextFit.Block block = fitted(request, face.glyphs(text), frame, diagnostics, work);
|
||||
long drawn = (long) Face.all().indexOf(face) << 48 | (long) block.milliPoints() << 16;
|
||||
long fresh = text.codePoints().filter(codePoint -> scene.glyphs().add(drawn | codePoint)).count();
|
||||
work.spend(fresh * TextRaster.GLYPH_STEPS + (long) characters * TextRaster.PEN_STEPS);
|
||||
if(block.milliPoints() < size)
|
||||
{
|
||||
diagnostics.inform("TEXT_SHRUNK", id, null, frame, Map.of("sizeCentipoints", block.milliPoints() / 10), Map.of("sizeCentipoints", size / 10, "floorCentipoints", floor / 10));
|
||||
}
|
||||
if(block.lines().size() > 1)
|
||||
{
|
||||
diagnostics.inform("TEXT_WRAPPED", id, null, frame, Map.of("lines", block.lines().size()), Map.of("lines", maxLines));
|
||||
}
|
||||
if(block.truncated())
|
||||
{
|
||||
diagnostics.warn("TEXT_TRUNCATED", id, null, frame, Map.of("characters", block.characters(), "shownCharacters", block.shown(), "lines", block.lines().size()), Map.of("lines", maxLines));
|
||||
}
|
||||
legibility(block.milliPoints(), dpi, frame, diagnostics);
|
||||
TextRaster.Drawing drawing = new TextRaster.Drawing(face, text, block, placement.align(), placement.valign(), rotation, threshold, !reverse);
|
||||
measured(diagnostics, id, frame, new TextRaster.Stray(validate ? TextRaster.outside(drawing, dpi) : 0, 0), block.milliPoints());
|
||||
return (drawing);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The value the record holds at the element's pointer, as it is printed.
|
||||
** Text is printed as it is and a truth value as `true` or `false`. A number
|
||||
** is printed in plain decimal digits, as the schema says: no exponent, and
|
||||
** no fraction that is only zeros, so `1.0E10` prints `10000000000`, `2.50`
|
||||
** prints `2.5` and `12.0` prints `12`. Anything else, a number that is not
|
||||
** finite among them, is `VALUE_MISSING`.
|
||||
***************************************************************************/
|
||||
private String bound(JsonNode bound, Frame frame, Diagnostics diagnostics)
|
||||
{
|
||||
if(bound.isTextual() || bound.isBoolean())
|
||||
{
|
||||
return (bound.asText());
|
||||
}
|
||||
if(bound.isIntegralNumber())
|
||||
{
|
||||
return (bound.bigIntegerValue().toString());
|
||||
}
|
||||
if(bound.isNumber() && Double.isFinite(bound.doubleValue()))
|
||||
{
|
||||
return (BigDecimal.valueOf(bound.doubleValue()).stripTrailingZeros().toPlainString());
|
||||
}
|
||||
throw diagnostics.refuse("VALUE_MISSING", id, null, frame, null, null);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Prove that the longest value fits whole, and answer the size it is
|
||||
** proven at, which is the smallest any value is printed at. The proof never
|
||||
** cuts: a cut value is not a value that fits. When it fails, an element
|
||||
** that did not ask for cutting is refused; one that did stands, since
|
||||
** cutting is what its author chose, with a warning that says how many
|
||||
** characters are proven to fit whole. Fewer characters never fit worse, so
|
||||
** that number is found by halving. The size is then the floor, where a cut
|
||||
** value is printed.
|
||||
***************************************************************************/
|
||||
private int proven(TextFit.Request request, Frame frame, Diagnostics diagnostics, TextFit.Work work)
|
||||
{
|
||||
TextFit.Request whole = new TextFit.Request(face, request.width(), request.height(), request.dpi(), size, floor, lineHeight, maxLines, false);
|
||||
Face.Glyph[] worst = new Face.Glyph[maxLength];
|
||||
Arrays.fill(worst, face.worst());
|
||||
TextFit.Block proof = truncate ? TextFit.fit(whole, worst, null, work) : fitted(whole, worst, frame, diagnostics, work);
|
||||
if(!proof.fits())
|
||||
{
|
||||
int low = 0;
|
||||
int high = maxLength - 1;
|
||||
while(low < high)
|
||||
{
|
||||
int middle = (low + high + 1) >>> 1;
|
||||
if(TextFit.fit(whole, Arrays.copyOf(worst, middle), null, work).fits())
|
||||
{
|
||||
low = middle;
|
||||
}
|
||||
else
|
||||
{
|
||||
high = middle - 1;
|
||||
}
|
||||
}
|
||||
diagnostics.warn("FIT_NOT_PROVEN", id, null, frame, Map.of("provenCharacters", low), Map.of("characters", maxLength));
|
||||
}
|
||||
return (proof.milliPoints());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit, or refuse with what was measured at the floor size against the box.
|
||||
***************************************************************************/
|
||||
private TextFit.Block fitted(TextFit.Request request, Face.Glyph[] glyphs, Frame frame, Diagnostics diagnostics, TextFit.Work work)
|
||||
{
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, face.glyph(TextFit.MARK), work);
|
||||
if(!block.fits())
|
||||
{
|
||||
throw diagnostics.refuse("TEXT_DOES_NOT_FIT", id, null, frame,
|
||||
Map.of("widthDots", block.width(), "heightDots", block.height(), "lines", block.lines().size(), "sizeCentipoints", block.milliPoints() / 10, "characters", block.characters()),
|
||||
Map.of("widthDots", request.width(), "heightDots", request.height(), "lines", maxLines));
|
||||
}
|
||||
return (block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Warn when the type is small for the printer: any face whose capitals are
|
||||
** under 12 dots, and a regular weight whose capitals are under 20.
|
||||
***************************************************************************/
|
||||
private void legibility(int milliPoints, int dpi, Frame frame, Diagnostics diagnostics)
|
||||
{
|
||||
int capitals = new TextFit.Scale(milliPoints, dpi).floor(face.capHeight());
|
||||
if(capitals < SMALL_CAPITAL_DOTS)
|
||||
{
|
||||
diagnostics.warn("TEXT_SMALL_FOR_DPI", id, null, frame, Map.of("capHeightDots", capitals, "sizeCentipoints", milliPoints / 10), Map.of("capHeightDots", SMALL_CAPITAL_DOTS));
|
||||
}
|
||||
if(!face.bold() && capitals < LIGHT_CAPITAL_DOTS)
|
||||
{
|
||||
diagnostics.warn("TEXT_LIGHT_FACE", id, null, frame, Map.of("capHeightDots", capitals, "sizeCentipoints", milliPoints / 10), Map.of("capHeightDots", LIGHT_CAPITAL_DOTS));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,18 +25,22 @@ 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 bundled with the renderer, rasterised by it, with
|
||||
** size, fit and legibility rules. Version 2 has no printer font: a face the
|
||||
** renderer cannot measure can be neither fitted nor previewed truthfully.
|
||||
** 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 properties of a `text` element beyond the common ones. `font` and
|
||||
** `size` may come from the scene's `defaults`.
|
||||
** The property names the design gives a `text` element. Their values are
|
||||
** not read yet.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public Set<String> properties()
|
||||
@@ -47,11 +51,11 @@ public final class TextFactory implements ElementFactory
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Build one text element.
|
||||
** The element, placed and then refused by name.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public SceneElement create(ElementSource source)
|
||||
{
|
||||
return (new TextElement(source));
|
||||
return (new UnavailableElement(source));
|
||||
}
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.renderer.scene2.text;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qbits.renderer.scene2.text.Face.Glyph;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Measurement and fit of one text in one box, in whole dots, by integer
|
||||
** arithmetic on the face's own numbers. Nothing is drawn here, and what is
|
||||
** drawn later is drawn from this answer: the pen position of every character
|
||||
** and the box of every line are decided once.
|
||||
**
|
||||
** The order is fixed. Lay the text out at the nominal size within the allowed
|
||||
** lines. If it does not fit, take the largest size, in steps of a quarter of
|
||||
** a point down to the floor, at which it does. If none does, the text is
|
||||
** refused, or, only when the element asked for it, cut at a character and
|
||||
** ended with an ellipsis at the floor size.
|
||||
**
|
||||
** The answer is the one a search of every step from the nominal size down
|
||||
** gives, and is found without making it. A count that errs only toward yes
|
||||
** (`possible`) says whether a size could hold the text; it never says no to a
|
||||
** size that fits and never says yes to a larger size after no to a smaller,
|
||||
** so the largest size it allows is found by halving, and no size above that
|
||||
** one fits. From there the steps are laid out one by one as before; the count
|
||||
** is within a few dots of the layout, so they are few. When one line at the
|
||||
** floor is higher than the box, no size fits and none is searched.
|
||||
**
|
||||
** Every character visited is counted against the `Work` the caller gives, so
|
||||
** a caller can hold all the fitting of one scene to a stated amount.
|
||||
**
|
||||
** At each size a line is first broken between words only. If that does not
|
||||
** fit, it is broken at any character. So a value with no spaces wraps, a
|
||||
** sentence keeps its words while it can, and a larger size is preferred to
|
||||
** whole words. Breaking at any character is also what makes a length provable:
|
||||
** a text of n characters needs no more lines than n of the face's widest.
|
||||
**
|
||||
** A line's box is its pen advance, widened to hold the ink of every character
|
||||
** in it (a `j` that starts left of the pen, an accent above the ascent) plus
|
||||
** `SLOP` dots on each inked side. The drawn glyph is the font's outline fitted
|
||||
** to the dot grid by the rasteriser, which moves an edge by less than two dots
|
||||
** from the outline scaled exactly; the slop is that allowance, and a test
|
||||
** holds every character of every bundled face to it.
|
||||
*******************************************************************************/
|
||||
public final class TextFit
|
||||
{
|
||||
public static final int SLOP = 2;
|
||||
public static final int STEP_MILLIPOINTS = 250;
|
||||
public static final int MARK = 0x2026;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private TextFit()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What is asked: a face, a box in dots as the text reads (before any
|
||||
** rotation), the resolution, the nominal size and the floor in thousandths
|
||||
** of a point, the line pitch in thousandths of the size (null for the
|
||||
** face's own), the most lines, and whether a text that fits at no size is
|
||||
** cut short rather than refused.
|
||||
***************************************************************************/
|
||||
public record Request(Face face, int width, int height, int dpi, int nominal, int floor, Integer lineHeight, int maxLines, boolean truncate)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One line: the characters `from` to `to` of the text, followed by the
|
||||
** ellipsis when `marked`. `left` (never positive) and `right` are the sides
|
||||
** of its box measured from the pen at the start of the line; `above` and
|
||||
** `below` are how far its ink reaches from the baseline.
|
||||
***************************************************************************/
|
||||
public record Line(int from, int to, boolean marked, int left, int right, int above, int below)
|
||||
{
|
||||
/************************************************************************
|
||||
** The width of the line's box.
|
||||
************************************************************************/
|
||||
public int width()
|
||||
{
|
||||
return (right - left);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The answer: the size chosen, the lines, the size of the block they make,
|
||||
** the baseline of the first line from the top of the block, the distance
|
||||
** between baselines, how many characters there were (trailing spaces never
|
||||
** print and are not counted), how many are shown, and whether it fits. When
|
||||
** it does not, the numbers are those of the text broken at any character at
|
||||
** the floor size, which is what a refusal reports.
|
||||
***************************************************************************/
|
||||
public record Block(int milliPoints, List<Line> lines, int width, int height, int baseline, int pitch, int characters, int shown, boolean fits)
|
||||
{
|
||||
/************************************************************************
|
||||
** Whether the text was cut short.
|
||||
************************************************************************/
|
||||
public boolean truncated()
|
||||
{
|
||||
return (shown < characters);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Dots from thousandths of the em at one size and resolution. A size of
|
||||
** `milliPoints` is `milliPoints * dpi / 72000` dots to the em, so a length
|
||||
** of `units` is `units * milliPoints * dpi / 72000000` dots, rounded as the
|
||||
** use says. The products stay far inside a long.
|
||||
***************************************************************************/
|
||||
public record Scale(int milliPoints, int dpi)
|
||||
{
|
||||
private static final long DIVISOR = 72_000_000L;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Rounded down.
|
||||
************************************************************************/
|
||||
public int floor(long units)
|
||||
{
|
||||
return (Math.toIntExact(Math.floorDiv(units * milliPoints * dpi, DIVISOR)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Rounded up.
|
||||
************************************************************************/
|
||||
public int ceil(long units)
|
||||
{
|
||||
return (-floor(-units));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** To the nearest dot, a half upward.
|
||||
************************************************************************/
|
||||
public int round(long units)
|
||||
{
|
||||
return (Math.toIntExact(Math.floorDiv(units * milliPoints * dpi + DIVISOR / 2, DIVISOR)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The size of the em in dots, for the font that draws.
|
||||
************************************************************************/
|
||||
public float size()
|
||||
{
|
||||
return ((float) ((double) milliPoints * dpi / 72_000));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The work fitting may do, in characters visited, shared by as many fits
|
||||
** as the caller wants held to one amount. It is counted by integer
|
||||
** arithmetic alone, so it is the same on every machine.
|
||||
***************************************************************************/
|
||||
public static final class Work
|
||||
{
|
||||
private final long limit;
|
||||
private long spent;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** An allowance of this many steps.
|
||||
************************************************************************/
|
||||
public Work(long limit)
|
||||
{
|
||||
this.limit = limit;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Count steps, and end the work when the allowance is passed. It stays
|
||||
** passed: whatever is asked next ends at once.
|
||||
************************************************************************/
|
||||
public void spend(long steps)
|
||||
{
|
||||
spent += steps;
|
||||
if(spent > limit)
|
||||
{
|
||||
throw new Exhausted();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The steps counted so far.
|
||||
************************************************************************/
|
||||
public long spent()
|
||||
{
|
||||
return (spent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The allowance of a `Work` was passed. It says nothing else: what was
|
||||
** being fitted is not in it.
|
||||
***************************************************************************/
|
||||
public static final class Exhausted extends RuntimeException
|
||||
{
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** No message beyond the name, no cause and no stack.
|
||||
************************************************************************/
|
||||
Exhausted()
|
||||
{
|
||||
super("TEXT_WORK_EXHAUSTED", null, false, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What a line reserves, in thousandths of the em, read from the face once
|
||||
** for a fit: the ascent above the baseline, the descent below it, and the
|
||||
** distance between baselines.
|
||||
***************************************************************************/
|
||||
record Leading(int ascent, int descent, int pitch)
|
||||
{
|
||||
/************************************************************************
|
||||
** The face's own, with the line height the request states if it does.
|
||||
************************************************************************/
|
||||
static Leading of(Request request)
|
||||
{
|
||||
Face face = request.face();
|
||||
return (new Leading(face.ascent(), face.descent(), request.lineHeight() == null ? face.ascent() + face.descent() + face.leading() : request.lineHeight()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The distance between baselines in dots, never less than one.
|
||||
************************************************************************/
|
||||
int pitch(Scale scale)
|
||||
{
|
||||
return (Math.max(1, scale.ceil(pitch)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The least a block of this many lines is high: one ascent, the pitches
|
||||
** between and one descent. Ink that reaches further makes it higher.
|
||||
************************************************************************/
|
||||
int height(Scale scale, int lines)
|
||||
{
|
||||
return (scale.ceil(ascent) + (lines - 1) * pitch(scale) + scale.ceil(descent));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box of a line as characters are added to it from the left.
|
||||
***************************************************************************/
|
||||
private static final class Extent
|
||||
{
|
||||
private final Scale scale;
|
||||
private long units;
|
||||
private int left;
|
||||
private int right;
|
||||
private int above;
|
||||
private int below;
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** An empty line at one size.
|
||||
************************************************************************/
|
||||
Extent(Scale scale)
|
||||
{
|
||||
this.scale = scale;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Add the next character at the pen. A space moves the pen and widens
|
||||
** nothing, so spaces at the end of a line take no room.
|
||||
************************************************************************/
|
||||
void add(Glyph glyph)
|
||||
{
|
||||
int pen = scale.round(units);
|
||||
units += glyph.advance();
|
||||
if(!glyph.space())
|
||||
{
|
||||
right = Math.max(right, scale.ceil(units));
|
||||
}
|
||||
if(glyph.ink())
|
||||
{
|
||||
left = Math.min(left, pen + scale.floor(glyph.left()) - SLOP);
|
||||
right = Math.max(right, pen + scale.ceil(glyph.right()) + SLOP);
|
||||
above = Math.max(above, scale.ceil(glyph.top()) + SLOP);
|
||||
below = Math.max(below, scale.ceil(glyph.bottom()) + SLOP);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The width of the box so far.
|
||||
************************************************************************/
|
||||
int width()
|
||||
{
|
||||
return (right - left);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text, given as the measurements of its characters, by the fixed
|
||||
** order, with no limit on the work. `mark` is the face's ellipsis, used
|
||||
** only when the request allows cutting.
|
||||
***************************************************************************/
|
||||
public static Block fit(Request request, Glyph[] glyphs, Glyph mark)
|
||||
{
|
||||
return (fit(request, glyphs, mark, new Work(Long.MAX_VALUE)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text by the fixed order, counting the work against an allowance
|
||||
** and ending as `Exhausted` when it is passed. The floor is never above
|
||||
** the nominal size and no character has a negative advance.
|
||||
***************************************************************************/
|
||||
public static Block fit(Request request, Glyph[] glyphs, Glyph mark, Work work)
|
||||
{
|
||||
int count = glyphs.length;
|
||||
while(count > 0 && glyphs[count - 1].space())
|
||||
{
|
||||
count--;
|
||||
}
|
||||
Leading leading = Leading.of(request);
|
||||
int last = (request.nominal() - request.floor() + STEP_MILLIPOINTS - 1) / STEP_MILLIPOINTS;
|
||||
Block block = at(request, leading, request.nominal(), glyphs, count, mark, work);
|
||||
int step = block.fits() || last == 0 ? last + 1 : first(request, leading, glyphs, count, last, work);
|
||||
while(step <= last)
|
||||
{
|
||||
block = at(request, leading, size(request, step), glyphs, count, mark, work);
|
||||
step = block.fits() ? last + 1 : step + 1;
|
||||
}
|
||||
return (block);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The size a number of quarter-point steps below the nominal, which is the
|
||||
** floor once the steps reach it.
|
||||
***************************************************************************/
|
||||
private static int size(Request request, int step)
|
||||
{
|
||||
return (Math.max(request.floor(), request.nominal() - step * STEP_MILLIPOINTS));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The answer at one size: the text broken between words if that fits, else
|
||||
** broken at any character; and at the floor, when that does not fit either
|
||||
** and the request allows it, cut. This is one step of the search of every
|
||||
** size, which a test makes in full to hold `fit` to it.
|
||||
***************************************************************************/
|
||||
static Block at(Request request, Leading leading, int size, Glyph[] glyphs, int count, Glyph mark, Work work)
|
||||
{
|
||||
Scale scale = new Scale(size, request.dpi());
|
||||
Block words = block(request, leading, scale, lines(request, scale, glyphs, count, true, work), count, count);
|
||||
if(words.fits())
|
||||
{
|
||||
return (words);
|
||||
}
|
||||
Block characters = block(request, leading, scale, lines(request, scale, glyphs, count, false, work), count, count);
|
||||
return (characters.fits() || size > request.floor() || !request.truncate() ? characters : cut(request, leading, scale, glyphs, mark, characters, work));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The fewest steps below the nominal, from one, at which the text could
|
||||
** fit; `last`, the floor, when it could at none above it. No size above the
|
||||
** answer fits, because `possible` never refuses a size that fits and, once
|
||||
** it allows a size, allows every smaller one. One line at the floor higher
|
||||
** than the box settles it for every text without a count.
|
||||
***************************************************************************/
|
||||
private static int first(Request request, Leading leading, Glyph[] glyphs, int count, int last, Work work)
|
||||
{
|
||||
if(leading.height(new Scale(request.floor(), request.dpi()), 1) > request.height())
|
||||
{
|
||||
return (last);
|
||||
}
|
||||
int low = 1;
|
||||
int high = last;
|
||||
while(low < high)
|
||||
{
|
||||
int middle = (low + high) >>> 1;
|
||||
if(possible(request, leading, new Scale(size(request, middle), request.dpi()), glyphs, count, work))
|
||||
{
|
||||
high = middle;
|
||||
}
|
||||
else
|
||||
{
|
||||
low = middle + 1;
|
||||
}
|
||||
}
|
||||
return (low);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a text could fit at one size, erring only toward yes. Lines are
|
||||
** filled at any character, each held to a width no layout of it can be
|
||||
** under: its advance to the end of its last character that is not a space,
|
||||
** or, when wider, the span of its ink scaled exactly and the allowance at
|
||||
** both ends less the one dot two roundings can give back. A line of a real
|
||||
** layout, by words or by characters, is never narrower than this, and a
|
||||
** line that holds more is never narrower than one that holds less, so no
|
||||
** layout has fewer lines than are counted here; and a block is never lower
|
||||
** than its lines' ascent, pitches and descent. Every one of these numbers
|
||||
** grows with the size, so a yes at one size is a yes at every smaller one.
|
||||
***************************************************************************/
|
||||
private static boolean possible(Request request, Leading leading, Scale scale, Glyph[] glyphs, int count, Work work)
|
||||
{
|
||||
work.spend(count);
|
||||
int lines = 0;
|
||||
int index = 0;
|
||||
while(index < count)
|
||||
{
|
||||
lines++;
|
||||
if(lines > request.maxLines() || leading.height(scale, lines) > request.height())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
int from = index;
|
||||
long units = 0;
|
||||
long end = 0;
|
||||
long reach = Long.MIN_VALUE;
|
||||
long lead = Long.MAX_VALUE;
|
||||
while(index < count)
|
||||
{
|
||||
Glyph glyph = glyphs[index];
|
||||
long after = units + glyph.advance();
|
||||
long ended = glyph.space() ? end : after;
|
||||
long reached = glyph.ink() ? Math.max(reach, units + glyph.right()) : reach;
|
||||
long led = glyph.ink() ? Math.min(lead, units + glyph.left()) : lead;
|
||||
int width = Math.max(scale.ceil(ended), reached < led ? 0 : scale.ceil(reached - led) + 2 * SLOP - 1);
|
||||
if(width > request.width() && index > from)
|
||||
{
|
||||
break;
|
||||
}
|
||||
if(width > request.width())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
units = after;
|
||||
end = ended;
|
||||
reach = reached;
|
||||
lead = led;
|
||||
index++;
|
||||
}
|
||||
}
|
||||
return (true);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Break a text into lines at one size, between words or at any character.
|
||||
** Spaces where a line is broken are dropped. A line is measured twice, to
|
||||
** find its end and to box it, and is counted so.
|
||||
***************************************************************************/
|
||||
private static List<Line> lines(Request request, Scale scale, Glyph[] glyphs, int count, boolean words, Work work)
|
||||
{
|
||||
List<Line> lines = new ArrayList<>();
|
||||
int from = 0;
|
||||
while(from < count)
|
||||
{
|
||||
int to = end(request, scale, glyphs, from, count, words);
|
||||
work.spend(2L * (to - from) + 1);
|
||||
lines.add(line(scale, glyphs, from, to, null));
|
||||
from = to;
|
||||
while(from < count && glyphs[from].space())
|
||||
{
|
||||
from++;
|
||||
}
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Where the line that starts at `from` ends: after the last word, or the
|
||||
** last character, that still fits the width. The first one is taken even
|
||||
** when it is too wide, so the line is then wider than the box and the
|
||||
** layout does not fit. Broken at any character, a line may end after a
|
||||
** space, which takes no room; so spaces at the start of a text cannot push
|
||||
** its first letter out of the box.
|
||||
***************************************************************************/
|
||||
private static int end(Request request, Scale scale, Glyph[] glyphs, int from, int count, boolean words)
|
||||
{
|
||||
Extent extent = new Extent(scale);
|
||||
int accepted = from;
|
||||
for(int index = from; index < count; index++)
|
||||
{
|
||||
extent.add(glyphs[index]);
|
||||
if(!words || (!glyphs[index].space() && (index + 1 == count || glyphs[index + 1].space())))
|
||||
{
|
||||
boolean wide = extent.width() > request.width();
|
||||
if(wide && accepted > from)
|
||||
{
|
||||
return (accepted);
|
||||
}
|
||||
accepted = index + 1;
|
||||
if(wide)
|
||||
{
|
||||
return (accepted);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (accepted);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Measure one line, with the ellipsis after it when one is given.
|
||||
***************************************************************************/
|
||||
private static Line line(Scale scale, Glyph[] glyphs, int from, int to, Glyph mark)
|
||||
{
|
||||
Extent extent = new Extent(scale);
|
||||
for(int index = from; index < to; index++)
|
||||
{
|
||||
extent.add(glyphs[index]);
|
||||
}
|
||||
if(mark != null)
|
||||
{
|
||||
extent.add(mark);
|
||||
}
|
||||
return (new Line(from, to, mark != null, extent.left, extent.right, extent.above, extent.below));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Stack lines into a block and hold it against the box. A line reserves the
|
||||
** face's ascent above its baseline and its descent below; the block grows
|
||||
** where the ink of a line reaches further.
|
||||
***************************************************************************/
|
||||
private static Block block(Request request, Leading leading, Scale scale, List<Line> lines, int characters, int shown)
|
||||
{
|
||||
if(lines.isEmpty())
|
||||
{
|
||||
return (new Block(scale.milliPoints(), lines, 0, 0, 0, 0, characters, shown, true));
|
||||
}
|
||||
int ascent = scale.ceil(leading.ascent());
|
||||
int pitch = leading.pitch(scale);
|
||||
int top = 0;
|
||||
int bottom = leading.height(scale, lines.size());
|
||||
int width = 0;
|
||||
for(int index = 0; index < lines.size(); index++)
|
||||
{
|
||||
Line line = lines.get(index);
|
||||
int baseline = ascent + index * pitch;
|
||||
top = Math.min(top, baseline - line.above());
|
||||
bottom = Math.max(bottom, baseline + line.below());
|
||||
width = Math.max(width, line.width());
|
||||
}
|
||||
boolean fits = lines.size() <= request.maxLines() && width <= request.width() && bottom - top <= request.height();
|
||||
return (new Block(scale.milliPoints(), lines, width, bottom - top, ascent - top, pitch, characters, shown, fits));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Cut a text that fits at no size: keep as many lines as the box holds, and
|
||||
** end the last of them with the ellipsis after as many of its characters as
|
||||
** still fit with it, down to the ellipsis alone. Something is always left
|
||||
** out, so the ellipsis always stands for text, and spaces before it are
|
||||
** dropped. When not even the ellipsis fits, the answer is the refusal that
|
||||
** was passed in. A box lower than a number of lines holds no cut of that
|
||||
** many, which is known without trying; so one lower than a single line
|
||||
** holds no ellipsis at all.
|
||||
***************************************************************************/
|
||||
private static Block cut(Request request, Leading leading, Scale scale, Glyph[] glyphs, Glyph mark, Block refused, Work work)
|
||||
{
|
||||
int all = refused.lines().size();
|
||||
int most = Math.min(request.maxLines(), all);
|
||||
while(most > 0 && leading.height(scale, most) > request.height())
|
||||
{
|
||||
most--;
|
||||
}
|
||||
for(int kept = most; kept > 0; kept--)
|
||||
{
|
||||
List<Line> lines = new ArrayList<>(refused.lines().subList(0, kept));
|
||||
int from = lines.get(kept - 1).from();
|
||||
for(int to = lines.get(kept - 1).to() - (kept == all ? 1 : 0); to >= from; to--)
|
||||
{
|
||||
if(to > from && glyphs[to - 1].space())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
work.spend(to - from + 1L + kept);
|
||||
lines.set(kept - 1, line(scale, glyphs, from, to, mark));
|
||||
Block block = block(request, leading, scale, lines, refused.characters(), to);
|
||||
if(block.fits())
|
||||
{
|
||||
return (block);
|
||||
}
|
||||
}
|
||||
}
|
||||
return (refused);
|
||||
}
|
||||
}
|
||||
@@ -1,291 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.renderer.scene2.text;
|
||||
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Font;
|
||||
import java.awt.Graphics2D;
|
||||
import java.awt.Rectangle;
|
||||
import java.awt.RenderingHints;
|
||||
import java.awt.font.GlyphVector;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferByte;
|
||||
import java.util.BitSet;
|
||||
import com.kingsrook.qbits.renderer.scene2.Canvas;
|
||||
import com.kingsrook.qbits.renderer.scene2.Frame;
|
||||
import com.kingsrook.qbits.renderer.scene2.Layout;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Draws a fitted text onto the label. Each glyph is drawn antialiased at the
|
||||
** whole-dot pen position the fit decided, by the same Java rasteriser version 1
|
||||
** raster text uses, and a dot is ink when its grey is darker than the
|
||||
** element's threshold, so a higher threshold prints heavier.
|
||||
**
|
||||
** A line is drawn on a strip: its measured box and a guard of `GUARD` dots all
|
||||
** round. Ink found in the guard, or a glyph whose bitmap the runtime places
|
||||
** anywhere outside the measured box, means the measurement was wrong. Such
|
||||
** ink is never clipped away and never printed outside the box, and a text is
|
||||
** never printed in part: the dots of a text are gathered first and reach the
|
||||
** label only when none strayed. What strayed is counted (`Stray`), for the
|
||||
** element to be refused by name.
|
||||
**
|
||||
** A strip of more than `BAND_DOTS` dots (very large type) is drawn in bands of
|
||||
** whole rows, so one line never needs more grey than that. Where the bands
|
||||
** fall depends only on the box of the line, so the picture is the same on
|
||||
** every machine; the rasteriser may shade an edge dot at the border of a band
|
||||
** differently than it would in one piece.
|
||||
*******************************************************************************/
|
||||
final class TextRaster
|
||||
{
|
||||
static final int GUARD = 2;
|
||||
static final int BAND_DOTS = 1 << 23;
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// what drawing counts against the work of a scene, in characters visited while fitting, //
|
||||
// which take about 15 ns each. A glyph the runtime has not drawn at a size is rasterised //
|
||||
// once, in 20 to 50 microseconds; after that, asking for it and for where its bitmap //
|
||||
// falls takes about a quarter of a microsecond //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
static final int GLYPH_STEPS = 4096;
|
||||
static final int PEN_STEPS = 16;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private TextRaster()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What to draw and how: the face, the text, its fitted block, where lines
|
||||
** sit across the block, the clockwise rotation, the grey below which a dot
|
||||
** is ink, and whether the dots are ink or (for reverse text) cleared from
|
||||
** the element's box filled black.
|
||||
***************************************************************************/
|
||||
record Drawing(Face face, String text, TextFit.Block block, Placement.Align align, Placement.Valign valign, int rotation, int threshold, boolean ink)
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** What the rasteriser put outside the measure: glyphs whose bitmap is not
|
||||
** wholly inside the box of their line, and dots of ink found in the guard
|
||||
** round a line or outside the element's box.
|
||||
***************************************************************************/
|
||||
record Stray(int glyphs, int dots)
|
||||
{
|
||||
/************************************************************************
|
||||
** Whether anything strayed.
|
||||
************************************************************************/
|
||||
boolean any()
|
||||
{
|
||||
return (glyphs > 0 || dots > 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many glyphs of a fitted text the runtime would place outside the box
|
||||
** measured for their line at this resolution, without drawing any: what
|
||||
** can be known of a text when a scene is only validated.
|
||||
***************************************************************************/
|
||||
static int outside(Drawing drawing, int dpi)
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(drawing.block().milliPoints(), dpi);
|
||||
Font font = drawing.face().font(scale.size());
|
||||
int outside = 0;
|
||||
for(TextFit.Line line : drawing.block().lines())
|
||||
{
|
||||
outside += glyphs(drawing, scale, font, line, null, 0);
|
||||
}
|
||||
return (outside);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Draw into the element's box, which is given as it appears on the label:
|
||||
** a text turned a quarter reads along the box's height. The answer is what
|
||||
** strayed; when anything did, the label is left as it was.
|
||||
***************************************************************************/
|
||||
static Stray paint(Canvas canvas, Frame frame, Drawing drawing, int bandDots)
|
||||
{
|
||||
TextFit.Block block = drawing.block();
|
||||
boolean upright = drawing.rotation() % 180 == 0;
|
||||
Frame reading = new Frame(0, 0, upright ? frame.width() : frame.height(), upright ? frame.height() : frame.width());
|
||||
Frame content = Layout.align(reading, block.width(), block.height(), drawing.align(), drawing.valign());
|
||||
TextFit.Scale scale = new TextFit.Scale(block.milliPoints(), canvas.dpi());
|
||||
Font font = drawing.face().font(scale.size());
|
||||
BitSet ink = new BitSet();
|
||||
int glyphs = 0;
|
||||
int dots = 0;
|
||||
for(int index = 0; index < block.lines().size(); index++)
|
||||
{
|
||||
TextFit.Line line = block.lines().get(index);
|
||||
int left = content.x() + drawing.align().position().place(block.width(), line.width(), 0);
|
||||
int top = content.y() + block.baseline() + index * block.pitch() - line.above();
|
||||
Stray stray = strip(ink, reading, drawing, scale, font, line, left, top, bandDots);
|
||||
glyphs += stray.glyphs();
|
||||
dots += stray.dots();
|
||||
}
|
||||
Stray stray = new Stray(glyphs, dots);
|
||||
if(!stray.any())
|
||||
{
|
||||
if(!drawing.ink())
|
||||
{
|
||||
canvas.fill(frame, true);
|
||||
}
|
||||
for(int dot = ink.nextSetBit(0); dot >= 0; dot = ink.nextSetBit(dot + 1))
|
||||
{
|
||||
dot(canvas, frame, reading, drawing, dot % reading.width(), dot / reading.width());
|
||||
}
|
||||
}
|
||||
return (stray);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Gather the ink of one line whose box has its top left at `left`, `top` of
|
||||
** the reading frame, a band of rows at a time, and count what strayed.
|
||||
***************************************************************************/
|
||||
private static Stray strip(BitSet ink, Frame reading, Drawing drawing, TextFit.Scale scale, Font font, TextFit.Line line, int left, int top, int bandDots)
|
||||
{
|
||||
int width = line.width() + 2 * GUARD;
|
||||
int height = line.above() + line.below() + 2 * GUARD;
|
||||
int rows = Math.max(1, bandDots / width);
|
||||
int glyphs = 0;
|
||||
int dots = 0;
|
||||
for(int first = 0; first < height; first += rows)
|
||||
{
|
||||
BufferedImage band = new BufferedImage(width, Math.min(rows, height - first), BufferedImage.TYPE_BYTE_GRAY);
|
||||
Graphics2D graphics = band.createGraphics();
|
||||
try
|
||||
{
|
||||
graphics.setColor(Color.WHITE);
|
||||
graphics.fillRect(0, 0, band.getWidth(), band.getHeight());
|
||||
graphics.setColor(Color.BLACK);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_FRACTIONALMETRICS, RenderingHints.VALUE_FRACTIONALMETRICS_ON);
|
||||
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// ink that fell wholly beyond the guard is clipped by the strip and leaves no //
|
||||
// dot to find; the bounds of its glyph say so, and they are the same for every //
|
||||
// band, so those of the first are the count //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
int beyond = glyphs(drawing, scale, font, line, graphics, first);
|
||||
glyphs = first == 0 ? beyond : glyphs;
|
||||
}
|
||||
finally
|
||||
{
|
||||
graphics.dispose();
|
||||
}
|
||||
byte[] grey = ((DataBufferByte) band.getRaster().getDataBuffer()).getData();
|
||||
for(int y = 0; y < band.getHeight(); y++)
|
||||
{
|
||||
for(int x = 0; x < width; x++)
|
||||
{
|
||||
if((grey[y * width + x] & 255) < drawing.threshold())
|
||||
{
|
||||
int row = first + y;
|
||||
int dotX = left + x - GUARD;
|
||||
int dotY = top + row - GUARD;
|
||||
if(x < GUARD || x >= width - GUARD || row < GUARD || row >= height - GUARD || dotX < 0 || dotY < 0 || dotX >= reading.width() || dotY >= reading.height())
|
||||
{
|
||||
dots++;
|
||||
}
|
||||
else
|
||||
{
|
||||
ink.set(dotY * reading.width() + dotX);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return (new Stray(glyphs, dots));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The glyphs of one line at their pens on its strip: how many the runtime
|
||||
** places outside the line's measured box, drawing each on a band of the
|
||||
** strip whose first row is `first` when one is given.
|
||||
***************************************************************************/
|
||||
private static int glyphs(Drawing drawing, TextFit.Scale scale, Font font, TextFit.Line line, Graphics2D band, int first)
|
||||
{
|
||||
int width = line.width() + 2 * GUARD;
|
||||
int height = line.above() + line.below() + 2 * GUARD;
|
||||
int outside = 0;
|
||||
long units = 0;
|
||||
for(int index = line.from(); index < line.to() + (line.marked() ? 1 : 0); index++)
|
||||
{
|
||||
char character = index < line.to() ? drawing.text().charAt(index) : (char) TextFit.MARK;
|
||||
Face.Glyph glyph = drawing.face().glyph(character);
|
||||
if(glyph.ink())
|
||||
{
|
||||
GlyphVector vector = font.createGlyphVector(Face.CONTEXT, new char[] {character});
|
||||
int pen = GUARD - line.left() + scale.round(units);
|
||||
Rectangle pixels = vector.getGlyphPixelBounds(0, Face.CONTEXT, pen, GUARD + line.above());
|
||||
outside += !pixels.isEmpty() && (pixels.x < GUARD || pixels.y < GUARD || pixels.x + pixels.width > width - GUARD || pixels.y + pixels.height > height - GUARD) ? 1 : 0;
|
||||
if(band != null)
|
||||
{
|
||||
band.drawGlyphVector(vector, pen, GUARD + line.above() - first);
|
||||
}
|
||||
}
|
||||
units += glyph.advance();
|
||||
}
|
||||
return (outside);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Put one dot of the reading frame on the label, turned clockwise.
|
||||
***************************************************************************/
|
||||
private static void dot(Canvas canvas, Frame frame, Frame reading, Drawing drawing, int x, int y)
|
||||
{
|
||||
int turnedX = switch(drawing.rotation())
|
||||
{
|
||||
case 90 -> reading.height() - 1 - y;
|
||||
case 180 -> reading.width() - 1 - x;
|
||||
case 270 -> y;
|
||||
default -> x;
|
||||
};
|
||||
int turnedY = switch(drawing.rotation())
|
||||
{
|
||||
case 90 -> x;
|
||||
case 180 -> reading.height() - 1 - y;
|
||||
case 270 -> reading.width() - 1 - x;
|
||||
default -> y;
|
||||
};
|
||||
canvas.set(frame.x() + turnedX, frame.y() + turnedY, drawing.ink());
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f ibm-plex-sans/IBMPlexSans-Bold.ttf
|
||||
975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5 ibm-plex-sans/IBMPlexSans-Regular.ttf
|
||||
7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da ibm-plex-sans/LICENSE.txt
|
||||
723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7 ibm-plex-sans-condensed/IBMPlexSansCondensed-Bold.ttf
|
||||
7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da ibm-plex-sans-condensed/LICENSE.txt
|
||||
5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb jetbrains-mono/JetBrainsMono-Bold.ttf
|
||||
a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f jetbrains-mono/JetBrainsMono-Regular.ttf
|
||||
30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad jetbrains-mono/OFL.txt
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,23 +0,0 @@
|
||||
# IBM Plex Sans Condensed (Bold)
|
||||
|
||||
An unmodified file from the official release of the IBM Plex project, retrieved 2026-11-12.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Project | https://github.com/IBM/plex |
|
||||
| Release | `@ibm/plex-sans-condensed@2.0.0` |
|
||||
| Archive | https://github.com/IBM/plex/releases/download/%40ibm/plex-sans-condensed%402.0.0/ibm-plex-sans-condensed.zip |
|
||||
| Archive SHA-256 | `f86ba73abbeed7cf7690ead863192a87211dfc2b0cef0ac6bf4700533ec5ab9e` |
|
||||
| Licence | SIL Open Font License 1.1, `LICENSE.txt` in this directory (the archive's `ibm-plex-sans-condensed/LICENSE.txt`, byte for byte) |
|
||||
| Reserved Font Name | "Plex" (the file is not modified and not renamed) |
|
||||
|
||||
| File here | Path in the archive | Font version (name table) | SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `IBMPlexSansCondensed-Bold.ttf` | `ibm-plex-sans-condensed/fonts/complete/ttf/IBMPlexSansCondensed-Bold.ttf` | Version 3.000 | `723e245902b45a49f8cf602d15891bae08c31309dce7c56d209d10cd471b88c7` |
|
||||
| `LICENSE.txt` | `ibm-plex-sans-condensed/LICENSE.txt` | | `7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da` |
|
||||
|
||||
The licence was confirmed from the release archive itself: its `LICENSE.txt` states "This Font Software is
|
||||
licensed under the SIL Open Font License, Version 1.1" and carries the full text; the licence field of the
|
||||
font's name table says the same, and the font's embedding bits (`OS/2.fsType`) are 0, installable embedding.
|
||||
|
||||
Scene name: `qqq-condensed-bold`.
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
|
||||
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,24 +0,0 @@
|
||||
# IBM Plex Sans (Regular, Bold)
|
||||
|
||||
Unmodified files from the official release of the IBM Plex project, retrieved 2026-11-12.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Project | https://github.com/IBM/plex |
|
||||
| Release | `@ibm/plex-sans@1.1.0` |
|
||||
| Archive | https://github.com/IBM/plex/releases/download/%40ibm/plex-sans%401.1.0/ibm-plex-sans.zip |
|
||||
| Archive SHA-256 | `fb365d910566e6d199cc2c15579a7dd9a267128e18431a394ed81f1970c69200` |
|
||||
| Licence | SIL Open Font License 1.1, `LICENSE.txt` in this directory (the archive's `ibm-plex-sans/LICENSE.txt`, byte for byte) |
|
||||
| Reserved Font Name | "Plex" (the files are not modified and not renamed) |
|
||||
|
||||
| File here | Path in the archive | Font version (name table) | SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `IBMPlexSans-Regular.ttf` | `ibm-plex-sans/fonts/complete/ttf/IBMPlexSans-Regular.ttf` | Version 3.005 | `975dcda37d80f038dcd143c22e33ca2d97a0cc5a929aace1c749153b0fe1afa5` |
|
||||
| `IBMPlexSans-Bold.ttf` | `ibm-plex-sans/fonts/complete/ttf/IBMPlexSans-Bold.ttf` | Version 3.005 | `9e6c74a889a700d707613d24548fe4ffa6bc59559a0689d2cf9e133bdcdafb2f` |
|
||||
| `LICENSE.txt` | `ibm-plex-sans/LICENSE.txt` | | `7e6b2818edbd8f6a01ae80641cc8f16a51080d08fb4e532be3a0b6f74adb07da` |
|
||||
|
||||
The licence was confirmed from the release archive itself: its `LICENSE.txt` states "This Font Software is
|
||||
licensed under the SIL Open Font License, Version 1.1" and carries the full text; the licence field of each
|
||||
font's name table says the same, and each font's embedding bits (`OS/2.fsType`) are 0, installable embedding.
|
||||
|
||||
Scene names: `qqq-sans` (Regular), `qqq-sans-bold` (Bold).
|
||||
@@ -1,93 +0,0 @@
|
||||
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -1,24 +0,0 @@
|
||||
# JetBrains Mono (Regular, Bold)
|
||||
|
||||
Unmodified files from the official release of the JetBrains Mono project, retrieved 2026-11-12.
|
||||
|
||||
| | |
|
||||
| --- | --- |
|
||||
| Project | https://github.com/JetBrains/JetBrainsMono |
|
||||
| Release | `v2.304` |
|
||||
| Archive | https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip |
|
||||
| Archive SHA-256 | `6f6376c6ed2960ea8a963cd7387ec9d76e3f629125bc33d1fdcd7eb7012f7bbf` |
|
||||
| Licence | SIL Open Font License 1.1, `OFL.txt` in this directory (the archive's `OFL.txt`, byte for byte) |
|
||||
| Reserved Font Name | none declared |
|
||||
|
||||
| File here | Path in the archive | Font version (name table) | SHA-256 |
|
||||
| --- | --- | --- | --- |
|
||||
| `JetBrainsMono-Regular.ttf` | `fonts/ttf/JetBrainsMono-Regular.ttf` | Version 2.304; ttfautohint (v1.8.4.7-5d5b) | `a0bf60ef0f83c5ed4d7a75d45838548b1f6873372dfac88f71804491898d138f` |
|
||||
| `JetBrainsMono-Bold.ttf` | `fonts/ttf/JetBrainsMono-Bold.ttf` | Version 2.304; ttfautohint (v1.8.4.7-5d5b) | `5590990c82e097397517f275f430af4546e1c45cff408bde4255dad142479dcb` |
|
||||
| `OFL.txt` | `OFL.txt` | | `30f0c136e3c88e422d0791acd97238870f9054a9729bc34cf2ff0d4ed8cac4ad` |
|
||||
|
||||
The licence was confirmed from the release archive itself: its `OFL.txt` states "This Font Software is
|
||||
licensed under the SIL Open Font License, Version 1.1" and carries the full text; the licence field of each
|
||||
font's name table says the same, and each font's embedding bits (`OS/2.fsType`) are 0, installable embedding.
|
||||
|
||||
Scene names: `qqq-mono` (Regular), `qqq-mono-bold` (Bold).
|
||||
@@ -22,8 +22,6 @@ package com.kingsrook.qbits.renderer;
|
||||
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
@@ -33,7 +31,6 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import com.fasterxml.jackson.core.JsonParseException;
|
||||
import com.fasterxml.jackson.core.exc.StreamConstraintsException;
|
||||
@@ -86,8 +83,6 @@ class MutationCampaignTest
|
||||
|
||||
private static final Pattern LENGTH = Pattern.compile("-?[0-9]{1,5}(\\.[0-9]{1,3})?(mm|in|pt|%|dot)");
|
||||
private static final Pattern ELEMENT_ID = Pattern.compile("[A-Za-z][A-Za-z0-9_-]{0,31}");
|
||||
private static final Pattern TYPE_SIZE = Pattern.compile("([0-9]{1,5}(?:\\.[0-9]{1,3})?)(mm|in|pt)");
|
||||
private static final Set<String> FACES = Set.of("qqq-sans", "qqq-sans-bold", "qqq-condensed-bold", "qqq-mono", "qqq-mono-bold");
|
||||
|
||||
|
||||
|
||||
@@ -310,15 +305,13 @@ class MutationCampaignTest
|
||||
** second parser, either a version 1 scene (as above) or a version 2 scene
|
||||
** that keeps every rule this checks on its own: only the declared top-level
|
||||
** properties, a physical design size, at most 1000 elements with unique ids
|
||||
** of the fixed pattern, only boxes, groups and text (the types this renderer
|
||||
** draws), groups at most four deep, every length a number and a unit, every
|
||||
** text in a bundled face at a physical size with one value and only
|
||||
** documented fit, length and legibility settings at each resolution, and
|
||||
** 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 (but for a text bound to the record, which has no value here). Every other input is refused with a `Refusal`, which carries a
|
||||
** 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.
|
||||
@@ -327,7 +320,7 @@ class MutationCampaignTest
|
||||
void theVersionTwoSceneParserAcceptsOnlyVersionTwoScenes() throws Exception
|
||||
{
|
||||
List<byte[]> corpus = new ArrayList<>();
|
||||
for(String name : List.of("anchors", "rules", "groups", "text-faces", "text-fit", "text-turned"))
|
||||
for(String name : List.of("anchors", "rules", "groups"))
|
||||
{
|
||||
try(InputStream stream = MutationCampaignTest.class.getResourceAsStream("/scene2/cases/" + name + ".json"))
|
||||
{
|
||||
@@ -336,8 +329,6 @@ class MutationCampaignTest
|
||||
}
|
||||
MutationCampaign campaign = new MutationCampaign(corpus, List.of("\"schema\"", "\"qqq-zpl-scene/2\"", "\"qqq-zpl-scene/1\"", "\"design\"", "\"elements\"", "\"overrides\"", "\"when\"", "\"dpi\"", "\"set\"",
|
||||
"\"children\"", "\"group\"", "\"box\"", "\"text\"", "\"barcode\"", "\"image\"", "\"id\"", "\"type\"", "\"anchor\"", "\"thickness\"", "\"fill\"", "\"rotation\"", "\"center\"", "true", "false", "null",
|
||||
"\"defaults\"", "\"font\"", "\"size\"", "\"value\"", "\"valuePath\"", "\"lineHeight\"", "\"fit\"", "\"maxLines\"", "\"shrinkTo\"", "\"overflow\"", "\"truncate\"", "\"refuse\"", "\"maxLength\"", "\"threshold\"",
|
||||
"\"reverse\"", "\"qqq-sans\"", "\"qqq-mono-bold\"", "\"qqq-\"", "\"/record/name\"", "90", "180", "270", "1000pt", "0.5", "1e400", "\\u0301", "\\t", "\\uD83D",
|
||||
"mm", "in", "pt", "%", "dot", "-", ".", "203", "300", "600", "99999999999", "4294967499", "{", "}", "[", "]", ":", ",", "\\u0000"));
|
||||
int iterations = MutationCampaign.iterations(ITERATIONS);
|
||||
for(long seed : MutationCampaign.seeds(SEEDS))
|
||||
@@ -415,12 +406,7 @@ class MutationCampaignTest
|
||||
}
|
||||
catch(Refusal refusal)
|
||||
{
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
// a text bound to the record has no value here, since the campaign's record is //
|
||||
// empty: that is the one refusal a render gives that validation, which reads no //
|
||||
// record, cannot //
|
||||
///////////////////////////////////////////////////////////////////////////////////////
|
||||
if(render && !"VALUE_MISSING".equals(refusal.diagnostic().code()))
|
||||
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());
|
||||
@@ -450,7 +436,7 @@ class MutationCampaignTest
|
||||
assertTrue(length != null && LENGTH.matcher(length).matches() && !length.startsWith("-") && !length.endsWith("%") && !length.endsWith("dot"), () -> "accepted design " + side + " " + length);
|
||||
}
|
||||
Set<String> ids = new HashSet<>();
|
||||
assertVersionTwoElements(accepted.path("elements"), 1, ids, accepted);
|
||||
assertVersionTwoElements(accepted.path("elements"), 1, ids);
|
||||
assertTrue(ids.size() <= 1000, () -> "accepted " + ids.size() + " elements");
|
||||
for(JsonNode override : accepted.path("overrides"))
|
||||
{
|
||||
@@ -465,7 +451,7 @@ class MutationCampaignTest
|
||||
/***************************************************************************
|
||||
** The independent reading of a version 2 element list.
|
||||
***************************************************************************/
|
||||
private static void assertVersionTwoElements(JsonNode elements, int depth, Set<String> ids, JsonNode scene)
|
||||
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)
|
||||
@@ -473,109 +459,23 @@ class MutationCampaignTest
|
||||
String id = element.path("id").textValue();
|
||||
assertTrue(id != null && ELEMENT_ID.matcher(id).matches() && ids.add(id), () -> "accepted element id " + element.path("id"));
|
||||
String type = element.path("type").textValue();
|
||||
assertTrue("box".equals(type) || "group".equals(type) || "text".equals(type), () -> "accepted element type " + element.path("type"));
|
||||
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")
|
||||
: "text".equals(type) ? Set.of("id", "type", "anchor", "x", "y", "width", "height", "align", "valign", "value", "valuePath", "font", "size", "lineHeight", "fit", "maxLength", "threshold", "reverse", "rotation")
|
||||
: Set.of("id", "type", "anchor", "x", "y", "width", "height", "align", "valign", "children");
|
||||
element.fieldNames().forEachRemaining(name -> assertTrue(allowed.contains(name), () -> "accepted " + type + " property " + name));
|
||||
if("group".equals(type))
|
||||
{
|
||||
assertTrue(depth <= 4, "accepted groups more than four deep");
|
||||
assertVersionTwoElements(element.path("children"), depth + 1, ids, scene);
|
||||
}
|
||||
if("text".equals(type))
|
||||
{
|
||||
assertVersionTwoText(element, scene);
|
||||
assertVersionTwoElements(element.path("children"), depth + 1, ids);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The independent reading of an accepted text element, as it stands at each
|
||||
** resolution: the scene's defaults, then the element, then the overrides
|
||||
** for that resolution in order. It names a bundled face, has a physical
|
||||
** size from 1 to 1000 pt, exactly one of a literal and a pointer into the
|
||||
** record, and only fit, length, threshold, rotation, reverse and line
|
||||
** height values of the documented kinds and ranges.
|
||||
***************************************************************************/
|
||||
private static void assertVersionTwoText(JsonNode element, JsonNode scene)
|
||||
{
|
||||
for(int dpi : List.of(203, 300, 600))
|
||||
{
|
||||
ObjectNode stated = INDEPENDENT.createObjectNode();
|
||||
scene.path("defaults").properties().forEach(property -> stated.set(property.getKey(), property.getValue()));
|
||||
element.properties().forEach(property -> stated.set(property.getKey(), property.getValue()));
|
||||
for(JsonNode override : scene.path("overrides"))
|
||||
{
|
||||
boolean applies = false;
|
||||
for(JsonNode resolution : override.path("when").path("dpi"))
|
||||
{
|
||||
applies |= resolution.intValue() == dpi;
|
||||
}
|
||||
if(applies)
|
||||
{
|
||||
override.path("set").path(element.path("id").textValue()).properties().forEach(property -> stated.set(property.getKey(), property.getValue()));
|
||||
}
|
||||
}
|
||||
String about = "accepted text " + element.path("id") + " at " + dpi + " dpi with ";
|
||||
assertTrue(stated.path("font").isTextual() && FACES.contains(stated.path("font").textValue()), () -> about + "font " + stated.path("font"));
|
||||
assertTrue(typeSize(stated.path("size"), 1, 1000), () -> about + "size " + stated.path("size"));
|
||||
assertTrue(stated.has("value") != stated.has("valuePath"), () -> about + "both or neither of value and valuePath");
|
||||
assertTrue(!stated.has("value") || (stated.get("value").isTextual() && stated.get("value").textValue().codePointCount(0, stated.get("value").textValue().length()) <= 1024), () -> about + "a value that is not text");
|
||||
assertTrue(!stated.has("valuePath") || (stated.get("valuePath").isTextual() && stated.get("valuePath").textValue().startsWith("/")), () -> about + "valuePath " + stated.path("valuePath"));
|
||||
JsonNode fit = stated.path("fit");
|
||||
assertTrue(fit.isMissingNode() || fit.isObject(), () -> about + "fit " + fit);
|
||||
fit.fieldNames().forEachRemaining(name -> assertTrue(Set.of("maxLines", "shrinkTo", "overflow").contains(name), () -> about + "fit property " + name));
|
||||
assertTrue(whole(fit.path("maxLines"), 1, 64) && whole(stated.path("threshold"), 1, 254) && whole(stated.path("maxLength"), 1, 1024), () -> about + "a count out of range");
|
||||
assertTrue(fit.path("shrinkTo").isMissingNode() || typeSize(fit.path("shrinkTo"), 1, 1000), () -> about + "shrinkTo " + fit.path("shrinkTo"));
|
||||
assertTrue(fit.path("overflow").isMissingNode() || Set.of("refuse", "truncate").contains(String.valueOf(fit.path("overflow").textValue())), () -> about + "overflow " + fit.path("overflow"));
|
||||
assertTrue(stated.path("rotation").isMissingNode() || (stated.path("rotation").isInt() && Set.of(0, 90, 180, 270).contains(stated.path("rotation").intValue())), () -> about + "rotation " + stated.path("rotation"));
|
||||
assertTrue(stated.path("reverse").isMissingNode() || stated.path("reverse").isBoolean(), () -> about + "reverse " + stated.path("reverse"));
|
||||
JsonNode lineHeight = stated.path("lineHeight");
|
||||
assertTrue(lineHeight.isMissingNode() || (lineHeight.isNumber() && lineHeight.doubleValue() >= 0.5 && lineHeight.doubleValue() <= 5), () -> about + "lineHeight " + lineHeight);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a value is absent or a whole number in a range.
|
||||
***************************************************************************/
|
||||
private static boolean whole(JsonNode value, int minimum, int maximum)
|
||||
{
|
||||
return (value.isMissingNode() || (value.isInt() && value.intValue() >= minimum && value.intValue() <= maximum));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether a value is a type size: an unsigned physical length between two
|
||||
** numbers of points, by decimal arithmetic with a hair of tolerance for the
|
||||
** renderer's rounding to thousandths of a point.
|
||||
***************************************************************************/
|
||||
private static boolean typeSize(JsonNode value, int minimum, int maximum)
|
||||
{
|
||||
Matcher matcher = TYPE_SIZE.matcher(value.isTextual() ? value.textValue() : "");
|
||||
if(!matcher.matches())
|
||||
{
|
||||
return (false);
|
||||
}
|
||||
BigDecimal points = new BigDecimal(matcher.group(1)).multiply(switch(matcher.group(2))
|
||||
{
|
||||
case "in" -> BigDecimal.valueOf(72);
|
||||
case "mm" -> BigDecimal.valueOf(72).divide(new BigDecimal("25.4"), 12, RoundingMode.HALF_EVEN);
|
||||
default -> BigDecimal.ONE;
|
||||
});
|
||||
return (points.compareTo(new BigDecimal(minimum).subtract(new BigDecimal("0.001"))) >= 0 && points.compareTo(new BigDecimal(maximum).add(new BigDecimal("0.001"))) <= 0);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The target: static validation of one scene on a stock all corpus scenes fit.
|
||||
***************************************************************************/
|
||||
|
||||
@@ -508,10 +508,9 @@ class Scene2Test
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Barcodes and images are parsed and placed by this renderer and then
|
||||
** Text, barcodes and images are parsed and placed by this renderer and then
|
||||
** refused by name, with the box they would have had. The design's own
|
||||
** example scene is the case: its barcode is refused, and its caption, a text
|
||||
** bound to the record, is refused for the value the empty record lacks.
|
||||
** example scene is the case.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void anElementThisRendererCannotDrawIsRefusedByName() throws Exception
|
||||
@@ -524,7 +523,7 @@ class Scene2Test
|
||||
+ "\"align\":\"center\",\"valign\":\"middle\",\"size\":\"11pt\",\"maxLength\":48,\"fit\":{\"maxLines\":2,\"shrinkTo\":\"7pt\",\"overflow\":\"refuse\"}}],"
|
||||
+ "\"overrides\":[{\"when\":{\"dpi\":[203]},\"set\":{\"caption\":{\"threshold\":144}}}]}";
|
||||
Map<String, Object> code = Map.of("code", "RENDERER_TOO_OLD", "severity", "ERROR", "element", "code", "box", List.of(6, 4, 751, 87));
|
||||
Map<String, Object> caption = Map.of("code", "VALUE_MISSING", "severity", "ERROR", "element", "caption", "box", List.of(6, 100, 751, 51));
|
||||
Map<String, Object> caption = Map.of("code", "RENDERER_TOO_OLD", "severity", "ERROR", "element", "caption", "box", List.of(6, 100, 751, 51));
|
||||
assertEquals(code, refused(example));
|
||||
assertEquals(code, SceneFixture.refusal(() -> RendererMain.process(SceneFixture.request("RENDER", example))));
|
||||
|
||||
@@ -540,10 +539,10 @@ class Scene2Test
|
||||
assertEquals(invalid("logo", "/elements/0/rotation"), refusedElements("{\"id\":\"logo\",\"type\":\"image\",\"asset\":\"logo\",\"rotation\":" + rotation + "}"), rotation);
|
||||
}
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "t", "pointer", "/elements/0", "measured", Map.of("unknownProperties", 1), "limit", Map.of("unknownProperties", 0)),
|
||||
refusedElements("{\"id\":\"t\",\"type\":\"text\",\"value\":\"TEST-0001\",\"font\":\"qqq-sans\",\"size\":\"9pt\",\"symbology\":\"qr\"}"));
|
||||
refusedElements("{\"id\":\"t\",\"type\":\"text\",\"value\":\"TEST-0001\",\"symbology\":\"qr\"}"));
|
||||
|
||||
assertEquals(Map.of("code", "SCENE_INVALID", "severity", "ERROR", "element", "t", "pointer", "/elements/0", "measured", Map.of("widthDots", 0, "heightDots", 155), "limit", Map.of("widthDots", 1, "heightDots", 1)),
|
||||
refusedElements("{\"id\":\"t\",\"type\":\"text\",\"value\":\"TEST-0001\",\"font\":\"qqq-sans\",\"size\":\"9pt\",\"width\":\"0.1mm\"}"));
|
||||
refusedElements("{\"id\":\"t\",\"type\":\"text\",\"value\":\"TEST-0001\",\"width\":\"0.1mm\"}"));
|
||||
|
||||
Diagnostics diagnostics = new Diagnostics(false);
|
||||
diagnostics.declare("t", "/elements/0");
|
||||
@@ -555,7 +554,7 @@ class Scene2Test
|
||||
assertEquals(144, source.value("threshold").intValue());
|
||||
assertNull(source.value("size"));
|
||||
assertEquals(new Placement(Placement.Anchor.TOP_LEFT, Length.ZERO, Length.ZERO, null, null, Placement.Align.LEFT, Placement.Valign.TOP), source.placement());
|
||||
SceneElement placeholder = ElementFactory.FACTORIES.get("image").create(source);
|
||||
SceneElement placeholder = ElementFactory.FACTORIES.get("text").create(source);
|
||||
assertEquals("t", placeholder.id());
|
||||
assertEquals(List.of(), placeholder.children());
|
||||
assertEquals(Map.of("code", "RENDERER_TOO_OLD", "severity", "ERROR", "element", "t", "box", List.of(1, 2, 3, 4)),
|
||||
@@ -575,8 +574,8 @@ class Scene2Test
|
||||
{
|
||||
String source = SceneFixture.scene("{\"id\":\"good\",\"type\":\"box\",\"x\":\"5dot\",\"width\":\"16dot\",\"height\":\"4dot\",\"fill\":true},"
|
||||
+ "{\"id\":\"wide\",\"type\":\"box\",\"x\":\"90mm\",\"width\":\"10mm\",\"height\":\"5mm\",\"thickness\":\"1pt\"},"
|
||||
+ "{\"id\":\"g\",\"type\":\"group\",\"width\":\"0.1mm\",\"children\":[{\"id\":\"unseen\",\"type\":\"barcode\",\"value\":\"TEST-0001\"}]},"
|
||||
+ "{\"id\":\"words\",\"type\":\"barcode\",\"value\":\"TEST-0001\",\"y\":\"10mm\",\"height\":\"5mm\"},"
|
||||
+ "{\"id\":\"g\",\"type\":\"group\",\"width\":\"0.1mm\",\"children\":[{\"id\":\"unseen\",\"type\":\"text\",\"value\":\"TEST-0001\"}]},"
|
||||
+ "{\"id\":\"words\",\"type\":\"text\",\"value\":\"TEST-0001\",\"y\":\"10mm\",\"height\":\"5mm\"},"
|
||||
+ "{\"id\":\"also\",\"type\":\"box\",\"anchor\":\"bottom-left\",\"width\":\"8dot\",\"height\":\"2dot\",\"fill\":true}");
|
||||
List<Map<String, Object>> expected = List.of(
|
||||
Map.of("code", "DEVICE_UNIT_USED", "severity", "WARNING", "element", "good", "pointer", "/elements/0/x"),
|
||||
|
||||
@@ -48,14 +48,8 @@ import static org.junit.jupiter.api.Assertions.fail;
|
||||
** Golden labels for the layout cases in `scene2/cases`, at 203, 300 and 600
|
||||
** dpi. Each case has a picture (the page a printer would draw, compared dot
|
||||
** for dot) and the exact ZPL. A box is whole dots of pure ink, so both are
|
||||
** exact on every machine. Text is measured by integer arithmetic and drawn
|
||||
** by the Java runtime's rasteriser from the bundled faces only; the text
|
||||
** goldens were generated on Linux (Temurin 21.0.9, the CI image) and are the
|
||||
** same bytes on Windows (Temurin 21.0.12), and they are compared exactly on
|
||||
** both. A golden changes only with a reviewed change to the layout rules or
|
||||
** the runtime: what this build drew is left in `target/scene2-actual`. The
|
||||
** runtimes, and the FreeType each ships, are recorded beside the goldens
|
||||
** (`scene2/README.md`).
|
||||
** exact on every machine. A golden changes only with a reviewed change to the
|
||||
** layout rules: what this build drew is left in `target/scene2-actual`.
|
||||
*******************************************************************************/
|
||||
class SceneGoldenTest
|
||||
{
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.renderer.scene2.text;
|
||||
|
||||
|
||||
import java.awt.Font;
|
||||
import java.awt.Rectangle;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
import com.kingsrook.qbits.renderer.scene2.Scene2;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The bundled faces: what ships, under which licence, with which hash, and
|
||||
** that the numbers layout relies on are the font files' own.
|
||||
*******************************************************************************/
|
||||
class FaceTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** A resource of the renderer jar, or null.
|
||||
***************************************************************************/
|
||||
private static byte[] resource(String path) throws Exception
|
||||
{
|
||||
try(InputStream stream = Face.class.getResourceAsStream(path))
|
||||
{
|
||||
return (stream == null ? null : stream.readAllBytes());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The lowercase hexadecimal SHA-256 of some bytes.
|
||||
***************************************************************************/
|
||||
private static String sha256(byte[] bytes) throws Exception
|
||||
{
|
||||
return (HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every character of the Basic Multilingual Plane a face draws.
|
||||
***************************************************************************/
|
||||
static List<Integer> drawn(Face face)
|
||||
{
|
||||
List<Integer> drawn = new ArrayList<>();
|
||||
for(int codePoint = 0; codePoint <= Character.MAX_VALUE; codePoint++)
|
||||
{
|
||||
if(face.draws(codePoint))
|
||||
{
|
||||
drawn.add(codePoint);
|
||||
}
|
||||
}
|
||||
return (drawn);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The set is the five faces the design names, by the names it gives them,
|
||||
** and no other name is a face.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theBundledSetIsFiveNamedFaces()
|
||||
{
|
||||
assertEquals(List.of("qqq-sans", "qqq-sans-bold", "qqq-condensed-bold", "qqq-mono", "qqq-mono-bold"), Face.all().stream().map(Face::scene).toList());
|
||||
assertEquals(List.of(false, true, true, false, true), Face.all().stream().map(Face::bold).toList());
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
assertEquals(face, Face.of(face.scene()));
|
||||
assertTrue(face.scene().startsWith("qqq-"), "a bundled face has the reserved prefix");
|
||||
}
|
||||
for(String other : List.of("", "qqq-", "qqq-serif", "QQQ-SANS", "qqq-sans ", "Abel", "IBM Plex Sans"))
|
||||
{
|
||||
assertNull(Face.of(other), other);
|
||||
}
|
||||
assertNull(Face.of(null));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every bundled face ships with its licence text, a provenance note and a
|
||||
** hash, and the bytes in the jar are the bytes that were hashed. The build
|
||||
** fails when any of the three is missing or a file changes.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyBundledFaceHasItsLicenceProvenanceAndAMatchingHash() throws Exception
|
||||
{
|
||||
Map<String, String> sums = new HashMap<>();
|
||||
for(String line : new String(resource("/fonts/SHA256SUMS"), StandardCharsets.US_ASCII).lines().toList())
|
||||
{
|
||||
assertTrue(line.matches("[0-9a-f]{64} [A-Za-z0-9./-]+"), line);
|
||||
assertNull(sums.put(line.substring(66), line.substring(0, 64)), "listed twice: " + line);
|
||||
}
|
||||
Set<String> licences = Set.of("ibm-plex-sans/LICENSE.txt", "ibm-plex-sans-condensed/LICENSE.txt", "jetbrains-mono/OFL.txt");
|
||||
assertEquals(Face.all().size() + licences.size(), sums.size(), "the list is exactly the faces and their licences");
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
String directory = face.file().substring(0, face.file().indexOf('/') + 1);
|
||||
byte[] font = resource("/fonts/" + face.file());
|
||||
assertNotNull(font, face.file());
|
||||
assertTrue(font[0] == 0 && font[1] == 1 && font[2] == 0 && font[3] == 0, "static TrueType outlines: " + face.file());
|
||||
assertEquals(sums.get(face.file()), sha256(font), face.file());
|
||||
|
||||
String licence = licences.stream().filter(name -> name.startsWith(directory)).findFirst().orElseThrow();
|
||||
byte[] text = resource("/fonts/" + licence);
|
||||
assertNotNull(text, licence);
|
||||
assertEquals(sums.get(licence), sha256(text), licence);
|
||||
String words = new String(text, StandardCharsets.UTF_8);
|
||||
assertTrue(words.contains("This Font Software is licensed under the SIL Open Font License, Version 1.1."), licence);
|
||||
assertTrue(words.contains("SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007") && words.contains("PERMISSION & CONDITIONS") && words.contains("TERMINATION"), "the whole licence text: " + licence);
|
||||
|
||||
byte[] provenance = resource("/fonts/" + directory + "SOURCE.md");
|
||||
assertNotNull(provenance, "provenance of " + face.file());
|
||||
String note = new String(provenance, StandardCharsets.UTF_8);
|
||||
String name = face.file().substring(directory.length());
|
||||
assertTrue(note.contains("`" + name + "`") && note.contains(sums.get(face.file())) && note.contains(sums.get(licence)), "the provenance names the file and both hashes: " + name);
|
||||
assertTrue(note.contains("https://github.com/") && note.contains("/releases/download/") && note.contains("Archive SHA-256") && (note.contains("Unmodified") || note.contains("unmodified")), name);
|
||||
assertTrue(note.contains("`" + face.scene() + "`"), "the provenance gives the scene name " + face.scene());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A file that is not a font is a broken build and says so; it is never a
|
||||
** silent fallback to another face.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aFileThatIsNotAFontIsABrokenBuild()
|
||||
{
|
||||
assertEquals("BUNDLED_FONT_UNREADABLE", assertThrows(IllegalStateException.class, () -> Face.load("/fonts/SHA256SUMS")).getMessage());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The bounds each face states are the font file's: the widest character
|
||||
** (the lowest of those that share the greatest advance, among characters
|
||||
** with ink), and the furthest any character reaches past its advance on the
|
||||
** left and right, above the baseline and below it. They are what a length
|
||||
** is proven against, so they are recomputed here from every character.
|
||||
**
|
||||
** A character with no ink still moves the pen (a no-break space, an em
|
||||
** quad), so the greatest advance is taken over every character the face
|
||||
** draws, inked or not, and the widest inked character must have it: were a
|
||||
** font ever to carry a blank wider than its widest letter, a line of that
|
||||
** blank would be wider than the proof allows, and this fails.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theBoundsOfAFaceAreThoseOfItsFontFile()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
int widest = -1;
|
||||
int advance = 0;
|
||||
int inked = 0;
|
||||
int blanks = 0;
|
||||
int left = 0;
|
||||
int right = 0;
|
||||
int top = 0;
|
||||
int bottom = 0;
|
||||
List<Integer> drawn = drawn(face);
|
||||
for(int codePoint : drawn)
|
||||
{
|
||||
Face.Glyph glyph = face.glyph(codePoint);
|
||||
assertTrue(glyph.advance() >= 0 && glyph.advance() <= 2 * Face.REFERENCE, "advance of " + codePoint);
|
||||
assertEquals(glyph.space(), codePoint == ' ');
|
||||
advance = Math.max(advance, glyph.advance());
|
||||
if(!glyph.ink())
|
||||
{
|
||||
assertEquals(new Face.Glyph(glyph.advance(), 0, 0, 0, 0, false, codePoint == ' '), glyph);
|
||||
blanks++;
|
||||
continue;
|
||||
}
|
||||
if(glyph.advance() > inked)
|
||||
{
|
||||
inked = glyph.advance();
|
||||
widest = codePoint;
|
||||
}
|
||||
left = Math.max(left, -glyph.left());
|
||||
right = Math.max(right, glyph.right() - glyph.advance());
|
||||
top = Math.max(top, glyph.top());
|
||||
bottom = Math.max(bottom, glyph.bottom());
|
||||
}
|
||||
assertTrue(drawn.size() > 800, face.scene() + " draws " + drawn.size() + " characters");
|
||||
assertTrue(blanks >= 2 && face.draws(0xA0) && !face.glyph(0xA0).ink() && face.glyph(0xA0).advance() > 0, face.scene() + " draws " + blanks + " characters with no ink, the no-break space among them");
|
||||
assertEquals(advance, inked, face.scene() + ": no character without ink advances further than the widest with ink");
|
||||
assertEquals(new Face.Glyph(advance, -left, advance + right, top, bottom, true, false), face.worst(), face.scene());
|
||||
assertEquals(widest, face.widest(), face.scene());
|
||||
assertEquals(face.glyph(face.widest()).advance(), face.worst().advance());
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// the faces have 1000 units to the em, so the reference size reads whole numbers //
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
assertTrue(face.ascent() > 900 && face.ascent() < 1100 && face.descent() > 200 && face.descent() < 350 && face.leading() == 0, face.scene());
|
||||
assertTrue(face.capHeight() > 650 && face.capHeight() < 760, face.scene());
|
||||
}
|
||||
assertEquals(List.of(1025, 275, 698), List.of(Face.SANS.ascent(), Face.SANS.descent(), Face.SANS.capHeight()));
|
||||
assertEquals(List.of(1020, 300, 730), List.of(Face.MONO_BOLD.ascent(), Face.MONO_BOLD.descent(), Face.MONO_BOLD.capHeight()));
|
||||
assertEquals(new Face.Glyph(600, 0, 0, 0, 0, false, true), Face.MONO.glyph(' '));
|
||||
assertEquals(Set.of(600), drawn(Face.MONO).stream().map(codePoint -> Face.MONO.glyph(codePoint).advance()).collect(Collectors.toSet()), "the monospace has one advance");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A face draws a character only as one glyph of its own, left to right.
|
||||
** Controls, formats (which would print nothing), marks that must be placed
|
||||
** on another letter, scripts that need shaping, and characters outside the
|
||||
** Basic Multilingual Plane are not drawn; nor is a character the face lacks.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aFaceDrawsOnlyWhatItCanDrawFaithfully()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
for(int codePoint = 0x20; codePoint < 0x7F; codePoint++)
|
||||
{
|
||||
assertTrue(face.draws(codePoint), face.scene() + " draws " + codePoint);
|
||||
}
|
||||
assertTrue(face.draws(TextFit.MARK), "the ellipsis that marks a cut");
|
||||
assertTrue(face.draws(0xE9) && face.draws(0xC5) && face.draws(0xA0) && face.draws(0x20AC));
|
||||
for(int codePoint : List.of(0x00, 0x09, 0x0A, 0x0D, 0x7F, 0x85, 0xAD, 0x200B, 0x200D, 0x202E, 0x2028, 0x2029, 0xFEFF, 0x0301, 0x0627, 0x05D0, 0x0915, 0x4E2D, 0xD83D, 0xE0A0, 0xFFFF, 0x1F600, 0x10FFFF))
|
||||
{
|
||||
assertFalse(face.draws(codePoint), face.scene() + " must not draw " + Integer.toHexString(codePoint));
|
||||
}
|
||||
assertEquals(0, face.missing("TEST-0001 (01)00012345678905 café")); // e acute
|
||||
assertEquals(0, face.missing(""));
|
||||
assertEquals(4, face.missing("A\tB́😀C中")); // a tab, a combining acute, one emoji (two units, one character), a CJK ideograph
|
||||
assertEquals(3, face.glyphs("A B").length);
|
||||
assertTrue(face.glyphs("A B")[1].space() && !face.glyphs("A B")[0].space());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The rasteriser keeps every glyph inside the box layout measures for it:
|
||||
** the outline at the reference size, scaled exactly, and `TextFit.SLOP`
|
||||
** dots. Held for every character of every face over the sizes and
|
||||
** resolutions labels use, and at the ends of what the renderer accepts:
|
||||
** 1 pt and 2 pt, where a glyph is a few dots, and 300 pt and 1000 pt,
|
||||
** where it is drawn from its outline. (That the ink itself stays inside is
|
||||
** held where text is drawn: `TextRasterTest`.)
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyGlyphIsRasterisedInsideItsMeasuredBox()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
List<Integer> drawn = drawn(face);
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
for(int milliPoints : List.of(1000, 2000, 5000, 6250, 7000, 8000, 9000, 10_500, 12_000, 18_000, 36_000, 72_000, 300_000, 1_000_000))
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(milliPoints, dpi);
|
||||
Font font = face.font(scale.size());
|
||||
for(int codePoint : drawn)
|
||||
{
|
||||
Face.Glyph glyph = face.glyph(codePoint);
|
||||
Rectangle pixels = font.createGlyphVector(Face.CONTEXT, new char[] {(char) codePoint}).getGlyphPixelBounds(0, Face.CONTEXT, 0, 0);
|
||||
if(!glyph.ink())
|
||||
{
|
||||
assertTrue(pixels.isEmpty(), "ink where none is measured");
|
||||
continue;
|
||||
}
|
||||
boolean inside = pixels.x >= scale.floor(glyph.left()) - TextFit.SLOP && pixels.x + pixels.width <= scale.ceil(glyph.right()) + TextFit.SLOP
|
||||
&& -pixels.y <= scale.ceil(glyph.top()) + TextFit.SLOP && pixels.y + pixels.height <= scale.ceil(glyph.bottom()) + TextFit.SLOP;
|
||||
assertTrue(inside, () -> face.scene() + " U+" + Integer.toHexString(codePoint) + " at " + milliPoints + " millipoints, " + dpi + " dpi: " + pixels + " outside " + glyph);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,871 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.renderer.scene2.text;
|
||||
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import com.kingsrook.qbits.renderer.scene2.Scene2;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** The fit rules, by example and as properties over seeded, deterministic
|
||||
** generators (fixed seeds, no clock): text, face, box, size, floor, lines and
|
||||
** resolution.
|
||||
*******************************************************************************/
|
||||
class TextFitTest
|
||||
{
|
||||
static final long[] SEEDS = {20261112L, 7L, 4242L};
|
||||
static final int CASES = 600;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One generated case.
|
||||
***************************************************************************/
|
||||
record Case(TextFit.Request request, String text)
|
||||
{
|
||||
/************************************************************************
|
||||
** The same case with another nominal size and floor, and no cutting.
|
||||
************************************************************************/
|
||||
Case at(int nominal, int floor)
|
||||
{
|
||||
TextFit.Request from = request;
|
||||
return (new Case(new TextFit.Request(from.face(), from.width(), from.height(), from.dpi(), nominal, floor, from.lineHeight(), from.maxLines(), false), text));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** The same case for another text.
|
||||
************************************************************************/
|
||||
Case of(String other)
|
||||
{
|
||||
return (new Case(request, other));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/************************************************************************
|
||||
** Fit it.
|
||||
************************************************************************/
|
||||
TextFit.Block fit()
|
||||
{
|
||||
return (TextFit.fit(request, request.face().glyphs(text), request.face().glyph(TextFit.MARK)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A request in a box, with the face's own line pitch, one line, no cutting.
|
||||
***************************************************************************/
|
||||
static TextFit.Request request(Face face, int width, int height, int nominal, int floor)
|
||||
{
|
||||
return (new TextFit.Request(face, width, height, 203, nominal, floor, null, 1, false));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The same request with more lines, and cutting or not.
|
||||
***************************************************************************/
|
||||
static TextFit.Request lines(TextFit.Request request, int maxLines, boolean truncate)
|
||||
{
|
||||
return (new TextFit.Request(request.face(), request.width(), request.height(), request.dpi(), request.nominal(), request.floor(), request.lineHeight(), maxLines, truncate));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text of a face's own characters.
|
||||
***************************************************************************/
|
||||
static TextFit.Block fit(TextFit.Request request, String text)
|
||||
{
|
||||
return (new Case(request, text).fit());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The characters of each line, with `~` where the ellipsis is.
|
||||
***************************************************************************/
|
||||
static List<String> shown(TextFit.Block block, String text)
|
||||
{
|
||||
return (block.lines().stream().map(line -> text.substring(line.from(), line.to()) + (line.marked() ? "~" : "")).toList());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A generated case: labels are small boxes, short values and type from 5 to
|
||||
** 30 pt. A third of the texts have no space, as identifiers do.
|
||||
***************************************************************************/
|
||||
static Case generate(Random random)
|
||||
{
|
||||
Face face = Face.all().get(random.nextInt(Face.all().size()));
|
||||
int dpi = Scene2.RESOLUTIONS.get(random.nextInt(3));
|
||||
int nominal = 5000 + random.nextInt(101) * 250 + (random.nextInt(4) == 0 ? random.nextInt(250) : 0);
|
||||
int floor = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> nominal;
|
||||
case 1 -> Math.max(1000, nominal * 7 / 10);
|
||||
default -> 1000 + random.nextInt(nominal - 999);
|
||||
};
|
||||
Integer lineHeight = random.nextInt(3) == 0 ? Integer.valueOf(700 + random.nextInt(1500)) : null;
|
||||
int width = (20 + random.nextInt(700)) * dpi / 203;
|
||||
int height = (8 + random.nextInt(160)) * dpi / 203;
|
||||
String alphabet = switch(random.nextInt(3))
|
||||
{
|
||||
case 0 -> "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-()/.";
|
||||
case 1 -> "abcdefghijklmnopqrstuvwxyzWMfjgyÅé‰ "; // A ring, e acute, per mille
|
||||
default -> "il1.,' WM@‰⁄ǺĢ "; // per mille, fraction slash, A ring acute, G cedilla: the widest and furthest-reaching
|
||||
};
|
||||
StringBuilder text = new StringBuilder();
|
||||
int length = random.nextInt(48);
|
||||
boolean spaces = random.nextInt(3) != 0;
|
||||
for(int index = 0; index < length; index++)
|
||||
{
|
||||
text.append(spaces && random.nextInt(6) == 0 ? ' ' : alphabet.charAt(random.nextInt(alphabet.length())));
|
||||
}
|
||||
return (new Case(new TextFit.Request(face, width, height, dpi, nominal, floor, lineHeight, 1 + random.nextInt(4), random.nextInt(3) == 0), text.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The search `fit` must agree with, made in full: every quarter-point step
|
||||
** from the nominal size down to the floor, each laid out between words and
|
||||
** then at any character, the first that fits taken. This is the search the
|
||||
** fit was first written as. It shares with `fit` only the layout at one
|
||||
** size (`TextFit.at`), and none of what `fit` uses to skip sizes.
|
||||
***************************************************************************/
|
||||
static TextFit.Block linear(TextFit.Request request, Face.Glyph[] glyphs, Face.Glyph mark, TextFit.Work work)
|
||||
{
|
||||
int count = glyphs.length;
|
||||
while(count > 0 && glyphs[count - 1].space())
|
||||
{
|
||||
count--;
|
||||
}
|
||||
int size = request.nominal();
|
||||
while(true)
|
||||
{
|
||||
TextFit.Block block = TextFit.at(request, TextFit.Leading.of(request), size, glyphs, count, mark, work);
|
||||
if(block.fits() || size == request.floor())
|
||||
{
|
||||
return (block);
|
||||
}
|
||||
size = Math.max(request.floor(), size - TextFit.STEP_MILLIPOINTS);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A generated case over everything a scene may state: type from 1 pt to
|
||||
** 1000 pt with a floor anywhere under it, boxes from a few dots to a large
|
||||
** label, up to 64 lines, any line height, and texts of up to 160
|
||||
** characters, some without a space, some of the widest and
|
||||
** furthest-reaching characters, some led or followed by spaces.
|
||||
***************************************************************************/
|
||||
static Case wide(Random random)
|
||||
{
|
||||
Face face = Face.all().get(random.nextInt(Face.all().size()));
|
||||
int dpi = Scene2.RESOLUTIONS.get(random.nextInt(3));
|
||||
int nominal = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> 1000 + random.nextInt(9001);
|
||||
case 1 -> 5000 + random.nextInt(30_001);
|
||||
case 2 -> 20_000 + random.nextInt(280_001);
|
||||
default -> 1000 + random.nextInt(999_001);
|
||||
};
|
||||
int floor = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> 1000;
|
||||
case 1 -> Math.max(1000, nominal / 10);
|
||||
case 2 -> Math.max(1000, nominal - random.nextInt(4000));
|
||||
default -> 1000 + random.nextInt(nominal - 999);
|
||||
};
|
||||
Integer lineHeight = random.nextInt(3) == 0 ? Integer.valueOf(500 + random.nextInt(4501)) : null;
|
||||
int width = switch(random.nextInt(3))
|
||||
{
|
||||
case 0 -> 1 + random.nextInt(60);
|
||||
case 1 -> 20 + random.nextInt(700);
|
||||
default -> 200 + random.nextInt(6000);
|
||||
};
|
||||
int height = switch(random.nextInt(3))
|
||||
{
|
||||
case 0 -> 1 + random.nextInt(40);
|
||||
case 1 -> 8 + random.nextInt(300);
|
||||
default -> 100 + random.nextInt(4000);
|
||||
};
|
||||
String alphabet = switch(random.nextInt(4))
|
||||
{
|
||||
case 0 -> "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-()/.";
|
||||
case 1 -> "abcdefghijklmnopqrstuvwxyzWMfjgyÅé‰ "; // A ring, e acute, per mille
|
||||
case 2 -> "il1.,' WM@‰⁄ǺĢ "; // per mille, fraction slash, A ring acute, G cedilla: the widest and furthest-reaching
|
||||
default -> new String(Character.toChars(face.widest())) + "j "; // the widest, a letter that starts left of the pen, a no-break space
|
||||
};
|
||||
StringBuilder text = new StringBuilder(random.nextInt(5) == 0 ? " " : "");
|
||||
int length = random.nextInt(4) == 0 ? random.nextInt(160) : random.nextInt(40);
|
||||
int spaces = random.nextInt(3);
|
||||
for(int index = 0; index < length; index++)
|
||||
{
|
||||
text.append(spaces > 0 && random.nextInt(spaces == 1 ? 6 : 2) == 0 ? ' ' : alphabet.charAt(random.nextInt(alphabet.length())));
|
||||
}
|
||||
return (new Case(new TextFit.Request(face, width, height, dpi, nominal, floor, lineHeight, 1 + random.nextInt(random.nextInt(3) == 0 ? 64 : 4), random.nextInt(3) == 0), text.toString()));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Dots are thousandths of the em times the size times the resolution over
|
||||
** 72 000 000, rounded down, up or to the nearest: checked against exact
|
||||
** decimal arithmetic, negative lengths included.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theScaleRoundsExactly()
|
||||
{
|
||||
Random random = new Random(SEEDS[0]);
|
||||
for(int index = 0; index < 5000; index++)
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(1000 + random.nextInt(999_001), Scene2.RESOLUTIONS.get(random.nextInt(3)));
|
||||
long units = random.nextInt(3_000_001) - 1_500_000L;
|
||||
BigDecimal exact = BigDecimal.valueOf(units).multiply(BigDecimal.valueOf(scale.milliPoints())).multiply(BigDecimal.valueOf(scale.dpi())).divide(BigDecimal.valueOf(72_000_000L), 12, RoundingMode.HALF_EVEN);
|
||||
assertEquals(exact.setScale(0, RoundingMode.FLOOR).intValueExact(), scale.floor(units));
|
||||
assertEquals(exact.setScale(0, RoundingMode.CEILING).intValueExact(), scale.ceil(units));
|
||||
assertEquals(exact.add(new BigDecimal("0.5")).setScale(0, RoundingMode.FLOOR).intValueExact(), scale.round(units));
|
||||
}
|
||||
assertEquals(25.375f, new TextFit.Scale(9000, 203).size());
|
||||
assertEquals(100f, new TextFit.Scale(12_000, 600).size());
|
||||
assertEquals(List.of(25, 26, 25), List.of(new TextFit.Scale(9000, 203).floor(1000), new TextFit.Scale(9000, 203).ceil(1000), new TextFit.Scale(9000, 203).round(1000)));
|
||||
assertEquals(List.of(-26, -25, -25), List.of(new TextFit.Scale(9000, 203).floor(-1000), new TextFit.Scale(9000, 203).ceil(-1000), new TextFit.Scale(9000, 203).round(-1000)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The default: one line. A text that fits at its size keeps it; one that
|
||||
** does not is reduced a quarter point at a time to the largest size that
|
||||
** fits; one that does not fit at the floor is refused with what it
|
||||
** measured there.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void oneLineShrinksToTheLargestSizeThatFitsThenRefuses()
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 9 pt at 203 dpi is 25.375 dots to the em and the monospace advances 0.6 em: 12 letters //
|
||||
// are 183 dots, and the line is 2 dots wider on each inked side //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block fits = fit(request(Face.MONO_BOLD, 200, 40, 9000, 6300), "ABCDEFGHIJKL");
|
||||
assertTrue(fits.fits() && !fits.truncated());
|
||||
assertEquals(9000, fits.milliPoints());
|
||||
assertEquals(List.of("ABCDEFGHIJKL"), shown(fits, "ABCDEFGHIJKL"));
|
||||
assertEquals(12, fits.characters());
|
||||
assertTrue(fits.width() >= 183 && fits.width() <= 187, "width " + fits.width());
|
||||
assertEquals(fits.width(), fits.lines().get(0).width());
|
||||
assertEquals(List.of(26, 34), List.of(fits.baseline(), fits.height()), "ascent 1.02 em and descent 0.3 em, each rounded up");
|
||||
|
||||
TextFit.Block shrunk = fit(request(Face.MONO_BOLD, 160, 40, 9000, 6300), "ABCDEFGHIJKL");
|
||||
assertTrue(shrunk.fits() && shrunk.milliPoints() < 9000 && shrunk.milliPoints() >= 6300 && shrunk.milliPoints() % 250 == 0);
|
||||
assertTrue(shrunk.width() <= 160);
|
||||
assertFalse(fit(request(Face.MONO_BOLD, 160, 40, shrunk.milliPoints() + 250, shrunk.milliPoints() + 250), "ABCDEFGHIJKL").fits(), "a quarter point larger does not fit");
|
||||
|
||||
TextFit.Block refused = fit(request(Face.MONO_BOLD, 100, 40, 9000, 6300), "ABCDEFGHIJKL");
|
||||
assertFalse(refused.fits());
|
||||
assertEquals(6300, refused.milliPoints());
|
||||
assertTrue(refused.lines().size() > 1 && refused.width() <= 100, "measured broken at any character: " + refused);
|
||||
assertEquals(12, refused.shown());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the floor need not be on the quarter-point grid from the size: it is the last size //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block floor = fit(request(Face.MONO_BOLD, 134, 40, 9000, 6400), "ABCDEFGHIJKL");
|
||||
assertTrue(floor.fits());
|
||||
assertEquals(6400, floor.milliPoints());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// too tall is refused as too wide is: the box is 20 dots high and the floor needs 25 //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block tall = fit(request(Face.MONO_BOLD, 400, 20, 9000, 6300), "ABC");
|
||||
assertFalse(tall.fits());
|
||||
assertTrue(tall.height() > 20 && tall.lines().size() == 1);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Nothing to draw always fits: an empty value, or one of spaces only.
|
||||
** Spaces at the end of a text take no room; spaces at its start do.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void spacesAtTheEndTakeNoRoom()
|
||||
{
|
||||
for(String blank : List.of("", " ", " "))
|
||||
{
|
||||
TextFit.Block block = fit(request(Face.SANS, 1, 1, 9000, 9000), blank);
|
||||
assertTrue(block.fits() && block.lines().isEmpty() && !block.truncated());
|
||||
assertEquals(List.of(0, 0, 0, 0), List.of(block.width(), block.height(), block.characters(), block.shown()));
|
||||
}
|
||||
TextFit.Request request = request(Face.SANS_BOLD, 300, 40, 9000, 9000);
|
||||
TextFit.Block plain = fit(request, "AB");
|
||||
assertEquals(plain, fit(request, "AB "));
|
||||
TextFit.Block led = fit(request, " AB");
|
||||
int shift = new TextFit.Scale(9000, 203).round(2L * Face.SANS_BOLD.glyph(' ').advance());
|
||||
assertTrue(shift > 8 && led.width() >= plain.width() + shift - 2 * TextFit.SLOP && led.width() <= plain.width() + shift + 1, plain.width() + " then " + led.width());
|
||||
assertEquals(4, led.characters());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** With more lines allowed, a line is broken between words while that fits,
|
||||
** and at any character when it does not: a larger size is preferred to
|
||||
** whole words, and a value with no space wraps.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void linesBreakBetweenWordsAndThenAtAnyCharacter()
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the monospace at 9 pt, 203 dpi advances 15.225 dots: a 100 dot box holds 6 letters //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Request box = request(Face.MONO, 100, 200, 9000, 9000);
|
||||
assertEquals(List.of("AAA", "BBB", "CCC"), shown(fit(lines(box, 3, false), "AAA BBB CCC"), "AAA BBB CCC"));
|
||||
assertEquals(List.of("AAA BB", "CCC"), shown(fit(lines(box, 2, false), "AAA BB CCC"), "AAA BB CCC"), "spaces at a break are dropped");
|
||||
assertEquals(List.of("AAA BB", "B CCC"), shown(fit(lines(box, 2, false), "AAA BBB CCC"), "AAA BBB CCC"), "two lines only when broken at any character");
|
||||
assertEquals(List.of("L24061", "1A-RET", "URNED"), shown(fit(lines(box, 3, false), "L240611A-RETURNED"), "L240611A-RETURNED"));
|
||||
assertEquals(9000, fit(lines(box, 2, false), "AAA BBB CCC").milliPoints());
|
||||
|
||||
TextFit.Block refused = fit(lines(box, 2, false), "L240611A-RETURNED");
|
||||
assertFalse(refused.fits());
|
||||
assertEquals(3, refused.lines().size());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a stated line height sets the distance between baselines; the block is one ascent, //
|
||||
// the pitches between, and one descent //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block natural = fit(lines(box, 3, false), "AAA BBB CCC");
|
||||
assertEquals(List.of(34, 26, 26 + 2 * 34 + 8), List.of(natural.pitch(), natural.baseline(), natural.height()));
|
||||
TextFit.Block tight = TextFit.fit(new TextFit.Request(Face.MONO, 100, 200, 203, 9000, 9000, 1000, 3, false), Face.MONO.glyphs("AAA BBB CCC"), null);
|
||||
assertEquals(List.of(26, 26, 26 + 2 * 26 + 8), List.of(tight.pitch(), tight.baseline(), tight.height()));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a character wider than the box stands alone on a line that does not fit //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block narrow = fit(lines(request(Face.MONO, 10, 200, 9000, 9000), 4, false), "AB");
|
||||
assertFalse(narrow.fits());
|
||||
assertEquals(List.of("A", "B"), shown(narrow, "AB"));
|
||||
assertTrue(narrow.width() > 10);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Cutting happens only when the element asked for it and only when the text
|
||||
** fits at no size down to the floor. The last line that fits ends with the
|
||||
** ellipsis after as many characters as fit beside it; when not even the
|
||||
** ellipsis fits, the text is refused.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void truncationIsOnlyWhenAskedAndAlwaysShowsTheMark()
|
||||
{
|
||||
String text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||
TextFit.Request box = request(Face.MONO, 100, 80, 9000, 9000);
|
||||
assertFalse(fit(box, text).fits());
|
||||
assertFalse(fit(lines(box, 2, false), text).truncated());
|
||||
|
||||
TextFit.Block cut = fit(lines(box, 1, true), text);
|
||||
assertTrue(cut.fits() && cut.truncated());
|
||||
assertEquals(List.of("ABCDE~"), shown(cut, text));
|
||||
assertEquals(List.of(26, 5), List.of(cut.characters(), cut.shown()));
|
||||
assertTrue(cut.width() <= 100);
|
||||
|
||||
TextFit.Block two = fit(lines(box, 2, true), text);
|
||||
assertEquals(List.of("ABCDEF", "GHIJK~"), shown(two, text));
|
||||
assertEquals(11, two.shown());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the box is high enough for one line only: the second is given up for the mark //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Block low = fit(lines(request(Face.MONO, 100, 40, 9000, 9000), 3, true), text);
|
||||
assertEquals(List.of("ABCDE~"), shown(low, text));
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a text that fits is not cut and has no mark, though cutting is allowed; one that fits //
|
||||
// after shrinking is shrunk, not cut //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of("ABCDEF"), shown(fit(lines(box, 1, true), "ABCDEF"), "ABCDEF"));
|
||||
TextFit.Block shrunk = fit(lines(request(Face.MONO, 100, 80, 9000, 6000), 1, true), "ABCDEFGH");
|
||||
assertTrue(shrunk.fits() && !shrunk.truncated() && shrunk.milliPoints() < 9000);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// spaces before the mark are dropped; the mark may stand alone; and where it cannot //
|
||||
// stand at all, or a line is too high, the answer is the refusal //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of("ABC~"), shown(fit(lines(box, 1, true), "ABC DEFGHIJKL"), "ABC DEFGHIJKL"));
|
||||
assertEquals(List.of("~"), shown(fit(lines(request(Face.MONO, 22, 80, 9000, 9000), 1, true), text), text));
|
||||
assertFalse(fit(lines(request(Face.MONO, 12, 80, 9000, 9000), 1, true), text).fits());
|
||||
assertFalse(fit(lines(request(Face.MONO, 100, 20, 9000, 9000), 1, true), text).fits());
|
||||
assertFalse(fit(lines(request(Face.MONO, 400, 20, 9000, 9000), 1, true), "ABC").fits(), "a line that is too high is too high with the mark");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// the last line of a text is cut too when the text fits at no size: here one letter is //
|
||||
// wider than the box and the mark is not, and here an accent above the ascent is too //
|
||||
// high for the box and is given up for the mark //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of("~"), shown(fit(lines(request(Face.SANS, 26, 80, 9000, 9000), 1, true), "\u2030"), "\u2030")); // per mille
|
||||
TextFit.Request shallow = lines(request(Face.SANS_BOLD, 200, 36, 9000, 9000), 1, true);
|
||||
assertTrue(fit(shallow, "ABC").fits() && !fit(shallow, "ABC").truncated());
|
||||
assertEquals(List.of("AB~"), shown(fit(shallow, "AB\u01FA"), "AB\u01FA")); // A ring acute
|
||||
assertFalse(fit(lines(shallow, 1, false), "AB\u01FA").fits()); // A ring acute
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// broken at any character, spaces at the start of a text stay on a line of their own //
|
||||
// rather than push the first letter out of the box //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
assertEquals(List.of(" ", "ABCDE"), shown(fit(lines(box, 2, false), " ABCDE"), " ABCDE"));
|
||||
assertEquals(List.of(" ABCD", "E"), shown(fit(lines(box, 2, false), " ABCDE"), " ABCDE"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: the answer is a layout inside the box or a refusal whose
|
||||
** measurement exceeds its limit. The size is never above the nominal and
|
||||
** never below the floor; it is on the quarter-point steps from the nominal
|
||||
** or is the floor. The lines are the text in order, whole unless cut, and
|
||||
** never more than allowed. A text is cut only when asked, only at the
|
||||
** floor, and then its last line carries the mark.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theAnswerIsALayoutInsideTheBoxOrARefusalThatMeasuresMore()
|
||||
{
|
||||
int fitted = 0;
|
||||
int refused = 0;
|
||||
int cut = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
TextFit.Request request = generated.request();
|
||||
TextFit.Block block = generated.fit();
|
||||
String about = "seed " + seed + " case " + index + ": " + request + " for " + generated.text().length() + " characters gave " + block;
|
||||
String trimmed = generated.text().stripTrailing();
|
||||
assertEquals(trimmed.length(), block.characters(), about);
|
||||
assertTrue(block.milliPoints() <= request.nominal() && block.milliPoints() >= request.floor(), about);
|
||||
assertTrue(block.milliPoints() == request.floor() || (request.nominal() - block.milliPoints()) % TextFit.STEP_MILLIPOINTS == 0, about);
|
||||
int next = 0;
|
||||
for(int line = 0; line < block.lines().size(); line++)
|
||||
{
|
||||
TextFit.Line each = block.lines().get(line);
|
||||
assertTrue(trimmed.substring(next, each.from()).isBlank() && each.to() >= each.from() && each.left() <= 0 && each.width() <= block.width(), about);
|
||||
assertTrue(each.to() > each.from() || each.marked(), about);
|
||||
assertTrue(each.to() == each.from() || trimmed.charAt(each.to() - 1) != ' ' || !each.marked(), about + ": a space before the mark");
|
||||
assertEquals(each.marked(), block.truncated() && line == block.lines().size() - 1, about);
|
||||
next = each.to();
|
||||
}
|
||||
assertEquals(block.shown(), next, about);
|
||||
if(block.fits())
|
||||
{
|
||||
fitted++;
|
||||
assertTrue(block.lines().size() <= request.maxLines() && block.width() <= request.width() && block.height() <= request.height(), about);
|
||||
}
|
||||
else
|
||||
{
|
||||
refused++;
|
||||
assertTrue(block.lines().size() > request.maxLines() || block.width() > request.width() || block.height() > request.height(), about);
|
||||
assertEquals(request.floor(), block.milliPoints(), about);
|
||||
assertFalse(block.truncated(), about);
|
||||
}
|
||||
if(block.truncated())
|
||||
{
|
||||
cut++;
|
||||
assertTrue(request.truncate() && block.fits() && block.milliPoints() == request.floor() && block.shown() < block.characters(), about);
|
||||
assertFalse(generated.at(request.floor(), request.floor()).fit().fits(), about);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertEquals(trimmed.length(), block.shown(), about);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(fitted > 300 && refused > 300 && cut > 50, "the cases must reach every outcome: " + fitted + " fitted, " + refused + " refused, " + cut + " cut");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: a text that fits at its nominal size is never shrunk, and the
|
||||
** size chosen is the largest that fits: the step above it does not.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aTextThatFitsIsNeverShrunkAndTheChosenSizeIsTheLargestThatFits()
|
||||
{
|
||||
int kept = 0;
|
||||
int shrunk = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
TextFit.Request request = generated.request();
|
||||
TextFit.Block block = generated.fit();
|
||||
String about = "seed " + seed + " case " + index + ": " + request + " gave " + block;
|
||||
TextFit.Block nominal = generated.at(request.nominal(), request.nominal()).fit();
|
||||
if(nominal.fits())
|
||||
{
|
||||
kept++;
|
||||
assertEquals(nominal, block, about);
|
||||
assertEquals(request.nominal(), block.milliPoints(), about);
|
||||
}
|
||||
else if(block.fits() && !block.truncated())
|
||||
{
|
||||
shrunk++;
|
||||
assertTrue(block.milliPoints() < request.nominal(), about);
|
||||
int steps = (request.nominal() - block.milliPoints() - 1) / TextFit.STEP_MILLIPOINTS;
|
||||
int above = request.nominal() - steps * TextFit.STEP_MILLIPOINTS;
|
||||
assertTrue(above > block.milliPoints() && above - block.milliPoints() <= TextFit.STEP_MILLIPOINTS, about);
|
||||
assertFalse(generated.at(above, above).fit().fits(), about + ", yet it fits at " + above);
|
||||
assertEquals(block, generated.at(block.milliPoints(), block.milliPoints()).fit(), about);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(kept > 200 && shrunk > 100, "the cases must reach both: " + kept + " kept, " + shrunk + " shrunk");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: in the same box a shorter text never gets a smaller size than a
|
||||
** longer one that starts with it, and fits whenever the longer one does.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aShorterTextNeverGetsASmallerSizeThanALongerOne()
|
||||
{
|
||||
int compared = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
if(generated.text().isEmpty())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
Case all = generated.at(generated.request().nominal(), generated.request().floor());
|
||||
TextFit.Block longer = all.fit();
|
||||
TextFit.Block shorter = all.of(generated.text().substring(0, random.nextInt(generated.text().length()))).fit();
|
||||
String about = "seed " + seed + " case " + index + ": " + all.request() + " gave " + shorter + " and then " + longer;
|
||||
if(longer.fits())
|
||||
{
|
||||
compared++;
|
||||
assertTrue(shorter.fits() && shorter.milliPoints() >= longer.milliPoints() && shorter.lines().size() <= longer.lines().size(), about);
|
||||
}
|
||||
else
|
||||
{
|
||||
assertTrue(!shorter.fits() || shorter.milliPoints() >= longer.milliPoints(), about);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(compared > 300, "compared " + compared);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: when the longest value is proven to fit (that many of the
|
||||
** face's worst character), no text of up to that many characters is
|
||||
** refused, whatever its characters, with spaces or without, cut or not.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aProvenLengthImpliesNoRefusalForAnyTextUpToThatLength()
|
||||
{
|
||||
int proven = 0;
|
||||
int tried = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < CASES; index++)
|
||||
{
|
||||
Case generated = generate(random);
|
||||
TextFit.Request request = generated.request();
|
||||
Face face = request.face();
|
||||
int maxLength = 1 + random.nextInt(40);
|
||||
Face.Glyph[] worst = new Face.Glyph[maxLength];
|
||||
Arrays.fill(worst, face.worst());
|
||||
TextFit.Block proof = TextFit.fit(request, worst, face.glyph(TextFit.MARK));
|
||||
if(!proof.fits())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
proven++;
|
||||
List<Integer> drawn = FaceTest.drawn(face);
|
||||
for(int attempt = 0; attempt < 12; attempt++)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
int length = attempt == 0 ? maxLength : 1 + random.nextInt(maxLength);
|
||||
for(int character = 0; character < length; character++)
|
||||
{
|
||||
int choice = attempt % 4;
|
||||
text.appendCodePoint(choice == 0 ? face.widest() : choice == 1 && random.nextInt(5) == 0 ? ' ' : choice == 3 ? "⁄Ǻ̉Wj".charAt(random.nextInt(6)) : drawn.get(random.nextInt(drawn.size())));
|
||||
}
|
||||
tried++;
|
||||
TextFit.Block block = generated.of(text.toString()).fit();
|
||||
assertTrue(block.fits(), () -> "seed " + seed + ": " + request + " proves " + maxLength + " characters with " + proof + " yet refuses " + text.length() + " as " + block);
|
||||
assertTrue(block.milliPoints() >= proof.milliPoints(), "a real text is never smaller than the proof");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(proven > 150 && tried > 1500, "proven " + proven + ", tried " + tried);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The proof agrees with rendering for the widest character: the longest
|
||||
** length it proves is drawn, and it is short of the longest that is really
|
||||
** drawn by no more than one character a line, which is the room the proof
|
||||
** keeps for characters that reach past their advance.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theProofAgreesWithTheWidestCharacters()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
for(int maxLines : List.of(1, 2, 3))
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(face, 700 * dpi / 203, 150 * dpi / 203, dpi, 11_000, 7000, null, maxLines, false);
|
||||
int proven = 0;
|
||||
int drawn = 0;
|
||||
for(int length = 1; length <= 200; length++)
|
||||
{
|
||||
Face.Glyph[] worst = new Face.Glyph[length];
|
||||
Arrays.fill(worst, face.worst());
|
||||
proven = TextFit.fit(request, worst, null).fits() ? length : proven;
|
||||
drawn = fit(request, new String(Character.toChars(face.widest())).repeat(length)).fits() ? length : drawn;
|
||||
}
|
||||
String about = face.scene() + " at " + dpi + " dpi on " + maxLines + " lines: proven " + proven + ", drawn " + drawn;
|
||||
assertTrue(proven >= 20 * maxLines && drawn < 200, about);
|
||||
assertTrue(proven <= drawn && drawn - proven <= maxLines, about);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: the answer is exactly the one the search of every size gives:
|
||||
** the same size, the same lines, the same numbers, fitted, refused or cut.
|
||||
** Held over the cases labels use and over everything a scene may state,
|
||||
** and for a proof of the face's worst character as for a text. The search
|
||||
** of every size visits far more characters than `fit` does.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theAnswerIsExactlyThatOfTheSearchOfEverySize()
|
||||
{
|
||||
int fitted = 0;
|
||||
int shrunk = 0;
|
||||
int refused = 0;
|
||||
int cut = 0;
|
||||
int deep = 0;
|
||||
long spent = 0;
|
||||
long searched = 0;
|
||||
for(long seed : SEEDS)
|
||||
{
|
||||
Random random = new Random(seed);
|
||||
for(int index = 0; index < 2 * CASES; index++)
|
||||
{
|
||||
Case generated = index < CASES ? generate(random) : wide(random);
|
||||
TextFit.Request request = generated.request();
|
||||
Face face = request.face();
|
||||
Face.Glyph[] glyphs = face.glyphs(generated.text());
|
||||
if(index % 7 == 0)
|
||||
{
|
||||
glyphs = new Face.Glyph[1 + random.nextInt(60)];
|
||||
Arrays.fill(glyphs, face.worst());
|
||||
}
|
||||
TextFit.Work work = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Work every = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, face.glyph(TextFit.MARK), work);
|
||||
TextFit.Block expected = linear(request, glyphs, face.glyph(TextFit.MARK), every);
|
||||
int length = glyphs.length;
|
||||
assertEquals(expected, block, () -> "seed " + seed + " case " + request + " for " + length + " characters");
|
||||
assertEquals(block, TextFit.fit(request, glyphs, face.glyph(TextFit.MARK)), "the same with no allowance stated");
|
||||
spent += work.spent();
|
||||
searched += every.spent();
|
||||
fitted += block.fits() && !block.truncated() ? 1 : 0;
|
||||
shrunk += block.fits() && !block.truncated() && block.milliPoints() < request.nominal() ? 1 : 0;
|
||||
deep += block.fits() && request.nominal() - block.milliPoints() > 40 * TextFit.STEP_MILLIPOINTS ? 1 : 0;
|
||||
refused += block.fits() ? 0 : 1;
|
||||
cut += block.truncated() ? 1 : 0;
|
||||
}
|
||||
}
|
||||
assertTrue(fitted > 900 && shrunk > 300 && deep > 100 && refused > 500 && cut > 150, "the cases must reach every outcome: " + fitted + " fitted, " + shrunk + " shrunk, " + deep + " by more than 10 pt, " + refused + " refused, " + cut + " cut");
|
||||
assertTrue(searched > 20L * spent, "the search of every size visited " + searched + " characters, the fit " + spent);
|
||||
System.out.println("3600 fits: " + spent + " characters visited, where the search of every size visits " + searched);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The case an independent review measured at 140 to 250 ms a fit: 1024
|
||||
** characters at 600 dpi, type of 1000 pt that may shrink to 1 pt (four
|
||||
** thousand sizes), in boxes it fits at no size, at a small size, or only
|
||||
** cut. The answer is the one the search of every size gives, for under
|
||||
** forty times the characters of the text where that search visits more
|
||||
** than ten thousand times them. The time is printed; the count is held,
|
||||
** because it is the same on every machine.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theWorstCaseOfTheShrinkSearchIsHeldToAStatedWork()
|
||||
{
|
||||
String wide = "W".repeat(1024);
|
||||
String words = "The quick brown fox jumps over the lazy dog 0123456789 ".repeat(19).substring(0, 1024);
|
||||
long most = 0;
|
||||
long least = Long.MAX_VALUE;
|
||||
long nanoseconds = 0;
|
||||
long linearNanoseconds = 0;
|
||||
int fits = 0;
|
||||
for(int[] box : new int[][] {{20, 20, 64}, {20, 20, 1}, {40, 40, 64}, {200, 30, 1}, {600, 300, 64}, {2400, 1200, 64}, {2400, 1200, 1}, {9000, 30_000, 64}, {9000, 30_000, 2}})
|
||||
{
|
||||
for(String text : List.of(wide, words))
|
||||
{
|
||||
for(boolean truncate : List.of(false, true))
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS, box[0], box[1], 600, 1_000_000, 1000, null, box[2], truncate);
|
||||
Face.Glyph[] glyphs = Face.SANS.glyphs(text);
|
||||
TextFit.Work work = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Work every = new TextFit.Work(Long.MAX_VALUE);
|
||||
long start = System.nanoTime();
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), work);
|
||||
nanoseconds += System.nanoTime() - start;
|
||||
start = System.nanoTime();
|
||||
assertEquals(linear(request, glyphs, Face.SANS.glyph(TextFit.MARK), every), block, () -> "in " + request);
|
||||
linearNanoseconds += System.nanoTime() - start;
|
||||
assertTrue(work.spent() <= 40L * 1024, "in " + request + ": " + work.spent() + " characters visited");
|
||||
most = Math.max(most, work.spent());
|
||||
least = Math.min(least, every.spent());
|
||||
fits += block.fits() ? 1 : 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(least > 10_000L * 1024, "the search of every size visited at least " + least + " characters");
|
||||
assertTrue(fits > 20 && fits < 36, fits + " of 36 fit or are cut");
|
||||
System.out.println("1024 characters, 1000 pt to 1 pt at 600 dpi, 36 fits: at most " + most + " characters visited in a fit and " + nanoseconds / 36_000 + " microseconds a fit; the search of every size visited at least "
|
||||
+ least + " and took " + linearNanoseconds / 36_000 + " microseconds a fit");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** When one line at the floor is higher than the box, no text fits at any
|
||||
** size, and that is known without searching: the text is laid out once at
|
||||
** its size and once at the floor, for the numbers a refusal reports, and
|
||||
** nothing is tried for a cut. The answer is still that of the search of
|
||||
** every size.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aBoxLowerThanOneLineAtTheFloorIsRefusedWithoutASearch()
|
||||
{
|
||||
String text = "The quick brown fox jumps over the lazy dog 0123456789 ".repeat(19).substring(0, 1024);
|
||||
Face.Glyph[] glyphs = Face.MONO.glyphs(text);
|
||||
for(boolean truncate : List.of(false, true))
|
||||
{
|
||||
for(int maxLines : List.of(1, 64))
|
||||
{
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// 1 pt at 600 dpi is 8.3 dots to the em: a line reserves 9 dots above its baseline //
|
||||
// and 3 below, and the box is 11 high //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Request request = new TextFit.Request(Face.MONO, 5000, 11, 600, 1_000_000, 1000, null, maxLines, truncate);
|
||||
TextFit.Work work = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, Face.MONO.glyph(TextFit.MARK), work);
|
||||
assertFalse(block.fits() || block.truncated());
|
||||
assertEquals(List.of(1000, 1024, 1024), List.of(block.milliPoints(), block.characters(), block.shown()));
|
||||
assertTrue(block.height() > 11 && block.width() <= 5000, "measured at the floor: " + block);
|
||||
assertEquals(linear(request, glyphs, Face.MONO.glyph(TextFit.MARK), new TextFit.Work(Long.MAX_VALUE)), block);
|
||||
assertTrue(work.spent() <= 12L * 1024, "two layouts at each of two sizes, each at most three times the text, and no more: " + work.spent());
|
||||
}
|
||||
}
|
||||
assertTrue(fit(new TextFit.Request(Face.MONO, 5000, 12, 600, 1_000_000, 1000, null, 1, true), text).truncated(), "one dot higher holds a line, and so a cut");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fitting counts the characters it visits against the allowance it is
|
||||
** given and ends, by name and with nothing of the text, when the allowance
|
||||
** is passed: at once when it is already passed, however little is asked.
|
||||
** The count is the same every time.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void fittingEndsWhenItsAllowanceIsPassed()
|
||||
{
|
||||
String text = "The quick brown fox jumps over the lazy dog";
|
||||
Face.Glyph[] glyphs = Face.SANS.glyphs(text);
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS, 300, 60, 203, 40_000, 5000, null, 2, true);
|
||||
TextFit.Work measure = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Block block = TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), measure);
|
||||
assertTrue(block.fits() && block.milliPoints() < 40_000 && measure.spent() > 500 && measure.spent() < 2000, measure.spent() + " characters visited for " + block);
|
||||
|
||||
TextFit.Work exact = new TextFit.Work(measure.spent());
|
||||
assertEquals(block, TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), exact));
|
||||
assertEquals(measure.spent(), exact.spent());
|
||||
|
||||
for(long allowance : List.of(0L, 1L, 43L, 200L, measure.spent() - 1))
|
||||
{
|
||||
TextFit.Work work = new TextFit.Work(allowance);
|
||||
TextFit.Exhausted exhausted = assertThrows(TextFit.Exhausted.class, () -> TextFit.fit(request, glyphs, Face.SANS.glyph(TextFit.MARK), work));
|
||||
assertEquals("TEXT_WORK_EXHAUSTED", exhausted.getMessage());
|
||||
assertEquals(0, exhausted.getStackTrace().length);
|
||||
assertTrue(work.spent() > allowance && work.spent() <= allowance + 2L * text.length() + 1, "passed by no more than one line: " + work.spent() + " of " + allowance);
|
||||
long passed = work.spent();
|
||||
assertThrows(TextFit.Exhausted.class, () -> TextFit.fit(request, Face.SANS.glyphs("A"), null, work));
|
||||
assertThrows(TextFit.Exhausted.class, () -> work.spend(0));
|
||||
assertTrue(work.spent() >= passed);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a cut is counted too: every try of the last line with the mark //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextFit.Request narrow = new TextFit.Request(Face.SANS, 120, 40, 203, 9000, 9000, null, 1, true);
|
||||
TextFit.Work cutting = new TextFit.Work(Long.MAX_VALUE);
|
||||
TextFit.Work whole = new TextFit.Work(Long.MAX_VALUE);
|
||||
assertTrue(TextFit.fit(narrow, glyphs, Face.SANS.glyph(TextFit.MARK), cutting).truncated());
|
||||
assertFalse(TextFit.fit(lines(narrow, 1, false), glyphs, null, whole).fits());
|
||||
assertTrue(cutting.spent() > whole.spent(), cutting.spent() + " with a cut, " + whole.spent() + " without");
|
||||
assertThrows(TextFit.Exhausted.class, () -> TextFit.fit(narrow, glyphs, Face.SANS.glyph(TextFit.MARK), new TextFit.Work(whole.spent())));
|
||||
}
|
||||
}
|
||||
@@ -1,482 +0,0 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2025. Kingsrook, LLC
|
||||
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||
* contact@kingsrook.com
|
||||
* https://github.com/Kingsrook/
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* https://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qbits.renderer.scene2.text;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import com.kingsrook.qbits.renderer.scene2.Canvas;
|
||||
import com.kingsrook.qbits.renderer.scene2.Frame;
|
||||
import com.kingsrook.qbits.renderer.scene2.Layout;
|
||||
import com.kingsrook.qbits.renderer.scene2.Placement;
|
||||
import com.kingsrook.qbits.renderer.scene2.Scene2;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** What is drawn is what was measured: the ink of every line is inside the
|
||||
** line's measured box, the block is where `align` and `valign` put it, and
|
||||
** nothing is clipped. These read the canvas the renderer composes. Every
|
||||
** text drawn here is also held to have nothing stray from its measure.
|
||||
*******************************************************************************/
|
||||
class TextRasterTest
|
||||
{
|
||||
private static final int MARGIN = 8;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Fit a text and draw it in a box that sits `MARGIN` dots inside a canvas,
|
||||
** so that ink outside the box would be seen. The box is given as the text
|
||||
** reads; turned a quarter, it is laid on its side.
|
||||
***************************************************************************/
|
||||
private static Canvas draw(TextFit.Request request, String text, Placement.Align align, Placement.Valign valign, int rotation, int threshold, int bandDots)
|
||||
{
|
||||
TextFit.Block block = TextFit.fit(request, request.face().glyphs(text), request.face().glyph(TextFit.MARK));
|
||||
assertTrue(block.fits(), "the case must fit: " + request);
|
||||
boolean upright = rotation % 180 == 0;
|
||||
Frame frame = new Frame(MARGIN, MARGIN, upright ? request.width() : request.height(), upright ? request.height() : request.width());
|
||||
Canvas canvas = new Canvas(frame.width() + 2 * MARGIN, frame.height() + 2 * MARGIN, request.dpi());
|
||||
TextRaster.Drawing drawing = new TextRaster.Drawing(request.face(), text, block, align, valign, rotation, threshold, true);
|
||||
assertEquals(new TextRaster.Stray(0, 0), TextRaster.paint(canvas, frame, drawing, bandDots), "nothing strays from the measure: " + request);
|
||||
assertEquals(0, TextRaster.outside(drawing, request.dpi()));
|
||||
return (canvas);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The box of the ink on a canvas: left, top, right and bottom (the last two
|
||||
** exclusive), or null when it is blank.
|
||||
***************************************************************************/
|
||||
private static Frame ink(Canvas canvas)
|
||||
{
|
||||
int left = Integer.MAX_VALUE;
|
||||
int top = -1;
|
||||
int right = -1;
|
||||
int bottom = -1;
|
||||
for(int y = 0; y < canvas.height(); y++)
|
||||
{
|
||||
int first = canvas.firstInk(y);
|
||||
if(first >= 0)
|
||||
{
|
||||
top = top < 0 ? y : top;
|
||||
bottom = y + 1;
|
||||
left = Math.min(left, first);
|
||||
right = Math.max(right, canvas.lastInk(y) + 1);
|
||||
}
|
||||
}
|
||||
return (top < 0 ? null : new Frame(left, top, right - left, bottom - top));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** How many dots of a canvas hold ink.
|
||||
***************************************************************************/
|
||||
private static int dots(Canvas canvas)
|
||||
{
|
||||
int dots = 0;
|
||||
for(int y = 0; y < canvas.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < canvas.width(); x++)
|
||||
{
|
||||
dots += canvas.get(x, y) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
return (dots);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Whether two canvases hold the same picture.
|
||||
***************************************************************************/
|
||||
private static void assertSamePicture(Canvas expected, Canvas actual, String about)
|
||||
{
|
||||
assertEquals(expected.width(), actual.width(), about);
|
||||
assertEquals(expected.height(), actual.height(), about);
|
||||
for(int y = 0; y < expected.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < expected.width(); x++)
|
||||
{
|
||||
if(expected.get(x, y) != actual.get(x, y))
|
||||
{
|
||||
assertEquals(expected.get(x, y), actual.get(x, y), about + ": dot " + x + ", " + y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Every character of every bundled face, at sizes and resolutions labels
|
||||
** use, is drawn with all of its ink inside the box that was measured for
|
||||
** its line. The renderer itself fails a render whose ink leaves that box
|
||||
** (the guard round each line), so drawing them all is the test; the ink is
|
||||
** then also read back from the canvas and held to the line's box.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void everyCharacterOfEveryFaceIsDrawnInsideItsMeasuredLine()
|
||||
{
|
||||
int[][] sizes = {{203, 6000}, {203, 9000}, {203, 14_250}, {300, 7000}, {300, 11_000}, {600, 5000}, {600, 12_250}};
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
List<Integer> drawn = FaceTest.drawn(face);
|
||||
for(int[] size : sizes)
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(size[1], size[0]);
|
||||
int inked = 0;
|
||||
for(int start = 0; start < drawn.size(); start += 16)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
drawn.subList(start, Math.min(drawn.size(), start + 16)).forEach(text::appendCodePoint);
|
||||
TextFit.Request request = new TextFit.Request(face, scale.ceil(1500) * 16 + 16, scale.ceil(2000), size[0], size[1], size[1], null, 1, false);
|
||||
Canvas canvas = draw(request, text.toString(), Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, TextRaster.BAND_DOTS);
|
||||
TextFit.Block block = TextFit.fit(request, face.glyphs(text.toString()), null);
|
||||
Frame ink = ink(canvas);
|
||||
if(ink != null)
|
||||
{
|
||||
inked++;
|
||||
TextFit.Line line = block.lines().get(0);
|
||||
Frame measured = new Frame(MARGIN, MARGIN + block.baseline() - line.above(), line.width(), line.above() + line.below());
|
||||
assertTrue(measured.contains(ink), () -> face.scene() + " at " + size[1] + " millipoints, " + size[0] + " dpi: ink " + ink + " outside the measured " + measured);
|
||||
assertTrue(new Frame(MARGIN, MARGIN, block.width(), block.height()).contains(measured));
|
||||
}
|
||||
}
|
||||
assertTrue(inked >= drawn.size() / 16 - 4, face.scene() + " drew " + inked + " lines");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Property: for generated texts, faces, boxes, sizes and resolutions, at
|
||||
** every rotation, no ink is outside the element's box and none is clipped,
|
||||
** and the upright picture turned is the turned picture. Up to 100 dots to
|
||||
** the em, where glyphs are drawn as bitmaps, the picture is also exactly
|
||||
** the same drawn a row at a time, so bands lose and repeat nothing.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void inkIsNeverOutsideTheBoxAndNeverClipped()
|
||||
{
|
||||
Random random = new Random(TextFitTest.SEEDS[0]);
|
||||
List<Placement.Align> aligns = List.of(Placement.Align.values());
|
||||
List<Placement.Valign> valigns = List.of(Placement.Valign.values());
|
||||
int drawn = 0;
|
||||
int banded = 0;
|
||||
for(int index = 0; drawn < 160; index++)
|
||||
{
|
||||
TextFitTest.Case generated = TextFitTest.generate(random);
|
||||
if(!generated.fit().fits() || generated.text().isBlank())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
drawn++;
|
||||
TextFit.Request request = generated.request();
|
||||
Placement.Align align = aligns.get(random.nextInt(3));
|
||||
Placement.Valign valign = valigns.get(random.nextInt(3));
|
||||
int threshold = 1 + random.nextInt(254);
|
||||
String about = "case " + index + ": " + request;
|
||||
Canvas upright = draw(request, generated.text(), align, valign, 0, threshold, TextRaster.BAND_DOTS);
|
||||
Frame ink = ink(upright);
|
||||
Frame box = new Frame(MARGIN, MARGIN, request.width(), request.height());
|
||||
assertTrue(ink == null || box.contains(ink), about + ": ink " + ink + " outside " + box);
|
||||
if(new TextFit.Scale(generated.fit().milliPoints(), request.dpi()).size() <= 100)
|
||||
{
|
||||
banded++;
|
||||
assertSamePicture(upright, draw(request, generated.text(), align, valign, 0, threshold, 1), about + ", row by row");
|
||||
}
|
||||
for(int rotation : List.of(90, 180, 270))
|
||||
{
|
||||
Canvas turned = draw(request, generated.text(), align, valign, rotation, threshold, TextRaster.BAND_DOTS);
|
||||
Canvas expected = new Canvas(turned.width(), turned.height(), request.dpi());
|
||||
for(int y = 0; y < request.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < request.width(); x++)
|
||||
{
|
||||
if(upright.get(MARGIN + x, MARGIN + y))
|
||||
{
|
||||
int turnedX = rotation == 90 ? request.height() - 1 - y : rotation == 180 ? request.width() - 1 - x : y;
|
||||
int turnedY = rotation == 90 ? x : rotation == 180 ? request.height() - 1 - y : request.width() - 1 - x;
|
||||
expected.set(MARGIN + turnedX, MARGIN + turnedY, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
assertSamePicture(expected, turned, about + ", turned " + rotation);
|
||||
}
|
||||
}
|
||||
assertTrue(banded > 60, "drawn row by row: " + banded);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The block sits in the box where `align` and `valign` say, a centred block
|
||||
** within one dot of the middle, and each line sits in the block the same
|
||||
** way. Read from the ink of a two-line text.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theBlockIsPlacedByAlignAndValignAndCentredWithinOneDot()
|
||||
{
|
||||
String text = "HHHHHHHH HH";
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS_BOLD, 241, 151, 203, 12_000, 12_000, null, 2, false);
|
||||
TextFit.Block block = TextFit.fit(request, Face.SANS_BOLD.glyphs(text), null);
|
||||
assertEquals(List.of("HHHHHHHH", "HH"), TextFitTest.shown(block, text));
|
||||
TextFit.Line wide = block.lines().get(0);
|
||||
TextFit.Line narrow = block.lines().get(1);
|
||||
for(Placement.Align align : Placement.Align.values())
|
||||
{
|
||||
for(Placement.Valign valign : Placement.Valign.values())
|
||||
{
|
||||
Canvas canvas = draw(request, text, align, valign, 0, 128, TextRaster.BAND_DOTS);
|
||||
Frame content = Layout.align(new Frame(MARGIN, MARGIN, 241, 151), block.width(), block.height(), align, valign);
|
||||
Frame ink = ink(canvas);
|
||||
assertTrue(content.contains(ink), align + " " + valign + ": ink " + ink + " outside the block " + content);
|
||||
int slackLeft = content.x() - MARGIN;
|
||||
int slackRight = MARGIN + 241 - content.right();
|
||||
int slackTop = content.y() - MARGIN;
|
||||
int slackBottom = MARGIN + 151 - content.bottom();
|
||||
switch(align)
|
||||
{
|
||||
case LEFT -> assertEquals(0, slackLeft);
|
||||
case CENTER -> assertTrue(slackLeft >= 0 && Math.abs(slackLeft - slackRight) <= 1);
|
||||
default -> assertEquals(0, slackRight);
|
||||
}
|
||||
switch(valign)
|
||||
{
|
||||
case TOP -> assertEquals(0, slackTop);
|
||||
case MIDDLE -> assertTrue(slackTop >= 0 && Math.abs(slackTop - slackBottom) <= 1);
|
||||
default -> assertEquals(0, slackBottom);
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
// the ink of each line, read from the rows of its own baseline, is in the line's //
|
||||
// box, and that box is against the same side of the block (or in its middle) //
|
||||
//////////////////////////////////////////////////////////////////////////////////////
|
||||
int capitals = new TextFit.Scale(12_000, 203).floor(Face.SANS_BOLD.capHeight());
|
||||
int first = content.y() + block.baseline() - capitals / 2;
|
||||
int second = first + block.pitch();
|
||||
int narrowLeft = content.x() + align.position().place(block.width(), narrow.width(), 0);
|
||||
assertTrue(canvas.firstInk(first) >= content.x() && canvas.lastInk(first) < content.x() + wide.width(), "the widest line fills the block");
|
||||
assertTrue(canvas.firstInk(second) >= narrowLeft && canvas.lastInk(second) < narrowLeft + narrow.width(), align + ": the short line " + canvas.firstInk(second) + " to " + canvas.lastInk(second));
|
||||
int gapLeft = canvas.firstInk(second) - canvas.firstInk(first);
|
||||
int gapRight = canvas.lastInk(first) - canvas.lastInk(second);
|
||||
switch(align)
|
||||
{
|
||||
case LEFT -> assertTrue(Math.abs(gapLeft) <= 1 && gapRight > 50);
|
||||
case CENTER -> assertTrue(Math.abs(gapLeft - gapRight) <= 2 && gapLeft > 25);
|
||||
default -> assertTrue(Math.abs(gapRight) <= 1 && gapLeft > 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A dot is ink when its grey is darker than the threshold, so a higher
|
||||
** threshold prints every dot a lower one prints and more: heavier strokes,
|
||||
** which is the control for small type on a thermal head.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void aHigherThresholdPrintsHeavier()
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS, 500, 60, 203, 14_000, 14_000, null, 1, false);
|
||||
List<Canvas> pictures = new ArrayList<>();
|
||||
for(int threshold : List.of(1, 60, 128, 200, 254))
|
||||
{
|
||||
pictures.add(draw(request, "Lot L240611A 0O1lI", Placement.Align.LEFT, Placement.Valign.TOP, 0, threshold, TextRaster.BAND_DOTS));
|
||||
}
|
||||
for(int index = 1; index < pictures.size(); index++)
|
||||
{
|
||||
Canvas lighter = pictures.get(index - 1);
|
||||
Canvas heavier = pictures.get(index);
|
||||
assertTrue(dots(heavier) > dots(lighter), "threshold step " + index + ": " + dots(lighter) + " then " + dots(heavier));
|
||||
for(int y = 0; y < lighter.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < lighter.width(); x++)
|
||||
{
|
||||
assertFalse(lighter.get(x, y) && !heavier.get(x, y), "a dot printed at the lower threshold is printed at the higher");
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(dots(pictures.get(0)) > 100, "full ink is black at every threshold");
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The ends of what the renderer accepts. At 1 pt and 2 pt, where a letter
|
||||
** is a few dots, every character of every face is drawn inside its
|
||||
** measured line. At 300 pt and 1000 pt, where a letter is drawn from its
|
||||
** outline in bands, the characters of each face that reach furthest are:
|
||||
** its widest, and those that reach furthest left, right, up and down.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void theSmallestAndTheLargestTypeAreDrawnInsideTheirMeasuredLine()
|
||||
{
|
||||
for(Face face : Face.all())
|
||||
{
|
||||
List<Integer> drawn = FaceTest.drawn(face);
|
||||
for(int[] size : new int[][] {{203, 1000}, {300, 1000}, {600, 1000}, {203, 2000}, {600, 2000}})
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(size[1], size[0]);
|
||||
for(int start = 0; start < drawn.size(); start += 16)
|
||||
{
|
||||
StringBuilder text = new StringBuilder();
|
||||
drawn.subList(start, Math.min(drawn.size(), start + 16)).forEach(text::appendCodePoint);
|
||||
TextFit.Request request = new TextFit.Request(face, scale.ceil(1500) * 16 + 16 * 2 * TextFit.SLOP, scale.ceil(2000) + 2 * TextFit.SLOP, size[0], size[1], size[1], null, 1, false);
|
||||
Canvas canvas = draw(request, text.toString(), Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, TextRaster.BAND_DOTS);
|
||||
TextFit.Line line = TextFit.fit(request, face.glyphs(text.toString()), null).lines().get(0);
|
||||
Frame ink = ink(canvas);
|
||||
assertTrue(ink == null || new Frame(MARGIN, MARGIN, line.width(), request.height()).contains(ink), () -> face.scene() + " at " + size[1] + " millipoints, " + size[0] + " dpi: ink " + ink);
|
||||
}
|
||||
}
|
||||
|
||||
Face.Glyph worst = face.worst();
|
||||
StringBuilder extremes = new StringBuilder().appendCodePoint(face.widest());
|
||||
for(int codePoint : drawn)
|
||||
{
|
||||
Face.Glyph glyph = face.glyph(codePoint);
|
||||
if(glyph.ink() && extremes.length() < 5 && (glyph.left() == worst.left() || glyph.right() - glyph.advance() == worst.right() - worst.advance() || glyph.top() == worst.top() || glyph.bottom() == worst.bottom()))
|
||||
{
|
||||
extremes.appendCodePoint(codePoint);
|
||||
}
|
||||
}
|
||||
assertTrue(extremes.length() >= 3, face.scene() + " has " + extremes.length() + " furthest-reaching characters");
|
||||
for(int milliPoints : List.of(300_000, 1_000_000))
|
||||
{
|
||||
TextFit.Scale scale = new TextFit.Scale(milliPoints, 203);
|
||||
for(int index = 0; index < (milliPoints == 300_000 ? extremes.length() : 2); index++)
|
||||
{
|
||||
String text = extremes.substring(index, index + 1);
|
||||
TextFit.Request request = new TextFit.Request(face, scale.ceil(2000), scale.ceil(2000), 203, milliPoints, milliPoints, null, 1, false);
|
||||
Canvas canvas = draw(request, text, Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, TextRaster.BAND_DOTS);
|
||||
Frame ink = ink(canvas);
|
||||
assertTrue(ink.width() > scale.floor(100) && ink.height() > scale.floor(100), () -> face.scene() + " U+" + Integer.toHexString(text.codePointAt(0)) + " at " + milliPoints + " millipoints: ink " + ink);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Ink outside the measured line is never clipped away and never printed,
|
||||
** and a text is never printed in part: what strayed is counted and the
|
||||
** label is left as it was, reverse text included, whose box is not filled.
|
||||
** Forced here with lines whose boxes are stated too small: one that ends in
|
||||
** the gap between two letters, so the second letter is wholly beyond the
|
||||
** guard and only its bounds tell, and two that cut through the letters,
|
||||
** whose ink is found in the guard. The bounds alone, which is what a
|
||||
** validation asks, count the same glyphs. Ink that would fall outside the
|
||||
** element's box is counted too, and none of that text is printed.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void inkOutsideTheMeasuredLineIsCountedAndNothingIsPainted()
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.SANS_BOLD, 200, 60, 203, 12_000, 12_000, null, 1, false);
|
||||
TextFit.Block honest = TextFit.fit(request, Face.SANS_BOLD.glyphs("HH"), null);
|
||||
TextFit.Line line = honest.lines().get(0);
|
||||
Canvas canvas = new Canvas(220, 80, 203);
|
||||
Frame frame = new Frame(10, 10, 200, 60);
|
||||
List<TextFit.Line> wrong = List.of(new TextFit.Line(0, 2, false, line.left(), line.right() / 2, line.above(), line.below()), new TextFit.Line(0, 2, false, line.left(), line.right(), line.above() / 3, line.below()),
|
||||
new TextFit.Line(0, 2, false, line.left(), line.right(), line.above(), -line.above() / 2));
|
||||
List<TextRaster.Stray> strays = new ArrayList<>();
|
||||
for(TextFit.Line each : wrong)
|
||||
{
|
||||
TextFit.Block forged = new TextFit.Block(honest.milliPoints(), List.of(each), each.width(), honest.height(), honest.baseline(), honest.pitch(), 2, 2, true);
|
||||
for(boolean reverse : List.of(false, true))
|
||||
{
|
||||
TextRaster.Drawing drawing = new TextRaster.Drawing(Face.SANS_BOLD, "HH", forged, Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, !reverse);
|
||||
TextRaster.Stray stray = TextRaster.paint(canvas, frame, drawing, TextRaster.BAND_DOTS);
|
||||
assertTrue(stray.any() && stray.glyphs() > 0, "strayed: " + stray);
|
||||
assertEquals(stray.glyphs(), TextRaster.outside(drawing, 203));
|
||||
assertEquals(stray, TextRaster.paint(canvas, frame, drawing, 1 << 10), "the same count drawn in bands");
|
||||
assertEquals(null, ink(canvas), "nothing of a text that strayed is on the label");
|
||||
strays.add(stray);
|
||||
}
|
||||
}
|
||||
assertEquals(List.of(new TextRaster.Stray(1, 0), new TextRaster.Stray(1, 0)), strays.subList(0, 2), "the second letter is wholly beyond the guard: only its bounds tell");
|
||||
assertTrue(strays.get(2).glyphs() == 2 && strays.get(2).dots() > 20 && strays.get(4).glyphs() == 2 && strays.get(4).dots() > 20, "ink in the guard: " + strays);
|
||||
assertFalse(new TextRaster.Stray(0, 0).any());
|
||||
assertTrue(new TextRaster.Stray(0, 1).any() && new TextRaster.Stray(1, 0).any());
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// a block measured honestly but handed a box too small for it: its ink would fall //
|
||||
// outside the element, to the right and below //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
TextRaster.Drawing whole = new TextRaster.Drawing(Face.SANS_BOLD, "HH", honest, Placement.Align.LEFT, Placement.Valign.TOP, 0, 128, true);
|
||||
for(Frame small : List.of(new Frame(10, 10, 20, 60), new Frame(10, 10, 200, 12)))
|
||||
{
|
||||
TextRaster.Stray stray = TextRaster.paint(canvas, small, whole, TextRaster.BAND_DOTS);
|
||||
assertTrue(stray.glyphs() == 0 && stray.dots() > 20, "outside the element: " + stray);
|
||||
assertEquals(null, ink(canvas));
|
||||
}
|
||||
|
||||
Canvas clean = new Canvas(220, 80, 203);
|
||||
assertEquals(new TextRaster.Stray(0, 0), TextRaster.paint(clean, frame, whole, TextRaster.BAND_DOTS));
|
||||
assertEquals(0, TextRaster.outside(whole, 203));
|
||||
assertTrue(new Frame(10, 10, honest.width(), honest.height()).contains(ink(clean)));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Very large type is drawn in bands of whole rows, so a line never needs
|
||||
** more grey than the band allows. 200 pt at 600 dpi is 1667 dots to the em.
|
||||
** The bands lose no row and repeat none: the picture is the one drawn in
|
||||
** one piece, but for a few edge dots where the rasteriser, given a band,
|
||||
** shades a curve differently: fewer than one ink dot in ten thousand (4 of
|
||||
** 121 thousand, 5 of 265 thousand and 40 of 1.06 million were seen).
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void veryLargeTypeIsDrawnInBands()
|
||||
{
|
||||
for(int dpi : Scene2.RESOLUTIONS)
|
||||
{
|
||||
TextFit.Request request = new TextFit.Request(Face.CONDENSED_BOLD, 2400 * dpi / 600, 2400 * dpi / 600, dpi, 200_000, 200_000, null, 1, false);
|
||||
Canvas whole = draw(request, "7g", Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, Integer.MAX_VALUE);
|
||||
assertSamePicture(whole, draw(request, "7g", Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, TextRaster.BAND_DOTS), "200 pt at " + dpi + " dpi fits one band");
|
||||
Canvas banded = draw(request, "7g", Placement.Align.CENTER, Placement.Valign.MIDDLE, 0, 128, 1 << 16);
|
||||
int different = 0;
|
||||
for(int y = 0; y < whole.height(); y++)
|
||||
{
|
||||
for(int x = 0; x < whole.width(); x++)
|
||||
{
|
||||
different += whole.get(x, y) == banded.get(x, y) ? 0 : 1;
|
||||
}
|
||||
}
|
||||
assertTrue(different * 10_000L <= dots(whole), "200 pt at " + dpi + " dpi: " + different + " dots differ of " + dots(whole));
|
||||
System.out.println("200 pt at " + dpi + " dpi in bands: " + different + " of " + dots(whole) + " ink dots differ from the picture drawn in one piece");
|
||||
assertEquals(ink(whole), ink(banded));
|
||||
int capitals = new TextFit.Scale(200_000, dpi).floor(Face.CONDENSED_BOLD.capHeight());
|
||||
assertTrue(ink(banded).height() > capitals && ink(banded).height() < 2 * capitals && ink(banded).width() > capitals, "the digits are the size asked: " + ink(banded));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
# Golden labels of version 2 scenes
|
||||
|
||||
Each case in `cases` has, in `golden`, at 203, 300 and 600 dpi, the exact ZPL the renderer emits
|
||||
(`<case>-<dpi>.zpl`) and the page a printer draws from it (`<case>-<dpi>.png`). `SceneGoldenTest` compares both
|
||||
exactly, on every machine, and holds `golden` to exactly those files, which is why this note is beside it.
|
||||
|
||||
## What the pictures depend on
|
||||
|
||||
A box is whole dots of ink and depends on the scene alone. Text is measured by integer arithmetic on the bundled
|
||||
faces, which is the same everywhere, but its glyph bitmaps are drawn by the Java runtime: the Java 2D rasteriser
|
||||
and the FreeType library that the JDK itself ships (`lib/libfreetype.so` on Linux, `bin/freetype.dll` on
|
||||
Windows). The system's FreeType is not used: in the images below `libfontmanager.so` loads the JDK's own copy.
|
||||
The version of that copy is the one the JDK states in `legal/java.desktop/freetype.md`.
|
||||
|
||||
The text goldens (`text-*`) were generated and compared on these runtimes, all x86-64:
|
||||
|
||||
| Where | JDK | FreeType in the JDK | The goldens there |
|
||||
|---|---|---|---|
|
||||
| CI image `maven:3.9.11-eclipse-temurin-21` (Ubuntu 24.04) | Temurin 21.0.9+10 | 2.13.3 | generated here; compared exactly by the build |
|
||||
| Windows 11 | Temurin 21.0.12.1+1 | 2.14.3 | the same bytes; compared exactly by the build |
|
||||
| The worker image's runtime (`renderer/Dockerfile` requires `jdk-21.0.13+9` on Ubuntu 24.04), as `eclipse-temurin:21-jre-noble` | Temurin 21.0.13+9 | 2.14.3 | the same bytes; the 21 labels were rendered there and compared by hand, not by a build |
|
||||
|
||||
So the same bytes came from two FreeType versions and three JDK builds. That is an observation about these
|
||||
versions, not a promise about the next: a golden that changes with a runtime is a reviewed change.
|
||||
|
||||
## Changing a golden
|
||||
|
||||
A golden changes only with a reviewed change to the layout rules or to the runtime. Run `SceneGoldenTest` in the
|
||||
CI image; what the build drew is left in `renderer/target/scene2-actual`. Copy the files to `golden`, then run the
|
||||
test on Windows as well: it must pass there on the same bytes. Say in the change which goldens changed and why, and
|
||||
update the table above when a runtime changes.
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"stock": {"widthMicrons": 50800, "heightMicrons": 25400, "topMicrons": 2000, "rightMicrons": 2000, "bottomMicrons": 2000, "leftMicrons": 2000, "dpi": 203},
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/2",
|
||||
"design": {"width": "2in", "height": "1in"},
|
||||
"defaults": {"size": "8pt"},
|
||||
"elements": [
|
||||
{"id": "sans", "type": "text", "value": "Sans 0O 1lI 5S 8B Åéj", "font": "qqq-sans", "y": "0mm", "height": "4.2mm"},
|
||||
{"id": "sans-bold", "type": "text", "value": "Bold 0O 1lI 5S 8B Åéj", "font": "qqq-sans-bold", "y": "4.2mm", "height": "4.2mm"},
|
||||
{"id": "condensed-bold", "type": "text", "value": "Condensed 0O 1lI 5S 8B Åéj", "font": "qqq-condensed-bold", "y": "8.4mm", "height": "4.2mm"},
|
||||
{"id": "mono", "type": "text", "value": "Mono 0O 1lI 5S 8B Åéj", "font": "qqq-mono", "y": "12.6mm", "height": "4.2mm"},
|
||||
{"id": "mono-bold", "type": "text", "value": "Bold 0O 1lI 5S 8B Åéj", "font": "qqq-mono-bold", "y": "16.8mm", "height": "4.2mm"}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"stock": {"widthMicrons": 50800, "heightMicrons": 25400, "topMicrons": 2000, "rightMicrons": 2000, "bottomMicrons": 2000, "leftMicrons": 2000, "dpi": 203},
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/2",
|
||||
"design": {"width": "2in", "height": "1in"},
|
||||
"defaults": {"font": "qqq-sans-bold", "size": "10pt"},
|
||||
"elements": [
|
||||
{"id": "kept-box", "type": "box", "width": "22mm", "height": "5mm", "thickness": "0.1mm"},
|
||||
{"id": "kept", "type": "text", "value": "LOT 240611", "width": "22mm", "height": "5mm", "align": "center", "valign": "middle"},
|
||||
{"id": "right-box", "type": "box", "anchor": "top-right", "width": "22mm", "height": "5mm", "thickness": "0.1mm"},
|
||||
{"id": "right", "type": "text", "value": "9.5 kg", "font": "qqq-condensed-bold", "size": "11pt", "anchor": "top-right", "width": "22mm", "height": "5mm", "align": "right", "valign": "bottom"},
|
||||
{"id": "shrunk-box", "type": "box", "y": "5.5mm", "width": "44mm", "height": "4mm", "thickness": "0.1mm"},
|
||||
{"id": "shrunk", "type": "text", "value": "(01)00012345678905(10)L240611A", "font": "qqq-mono-bold", "size": "9pt", "y": "5.5mm", "width": "44mm", "height": "4mm", "valign": "middle"},
|
||||
{"id": "wrapped-box", "type": "box", "y": "10mm", "width": "36mm", "height": "7.5mm", "thickness": "0.1mm"},
|
||||
{"id": "wrapped", "type": "text", "value": "(01)00012345678905(10)L240611A-RETURNED-000123", "font": "qqq-mono", "size": "7pt", "y": "10mm", "width": "36mm", "height": "7.5mm", "align": "center", "valign": "middle",
|
||||
"maxLength": 46, "fit": {"maxLines": 2}},
|
||||
{"id": "cut-box", "type": "box", "anchor": "bottom-left", "width": "30mm", "height": "3.6mm", "thickness": "0.1mm"},
|
||||
{"id": "cut", "type": "text", "value": "A description of the item that is too long", "font": "qqq-sans", "size": "7pt", "anchor": "bottom-left", "width": "30mm", "height": "3.6mm", "fit": {"overflow": "truncate", "shrinkTo": "7pt"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"stock": {"widthMicrons": 50800, "heightMicrons": 25400, "topMicrons": 2000, "rightMicrons": 2000, "bottomMicrons": 2000, "leftMicrons": 2000, "dpi": 203},
|
||||
"scene": {
|
||||
"schema": "qqq-zpl-scene/2",
|
||||
"design": {"width": "2in", "height": "1in"},
|
||||
"defaults": {"font": "qqq-sans-bold", "size": "10pt"},
|
||||
"elements": [
|
||||
{"id": "up", "type": "text", "value": "TEST-0001", "font": "qqq-mono-bold", "rotation": 90, "width": "5mm", "align": "center"},
|
||||
{"id": "down", "type": "text", "value": "TEST-0001", "font": "qqq-mono-bold", "rotation": 270, "anchor": "top-right", "width": "5mm", "align": "center"},
|
||||
{"id": "flip", "type": "text", "value": "THIS WAY UP", "rotation": 180, "x": "7mm", "width": "30mm", "height": "5mm", "align": "center"},
|
||||
{"id": "panel", "type": "text", "value": "ZONE 7", "font": "qqq-condensed-bold", "size": "16pt", "reverse": true, "x": "7mm", "y": "6mm", "width": "30mm", "height": "8mm", "align": "center", "valign": "middle"},
|
||||
{"id": "fine", "type": "text", "value": "Best before 2027-06-11", "font": "qqq-sans", "size": "6.5pt", "x": "7mm", "y": "15mm", "width": "32mm", "height": "5mm", "valign": "bottom"}
|
||||
],
|
||||
"overrides": [
|
||||
{"when": {"dpi": [203]}, "set": {"fine": {"font": "qqq-sans-bold", "threshold": 150}}}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,3 +0,0 @@
|
||||
^XA^PW406^LL203^LH0,0^CI28
|
||||
^FO19,19^GFA,5600,5600,35,00000000000000000000000000000000000000000000000038000000000000000000000000000000000000000000000000000000000000000000004C0000000000000000000000000000000000000000000000000000000000000000000044000000000000000000000000000000000000000000000000000000000000000000004C00000000000000000000000000000000000000000000000000000000000000000000380000000000000000000000000000000000000000000000800000000000000000000000006080000000000000001F0000000000007C00FC000700C3E007F80F8003E03FC0003000C0C0000000000000007FC00000000000FE01FE000F00C7F007F83FE007F83FF000380080800000000000000060E00000000001C30303001B00C080040030700C18303000780080000000000000000040000000000001810601803300C080040020000C0C3018006C00000000000000000000C000F02780F801818601806300C080040060000C0C3018004C03C080000000000000006003F83FC1FC01018401800300C0800C0030000C08301800CC07F0C00000000000000070020C30E30403018C00C00300C0800CE038000618303000C60C30C0000000000000003F000C30630003018C00C00300C0800FF81F8003E03FE001860818C0000000000000000FC00C30638003018C00C00300C0800E1807E007F03FF001861818C00000000000000000C0FC3061F003018C00C00300C080000C00600C1C301801831FF8C0000000000000000063FC30607C01018400800300C080000C0030080C301803FF1FF8C00000000000000000660C30600C01818601800300C080000C0030180C301803031800C00000000000000000660C30600601818601800300C080000C0030180C301803019800C000000000000000C0E60C30630601830303000300C0800C1860700C0C301806018C18C0000000000000007BC71E30638C00E703CE003FF0E3E007783DE00F383FF006018E30C0000000000000003F83E63061F8007E00FC003FF067F003F01FC007F03FE00600C3E0C000000000000000000000000000000000000000000000000000000000000000000000C000000000000000000000000000000000000000000000000000000000000000000000C000000000000000000000000000000000000000000000000000000000000000000000C000000000000000000000000000000000000000000000000000000000000000000003800000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C000000000000000000000000000000000000000000000000000000000000000000003E000000000000000000000000000000000000000000000000000000000000000000003300000000000000000000000000000000000000000000000000000000000000000000630000000000000000000000000000000000000000000000000000000000000000000073000000000000000000000000000000000000000000000000000000000000000000003E001800000000000000000000000E00380000000000000380000000000000000000001C00381800000000000000FF80000E003800F803F0001F838FE00FFC0FC001F83FE0001E00383C00000000000000FFE0000E003801FE07F8001F838FF00FFC1FF007FC3FF8003E00703C00000000000000FFE0000E003803FF0FFC003F838FF00FFC3FF007FE3FF8003F00201C00000000000000E1E0000E0038078F1E1E007F8383C00E0038600F0E3878003F00000000000000000000E0F07C0E0F3807871C0E00F78383C00E0078000F0E383C007700F81C00000000000000E0E1FE0E1FF80707BC0F00E78383C00C007800070E3838007783FC1C00000000000000E1E1FF0E1FF80707BC0F00478383C00CF83F00079C3878007383DE1C00000000000000FFC3C70E3C380707BC0F00078383C00FFC3FE001F83FF000E3878E1C00000000000000FFE3878E3C380707BC0F00078383C00F7C1FF003FC3FF800E3C70F1C00000000000000FFF3878E38380707BC0F00078383C0001E03F8079E3FFC00FFC7FF1C00000000000000E0F3878E38380707BC0F00078383C0001E00780F0E383C01FFC7FF1C00000000000000E073878E383807871C0E00078383C0001E00780E0F381C01FFE7001C00000000000000E0F3C78E3C38078F1E1E00078383C00C1C30780F0F383C01C1E7801C00000000000000FFF1EF0F1EF803FF0FFE007FF3CFE01FFC7FF00F9E3FFC03C0E3CE1C00000000000000FFE1FE0F1FF801FE0FFC007FF3CFF00FF83FF007FE3FF80380F3FE1C00000000000000FFC0FC070FB800FC03F0007FF1CFF007F01FC003F83FF00380F1FC1C000000000000000000000000000000000000000000000000000000000000000000001C000000000000000000000000000000000000000000000000000000000000000000001C000000000000000000000000000000000000000000000000000000000000000000007C0000000000000000000000000000000000000000000000000000000000000000000078000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000000001F000000000000000000000000000000000000000000000000000000000000000000001980000000000000000000000000000000000000000000000000000000000000000000198000000000000000000000000000000000000000000000000000000000000000000019800000000000Line truncated
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 3.0 KiB |
@@ -1,7 +0,0 @@
|
||||
^XA^PW600^LL300^LH0,0^CI28
|
||||
^FO27,26^GFA,1640,1640,41,0000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000000000000000000007C00000000000000000000000000000000000000000000000000000000000000000000000000000000E600000000000000000000000000000000000000000000000000000000000000000000000000000000C200000000000000000000000000000000000000000000000000000000000000000000000000000000C300000000000000000000000000000000000000000000000000000000000000000000000000000000C6000000000000000000000000000000000000000000000000000000000000000000000000000000007E000000000000000000000000000000000000000000000000000000000000000000000000000000003C0000C000000000000000000000000000000000000000000600000000000000000000000000000000000000E01800380000000000000000003C0000E000000000070000000000000E000001E00000000000000001C01C01FF800000000000000000FF0007FE000007C00707FC000FFF807FE0000FFC03FFE000003C0001C01C03FFC00000000000000001FF800FFF00000FC00707FC000FFF80FFF0001FFE03FFF000007C000380180781E0000000000000000381C01E0780001DC00700E0000E0001E078003C0F03807800007C000300000E00E0000000000000000700E03803C00039C00700E0000C0003803800380703803800006E000000000E0000000000000000000700E07001C00071C00700E0000C0003800000380703801C0000EE000000000E00000E0000600060000600607000E000E1C00700E0000C0003800000380703801C0000C6000300000E00007F803BF803FC000E00706000E00041C00700E0000C0003800000380703801C0000C7001FE01C0E0000FFE03FFC07FE000E0070E000E00001C00700E0000C0003800000380703801C0001C7003FF01C078001C0E03C1E0E07000E0070E000600001C00700E0000C0001E000001C0E0380380001838078381C07E0008070380E0E00000E0070E000700001C00700E0001DFC01F800000F3C03807000038380601C1C01FE000070380E0E00000E0070E000700001C00700E0001FFF007F800007F803FFE000038180E01C1C007F80007038060E00000E0070E000700001C00700E0001E07801FE0000FFC03FFF0000301C0E00C1C000FC0007038060F00000E0070E000700001C00700E0000C038003F0001C0E038078000701C0C00E1C0001E01FF0380607F0000E0070E000700001C00700E000000380007800380703801C000701C0FFFE1C0000E0FFF0380603FE000E0070E000600001C00700E0000001C0003800700383801C0007FFE0FFFE1C000071E0703806003F000E0070E000E00001C00700E0000001C0001C00700383800E000FFFE0C0001C000071C07038060007000600606000E00001C00700E0000001C0001C00700383800E000E00E0C0001C000071807038060003000700E07000E00001C00700E000000180001C00700383800E000C0070E0001C0C00E3807038060003000700E07801C00001C00700E000080383003800700383801C001C0070E0081C0E00E1C07038060C07000381C03C03C00001C00700E0001C0383803800380703801C001C0070701C1C0F83C1C1F038060E070003C3C01E0F800001E00700E0001F0F03E0F0003C0F0380F80018003878381C03FF80FF3C380607FE0001FF800FFF00007FFF07C7FC000FFE00FFE0001FFE03FFF0003800383FF81C01FF007E1C380603FC0000FF0003FC00007FFF03C7FC0003FC007FC00007F803FFE0003800380FE01C000000000000000000000000000000000000000000000000000000000000000000000000000000001C000000000000000000000000000000000000000000000000000000000000000000000000000000001C0000000000000000000000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000F800000000000000000000000000000000000000000000000000000000000000000000000000000000F800000000000000000000000000000000000000000000000000000000000000000000000000000000E00^FS
|
||||
^FO27,77^GFA,1680,1680,42,000000000000000000000000000000000000000000000000000000000000000000000001F80000000000000000000000000000000000000000000000000000000000000000000000000000000003FC00000000000000000000000000000000000000000000000000000000000000000000000000000000039E00000000000000000000000000000000000000000000000000000000000000000000000000000000078E00000000000000000000000000000000000000000000000000000000000000000000000000000000078E00000000000000000000000000000000000000000000000000000000000000000000000000000000038E0000000000000000000000000000000000000000000000000000000000000000000000000000000003FE0000C00000000000000000000000000000000000000000000000000000000000000000000000000001FC0000F000000000000000780007C000000000000000000001E000000000000000000000000000000000F80001F00F000000000000F80007C0000F00003C0000000003E000000000000078000007800000000000000001E01F00FFFC000000F80007C0007FE001FF800003FC03E0FFE001FFF803FF00003FF00FFFC00001FC0003E01F80FFFE000000F80007C000FFF007FFC00007FC03E0FFE001FFF80FFFC0007FF80FFFE00001FC0003C01F80FFFF000000F80007C001FFF80FFFE00007FC03E0FFE001FFF81FFFC000FFFC0FFFF00003FC0003801F00FFFF800000F80007C001FFF80FFFF0000FFC03E0FFE001FFF81FFFC001F87E0FFFF80003FE0000000600F81F800000F80007C003F0FC1F83F8001F7C03E01F0001E0003F078001F03E0F81F80003FE0000000000F80F803E00F803C7C003E07C1F00F8003E7C03E01F0001E0003E010001F03E0F80F80007DE0007C00F00F80F80FF80F80FF7C003E07C3F00F8003E7C03E01F0001E0003E000001F03E0F80F80007DF001FF01F00F80F83FFC0F81FF7C007E07E3E007C003C7C03E01F0001E0003E000001F03E0F80F800079F007FF81F00F81F03FFE0F81FFFC007C03E3E007C00187C03E01F0001E7E03FC00000F87C0F81F0000F8F007FFC1F00FFFF07E3F0F83F0FC007C03E3E007C00007C03E01F0001CFF01FFC00007FF80FFFF0000F8F80F87E1F00FFFC07C1F0F83E07C007C03E3E007C00007C03E01F0003DFF81FFF80003FF00FFFC0000F0F80F83E1F00FFFE0781F0F83E07C007C03E3E007C00007C03E01F0003FFFC0FFFC0003FF00FFFE0001F0780F03E1F00FFFF0F81F0F83E07C007C03E3E007C00007C03E01F0003F0FC03FFC000FFFC0FFFF0001F07C1F83E1F00F81F8F81F8F87E07C007C03E3E007C00007C03E01F0000007E003FE001F87E0F81F8001FFFC1FFFE1F00F80FCF81F8F87E07C007E07E3E007C00007C03E01F0000007E0007E001F03E0F80FC003FFFC1FFFF1F00F807CF81F8F87E07C007E07E3E00FC00007C03E01F0000007E0003E003F03F0F807C003FFFE1FFFE1F00F807CF81F0F83E07C003E07C3F00F800007C03E01F0000007E0003E003F03F0F807C003FFFE1F0001F00F80FC7C1F0F83E07C003E07C1F01F800007C03E01F0001C07C1C03E003F03F0F80FC007E03E0F8001F00F81FC7C1F0F83F0FC003F0FC1FC3F800007C03E01F0003E0FC3F07E001F03E0F81FC007C01F0F8181F00FFFF87FFE0FE3FFFC001FFF80FFFF0001FFFE3F8FFE003FFFC3FFFC001FFFE0FFFF8007C01F0FFFC1F00FFFF83FFE07E1FFFC001FFF807FFE0001FFFE1F8FFE001FFF83FFFC000FFFC0FFFF800FC01F07FFE1F00FFFF01FFC07E0FF7C000FFF003FFC0001FFFE1F8FFE000FFF01FFF80007FF80FFFF000F801F83FFC1F00FFFC00FF001E07E7C0003FC001FF00001FFFE078FFE0007FC007FE00003FF00FFFC000F800F80FF01F00000000000000000000000000000000000000000000000000000000000000000000000000000000001F00000000000000000000000000000000000000000000000000000000000000000000000000000000001F00000000000000000000000000000000000000000000000000000000000000000000000000000000001F00000000000000000000000000000000000000000000000000000000000000000000000000000000007F00000000000000000000000000000000000000000000000000000000000000000000000000000000007F00000000000000000000000000000000000000000000000000000000000000000000000000000000007E00000000000000000000000000000000000000000000000000000000000000000000000000000000007800^FS
|
||||
^FO26,126^GFA,1960,1960,49,00000000000000000000000000000000000000000000000000000000000000000000000000000000000000003F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007F80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000071C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000071C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000071C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000071C000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007BC000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003F8000780000000000000000003C00000000000000000001E0000000000000000001E000000000000000000000000000001F0000F80E00F00000000000003C00000000000000000001E0001E0001C000000001F000000000000E0000078000000000000000F01F07FC0000000000003C00000000000000000001E0007F800FF80000FE01F1FFC007FFC07FC0003FF03FFF00003F8001E01F0FFF0000000000003C00000000000000000001E001FFC01FFE0001FE01F1FFC007FFC1FFF0007FF83FFF80003F8001E01F1FFF0000000000003C00000000000000000001E001FFE03FFF0003FE01F1FFC00FFFC1FFF800FFFC3FFF80007F8001C01F3FFF8000000000003C00000000000000000001E003F3F07FFF0003DE01F0FF800FFFC3FFF000F87C3FFFC0007F800000003E0FC000000000003C00000000000000000001E003E1F07C1F8007DE01F03E000F0003E06000F03C3E07C0007BC00000007C07007C01C3801E3C03E00E1C00F800F800F1E003C0F0F80F800F9E01F03E000F0003C00000F03C3E07C0007BC003E00E7C0001FF03EFE07F3C0FF81F7F03FF03FE03F9E007C0F8F80780071E01F03E000F0003C00000F03C3E07C000F3C00FF81F7C0003FF83EFE07FBC1FFC1F7F07FF87FF03FDE007C0F8F807C0001E01F03E000F0003E00000F03C3E07C000F3E01FFC1F7C0007FFC3FFF0FFFC3FFE1FFF87FF0FFF87FFE007C0F8F807C0001E01F03E000F3F03F0000078783E07C000F3E03FFE1F7C0007C7C3F1F0F87C3E3E1F8F8F860F8F87C3E007C0F8F807C0001E01F03E000F7F83FF00007FF83FFF8001F1E03E3E1F7C000F83C3E1F0F07C7C1E1F0F8F801F078783E007C0F8F807C0001E01F03E000F7FC1FFE0001FE03FFE0001F1E07C1E1FFC000F83E3E1F1F03C7C1F1F0F8F801F07CF81E007C0F9F807C0001E01F03E000FFFC0FFF0003FF03FFF0001E1F07C1F1F7C000F83E3E1F1F03C7C1F1F0F87F01F07CF81E007C0F8F807C0001E01F03E000F87E03FF8007FF83FFF8001E1F07C1F1F7C000F83E3E1F1F03C7FFF1F0F87FE1FFFCF81E007C0F8F807C0001E01F03E000003E003F800F87C3E07C003FFF07FFF1F7C000F83E3E1F1F03C7FFF1F0F83FF1FFFCF81E007C0F8F807C0001E01F03E000003E000F801F03E3E03E003FFF87FFF1F7C000F83E3E1F1F03C7C001F0F80FF9F000F81E007C0F8F807C0001E01F03E000003E000F801F03E3E03E003FFF87C001F7C020F83E3E1F1F03C7C001F0F800F9F000F81E007C0F8F80780001E01F03E000003E000F801F03E3E03E003FFF87C001F7C078F83E3E1F1F03C7C001F0F80079F000F81E003C0F0F80F80001E01F03E000603E180F801F03E3E03E007C0787C001F3E0F8783C3E1F0F87C3E0C1F0F86078F8307C3E003E1F07C1F80001E01F03E000F07E3C0F801F87E3E07E007807C3E0C1F3FFF87CFC3E1F0FCFC3F1E1F0F8F8F8FC787E7E003FFF07FFF0003FFF1F8FFC00FFFC7FFF800FCFC3FFFC007807C3F1E1F1FFF07FF83E1F0FFBC1FFE1F0F8FFF87FF87FDE001FFE03FFE0003FFF1FDFFC00FFFC3FFF000FFFC3FFFC00F803C1FFE1F0FFE03FF83E1F07F3C0FFC1F0F87FF03FF03F9E000FFC01FFC0003FFF0FDFFC007FF81FFE0007FF83FFF800F803E0FFC1F07FC00FE03E1F03F3C07F81F0F83FE01FE01F9E0007F800FF80003FFF07DFFC001FE007FC0003FF03FFF000F003E07F81F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000078^FS
|
||||
^FO26,176^GFA,2028,2028,52,00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C3000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000E70000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000070000000000000000000000000003C0003C00000000000000000000000000000000038000000003C0000000000000000000F0000F8F81F0000000000000000000000FF000FF000000007E01FF0007FFE0000007FFC00FF00000000FF007FF000000003C0000E0000F8F81F0000000000000000000003FF801FF80000000FE01FF0007FFE0000007FFC01FF80000001FF807FF800000003E0001C000078FC3F0000000000000000000003C3C03C3C0000001FE000700003C0000000700003C3C0000003C3C0707C00000007E00018000000EC37000000000000000000000781E0781E0000007CE000700003C000000070000781C000000781E0701E00000007E00000000000EC37000000000000000000000700E0700E00000078E000700003C000000070000700E000000700E0700E00000006E00000000000EE7700180000C000180000000700E0700E00000060E000700003C000000070000700E000000700E0700E0000000E700038000000E66700FF0077F800FF0000000700E0700E00000040E000700003C0000000700007000000000700E0700E0000000E7000FF007FF8E66701FF8077FC01FF8000000700E0700E00000000E000700003C0000000700007000000000700E0700E0000000E7001FF807FF8E6E703C3C07C3C03C3C000000700E0700E00000000E000700003C0000000700003800000000381C0701C0000000E700381C00078E7C70781E0781E0781E000000700E0700E00000000E000700003C00000007FF003E000000003C3C0703C0000001C380700E00078E3C70700E0700E0700E000000718E0700E00000000E000700003C00000007FF801FE00000000FF007FF80000001C380700E00078E3C70700E0700E0700E00000073CE0700E00000000E000700003C00000003FFC00FF80000000FF007FF00000001C380700E00078E3870700E0700E0700E00000073CE0700E00000000E000700003C0000000001E001FC0000003FFC07FFC0000001C380700E00078E0070700E0700E0700E000000718E0700E00000000E000700003C0000000000E0003E000000781E0701C000000381C07FFE00078E0070700E0700E0700E000000700E0700E00000000E000700003C0000000000E0000E000000700E0700E000000381C07FFE00078E0070700E0700E0700E000000700E0700E00000000E000700003C0000000000E0000E000000E0070700E0000003FFC07FFE00078E0070700E0700E0700E000000700E0700E00000000E000700003C0000000000E00007000000E007070070000003FFC0700000078E0070700E0700E0700E000000700E0700E00000000E000700003C0000000600E06007000000E007070070000007FFE0700000078E0070700E0700E0700E000000700E0700E00000000E000700003C0000000700E07006000000E0070700F000000700E0700000078E0070700E0700E0700E000000700E0700E00000000E000700003C0000000700E0700E000000700E0700E000000700E0700E00078E0070381C0700E0381C000000781E0381C00000000E000380003C0000000781E0781E000000781E0701E000000700E0381E00078E00703E7C0700E03E7C0000003C3C03E7C0000007FFF003FF03FFC0000003C7C03E7C0000003E7C07FFC000000E00703C3C00078E00701FF80700E01FF80000001FF801FF80000007FFF001FF07FFE0000001FF801FF80000001FF807FF8000000E00701FF800070E00700FF00700E00FF00000000FF000FF00000007FFF000FF07FFE0000000FF000FF00000000FF007FF0000000E00700FF0000700000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007F00^FS
|
||||
^FO27,225^GFA,2028,2028,52,0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001860000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000018600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000186000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001CE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780001F0000F00000000000000000000000000078000780000000000000000000000000000000007000000000780000000000000000001E0001F0FFE0000003FF000003C0000001FE001FE00000000FC03FF000FFFC000000FFFC03FE00000001FE00FFE00000000FC0003C0001F8FFF8000003FF000003C0000007FF807FF80000001FC03FF000FFFC000000FFFC07FF80000007FF80FFF80000000FC000780000F0FFFC000003FF000003C000000FFFC07FF80000007FC03FF000FFFC000000FFFC0FFF8000000FFFC0FFFC0000000FC00070000060F07C0000000F000003C000000F87C0F87C000000FFC000F0000780000000F0000F87C000000F87C0F07C0000001FE00000000000F03E0000000F000003C000000F03C0F03C000000FBC000F0000780000000F0000F03C000000F03C0F03E0000001FE00000000000F03E0078000F0000C3C000001E01E0F03C000000F3C000F0000780000000F0001F03C000000F03E0F03E0000001FE00030000000F03E01FE000F0003FBC000001E01E1F03E000000C3C000F0000780000000F0001F000000000F03E0F03E0000001CE001FE00FFF0F03C07FF800F0007FFC000001E01E1F03E00000083C000F0000780000000F0000F000000000F03C0F03C0000003CF007FF80FFF0F03C0FFFC00F000FFFC000001E01E1F03E00000003C000F0000780000000FE000F800000000F03C0F03C0000003CF007FF80FFF0F0F80F87C00F000F87C000001E01E1F03E00000003C000F0000780000000FFE00FF000000007CF80F0F80000003CF00F03C000F0FFF00F03C00F000F03C000001E79E1F03E00000003C000F0000780000000FFF807FE00000003FF00FFF000000038700F03C000F0FFF01F03E00F001F03C000001E79E1F03E00000003C000F0000780000000FFFC03FF80000003FF00FFF000000078781E01E000F0FFF81F03E00F001F03C000001E79E1F03E00000003C000F0000780000000007C00FFC0000007FF80FFF800000078781E01E000F0F07C1F03E00F001F03C000001E31E1F03E00000003C000F0000780000000003C001FC000000F87C0F07C00000078781FFFE000F0F03E1F03E00F001F03C000001E01E1F03E00000003C000F0000780000000003E0003E000001F03E0F03E00000078781FFFE000F0F01E1F03E00F001F03C000001E01E1F03E00000003C000F0000780000000003E0001E000001E01E0F01E000000FFFC1FFFE000F0F01E1F03E00F001F03C000001E01E1F03E00000003C000F0000780000000003E0001E000001E01E0F01E000000FFFC1E000000F0F01E1F03E00F001F03C000001E01E1F03E00000003C000F0000780000001E03E1E01E000001E01E0F01E000000FFFC1E000000F0F01E1F03E00F001F03C000001E01E0F03C00000003C000F0000780000001E03E1E01E000001E01E0F01E000000F03C1F000000F0F03E0F03C00F800F03C000000F03C0F03C00000003C000F8000780000001F03C1F03E000001F03E0F03E000001E01E0F03C000F0F1FC0FCFC00FFE0FCFC000000FCFC0FCFC000000FFFE00FFE0FFFC000000FCFC0FCFC000000F87C0F1FC000001E01E0F87C000F0FFFC07FF8007FF0FFFC0000007FF807FF8000000FFFE007FF0FFFC000000FFF80FFFC000000FFFC0FFFC000001E01E07FF8000F0FFF803FF0003FF07FBC0000003FF003FF0000000FFFE003FF0FFFC0000007FF007FF80000007FF80FFF8000001E01E03FF0000F0FFE001FE0001FF03F3C0000001FE001FE0000000FFFE001FF0FFFC0000001FE001FE00000001FE00FFE0000003E01F01FE0000F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFE00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFE00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFC00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FF00^FS
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 7.4 KiB |
@@ -1,7 +0,0 @@
|
||||
^XA^PW1200^LL600^LH0,0^CI28
|
||||
^FO51,52^GFA,6318,6318,81,000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003E0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003E3E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000780F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003C1E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF80000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007F0000000003800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E00000000000000000000000000000000000000000000000000000000000000000000000000000000003E00000000000000000000000000000000000000000000000000000000000000000000000000007C0000F800000000000000000000000000000000000000000000000000000000000000000000000000003E0000000000000000000000000000000000000000000000000000000000000000000000000000FC0001F8003FFE0000000000000000000000000000000000000FFC00000001FFE000000000000FF000003E003FFFF00000003FFFFFC00003FFE00000000007FF800003FFFF80000000000007F000000000F80001FC00FFFF8000000000000000000000000000000000007FFF8000000FFFF800000000001FF000003E003FFFF80000007FFFFFC0000FFFF8000000003FFFF00007FFFFFC00000000000FF000000001F00001FC03FFFFE00000000000000000000000000000000000FFFFC000003FFFFE00000000003FF000003E003FFFF80000007FFFFFC0003FFFFE000000007FFFF80007FFFFFF00000000000FF800000001F00001F807FFFFF00000000000000000000000000000000001FFFFE000007FFFFF0000000000FFF000003E003FFFF80000007FFFFFC0007FFFFF00000001FFFFFE0007FFFFFF80000000000FF800000003E00000F80FFFFFFC0000000000000000000000000000000003FFFFF00000FFFFFFC000000001FFF000003E003FFFF80000007FFFFFC000FFFFFFC0000001FFFFFE0007FFFFFFC0000000001FF800000003C00000201FE003FE0000000000000000000000000000000007F807F80001FF007FE000000003F9F000003E0000FC000000007C00000001FE003FE0000003FC00FF0007E0007FE0000000001FFC00000007C00000001F8000FE0000000000000000000000000000000007E001F80003FC001FE000000007F1F000003E00007C000000007C00000001F8000FE0000007F0003F8007E0000FE0000000001F7C00000007800000003F00007F000000000000000000000000000000000FC000FC0007F00007F00000000FE1F000003E00007C000000007C00000003F00007F0000007E0001F8007E00007F0000000003E7C00000001000000003F00003C000000000000000000000000000000000F80007C000FE00003F80000001FC1F000003E00007C000000007C00000003F00003C0000007E0001F8007E00003F0000000003E7E00000000000000003E000018000000000000000000000000000000001F80007E000FC00001F80000003F81F000003E00007C000000007800000003E000018000000FC0000FC007E00003F0000000003E3E00000000000000007E000000000000000000000000000000000000001F00003E001F800000FC0000007F01F000003E00007C000000007800000007E000000000000FC0000FC007E00001F8000000007C3E00000000000000007E000000000000000000000000000000000000003F00003E001F800000FC000000FE01F000003E00007C000000007800000007E000000000000FC0000FC007E00001F8000000007C3F00000000000000007E0000000000FFC0000000FF000000FFC00000003F00003F001F0000007E000000FC01F000003E00007C00000000F800000007E0000000000Line truncated
|
||||
^FO53,149^GFA,6640,6640,83,0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003F80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFE0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFF0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FFFC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007F1FE00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FE0FE00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FE0FE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007F0FE000000003000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FFFC000000007C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFF8000000007F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFF000000000FFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFE000000000FFC0007C00000000000000000000003FE00000001FF8000000000000000000000000000000000000000003FE0000000000000000000000000000000000000000000000000000000000000000003F8000000001FF8001FF00000000000000000000003FE00000001FF8000000000000000000000000000000000000000003FE000000000000000000000000000000000000000000000000000000000000000000000000000003FF0003FF807FFFFE000000000000003FE00000001FF800000000FFF00000000FFF000000000001FFF80003FE007FFFFC0000007FFFFFF000007FFC00000000007FF800007FFFFE000000000000FFF000000003FE0003FF80FFFFFFF00000000000003FE00000001FF800000003FFFC0000007FFFE00000000003FFFC0003FE00FFFFFC000000FFFFFFF80003FFFF8000000003FFFF0000FFFFFFF00000000001FFF800000007FE0003FF80FFFFFFFC0000000000003FE00000001FF80000000FFFFF000001FFFFF80000000007FFFC0003FE00FFFFFC000000FFFFFFF8000FFFFFE00000000FFFFFC000FFFFFFFC0000000001FFF800000007FC0003FF80FFFFFFFE0000000000003FE00000001FF80000001FFFFF800007FFFFFC000000000FFFFC0003FE00FFFFFC000000FFFFFFF8001FFFFFF80000001FFFFFE000FFFFFFFE0000000001FFF80000000FF80003FF80FFFFFFFF0000000000003FE00000001FF80000003FFFFFC0000FFFFFFE000000001FFFFC0003FE00FFFFFC000000FFFFFFF8003FFFFFFC0000003FFFFFF000FFFFFFFF0000000003FFFC0000000FF00003FF80FFFFFFFF8000000000003FE00000001FF80000007FFFFFE0001FFFFFFF000000001FFFFC0003FE00FFFFFC000000FFFFFFF8007FFFFFFE0000007FFFFFF800FFFFFFFF8000000003FFFC0000000FE00003FF80FFFFFFFFC000000000003FE00000001FF8000000FFFFFFF0003FFFFFFF800000003FFFFC0003FE00FFFFFC000000FFFFFFF800FFFFFFFE000000FFFFFFFC00FFFFFFFFC000000003FFFC00000007E00001FF00FFFFFFFFC000000000003FE00000001FF8000000FFFFFFF0003FFFFFFFC00000007FFFFC0003FE00FFFFFC000000FFFFFFF801FFFFFFFC000000FFF87FFC00FFFFFFFFC000000007FFFC00000001C00000FE00FFFFFFFFC000000000003FE00000001FF8000001FFF0FFF8007FFFFFFFE0000000FFEFFC0003FE007FFFFC000000FFFFFFF801FFFFFFF8000000FFE01FFC00FFFFFFFFC000000007FFFE000000000000000000FFC001FFE000000000003FE00000001FF8000001FFC03FF800FFFC03FFE0000000FFCFFC0003FE0003FF00000000FF00000001FFC00FF0000001FFC00FFE00FFC001FFE000000007FFFE0000000000Line truncated
|
||||
^FO51,248^GFA,7840,7840,98,0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003F80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFE0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFF0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FFFC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E0FC00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC07E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E0FC0000000C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FFFC0000000F8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFF80000001FE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFF80000001FF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFE00000003FF0003F0000000000000000000000000000000007FE0000000000000000000000000000000000000000FFC00000000000000000000000000000000000007FC000000000000000000000000000000000000000000000000000000000003F800000003FE0007F8000000000000000000000000000000007FE0000000000000000000000000000000000000000FFC00000000000000000000000000000000000007FC0000000000000000000000000000000000000000000000000000000000000000000007FC000FFC0007FF800000000000000000000000007FE0000000000000000000000000000000000000000FFC0000000FFF0000001FFE0000000000FFF80007FC03FFFFC000003FFFFFE0000FFF8000000001FFE0000FFFFF80000000000FFE00000007FC000FFC003FFFE00000000000000000000000007FE0000000000000000000000000000000000000000FFC0000003FFFC000007FFFC000000001FFFC0007FC03FFFFC000007FFFFFE0003FFFF00000000FFFFC000FFFFFF8000000001FFF00000007F8000FFC00FFFFF80000000000000000000000007FE0000000000000000000000000000000000000000FFC000000FFFFF00001FFFFF000000003FFFC0007FC03FFFFC000007FFFFFE000FFFFFC0000001FFFFE000FFFFFFE000000001FFF0000000FF0000FFC01FFFFFC0000000000000000000000007FE0000000000000000000000000000000000000000FFC000001FFFFF80003FFFFF800000007FFFC0007FC03FFFFC000007FFFFFE001FFFFFE0000007FFFFF800FFFFFFF000000001FFF8000000FF0000FFC03FFFFFE0000000000000000000000007FE0000000000000000000000000000000000000000FFC000003FFFFFC0007FFFFFC00000007FFFC0007FC03FFFFC000007FFFFFE003FFFFFF8000007FFFFF800FFFFFFF800000003FFF8000001FE0000FFC07FFFFFF0000000000000000000000007FE0000000000000000000000000000000000000Line truncated
|
||||
^FO53,349^GFA,8112,8112,104,000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FE00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F87C000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F03E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001E01E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001E01E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001E01E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001E01E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F03E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F03C000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FF80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC0000000003F000000003C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007E000000007F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FC00000000FF80FF8007FC000000000000000000000000000000000000000000003FF00000003FF000000000000000000FF00007FFFE0000003FFFFFF00000000000003FFFFFE000003FF000000000000000003FF000007FFF8000000000000000001FE000000001FC00000000FF80FF8007FC00000000000000000000000000000000000000000001FFFE000001FFFE00000000000000003FF00007FFFF0000003FFFFFF00000000000003FFFFFF00001FFFC0000000000000001FFFE00007FFFFC00000000000000001FE000000001F800000000FF80FF8007FC00000000000000000000000000000000000000000003FFFF000003FFFF00000000000000007FF00007FFFF0000003FFFFFF00000000000003FFFFFF00003FFFF0000000000000003FFFF00007FFFFF00000000000000003FF000000003F000000000FF80FFC00FFC0000000000000000000000000000000000000000000FFFFFC00007FFFF8000000000000000FFF00007FFFF0000003FFFFFF00000000000003FFFFFF00007FFFF800000000000000FFFFFC0007FFFFF80000000000000003FF000000007E000000000FF80FFC00FFC0000000000000000000000000000000000000000000FFFFFC0000FFFFFC000000000000003FFF00007FFFF0000003FFFFFF00000000000003FFFFFF0000FFFFFC00000000000000FFFFFC0007FFFFFC0000000000000003FF00000000FC0000000007F00FBC00F7C0000000000000000000000000000000000000000001FE01FE0001FF03FE000000000000007FFF00007FFFF0000003FFFFFF00000000000003FFFFFE0001FF03FE00000000000001FF03FE0007FFFFFE0000000000000003FF00000000FC0000000001C00FBC01F7C0000000000000000000000000000000000000000003FC00FF0001FC00FE00000000000000FFBF00000003F000000000FC0000000000000003E000000003FC00FE00000000000003FC00FF0007E001FF0000000000000007FF80Line truncated
|
||||
^FO53,447^GFA,8216,8216,104,0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FCFE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F03E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003E01F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003E01F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003E01F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003E01F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003F03F000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F87E000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFFE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FF80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FE000000000FF800000007F0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FF00000000FF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF00000000FFC0FFFF8000000000000007FFFF800000000003FC000000000000007FF80000007FF800000000000000000FF80007FFFF8000007FFFFFF80000000000007FFFFFF000007FF800000000000000007FF80000FFFF8000000000000000003FF000000003FE00000001FFC0FFFFFC00000000000007FFFF800000000003FC00000000000001FFFE000001FFFE00000000000000003FFC0007FFFF8000007FFFFFF80000000000007FFFFFF80001FFFE0000000000000001FFFE0000FFFFFC00000000000000007FF800000007FC00000001FFC0FFFFFF00000000000007FFFF800000000003FC00000000000007FFFF800007FFFF80000000000000007FFC0007FFFF8000007FFFFFF80000000000007FFFFFF80007FFFF8000000000000007FFFF8000FFFFFF00000000000000007FF800000007F800000001FFC0FFFFFFC0000000000007FFFF800000000003FC0000000000000FFFFFC0000FFFFFC000000000000001FFFC0007FFFF8000007FFFFFF80000000000007FFFFFF8000FFFFFC00000000000000FFFFFC000FFFFFFC0000000000000007FF80000000FF000000000FFC0FFFFFFE0000000000007FFFF800000000003FC0000000000001FFFFFE0001FFFFFE000000000000003FFFC0007FFFF8000007FFFFFF80000000000007FFFFFF8001FFFFFE00000000000001FFFFFE000FFFFFFE0000000000000007FF80000001FF000000000FF80FFFFFFF0000000000007FFFF800000000003FC0000000000003FFFFFF0003FFFFFF000000000000007FFFC0007FFFF8000007FFFFFF80000000000007FFFFFF8003FFFFFF00000000000003FFFFFF000FFFFFFF000000000000000FFFC0Line truncated
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -1,3 +0,0 @@
|
||||
^XA^PW406^LL203^LH0,0^CI28
|
||||
^FO16,16^GFA,8037,8037,47,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE000001FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC8000000000000000000000000000000000000000000200000100000000000000000000000000000000000000000004800000000000000000000000000000000000000000020000010000000000000000000000000000000000000000000480000000000000000000000000000000000000000002000001000000000000000000000000000000000000000000048000000000000000000000000000000000000000000200000100000000000000000000000000000000000000000004800000000000000000000000000000000000000000020000010000000000000000000000000000000000000000000480000000000000000000000000000000000000000002000001000000000000000000000000000000000000000000048000000000000000000000000000000000000000000200000100000000000000000000000000000000000000000004800000000000000000000000000000000000000000020000010000000000000000000000000000000000000000000480000000000000000000000000000000000000000002000001000000000000000000000000000000000000F000000480F0000FC03FFF0007E0003C00F8001F003F001F80020000010000000000000000000000000FF0003FFC00F000000480F0003FF03FFF800FF8007E03FE003E007F003F80020000010000000000000000000000003FF8003FFC00F000000480F0007FF83FFF801FFC00FE07FF007C00FF007F80020000010000000000000000000000003FFC003FFC00F00001E480F000FFFC3FFF803FFE00FE0FFF80F801FF00FF80020000010000000000000000000000007C7C003C0000F00001E480F000F87E01E0007C3E01FE0F0781F001EF00F78002000001000000000000000000000000783C00380000F00003E480F001F03E01E000381E03FE1F0783E003CF01E78002000001000000000000000000000000783C00380000F0F0FF0480F001E01E01E000001E03BE1F07C3C0078F03C78002000001000000000000000000000000783E00380000F1F1FF8480F001E01E01E000003E07BE1E07C7CE038F01C78002000001000000000000000000000000783E0039F000F3E1E7C480F001E01F01E000003E0F3E1E07C7BF800F000780020000010000000000000000000000007C3E003BF800F3C3C3C480F003E01F01E000007C0E3E1E07C7FFC00F000780020000010000000000000000000000007FFC003BFC00F783C3C480F003E01F01E00000F81E3E1E07CFFFC00F000780020000010000000000000000000000003FFC003E7C00F783C3C480F003E01F01E00001F01C3E1E07CF83E00F000780020000010000000000000000000000001FBC00083E00FF83E3C480F001E01F01E00003E03C3E1E07CF83E00F000780020000010000000000000000000000000E7C00003E00FF81FF8480F001E01E01E00007C03FFF9F07CF81E00F00078002000001000000000000000000000000007800003E00FFC0FF0480F001F01E01E0000F803FFF9F07CF83E00F0007800200000100000000000000000000000000F800003E00FBC0FC0480F001F03E01E0003E003FFF8F078783E00F0007800200000100000000000000000000000001F018383E00F3E1800480FFF0FFFC01E0003FFE003E0F8F87C7C00F8007C00200000100000000000000000000000001F03C7C7C00F1E3C00480FFF07FFC01E0003FFF003E07FF03FFC1FFF0FFF80200000100000000000000000000000003E03C3FFC00F1F3FFC480FFF03FF801E0003FFF003E07FE03FF81FFF0FFF8020000010000000000000000000000000FC03C1FF800F0F1FFE480FFF01FE001E0003FFF003E01FC00FE01FFF0FFF8020000010000000000000000000000001F803C0FF000F0F8FFE480000000000000000000000000000000000000000002000001000000000000000000000000000000000000000181E480000000000000000000000000000000000000000002000001000000000000000000000000000000000000000380F480000000000000000000000000000000000000000002000001000000000000000000000000000000000000000381E4800000000000000000000000000000000000000000020000010000000000000000000000000000000000000003FFE4800000000000000000000000000000000000000000020000010000000000000000000000000000000000000003FFC4800000000000000000000000000000000000000000020000010000000000000000000000000000000000000000FF8480000000000000000000000000000000000000000002000001000000000000000000000000000000000000000000048000000000000000000000000000000000000000000200000100000000000000000000000000000000000000000004FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE000001FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE00000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000800000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000808000000C000000000000000000000000000000000000000000020000003000000000000000000000000002000000838000000E0000000000000000000000000000000000000000000E00000038000000000000000000000000020000008783E01E07007C0F80F80F03E07F80E0FF0387FE1F03E03E0FF01E0781F01C07003E00E0F80380F00F01CLine truncated
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,3 +0,0 @@
|
||||
^XA^PW600^LL300^LH0,0^CI28
|
||||
^FO24,24^GFA,17388,17388,69,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000000200000000400000000000000000000000000000000000000000000000000001F8000000001800000000000000000000000000000000000000000000000000000000000000020000000040000000000000000000000000000000000003F000000000000001F800000000180000000000300000000000000180000000000020000000000000000000000002000000004000000000000000000000000000000000001FFE000003FFFE0001F80000000018007F000007FF001FFFFF80001FFC00007F8007FF00003FE000FFC0007FE00002000000004000000000000000000000000000000000003FFF000007FFFE0001F80000000018007F00000FFFC01FFFFF80003FFE00007F800FFF80007FC001FFC000FFE00002000000004000000000000000000000000000000000007FFF800007FFFE0001F80000000018007F00003FFFE01FFFFF8000FFFF0000FF801FFFC000FF0003FFC001FFE0000200000000400000000000000000000000000000000000FFFFC00007FFFE0001F80000001F18007F00007FFFF01FFFFF8001FFFF8001FF803FFFE001FE0003FFC001FFE0000200000000400000000000000000000000000000000001FF3FC00007FFFE0001F80000003F18007F00007FFFF81FFFFF8001FFFFC001FF807FFFF003FC0007FFC003FFE0000200000000400000000000000000000000000000000001FC0FE00007E0000001F80000003F18007F0000FF07FC0FFFFF8003FC3FC003FF807F07F007F8000FEFC007F7E0000200000000400000000000000000000000000000000001F807E00007C0000001F80000003F18007F0000FE01FC001F800003F01FC007FF807E03F007F0001FCFC00FE7E0000200000000400000000000000000000000000000000003F807E00007C0000001F80FC03FFF18007F0001FC00FE001F800001F00FC007DF80FE03F80FE0001F8FC00FC7E0000200000000400000000000000000000000000000000003F807E00007C0000001F81FC0FFF018007F0001F800FE001F800000200FC00F9F80FC01F81FC0003F0FC01F87E0000200000000400000000000000000000000000000000003F807F00007C0000001F81FC1FFF818007F0001F8007E001F800000000FC00F9F80FC01F81F80003F0FC01F87E0000200000000400000000000000000000000000000000003F807F00007C0000001F83F83FFFC18007F0003F8007E001F800000001FC01F1F80FC01F83F8F001E0FC00F07E0000200000000400000000000000000000000000000000003F807F00007C7F00001F87F07F0FC18007F0003F8007E001F800000001FC03F1F81FC01FC3F3FE0040FC00207E0000200000000400000000000000000000000000000000003F807F00007CFFC0001F8FE07E07E18007F0003F8007F001F800000003F803E1F81FC01FC3F7FF0000FC00007E0000200000000400000000000000000000000000000000001FC0FF00007DFFE0001F8FC07E07E18007F0003F8007F001F800000003F807C1F81FC01FC7FFFF8000FC00007E0000200000000400000000000000000000000000000000001FF3FE00007DFFF0001F9FC07E07E18007F0003F8007F001F800000007F00FC1F81FC01FC7FFFF8000FC00007E0000200000000400000000000000000000000000000000001FFF7E00007FFFF0001F9F807E07E18007F0003F8007F001F80000001FE00F81F81FC01FC7F83FC000FC00007E0000200000000400000000000000000000000000000000000FFF7E00007F07F0001FBF007E07E18007F0003F8007F001F80000003FC01F81F81FC01FC7F01FC000FC00007E00002000000004000000000000000000000000000000000007FEFE00007C03F8001FFF007E07E18007F0003F8007E001F80000007F801F01F81FC01FC7F00FC000FC00007E00002000000004000000000000000000000000000000000003FCFE00000003F8001FFF803F9FE18007F0003F8007E001F8000000FF003F01F80FC01F87E00FE000FC00007E00002000000004000000000000000000000000000000000000F0FC00000003F8001FFF803FFFC18007F0001F8007E001F8000001FE003FFFFF8FC01F87E00FE000FC00007E0000200000000400000000000000000000000000000000000001FC00000003F8001FFFC01FFF81800Line truncated
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 8.4 KiB |
@@ -1,5 +0,0 @@
|
||||
^XA^PW1200^LL600^LH0,0^CI28
|
||||
^FO48,48^GFA,16402,16402,139,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE00000000000000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFE00000000000000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000180C000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006000000000000000030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000Line truncated
|
||||
^FO48,178^GFA,12220,12220,130,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000006C0000000000000000000000000000Line truncated
|
||||
^FO48,284^GFA,28783,28783,107,FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC0FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFC0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000C0C0000001800000000000000000000600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000060000000000000000000018000000000000000000000000C0C0000007800000000000000000000780000000000000000Line truncated
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,3 +0,0 @@
|
||||
^XA^PW406^LL203^LH0,0^CI28
|
||||
^FO26,26^GFA,6795,6795,45,0000300000000000000003C03FE00003C07C01E03E03E0001FF07FE1E01E01E0000000000000000000000000000000780000000000000003C0FFF00003C03C01E03E07E0003FF87FE1E01E01E0000000000000000000000001C00000780000000000000003C0FFF80003C03C03E07E07E0007FFE7FE1E01E01E0000000000000000000000001C00000780000000000000003C1FDF80003C03E03C07F07F000FEFC1F01E01E01E0000000000000000000000001C00000780000000000000003C1F07C0003C01E03C07F07F000F8180F01E01E01E0000000000000000000000001C00000780000000000000003C1E07C0003C01FFFC07F07F000F0000F01E01E01E0000000000000000000000001C0FFFFF80000000000000003C1E03C0003C01FFF80F70FF000F0000F01E01E01E000000000000000000007FFFFC0FFFFF800000000000003FFC1E03C0003C00FFF80F78E7000F8000F01E01E01E000000000000000000007FFFFC0FFFFF800000000000007FFC1E03C0007C00F0F80F78E7800FFC00F01FFFE01E000000000000000000007FFFFC00000780000000000000FFFC3E03C0007E00F8F00E38E78007FF00F01FFFE01E0000000000000000000078001C00000780000000000001FFFC3E03C000FF0078F00E39E78003FF80F01FFFE01E000000000000000000003C001C00000780000000000001E03C3E03C000FF0078F01E3DC78000FFC0F01FFFE01E000000000000000000001C001C00000780000000000001E03C3E03C001EF8079E01E3DC3C00007C0F01E01E01E000000000000000000001E001C00000780000000000001E03C3E03C003E7803DE01E3DC3C00003C0F01E01E01E000000000000000000000F001C00000300000000000001E03C3E03C003C7C03DE01C1FC3C00003C0F01E01E01E0000000000000000000007001C00000000000000000001F03C3E03C007C3C03FC03C1FC3C00307C0F01E01E01E0000000000000000000000000000000000000000000001FFFC3E03C00783E01FC03C1F83C007FFC7FC1E01E7FFF000000000000000000000000000000000000000000000FFFC3E03C00F81F01FC03C1F81E00FFF87FE1E01E7FFF00000000000000000000000000FFFFF000000000000007FFC3E03C00F01F01F803C0F81E003FF07FE1E01E7FFF00000000000000000000000000FFFFF800000000000001FFC1C03C01F00F00F80380F01E000FC07FC1E01E3FFF000000000000000000007FFE00FFFFF8000000000000000000000000000000000000000000000000000000000000000000000000000001FFFF00FFFFF8000000000000000000000000000000000000000000000000000000000000000000000000000003FFFF80E07078000000000000000000000000000000000000000000000000000000000000000000000000000003C007C0E070780000000000000000000000000000000000000000000000000000000000000000000000000000078001C0E070780000000000000000000000000000000000000000000000000000000000000000000000000000070381C0E070780000000000000000000000000000000000000000000000000000000000000000000000000000070381C0E070780000000000000000000000000000000000000000000000000000000000000000000000000000070381C0E070780000000000000000000000000000000000000000000000000000000000000000000000000000078001C0E07078000000000000000000000000000000000000000000000000000000000000000000000000000003C007C0E07078000000000000000000000000000000000000000000000000000000000000000000000000000003FFFF80E00030000000000000000000000000000000000000000000000000000000000000000000000000000001FFFF000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FFE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001C07800000000000000000000000000000000000000000000000000000000000000000000000000000007FFE007C1FE0000000000000000000000000000000000000000000000000000000000000000000000000000001FFFF007C3FF0000000000000000000000000000000000000000000000000000000000000000000000000000003FFFF80F03FF0000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000003C007C0E07878000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000078001C0E07838000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000070381C0E07038000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000070381C0E07038000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000070381C0E0F078000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000078001C0F1F1F0000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000003C007C07FE1F0000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000003FFFF807FC1E0000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000001FFFF001F8180000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000007FFE00000000000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000000000000000000000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000000000000000000000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000000000000000030000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000000000000000078000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000007FFE00000078000003FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000001FFFF00000078000003FFFFFFFFFFFFFFFFFFFFE03FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF8000000000003FFFF80000078000003FFFFFFFFFFFLine truncated
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 2.1 KiB |
@@ -1,3 +0,0 @@
|
||||
^XA^PW600^LL300^LH0,0^CI28
|
||||
^FO40,39^GFA,14430,14430,65,000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001F0000003C000000000000000000000003F8007FF00000007F003F8001F803FC00FE000000FFE00FFFC0FC007F000FC000000000000000000000000000000000001F0000003C000000000000000000000003F801FFFC0000007F001F8003F803FC00FF000003FFF80FFFC0FC007F000FC000000000000000000000000000000000001F0000003C000000000000000000000003F803FFFE0000007F001FC003F003FC01FF000007FFFE0FFFC0FC007F000FC000000000000000000000000000000000001F0000003C000000000000000000000003F807FFFF0000007F001FC003F003FC01FF00000FFFFF0FFFC0FC007F000FC000000000000000000000000000000000001F0000003C000000000000000000000003F80FFFFF8000007F000FC007F007FE01FF00001FFFFF0FFFC0FC007F000FC000000000000000000000000000000000001F0000003C000000000000000000000003F80FF07F8000007F000FE007E007FE01FF00001FC0FE00FC00FC007F000FC000000000000000000000000000003FFFFFFF0000003C000000000000000000000003F80FE03F8000007F000FE007E007FE03FF80003F803C00FC00FC007F000FC000000000000000000000000000003FFFFFFF0000003C000000000000000000000003F80FC01FC000007F0007FFFFE007FE03FF80003F801800FC00FC007F000FC000000000000000000000000000003FFFFFFFFFFFFFFC000000000000000000000003F81FC01FC000007F0007FFFFC00FFF03EF80003F000000FC00FC007F000FC000000000000000000000000000003FFFFFFFFFFFFFFC000000000000000000000003F81F800FC000007F0007FFFFC00FDF03EF80003F800000FC00FC007F000FC000000000000000000000000000003FFFFFFFFFFFFFFC0000000000000000000000FFF81F800FC000007F0003FFFFC00F9F03CFC0003F800000FC00FC007F000FC000000000000000000000000000003FFFFFFFFFFFFFFC000000000000000000001FFFF81F800FC000007F0003FFFF800F9F07CFC0003FE00000FC00FC007F000FC000000000000000000000000000003E00001FFFFFFFFC000000000000000000007FFFF81F800FC000007F0003F81F800F9F87CFC0001FFF8000FC00FFFFFF000FC000000000000000000000000000003F00001F0000003C00000000000000000000FFFFF81F800FC00000FF8001F81F801F8F87CFC0001FFFE000FC00FFFFFF000FC000000000000000000000000000001F80001F0000003C00000000000000000000FFFFF81F800FC00000FF8001F81F001F8F8787C0000FFFF800FC00FFFFFF000FC000000000000000000000000000000F80001F0000003C00000000000000000001FFFFF81F800FC00001FFC001F83F001F0F8F87E00007FFFC00FC00FFFFFF000FC000000000000000000000000000000FC0001F0000003C00000000000000000001FC03F81F800FC00003FFE000FC3F001F0F8F87E00001FFFE00FC00FFFFFF000FC0000000000000000000000000000007E0001F0000003C00000000000000000003F803F81F800FC00003FFE000FC3E003F0FCF87E000001FFE00FC00FFFFFF000FC0000000000000000000000000000003F0001F0000003C00000000000000000003F803F81F800FC00007F7F000FC7E003F07CF87E0000001FE00FC00FC007F000FC0000000000000000000000000000001F0000F0000003C00000000000000000003F803F81F800FC00007E7F0007E7E003F07DF03F00000007E00FC00FC007F000FC00000000000000000000000000000000000000000003C00000000000000000003F803F81F800FC0000FE3F8007E7C003E07DF03F00000007F00FC00FC007F000FC00000000000000000000000000000000000000000000000000000000000000003F803F81F800FC0000FC3F8007EFC007E07FF03F00000007E00FC00FC007F000FC00000000000000000000000000000000000000000000000000000000000000001F803F81F800FC0001FC1FC003FFC007E03FF03F00003007E00FC00FC007F000FC00000000000000000000000000000000000000000000000000000000000000001FE03F81F800FC0003F80FC003FF8007E03FE03F80007C0FE00FC00FC007F0FFFFF8000000000000000000000000000000000000000000000000000000000000001FFFFF81F800FC0003F80FE003FF8007E03FE01F8000FFFFE0FFFC0FC007F0FFFFFC000000000000000000000000000000000000000000000000000000000000000FFFFF81F800FC0007F007F001FF8007C03FE01F8001FFFFC0FFFC0FC007F0FFFFFC00000000000000000000000000001FFFFE000000000000000000000000000007FFFF81F800FC0007F007F001FF000FC01FE01F8000FFFF80FFFC0FC007F0FFFFFC00000000000000000000000000007FFFFF8FFFFFFFC000000000000000000003FFFF81F800FC000FE003F801FF000FC01FC01F80007FFF00FFFC0FC007F0FFFFFC0000000000000000000000000000FFFFFFCFFFFFFFC000000000000000000000FFFF81F800FC000FE003F800FF000FC01FC01FC0001FFC00FFFC0FC007F0FFFFFC0000000000000000000000000001FFFFFFEFFFFFFFC00000000000000000000000000000000000000000000000000000000000000001C0000000000000000000000000000000000000000000000003FFFFFFEFFFFFFFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003F00003FFFFFFFFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003E00001FF807C03C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007C01E00FF807C03C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007C03E00FF807C03C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007C03E00FF807C03C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007C03E00FF807C03C0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007C01E00FF807C03C00000000000000000000000Line truncated
|
||||
^XZ
|
||||
|
Before Width: | Height: | Size: 5.2 KiB |
@@ -1,5 +0,0 @@
|
||||
^XA^PW1200^LL600^LH0,0^CI28
|
||||
^FO81,79^GFA,11830,11830,130,0000000000001FF80000000000000000000000000000000000000000000000000000000007FFC000000000000000000000000000000000000000000000000000000000000000000007FF80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF800000000000000000000000000000000000000000000000FFF800000FFFFFC000000000000007FF800000FFF8000000FFF80001FFF800003FFF80000000000007FFFF80000FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF800003FFFFFF000000000000007FF800000FFF8000000FFF80003FFFC00003FFF8000000000003FFFFFE0000FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF80000FFFFFFFC00000000000007FF800000FFFC000000FFF80003FFFC00007FFF800000000000FFFFFFF8000FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF80001FFFFFFFE00000000000007FF8000007FFC000001FFF00003FFFC00007FFF800000000001FFFFFFFE000FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF80003FFFFFFFF00000000000007FF8000007FFC000001FFF00003FFFC00007FFF800000000003FFFFFFFF000FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF80007FFFFFFFF80000000000007FF8000007FFC000001FFF00003FFFE00007FFFC00000000007FFFFFFFFC00FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF8000FFFFFFFFFC0000000000007FF8000003FFE000001FFE00007FFFE0000FFFFC0000000000FFFFFFFFFE00FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF8000FFFFFFFFFE0000000000007FF8000003FFE000003FFE00007FFFE0000FFFFC0000000001FFFFFFFFFF00FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF8001FFFFFFFFFE0000000000007FF8000003FFE000003FFE00007FFFE0000FFFFC0000000003FFFFFFFFFF80FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF8001FFFFFFFFFF0000000000007FF8000001FFF000003FFC00007FFFE0000FFFFE0000000003FFFFFFFFFF00FFFFFFF003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF8003FFFF03FFFF0000000000007FF8000001FFF000007FFC0000FFFFF0001FFFFE0000000003FFFE03FFFE0000FFF00003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF8003FFF8007FFF0000000000007FF8000001FFF000007FFC0000FFFFF0001FFFFE0000000007FFF0007FFC0000FFF00003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000003FE0000000000001FF800000000000000000000000000000000000000000000000FFF8003FFF0003FFF8000000000007FF8000000FFF800007FF80000FFFFF0001FFFFE0000000007FFE0001FF80000FFF00003FFE000007FFC000003FFE0000000000000000000000000000000000000000000000000000000000000000000000007FE0000000000001FF800000000000000000000000000000000000000000000000FFF8007FFE0001FFF8000000000007FF8000000FFF80000FFF80000FFFFF0001FFFFE0000000007FFC00007F00000FFF00003FFE000007FFC000003FFE000000000000000000000000000000000000000000000000000000000003FFFFFFFFFFFFFFE0000000000001FF800000000000000000000000000000000000000000000000FFF8007FFC0000FFF8000000000007FF8000000FFFFFFFFFFF80001FFFFF8001FFFFF000000000FFF800003E00000FFF00003FFE000007FFC000003FFE000000000000000000000000000000000000000000000000000000000003FFFFFFFFFFFFFFEFFFFFFFFFFFFFFF800000000000000000000000000000000000000000000000FFF8007FFC0000FFF8000000000007FF80000007FFFFFFFFFF00001FFDFF8003FF7FF000000000FFF800001C00000FFF00003FFE000007FFC000003FFE000000000000000000000000000000000000000000000000000000000003FFFFFFFFFFFFFFEFFFFFFFFFFFFFFF800000000000000000000000000000000000000000000000FFF8007FF800007FF8000000000007FF80000007FFFFFFFFFF00001FFDFF8003FF7FF000000000FFF800000800000FFF00003FFE000007FFC000003FFE000000000000000000000000000000000000000000000000000000000003FFFFFFFFFFFFFFEFFFFFFFFFFFFFFF800000000000000000000000000000000000000000000000FFF8007FF800007FFC000000000007FF80000007FFFFFFFFFF00001FF9FF8003FE7FF000000000FFF800000000000FFF00003FFE000007FFC000003FFE000000000000000000000000000000000000000000000000000000000003FFFFFFFFFFFFFFEFFFFFFFFFFFFFFF800000000000000Line truncated
|
||||
^FO80,180^GFA,31571,31571,131,0007C00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FC000003FC00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFC00001FFF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFFFFFFFF8000001FFC00007FFFE00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFFFFE000003FFC0001FFFFF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFFFFFFFFFF800007FFC0003FFFFF80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FFFFFFFFFFFC0000FFFC0007FFFFFC000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFFFFFFE0001FFFC000FFFFFFE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FFFFFFFFFFFFF0001FFFC000FFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFFFFFFFFFFFF8003FFFC001FFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000007FFFFFFFFFFFFFC003FFE0003FFFFFFF8000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF800000000000000000000000000000000007FFFFFFFFFFFFFC003FF80003FFF0FFF8000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000000FFFFFFFFFFFFFFE007FF00007FFC03FFC000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000000FFF000000001FFE007FE00007FF800FFC000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000001FFE0000000007FF007FE00007FF000FFC000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000001FF80000000003FF007FC0000FFE0007FC000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000001FF80000000003FF00FFC0000FFE0007FC000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000001FF00003F80001FF00FFC0000FFE0007FE000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000001FF00007FC0001FF80FF80000FFC0003FE000000000000000007FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF80000000000000000000000000000000003FF00007FLine truncated
|
||||
^FO80,431^GFA,11830,11830,130,000FFFFFFFFFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003FFFFFFFFFF8000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF00000000001FF00FFFFFFFFFFFE000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF00000000001FF01FFFFFFFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF00000000001FF03FFFFFFFFFFFF800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF07FFFFFFFFFFFFC00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF0FFFFFFFFFFFFFE00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF1FFFFFFFFFFFFFF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF1FFFFFFFFFFFFFF00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF3FFFFFFFFFFFFFF80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF3FFC000000007FF80000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF7FF0000000001FFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF7FE0000000000FFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF7FE0000000000FFC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FF7FC0000FE00007FC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FFFFC0001FF00007FC0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FFFF80001FF00007FE0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FFFF80001FF80003FE0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FFFF80003FF80003FE0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001FF0000FF80001FFFF80003FF80003FE0000000000000Line truncated
|
||||
^XZ
|
||||