diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 31ce987b2..c2cf8a7d2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -208,6 +208,12 @@ jobs:
- name: Compile benchmarks module
run: ./mvnw -B -ntp -f benchmarks/pom.xml clean compile
+ - name: Run deterministic benchmark gates
+ # Fast, machine-independent unit/gate tests (image-cache reuse,
+ # render-operator coalescing, scenario/threshold coverage, diff tooling).
+ # Catches structural regressions the timing smoke run cannot.
+ run: ./mvnw -B -ntp -f benchmarks/pom.xml test
+
- name: Run coarse performance smoke benchmark
run: |
./mvnw -B -ntp -f benchmarks/pom.xml -DskipTests \
@@ -223,6 +229,14 @@ jobs:
path: benchmarks/target/benchmarks/current-speed/**
if-no-files-found: ignore
+ - name: Upload benchmark gate reports
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: benchmark-gate-reports-${{ github.run_id }}
+ path: benchmarks/target/surefire-reports/**
+ if-no-files-found: ignore
+
benchmark-diff:
name: Weekly Benchmark Diff
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
diff --git a/.gitignore b/.gitignore
index 9951201a3..c9f7fc1ef 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,6 +37,7 @@ build/
### Mac OS ###
.DS_Store
/logs/
+benchmarks/logs/
/CV_Generated.pdf
*.pdf
# Allow PDF previews that are committed README assets.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 19c44ff5f..8323af3e9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -337,6 +337,55 @@ Entries land here as they merge.
### Internal
+- **Benchmark suite cleanup (not shipped).** Removed three redundant
+ benchmark mains: `FullCvBenchmark` (superseded by the JMH
+ `TemplateCvJmhBenchmark`), `GraphComposeBenchmark` (early-engine relic
+ duplicating `CurrentSpeedBenchmark`'s `engine-simple` scenario), and
+ `ScalabilityBenchmark` (its thread-scaling sweep folded into
+ `CurrentSpeedBenchmark`'s full-profile throughput run, now `1,2,4,8,16`).
+ Dropped the matching `run-benchmarks.ps1` steps and doc entries.
+- **Feature-object benchmarks for the v1.8 vector surface (not shipped).**
+ The suite previously exercised only text/table primitives. Added JMH render
+ benches and deterministic probes over the new vector features:
+ `SvgJmhBenchmark` (path parse / whole-file icon read / icon→node) plus a
+ `SvgParseAllocProbe`; `ChartJmhBenchmark` (bar + line + pie render) plus a
+ `ChartAllocProbe` (layout-compile allocation); `VectorRenderOperatorProbe`
+ (the same paths drawn flat vs. gradient vs. translucent, counted as PDF
+ content-stream operators); `IconRampJmhBenchmark` (icon-placement scaling,
+ `@Param` 8/32/128); and `MixedShowcaseJmhBenchmark` (one document combining
+ prose, inline sparklines, bar + pie charts, SVG icons and a gradient path).
+ Shared `SvgBenchmarkFixtures` / `ChartBenchmarkFixtures` hold the inputs so
+ each bench and its probe measure identical data.
+- **Current-speed report carries a stage breakdown and a run summary (not
+ shipped).** `CurrentSpeedBenchmark` persists a per-scenario compose / layout /
+ render split (`stages[]`, median ms) to the JSON and a `stages` CSV, and
+ writes a readable `summary.md`. `BenchmarkDiffTool` consumes `stages[]`,
+ prints a per-stage delta table, and reports the scenarios added/removed
+ between two runs.
+- **Every current-speed scenario is now covered by the smoke perf gate (not
+ shipped).** The `long-token` scenario previously had no SMOKE threshold and
+ silently escaped the gate; it now has one, and `CurrentSpeedScenarioGateTest`
+ fails the build if any scenario lacks a threshold.
+- **Benchmark coverage for the render hot paths (not shipped).** Added an image
+ embed/scale gate (`ImageCacheOperatorProbe` + `ImageBenchmarkFixtures` +
+ `ImageJmhBenchmark`, with `ImageCacheGateTest` pinning `PdfImageCache` reuse), a
+ single-shot cold-start render bench (`ColdStartJmhBenchmark`), a report-scaling
+ sweep in `ComparativeBenchmark` (equivalent content across GraphCompose /
+ iText 9 / JasperReports at 40 / 200 / 1000 table rows — iText upgraded from the
+ EOL 5.5.x to current 9.x — printing a per-size GraphCompose-advantage ratio plus
+ a post-run sample-PDF dump per library/size), a
+ production-scale `LargeTableJmhBenchmark`, an allocation-rate / GC-pressure probe
+ (`AllocationRateProbe`), and an accented-Latin measurement scenario.
+- **Deterministic benchmark gates run on every PR (not shipped).** The benchmarks
+ module's tests never ran in CI; the `perf-smoke` job now runs them, so the
+ image-cache, render-operator (F5 coalescing), vector-paint (flat / gradient /
+ alpha / stroked / dashed operator structure), and scenario-coverage gates fail a
+ PR on a structural regression. A `vector-rich` scenario (charts + SVG icons +
+ gradient) joins the gated current-speed harness; `BenchmarkMedianTool` carries the
+ stage breakdown into its aggregate; and the smoke gate's GC-noisy `peakHeapMb`
+ check is now advisory (fails only on average latency). Chart-layout variants
+ (horizontal / stacked / donut / value-axis-min), a sparkline ramp, and a
+ per-paint-mode vector render bench round out the JMH suite.
- **Removed the `java.awt.*` / `java.util.*` co-wildcard in four files.**
`InvoiceTemplateComposer`, `ProposalTemplateComposer`,
`WeeklyScheduleTemplateComposer`, and the engine `PdfRenderingSystemECS`
diff --git a/benchmarks/README.md b/benchmarks/README.md
index f6041365c..9322e1018 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -23,7 +23,7 @@
## When to use the harness
- **Smoke check before a release** — `CurrentSpeedBenchmark -Dgraphcompose.benchmark.profile=smoke`
- takes ~15 s, exercises the canonical render path through 5 fixture
+ takes ~15 s, exercises the canonical render path through 7 fixture
scenarios, and prints a single-page latency / throughput table.
CI runs this on every PR (the `perf-smoke` job); the goal is "did
this PR make a representative render visibly slower?" — *not* "is
@@ -51,25 +51,54 @@
layout-pass count) and reason about it; the harness is a sanity
check after you've already chosen, not a decision tool before.
- For **comparing GraphCompose to another PDF library** —
- `ComparativeBenchmark` does render the same fixture through iText /
- openHTMLToPDF / JasperReports for rough sizing, but the comparison
- is a manual smoke test: each library has different defaults
- (compression, font embedding, image resampling) and reading too much
- into a single number is the wrong call.
+ `ComparativeBenchmark` does render equivalent content through iText /
+ JasperReports for rough sizing (a tiny single-page invoice for fixed
+ overhead, plus a report-scaling sweep — title + prose + an N-row table
+ at N = 40 / 200 / 1000 — that shows how each engine scales and prints a
+ GraphCompose-advantage ratio per size), but the comparison is a manual smoke test:
+ each library has different defaults (compression, font embedding, image
+ resampling) and reading too much into a single number is the wrong call.
+ Note one boundary asymmetry: the JasperReports figure measures fill +
+ PDF export with the design compiled once outside the loop, while the
+ GraphCompose and iText figures include per-iteration document
+ construction — so the Jasper number excludes work the other two pay.
+ `openHTMLtoPDF` is intentionally absent: its current release (1.0.10)
+ targets PDFBox 2.x and fails at runtime against the PDFBox 3.x this
+ project uses (no PDFBox-3-compatible openhtmltopdf release exists yet),
+ so it cannot share GraphCompose's classpath.
+
+## What runs on a PR — and what is on-demand (by design)
+
+The per-PR CI gate is deliberately light and deterministic:
+
+- **`perf-smoke` job** — `CurrentSpeedBenchmark` in the `smoke` profile with
+ absolute latency / heap thresholds (a gross-regression tripwire), plus the
+ module's deterministic gate tests (`mvnw -f benchmarks/pom.xml test`:
+ image-cache reuse, render-operator coalescing, scenario/threshold coverage).
+
+These are intentionally **not** on the per-PR path:
+
+- **The JMH benches** (`*JmhBenchmark`) are full / on-demand only. A forked,
+ warmed JMH run of the whole suite takes minutes; running it per PR is too
+ expensive for the signal. Run them by hand (or on a schedule) before a release
+ and quote those numbers for rigorous claims.
+- **The relative `BenchmarkVerdictTool` gate** (±% vs a committed baseline) runs
+ locally only, and no static `smoke` baseline is committed: absolute timings are
+ machine-specific, so a baseline captured on one machine would false-positive on
+ another. Use a local same-machine A/B (a `-Repeat` median before/after) for
+ relative comparison; the absolute smoke thresholds are the CI safety net.
## Files in this module
| File | Role |
|---|---|
| `CurrentSpeedBenchmark` | Default scenario runner — what CI's `perf-smoke` job exercises. Takes a `-Dgraphcompose.benchmark.profile=smoke\|full\|stress` switch. |
-| `ComparativeBenchmark` | Renders the same fixtures through GraphCompose, iText, openHTMLToPDF, JasperReports. **Rough local comparison only** — see "When not to use" above. |
-| `FullCvBenchmark`, `ScalabilityBenchmark` | Fixture-specific runners for CV and table-heavy scenarios. |
+| `ComparativeBenchmark` | Renders equivalent content through GraphCompose, iText, JasperReports — a small-invoice tier plus a report-scaling sweep (40 / 200 / 1000 rows) with a per-size advantage ratio, and dumps a sample PDF per library/size. **Rough local comparison only** — see "When not to use" above. |
| `CanonicalBenchmarkSupport`, `BenchmarkSupport` | Shared fixture builders + measurement helpers. |
| `BenchmarkReportWriter` | Writes JSON / CSV / text reports under `benchmarks/target/benchmarks/`. |
| `BenchmarkDiffTool` | Compares two JSON reports and prints a delta table. Useful for pre/post comparisons. |
| `BenchmarkMedianTool` | Median + dispersion across N runs of the same scenario. |
| `GraphComposeStressTest`, `EnduranceTest` | Long-running stress / endurance harnesses. |
-| `GraphComposeBenchmark` | Legacy entry point preserved for one downstream caller. New work should target `CurrentSpeedBenchmark`. |
## Running
@@ -97,28 +126,46 @@ without reproducing locally.
## How to read a report
The JSON shape is intentionally simple — a top-level run record with
-per-scenario sub-records. Each sub-record carries:
-
-- `avgMs`, `p50Ms`, `p95Ms`, `maxMs` — latency distribution across
- iterations within the run.
-- `docsPerSec` — rough throughput; **not statistically rigorous**,
- intended only as a relative number against a sibling scenario or a
- previous run on the same machine.
-- `avgKB` — average output byte size. Stable across runs on the same
- fixture; useful for catching content corruption (size shifts by
- > a few hundred bytes are usually a bug, not a benchmark fluctuation).
-- `peakMB` — peak heap as observed by `MemoryMXBean`; coarse, do not
- use for memory-budget enforcement.
+per-scenario sub-records. The latency rows carry these fields (the JSON
+keys are camelCase; the CSV columns are the snake_case equivalents):
+
+- `avgMillis`, `p50Millis`, `p95Millis`, `maxMillis` — latency distribution
+ across iterations within the run.
+- `docsPerSecond` — a **derived** figure, `1000 / avgMillis`: the reciprocal of
+ average latency, **not** a measured throughput rate. Real parallel throughput
+ lives in the separate `throughput[]` section (full profile only). Treat it as
+ a relative number against a sibling scenario or a previous run on the same
+ machine, not a publishable rate.
+- `avgKilobytes` — average output byte size. Stable across runs on the same
+ fixture; useful for catching content corruption (size shifts by more than a
+ few hundred bytes are usually a bug, not a benchmark fluctuation).
+- `peakHeapMb` — used-heap **delta** over the post-warmup baseline (closer to
+ per-iteration allocation pressure than to absolute live heap). GC-timing
+ noisy, so **advisory only** — for a deterministic memory signal use the
+ allocation bytes from `MeasurementCountBenchmark` or the alloc probes.
+
+A `stages[]` array carries the per-template-scenario compose / layout / render
+median split (`composeMillis` / `layoutMillis` / `renderMillis` / `totalMillis`),
+present when the run has enough measurement iterations.
## Strict JMH layer
The Track C JMH layer (forked JVM, warmup + measurement, JIT-stable numbers)
lives alongside this manual harness. JMH benchmarks are annotated classes under
`com.demcha.compose.jmh`; the shade plugin builds a self-contained runner jar so
-forked benchmark JVMs inherit the full classpath. Present benchmarks:
-`CanonicalRender` (bare-DSL multi-section render), `TemplateCv` (the
-`ModernProfessional` layered template), and `PaginatedDocument` (a multi-page
-document parameterised by section count).
+forked benchmark JVMs inherit the full classpath. The suite spans steady-state
+render benches (`CanonicalRender`, `TemplateCv`, `Chart`, `ChartVariant`, `Image`,
+`MixedShowcase`), parameterised scaling ramps (`IconRamp`, `LargeTable`,
+`SparklineRamp`, `PaginatedDocument`, `VectorPaint`), the SVG-import micro-benches
+(`Svg`), and a single-shot cold-start bench (`ColdStart`).
+
+Every steady-state JMH bench uses `@Fork(1)` with a 3×2s warmup / 5×2s measurement
+window — a deliberately fast default for on-demand local iteration (a single fork,
+so the reported `Error` column is blank). For a number you intend to quote, pass
+more forks on the CLI (e.g. `-f 5`) for a cross-fork error estimate. The exception
+is `ColdStart`, which is single-shot (`Mode.SingleShotTime`, `@Warmup(0)`,
+`@Fork(10)`) — it deliberately measures the JIT-cold first render across ten fresh
+JVMs.
The measured region differs per benchmark: `TemplateCv` hoists fixture
construction into `@Setup` and times the render only, while `CanonicalRender` and
diff --git a/benchmarks/pom.xml b/benchmarks/pom.xml
index 25ac8f25d..b48aff3a8 100644
--- a/benchmarks/pom.xml
+++ b/benchmarks/pom.xml
@@ -30,7 +30,7 @@
1.5.341.0.10
- 5.5.13.3
+ 9.6.07.0.7
@@ -100,8 +100,9 @@
com.itextpdf
- itextpdf
- ${itextpdf.version}
+ itext-core
+ ${itext.version}
+ pomnet.sf.jasperreports
diff --git a/benchmarks/src/main/java/com/demcha/compose/AllocationRateProbe.java b/benchmarks/src/main/java/com/demcha/compose/AllocationRateProbe.java
new file mode 100644
index 000000000..e81c2af92
--- /dev/null
+++ b/benchmarks/src/main/java/com/demcha/compose/AllocationRateProbe.java
@@ -0,0 +1,161 @@
+package com.demcha.compose;
+
+import com.demcha.compose.document.api.DocumentPageSize;
+import com.demcha.compose.document.api.DocumentSession;
+import com.demcha.compose.document.templates.builtins.InvoiceTemplateV1;
+import com.demcha.compose.document.templates.builtins.ProposalTemplateV1;
+import com.demcha.compose.document.templates.data.invoice.InvoiceDocumentSpec;
+import com.demcha.compose.document.templates.data.proposal.ProposalDocumentSpec;
+
+import java.lang.management.GarbageCollectorMXBean;
+import java.lang.management.ManagementFactory;
+
+/**
+ * Allocation-rate and GC-pressure probe over realistic templates. The endurance
+ * and stress harnesses only check that sustained rendering stays stable / under a
+ * heap ceiling; nothing reports how much garbage a single render churns, which is
+ * what drives GC pressure for a high-throughput server.
+ *
+ *
For each template it renders many warm documents and reports two things: the
+ * warm per-document allocation (ThreadMXBean current-thread bytes / doc — a
+ * deterministic figure ideal for an A/B), and the JVM garbage collections those
+ * renders triggered (count + time via {@code GarbageCollectorMXBean} — JVM-wide
+ * and GC-timing sensitive, so advisory). No {@code src/main} changes.
The current-speed per-stage breakdown ({@code stages[]}) is medianed and
+ * carried into the aggregate when every source run has it (it is present only for
+ * runs with enough measurement iterations), so a median-vs-median
+ * {@link BenchmarkDiffTool} run still attributes a regression to
+ * compose / layout / render.
*/
public final class BenchmarkMedianTool {
@@ -75,6 +81,7 @@ private void aggregateCurrentSpeed(List reportFiles) throws Exceptio
List threadCounts = requireIntegerArrayConsistency(reportFiles, "threadCounts");
List latencyRows = aggregateCurrentSpeedLatency(reportFiles);
+ List stageRows = aggregateCurrentSpeedStages(reportFiles);
List throughputRows = aggregateCurrentSpeedThroughput(reportFiles);
long totalBytesMedian = Math.round(median(
@@ -90,6 +97,7 @@ private void aggregateCurrentSpeed(List reportFiles) throws Exceptio
docsPerThread,
threadCounts,
latencyRows,
+ stageRows,
throughputRows,
totalBytesMedian,
"median",
@@ -126,12 +134,28 @@ private void aggregateCurrentSpeed(List reportFiles) throws Exceptio
format(row.avgMillisPerDoc())))
.toList());
+ Path stagesCsv = null;
+ if (!stageRows.isEmpty()) {
+ stagesCsv = artifacts.writeCsv(
+ "stages",
+ List.of("scenario", "compose_ms", "layout_ms", "render_ms", "total_ms"),
+ stageRows.stream()
+ .map(row -> List.of(
+ row.scenario(),
+ format(row.composeMillis()),
+ format(row.layoutMillis()),
+ format(row.renderMillis()),
+ format(row.totalMillis())))
+ .toList());
+ }
+
System.out.println("Median benchmark report");
System.out.println("Suite: current-speed");
System.out.println("Profile: " + profile);
System.out.println("Source runs: " + reportFiles.size());
System.out.println("Saved JSON median report to " + jsonPath);
- System.out.println("Saved CSV median reports to " + latencyCsv + " and " + throughputCsv);
+ System.out.println("Saved CSV median reports to " + latencyCsv
+ + (stagesCsv != null ? ", " + stagesCsv : "") + " and " + throughputCsv);
}
private List aggregateCurrentSpeedLatency(List reportFiles) {
@@ -165,6 +189,42 @@ private List aggregateCurrentSpeedLatency(List aggregateCurrentSpeedStages(List reportFiles) {
+ // stages[] is optional: CurrentSpeedBenchmark only emits it when the run
+ // has enough measurement iterations (smoke < 20 emits none). Aggregate only
+ // when EVERY source report carries a non-empty stages[] with the same
+ // scenario set; otherwise return empty so the median report simply carries
+ // no stages — mirroring the benchmark's own conditional emission rather
+ // than throwing on an absent/partial optional field.
+ List firstRows = iterable(reportFiles.get(0).report().path("stages"));
+ if (firstRows.isEmpty()) {
+ return List.of();
+ }
+ Map firstByScenario = indexBy(firstRows, "scenario");
+ for (ReportFile reportFile : reportFiles) {
+ Map currentByScenario = indexBy(iterable(reportFile.report().path("stages")), "scenario");
+ if (!firstByScenario.keySet().equals(currentByScenario.keySet())) {
+ System.out.println("Note: stages omitted from the median aggregate — "
+ + "the stage-breakdown scenario set differs across the source runs.");
+ return List.of();
+ }
+ }
+
+ return firstByScenario.keySet().stream()
+ .map(scenario -> {
+ List rows = reportFiles.stream()
+ .map(reportFile -> indexBy(iterable(reportFile.report().path("stages")), "scenario").get(scenario))
+ .toList();
+ return new CurrentSpeedStageMedianRow(
+ scenario,
+ median(rows, "composeMillis"),
+ median(rows, "layoutMillis"),
+ median(rows, "renderMillis"),
+ median(rows, "totalMillis"));
+ })
+ .toList();
+ }
+
private List aggregateCurrentSpeedThroughput(List reportFiles) {
List firstRows = iterable(reportFiles.get(0).report().path("throughput"));
Map firstByScenario = indexThroughput(firstRows);
@@ -393,6 +453,13 @@ private record CurrentSpeedThroughputMedianRow(String scenario,
double avgMillisPerDoc) {
}
+ private record CurrentSpeedStageMedianRow(String scenario,
+ double composeMillis,
+ double layoutMillis,
+ double renderMillis,
+ double totalMillis) {
+ }
+
private record CurrentSpeedMedianReport(String timestamp,
String profile,
int warmupIterations,
@@ -400,6 +467,7 @@ private record CurrentSpeedMedianReport(String timestamp,
int docsPerThread,
List threadCounts,
List latency,
+ List stages,
List throughput,
long totalBytes,
String aggregation,
diff --git a/benchmarks/src/main/java/com/demcha/compose/BenchmarkReportWriter.java b/benchmarks/src/main/java/com/demcha/compose/BenchmarkReportWriter.java
index 73e061d3d..51d2b2e42 100644
--- a/benchmarks/src/main/java/com/demcha/compose/BenchmarkReportWriter.java
+++ b/benchmarks/src/main/java/com/demcha/compose/BenchmarkReportWriter.java
@@ -60,6 +60,14 @@ Path writeCsv(String tableName, List headers, List> rows) t
return archived;
}
+ Path writeMarkdown(String name, String content) throws IOException {
+ Path latest = directory.resolve("latest-" + name + ".md");
+ Path archived = directory.resolve(name + "-" + timestamp + ".md");
+ Files.writeString(latest, content, StandardCharsets.UTF_8);
+ Files.writeString(archived, content, StandardCharsets.UTF_8);
+ return archived;
+ }
+
Path directory() {
return directory;
}
diff --git a/benchmarks/src/main/java/com/demcha/compose/ChartAllocProbe.java b/benchmarks/src/main/java/com/demcha/compose/ChartAllocProbe.java
new file mode 100644
index 000000000..2921bde80
--- /dev/null
+++ b/benchmarks/src/main/java/com/demcha/compose/ChartAllocProbe.java
@@ -0,0 +1,114 @@
+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;
+
+/**
+ * Deterministic allocation probe for the v1.8 chart subsystem: warm
+ * (JIT-steady) bytes allocated by the layout-compile pass of a chart-heavy
+ * document (a grouped bar, a multi-series line, and a pie). Charts are resolved
+ * into engine primitives during compile, so this isolates the chart-resolve +
+ * geometry-emission allocation — the noise-free signal a develop-vs-branch A/B
+ * needs. No {@code src/main} changes.
+ *
+ * @author Artem Demchyshyn
+ */
+public final class ChartAllocProbe {
+
+ private static final com.sun.management.ThreadMXBean THREAD_MX =
+ (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
+
+ 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
+ .chart(ChartBenchmarkFixtures.barSpec(), ChartBenchmarkFixtures.barStyle())
+ .chart(ChartBenchmarkFixtures.lineSpec(), ChartBenchmarkFixtures.lineStyle())
+ .chart(ChartBenchmarkFixtures.pieSpec()));
+
+ 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 so the measured allocation is 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 chart layout-compile allocation probe");
+ System.out.printf("document: grouped bar + line (12 cats x 3 series) + 6-slice pie, pages: %d%n", pages);
+ System.out.printf("warm compile allocation (median of %d): %s%n",
+ MEASURE, kb(alloc[MEASURE / 2]));
+ System.out.printf(" min %s / max %s%n", kb(alloc[0]), kb(alloc[MEASURE - 1]));
+ }
+ }
+ }
+
+ 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 String kb(long bytes) {
+ return bytes < 0 ? "n/a (allocation measurement unsupported)" : "%.1f KB".formatted(bytes / 1024.0);
+ }
+
+ 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();
+ }
+}
diff --git a/benchmarks/src/main/java/com/demcha/compose/ChartBenchmarkFixtures.java b/benchmarks/src/main/java/com/demcha/compose/ChartBenchmarkFixtures.java
new file mode 100644
index 000000000..1993acb36
--- /dev/null
+++ b/benchmarks/src/main/java/com/demcha/compose/ChartBenchmarkFixtures.java
@@ -0,0 +1,134 @@
+package com.demcha.compose;
+
+import com.demcha.compose.document.chart.AxisSpec;
+import com.demcha.compose.document.chart.BarGrouping;
+import com.demcha.compose.document.chart.ChartData;
+import com.demcha.compose.document.chart.ChartSize;
+import com.demcha.compose.document.chart.ChartSpec;
+import com.demcha.compose.document.chart.ChartStyle;
+import com.demcha.compose.document.chart.LegendPosition;
+import com.demcha.compose.document.chart.PointMarker;
+import com.demcha.compose.document.chart.SliceLabelMode;
+import com.demcha.compose.document.chart.ValueLabelMode;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentPaint;
+import com.demcha.compose.document.style.DocumentStroke;
+
+/**
+ * Shared fixtures for the v1.8 chart benchmarks: a non-trivial grouped bar and
+ * multi-series line (12 categories × 3 series) plus a 6-slice pie. Charts
+ * compile at layout time into ordinary shapes / lines / polygons / labels, so
+ * these stress {@code ChartLayoutResolver} + per-primitive geometry + label
+ * text-metrics — the cost no text/table bench exercises.
+ *
+ * @author Artem Demchyshyn
+ */
+public final class ChartBenchmarkFixtures {
+
+ private ChartBenchmarkFixtures() {
+ }
+
+ /** 12 categories × 3 series — a representative grouped-bar / line workload. */
+ public static ChartData monthlySeries() {
+ return ChartData.builder()
+ .categories("Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
+ .series("2023", 12.4, 15.1, 9.8, 14.2, 16.0, 13.3, 17.1, 18.4, 15.9, 14.0, 19.2, 21.1)
+ .series("2024", 14.0, 18.2, 11.3, 16.9, 17.5, 15.0, 19.0, 20.2, 17.1, 16.4, 21.0, 23.5)
+ .series("2025", 15.5, 19.0, 12.0, 18.0, 19.1, 16.2, 20.5, 22.0, 18.9, 17.7, 22.8, 25.0)
+ .build();
+ }
+
+ /** 6-slice single-series data for the pie. */
+ public static ChartData regionShare() {
+ return ChartData.builder()
+ .categories("EMEA", "Americas", "APAC", "LATAM", "MEA", "Other")
+ .series("Share", 31.0, 27.0, 19.0, 10.0, 8.0, 5.0)
+ .build();
+ }
+
+ public static ChartSpec barSpec() {
+ return ChartSpec.bar()
+ .data(monthlySeries())
+ .valueAxis(AxisSpec.builder().baselineAtZero(true).build())
+ .legend(LegendPosition.BOTTOM)
+ .valueLabels(ValueLabelMode.OUTSIDE)
+ .size(ChartSize.aspectRatio(16, 7))
+ .build();
+ }
+
+ public static ChartStyle barStyle() {
+ return ChartStyle.builder()
+ .seriesPaint(0, DocumentPaint.solid(DocumentColor.rgb(20, 80, 95)))
+ .seriesPaint(1, DocumentPaint.solid(DocumentColor.rgb(196, 153, 76)))
+ .seriesPaint(2, DocumentPaint.solid(DocumentColor.rgb(120, 60, 140)))
+ .build();
+ }
+
+ public static ChartSpec lineSpec() {
+ return ChartSpec.line()
+ .data(monthlySeries())
+ .valueAxis(AxisSpec.builder().baselineAtZero(true).build())
+ .legend(LegendPosition.BOTTOM)
+ .size(ChartSize.aspectRatio(16, 7))
+ .build();
+ }
+
+ public static ChartStyle lineStyle() {
+ return ChartStyle.builder()
+ .lineWidth(1.8)
+ .pointMarker(PointMarker.circle(5.0)
+ .withStroke(DocumentStroke.of(DocumentColor.WHITE, 1.0)))
+ .build();
+ }
+
+ public static ChartSpec pieSpec() {
+ return ChartSpec.pie()
+ .data(regionShare())
+ .sliceLabels(SliceLabelMode.CATEGORY_PERCENT)
+ .size(ChartSize.fixedHeight(190))
+ .build();
+ }
+
+ /** Horizontal grouped bar — exercises the transposed (category-on-Y) layout branch. */
+ public static ChartSpec horizontalBarSpec() {
+ return ChartSpec.bar()
+ .data(monthlySeries())
+ .horizontal(true)
+ .valueAxis(AxisSpec.builder().baselineAtZero(true).build())
+ .legend(LegendPosition.BOTTOM)
+ .size(ChartSize.aspectRatio(16, 9))
+ .build();
+ }
+
+ /** Stacked bar — exercises the cumulative-stacking layout branch. */
+ public static ChartSpec stackedBarSpec() {
+ return ChartSpec.bar()
+ .data(monthlySeries())
+ .grouping(BarGrouping.STACKED)
+ .valueAxis(AxisSpec.builder().baselineAtZero(true).build())
+ .legend(LegendPosition.BOTTOM)
+ .size(ChartSize.aspectRatio(16, 7))
+ .build();
+ }
+
+ /** Bar with a non-zero value-axis minimum — exercises the lifted-baseline branch. */
+ public static ChartSpec axisMinBarSpec() {
+ return ChartSpec.bar()
+ .data(monthlySeries())
+ .valueAxis(AxisSpec.builder().min(8.0).build())
+ .legend(LegendPosition.BOTTOM)
+ .size(ChartSize.aspectRatio(16, 7))
+ .build();
+ }
+
+ /** Donut — exercises the pie's donut-ratio (inner-radius) branch. */
+ public static ChartSpec donutSpec() {
+ return ChartSpec.pie()
+ .data(regionShare())
+ .donutRatio(0.55)
+ .sliceLabels(SliceLabelMode.CATEGORY_PERCENT)
+ .size(ChartSize.fixedHeight(190))
+ .build();
+ }
+}
diff --git a/benchmarks/src/main/java/com/demcha/compose/ComparativeBenchmark.java b/benchmarks/src/main/java/com/demcha/compose/ComparativeBenchmark.java
index 76cd87c70..b37215fcc 100644
--- a/benchmarks/src/main/java/com/demcha/compose/ComparativeBenchmark.java
+++ b/benchmarks/src/main/java/com/demcha/compose/ComparativeBenchmark.java
@@ -1,25 +1,36 @@
package com.demcha.compose;
+import com.demcha.compose.document.api.DocumentPageSize;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.node.ContainerNode;
import com.demcha.compose.document.node.ParagraphNode;
import com.demcha.compose.document.node.TextAlign;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentTextStyle;
-import com.itextpdf.text.Document;
-import com.itextpdf.text.Paragraph;
-import com.itextpdf.text.pdf.PdfPTable;
-import com.itextpdf.text.pdf.PdfWriter;
+import com.demcha.compose.document.table.DocumentTableColumn;
+import com.itextpdf.kernel.pdf.PdfDocument;
+import com.itextpdf.kernel.pdf.PdfWriter;
+import com.itextpdf.layout.Document;
+import com.itextpdf.layout.element.Cell;
+import com.itextpdf.layout.element.Paragraph;
+import com.itextpdf.layout.element.Table;
+import com.itextpdf.layout.properties.UnitValue;
import net.sf.jasperreports.engine.*;
+import net.sf.jasperreports.engine.data.JRMapCollectionDataSource;
import net.sf.jasperreports.engine.design.*;
+import net.sf.jasperreports.engine.type.TextAdjustEnum;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import java.io.ByteArrayOutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.lang.management.ManagementFactory;
+import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
+import java.util.Map;
/**
* Fair Comparative Benchmark (CPU & RAM)
@@ -32,8 +43,23 @@ public class ComparativeBenchmark {
private static final int WARMUP_ITERATIONS = 50;
private static final int MEASUREMENT_ITERATIONS = 100;
+ // Report-scaling sweep: the same title + prose + N-row table rendered through
+ // every library at growing row counts, so the numbers show how each engine
+ // SCALES (and whether GraphCompose's lead widens with document size) instead
+ // of at a single fixed size. The heavy sizes use fewer iterations to keep the
+ // on-demand run reasonable; this is a directional comparative, not a strict
+ // JMH measurement (see benchmarks/README.md).
+ private static final int[] SWEEP_SIZES = {40, 200, 1000};
+ private static final int SWEEP_WARMUP_ITERATIONS = 20;
+ private static final int SWEEP_MEASUREMENT_ITERATIONS = 30;
+
+ private static final String REPORT_PROSE =
+ ("GraphCompose lays out structured business documents across many pages "
+ + "while keeping header and footer placement stable. ").repeat(6);
+
// Предкомпилированный отчет для честного теста Jasper
private static JasperReport compiledJasperReport;
+ private static JasperReport compiledJasperReportHeavy;
public static void main(String[] args) throws Exception {
BenchmarkSupport.configureQuietLogging();
@@ -41,28 +67,69 @@ public static void main(String[] args) throws Exception {
System.out.println("Timestamp: " + LocalDateTime.now().format(TIMESTAMP_FORMAT));
System.out.println("------------------------------------------------------------");
- // Подготавливаем Jasper 1 раз (как в Production)
+ // Per-thread allocation accounting backs the "Avg Heap (MB)" column and the
+ // heap-advantage ratios. Enable it explicitly (and bail loudly if the JVM
+ // does not support it) instead of trusting the platform default, matching
+ // the guard the other allocation probes in this module use.
+ com.sun.management.ThreadMXBean allocBean =
+ (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
+ if (!allocBean.isThreadAllocatedMemorySupported()) {
+ throw new IllegalStateException("Thread allocated-memory measurement is not supported on this JVM");
+ }
+ allocBean.setThreadAllocatedMemoryEnabled(true);
+
+ // Подготавливаем оба отчета Jasper 1 раз (как в Production)
setupJasper();
+ setupJasperReport();
- // Прогрев JVM (JIT компилятор)
+ // Прогрев JVM (JIT компилятор) — оба сценария
System.out.println("Warming up JVM...");
for (int i = 0; i < WARMUP_ITERATIONS; i++) {
benchmarkGraphComposeCanonical();
benchmarkIText();
benchmarkJasper();
}
+ for (int i = 0; i < SWEEP_WARMUP_ITERATIONS; i++) {
+ for (int size : SWEEP_SIZES) {
+ benchmarkGraphComposeReport(size);
+ benchmarkITextReport(size);
+ benchmarkJasperReport(size);
+ }
+ }
+
+ // Замер — два сценария: дешёвый (фиксированные накладные) и масштабирование отчёта
+ System.out.println("Measuring performance...");
+ List rows = new ArrayList<>();
- // Замер
- System.out.println("Measuring performance (" + MEASUREMENT_ITERATIONS + " iterations)...");
System.out.println();
- System.out.printf("%-24s | %14s | %14s%n", "Library", "Avg Time (ms)", "Avg Heap (MB)");
- System.out.println("-".repeat(60));
+ System.out.println("Scenario: small invoice (single page, ~3 lines), " + MEASUREMENT_ITERATIONS + " iterations");
+ printTableHeader();
+ rows.add(runBenchmark("GraphCompose Canonical", MEASUREMENT_ITERATIONS, ComparativeBenchmark::benchmarkGraphComposeCanonical).toRow());
+ rows.add(runBenchmark("iText 9", MEASUREMENT_ITERATIONS, ComparativeBenchmark::benchmarkIText).toRow());
+ rows.add(runBenchmark("JasperReports", MEASUREMENT_ITERATIONS, ComparativeBenchmark::benchmarkJasper).toRow());
- List rows = List.of(
- runBenchmark("GraphCompose Canonical", ComparativeBenchmark::benchmarkGraphComposeCanonical),
- runBenchmark("iText 5 (Old)", ComparativeBenchmark::benchmarkIText),
- runBenchmark("JasperReports", ComparativeBenchmark::benchmarkJasper)
- );
+ System.out.println();
+ System.out.println("Scenario: report scaling sweep (title + prose + N-row table), "
+ + SWEEP_MEASUREMENT_ITERATIONS + " iterations per size");
+ List scaling = new ArrayList<>();
+ for (int size : SWEEP_SIZES) {
+ System.out.println();
+ System.out.println(" N = " + size + " rows");
+ printTableHeader();
+ Measured gc = runBenchmark("GraphCompose (" + size + " rows)", SWEEP_MEASUREMENT_ITERATIONS,
+ () -> benchmarkGraphComposeReport(size));
+ Measured it = runBenchmark("iText 9 (" + size + " rows)", SWEEP_MEASUREMENT_ITERATIONS,
+ () -> benchmarkITextReport(size));
+ Measured js = runBenchmark("JasperReports (" + size + " rows)", SWEEP_MEASUREMENT_ITERATIONS,
+ () -> benchmarkJasperReport(size));
+ rows.add(gc.toRow());
+ rows.add(it.toRow());
+ rows.add(js.toRow());
+ // Ratios are computed from the full-precision averages, not the rounded
+ // report rows, so the advantage figures don't compound rounding error.
+ scaling.add(new ScalingPoint(size, gc, it, js));
+ }
+ printScalingSummary(scaling);
BenchmarkReportWriter.BenchmarkArtifacts artifacts = BenchmarkReportWriter.prepare("comparative");
ComparativeReport report = new ComparativeReport(
@@ -83,16 +150,46 @@ public static void main(String[] args) throws Exception {
System.out.println("-".repeat(60));
System.out.println("Saved JSON benchmark report to " + jsonPath);
System.out.println("Saved CSV benchmark report to " + csvPath);
+
+ // After all measurement, dump one rendered PDF per library/scenario so the
+ // exact documents that were benchmarked can be inspected visually. This runs
+ // outside the measured region, so it cannot affect the timing/allocation numbers.
+ Path samples = writeSampleRenders(artifacts.directory().resolve("samples"));
+ System.out.println("Saved sample renders (one PDF per library/scenario) to " + samples);
+ }
+
+ /**
+ * Renders each library/scenario once more and writes the bytes to PDF files,
+ * so a reader can open the actual documents the benchmark measured.
+ */
+ private static Path writeSampleRenders(Path directory) throws Exception {
+ Files.createDirectories(directory);
+ Files.write(directory.resolve("graphcompose-small.pdf"), benchmarkGraphComposeCanonical());
+ Files.write(directory.resolve("itext-small.pdf"), benchmarkIText());
+ Files.write(directory.resolve("jasper-small.pdf"), benchmarkJasper());
+ // The smallest and largest sweep sizes, so the reader can see both a short
+ // report and the multi-page document that drives the scaling numbers.
+ for (int size : new int[]{SWEEP_SIZES[0], SWEEP_SIZES[SWEEP_SIZES.length - 1]}) {
+ Files.write(directory.resolve("graphcompose-report-" + size + ".pdf"), benchmarkGraphComposeReport(size));
+ Files.write(directory.resolve("itext-report-" + size + ".pdf"), benchmarkITextReport(size));
+ Files.write(directory.resolve("jasper-report-" + size + ".pdf"), benchmarkJasperReport(size));
+ }
+ return directory;
+ }
+
+ private static void printTableHeader() {
+ System.out.printf("%-24s | %14s | %14s%n", "Library", "Avg Time (ms)", "Avg Heap (MB)");
+ System.out.println("-".repeat(60));
}
- private static ComparativeRow runBenchmark(String name, BenchmarkTask task) throws Exception {
+ private static Measured runBenchmark(String name, int iterations, BenchmarkTask task) throws Exception {
long totalTimeNs = 0;
long totalAllocatedBytes = 0;
long dummyAccumulator = 0; // Защита от Dead Code Elimination
com.sun.management.ThreadMXBean bean = (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
- for (int i = 0; i < MEASUREMENT_ITERATIONS; i++) {
+ for (int i = 0; i < iterations; i++) {
System.gc(); // Форсируем сборку мусора перед каждым замером для чистоты аллокации
long startBytes = bean.getThreadAllocatedBytes(Thread.currentThread().getId());
@@ -109,19 +206,15 @@ private static ComparativeRow runBenchmark(String name, BenchmarkTask task) thro
dummyAccumulator += pdfBytes.length;
}
- double avgTimeMs = (totalTimeNs / (double) MEASUREMENT_ITERATIONS) / 1_000_000.0;
- double avgMemMb = (totalAllocatedBytes / (double) MEASUREMENT_ITERATIONS) / (1024.0 * 1024.0);
+ double avgTimeMs = (totalTimeNs / (double) iterations) / 1_000_000.0;
+ double avgMemMb = (totalAllocatedBytes / (double) iterations) / (1024.0 * 1024.0);
System.out.printf("%-24s | %14.2f | %14.2f%n", name, avgTimeMs, avgMemMb);
// Печатаем dummy-переменную, чтобы JIT не вырезал код генерации
if (dummyAccumulator == 0) System.out.println("Error: No bytes generated");
- return new ComparativeRow(
- name,
- round(avgTimeMs),
- round(avgMemMb)
- );
+ return new Measured(name, avgTimeMs, avgMemMb);
}
/**
@@ -145,24 +238,78 @@ private static byte[] benchmarkGraphComposeCanonical() throws Exception {
}
}
+ /**
+ * GraphCompose canonical, multi-page report: title + {@code rows}-row table +
+ * prose, authored through the public page-flow DSL (the realistic consumer path).
+ */
+ private static byte[] benchmarkGraphComposeReport(int rows) throws Exception {
+ // Equal full-width columns (page width minus the 32pt L/R margins, split
+ // four ways), so the table fills the page like iText (setWidthPercentage
+ // 100) and Jasper (full-column-width cells) rather than hugging its text.
+ final double columnWidth = (DocumentPageSize.A4.width() - 2 * 32) / 4.0;
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(DocumentPageSize.A4).margin(DocumentInsets.of(32)).create()) {
+ session.pageFlow(flow -> {
+ flow.name("Report").spacing(8);
+ flow.addParagraph("Quarterly Business Report");
+ flow.addParagraph(REPORT_PROSE);
+ flow.addTable(t -> {
+ t.columns(
+ DocumentTableColumn.fixed(columnWidth),
+ DocumentTableColumn.fixed(columnWidth),
+ DocumentTableColumn.fixed(columnWidth),
+ DocumentTableColumn.fixed(columnWidth))
+ .header("Item", "Qty", "Unit", "Total").repeatHeader();
+ for (int r = 1; r <= rows; r++) {
+ t.row("Line item " + r, "3", "ea", "38.75");
+ }
+ });
+ flow.addParagraph(REPORT_PROSE);
+ });
+ return session.toPdfBytes();
+ }
+ }
+
/**
* iText: Тестируем с таблицей, чтобы заставить его рассчитывать геометрию
*/
private static byte[] benchmarkIText() throws Exception {
ByteArrayOutputStream baos = new ByteArrayOutputStream();
- Document document = new Document();
- PdfWriter.getInstance(document, baos);
- document.open();
-
- // Используем таблицу, чтобы iText делал расчет ширины (как GraphCompose)
- PdfPTable table = new PdfPTable(1);
- table.setWidthPercentage(100);
- table.addCell(new Paragraph("INVOICE #12345"));
- table.addCell(new Paragraph("Customer: John Doe"));
- table.addCell(new Paragraph("Amount: $1,000.00"));
-
- document.add(table);
- document.close();
+ // iText 9 (kernel + layout). A full-width 1-column table makes iText do
+ // the same width calculation GraphCompose does.
+ try (Document document = new Document(new PdfDocument(new PdfWriter(baos)))) {
+ Table table = new Table(UnitValue.createPercentArray(new float[]{1})).useAllAvailableWidth();
+ table.addCell(new Cell().add(new Paragraph("INVOICE #12345")));
+ table.addCell(new Cell().add(new Paragraph("Customer: John Doe")));
+ table.addCell(new Cell().add(new Paragraph("Amount: $1,000.00")));
+ document.add(table);
+ }
+ return baos.toByteArray();
+ }
+
+ /**
+ * iText, multi-page report: same title + {@code rows}-row table + prose. iText
+ * paginates the table natively, so this exercises real multi-page layout.
+ */
+ private static byte[] benchmarkITextReport(int rows) throws Exception {
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try (Document document = new Document(new PdfDocument(new PdfWriter(baos)))) {
+ document.add(new Paragraph("Quarterly Business Report"));
+ document.add(new Paragraph(REPORT_PROSE));
+
+ Table table = new Table(UnitValue.createPercentArray(new float[]{1, 1, 1, 1})).useAllAvailableWidth();
+ for (String header : new String[]{"Item", "Qty", "Unit", "Total"}) {
+ table.addHeaderCell(new Cell().add(new Paragraph(header)));
+ }
+ for (int r = 1; r <= rows; r++) {
+ table.addCell(new Cell().add(new Paragraph("Line item " + r)));
+ table.addCell(new Cell().add(new Paragraph("3")));
+ table.addCell(new Cell().add(new Paragraph("ea")));
+ table.addCell(new Cell().add(new Paragraph("38.75")));
+ }
+ document.add(table);
+ document.add(new Paragraph(REPORT_PROSE));
+ }
return baos.toByteArray();
}
@@ -199,6 +346,124 @@ private static void setupJasper() throws Exception {
compiledJasperReport = JasperCompileManager.compileReport(jd);
}
+ /**
+ * JasperReports, multi-page report: a 4-field detail band filled from a
+ * {@code rows}-row data source, with a title (+ prose) and column header.
+ * Compiled once here; the benchmark measures fill + PDF export.
+ */
+ private static byte[] benchmarkJasperReport(int rows) throws Exception {
+ List