Files
qbit-printing/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java
T
james.maesandClaude Fable 5.1 a514a70953 feat(printing): render to PDF for the caller; app header and footer on every page (#31)
printingRenderToPdf renders a record sheet, table list, text page or content sections and delivers the PDF through core's standard process download with the caller's permissions, no printer and no queued job. Every rendered document carries an app header (app name, breadcrumb, server, user, time) and footer (client and server, filter/sort line, Page n of m) from values the client sends, with server defaults. Extension 2.2 (additive).

Signed-off-by: James Maes <jmaes@greatergoods.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-01 13:32:37 -06:00

503 lines
17 KiB
Java

/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.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 PrintingChrome chrome;
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
{
this(pageSize, landscape, title, subtitle, null);
}
/***************************************************************************
** With the app's header and footer on every page (defaults when null).
***************************************************************************/
PrintingLayout(String pageSize, boolean landscape, String title, String subtitle, PrintingChrome chrome) throws QException
{
this.chrome = chrome == null ? PrintingChrome.defaults(title) : chrome;
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;
}
/***************************************************************************
** A note above the footer of every page (a grid's filter and sort).
***************************************************************************/
void footer(String text)
{
chrome = chrome.withFooterNote(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 < bottom())
{
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 * leading < bottom())
{
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;
float base = MARGIN - 14;
footer.setLineWidth(0.5f);
footer.moveTo(MARGIN, base + (hasNote() ? 20 : 10));
footer.lineTo(size.getWidth() - MARGIN, base + (hasNote() ? 20 : 10));
footer.stroke();
if(hasNote())
{
at(footer, MARGIN, base + 10, PrintingRecordDocuments.truncate(chrome.footerNote(), columns(7.5f)), 7.5f);
}
at(footer, size.getWidth() - MARGIN - text.length() * 4.8f, base, text, 8);
at(footer, MARGIN, base, PrintingRecordDocuments.truncate(chrome.footerLeft(), Math.max(10, columns(8) - text.length() - 2)), 8);
}
}
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 + 14;
header();
if(document.getNumberOfPages() > 1)
{
draw(PrintingRecordDocuments.truncate(title, columns(8)) + " (continued)", 8, 12);
y -= 4;
}
}
/***************************************************************************
** The app's header band: the app and server/user/time, then the title or
** breadcrumb, then a rule.
***************************************************************************/
private void header() throws IOException
{
String right = chrome.headerRight();
int rightChars = Math.min(right.length(), columns(7.5f) / 2 + 10);
String rightText = PrintingRecordDocuments.truncate(right, rightChars);
y -= 9;
at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9);
at(content, size.getWidth() - MARGIN - rightText.length() * 4.5f, y, rightText, 7.5f);
if(chrome.title() != null && !chrome.title().isBlank())
{
y -= 10;
at(content, MARGIN, y, PrintingRecordDocuments.truncate(chrome.title(), columns(7.5f)), 7.5f);
}
y -= 4;
content.setLineWidth(0.5f);
content.moveTo(MARGIN, y);
content.lineTo(size.getWidth() - MARGIN, y);
content.stroke();
content.setLineWidth(1f);
y -= 6;
}
/***************************************************************************
** Whether pages carry a footer note.
***************************************************************************/
private boolean hasNote()
{
return (chrome.footerNote() != null && !chrome.footerNote().isBlank());
}
/***************************************************************************
** The lowest baseline body text may use, above the footer band.
***************************************************************************/
private float bottom()
{
return (MARGIN + (hasNote() ? 22 : 12));
}
/***************************************************************************
** Text at a position on a content stream.
***************************************************************************/
private void at(PDPageContentStream stream, float x, float baseline, String text, float fontSize) throws IOException
{
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(x, baseline);
stream.showText(safe(text));
stream.endText();
}
/***************************************************************************
** 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());
}
}