Files
qbit-document-generation/foundation/src/main/java/com/kingsrook/qbits/documentgeneration/GenerationPreviewWidget.java
T
james.maesandClaude Fable 5.2 05518acd02
maven / build (push) Successful in 6m59s
build / maven (push) Successful in 6m59s
fix(renderer): scene 2 barcodes after independent review: no symbol reads as another value, rules by symbology, nothing painted over a symbol
A label is scanned, so a symbol must never read as something other than
the value it was given. Record values are untrusted and stay out of every
diagnostic and exception.

- No value, literal or bound, may hold the two characters an escape of the
  encoder begins with. \<FNC1> in a plain Code 128 value drew a GS1-128 of
  the record's choosing, \<FNC3> set the reader-programming flag, and QR
  dropped the escape. Refused in every symbology, with a count only.
- Interleaved 2 of 5 of an odd number of digits is refused, not padded.
- EAN and UPC values are the whole number with its check digit, which is
  verified; a shorter number is refused, never completed or padded.
- The check digit of every GS1 key in a gs1-128 element string is
  verified: the identifiers the GS1 Syntax Dictionary marks, as the
  bundled Zint carries it.
- VALUE_NOT_ENCODABLE joins the errors for these and for a value a
  symbology cannot hold; SCENE_INVALID is no longer used for a value. A
  symbol of more modules than the encoder draws is BARCODE_DOES_NOT_FIT.
- Quiet zones and the smallest module are a table by symbology
  (scene2/barcode/Symbology), each number with its source. barcodeProfile
  names the rule: none, gs1-general or gs1-logistics; not stated, the
  strictest written for the symbology. A GS1-128 is so held to 0.495 mm
  unless its scene says otherwise, at which a GTIN with a date and a
  serial number fits no label 4 in wide.
- The bars of a linear symbol must be as tall as its symbology asks, and
  the height is part of the fit. minHeight overrides, with the standing
  warning BARCODE_HEIGHT_BELOW_RECOMMENDED.
- QUIET_ZONE_INTRUDED is an error: an element later in the list whose ink
  would enter a symbol or its quiet zones is refused, when a scene is
  validated as when it is rendered.
- A barcode sits in the middle of its box unless the scene says otherwise.
- A bound barcode takes maxLength and is proven at validation in a linear
  symbology. FIT_NOT_PROVEN stays for no length and for two-dimensional
  symbols, whose encoders have no largest symbol of a length.

Codes and property names are added in one change: foundation's
FailureCode and warning set, the preview's sentences, the bridge's
accepted refusals and schema vocabulary, the renderer's vocabulary and
the schema. The three barcode goldens are unchanged byte for byte;
barcode-retail is new.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-12 18:19:02 -06:00

