Files
qbit-printing/src/main/java/com/kingsrook/qbits/printing/PrintingLayout.java
T
james.maesandClaude Sonnet 5.5 e4639e3cca chore: integrate printing standards and strict Java compiler checks
Preserve current rendering, operator and retained-source behavior while integrating script quality, meaningful headers and strict Error Prone/NullAway contracts. Production and test compilation and the 189-case verification gate pass with zero failures and one intentional workload skip.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: James Maes <james@kof22.com>
2026-11-01 16:51:18 -06:00

520 lines
18 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 com.uber.nullaway.annotations.EnsuresNonNull;
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;
import org.jspecify.annotations.Nullable;
/*******************************************************************************
** 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 @Nullable 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, @Nullable 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(@Nullable 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
{
activeContent().moveTo(MARGIN, y);
activeContent().lineTo(size.getWidth() - MARGIN, y);
activeContent().stroke();
y -= 4;
}
/***************************************************************************
** Footers on every page, then the PDF bytes.
***************************************************************************/
byte[] finish() throws QException
{
try
{
activeContent().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.
***************************************************************************/
@EnsuresNonNull("content")
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);
PDPageContentStream opened = new PDPageContentStream(document, page);
content = opened;
y = size.getHeight() - MARGIN + 14;
header(opened);
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(PDPageContentStream page) 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(page, MARGIN, y, PrintingRecordDocuments.truncate(chrome.app(), Math.max(10, columns(9) - rightText.length())), 9);
at(page, size.getWidth() - MARGIN - rightText.length() * 4.5f, y, rightText, 7.5f);
if(chrome.title() != null && !chrome.title().isBlank())
{
y -= 10;
at(page, MARGIN, y, PrintingRecordDocuments.truncate(chrome.title(), columns(7.5f)), 7.5f);
}
y -= 4;
page.setLineWidth(0.5f);
page.moveTo(MARGIN, y);
page.lineTo(size.getWidth() - MARGIN, y);
page.stroke();
page.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;
activeContent().beginText();
activeContent().setFont(font, fontSize);
activeContent().newLineAtOffset(MARGIN, y);
activeContent().showText(safe(PrintingRecordDocuments.truncate(text, columns(fontSize))));
activeContent().endText();
}
/***************************************************************************
** Construction/newPage opens content; drawing and finish precede its null
** transition. Preserve the existing failure on use after finish, without
** imposing a non-null precondition on pure footer formatting operations.
***************************************************************************/
@SuppressWarnings("NullAway")
private PDPageContentStream activeContent()
{
return content;
}
/***************************************************************************
** 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();
}
}