Skip to content

Commit 8ad2bfa

Browse files
committed
feat: end-to-end report rendering - data/layout/Page-IR pipeline with HTML/SVG/PDF/PNG backends, chart & cross-tab drawing, formula evaluator, live-DB and saved-data sources, byte-faithful .rpt writer, and a render-parity test corpus
1 parent 4127c4d commit 8ad2bfa

613 files changed

Lines changed: 414868 additions & 19574 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.dockerignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ target/
66
# Sensitive / local-only — must never enter the build context or image.
77
samples/
88
research/
9-
dump/
10-
caches/
119
.claude/
1210
CLAUDE.md
1311

.github/workflows/ci.yml

Lines changed: 35 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ jobs:
2323
run: cargo fmt --all --check
2424
- name: Clippy
2525
run: cargo clippy --all-targets --all-features -- -D warnings
26-
- name: Build (all features)
27-
run: cargo build --all-features
26+
# --all-targets compiles every binary (rpt, rpt-render), example
27+
# (record_bytes), and test/bench target, so a broken build in any of them
28+
# fails CI here rather than only when that target is exercised.
29+
- name: Build (all features, all targets)
30+
run: cargo build --all-features --all-targets
2831

2932
test:
3033
runs-on: ubuntu-latest
@@ -36,6 +39,34 @@ jobs:
3639
- name: Test
3740
run: cargo test --all-features
3841

42+
# Render-parity corpus: seed the committed SQL fixtures into a real PostgreSQL (the single DB
43+
# technology for render testing) and diff the rendered HTML against the committed baselines. The
44+
# test skips without RPT_DB_URL, so it only runs where a server is provided — here, and locally via
45+
# `docker compose up -d --wait` (see docker-compose.yml / Makefile).
46+
render-fixtures:
47+
runs-on: ubuntu-latest
48+
services:
49+
postgres:
50+
image: postgres:16-alpine
51+
env:
52+
POSTGRES_USER: rpt
53+
POSTGRES_PASSWORD: rpt
54+
POSTGRES_DB: rptfixtures
55+
ports:
56+
- 5432:5432
57+
options: >-
58+
--health-cmd "pg_isready -U rpt -d rptfixtures"
59+
--health-interval 2s
60+
--health-timeout 3s
61+
--health-retries 15
62+
steps:
63+
- uses: actions/checkout@v4
64+
- uses: dtolnay/rust-toolchain@stable
65+
- name: Render fixtures against PostgreSQL
66+
env:
67+
RPT_DB_URL: postgres://rpt:rpt@localhost:5432/rptfixtures
68+
run: cargo test -p rpt-render --test postgres_fixtures
69+
3970
msrv:
4071
runs-on: ubuntu-latest
4172
steps:
@@ -73,7 +104,7 @@ jobs:
73104
- name: XML baseline regression
74105
env:
75106
RPT_REQUIRE_SANDBOX: "1"
76-
run: cargo test -p rpt-to-xml --test baseline
107+
run: cargo test -p rpt-cli --test baseline
77108

78109
# Create the GitHub Release once, before the matrix upload jobs. The upload
79110
# action only uploads assets to an existing release; if the per-target jobs
@@ -131,45 +162,9 @@ jobs:
131162
- name: Build and upload binaries
132163
uses: taiki-e/upload-rust-binary-action@v1
133164
with:
134-
bin: rpt,rpt-to-xml
165+
bin: rpt,rpt-render
135166
target: ${{ matrix.target }}
136167
archive: rpt-rs-$tag-$target
137168
include: README.md,LICENSE
138169
checksum: sha256
139170
token: ${{ secrets.GITHUB_TOKEN }}
140-
141-
# Publish the Docker image to the GitHub Container Registry, tagged with the
142-
# release version and `latest`. Same gating as the release stage.
143-
docker:
144-
needs: [check, test, msrv, feature-matrix, baseline]
145-
if: startsWith(github.ref, 'refs/tags/v')
146-
runs-on: ubuntu-latest
147-
permissions:
148-
contents: read
149-
packages: write
150-
steps:
151-
- uses: actions/checkout@v4
152-
- uses: docker/setup-buildx-action@v3
153-
- name: Log in to GHCR
154-
uses: docker/login-action@v3
155-
with:
156-
registry: ghcr.io
157-
username: ${{ github.actor }}
158-
password: ${{ secrets.GITHUB_TOKEN }}
159-
- name: Image tags and labels
160-
id: meta
161-
uses: docker/metadata-action@v5
162-
with:
163-
images: ghcr.io/${{ github.repository }}
164-
tags: |
165-
type=semver,pattern={{version}}
166-
type=raw,value=latest
167-
- name: Build and push
168-
uses: docker/build-push-action@v6
169-
with:
170-
context: .
171-
push: true
172-
tags: ${{ steps.meta.outputs.tags }}
173-
labels: ${{ steps.meta.outputs.labels }}
174-
cache-from: type=gha
175-
cache-to: type=gha,mode=max

