Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63022f1065 |
No files matched your search
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased (0.3.1-SNAPSHOT)
|
||||
|
||||
- Add record printing: `PrintingRecordProcesses.register(instance, table)` adds a
|
||||
**Print** process (`printingPrintRecords_{table}`) to a host table's records and
|
||||
selections. It asks for an approved, exposed PDF printer and a copy count, shows
|
||||
the standard review, then renders each selected record as a Letter record sheet
|
||||
(`PrintingRecordDocuments`: heading, visible non-binary fields with display
|
||||
values, wrapped and continued across pages) and queues one PDF job per record.
|
||||
The queued job IDs are returned in the process value `printingJobIds`.
|
||||
- Fix selection actions whose first step is a form (**Print a test page**,
|
||||
**Print a test label** on selected printers, and **Print**): over the UI API the
|
||||
record selection reaches only the init call, so the review answered "No records
|
||||
were selected". The prepare step now keeps the selection as `queryFilterJson`.
|
||||
|
||||
## 0.3.0-rc.1 — 2026-10-31
|
||||
|
||||
Upgrade the server before the matching Windows connector. Native printing API
|
||||
|
||||
@@ -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.0-rc.1</version>
|
||||
<version>0.3.1-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>
|
||||
|
||||
@@ -376,6 +376,24 @@ public class PrintingProcesses implements BackendStep
|
||||
currentService();
|
||||
String request = input.getValueString(TEST_REQUEST_ID);
|
||||
output.addValue(TEST_REQUEST_ID, request == null || request.isBlank() ? UUID.randomUUID().toString() : request);
|
||||
keepSelection(input, output);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A selection action whose first step is a form loses the init request's
|
||||
** record selection: the UI API passes it only to the init call (as the
|
||||
** callback filter) and the extract step, which would store it, runs after
|
||||
** the form. Keep it as the extract step's own queryFilterJson value.
|
||||
***************************************************************************/
|
||||
static void keepSelection(RunBackendStepInput input, RunBackendStepOutput output)
|
||||
{
|
||||
String kept = input.getValueString("queryFilterJson");
|
||||
if((kept == null || kept.isBlank()) && input.getCallback() != null && input.getCallback().getQueryFilter() != null)
|
||||
{
|
||||
output.addValue("queryFilterJson", com.kingsrook.qqq.backend.core.utils.JsonUtils.toJson(input.getCallback().getQueryFilter()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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.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.
|
||||
*******************************************************************************/
|
||||
public final class PrintingRecordDocuments
|
||||
{
|
||||
static final int MAX_LINES = 2000;
|
||||
private static final float MARGIN = 54;
|
||||
private static final int TITLE_SIZE = 16;
|
||||
private static final int BODY_SIZE = 10;
|
||||
private static final float LEADING = 14;
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private 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).
|
||||
***************************************************************************/
|
||||
public static byte[] pdf(String title, String subtitle, Map<String, String> fields) throws QException
|
||||
{
|
||||
try(PDDocument document = new PDDocument(); InputStream fontStream = fontStream())
|
||||
{
|
||||
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())
|
||||
{
|
||||
List<String> wrapped = wrap(field.getValue() == null ? "" : field.getValue(), columns - labelWidth - 1);
|
||||
for(int i = 0; i < wrapped.size() && lines.size() < MAX_LINES; i++)
|
||||
{
|
||||
String label = i == 0 ? truncate(field.getKey(), labelWidth - 1) : "";
|
||||
lines.add(String.format("%-" + labelWidth + "s %s", label, wrapped.get(i)));
|
||||
}
|
||||
}
|
||||
int index = 0;
|
||||
int pageNumber = 0;
|
||||
do
|
||||
{
|
||||
pageNumber++;
|
||||
PDPage page = new PDPage(PDRectangle.LETTER);
|
||||
document.addPage(page);
|
||||
try(PDPageContentStream content = new PDPageContentStream(document, page))
|
||||
{
|
||||
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)
|
||||
{
|
||||
text(content, font, BODY_SIZE, MARGIN, y, lines.get(index++));
|
||||
y -= LEADING;
|
||||
}
|
||||
}
|
||||
}
|
||||
while(index < lines.size());
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||
document.save(output);
|
||||
return (output.toByteArray());
|
||||
}
|
||||
catch(IOException exception)
|
||||
{
|
||||
throw new QException("Could not render the record document", exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Break text at spaces (or hard, for long words) into lines of at most
|
||||
** width characters; embedded line breaks start new lines.
|
||||
***************************************************************************/
|
||||
static List<String> wrap(String text, int width)
|
||||
{
|
||||
List<String> lines = new ArrayList<>();
|
||||
for(String paragraph : text.replace("\r\n", "\n").replace('\r', '\n').replace('\t', ' ').split("\n", -1))
|
||||
{
|
||||
String rest = paragraph.strip();
|
||||
if(rest.isEmpty())
|
||||
{
|
||||
lines.add("");
|
||||
continue;
|
||||
}
|
||||
while(rest.length() > width)
|
||||
{
|
||||
int cut = rest.lastIndexOf(' ', width);
|
||||
if(cut <= 0)
|
||||
{
|
||||
cut = width;
|
||||
}
|
||||
lines.add(rest.substring(0, cut).stripTrailing());
|
||||
rest = rest.substring(cut).stripLeading();
|
||||
}
|
||||
lines.add(rest);
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Shorten to at most length characters, marking the cut.
|
||||
***************************************************************************/
|
||||
static String truncate(String text, int length)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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.time.Instant;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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.GetAction;
|
||||
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.ProcessSummaryLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
|
||||
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.processes.Status;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.get.GetInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
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.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.processes.implementations.etl.streamedwithfrontend.AbstractLoadStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.AbstractTransformStep;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.ProcessSummaryProviderInterface;
|
||||
import com.kingsrook.qqq.backend.core.processes.implementations.etl.streamedwithfrontend.StreamedETLWithFrontendProcess;
|
||||
import org.json.JSONObject;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** "Print" on the records of a host table: choose an approved, exposed PDF
|
||||
** printer and a copy count, review the selection, then each selected record is
|
||||
** rendered as a record sheet (PrintingRecordDocuments) and queued as its own
|
||||
** print job through the ordinary durable queue. The queued job IDs are returned
|
||||
** in the process value printingJobIds (comma separated) so clients can follow
|
||||
** each job's status in Print jobs.
|
||||
**
|
||||
** Register per table with register(instance, tableName); the process is named
|
||||
** printingPrintRecords_{tableName} (processName(tableName)).
|
||||
*******************************************************************************/
|
||||
public final class PrintingRecordProcesses
|
||||
{
|
||||
public static final String PREFIX = "printingPrintRecords_";
|
||||
public static final String PRINTER_FIELD = "printingPrinterId";
|
||||
public static final String COPIES_FIELD = "printingCopies";
|
||||
public static final String JOB_IDS = "printingJobIds";
|
||||
static final String REQUEST_ID = "printingRecordRequestId";
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Utility class.
|
||||
***************************************************************************/
|
||||
private PrintingRecordProcesses()
|
||||
{
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The record-print process name of a table.
|
||||
***************************************************************************/
|
||||
public static String processName(String tableName)
|
||||
{
|
||||
return (PREFIX + tableName);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Add the Print action to a table's records and selections.
|
||||
***************************************************************************/
|
||||
public static void register(QInstance instance, String tableName) throws QException
|
||||
{
|
||||
QTableMetaData table = instance.getTable(tableName);
|
||||
if(table == null)
|
||||
{
|
||||
throw new QException("Cannot add printing to unknown table " + tableName);
|
||||
}
|
||||
Map<String, Serializable> values = new HashMap<>();
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE, tableName);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DESTINATION_TABLE, tableName);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_SUPPORTS_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_DO_FULL_VALIDATION, true);
|
||||
values.put(StreamedETLWithFrontendProcess.FIELD_PREVIEW_MESSAGE, "Each selected record will be printed as its own job on the chosen printer after you continue.");
|
||||
QProcessMetaData process = StreamedETLWithFrontendProcess.defineProcessMetaData(PrintingExtract.class, Transform.class, Load.class, values)
|
||||
.withName(processName(tableName)).withLabel("Print").withTableName(tableName).withMinInputRecords(1)
|
||||
.withPermissionRules(PrintingProcesses.permissionRules());
|
||||
process.withStep(0, new QFrontendStepMetaData().withName("printer").withLabel("Choose a printer")
|
||||
.withFormFields(List.of(printerField(), new QFieldMetaData(COPIES_FIELD, QFieldType.INTEGER).withLabel("Copies").withIsRequired(true).withDefaultValue(1)))
|
||||
.withComponent(new QFrontendComponentMetaData().withType(QComponentType.EDIT_FORM)));
|
||||
process.withStep(0, new QBackendStepMetaData().withName("preparePrint").withCode(new QCodeReference(Prepare.class)));
|
||||
instance.addProcess(process);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Only approved printers that the device currently exposes and that take PDF.
|
||||
***************************************************************************/
|
||||
static QFieldMetaData printerField()
|
||||
{
|
||||
QQueryFilter filter = new QQueryFilter(new QFilterCriteria("enabled", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("available", QCriteriaOperator.EQUALS, 1))
|
||||
.withCriteria(new QFilterCriteria("formats", QCriteriaOperator.CONTAINS, "PDF"));
|
||||
return (new QFieldMetaData(PRINTER_FIELD, QFieldType.STRING).withLabel("Printer").withIsRequired(true)
|
||||
.withPossibleValueSourceName(PrintingTables.PRINTER).withPossibleValueSourceFilter(filter));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The chosen printer, re-read with the caller's permissions and checked.
|
||||
***************************************************************************/
|
||||
static QRecord printer(RunBackendStepInput input) throws QException
|
||||
{
|
||||
String id = input.getValueString(PRINTER_FIELD);
|
||||
if(id == null || id.isBlank())
|
||||
{
|
||||
throw new QException("Choose a printer");
|
||||
}
|
||||
QRecord printer = PrintingProcesses.resolvePrinter(id, null);
|
||||
if(!Integer.valueOf(1).equals(printer.getValueInteger("enabled")) || !Integer.valueOf(1).equals(printer.getValueInteger("available"))
|
||||
|| printer.getValueString("formats") == null || !new org.json.JSONArray(printer.getValueString("formats")).toList().contains("PDF"))
|
||||
{
|
||||
throw new QException("Printer " + printer.getValueString("name") + " must be exposed by its device, approved, and support PDF");
|
||||
}
|
||||
return (printer);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** 1 when unset; 1 to 100 otherwise (the protocol's copies range).
|
||||
***************************************************************************/
|
||||
static int copies(RunBackendStepInput input) throws QException
|
||||
{
|
||||
Integer copies = input.getValueInteger(COPIES_FIELD);
|
||||
if(copies == null)
|
||||
{
|
||||
return (1);
|
||||
}
|
||||
if(copies < 1 || copies > 100)
|
||||
{
|
||||
throw new QException("Copies must be between 1 and 100");
|
||||
}
|
||||
return (copies);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Render one record (display values, visible non-binary fields) and queue it.
|
||||
***************************************************************************/
|
||||
static String submit(QTableMetaData table, QRecord selected, QRecord printer, int copies, String request) throws QException
|
||||
{
|
||||
Serializable primaryKey = selected.getValue(table.getPrimaryKeyField());
|
||||
GetInput get = new GetInput();
|
||||
get.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
|
||||
get.setTableName(table.getName());
|
||||
get.setPrimaryKey(primaryKey);
|
||||
get.setShouldGenerateDisplayValues(true);
|
||||
get.setShouldTranslatePossibleValues(true);
|
||||
QRecord record = new GetAction().execute(get).getRecord();
|
||||
if(record == null)
|
||||
{
|
||||
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 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);
|
||||
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("contentBase64", Base64.getEncoder().encodeToString(bytes))
|
||||
.put("idempotencyKey", "record-print:" + request + ":" + table.getName() + ":" + primaryKey + ":" + printer.getValueString("id"))
|
||||
.put("options", new JSONObject().put("copies", copies).put("scale", "FIT")));
|
||||
return (job.getString("jobId"));
|
||||
}
|
||||
catch(java.sql.SQLException | PrintingException exception)
|
||||
{
|
||||
throw new QException("Could not queue the print job: " + exception.getMessage(), exception);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** One request identity per process run, so a replayed load does not print twice.
|
||||
***************************************************************************/
|
||||
public static class Prepare implements BackendStep
|
||||
{
|
||||
/***************************************************************************
|
||||
** Require printing access before any form is shown.
|
||||
***************************************************************************/
|
||||
@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);
|
||||
PrintingProcesses.keepSelection(input, output);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Review: the printer is still usable; the selected records pass through.
|
||||
***************************************************************************/
|
||||
public static class Transform extends AbstractTransformStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine ready = new ProcessSummaryLine(Status.OK, 0, null)
|
||||
.withSingularFutureMessage("record will be printed").withPluralFutureMessage("records will be printed");
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Check the printer and copies once per page; keep the records.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
|
||||
printer(input);
|
||||
copies(input);
|
||||
QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE));
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
ready.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Standard review summary.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
return (new ArrayList<>(List.of(ready)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Execute: render and queue each record; report the job IDs.
|
||||
***************************************************************************/
|
||||
public static class Load extends AbstractLoadStep implements ProcessSummaryProviderInterface
|
||||
{
|
||||
private final ProcessSummaryLine done = new ProcessSummaryLine(Status.OK, 0, null)
|
||||
.withSingularPastMessage("record was sent to the printer").withPluralPastMessage("records were sent to the printer");
|
||||
private final List<String> jobIds = new ArrayList<>();
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Resolve the printer again (access may have changed since review).
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public void runOnePage(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
new PrintingAdminPermission().checkPermissionsThrowing(input, null);
|
||||
QRecord printer = printer(input);
|
||||
int copies = copies(input);
|
||||
String request = input.getValueString(REQUEST_ID);
|
||||
if(request == null || request.isBlank())
|
||||
{
|
||||
throw new QException("Restart Print to obtain a safe request identity");
|
||||
}
|
||||
QTableMetaData table = QContext.getQInstance().getTable(input.getValueString(StreamedETLWithFrontendProcess.FIELD_SOURCE_TABLE));
|
||||
for(QRecord record : input.getRecords())
|
||||
{
|
||||
jobIds.add(submit(table, record, printer, copies, request));
|
||||
done.incrementCountAndAddPrimaryKey(record.getValue(table.getPrimaryKeyField()));
|
||||
output.getRecords().add(record);
|
||||
}
|
||||
output.addValue(JOB_IDS, String.join(",", jobIds));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Result summary: what was sent, and the job IDs to follow.
|
||||
***************************************************************************/
|
||||
@Override
|
||||
public ArrayList<ProcessSummaryLineInterface> getProcessSummary(RunBackendStepOutput output, boolean isForResultScreen)
|
||||
{
|
||||
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>(List.of(done));
|
||||
if(!jobIds.isEmpty())
|
||||
{
|
||||
lines.add(new ProcessSummaryLine(Status.INFO, null, "Print job" + (jobIds.size() == 1 ? " " : "s ") + String.join(", ", jobIds) + " queued; follow them in Print jobs."));
|
||||
}
|
||||
return (lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.QProcessCallback;
|
||||
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.tables.insert.InsertInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
|
||||
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.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
|
||||
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.json.JSONObject;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Record printing: the per-table Print process and the record sheet document.
|
||||
*******************************************************************************/
|
||||
class PrintingRecordProcessesTest
|
||||
{
|
||||
/***************************************************************************
|
||||
** Clear the shared memory store and context between tests.
|
||||
***************************************************************************/
|
||||
@AfterEach
|
||||
void clear()
|
||||
{
|
||||
MemoryRecordStore.getInstance().reset();
|
||||
QContext.clear();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Printer form, review, then one queued PDF job per selected record.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void printsEachSelectedRecordOnceAndReturnsTheJobIds() throws Exception
|
||||
{
|
||||
PrintingProcessesTest.Fixture fixture = fixtureWithPeople();
|
||||
String process = PrintingRecordProcesses.processName("person");
|
||||
assertEquals("printingPrintRecords_person", process);
|
||||
QFrontendStepMetaData printerStep = (QFrontendStepMetaData) fixture.instance().getProcess(process).getStep("printer");
|
||||
assertEquals(PrintingTables.PRINTER, printerStep.getFormField(PrintingRecordProcesses.PRINTER_FIELD).getPossibleValueSourceName());
|
||||
|
||||
var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1,2"));
|
||||
assertFalse(form.getException().isPresent(), () -> form.getException().toString());
|
||||
assertEquals("printer", form.getProcessState().getNextStepName().orElseThrow());
|
||||
var review = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID())
|
||||
.withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId()).withValue(PrintingRecordProcesses.COPIES_FIELD, 2));
|
||||
assertFalse(review.getException().isPresent(), () -> review.getException().toString());
|
||||
assertEquals(0, fixture.service().jobs().length(), "review must not print");
|
||||
var done = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(review.getProcessUUID())
|
||||
.withStartAfterStep(review.getProcessState().getNextStepName().orElseThrow()).withFrontendStepBehavior(RunProcessInput.FrontendStepBehavior.SKIP));
|
||||
assertFalse(done.getException().isPresent(), () -> done.getException().toString());
|
||||
|
||||
JSONArray jobs = fixture.service().jobs();
|
||||
assertEquals(2, jobs.length());
|
||||
String ids = String.valueOf(done.getValues().get(PrintingRecordProcesses.JOB_IDS));
|
||||
assertEquals(2, ids.split(",").length);
|
||||
for(int i = 0; i < jobs.length(); i++)
|
||||
{
|
||||
JSONObject job = jobs.getJSONObject(i);
|
||||
assertTrue(ids.contains(job.getString("jobId")));
|
||||
assertEquals("PDF", job.getString("format"));
|
||||
assertTrue(job.getString("title").startsWith("Person: "));
|
||||
assertEquals(2, job.getJSONObject("options").getInt("copies"));
|
||||
fixture.service().claimForJob(fixture.device(), job.getString("jobId"));
|
||||
byte[] content = fixture.service().content(fixture.device(), job.getString("jobId"));
|
||||
try(PDDocument document = PDDocument.load(content))
|
||||
{
|
||||
String text = new PDFTextStripper().getText(document);
|
||||
assertTrue(text.contains("Person:"));
|
||||
assertTrue(text.contains("First name"));
|
||||
assertTrue(text.contains("Ada") || text.contains("Grace"));
|
||||
assertFalse(text.contains("Secret"), "hidden fields are not printed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The UI API passes the selection only to init (as the callback filter);
|
||||
** the selection must survive the printer form that comes before review.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void keepsTheInitSelectionAcrossThePrinterForm() throws Exception
|
||||
{
|
||||
PrintingProcessesTest.Fixture fixture = fixtureWithPeople();
|
||||
String process = PrintingRecordProcesses.processName("person");
|
||||
var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withCallback(new QProcessCallback()
|
||||
{
|
||||
@Override
|
||||
public QQueryFilter getQueryFilter()
|
||||
{
|
||||
return (new QQueryFilter(new QFilterCriteria("id", QCriteriaOperator.IN, List.of(2))));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@Override
|
||||
public Map<String, Serializable> getFieldValues(List<QFieldMetaData> fields)
|
||||
{
|
||||
return (Map.of());
|
||||
}
|
||||
}));
|
||||
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));
|
||||
JSONArray jobs = fixture.service().jobs();
|
||||
assertEquals(1, jobs.length());
|
||||
assertTrue(jobs.getJSONObject(0).getString("title").contains("Grace"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** A printer that is not approved (or not exposed) is refused at review.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void refusesAPrinterThatIsNotApproved() throws Exception
|
||||
{
|
||||
PrintingProcessesTest.Fixture fixture = fixtureWithPeople();
|
||||
String process = PrintingRecordProcesses.processName("person");
|
||||
var device = fixture.service();
|
||||
device.enablePrinter(fixture.device(), "laser", false);
|
||||
var form = new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withValue("recordIds", "1"));
|
||||
QException refused = assertThrows(QException.class, () -> new RunProcessAction().execute(new RunProcessInput().withProcessName(process).withProcessUUID(form.getProcessUUID())
|
||||
.withStartAfterStep("printer").withValue(PrintingRecordProcesses.PRINTER_FIELD, fixture.printerId())));
|
||||
assertTrue(refused.getMessage().contains("approved"), refused::getMessage);
|
||||
assertEquals(0, fixture.service().jobs().length());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Unknown tables are refused at registration.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void registerRefusesAnUnknownTable() throws Exception
|
||||
{
|
||||
PrintingProcessesTest.Fixture fixture = PrintingProcessesTest.fixture();
|
||||
assertThrows(QException.class, () -> PrintingRecordProcesses.register(fixture.instance(), "nope"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Long values wrap, long documents continue on more pages, unencodable
|
||||
** characters are replaced rather than failing the render.
|
||||
***************************************************************************/
|
||||
@Test
|
||||
void recordSheetWrapsAndContinuesAcrossPages() throws Exception
|
||||
{
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
fields.put("Notes", "word ".repeat(400));
|
||||
for(int i = 0; i < 80; i++)
|
||||
{
|
||||
fields.put("Field " + i, "value " + i + " 中文");
|
||||
}
|
||||
fields.put("Empty", null);
|
||||
byte[] bytes = PrintingRecordDocuments.pdf("Person: Ada Lovelace", "Person 1", fields);
|
||||
try(PDDocument document = PDDocument.load(bytes))
|
||||
{
|
||||
assertTrue(document.getNumberOfPages() > 1);
|
||||
String text = new PDFTextStripper().getText(document);
|
||||
assertTrue(text.contains("Field 79"));
|
||||
assertTrue(text.contains("continued, page 2"));
|
||||
}
|
||||
assertEquals(List.of("abc", "def"), PrintingRecordDocuments.wrap("abc def", 4));
|
||||
assertEquals(List.of("abcd", "ef"), PrintingRecordDocuments.wrap("abcdef", 4));
|
||||
assertEquals("ab~", PrintingRecordDocuments.truncate("abcdef", 3));
|
||||
}
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** The process fixture plus a memory "person" table with two records.
|
||||
***************************************************************************/
|
||||
private static PrintingProcessesTest.Fixture fixtureWithPeople() 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("secret", QFieldType.STRING).withLabel("Secret").withIsHidden(true)));
|
||||
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("secret", "x"),
|
||||
new QRecord().withValue("id", 2).withValue("firstName", "Grace").withValue("lastName", "Hopper"))));
|
||||
return (fixture);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user