Compare commits

...
Author SHA1 Message Date
james.maesandClaude Fable 5.1 0d5bb0718f fix(printing): keep a terminal capture's lines whole on paper
Text prints size the monospace font (8 down to 5.5 points) so the widest
line fits the page, and use tighter line spacing so box-drawing characters
join up: a 150-column screen capture prints line for line on landscape.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:51:21 -06:00
james.maesandClaude Fable 5.1 bda172a032 fix(printing): keep the title rule clear of the subtitle
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:34:07 -06:00
james.maesandClaude Fable 5.1 f1e05eece5 fix(printing): accept the table-list and text inputs from users
Core refuses user-supplied values for non-editable process inputs, so the
UI API rejected printTableList's table and printText's title. Both fields
are editable now, and the tests run these processes as user input, as the
UI API does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:32:49 -06:00
james.maesandClaude Fable 5.1 8acee4e82e feat(printing): print a table's whole result as a table list
printTableList now reads the whole result a thousand rows at a time up to a
10,000-row print limit; a larger result prints its first rows and says so
in the page header and in printingMessage. It takes the grid's column
widths, chooses landscape when the columns need it (AUTO, the default),
repeats the header on every page and shows the rows printed of the total,
the filter text and the sort in the header and the table, time, user and
page number in the footer. A 2,500-row test covers the limit, paging,
landscape and the repeated header.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:29:16 -06:00
james.maesandClaude Fable 5.1 f260808bad feat(printing): print any record, any table list and any text
registerAll puts Print on every readable table (opt out per table with
PrintingTableSettings). The record sheet now follows the table's sections in
tier order and summarizes each child list. printingPrintTableList prints a
grid of any table with the caller's columns, filter, sort and either the
current page or all rows up to a limit, on Letter or A4 in either
orientation. printingPrintText prints text such as a terminal screen
capture. A shared page writer adds running titles and page footers.

Extension 2.1.0 (additive), version 0.4.0-SNAPSHOT.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 06:25:44 -06:00
16 changed files with 1824 additions and 120 deletions

No files matched your search