.gitignore

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
.idea
1+
**/.idea/
2+
**/.vs/
23

34
# Sensitive or work-in-progress staff that should not be committed
45
/samples/
@@ -12,3 +13,15 @@
1213
/CLAUDE.md
1314
/.claude/
1415

16+
# Beads will be ignored until project is not stabilized
17+
.beads/
18+
render-out/
19+
20+
# Example programs are dev/scratch tools — kept on disk for `cargo run --example`, not tracked.
21+
**/examples/
22+
23+
# Private-report SQL fixtures carry real production schema/table/column names — never commit.
24+
/tests/fixtures/sql/private/
25+
26+
# Private-report HTML baselines (real schema-derived render) — never commit.
27+
/tests/fixtures/baselines/html/private/

CHANGELOG.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,120 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66

77
## [Unreleased]
88

9+
This release turns `rpt-rs` from a reader/exporter into a full reporting engine: a complete render pipeline
10+
(data → layout → Page IR → HTML/SVG/PDF/PNG), a formula evaluator, live-database support, saved-data decoding,
11+
a byte-faithful writer, and the test corpus to validate it all against the native Crystal engine.
12+
13+
### Added
14+
15+
- **End-to-end report rendering.** A new render & data pipeline built purely on the decoded model:
16+
- **`rpt-data`** — the record pipeline: `RowSource` → record selection → sort → grouping → summaries → running
17+
totals, with the formula evaluation context (`Global`/`Shared` variables, per-record cache, evaluation-time
18+
scheduling). Two-field summaries (`WeightedAverage`, `Correlation`, `Covariance`) resolve to an
19+
empty/unavailable value rather than a plausible-but-wrong one until the second field is decoded.
20+
- **`rpt-layout`** — the layout & pagination engine: places every object at its twip position, paginates
21+
band-by-band, and honours the section-break controls (New Page Before/After, Keep Group Together, Print at
22+
Bottom of Page, Reset Page Number After, Underlay Following Sections). Resolves each field's display format
23+
from the locale + its stored format spec.
24+
- **`rpt-pages`** — the backend-agnostic Page IR: `Rect` / `Ellipse` / `Line` / `Text` / `Polygon` / `Image`
25+
draw-ops in twips, solid/gradient/hatch fills, rotated text runs, image assets, checkpoints, and fidelity
26+
diagnostics. `serde`-serializable — the frozen contract between layout and backends.
27+
- **Four output backends**`rpt-render-html` (self-contained XHTML, images inlined), `rpt-render-svg` (one file
28+
per page), `rpt-render-pdf` (krilla with real font-subset embedding, plus a dependency-free fallback writer),
29+
and `rpt-render-raster` (tiny-skia → PNG per page).
30+
- **`rpt-text`** — the real text stack (cosmic-text): font metrics, Unicode/CJK line breaking, bidi, and font
31+
fallback behind a swappable `TextLayout` trait, with bundled Liberation fonts and a dependency-free
32+
`ApproxLayout` for deterministic output.
33+
- **`rpt-render`** — the orchestration facade: `ReportDocument` (load → inspect → `to_pdf`/`to_html`/…) and an
34+
options-driven `render_with(report, RenderOptions)` threading the datasource (`Saved`/`Rows`/`Dataset`),
35+
parameters, locale, and subreport scope, with typed `RenderError`s.
36+
- **The `rpt-render` CLI** — renders a report to HTML / PDF / SVG / PNG from its saved data or a live database
37+
(`--db`), with `--param`, `--locale`, format inference from the output extension, and stdout piping.
38+
- The pipeline up to the Page IR plus the HTML and SVG backends compile to **WebAssembly**; a CI job guards the
39+
boundary.
40+
41+
- **Chart rendering — 16 chart types as native vector draw-ops** (no rasterization): bar (clustered / stacked /
42+
percent, multi-series), line, area, pie, doughnut, 3-D riser, 3-D surface, 3-D area, scatter, bubble, stock
43+
(hi-lo / OHLC), histogram, radar, gauge, funnel, and numeric-axis, plus a bar fallback (with a diagnostic) for
44+
types without a dedicated renderer. Matches the native engine's defaults: the full 20-colour palette, axis
45+
titles, tick density, compact temporal category labels with period bucketing (weekly/monthly/quarterly/…), label
46+
thinning on dense axes, family-dependent legend rules, and a perspective 3-D scene (corner room, floor grid,
47+
painter-sorted risers). Per-axis gridline modes are decoded from the chart styling record.
48+
49+
- **Cross-tab rendering.** A cross-tab pivots the dataset by row × column dimensions with an aggregate measure per
50+
cell and draws a native grid (cell rects, grid lines, headers, grand totals). Current cut: one row dimension ×
51+
one column dimension × the first measure.
52+
53+
- **Live database support.** `rpt-query` generates the joined `SELECT` from the report's table/link graph —
54+
projecting only the tables and columns the report actually uses (matching the native engine's SQL and avoiding
55+
accidental cartesian joins) and pushing the translatable record-selection subset into `WHERE`. `rpt-db-postgres`
56+
and `rpt-db-sqlite` implement `RowSource` (native-only, isolated behind the trait so the portable core links no
57+
driver). Connection URLs are read only from the environment (`RPT_DB_URL`, `DATABASE_URL`,
58+
`RPT_DB_URL_<SERVER>`), never from flags.
59+
60+
- **Formula evaluation.** The `crystal-formula` engine gained a full evaluator: a bytecode VM (with a tree-walking
61+
reference evaluator), the builtin library across every family — string, math, conversion, date/time (incl.
62+
`DatePart` week-numbering modes), financial (`Pmt`/`FV`/`PV`/`NPV`/`IRR`/`Rate`/`DDB`/`SLN`/`SYD`), statistical
63+
(sample + population), and numeral (`ToWords`, `Roman`) — plus loop `Exit`, textual `#Month d, yyyy#` date
64+
literals, and a **semantic validation pass** (`validate`/`validate_str`): unknown/misspelled builtins with
65+
suggestions, arity and operator-type errors, and unknown field/parameter/formula references, as spanned,
66+
severity-tagged diagnostics.
67+
68+
- **Saved-data decoding.** A report saved with data now yields its schema (column names + types) and cached rows
69+
(`Report::saved_data`, the `rpt saved` subcommand) — both the external-memo and inline packed batch layouts
70+
decode, including the per-batch encryption and the memo heap.
71+
72+
- **`.rpt` writer (byte-faithful).** The decode pipeline is invertible at the record-substrate level:
73+
`Rpt::reencode` re-serializes, deflates, encrypts, and rewrites a valid `.rpt` that re-opens byte-identically at
74+
the logical level, and `Rpt::patch_record_leaf` overwrites a same-size region of a decoded record's leaf. Exposed
75+
as the `rpt reencode` / `rpt patch` subcommands. There is no model→records lowering yet.
76+
77+
- **New decoders in the reader:** charts (bindings, analytic layout, data-value labels, gridline modes),
78+
cross-tabs (dimensions, grid formats), object hyperlinks, hierarchical grouping, formula variables
79+
(name/type/scope), typed field sub-formats (number / currency / date / time / boolean / string masks), subreport
80+
re-import metadata, save metadata, designer state (rulers, guidelines), and the field-manager census. Every
81+
record type observed in the corpus is now named in the registry. `rpt` decode errors carry structured context
82+
(stream, byte offset, record type) via dedicated error types instead of message strings.
83+
84+
- **CLI additions.** `rpt inspect` shows each chart's data binding ("show value(s)" / "on change of");
85+
`rpt tree` prints the colorized decoded record DOM; `rpt streams` reports per-stream decode coverage;
86+
`rpt dump` is a byte-layout workbench (annotated hex, string scan, scalar probe, minimal-pair diff);
87+
`rpt saved` prints the decoded saved rows; `rpt sql` lists every SQL a report can run against its database
88+
(the generated join query + stored SQL Commands + SQL Expression fields, recursively through subreports, with
89+
connection/table provenance and a `--dialect` selector); `Report::objects()` / `objects_mut()` iterate all
90+
report objects.
91+
92+
- **Render-parity test corpus and infrastructure.** A committed corpus of 36 reports over one synthetic
93+
"parking" database, each with an XML decode baseline and an HTML render baseline, validated out-of-band against
94+
the native Crystal engine; golden-file tests for the Page IR and every backend; `docker-compose.yml` and a
95+
`Makefile` for the fixture database; DB-gated CI regression tests.
96+
97+
- **Documentation.** New guides: rendering (`docs/11-rendering.md`), a compile-verified render cookbook
98+
(`docs/12-render-examples.md`), and the five-part formula-engine set (architecture/VM, language reference,
99+
builtins, validation). GitHub-native Mermaid diagrams throughout, a rewritten README (status section, quick
100+
start, example render), and a docs↔code audit bringing the block catalog, support matrix, and saved-data docs
101+
in line with the code.
102+
103+
### Changed
104+
105+
- **Workspace restructured into a 20-crate, two-layer workspace** with compiler-enforced boundaries:
106+
- The semantic model moved out of `rpt` into the standalone, pure-data **`rpt-model`** crate (no I/O, WASM-safe,
107+
optional `serde`); `rpt` re-exports it as `rpt::model`. The whole render/data pipeline depends on `rpt-model`,
108+
not the decoder, so the render stack links no CFB/inflate. Byte-level provenance notes live in the
109+
documentation-only `rpt::provenance` module.
110+
- The formula language moved into the standalone **`crystal-formula`** crate (depends only on
111+
`rpt-format-value`), reusable without the binary reader (LSP, WASM sandbox, validator).
112+
- The `rpt-engine` crate was dissolved (its derived analytics now live in `rpt-cli`'s private
113+
`export::analysis`), and the `rpt-to-xml` binary was folded into `rpt-cli` as the **`rpt xml-dump`**
114+
subcommand — one `rpt` binary for all inspection and export. XML output is byte-identical.
115+
- **Naming:** standalone "SAP" was dropped in favor of "Crystal Reports" across the README, docs, CLI help, and
116+
crate metadata.
117+
118+
### Removed
119+
120+
- The standalone **`rpt-to-xml`** binary (now `rpt xml-dump`) and the **`rpt-engine`** crate (dissolved into
121+
`rpt-cli` / `crystal-formula`), per the restructuring above.
122+
9123
## [0.1.0]
10124

