diff --git a/benchmarks/src/main/java/com/demcha/compose/AutoSizeMeasureProbe.java b/benchmarks/src/main/java/com/demcha/compose/AutoSizeMeasureProbe.java new file mode 100644 index 000000000..f94782f77 --- /dev/null +++ b/benchmarks/src/main/java/com/demcha/compose/AutoSizeMeasureProbe.java @@ -0,0 +1,70 @@ +package com.demcha.compose; + +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources; +import com.demcha.compose.document.layout.DocumentGraph; +import com.demcha.compose.document.layout.DocumentLayoutPassContext; +import com.demcha.compose.document.layout.LayoutCanvas; +import com.demcha.compose.document.layout.LayoutCompiler; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.NodeRegistry; +import com.demcha.compose.document.node.DocumentNode; + +import java.util.List; + +/** + * Finding 12 probe — counts the text-width measurements the layout makes to + * resolve auto-size paragraphs. + * + *

Auto-size shrinks the font over a size grid until the line fits. This probe + * compiles a page of auto-size headings through a {@link CountingTextMeasurementSystem} + * and reports the {@code textWidth} request count, so a develop (linear scan) vs + * branch (binary search) A/B shows the measurement reduction directly. No + * {@code src/main} changes.

+ */ +public final class AutoSizeMeasureProbe { + + public static void main(String[] args) throws Exception { + BenchmarkSupport.configureQuietLogging(); + + int autoSizeParagraphs = 8; + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.of(220, 1200)) + .margin(10, 10, 10, 10) + .create()) { + session.pageFlow(flow -> { + for (int i = 0; i < autoSizeParagraphs; i++) { + // 36pt heading that must shrink to ~16pt to fit the ~200pt inner + // width: the linear scan steps 36 -> 16 by 0.5 (~40 measures), + // the binary search resolves the same size in ~log2(n). + flow.addParagraph(p -> p + .text("Auto-size headline that should fit on a single line") + .autoSize(36, 6)); + } + }); + + List roots = session.roots(); + LayoutCanvas canvas = session.canvas(); + NodeRegistry registry = session.registry(); + + try (PdfMeasurementResources resources = PdfMeasurementResources.open(List.of())) { + CountingTextMeasurementSystem counter = + new CountingTextMeasurementSystem(resources.textMeasurementSystem()); + DocumentLayoutPassContext context = new DocumentLayoutPassContext( + registry, canvas, resources.fontLibrary(), counter, false); + LayoutCompiler compiler = new LayoutCompiler(registry); + LayoutGraph layout = compiler.compile(new DocumentGraph(roots), context, context); + + CountingTextMeasurementSystem.Counts c = counter.snapshot(); + System.out.println("GraphCompose Finding-12 Auto-Size Measurement Probe"); + System.out.printf("auto-size paragraphs: %d, pages: %d%n", + autoSizeParagraphs, layout.totalPages()); + System.out.printf("textWidth requests during compile (auto-size grid search + wrap): %d%n", + c.widthRequests()); + System.out.printf("per auto-size paragraph: %.1f%n", + c.widthRequests() / (double) autoSizeParagraphs); + } + } + } +} diff --git a/benchmarks/src/main/java/com/demcha/compose/RenderOperatorProbe.java b/benchmarks/src/main/java/com/demcha/compose/RenderOperatorProbe.java new file mode 100644 index 000000000..94cafb25e --- /dev/null +++ b/benchmarks/src/main/java/com/demcha/compose/RenderOperatorProbe.java @@ -0,0 +1,102 @@ +package com.demcha.compose; + +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.style.DocumentColor; +import com.demcha.compose.document.style.DocumentTextDecoration; +import com.demcha.compose.document.style.DocumentTextStyle; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.contentstream.operator.Operator; +import org.apache.pdfbox.pdfparser.PDFStreamParser; +import org.apache.pdfbox.pdmodel.PDDocument; + +import java.awt.Color; +import java.io.IOException; +import java.util.List; +import java.util.function.Consumer; + +/** + * Finding 5 probe — counts content-stream operators in rendered documents. + * + *

Before F5 the paragraph render handler wrote one {@code setFont} (Tf) and one + * {@code setNonStrokingColor} (rg/g) per text span, i.e. exactly one of each per + * text-show ({@code Tj}/{@code TJ}). So the pre-F5 Tf and colour counts equal the + * text-draw count — that is the deterministic baseline this probe prints alongside + * the post-F5 counts; no A/B build needed. Tables render through a different + * handler that F5 does not touch, included to show the honest boundary.

+ */ +public final class RenderOperatorProbe { + + private static final DocumentTextStyle BODY = DocumentTextStyle.builder() + .size(10).decoration(DocumentTextDecoration.DEFAULT) + .color(DocumentColor.of(new Color(40, 50, 60))).build(); + private static final DocumentTextStyle HEAD = DocumentTextStyle.builder() + .size(16).decoration(DocumentTextDecoration.BOLD) + .color(DocumentColor.of(new Color(18, 40, 74))).build(); + + private static final String SENTENCE = + "GraphCompose lays out structured business documents across many pages " + + "while keeping header and footer placement stable. "; + + public static void main(String[] args) throws Exception { + BenchmarkSupport.configureQuietLogging(); + + System.out.println("GraphCompose Finding-5 Render-Operator Probe"); + System.out.printf("%-22s | %8s | %8s | %8s | %12s | %9s%n", + "Scenario", "Draws", "Tf(now)", "rg(now)", "Tf+rg saved", "Reduction"); + System.out.println("-".repeat(80)); + + report("long-paragraph", flow -> flow.addParagraph(p -> p.text(SENTENCE.repeat(40)).textStyle(BODY))); + report("20-paragraph body", flow -> { + for (int i = 0; i < 20; i++) { + flow.addParagraph(p -> p.text(SENTENCE.repeat(3)).textStyle(BODY)); + } + }); + report("headed sections", flow -> { + for (int i = 0; i < 8; i++) { + flow.addParagraph(p -> p.text("Section heading").textStyle(HEAD)); + flow.addParagraph(p -> p.text(SENTENCE.repeat(4)).textStyle(BODY)); + } + }); + report("50-row table (F5 N/A)", flow -> flow.addTable(t -> { + t.autoColumns(4).header("Item", "Qty", "Unit", "Total"); + for (int r = 1; r <= 50; r++) { + t.row("Line item " + r, "3", "ea", "38.75"); + } + })); + + System.out.println(); + System.out.println("Pre-F5 Tf == rg == Draws (one font + one colour op per span). 'saved' = (Draws-Tf)+(Draws-rg)."); + } + + private static void report(String scenario, Consumer author) throws Exception { + byte[] pdf; + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4).margin(28, 28, 28, 28).create()) { + session.pageFlow(author); + pdf = session.toPdfBytes(); + } + try (PDDocument document = Loader.loadPDF(pdf)) { + int draws = count(document, "Tj") + count(document, "TJ"); + int tf = count(document, "Tf"); + int rg = count(document, "rg") + count(document, "g") + count(document, "sc") + count(document, "scn"); + int saved = Math.max(0, draws - tf) + Math.max(0, draws - rg); + double reduction = draws == 0 ? 0 : 100.0 * (2.0 * draws - tf - rg) / (2.0 * draws); + System.out.printf("%-22s | %8d | %8d | %8d | %12d | %8.1f%%%n", + scenario, draws, tf, rg, saved, reduction); + } + } + + private static int count(PDDocument document, String op) throws IOException { + int n = 0; + for (var page : document.getPages()) { + List tokens = new PDFStreamParser(page).parse(); + for (Object token : tokens) { + if (token instanceof Operator operator && op.equals(operator.getName())) { + n++; + } + } + } + return n; + } +} diff --git a/benchmarks/src/main/java/com/demcha/compose/TablePaginationAllocProbe.java b/benchmarks/src/main/java/com/demcha/compose/TablePaginationAllocProbe.java new file mode 100644 index 000000000..9c039a6d5 --- /dev/null +++ b/benchmarks/src/main/java/com/demcha/compose/TablePaginationAllocProbe.java @@ -0,0 +1,120 @@ +package com.demcha.compose; + +import com.demcha.compose.document.api.DocumentPageSize; +import com.demcha.compose.document.api.DocumentSession; +import com.demcha.compose.document.backend.fixed.pdf.PdfMeasurementResources; +import com.demcha.compose.document.layout.DocumentGraph; +import com.demcha.compose.document.layout.DocumentLayoutPassContext; +import com.demcha.compose.document.layout.LayoutCanvas; +import com.demcha.compose.document.layout.LayoutCompiler; +import com.demcha.compose.document.layout.LayoutGraph; +import com.demcha.compose.document.layout.NodeRegistry; +import com.demcha.compose.document.node.DocumentNode; + +import java.lang.management.ManagementFactory; +import java.util.Arrays; +import java.util.List; + +/** + * Finding 10 probe — warm per-compile allocation of paginating one large table + * across many pages. + * + *

A table that spans N pages is split page-by-page: each split re-slices the + * shrinking tail via {@code TableLayoutSupport.sliceTablePreparedNode}. On the + * body-only path that slice currently re-copies the already-immutable row / + * row-height sub-lists ({@code List.copyOf} of a {@code subList}), so the + * cumulative copy work is O(rows x pages). This probe compiles a big multi-page + * table through the canonical {@link LayoutCompiler} and reports the warm + * (JIT-steady) bytes allocated by the compile pass, so a develop (copy) vs + * branch (sub-list view) A/B shows the slice-allocation reduction directly. No + * {@code src/main} changes.

+ */ +public final class TablePaginationAllocProbe { + + private static final com.sun.management.ThreadMXBean THREAD_MX = + (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean(); + + private static final int ROWS = 2500; + private static final int COLUMNS = 4; + private static final int WARMUP = 60; + private static final int MEASURE = 11; + + public static void main(String[] args) throws Exception { + BenchmarkSupport.configureQuietLogging(); + enableAllocationMeasurement(); + + try (DocumentSession session = GraphCompose.document() + .pageSize(DocumentPageSize.A4) + .margin(24, 24, 24, 24) + .create()) { + session.pageFlow(flow -> flow.addTable(table -> { + table.autoColumns(COLUMNS).header("Item", "Qty", "Price", "Total"); + for (int row = 1; row <= ROWS; row++) { + table.row("Line item " + row, "3", "12.50", "38.75"); + } + })); + + List roots = session.roots(); + LayoutCanvas canvas = session.canvas(); + NodeRegistry registry = session.registry(); + + try (PdfMeasurementResources resources = PdfMeasurementResources.open(List.of())) { + LayoutCompiler compiler = new LayoutCompiler(registry); + DocumentGraph graph = new DocumentGraph(roots); + + int pages = 0; + // Warm up the compile path so the measured allocation reflects + // JIT steady state, not class-load / first-call cold start. + for (int i = 0; i < WARMUP; i++) { + pages = compile(compiler, graph, registry, canvas, resources).totalPages(); + } + + long[] alloc = new long[MEASURE]; + for (int m = 0; m < MEASURE; m++) { + long before = currentThreadAllocatedBytes(); + LayoutGraph layout = compile(compiler, graph, registry, canvas, resources); + alloc[m] = before < 0 ? -1 : currentThreadAllocatedBytes() - before; + pages = layout.totalPages(); + } + Arrays.sort(alloc); + + System.out.println("GraphCompose Finding-10 Table-Pagination Allocation Probe"); + System.out.printf("table: %d rows x %d cols, pages: %d (≈%d page splits)%n", + ROWS, COLUMNS, pages, Math.max(0, pages - 1)); + System.out.printf("warm compile allocation (median of %d): %.1f KB%n", + MEASURE, alloc[MEASURE / 2] / 1024.0); + System.out.printf(" min %.1f KB / max %.1f KB%n", + alloc[0] / 1024.0, alloc[MEASURE - 1] / 1024.0); + } + } + } + + private static LayoutGraph compile(LayoutCompiler compiler, DocumentGraph graph, + NodeRegistry registry, LayoutCanvas canvas, + PdfMeasurementResources resources) { + DocumentLayoutPassContext context = new DocumentLayoutPassContext( + registry, canvas, resources.fontLibrary(), resources.textMeasurementSystem(), false); + return compiler.compile(graph, context, context); + } + + private static void enableAllocationMeasurement() { + try { + if (THREAD_MX.isThreadAllocatedMemorySupported() && !THREAD_MX.isThreadAllocatedMemoryEnabled()) { + THREAD_MX.setThreadAllocatedMemoryEnabled(true); + } + } catch (UnsupportedOperationException ignored) { + // Allocation measurement unsupported on this JVM; the probe reports n/a. + } + } + + private static long currentThreadAllocatedBytes() { + try { + if (!THREAD_MX.isThreadAllocatedMemorySupported() || !THREAD_MX.isThreadAllocatedMemoryEnabled()) { + return -1; + } + } catch (UnsupportedOperationException ex) { + return -1; + } + return THREAD_MX.getCurrentThreadAllocatedBytes(); + } +}