447 lines
26 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.documentgeneration;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import java.util.stream.Stream;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.CustomPermissionChecker;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QBadRequestException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.exceptions.QNotFoundException;
import com.kingsrook.qqq.backend.core.exceptions.QPermissionDeniedException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.CompositeWidgetData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageBlockData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageStyles;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.image.ImageValues;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextBlockData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextStyles;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.blocks.text.TextValues;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.MetaDataWithPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
** The label preview as a standard composite widget: a line that says whether
** the picture is what prints, the picture itself (the page's PNG as a data
** URL, at the label's physical size), the facts, and one line per diagnostic.
**
** It appears in two places with no host code. On the template record screen
** it is the "Label preview" section, drawn with sample data only, so opening
** a template never renders record data. In the "Preview layout" action it is
** the result step, drawn from the choices the person made. Either way the
** client fetches the widget, the picture is drawn for that request and
** returned in its answer, and nothing of it is kept on the server: not in a
** table, not in a file and not in the process's state.
**
** The request names a template record (the record screen) or a run of the
** action (the result step) and nothing else is read from it: no revision,
** record, sample, stock or resolution is taken from a request parameter. QQQ's
** widget route logs the parameters of a request that fails or takes over a
** second, so what a request carries is what that log can hold: the widget's
** name with a table name and a template identity, or with the identifier of a
** run and the plain process values a client adds to it. After the form those
** are the template or revision identity, the source table's name and whatever
** the client itself sent to start the run (its selection of the template or
** revision row); no choice of the form is among them.
**
** A widget renderer cannot set response headers and the widget route sets no
** `Cache-Control`, so the host or its proxy must send `no-store` for widget
** responses (see the README).
*******************************************************************************/
final class GenerationPreviewWidget
{
static final String NAME = "document_generation_preview";
static final String SECTION = "labelPreview";
static final int MAXIMUM_IMAGE_BYTES = 2 * 1024 * 1024;
static final int MAXIMUM_LINES = 50;
////////////////////////////////////////////////////////////////////////////////////////////////
// the closed set of verdicts for a label whose picture is not drawn although nothing says it //
// cannot print: a preview paints at most sixteen times the page and draws at most sixteen //
// million dots, and neither limit is a limit of printing //
////////////////////////////////////////////////////////////////////////////////////////////////
static final String TOO_DENSE = "This label is too dense to preview; printing is not affected.";
static final String TOO_LARGE = "This label is too large to preview; printing is not affected.";
////////////////////////////////////////////////////////////////////////////////////////////////
// one plain sentence for each code of the closed vocabulary that a preview can report; a //
// code without one is shown by its name, element and numbers alone //
////////////////////////////////////////////////////////////////////////////////////////////////
private static final Map<String, String> MEANINGS = Map.ofEntries(
Map.entry("SCENE_INVALID", "The scene is not valid at the place named."),
Map.entry("STOCK_MISMATCH", "The scene was drawn for another stock size."),
Map.entry("TARGET_REQUIRES_SCENE_2", "Only a version 2 scene can be drawn at a resolution other than its stock's."),
Map.entry("ELEMENT_OUTSIDE_PRINTABLE_AREA", "The element reaches outside the printable area."),
Map.entry("TEXT_DOES_NOT_FIT", "The text does not fit its box."),
Map.entry("VALUE_MISSING", "The data has no value for this element."),
Map.entry("VALUE_TOO_LONG", "The value is longer than the maximum length the element declares."),
Map.entry("FONT_UNKNOWN", "The font is not available."),
Map.entry("FONT_MISSING_GLYPH", "The font has no shape for a character of the value."),
Map.entry("RESERVED_FONT_NAME", "An included font uses a name reserved for the bundled fonts."),
Map.entry("BARCODE_DOES_NOT_FIT", "The barcode does not fit its box at the smallest module allowed."),
Map.entry("GS1_INVALID", "The GS1 data is not valid."),
Map.entry("ASSET_UNKNOWN", "The scene names an asset the template does not include."),
Map.entry("PREVIEW_TOO_LARGE", "The picture would be too large to draw."),
Map.entry("RENDERER_TOO_OLD", "The renderer in use cannot draw this yet."),
Map.entry("VALUE_NOT_ENCODABLE", "The value cannot be drawn as this barcode."),
Map.entry("QUIET_ZONE_INTRUDED", "A later element is drawn over the barcode or its quiet zone."),
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("BARCODE_HEIGHT_BELOW_RECOMMENDED", "The barcode's bars are shorter than recommended."),
Map.entry("ELEMENTS_OVERLAP", "Elements overlap."),
Map.entry("IMAGE_UPSCALED", "The image was enlarged and may look coarse."),
Map.entry("DEVICE_UNIT_USED", "A device unit (dots) is used, so the layout depends on the resolution."),
Map.entry("PRINTER_FONT_NOT_SHOWN", "Printer font text cannot be drawn; its estimated area is outlined and crossed."),
Map.entry("TEXT_SHRUNK", "The text was reduced to fit."),
Map.entry("TEXT_WRAPPED", "The text was wrapped."),
Map.entry("BARCODE_MODULE", "The width of the barcode's narrow bar."),
Map.entry("IMAGE_SCALED", "The image was scaled."));
/*******************************************************************************
** Metadata namespace.
*******************************************************************************/
private GenerationPreviewWidget()
{
}
/*******************************************************************************
** Called once for the template table, after its standard sections: the
** preview goes directly below the overview.
*******************************************************************************/
static void section(QTableMetaData table)
{
List<QFieldSection> sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections());
sections.add(Math.min(1, sections.size()), new QFieldSection(SECTION, new QIcon("image"), Tier.T2).withLabel("Label preview").withWidgetName(NAME)
.withHelpContent(GenerationTables.help("The label as it would print, drawn from the current draft with the first sample the scene declares, at the stock's own resolution. "
+ "It is drawn each time the record opens and nothing is saved. Use Preview layout to choose other data, another stock or another resolution.")));
table.setSections(sections);
}
/*******************************************************************************
** The widget a host gets by registering the QBit. Its renderer decides what
** each person may see, so the rule here is only that the person holds one of
** the existing permissions a preview can be drawn under.
*******************************************************************************/
static QWidgetMetaData metaData()
{
return (new QWidgetMetaData().withName(NAME).withType(WidgetType.COMPOSITE.getType()).withLabel("Label preview").withIsCard(true).withGridColumns(12)
.withShowReloadButton(true).withCodeReference(new QCodeReference(Renderer.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED).withCustomPermissionChecker(new QCodeReference(Permission.class)))
.withHelpContent("label", GenerationTables.help("The picture is decoded from the exact bytes that would be sent to the printer, one pixel per printer dot. "
+ "Refusals mean the label cannot print as it stands; warnings do not stop printing. Reload after editing the layout.")));
}
/*******************************************************************************
** A person with none of the document permissions never reaches the renderer.
*******************************************************************************/
public static class Permission implements CustomPermissionChecker
{
/*******************************************************************************
** Discovery and direct execution both apply the rule.
*******************************************************************************/
@Override
public void checkPermissionsThrowing(AbstractActionInput input, MetaDataWithPermissionRules metadata) throws QPermissionDeniedException
{
var session = QContext.getQSession();
if(session == null || Stream.of("view", "configure", "generate").noneMatch(suffix -> session.hasPermission("document-generation." + suffix)))
{
throw new QPermissionDeniedException("Document Generation permission denied.");
}
}
}
/*******************************************************************************
** The standard widget route: the request names a template record or a run of
** the action, and what it leads to is authorised again by the preview service.
*******************************************************************************/
public static class Renderer extends AbstractWidgetRenderer
{
/*******************************************************************************
** Draw for this request only; the answer is not stored.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
return (new RenderWidgetOutput(widget(GenerationProcesses.config(QContext.getQInstance()), input.getQueryParams() == null ? Map.of() : input.getQueryParams())));
}
}
/*******************************************************************************
** The whole widget for one request. A refusal this module states in its own
** words (no source to draw, an unreadable record, an unknown sample) is one
** line; a failure of the renderer is a fixed sentence, never its message; a
** label too dense to draw is said to be so, and to print all the same; a
** missing permission stays a denial.
*******************************************************************************/
static CompositeWidgetData widget(DocumentGenerationQBitConfig config, Map<String, String> parameters) throws QException
{
CompositeWidgetData data = new CompositeWidgetData().withLayout(CompositeWidgetData.Layout.FLEX_COLUMN);
try
{
DocumentPreviewService service = new DocumentPreviewService(config);
DocumentPreviewService.Options options = options(service, parameters);
blocks(data, options, service.preview(options));
}
catch(QPermissionDeniedException denied)
{
throw (denied);
}
catch(QBadRequestException | QNotFoundException refused)
{
data.addBlock(line(refused.getMessage(), TextStyles.StandardColor.WARNING, true));
}
catch(GenerationPorts.RenderException named)
{
boolean dense = named.code() == GenerationPorts.FailureCode.PREVIEW_TOO_LARGE;
data.addBlock(line(dense ? TOO_DENSE : "The preview could not be drawn (" + named.code() + ").", dense ? TextStyles.StandardColor.WARNING : TextStyles.StandardColor.ERROR, true));
}
catch(QException failure)
{
data.addBlock(line("The preview could not be drawn. A label that takes its values from a record needs sample data in its scene, or use Preview layout with a record.",
TextStyles.StandardColor.ERROR, true));
}
return (data);
}
/*******************************************************************************
** On a record screen the client sends the table and the record: the draft
** for a person who may author it, the published revision for a person who
** may only view it, always with sample data. In the action the client sends
** the identifier of the run, and the choices of the form are read from the
** state of that run; whatever else the request carries is not read.
*******************************************************************************/
private static DocumentPreviewService.Options options(DocumentPreviewService service, Map<String, String> parameters) throws QException
{
if(parameters.containsKey("processUUID"))
{
return (GenerationPreviewProcesses.choices(parameters.get("processUUID")));
}
if(!GenerationTables.TEMPLATE.equals(parameters.get("tableName")))
{
throw new QBadRequestException("The label preview belongs to a document template.");
}
DocumentPreviewService.Options screen = GenerationPreviewProcesses.options(name -> switch(name)
{
case "previewTemplateId" -> parameters.get("id");
case "dataSource" -> "SAMPLE";
default -> null;
});
if(QContext.getQSession().hasPermission("document-generation.configure"))
{
return (screen);
}
Integer published = service.publishedRevision(screen.templateId());
if(published == null)
{
throw new QBadRequestException("This template has no published revision to preview.");
}
return (new DocumentPreviewService.Options(null, published, screen.format(), screen.data(), null, null, null, null));
}
/*******************************************************************************
** The verdict, what was drawn, the picture, the facts and the diagnostics.
*******************************************************************************/
private static void blocks(CompositeWidgetData data, DocumentPreviewService.Options options, DocumentPreviewService.Result result)
{
GenerationPorts.Preview preview = result.preview();
boolean page = !preview.pages().isEmpty();
List<GenerationPorts.Diagnostic> refusals = preview.diagnostics().stream().filter(diagnostic -> diagnostic.severity() == GenerationPorts.Diagnostic.Severity.ERROR).toList();
if(!page && !refusals.isEmpty() && refusals.stream().allMatch(refusal -> GenerationPorts.FailureCode.PREVIEW_TOO_LARGE.name().equals(refusal.code())))
{
/////////////////////////////////////////////////////////////////////////////////////
// the only thing refused is the picture: the label was not found unprintable, it //
// would paint or measure more than a preview draws //
/////////////////////////////////////////////////////////////////////////////////////
data.addBlock(line(refusals.getFirst().limit().containsKey("paintedPercent") ? TOO_DENSE : TOO_LARGE, TextStyles.StandardColor.WARNING, true));
}
else if(!preview.printable())
{
data.addBlock(line("This layout cannot print. " + (page ? "The picture shows only where the refused elements are." : "There is nothing to draw."),
TextStyles.StandardColor.ERROR, true));
}
else if(preview.exact())
{
data.addBlock(line("This is the picture that would print.", TextStyles.StandardColor.SUCCESS, true));
}
else
{
data.addBlock(line("This picture is not exact: what the printer draws itself is shown as an outlined, crossed area.", TextStyles.StandardColor.WARNING, true));
}
String values = options.data() == DocumentPreviewService.Data.SAMPLE ? "sample data" : options.data() == DocumentPreviewService.Data.LONGEST ? "longest values" : "one record";
data.addBlock(line((options.revisionId() == null ? "Current draft" : "Published revision " + options.revisionId()) + ", " + values + ", " + result.dpi() + " dpi.", null, false));
if(page)
{
GenerationPorts.PreviewPage first = preview.pages().getFirst();
byte[] png = first.png();
if(png.length <= MAXIMUM_IMAGE_BYTES)
{
data.addBlock(new ImageBlockData().withValues(new ImageValues().withPath("data:image/png;base64," + Base64.getEncoder().encodeToString(png))
.withAlt("Label preview, " + first.widthDots() + " by " + first.heightDots() + " dots"))
.withStyles(new ImageStyles().withWidth("min(100%, " + millimetres(first.widthDots(), result.dpi()) + "mm)")));
}
else
{
data.addBlock(line("The picture is " + png.length + " bytes, which is too large to show here.", TextStyles.StandardColor.WARNING, false));
}
data.addBlock(line("Size: " + first.widthDots() + " x " + first.heightDots() + " dots at " + result.dpi() + " dpi (" + millimetres(first.widthDots(), result.dpi()) + " x "
+ millimetres(first.heightDots(), result.dpi()) + " mm). Output: " + preview.outputBytes() + " bytes, SHA-256 " + preview.outputSha256() + ".", null, false));
}
List<GenerationPorts.Diagnostic> ordered = preview.diagnostics().stream().sorted(Comparator.comparing(GenerationPorts.Diagnostic::severity)).toList();
ordered.stream().limit(MAXIMUM_LINES).forEach(diagnostic -> data.addBlock(line(sentence(diagnostic, result.dpi()),
TextStyles.StandardColor.valueOf(diagnostic.severity().name()), false)));
if(ordered.size() > MAXIMUM_LINES)
{
data.addBlock(line("And " + (ordered.size() - MAXIMUM_LINES) + " more.", null, false));
}
if(ordered.isEmpty())
{
data.addBlock(line("No refusals and no warnings.", null, false));
}
}
/*******************************************************************************
** One diagnostic as a sentence: a refusal, a warning or a note, its code, the
** element the author named, what it means, and the measured values against
** the limits in dots and in physical units. A diagnostic holds identifiers
** and integers only, so no record value can appear here.
*******************************************************************************/
static String sentence(GenerationPorts.Diagnostic diagnostic, int dpi)
{
GenerationPorts.Diagnostic.Severity severity = diagnostic.severity();
StringBuilder text = new StringBuilder(severity == GenerationPorts.Diagnostic.Severity.ERROR ? "Refusal" : severity == GenerationPorts.Diagnostic.Severity.WARNING ? "Warning" : "Note")
.append(": ").append(diagnostic.code());
if(diagnostic.element() != null)
{
text.append(", element '").append(diagnostic.element()).append('\'');
}
if(diagnostic.pointer() != null)
{
text.append(", at ").append(diagnostic.pointer().isEmpty() ? "the scene" : diagnostic.pointer());
}
text.append('.');
if(MEANINGS.containsKey(diagnostic.code()))
{
text.append(' ').append(MEANINGS.get(diagnostic.code()));
}
if(!diagnostic.measured().isEmpty())
{
text.append(" Measured ").append(numbers(diagnostic.measured(), dpi)).append(diagnostic.limit().isEmpty() ? "." : " against a limit of " + numbers(diagnostic.limit(), dpi) + ".");
}
else if(!diagnostic.limit().isEmpty())
{
text.append(" Limit ").append(numbers(diagnostic.limit(), dpi)).append('.');
}
return (text.toString());
}
/*******************************************************************************
** Named integers in a fixed order, each with its unit.
*******************************************************************************/
private static String numbers(Map<String, Integer> values, int dpi)
{
return (String.join(", ", new TreeMap<>(values).entrySet().stream().map(entry -> number(entry.getKey(), entry.getValue(), dpi)).toList()));
}
/*******************************************************************************
** A value by the unit its name ends with: dots also in millimetres at the
** resolution drawn, hundredths of a point as points, micrometres as
** millimetres, a percentage as such, and anything else as the bare number.
*******************************************************************************/
private static String number(String name, int value, int dpi)
{
if(name.endsWith("Dots"))
{
return (words(name, 4) + " " + value + " dots (" + millimetres(value, dpi) + " mm)");
}
if(name.endsWith("Centipoints"))
{
return (words(name, 11) + " " + BigDecimal.valueOf(value, 2).stripTrailingZeros().toPlainString() + " pt");
}
if(name.endsWith("Micrometres"))
{
return (words(name, 11) + " " + BigDecimal.valueOf(value, 3).stripTrailingZeros().toPlainString() + " mm");
}
if(name.endsWith("Percent"))
{
return (words(name, 7) + " " + value + "%");
}
return (words(name, 0) + " " + value);
}
/*******************************************************************************
** A value's name without its unit, as lower-case words.
*******************************************************************************/
private static String words(String name, int unit)
{
return (name.substring(0, name.length() - unit).replaceAll("([a-z0-9])([A-Z])", "$1 $2").toLowerCase(Locale.ROOT));
}
/*******************************************************************************
** Dots as millimetres at a resolution, to a tenth.
*******************************************************************************/
private static String millimetres(int dots, int dpi)
{
return (BigDecimal.valueOf(dots * 254L).divide(BigDecimal.valueOf(dpi * 10L), 1, RoundingMode.HALF_UP).toPlainString());
}
/*******************************************************************************
** One line of text, coloured by what it reports and boxed when it is the
** verdict.
*******************************************************************************/
private static TextBlockData line(String text, TextStyles.StandardColor color, boolean verdict)
{
TextStyles styles = new TextStyles();
if(color != null)
{
styles.withColor(color);
}
if(verdict)
{
styles.withFormat(TextStyles.StandardFormat.ALERT).withWeight(TextStyles.StandardWeight.BOLD);
}
return (new TextBlockData().withValues(new TextValues(text)).withStyles(styles));
}
}