11125
### Added

CONTRIBUTING.md

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,16 @@ The workspace forbids `unsafe` code (`unsafe_code = "forbid"`); keep contributio
1717

1818
## Workspace layout
1919

20-
rpt-rs is a four-crate workspace, and the boundary between the crates is intentional:
20+
The reader crates, and the boundary between them, are intentional:
2121

2222
| Crate | Role |
2323
| ------------ | --------------------------------------------------------------------------------------------- |
2424
| `rpt` | Pure I/O. Decodes the **stored** facts from the bytes: container → records → typed model. |
25-
| `rpt-engine` | **Derived** analytics computed on top of the model (e.g. field use counts, formula analysis). |
26-
| `rpt-to-xml` | Exports a report to a structured XML document. |
27-
| `rpt-cli` | The `rpt` inspection CLI. |
25+
| `crystal-formula` | The standalone Crystal/Basic formula-language engine (no `.rpt` coupling). |
26+
| `rpt-cli` | The `rpt` inspection CLI; its `xml-dump` subcommand exports a report to a structured XML document and owns the **derived** analytics (`export::analysis`: field use counts, summaries, formula analysis). |
2827

2928
**The boundary is load-bearing.** If a value is read directly from the file, decode it in `rpt`. If a value is _computed
30-
or inferred_ (not present in the bytes), it belongs in `rpt-engine` — not as a stored field on an `rpt` model struct.
29+
or inferred_ (not present in the bytes), it belongs in the derive layer (`rpt-cli`'s `export::analysis`, the XML exporter) — not as a stored field on an `rpt` model struct.
3130
Keeping I/O separate from derivation is the core design rule.
3231

3332
## The `.rpt` format
@@ -43,21 +42,21 @@ Keeping I/O separate from derivation is the core design rule.
4342

4443
## Tests and XML baselines
4544

46-
The regression suite in `crates/rpt-to-xml/tests/baseline.rs` exports a set of public sample reports (under
45+
The regression suite in `crates/rpt-cli/tests/baseline.rs` exports a set of public sample reports (under
4746
`tests/fixtures/`) to XML and compares the output against committed baselines. To keep results deterministic across
4847
machines, the exporter runs inside a [Bubblewrap](https://github.com/containers/bubblewrap) sandbox with the report
4948
bind-mounted at a fixed path, so path-derived attributes are identical everywhere.
5049

5150
Run it (requires `bwrap` on Linux):
5251

5352
```sh
54-
cargo test -p rpt-to-xml --test baseline
53+
cargo test -p rpt-cli --test baseline
5554
```
5655

5756
When a change _intentionally_ alters the XML output, regenerate the baselines and review the diff:
5857

5958
```sh
60-
RPT_BLESS=1 cargo test -p rpt-to-xml --test baseline
59+
RPT_BLESS=1 cargo test -p rpt-cli --test baseline
6160
```
6261

6362
Only commit baseline changes you can explain. A mismatch prints a git-style unified diff showing exactly which lines

0 commit comments

Comments
 (0)