+28 -1
View File
@@ -1,6 +1,33 @@
# Changelog
## Unreleased (0.3.1-SNAPSHOT)
## Unreleased (0.4.0-SNAPSHOT)
Printing extension **2.1.0** (additive; the agent contract is unchanged).
- Print anything: `PrintingRecordProcesses.registerAll(instance)` adds **Print** to
every table that can be read one record at a time (call it after the host's
tables are in the instance). Hidden tables, tables without a primary key or
`TABLE_GET`, and tables carrying `PrintingTableSettings().withPrintable(false)`
are skipped.
- The record sheet now follows the table's metadata: field sections in tier order
with their labels, remaining fields under "Other fields", and a summary of each
child list (association): its label, row count and first ten rows.
- New `printingPrintTableList`: a table list (grid) of any readable table with the
caller's columns (in order) and widths, filter and sort, either the whole result
(`scope` `ALL`, the default: read a thousand rows at a time up to `maxRows`,
10,000 by default and at most; a larger result prints its first rows and says
so in the header and in `printingMessage`) or the current page (`PAGE`, the
filter's skip/limit). Letter or A4; orientation `AUTO` (landscape when the
columns need it), portrait or landscape. The header repeats on every page; the
page header carries the table, the rows printed of the total, the filter text
and the sort, and the footer the table, time, user and "Page n of m".
`printingRowCount` reports the matching rows before printing.
- New `printingPrintText`: any text (a capture of a terminal screen) as a
monospace PDF, wrapped and broken across pages.
- All documents share one page writer with "Page n of m" footers and a running
title on later pages. Table and record reads enforce the user's permissions.
## 0.3.1-SNAPSHOT
- Add record printing: `PrintingRecordProcesses.register(instance, table)` adds a
**Print** process (`printingPrintRecords_{table}`) to a host table's records and
+2
View File
@@ -135,6 +135,8 @@ JSONObject job = printing.submit(new JSONObject()
String printJobId = job.getString("jobId");
```
For terminal and web clients the QBit also offers ready-made print processes: `PrintingRecordProcesses.registerAll(instance)` (after all host tables are added) puts **Print** on every readable table, rendering a record sheet from the table's sections and child lists; `printingPrintTableList` prints a grid of any table (columns, filter, sort, page or all rows, paper and orientation); `printingPrintText` prints text such as a screen capture. Opt a table out with `PrintingTableSettings().withPrintable(false)`. Each returns the queued job id in `printingJobIds`.
The returned ID identifies durable queue acceptance. Check job status asynchronously. The [protocol v1 specification](https://github.com/QRun-IO/qqq-print-protocol/blob/f977bd01b62ceb7539190fd2861809537fcf504c/README.md) defines HTTP endpoints, JSON fields, integrity checks and lifecycle semantics.
For durable QQQ jobs register `PrintEnqueueEffectTarget` as an `IDEMPOTENT_EXTERNAL` target in `DurableJobsMetaData`, then emit the submit-request payload. The target replaces the payload idempotency key with the durable effect key. Its SENT result means accepted into the queue, not physical output. This implementation uses its own JDBC transaction; do not register it as LOCAL_TX. Authorize the destination in the emitting process before writing the effect. QQQ's durable dispatcher runs with a `QSystemUserSession` and does not propagate the user's tenant keys. For that trusted outbox path only, the target resolves the service configured on the dispatcher's current QQQ instance; that service fixes the tenant and rejects devices belonging to another tenant. The payload cannot choose a service or tenant. A cached target resolves the current instance on every call. Inline user calls retain the `printing_tenant` scope check, and native management processes (including enrollment) still require both administrator permission and matching tenant scope. Do not expose direct effect emission through an arbitrary user-supplied target/payload endpoint.
+1 -1
View File
@@ -4,7 +4,7 @@
<parent><groupId>com.kingsrook</groupId><artifactId>qbit-build-parent</artifactId><version>2.0.0</version><relativePath/></parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-printing</artifactId>
<version>0.3.1-SNAPSHOT</version>
<version>0.4.0-SNAPSHOT</version>
<name>QQQ Printing</name>
<licenses><license><name>Apache License, Version 2.0</name><url>https://www.apache.org/licenses/LICENSE-2.0</url></license></licenses>
<properties>
@@ -0,0 +1,430 @@
/*
* 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.printing;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
/*******************************************************************************
** A small monospace page writer shared by the record, table-list and text
** documents: a heading on the first page, a running title on later pages,
** lines that break onto new pages (optionally repeating a header), and
** "Page n of m" footers. Characters the bundled font cannot encode print as ?.
*******************************************************************************/
final class PrintingLayout implements AutoCloseable
{
static final float MARGIN = 40;
static final int MAX_PAGES = 500;
private final PDDocument document = new PDDocument();
private final PDType0Font font;
private final PDRectangle size;
private final String title;
private PDPageContentStream content;
private float y;
private List<String> repeatHeader = List.of();
private int repeatSize = 8;
private String footer = "";
private float leading = 1.35f;
/***************************************************************************
** Page size LETTER or A4, portrait or landscape; the first page carries
** the title and subtitle.
***************************************************************************/
PrintingLayout(String pageSize, boolean landscape, String title, String subtitle) throws QException
{
try(InputStream stream = PrintingLayout.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE))
{
if(stream == null)
{
throw new IOException("Bundled document font is missing");
}
font = PDType0Font.load(document, stream, true);
PDRectangle base = "A4".equals(pageSize == null ? null : pageSize.toUpperCase(Locale.ROOT)) ? PDRectangle.A4 : PDRectangle.LETTER;
size = landscape ? new PDRectangle(base.getHeight(), base.getWidth()) : base;
this.title = title == null ? "" : title;
newPage();
line(this.title, 15);
y -= 4;
if(subtitle != null && !subtitle.isBlank())
{
line(subtitle, 9);
}
y -= 4;
rule();
y -= 6;
}
catch(IOException exception)
{
close();
throw new QException("Could not start the print document", exception);
}
}
/***************************************************************************
** How many monospace characters fit across the page at a font size.
***************************************************************************/
int columns(float fontSize)
{
return (Math.max(20, (int) ((size.getWidth() - 2 * MARGIN) / (0.6f * fontSize))));
}
/***************************************************************************
** Line spacing as a multiple of the font size (1.35 by default; text
** captures use less so box-drawing characters join up).
***************************************************************************/
void leading(float factor)
{
leading = factor;
}
/***************************************************************************
** Text at the bottom left of every page (who printed it and when).
***************************************************************************/
void footer(String text)
{
footer = text == null ? "" : text;
}
/***************************************************************************
** Lines repeated at the top of every following page (a table header).
***************************************************************************/
void repeatOnNewPages(List<String> lines, int fontSize)
{
repeatHeader = List.copyOf(lines);
repeatSize = fontSize;
}
/***************************************************************************
** One line at a font size; breaks the page first when it does not fit.
***************************************************************************/
void line(String text, float fontSize) throws IOException
{
float step = fontSize * leading;
if(y - step < MARGIN + 14)
{
newPage();
for(String header : repeatHeader)
{
draw(header, repeatSize, repeatSize * leading);
}
}
draw(text, fontSize, step);
}
/***************************************************************************
** Keep at least this many lines together (a heading with its first rows).
***************************************************************************/
void keep(int lines, int fontSize) throws IOException
{
if(y - lines * fontSize * 1.35f < MARGIN + 14)
{
newPage();
}
}
/***************************************************************************
** Vertical space.
***************************************************************************/
void gap(float points)
{
y -= points;
}
/***************************************************************************
** A thin rule across the page.
***************************************************************************/
void rule() throws IOException
{
content.moveTo(MARGIN, y);
content.lineTo(size.getWidth() - MARGIN, y);
content.stroke();
y -= 4;
}
/***************************************************************************
** Footers on every page, then the PDF bytes.
***************************************************************************/
byte[] finish() throws QException
{
try
{
content.close();
content = null;
int total = document.getNumberOfPages();
for(int i = 0; i < total; i++)
{
PDPage page = document.getPage(i);
try(PDPageContentStream footer = new PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true))
{
String text = "Page " + (i + 1) + " of " + total;
footer.beginText();
footer.setFont(font, 8);
footer.newLineAtOffset(size.getWidth() - MARGIN - text.length() * 4.8f, MARGIN - 14);
footer.showText(text);
footer.endText();
if(!this.footer.isEmpty())
{
footer.beginText();
footer.setFont(font, 8);
footer.newLineAtOffset(MARGIN, MARGIN - 14);
footer.showText(safe(PrintingRecordDocuments.truncate(this.footer, Math.max(10, columns(8) - text.length() - 2))));
footer.endText();
}
}
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
document.save(output);
return (output.toByteArray());
}
catch(IOException exception)
{
throw new QException("Could not render the print document", exception);
}
}
/***************************************************************************
** Release the document.
***************************************************************************/
@Override
public void close()
{
try
{
if(content != null)
{
content.close();
}
document.close();
}
catch(IOException exception)
{
// nothing more to release
}
}
/***************************************************************************
** Start a page; later pages show the running title.
***************************************************************************/
private void newPage() throws IOException
{
if(document.getNumberOfPages() >= MAX_PAGES)
{
throw new IOException("The document would exceed " + MAX_PAGES + " pages; print fewer rows");
}
if(content != null)
{
content.close();
}
PDPage page = new PDPage(size);
document.addPage(page);
content = new PDPageContentStream(document, page);
y = size.getHeight() - MARGIN;
if(document.getNumberOfPages() > 1)
{
draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12);
y -= 4;
}
}
/***************************************************************************
** Draw one line at the current position.
***************************************************************************/
private void draw(String text, float fontSize, float step) throws IOException
{
y -= step;
content.beginText();
content.setFont(font, fontSize);
content.newLineAtOffset(MARGIN, y);
content.showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize))));
content.endText();
}
/***************************************************************************
** Replace characters the font cannot encode, and control characters.
***************************************************************************/
private String safe(String text)
{
StringBuilder out = new StringBuilder();
text.codePoints().forEach(codePoint ->
{
String character = new String(Character.toChars(codePoint));
if(Character.isISOControl(codePoint))
{
out.append(' ');
return;
}
try
{
font.encode(character);
out.append(character);
}
catch(IllegalArgumentException | IOException exception)
{
out.append('?');
}
});
return (out.toString());
}
/***************************************************************************
** Width-limited columns for a table: natural widths shrunk to the line.
***************************************************************************/
static int[] columnWidths(List<String> headers, List<List<String>> rows, int available)
{
return (fit(naturalWidths(headers, rows), available));
}
/***************************************************************************
** Each column's natural width: the header or the 90th percentile of its
** cells, between 4 and 60 characters.
***************************************************************************/
static int[] naturalWidths(List<String> headers, List<List<String>> rows)
{
int count = headers.size();
int[] widths = new int[count];
for(int c = 0; c < count; c++)
{
List<Integer> lengths = new ArrayList<>();
for(List<String> row : rows)
{
lengths.add(c < row.size() && row.get(c) != null ? row.get(c).length() : 0);
}
lengths.sort(null);
int typical = lengths.isEmpty() ? 0 : lengths.get(Math.max(0, (int) Math.ceil(lengths.size() * 0.9) - 1));
widths[c] = Math.max(4, Math.min(60, Math.max(headers.get(c).length(), typical)));
}
return (widths);
}
/***************************************************************************
** The width a row of these columns takes, with two-space gaps.
***************************************************************************/
static int lineWidth(int[] widths)
{
return (sum(widths) + Math.max(0, widths.length - 1) * 2);
}
/***************************************************************************
** Shrink widths (widest first, never below 4) until a row fits.
***************************************************************************/
static int[] fit(int[] start, int available)
{
int[] widths = start.clone();
while(lineWidth(widths) > available)
{
int widest = 0;
for(int c = 1; c < widths.length; c++)
{
if(widths[c] > widths[widest])
{
widest = c;
}
}
if(widths[widest] <= 4)
{
break;
}
widths[widest]--;
}
return (widths);
}
/***************************************************************************
** Sum of widths.
***************************************************************************/
private static int sum(int[] values)
{
int total = 0;
for(int value : values)
{
total += value;
}
return (total);
}
/***************************************************************************
** One table row laid out in fixed-width columns.
***************************************************************************/
static String row(List<String> cells, int[] widths)
{
StringBuilder out = new StringBuilder();
for(int c = 0; c < widths.length; c++)
{
String cell = c < cells.size() && cells.get(c) != null ? cells.get(c).replace('\n', ' ').replace('\r', ' ') : "";
cell = PrintingRecordDocuments.truncate(cell, widths[c]);
out.append(c == 0 ? "" : " ").append(String.format("%-" + widths[c] + "s", cell));
}
return (out.toString().stripTrailing());
}
}
@@ -0,0 +1,425 @@
/*
* 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.printing;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.UUID;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
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.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
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.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.QProcessMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.JsonUtils;
/*******************************************************************************
** Two print processes that are not bound to one table:
**
** - printingPrintTableList: a table list (grid) of any readable table. Values:
** tableName, columns (comma separated, in order; default the visible
** fields), filterJSON (a QQueryFilter with orderBys; its skip/limit are the
** page), scope PAGE (the filter's skip/limit) or ALL (the whole result,
** read a thousand rows at a time, up to maxRows: default and most 10,000;
** a larger result prints its first rows and says so), columnWidths (the
** grid's widths in characters, comma separated, same order), pageSize
** LETTER/A4, orientation PORTRAIT/LANDSCAPE/AUTO (landscape when the
** columns need it; the default), title, filterDescription (the filter as
** the user sees it), then the printer and copies.
** - printingPrintText: any text as a monospace PDF (a capture of a terminal
** screen). Values: title, text, subtitle, pageSize, orientation, printer,
** copies.
**
** Both ask for the printer on an options form (prefilled from the init
** values), queue one PDF job and return its id in printingJobIds. Reading the
** table enforces the user's table and record permissions.
*******************************************************************************/
public final class PrintingListProcesses
{
public static final String TABLE_LIST = "printingPrintTableList";
public static final String TEXT = "printingPrintText";
public static final int MAX_ROWS = 10_000;
public static final int DEFAULT_MAX_ROWS = MAX_ROWS;
static final int QUERY_PAGE = 1000;
static final String REQUEST_ID = "printingListRequestId";
/***************************************************************************
** Utility class.
***************************************************************************/
private PrintingListProcesses()
{
}
/***************************************************************************
** Register both processes and their choice lists.
***************************************************************************/
public static void register(QInstance instance)
{
choices(instance, "printing_pageSize", List.of("LETTER", "A4"), List.of("Letter", "A4"));
choices(instance, "printing_orientation", List.of("AUTO", "PORTRAIT", "LANDSCAPE"), List.of("Automatic (landscape when the columns need it)", "Portrait", "Landscape"));
choices(instance, "printing_listScope", List.of("ALL", "PAGE"), List.of("All rows (current filter and sort)", "The current page"));
instance.addProcess(process(TABLE_LIST, "Print a table list", List.of(
new QFieldMetaData("tableName", QFieldType.STRING).withLabel("Table").withIsRequired(true),
new QFieldMetaData("scope", QFieldType.STRING).withLabel("Rows").withPossibleValueSourceName("printing_listScope").withDefaultValue("ALL"),
new QFieldMetaData("maxRows", QFieldType.INTEGER).withLabel("Most rows (all rows)").withDefaultValue(DEFAULT_MAX_ROWS),
new QFieldMetaData("orientation", QFieldType.STRING).withLabel("Orientation").withPossibleValueSourceName("printing_orientation").withDefaultValue("AUTO"))));
instance.addProcess(process(TEXT, "Print text", List.of(
new QFieldMetaData("title", QFieldType.STRING).withLabel("Title"),
new QFieldMetaData("orientation", QFieldType.STRING).withLabel("Orientation").withPossibleValueSourceName("printing_orientation").withDefaultValue("PORTRAIT"))));
}
/***************************************************************************
** prepare, options (printer and layout), execute, result.
***************************************************************************/
private static QProcessMetaData process(String name, String label, List<QFieldMetaData> extra)
{
List<QFieldMetaData> fields = new ArrayList<>(extra);
fields.add(PrintingRecordProcesses.printerField());
fields.add(new QFieldMetaData(PrintingRecordProcesses.COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1));
fields.add(new QFieldMetaData("pageSize", QFieldType.STRING).withLabel("Paper").withPossibleValueSourceName("printing_pageSize").withDefaultValue("LETTER"));
return (new QProcessMetaData().withName(name).withLabel(label).withPermissionRules(PrintingProcesses.permissionRules())
.withStep(new QBackendStepMetaData().withName("prepare").withCode(new QCodeReference(Prepare.class)))
.withStep(new QFrontendStepMetaData().withName("options").withLabel("Choose a printer").withFormFields(fields)
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)))
.withStep(new QBackendStepMetaData().withName("execute").withCode(new QCodeReference(Execute.class)))
.withStep(new QFrontendStepMetaData().withName("result").withLabel("Result")
.withViewFields(List.of(new QFieldMetaData("result", QFieldType.STRING).withLabel("Result")))
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.VIEW_FORM))));
}
/***************************************************************************
** A fixed enum choice list.
***************************************************************************/
private static void choices(QInstance instance, String name, List<String> ids, List<String> labels)
{
ArrayList<QPossibleValue<?>> values = new ArrayList<>();
for(int i = 0; i < ids.size(); i++)
{
values.add(new QPossibleValue<>(ids.get(i), labels.get(i)));
}
instance.addPossibleValueSource(new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM).withEnumValues(values));
}
/***************************************************************************
** The table a table list reads, checked readable.
***************************************************************************/
static QTableMetaData table(RunBackendStepInput input) throws QException
{
String name = input.getValueString("tableName");
QTableMetaData table = name == null ? null : QContext.getQInstance().getTable(name);
if(table == null)
{
throw new QException("Choose a table to print (unknown table " + name + ")");
}
PrintingTableSettings settings = PrintingTableSettings.of(table);
if(settings != null && !settings.getPrintable())
{
throw new QException(table.getLabel() + " is not printable");
}
return (table);
}
/***************************************************************************
** The filter from filterJSON (or everything), with the page or the row limit.
***************************************************************************/
static QQueryFilter filter(RunBackendStepInput input) throws QException
{
String json = input.getValueString("filterJSON");
QQueryFilter filter;
try
{
filter = json == null || json.isBlank() ? new QQueryFilter() : JsonUtils.toObject(json, QQueryFilter.class);
}
catch(Exception exception)
{
throw new QException("The filter is not valid: " + exception.getMessage(), exception);
}
if("ALL".equalsIgnoreCase(input.getValueString("scope")))
{
Integer max = input.getValueInteger("maxRows");
int limit = max == null ? DEFAULT_MAX_ROWS : max;
if(limit < 1 || limit > MAX_ROWS)
{
throw new QException("Most rows must be between 1 and " + MAX_ROWS);
}
filter.setSkip(0);
filter.setLimit(limit);
}
else
{
if(filter.getLimit() == null || filter.getLimit() > MAX_ROWS)
{
filter.setLimit(filter.getLimit() == null ? 50 : MAX_ROWS);
}
}
return (filter);
}
/***************************************************************************
** The columns to print: given (unknown names refused), else visible fields.
***************************************************************************/
static List<QFieldMetaData> columns(QTableMetaData table, String given) throws QException
{
List<QFieldMetaData> out = new ArrayList<>();
if(given == null || given.isBlank())
{
table.getFields().values().stream().filter(PrintingRecordProcesses::printable).forEach(out::add);
return (out);
}
for(String name : Arrays.stream(given.split(",")).map(String::trim).filter(n -> !n.isEmpty()).toList())
{
QFieldMetaData field = table.getFields().get(name);
if(field == null)
{
throw new QException("Table " + table.getName() + " has no field " + name);
}
if(PrintingRecordProcesses.printable(field))
{
out.add(field);
}
}
if(out.isEmpty())
{
throw new QException("None of the columns can be printed");
}
return (out);
}
/***************************************************************************
** The rows to print: the page as given, or the whole result read a page at
** a time up to the filter's limit (the print limit).
***************************************************************************/
static List<QRecord> read(QTableMetaData table, QQueryFilter filter, boolean all) throws QException
{
List<QRecord> out = new ArrayList<>();
int limit = filter.getLimit() == null ? 50 : filter.getLimit();
int skip = filter.getSkip() == null ? 0 : filter.getSkip();
while(out.size() < limit)
{
int size = all ? Math.min(QUERY_PAGE, limit - out.size()) : limit;
QueryInput query = new QueryInput();
query.setTableName(table.getName());
query.setFilter(filter.clone().withSkip(skip + out.size()).withLimit(size));
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
query.setShouldGenerateDisplayValues(true);
query.setShouldTranslatePossibleValues(true);
List<QRecord> page = new QueryAction().execute(query).getRecords();
out.addAll(page);
if(!all || page.size() < size)
{
break;
}
}
return (out);
}
/***************************************************************************
** The sort as labels: "Last name, First name descending".
***************************************************************************/
static String sortText(QTableMetaData table, QQueryFilter filter)
{
if(filter.getOrderBys() == null)
{
return ("");
}
List<String> parts = new ArrayList<>();
for(var orderBy : filter.getOrderBys())
{
String name = orderBy.getFieldName();
QFieldMetaData field = name == null ? null : table.getFields().get(name);
parts.add((field == null || field.getLabel() == null ? name : field.getLabel()) + (orderBy.getIsAscending() ? "" : " descending"));
}
return (String.join(", ", parts));
}
/***************************************************************************
** The grid's column widths ("12,30,8"), or null.
***************************************************************************/
static int[] widths(String text)
{
if(text == null || text.isBlank())
{
return (null);
}
try
{
return (Arrays.stream(text.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray());
}
catch(NumberFormatException exception)
{
return (null);
}
}
/***************************************************************************
** AUTO unless PORTRAIT or LANDSCAPE was chosen.
***************************************************************************/
static String orientation(RunBackendStepInput input)
{
String value = input.getValueString("orientation");
return (value == null || value.isBlank() ? "AUTO" : value.toUpperCase(Locale.ROOT));
}
/***************************************************************************
** Request identity, and for a table list the number of rows it would print.
***************************************************************************/
public static class Prepare implements BackendStep
{
/***************************************************************************
** Check access early so the form is not shown to someone who may not print.
***************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
PrintingProcesses.currentService();
String request = input.getValueString(REQUEST_ID);
output.addValue(REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request);
if(TABLE_LIST.equals(input.getProcessName()))
{
QTableMetaData table = table(input);
CountInput count = new CountInput();
count.setTableName(table.getName());
count.setFilter(filter(input).clone().withSkip(null).withLimit(null));
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
output.addValue("printingRowCount", new CountAction().execute(count).getCount());
if(input.getValueString("title") == null)
{
output.addValue("title", table.getLabel() == null ? table.getName() : table.getLabel());
}
}
}
}
/***************************************************************************
** Render and queue.
***************************************************************************/
public static class Execute implements BackendStep
{
/***************************************************************************
** One PDF job on the chosen printer.
***************************************************************************/
@Override
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
{
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
QRecord printer = PrintingRecordProcesses.printer(input);
int copies = PrintingRecordProcesses.copies(input);
String request = input.getValueString(REQUEST_ID);
if(request == null || request.isBlank())
{
throw new QException("Restart the print to obtain a safe request identity");
}
String pageSize = input.getValueString("pageSize") == null ? "LETTER" : input.getValueString("pageSize").toUpperCase(Locale.ROOT);
String title;
byte[] bytes;
if(TABLE_LIST.equals(input.getProcessName()))
{
QTableMetaData table = table(input);
List<QFieldMetaData> columns = columns(table, input.getValueString("columns"));
QQueryFilter filter = filter(input);
boolean all = "ALL".equalsIgnoreCase(input.getValueString("scope"));
List<QRecord> records = read(table, filter, all);
List<List<String>> rows = new ArrayList<>();
for(QRecord record : records)
{
List<String> row = new ArrayList<>();
columns.forEach(field -> row.add(PrintingRecordProcesses.text(record, field)));
rows.add(row);
}
title = input.getValueString("title") != null ? input.getValueString("title") : table.getLabel();
Integer total = input.getValueInteger("printingRowCount");
int skip = filter.getSkip() == null ? 0 : filter.getSkip();
String scope = all ? (total != null && total > records.size() ? "first " + records.size() + " of " + total + " rows (print limit " + filter.getLimit() + ")" : "all " + records.size() + " rows")
: "rows " + (records.isEmpty() ? 0 : skip + 1) + "-" + (skip + records.size()) + (total != null ? " of " + total : "");
String described = input.getValueString("filterDescription");
String sort = sortText(table, filter);
String subtitle = scope + " - " + (described != null && !described.isBlank() ? "filter " + described : "no filter") + (sort.isEmpty() ? "" : " - sort " + sort);
bytes = PrintingTableDocuments.pdf(title, subtitle, (table.getLabel() == null ? table.getName() : table.getLabel()) + " - " + PrintingRecordProcesses.printedBy(),
columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), rows, widths(input.getValueString("columnWidths")), pageSize, orientation(input));
if(all && total != null && total > records.size())
{
output.addValue("printingTruncated", true);
output.addValue("printingMessage", "Printed the first " + records.size() + " of " + total + " rows: the print limit is " + filter.getLimit() + " rows. Narrow the filter to print the rest.");
}
}
else
{
title = input.getValueString("title") == null || input.getValueString("title").isBlank() ? "Screen" : input.getValueString("title");
String subtitle = input.getValueString("subtitle");
bytes = PrintingTextDocuments.pdf(title, (subtitle == null || subtitle.isBlank() ? "" : subtitle + " - ") + PrintingRecordProcesses.printedBy(), input.getValueString("text"), pageSize, "LANDSCAPE".equalsIgnoreCase(input.getValueString("orientation")));
}
String job = PrintingRecordProcesses.queue(printer, title, bytes, copies, "list-print:" + request + ":" + input.getProcessName() + ":" + printer.getValueString("id"));
output.addValue(PrintingRecordProcesses.JOB_IDS, job);
output.addValue("result", "Queued print job " + job + ". Check Print jobs for its status." + (output.getValue("printingMessage") != null ? " " + output.getValue("printingMessage") : ""));
}
}
}
@@ -173,6 +173,7 @@ public class PrintingQBitProducer implements QBitProducer
QAppMetaData app = new QAppMetaData().withName("printing").withLabel("Printing").withIcon(new QIcon("print")).withChild(devices).withChild(printers).withChild(jobs).withChild(commands);
app.withPermissionRules(PrintingProcesses.permissionRules());
PrintingProcesses.register(instance, app);
PrintingListProcesses.register(instance);
PrintingDeviceProcesses.register(instance);
PrintingUiApi.register(instance);
PrintingRetentionProcesses.register(instance, config);
@@ -21,32 +21,25 @@
package com.kingsrook.qbits.printing;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
/*******************************************************************************
** A plain Letter "record sheet": a heading, a subtitle and one label/value line
** per field, wrapped and continued across pages. It is the generic document a
** record print renders when the host supplies no template of its own.
** A Letter "record sheet": a heading and subtitle, then each section of the
** record (label/value lines, wrapped and continued across pages), then a
** summary of each child list (its label, how many rows, and the first rows as
** a small table). It is the generic document a record print renders when the
** host supplies no template of its own.
*******************************************************************************/
public final class PrintingRecordDocuments
{
static final int MAX_LINES = 2000;
private static final float MARGIN = 54;
private static final int TITLE_SIZE = 16;
static final int MAX_LINES = 4000;
private static final int BODY_SIZE = 10;
private static final float LEADING = 14;
private static final int CHILD_SIZE = 8;
@@ -60,60 +53,91 @@ public final class PrintingRecordDocuments
/***************************************************************************
** Render one record as a PDF. Field order is the map's order; values are the
** display text the caller already formatted (null values print blank).
** One section: a label (blank for none) and label/value lines in order.
***************************************************************************/
public record Section(String label, Map<String, String> fields)
{
}
/***************************************************************************
** One child list: its label, total row count, column headings and the
** first rows (display text).
***************************************************************************/
public record ChildList(String label, int total, List<String> columns, List<List<String>> rows)
{
}
/***************************************************************************
** One section without children (the original record sheet).
***************************************************************************/
public static byte[] pdf(String title, String subtitle, Map<String, String> fields) throws QException
{
try(PDDocument document = new PDDocument(); InputStream fontStream = fontStream())
return (pdf(title, subtitle, List.of(new Section("", fields)), List.of()));
}
/***************************************************************************
** Render sections and child list summaries as a PDF.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, List<Section> sections, List<ChildList> children) throws QException
{
try(PrintingLayout layout = new PrintingLayout("LETTER", false, title, subtitle))
{
PDType0Font font = PDType0Font.load(document, fontStream, true);
float width = PDRectangle.LETTER.getWidth() - 2 * MARGIN;
int columns = Math.max(20, (int) (width / (font.getStringWidth("0") / 1000 * BODY_SIZE)));
int labelWidth = Math.min(28, Math.max(8, fields.keySet().stream().mapToInt(String::length).max().orElse(8) + 1));
List<String> lines = new ArrayList<>();
for(Map.Entry<String, String> field : fields.entrySet())
int columns = layout.columns(BODY_SIZE);
int labelWidth = Math.min(28, Math.max(8, sections.stream().flatMap(s -> s.fields().keySet().stream()).mapToInt(String::length).max().orElse(8) + 1));
int lines = 0;
for(Section section : sections)
{
List<String> wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1);
for(int i = 0; i < wrapped.size() && lines.size() < MAX_LINES; i++)
if(section.fields().isEmpty())
{
String label = i == 0 ? truncate(field.getKey(), labelWidth - 1) : "";
lines.add(String.format("%-" + labelWidth + "s %s", label, wrapped.get(i)));
continue;
}
}
int index = 0;
int pageNumber = 0;
do
{
pageNumber++;
PDPage page = new PDPage(PDRectangle.LETTER);
document.addPage(page);
try(PDPageContentStream content = new PDPageContentStream(document, page))
if(section.label() != null && !section.label().isBlank())
{
float y = PDRectangle.LETTER.getHeight() - MARGIN;
text(content, font, TITLE_SIZE, MARGIN, y, truncate(title, (int) (width / (font.getStringWidth("0") / 1000 * TITLE_SIZE))));
y -= LEADING + 6;
text(content, font, BODY_SIZE, MARGIN, y, truncate(subtitle + (pageNumber > 1 ? " (continued, page " + pageNumber + ")" : ""), columns));
y -= 6;
content.moveTo(MARGIN, y);
content.lineTo(MARGIN + width, y);
content.stroke();
y -= LEADING + 4;
while(index < lines.size() && y > MARGIN)
layout.gap(4);
layout.keep(3, BODY_SIZE);
layout.line(section.label(), BODY_SIZE + 1);
}
for(Map.Entry<String, String> field : section.fields().entrySet())
{
List<String> wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1);
for(int i = 0; i < wrapped.size() && lines++ < MAX_LINES; i++)
{
text(content, font, BODY_SIZE, MARGIN, y, lines.get(index++));
y -= LEADING;
layout.line(String.format("%-" + labelWidth + "s %s", i == 0 ? truncate(field.getKey(), labelWidth - 1) : "", wrapped.get(i)), BODY_SIZE);
}
}
}
while(index < lines.size());
ByteArrayOutputStream output = new ByteArrayOutputStream();
document.save(output);
return (output.toByteArray());
for(ChildList child : children)
{
layout.gap(8);
layout.keep(4, CHILD_SIZE);
layout.line(child.label() + " (" + child.total() + ")", BODY_SIZE + 1);
if(child.rows().isEmpty())
{
layout.line("(none)", CHILD_SIZE);
continue;
}
int[] widths = PrintingLayout.columnWidths(child.columns(), child.rows(), layout.columns(CHILD_SIZE));
layout.line(PrintingLayout.row(child.columns(), widths), CHILD_SIZE);
for(List<String> row : child.rows())
{
layout.line(PrintingLayout.row(row, widths), CHILD_SIZE);
}
if(child.total() > child.rows().size())
{
layout.line("... and " + (child.total() - child.rows().size()) + " more", CHILD_SIZE);
}
}
return (layout.finish());
}
catch(IOException exception)
{
throw new QException("Could not render the record document", exception);
throw new QException("Could not render the record document: " + exception.getMessage(), exception);
}
}
@@ -159,47 +183,4 @@ public final class PrintingRecordDocuments
String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ');
return (value.length() <= length ? value : value.substring(0, Math.max(0, length - 1)) + "~");
}
/***************************************************************************
** Draw one line, replacing characters the bundled font cannot encode.
***************************************************************************/
private static void text(PDPageContentStream content, PDType0Font font, int size, float x, float y, String text) throws IOException
{
StringBuilder safe = new StringBuilder();
text.codePoints().forEach(codePoint ->
{
String character = new String(Character.toChars(codePoint));
try
{
font.encode(character);
safe.append(character);
}
catch(IllegalArgumentException | IOException exception)
{
safe.append('?');
}
});
content.beginText();
content.setFont(font, size);
content.newLineAtOffset(x, y);
content.showText(safe.toString());
content.endText();
}
/***************************************************************************
** The licensed font shipped for the diagnostic documents.
***************************************************************************/
private static InputStream fontStream() throws IOException
{
InputStream stream = PrintingRecordDocuments.class.getResourceAsStream(PrintingTestDocuments.FONT_RESOURCE);
if(stream == null)
{
throw new IOException("Bundled document font is missing");
}
return (stream);
}
}
@@ -81,6 +81,7 @@ public final class PrintingRecordProcesses
public static final String COPIES_FIELD = "printingCopies";
public static final String JOB_IDS = "printingJobIds";
static final String REQUEST_ID = "printingRecordRequestId";
static final int CHILD_ROWS = 10;
@@ -131,6 +132,33 @@ public final class PrintingRecordProcesses
/***************************************************************************
** Add Print to every table of the instance that can be read one record at
** a time: skips hidden tables, tables without a primary key or without
** TABLE_GET, tables that already have Print, and tables whose
** PrintingTableSettings say printable false. Call it after every table is
** in the instance. Returns the tables that got Print.
***************************************************************************/
public static List<String> registerAll(QInstance instance) throws QException
{
List<String> added = new ArrayList<>();
for(QTableMetaData table : new ArrayList<>(instance.getTables().values()))
{
PrintingTableSettings settings = PrintingTableSettings.of(table);
boolean readable = table.isCapabilityEnabled(instance.getBackendForTable(table.getName()), com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_GET);
if(Boolean.TRUE.equals(table.getIsHidden()) || table.getPrimaryKeyField() == null || !readable
|| (settings != null && !settings.getPrintable()) || instance.getProcess(processName(table.getName())) != null)
{
continue;
}
register(instance, table.getName());
added.add(table.getName());
}
return (added);
}
/***************************************************************************
** Only approved printers that the device currently exposes and that take PDF.
***************************************************************************/
@@ -202,29 +230,164 @@ public final class PrintingRecordProcesses
{
throw new QException("Record " + primaryKey + " of " + table.getLabel() + " is not accessible");
}
Map<String, String> fields = new LinkedHashMap<>();
for(QFieldMetaData field : table.getFields().values())
{
if(Boolean.TRUE.equals(field.getIsHidden()) || field.getType() == QFieldType.BLOB)
{
continue;
}
String display = record.getDisplayValue(field.getName());
Serializable raw = record.getValue(field.getName());
fields.put(field.getLabel() == null ? field.getName() : field.getLabel(), display != null ? display : raw == null ? null : String.valueOf(raw));
}
String label = record.getRecordLabel() != null ? record.getRecordLabel() : String.valueOf(primaryKey);
String tableLabel = table.getLabel() == null ? table.getName() : table.getLabel();
String subtitle = tableLabel + " " + primaryKey + " - " + printedBy();
byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, sections(table, record), children(table, record));
return (queue(printer, tableLabel + ": " + label, bytes, copies, "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id")));
}
/***************************************************************************
** "printed <instant> by <user>" for subtitles.
***************************************************************************/
static String printedBy()
{
String user = QContext.getQSession() == null || QContext.getQSession().getUser() == null ? null : QContext.getQSession().getUser().getFullName();
String subtitle = tableLabel + " " + primaryKey + " - printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user);
byte[] bytes = PrintingRecordDocuments.pdf(tableLabel + ": " + label, subtitle, fields);
return ("printed " + Instant.now().truncatedTo(ChronoUnit.SECONDS) + (user == null ? "" : " by " + user));
}
/***************************************************************************
** Display text of one field of a record (display value, else raw value).
***************************************************************************/
static String text(QRecord record, QFieldMetaData field)
{
String display = record.getDisplayValue(field.getName());
Serializable raw = record.getValue(field.getName());
return (display != null ? display : raw == null ? null : String.valueOf(raw));
}
/***************************************************************************
** Whether a field belongs on paper: visible and not binary.
***************************************************************************/
static boolean printable(QFieldMetaData field)
{
return (field != null && !Boolean.TRUE.equals(field.getIsHidden()) && field.getType() != QFieldType.BLOB);
}
/***************************************************************************
** The record's field sections in tier order (T1, T2, T3), as the record
** view shows them; fields in no section follow under "Other fields". A
** table without sections prints one unlabeled section of every field.
***************************************************************************/
static List<PrintingRecordDocuments.Section> sections(QTableMetaData table, QRecord record)
{
List<PrintingRecordDocuments.Section> out = new ArrayList<>();
java.util.Set<String> placed = new java.util.HashSet<>();
List<com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection> sections = new ArrayList<>(table.getSections() == null ? List.of() : table.getSections());
sections.removeIf(section -> Boolean.TRUE.equals(section.getIsHidden()) || section.getFieldNames() == null || section.getFieldNames().isEmpty());
sections.sort(java.util.Comparator.comparing(section -> section.getTier() == null ? 9 : section.getTier().ordinal()));
for(var section : sections)
{
Map<String, String> fields = new LinkedHashMap<>();
for(String name : section.getFieldNames())
{
QFieldMetaData field = table.getFields().get(name);
if(printable(field) && placed.add(name))
{
fields.put(field.getLabel() == null ? name : field.getLabel(), text(record, field));
}
}
out.add(new PrintingRecordDocuments.Section(sections.size() == 1 ? "" : section.getLabel(), fields));
}
Map<String, String> rest = new LinkedHashMap<>();
for(QFieldMetaData field : table.getFields().values())
{
if(printable(field) && !placed.contains(field.getName()))
{
rest.put(field.getLabel() == null ? field.getName() : field.getLabel(), text(record, field));
}
}
if(!rest.isEmpty())
{
out.add(new PrintingRecordDocuments.Section(out.isEmpty() ? "" : "Other fields", rest));
}
return (out);
}
/***************************************************************************
** Each association of the table: its label, how many child rows, and the
** first rows with up to five visible columns. Children the user may not
** read, or that fail to load, are left out rather than failing the print.
***************************************************************************/
static List<PrintingRecordDocuments.ChildList> children(QTableMetaData table, QRecord record)
{
List<PrintingRecordDocuments.ChildList> out = new ArrayList<>();
for(var association : table.getAssociations() == null ? List.<com.kingsrook.qqq.backend.core.model.metadata.tables.Association>of() : table.getAssociations())
{
try
{
var join = QContext.getQInstance().getJoin(association.getJoinName());
QTableMetaData child = QContext.getQInstance().getTable(association.getAssociatedTableName());
if(join == null || child == null || join.getJoinOns() == null || join.getJoinOns().isEmpty())
{
continue;
}
boolean forward = table.getName().equals(join.getLeftTable());
QQueryFilter filter = new QQueryFilter();
for(var on : join.getJoinOns())
{
Serializable value = record.getValue(forward ? on.getLeftField() : on.getRightField());
filter.withCriteria(new QFilterCriteria(forward ? on.getRightField() : on.getLeftField(), QCriteriaOperator.EQUALS, value));
}
var count = new com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput();
count.setTableName(child.getName());
count.setFilter(filter);
count.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
Integer total = new com.kingsrook.qqq.backend.core.actions.tables.CountAction().execute(count).getCount();
var query = new com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput();
query.setTableName(child.getName());
query.setFilter(filter.clone().withLimit(CHILD_ROWS));
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
query.setShouldGenerateDisplayValues(true);
query.setShouldTranslatePossibleValues(true);
List<QRecord> rows = new com.kingsrook.qqq.backend.core.actions.tables.QueryAction().execute(query).getRecords();
java.util.Set<String> joinFields = new java.util.HashSet<>();
join.getJoinOns().forEach(on -> joinFields.add(forward ? on.getRightField() : on.getLeftField()));
List<QFieldMetaData> columns = child.getFields().values().stream()
.filter(field -> printable(field) && !joinFields.contains(field.getName())).limit(5).toList();
List<List<String>> cells = new ArrayList<>();
for(QRecord row : rows)
{
List<String> line = new ArrayList<>();
columns.forEach(field -> line.add(text(row, field)));
cells.add(line);
}
String label = association.getName().equals(child.getName()) && child.getLabel() != null ? child.getLabel() : com.kingsrook.qqq.backend.core.instances.QInstanceEnricher.nameToLabel(association.getName());
out.add(new PrintingRecordDocuments.ChildList(label, total == null ? rows.size() : total,
columns.stream().map(field -> field.getLabel() == null ? field.getName() : field.getLabel()).toList(), cells));
}
catch(Exception exception)
{
// a child list the user may not read (or that cannot load) is left out of the sheet
}
}
return (out);
}
/***************************************************************************
** Queue one PDF on a printer through the durable queue; returns the job id.
***************************************************************************/
static String queue(QRecord printer, String title, byte[] bytes, int copies, String idempotencyKey) throws QException
{
try
{
JSONObject job = PrintingProcesses.currentService().submit(new JSONObject()
.put("deviceId", printer.getValueString("device_id")).put("printerId", printer.getValueString("printer_id"))
.put("title", PrintingRecordDocuments.truncate(tableLabel + ": " + label, 200)).put("format", "PDF")
.put("title", PrintingRecordDocuments.truncate(title, 200)).put("format", "PDF")
.put("contentBase64", Base64.getEncoder().encodeToString(bytes))
.put("idempotencyKey", "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"))
.put("idempotencyKey", idempotencyKey)
.put("options", new JSONObject().put("copies", copies).put("scale", "FIT")));
return (job.getString("jobId"));
}
@@ -0,0 +1,124 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.printing;
import java.io.IOException;
import java.util.List;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** A table list: a grid of rows under a repeated column header, in fixed-width
** monospace columns sized from the content and shrunk to fit the page.
*******************************************************************************/
public final class PrintingTableDocuments
{
static final int FONT_SIZE = 8;
/***************************************************************************
** Utility class.
***************************************************************************/
private PrintingTableDocuments()
{
}
/***************************************************************************
** Render columns and rows (display text) on LETTER or A4, portrait or
** landscape, without preferred widths or a footer.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, List<String> columns, List<List<String>> rows, String pageSize, boolean landscape) throws QException
{
return (pdf(title, subtitle, null, columns, rows, null, pageSize, landscape ? "LANDSCAPE" : "PORTRAIT"));
}
/***************************************************************************
** Render a table list. Preferred widths (the grid's, in characters) are
** the starting column widths, else the content decides; either way they
** shrink to fit the page. Orientation AUTO turns the page landscape when
** the columns do not fit portrait. The header repeats on every page; the
** footer text goes at the bottom left of every page.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, String footer, List<String> columns, List<List<String>> rows, int[] preferredWidths, String pageSize, String orientation) throws QException
{
if(columns.isEmpty())
{
throw new QException("A table list needs at least one column");
}
int[] natural = PrintingLayout.naturalWidths(columns, rows);
if(preferredWidths != null && preferredWidths.length == columns.size())
{
for(int c = 0; c < natural.length; c++)
{
natural[c] = Math.max(4, Math.min(60, preferredWidths[c]));
}
}
boolean landscape = isLandscape(orientation, natural, pageSize);
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle))
{
layout.footer(footer);
int[] widths = PrintingLayout.fit(natural, layout.columns(FONT_SIZE));
String header = PrintingLayout.row(columns, widths);
String rule = "-".repeat(Math.min(layout.columns(FONT_SIZE), Math.max(1, header.length())));
layout.line(header, FONT_SIZE);
layout.line(rule, FONT_SIZE);
layout.repeatOnNewPages(List.of(header, rule), FONT_SIZE);
for(List<String> row : rows)
{
layout.line(PrintingLayout.row(row, widths), FONT_SIZE);
}
layout.repeatOnNewPages(List.of(), FONT_SIZE);
layout.gap(4);
layout.line(rows.size() + (rows.size() == 1 ? " row" : " rows"), FONT_SIZE);
return (layout.finish());
}
catch(IOException exception)
{
throw new QException("Could not render the table list: " + exception.getMessage(), exception);
}
}
/***************************************************************************
** PORTRAIT, LANDSCAPE, or AUTO: landscape when the natural widths do not
** fit across a portrait page.
***************************************************************************/
static boolean isLandscape(String orientation, int[] natural, String pageSize)
{
if("LANDSCAPE".equalsIgnoreCase(orientation))
{
return (true);
}
if(!"AUTO".equalsIgnoreCase(orientation))
{
return (false);
}
float width = ("A4".equalsIgnoreCase(pageSize) ? 595.28f : 612f) - 2 * PrintingLayout.MARGIN;
return (PrintingLayout.lineWidth(natural) > (int) (width / (0.6f * FONT_SIZE)));
}
}
@@ -0,0 +1,91 @@
/*
* 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.printing;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Per-table printing metadata. A table carrying it with printable false gets
** no Print process from PrintingRecordProcesses.registerAll:
** {@code table.withSupplementalMetaData(new PrintingTableSettings().withPrintable(false))}.
*******************************************************************************/
public class PrintingTableSettings extends QSupplementalTableMetaData
{
public static final String TYPE = "printing";
private boolean printable = true;
/***************************************************************************
** The settings of a table, or null when it has none.
***************************************************************************/
public static PrintingTableSettings of(QTableMetaData table)
{
return (table.getSupplementalMetaData() == null ? null : (PrintingTableSettings) table.getSupplementalMetaData(TYPE));
}
/***************************************************************************
** Supplemental metadata type.
***************************************************************************/
@Override
public String getType()
{
return (TYPE);
}
/***************************************************************************
** Whether the table's records and lists may be printed (default true).
***************************************************************************/
public boolean getPrintable()
{
return (printable);
}
/***************************************************************************
** Fluent setter.
***************************************************************************/
public PrintingTableSettings withPrintable(boolean value)
{
printable = value;
return (this);
}
/***************************************************************************
** No mutable references beyond the flag, which the base clone copies.
***************************************************************************/
@Override
protected QSupplementalTableMetaData finishClone(QSupplementalTableMetaData clone)
{
return (clone);
}
}
@@ -0,0 +1,102 @@
/*
* 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.printing;
import java.io.IOException;
import com.kingsrook.qqq.backend.core.exceptions.QException;
/*******************************************************************************
** Plain text as a PDF: monospace, long lines hard-wrapped, broken across
** pages. Used to print a capture of a terminal screen (or any text).
*******************************************************************************/
public final class PrintingTextDocuments
{
static final int FONT_SIZE = 8;
static final float MIN_FONT_SIZE = 5.5f;
static final int MAX_CHARACTERS = 2_000_000;
/***************************************************************************
** Utility class.
***************************************************************************/
private PrintingTextDocuments()
{
}
/***************************************************************************
** Render text on LETTER or A4, portrait or landscape.
***************************************************************************/
public static byte[] pdf(String title, String subtitle, String text, String pageSize, boolean landscape) throws QException
{
if(text == null || text.isEmpty())
{
throw new QException("There is no text to print");
}
if(text.length() > MAX_CHARACTERS)
{
throw new QException("The text is too long to print (at most " + MAX_CHARACTERS + " characters)");
}
try(PrintingLayout layout = new PrintingLayout(pageSize, landscape, title, subtitle))
{
String[] lines = text.replace("\r\n", "\n").replace('\r', '\n').replace("\t", " ").split("\n", -1);
float size = fontSize(lines, layout.columns(1) * 1f);
int width = layout.columns(size);
layout.leading(1.2f);
for(String line : lines)
{
String rest = line.stripTrailing();
do
{
layout.line(rest.length() > width ? rest.substring(0, width) : rest, size);
rest = rest.length() > width ? rest.substring(width) : "";
}
while(!rest.isEmpty());
}
return (layout.finish());
}
catch(IOException exception)
{
throw new QException("Could not render the text: " + exception.getMessage(), exception);
}
}
/***************************************************************************
** The font size (8 down to 5.5 points) at which the widest line fits, so a
** screen capture keeps its layout; wider text still wraps at 5.5.
***************************************************************************/
static float fontSize(String[] lines, float charactersAtOnePoint)
{
int widest = 1;
for(String line : lines)
{
widest = Math.max(widest, line.stripTrailing().length());
}
float fits = charactersAtOnePoint / widest;
return (Math.max(MIN_FONT_SIZE, Math.min(FONT_SIZE, (float) Math.floor(fits * 2) / 2)));
}
}
@@ -49,11 +49,11 @@ public final class PrintingUiApi
MiddlewareSpecExtensionsMetaData.ofOrWithNew(instance).withExtension(extension());
}
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims. */
/** Semantic extension 2.0.0 requires authenticated realtime delivery for native claims; 2.1.0 adds the record, table-list and text print processes. */
public static MiddlewareSpecExtension extension()
{
return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.0.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1.")
return (new MiddlewareSpecExtension().withName("printing").withSemanticVersion("2.1.0")
.withDescription("Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes.")
.withSpec(Session.class)
.withSpec(Bind.class)
.withSpec(Enroll.class)
@@ -605,9 +605,9 @@ info:
contact:
email: "contact@kingsrook.com"
description: "Native current-user printing with secondary device proof, device-control-v1\
\ and realtime-v1."
\ and realtime-v1; record, table-list and text printing processes."
title: "QQQ Middleware API extension: printing"
version: "2.0.0"
version: "2.1.0"
openapi: "3.0.3"
paths:
/qqq/v1/qbit/printing/session:
@@ -0,0 +1,357 @@
/*
* 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.printing;
import java.io.Serializable;
import java.util.List;
import java.util.Map;
import com.kingsrook.qqq.backend.core.actions.processes.RunProcessAction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessInput;
import com.kingsrook.qqq.backend.core.model.actions.processes.RunProcessOutput;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Association;
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;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryBackendModule;
import com.kingsrook.qqq.backend.core.modules.backend.implementations.memory.MemoryRecordStore;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.json.JSONArray;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/*******************************************************************************
** Print anything: Print on every table (registerAll, opt-out), the metadata
** record sheet with sections and child lists, the table list and text prints.
*******************************************************************************/
class PrintingListProcessesTest
{
/***************************************************************************
** Clear the memory store and context.
***************************************************************************/
@AfterEach
void clear()
{
MemoryRecordStore.getInstance().reset();
QContext.clear();
}
/***************************************************************************
** registerAll adds Print to readable tables and honors the opt-out.
***************************************************************************/
@Test
void registerAllAddsPrintToEveryTableButHiddenAndOptedOut() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixture();
List<String> added = PrintingRecordProcesses.registerAll(fixture.instance());
assertTrue(added.contains("pet"), added::toString);
assertFalse(added.contains("person"), "person already had Print");
assertFalse(added.contains("hiddenThing"));
assertFalse(added.contains("secretThing"));
assertTrue(fixture.instance().getProcess("printingPrintRecords_pet") != null);
assertTrue(fixture.instance().getProcess("printingPrintRecords_secretThing") == null);
assertEquals(List.of(), PrintingRecordProcesses.registerAll(fixture.instance()), "a second call adds nothing");
assertTrue(fixture.instance().getProcess(PrintingListProcesses.TABLE_LIST) != null);
assertTrue(fixture.instance().getProcess(PrintingListProcesses.TEXT) != null);
}
/***************************************************************************
** The record sheet follows the sections in tier order and summarizes child lists.
***************************************************************************/
@Test
void recordSheetHasSectionsAndChildLists() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixture();
String process = PrintingRecordProcesses.processName("person");
var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1"));
var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID())
.withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()));
new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID())
.withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
String text = jobText(fixture, 0);
assertTrue(text.indexOf("Identity") < text.indexOf("Contact"), text);
assertTrue(text.contains("Email"), text);
assertTrue(text.contains("ada@example.test"), text);
assertTrue(text.contains("Other fields"), text);
assertTrue(text.contains("Pets (2)"), text);
assertTrue(text.contains("Rex") && text.contains("Tom"), text);
assertFalse(text.contains("Secret"), text);
}
/***************************************************************************
** All rows, in the given column order and sort, with the count.
***************************************************************************/
@Test
void tableListOfAllRowsInColumnOrder() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixture();
RunProcessOutput output = run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "columns", "lastName,firstName", "scope", "ALL",
"filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"lastName\",\"isAscending\":true}]}", "orientation", "PORTRAIT",
PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()));
assertEquals(1, String.valueOf(output.getValues().get(PrintingRecordProcesses.JOB_IDS)).split(",").length);
String text = jobText(fixture, 0);
assertTrue(text.contains("Last name"), text);
assertTrue(text.indexOf("Last name") < text.indexOf("First name"), text);
assertTrue(text.indexOf("Hopper") < text.indexOf("Lovelace"), text);
assertTrue(text.contains("all 2 rows"), text);
assertTrue(text.contains("sort Last name"), text);
assertTrue(text.contains("Person - printed "), "the footer names the table, time and user");
assertTrue(text.contains("2 rows"), text);
assertTrue(fixture.service().jobs().getJSONObject(0).getString("title").startsWith("Person"));
}
/***************************************************************************
** The current page only: the filter's skip and limit.
***************************************************************************/
@Test
void tableListOfThePageOnly() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixture();
run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "scope", "PAGE", "title", "People page 2",
"filterJSON", "{\"criteria\":[],\"orderBys\":[{\"fieldName\":\"id\",\"isAscending\":true}],\"skip\":1,\"limit\":1}",
PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()));
String text = jobText(fixture, 0);
assertTrue(text.contains("People page 2"), text);
assertTrue(text.contains("rows 2-2 of 2"), text);
assertTrue(text.contains("Grace") && !text.contains("Lovelace"), text);
assertTrue(text.contains("Email"), "default columns are the visible fields");
assertFalse(text.contains("Secret"), text);
}
/***************************************************************************
** All rows of a result larger than the limit: the first rows, read a page
** at a time, with a message saying how many were left out; the grid's
** widths are used and AUTO turns wide lists landscape.
***************************************************************************/
@Test
void tableListOfAllRowsBeyondTheLimitSaysSo() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixture();
List<QRecord> more = new java.util.ArrayList<>();
for(int i = 3; i <= 2500; i++)
{
more.add(new QRecord().withValue("id", i).withValue("firstName", "First" + i).withValue("lastName", "Last" + i).withValue("email", "p" + i + "@example.test").withValue("note", "note " + i));
}
new InsertAction().execute(new InsertInput("person").withRecords(more));
RunProcessOutput output = run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "scope", "ALL", "maxRows", 2100,
"columnWidths", "4,40,40,40,40", "filterDescription", "id > 0", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()));
assertEquals(true, output.getValues().get("printingTruncated"));
assertTrue(String.valueOf(output.getValues().get("printingMessage")).contains("first 2100 of 2500 rows"), () -> String.valueOf(output.getValues()));
String job = fixture.service().jobs().getJSONObject(0).getString("jobId");
fixture.service().claimForJob(fixture.device(), job);
try(PDDocument document = PDDocument.load(fixture.service().content(fixture.device(), job)))
{
assertTrue(document.getPage(0).getMediaBox().getWidth() > document.getPage(0).getMediaBox().getHeight(), "AUTO chose landscape");
assertTrue(document.getNumberOfPages() > 30, "pages: " + document.getNumberOfPages());
String text = new PDFTextStripper().getText(document);
assertTrue(text.contains("first 2100 of 2500 rows (print limit 2100)"), text.substring(0, 400));
assertTrue(text.contains("filter id > 0"));
assertTrue(text.contains("2100 rows"));
assertTrue(text.contains("First2100") && !text.contains("First2101 "), "rows stop at the limit");
PDFTextStripper last = new PDFTextStripper();
last.setStartPage(document.getNumberOfPages());
String lastPage = last.getText(document);
assertTrue(lastPage.contains("Id") && lastPage.contains("First name"), "the header repeats on every page");
}
}
/***************************************************************************
** Unknown columns, opted-out tables and too many rows are refused.
***************************************************************************/
@Test
void tableListRefusals() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixture();
assertThrows(QException.class, () -> run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "columns", "nope", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())));
assertThrows(QException.class, () -> run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "secretThing", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())));
assertThrows(QException.class, () -> run(PrintingListProcesses.TABLE_LIST, Map.of("tableName", "person", "scope", "ALL", "maxRows", 999999, PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())));
assertEquals(0, fixture.service().jobs().length());
}
/***************************************************************************
** Text prints monospace, wraps long lines and breaks pages.
***************************************************************************/
@Test
void textPrintsAScreenCapture() throws Exception
{
PrintingProcessesTest.Fixture fixture = fixture();
StringBuilder screen = new StringBuilder("+-- Dashboard --+\n| sales 1,234 |\n" + "x".repeat(300) + "\n");
for(int i = 0; i < 150; i++)
{
screen.append("line ").append(i).append('\n');
}
run(PrintingListProcesses.TEXT, Map.of("title", "Dashboard", "subtitle", "qqq ui", "text", screen.toString(), PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()));
JSONArray jobs = fixture.service().jobs();
assertEquals("Dashboard", jobs.getJSONObject(0).getString("title"));
fixture.service().claimForJob(fixture.device(), jobs.getJSONObject(0).getString("jobId"));
try(PDDocument document = PDDocument.load(fixture.service().content(fixture.device(), jobs.getJSONObject(0).getString("jobId"))))
{
assertTrue(document.getNumberOfPages() > 1);
String text = new PDFTextStripper().getText(document);
assertTrue(text.contains("| sales 1,234 |"), text);
assertTrue(text.contains("line 149"), text);
}
assertThrows(QException.class, () -> run(PrintingListProcesses.TEXT, Map.of("text", "", PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())));
}
/***************************************************************************
** A 150-column terminal capture keeps its lines on a landscape page (the
** font shrinks to fit); much wider text still wraps at the smallest size.
***************************************************************************/
@Test
void wideScreenCaptureFitsWithoutWrapping() throws Exception
{
String wide = "| " + "y".repeat(146) + " |";
try(PDDocument document = PDDocument.load(PrintingTextDocuments.pdf("Screen", "", wide + "\n" + wide, "LETTER", true)))
{
assertTrue(new PDFTextStripper().getText(document).contains(wide));
}
assertEquals(8f, PrintingTextDocuments.fontSize(new String[] {"short"}, 1000), 0.01);
assertEquals(PrintingTextDocuments.MIN_FONT_SIZE, PrintingTextDocuments.fontSize(new String[] {"z".repeat(5000)}, 1000), 0.01);
}
/***************************************************************************
** Column widths shrink widest-first to fit, never below four.
***************************************************************************/
@Test
void columnWidthsFitTheLine()
{
int[] widths = PrintingLayout.columnWidths(List.of("Id", "Name", "Notes"), List.of(List.of("1", "Ada", "x".repeat(200))), 40);
assertTrue(widths[0] + widths[1] + widths[2] + 4 <= 40);
assertEquals(4, widths[0]);
assertArrayEquals(new int[] {4, 4}, PrintingLayout.columnWidths(List.of("A", "B"), List.of(), 3));
assertEquals("1 Ada", PrintingLayout.row(List.of("1", "Ada"), new int[] {4, 4}));
}
/***************************************************************************
** Run a process straight through with values.
***************************************************************************/
private static RunProcessOutput run(String process, Map<String, Serializable> values) throws QException
{
RunProcessInput input = new RunProcessInput().withProcessName(process).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP);
// as the UI API runs it: user input, so server-configured inputs would be refused
input.setInputSource(com.kingsrook.qqq.backend.core.model.actions.tables.QInputSource.USER);
values.forEach(input::withValue);
RunProcessOutput output = new RunProcessAction().execute(input);
if(output.getException().isPresent())
{
throw new QException(output.getException().get());
}
return (output);
}
/***************************************************************************
** The text of the n-th newest job's PDF.
***************************************************************************/
private static String jobText(PrintingProcessesTest.Fixture fixture, int index) throws Exception
{
String job = fixture.service().jobs().getJSONObject(index).getString("jobId");
fixture.service().claimForJob(fixture.device(), job);
try(PDDocument document = PDDocument.load(fixture.service().content(fixture.device(), job)))
{
return (new PDFTextStripper().getText(document));
}
}
/***************************************************************************
** The process fixture plus person (sections, pets), pet, a hidden and an
** opted-out table, on the memory backend.
***************************************************************************/
private static PrintingProcessesTest.Fixture fixture() throws Exception
{
PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture();
QInstance instance = fixture.instance();
instance.addBackend(new QBackendMetaData().withName("memory").withBackendType(MemoryBackendModule.class));
instance.addTable(new QTableMetaData().withName("person").withLabel("Person").withBackendName("memory").withPrimaryKeyField("id")
.withRecordLabelFormat("%s %s").withRecordLabelFields(List.of("firstName", "lastName"))
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id"))
.withField(new QFieldMetaData("firstName", QFieldType.STRING).withLabel("First name"))
.withField(new QFieldMetaData("lastName", QFieldType.STRING).withLabel("Last name"))
.withField(new QFieldMetaData("email", QFieldType.STRING).withLabel("Email"))
.withField(new QFieldMetaData("note", QFieldType.STRING).withLabel("Note"))
.withField(new QFieldMetaData("secret", QFieldType.STRING).withLabel("Secret").withIsHidden(true))
.withSection(new QFieldSection("contact", "Contact", null, Tier.T2, List.of("email")))
.withSection(new QFieldSection("identity", "Identity", null, Tier.T1, List.of("id", "firstName", "lastName")))
.withAssociation(new Association().withName("pets").withAssociatedTableName("pet").withJoinName("personPet")));
instance.addTable(new QTableMetaData().withName("pet").withLabel("Pet").withBackendName("memory").withPrimaryKeyField("id")
.withField(new QFieldMetaData("id", QFieldType.INTEGER).withLabel("Id"))
.withField(new QFieldMetaData("personId", QFieldType.INTEGER).withLabel("Owner"))
.withField(new QFieldMetaData("name", QFieldType.STRING).withLabel("Name")));
instance.addJoin(new QJoinMetaData().withName("personPet").withLeftTable("person").withRightTable("pet").withType(JoinType.ONE_TO_MANY).withJoinOn(new JoinOn("id", "personId")));
instance.addTable(new QTableMetaData().withName("hiddenThing").withBackendName("memory").withPrimaryKeyField("id").withIsHidden(true)
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
instance.addTable(new QTableMetaData().withName("secretThing").withLabel("Secret thing").withBackendName("memory").withPrimaryKeyField("id")
.withSupplementalMetaData(new PrintingTableSettings().withPrintable(false))
.withField(new QFieldMetaData("id", QFieldType.INTEGER)));
PrintingRecordProcesses.register(instance, "person");
new InsertAction().execute(new InsertInput("person").withRecords(List.of(
new QRecord().withValue("id", 1).withValue("firstName", "Ada").withValue("lastName", "Lovelace").withValue("email", "ada@example.test").withValue("note", "n").withValue("secret", "x"),
new QRecord().withValue("id", 2).withValue("firstName", "Grace").withValue("lastName", "Hopper").withValue("email", "grace@example.test"))));
new InsertAction().execute(new InsertInput("pet").withRecords(List.of(
new QRecord().withValue("id", 1).withValue("personId", 1).withValue("name", "Rex"),
new QRecord().withValue("id", 2).withValue("personId", 1).withValue("name", "Tom"),
new QRecord().withValue("id", 3).withValue("personId", 2).withValue("name", "Kit"))));
return (fixture);
}
}
@@ -209,7 +209,8 @@ class PrintingRecordProcessesTest
assertTrue(document.getNumberOfPages() > 1);
String text = new PDFTextStripper().getText(document);
assertTrue(text.contains("Field 79"));
assertTrue(text.contains("continued, page 2"));
assertTrue(text.contains("(continued)"));
assertTrue(text.contains("Page 2 of "));
}
assertEquals(List.of("abc", "def"), PrintingRecordDocuments.wrap("abc def", 4));
assertEquals(List.of("abcd", "ef"), PrintingRecordDocuments.wrap("abcdef", 4));
@@ -61,7 +61,7 @@ class PrintingUiApiContractTest
var model = version.generateExtensionOpenAPIModel("qqq", extension);
assertEquals(17, model.getPaths().size());
assertTrue(model.getPaths().keySet().stream().allMatch(path -> path.startsWith("/qqq/v1/qbit/printing/")));
assertEquals("2.0.0", extension.getSemanticVersion());
assertEquals("2.1.0", extension.getSemanticVersion());
assertTrue(generated.contains("X-QQQ-Device-Token"));
assertTrue(generated.contains("application/octet-stream"));
assertTrue(generated.contains("principalId"));