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>
447 lines
26 KiB
Java
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));
|
|
}
|
|
}
|