Compare commits

...
Author SHA1 Message Date
FredApps c0ee120ac2 Merge pull request #1140 from vangel76/perf/chart-frame-cache
Charts: resolved data and rendering are cached between frames (#1139)
2026-11-12 19:55:40 +01:00
Codex bf76e6e63b test: keep ref-edit three-sheet check clippy-clean 2026-11-12 19:41:19 +01:00
Codex 467d36a9c8 Merge main after PDF cleanup; retain both roadmap entries 2026-11-12 19:30:40 +01:00
FredApps efc11acede Merge pull request #818 from vangel76/perf/pdf-writer-cleanup
PDF: writer cleanup, q nesting limit, image dedupe, style only on change (#816)
2026-11-12 18:59:02 +01:00
Codex 0c5436de76 Merge main into chart frame cache; preserve both roadmap entries 2026-11-12 18:13:44 +01:00
Codex ce0beb6900 Merge main into PDF writer cleanup; preserve both roadmap entries 2026-11-12 18:13:44 +01:00
FredApps a60db4f1c7 Merge pull request #3323 from vangel76/fix/linest-qr
LINEST, LOGEST, TREND, GROWTH: solve by Householder QR (#3322)
2026-11-12 17:59:31 +01:00
FredApps a3366cff65 Merge pull request #1135 from vangel76/fix/group-resize-on-release-1082
Grid: resizing several selected columns applies the width on release (#1082)
2026-11-12 17:58:46 +01:00
FredApps 591b8354f2 Merge pull request #3624 from vangel76/fix/3619-merged-shift-enter
Shift+arrows, Enter and Tab no longer get stuck on a merged cell
2026-11-12 17:57:39 +01:00
FredApps 0b31cebe5d Merge pull request #789 from vangel76/feat/wordart-784
Insert: WordArt (insert.wordArt) (#784)
2026-11-12 17:56:55 +01:00
Dylan M. Taylor 37941f0624 Merge pull request #3844 from storytold/switch-craft-libs-xls-dv
xlsx: .xls data validation from craft-xls
2026-11-12 01:43:22 -05:00
Dylan M. Taylor b1bd8d8b20 share legacy XLS test helpers 2026-11-12 06:29:50 +00:00
Dylan M. Taylor 794b65ecac fix XLS validation warnings 2026-11-12 06:13:59 +00:00
Dylan M. Taylor 6308fc9edb Merge pull request #3843 from storytold/switch-craft-libs-opc
xlsx: OPC packages through craft-opc; craft-libs at aa84415b
2026-11-12 01:10:14 -05:00
Dylan M. Taylor 48659c1668 Merge pull request #3842 from storytold/switch-craft-libs-offcrypto
xlsx: encrypted workbooks through craft-offcrypto and craft-cfb
2026-11-12 01:09:49 -05:00
Dylan M. Taylor e8845f648a Merge pull request #3841 from storytold/switch-craft-libs-csv
xlsx, engine: CSV and SYLK through craft-csv
2026-11-12 01:09:16 -05:00
Dylan M. Taylor 3858482465 Merge pull request #3840 from storytold/switch-craft-libs-xls
xlsx: read .xls through craft-xls
2026-11-12 01:00:41 -05:00
Dylan M. Taylor 73a18c4f4c Merge remote-tracking branch 'origin/switch-craft-libs-opc' into codex/reviewfix-3844 2026-11-12 05:41:20 +00:00
Dylan M. Taylor 8d4fb0b12e Merge remote-tracking branch 'origin/switch-craft-libs-opc' into codex/propagate-3844 2026-11-12 05:37:34 +00:00
Dylan M. Taylor 3ba4945e72 GridCraft: use shared XLS validation operator type 2026-11-12 05:24:28 +00:00
Dylan M. Taylor 0752f3181b Merge current OPC switch into XLS validation switch 2026-11-12 05:20:25 +00:00
Dylan M. Taylor 1c84e32f58 GridCraft: update XLS validation switch to current shared crates 2026-11-12 05:16:38 +00:00
Dylan M. Taylor cf3b11c858 Merge remote-tracking branch 'origin/switch-craft-libs-opc' into codex/gridcraft-3844 2026-11-12 05:16:08 +00:00
Dylan M. Taylorandkiwi4boy ae7fbd81b7 xlsx: .xls data validation from craft-xls
craft-xls reads Dv/DVal records (storytold/craft-libs#999, a port of #3636).
xls.rs maps each rule onto the model as the .xlsx reader maps a dataValidation:
a non-literal list source becomes =ref. The tests are #3636's, run through
read_xls; the cap test now expects craft-xls's skipped-rules warning.

Co-authored-by: kiwi4boy <kiwi4boy@gmail.com>
2026-11-12 02:47:52 +00:00
vangel76andClaude Sonnet 5.5 2ff6001d3d Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:54:23 +01:00
vangel76andClaude Sonnet 5.5 20917cfd01 Merge upstream/main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:54:20 +01:00
vangel76andClaude Sonnet 5.5 4dd1c6cf6c Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-11 21:54:05 +01:00
vangel76andClaude Sonnet 5.5 60063b85cd Keep the Decorative Text ribbon button two lines wide
The one-line label widened the Insert tab enough to scroll the Equation button
out of view at 1400 px, which broke the equation editor tests in CI.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 23:20:06 +01:00
vangel76andClaude Sonnet 5.5 40c1afbe84 Use the Decorative Text label for the WordArt ribbon button
Upstream retires WordArt as a display name (display_names guard); the
button and its tooltip use the already translated Decorative Text key,
and the parity checklist is regenerated.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:17:09 +01:00
vangel76andClaude Sonnet 5.5 75c07a1a83 Merge upstream/main into feat/wordart-784
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-10 22:01:55 +01:00
vangel76 353908fca5 Merge remote-tracking branch 'upstream/main' into fix3624-wt 2026-11-09 23:30:34 +01:00
vangel76andClaude Opus 5.5 10545dc4e1 fix(selection): keep the old walk when the hidden axis has no direction
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:30:29 +01:00
vangel76andClaude Opus 5.5 f8070bbbd0 fix(selection): keep the Tab run's column when Enter skips hidden rows after a merged cell
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 23:21:03 +01:00
vangel76andClaude Opus 5.5 5e5b5d082d fix(selection): Enter from a merged cell keeps the Tab run's column
Leaving a merged cell downwards kept the start column and dropped the
column offset Enter passes to return to the Tab run's start column, so
Tab A2 -> B2:C3, Enter landed on B4 instead of A4. Keep the computed
column (and the computed row when leaving to the right).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 23:22:22 +01:00
vangel76andClaude Opus 5.5 2bc50341a0 fix(selection): Shift+arrows and Enter/Tab treat a merged cell as one cell (#3619)
Shift+Left/Up from a selected merged cell now widen the selection past
its edges, Shift+arrows shrink an area back over a merged cell, and
Enter/Tab leave a selected merged cell instead of walking the cells it
covers. Inside a larger selection Enter visits a merged cell once.

Fixes #3619

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:23:32 +01:00
vangel76andClaude Opus 5.5 7cd0a6cc72 Resize drag: spend Esc on the cancelled drag
Esc during a header-boundary resize was only peeked at, so the grid's
own Esc handling saw it as well and dropped a marked copy and Format
Painter. The cancel now removes the Esc presses from the frame's input,
with any modifiers, as before, cancelling the drag. The regression test
covers a pending copy and sticky Format Painter for rows and columns,
with the release in a later frame or the same frame, and Shift+Esc.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:01:31 +01:00
vangel76andClaude Opus 5.5 14aae7cee1 WordArt: keep ordinary shapes with textNoShape; print the text outline
textNoShape is the standard no-warp value, so it alone no longer makes a
shape WordArt: only a rectangle that is a text box (as the writer saves
WordArt) or has no fill and no outline reads as WordArt. A filled ellipse
with an explicit textNoShape now reads and saves as the same ellipse.

The PDF/print path drew WordArt text without its outline, so Mint
Outline (white text, green outline) printed as a blank page. The outline
is now drawn like on screen: the text in the outline colour, offset in
eight directions, under the filled text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:54:30 +01:00
vangel76andClaude Opus 5.5 8605fb02f0 Charts: follow defined names when deciding a chart is volatile
A series like =FreshClock, where the workbook name FreshClock is NOW(),
was cached because only the literal series text was searched for
volatile calls. The check now also follows defined names the formulas
use (workbook or sheet scope, bare or sheet-qualified, LAMBDA names),
the way the evaluator looks them up. Nesting deeper than 8, cycles and
more than 64 lookups per chart count as volatile, so a chart whose
stability isn't established is not cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:52:00 +01:00
vangel76 032b280b04 Merge upstream/main into feat/wordart-784 2026-11-08 12:39:59 +01:00
vangel76andClaude Opus 5.5 40c3ada226 Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:28:15 +01:00
vangel76andClaude Opus 5.5 01fe67010d PDF: look up page images by hash instead of scanning every earlier image
Page::image_rgba compared each new picture with all earlier pictures on
the page, so N distinct pictures cost N(N-1)/2 comparisons. Keep a
page-local hash-to-index map (pixels still compared with ImageData::same)
and add a regression test with 3,000 distinct pictures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:19:46 +01:00
vangel76andClaude Opus 5.5 86ed3d93b4 LINEST: treat only identical raw values as a constant predictor
The 64-ulp spread threshold still classified distinct representable
predictors such as [1e15, 1e15 + 1, 1e15 + 2] as constant and dropped
them. A column is now constant only when every raw value is the same
double; the 1e15 case is added to the offset regression test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 12:19:34 +01:00
vangel76 ef320b988d Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-08 12:18:59 +01:00
vangel76andClaude Sonnet 5.5 0144ffa51b Merge upstream/main into feat/wordart-784
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:39:57 +01:00
vangel76andClaude Sonnet 5.5 522120c34c Merge upstream/main into perf/pdf-writer-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:39:27 +01:00
vangel76andClaude Sonnet 5.5 3e64ddbd80 Merge upstream/main into fix/group-resize-on-release-1082
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:39:12 +01:00
vangel76andClaude Sonnet 5.5 2cae4aa316 Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:38:57 +01:00
vangel76andClaude Sonnet 5.5 ed15db4101 Merge upstream/main into fix/linest-qr
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:37:48 +01:00
vangel76andClaude Sonnet 5.5 3d50413aff Resize drag: keep the last accepted preview and the redo history on Esc
A refused size no longer replaces the size the release applies to the
selection, and Esc puts back the redo stack the drag began with.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:34:38 +01:00
vangel76andClaude Sonnet 5.5 224d0e96d7 WordArt: refuse insertion on a protected sheet
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:32:14 +01:00
vangel76andClaude Sonnet 5.5 1baa723ae7 Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:28:55 +01:00
vangel76andClaude Sonnet 5.5 cca09cc8f4 PDF: track the first text run with a flag
text_rotated counted the Tf operators written so far on every run, which is
quadratic for text that alternates between WinAnsi and CID characters.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:19:10 +01:00
vangel76andClaude Sonnet 5.5 583c8a4819 LINEST: keep predictors that vary by little next to a large offset
A centred column was dropped when its length fell under 1e-12 of the raw
column, which also removed real variation such as [1e13, 1e13+1, 1e13+2].
Constant columns are now recognised on the raw values, within a few ulps.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:01:20 +01:00
vangel76andClaude Sonnet 5.5 8e3fba8a25 Chart cache: drop closed documents once per frame, skip volatile series
Move retain_docs out of the per-chart paint path into SheetApp::logic so closed
documents are forgotten even when no chart is painted. Charts whose series call
NOW, TODAY, RAND, RANDBETWEEN or RANDARRAY bypass the cache because their
resolved values change between evaluations.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:54:59 +01:00
vangel76andClaude Sonnet 5.5 dd5cd48b8f Regenerate parity checklist after merging main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:29:48 +01:00
vangel76andClaude Sonnet 5.5 2a9a21d41c WordArt xlsx: only the textNoShape marker reads as WordArt, an empty WordArt keeps its body
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:25:50 +01:00
vangel76andClaude Sonnet 5.5 6a22a3b357 Merge upstream/main into feat/wordart-784
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 04:24:34 +01:00
vangel76andClaude Sonnet 5.5 38f48ad974 Merge upstream/main into fix/group-resize-on-release-1082
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 02:00:21 +01:00
vangel76andClaude Sonnet 5.5 ac0588dbaf Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:54:42 +01:00
vangel76andClaude Sonnet 5.5 a5c74edfec linest test: use f64::from directly (clippy)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:18:00 +01:00
vangel76andClaude Sonnet 5.5 d0b40c3b30 linest: drop a constant column that centres to rounding noise (review point)
CodeRabbit pointed out that the drop test used the centred column length, so a
constant column such as [0.1; 3] (centred to about -1.4e-17) was scaled to a unit
vector and kept, giving a meaningless coefficient and wrong df. A centred column
shorter than 1e-12 of its uncentred length is now treated as collinear with the
intercept. The suggested 1e-6 threshold was not used: it would also drop columns
that sit far from zero, such as 1e9 + i.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 00:05:38 +01:00
vangel76andClaude Sonnet 5.5 f74d95542f LINEST: solve by Householder QR instead of the normal equations (#3322)
The normal equations square the condition number, so a degree-5 polynomial on
x = 1..12 came back with errors up to 2.6e-6 and x, x^2 near 1e6 gave slope
2.99976 and intercept 251 for y = 3x + 7. LINEST, LOGEST, TREND and GROWTH now
factor the centred, column-scaled data with Householder QR. Collinear columns
are still dropped under the same relative tolerance.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 23:38:55 +01:00
vangel76andClaude Haiku 5.5 f3e24e2b68 Regenerate parity checklist after merging main
The parity CI step failed because docs/parity-checklist.md was stale
after merging upstream main. Regenerated with cargo xtask parity.
Counts now 278 / 292 (Insert: WordArt moved to implemented).

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
2026-11-02 20:58:00 +01:00
vangel76andClaude Sonnet 5.5 5b3b3bdc0e Keytip pending-sequence test: use NSC so it does not race the NJ go-live test
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:19:10 +01:00
vangel76andClaude Sonnet 5.5 f8a764f20c Fix merge: Shape::math for WordArt, fmt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:07:54 +01:00
vangel76andClaude Sonnet 5.5 0d1296e532 Merge upstream/main into feat/wordart-784
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:03:35 +01:00
vangel76andClaude Sonnet 5.5 b396e6c0bd Merge upstream/main into perf/pdf-writer-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:52:51 +01:00
vangel76andClaude Sonnet 5.5 9bd6fa1d75 Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 19:50:48 +01:00
vangel76 e624f4d178 Merge remote-tracking branch 'upstream/main' into HEAD 2026-10-30 00:58:28 +01:00
vangel76andClaude Sonnet 5.5 96aece4e57 Keep Esc on a resize drag from undoing an earlier size edit
A resize drag shares its undo label with an earlier width/height edit, so
coalescing removed the drag's own entry and Esc undid the earlier edit too.
Track the workbook the drag started from and merge/cancel only entries from
there. Esc is also handled when egui has already ended the drag.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 00:58:28 +01:00
vangel76andClaude Sonnet 5.5 218aa884be Merge upstream/main into feat/wordart-784
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 23:17:05 +01:00
vangel76 4f372ff470 Name the test case type (clippy type_complexity) 2026-10-29 23:09:29 +01:00
vangel76andClaude Sonnet 5.5 5ca5e6b61e Merge upstream/main into perf/pdf-writer-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 23:09:10 +01:00
vangel76andClaude Sonnet 5.5 5d440c82ac Merge upstream/main into fix/group-resize-on-release-1082
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-29 23:07:01 +01:00
vangel76 57e0eb5b0f Merge upstream/main into perf/chart-frame-cache 2026-10-29 23:06:07 +01:00
vangel76andClaude Sonnet 5.5 71469495bc Merge upstream/main into feat/wordart-784
Main moved shape text editing, PDF shape drawing, grid interaction and the Insert ribbon into submodules; the WordArt changes are re-applied there.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:16:35 +01:00
vangel76andClaude Sonnet 5.5 527abcfc7d Merge upstream/main into fix/group-resize-on-release-1082
grid.rs was split into grid/ modules on main; the resize-on-release changes are re-applied in grid.rs and grid/interact.rs.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 20:01:56 +01:00
vangel76andClaude Sonnet 5.5 37cf274a03 Merge upstream/main into perf/chart-frame-cache
SheetApp setup and font handling moved to app.rs and app/fonts.rs on main; the cache field and its clears are re-applied there. The cache tests use the ActiveSheet accessors.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 19:57:18 +01:00
vangel76andClaude Sonnet 5.5 f9bd1e7eca Fix WordArt for ActiveSheet API change after merge
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 20:52:27 +01:00
vangel76andClaude Sonnet 5.5 5a93bc3311 Merge upstream/main into feat/wordart-784
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 20:47:05 +01:00
vangel76andClaude Sonnet 5.5 f1d8d2e1e8 Merge upstream/main into feat/wordart-784
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:07:13 +01:00
vangel76andClaude Sonnet 5.5 3afaf39584 Merge upstream/main into perf/pdf-writer-cleanup
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:02:43 +01:00
vangel76andClaude Sonnet 5.5 4777381dec Merge upstream/main into fix/group-resize-on-release-1082
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 00:00:00 +01:00
vangel76andClaude Sonnet 5.5 97e43b84fa Chart cache test: set the MouseWheel source field added upstream
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:59:30 +01:00
vangel76andClaude Sonnet 5.5 6e2ec96c85 Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 23:55:51 +01:00
vangel76andClaude Sonnet 5.5 e6b6d2d65f Merge upstream/main into perf/chart-frame-cache
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-25 10:22:23 +01:00
vangel76andClaude Opus 5.5 d59c51ba1f Merge upstream/main into feat/wordart-784
print.rs: main moved shape drawing into draw_shape; the WordArt shadow
pass now sits in its text loop. ja.rs: main's catalog and tests, plus
the two WordArt CORE labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 20:12:18 +02:00
vangel76andClaude Opus 5.5 bce01e7789 Merge upstream/main into fix/group-resize-on-release-1082
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 19:54:27 +02:00
vangel76andClaude Sonnet 5.5 4b75f3ea00 docs: regenerate parity checklist after merging main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 20:26:34 +02:00
vangel76 a9069cb8f9 Merge remote-tracking branch 'upstream/main' into feat/wordart-784 2026-10-22 20:23:53 +02:00
vangel76 fb71f723fc Merge upstream/main into perf/pdf-writer-cleanup 2026-10-22 19:49:10 +02:00
vangel76 caf897daad Add WordArt labels to the catalogs added on main 2026-10-22 19:40:36 +02:00
vangel76 a9cc381332 Merge upstream/main into feat/wordart-784 2026-10-22 19:35:37 +02:00
vangel76 451debe5e8 Merge upstream/main into fix/group-resize-on-release-1082 2026-10-22 19:32:49 +02:00
vangel76 5f53afd72e Merge remote-tracking branch 'upstream/main' into perf/chart-frame-cache
# Conflicts:
#	ROADMAP.md
#	crates/ui-egui/src/chartview.rs
2026-10-22 19:30:01 +02:00
vangel76andClaude Sonnet 5.5 c7e4d6a19c shape.setText: apply the WordArt length limit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-20 01:35:46 +02:00
vangel76 30dfc3fbd8 PR789: merge main and preserve WordArt with RTL paragraph export 2026-10-20 00:57:39 +02:00
vangel76andClaude Sonnet 5.5 8e30c095db Charts: resolved data and rendering are cached between frames (#1139)
Part of #1098. Charts were resolved and rendered on every frame. The
primitives are now cached per document/sheet/chart and checked against a
Weak<Workbook> (any write to the workbook moves or clones it), the zoom and
pixels-per-point; capped, LRU, closed documents dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:26:01 +02:00
vangel76andClaude Sonnet 5.5 6090366562 Grid: resizing several selected columns applies the width on release (#1082)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 20:09:53 +02:00
vangel76andClaude Sonnet 5.5 c59575b766 Merge main into #789
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:05:22 +02:00
vangel76andClaude Sonnet 5.5 30ef61982b Merge upstream/main into perf/pdf-writer-cleanup
Combine the writer cleanup with the PDF hardening (#812) and UTF-16BE info strings (#813): sanitised widths/dashes and finite/extent guards now feed the style tracking; tests adapted to emit-on-change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 19:02:32 +02:00
vangel76andClaude Sonnet 5.5 e04e559da3 PDF: writer cleanup, q nesting limit, image dedupe, style only on change (#816)
Direct object writing, image dedupe, per-page style tracking, 28-level q limit,
explicit joins, /ProcSet dropped. See the PR description.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 22:28:19 +02:00
vangel76andClaude Sonnet 5.5 3109f2a9e0 Keytips: NW runs insert.wordArt now that the command exists; pending-sequence test uses NJ (insert.object)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:47:39 +02:00
vangel76andClaude Sonnet 5.5 a39c6b8977 Calc: SUBTOTAL(10x) recalculates when Row Height, AutoFit Row Height or Ungroup reveal rows
home.rowHeight, home.autofitRowHeight and data.ungroup change row hidden
state without marking the edit structural, so SUBTOTAL(101-111) kept a stale
value. Found by the command-sequence fuzz (seed 3).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 21:09:41 +02:00
vangel76andClaude Sonnet 5.5 470968fcdc Insert: WordArt (insert.wordArt) (#784)
Floating decorative text built on the text-box shape path: ShapeKind::WordArt, ten original
styles, outline/shadow, wordArt.set, gallery in the ribbon, XLSX and print.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-12 20:38:11 +02:00
49 changed files with 4146 additions and 344 deletions
+7
View File
@@ -198,9 +198,13 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | XLSX export writes a picture used on several sheets once, as one `xl/media` part every drawing points to (it wrote a copy per drawing: a 16-sheet workbook with one 112 KB logo grew from 0.4 to 1.9 MB) (#985) |
| 2026-10-13 | XLSX: `[Content_Types].xml`, `_rels/.rels`, the workbook rels and `xl/workbook.xml` are the first zip entries (document properties last), so libmagic and PHP `finfo` detect the file type even with large properties (#1090) |
| 2026-10-13 | Conditional formatting: a rule gathers range statistics only when it reads them (top/bottom, above average, duplicates, scales, bars, icons) and borrows them instead of copying them per cell, so the per-frame lookup of an expression or cell-value rule no longer visits its whole range (#1098, part 1) |
| 2026-10-13 | Grid: resizing several selected columns or rows previews on the dragged one and applies the size to all on release; Esc cancels (#1082) |
| 2026-10-12 | Next/previous sheet navigation stops at the first/last visible worksheet instead of activating a hidden boundary sheet; the current selection is retained |
| 2026-10-12 | PDF writer: objects are written straight to the output, identical pictures are stored once, style operators are emitted only when they change, `q` nesting is limited to 28 and polyline joins no longer leak into later strokes (#816) |
| 2026-10-12 | Insert › Text › WordArt (`insert.wordArt`, `wordArt.set`): floating decorative text with 10 original styles, outline and shadow, in-place editing, XLSX and print (#784) |
| 2026-10-12 | Number formatting: one table of built-in formats (xlsx); numbers, dates and General render with one scratch buffer instead of per-token strings; output unchanged, checked against digests recorded from the old code (#817) |
| 2026-10-21 | Macro engine ([macros.md](docs/macros.md)): typed, declarative macros saved in the workbook (a JSON field; hidden names in xlsx); declared inputs checked with messages that name the cells; tokens (`Input.col(2)`, `Row`, `Output`, `Name:Total`, `{{…}}` in formulas); one undo step with full rollback; a footprint dry run with ask, fail, overwrite, shift down or shift right; a policy that keeps macros away from files and the app, with approval for macros in opened files; Repeat, If and Stop steps; a debugger; a relative recorder; `macro.*` commands beside the unchanged Automate scripts |
| 2026-10-13 | Performance: a chart is resolved and rendered once per change of its workbook, zoom or fonts instead of on every frame; idle frames, hovering and scrolling reuse it (#1139, part of #1098) |
| 2026-10-13 | Charts: work and memory are bounded by series x points (2,000,000 per chart: every series keeps its first points; 16 trendlines per chart), hairline columns and bars are merged per pixel column, series ranges are clipped before they are evaluated, the moving average is linear and rendering clean data no longer copies it (#1147) |
| 2026-10-13 | Charts: one combo plot-kind rule for resolving and drawing; ticks of a scale off the step grid keep their own values (#1146) |
| 2026-10-12 | Security: the `--control` server requires an auth token (random, kept in a 0600 file or chosen with `GRIDCRAFT_CONTROL_TOKEN`) on each connection's first line and closes bad connections (#777) |
@@ -313,6 +317,9 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| 2026-10-13 | trivial | Progress log: shared pictures written once on XLSX export (#985) |
| 2026-10-13 | minor | Progress log: conditional-format lookups no longer scan the range for rules without statistics (#1098) |
| 2026-10-12 | minor | Progress log: hidden-sheet navigation at workbook boundaries |
| 2026-10-13 | trivial | Progress log: charts cached between frames (#1139) |
| 2026-10-12 | minor | Progress log: PDF writer cleanup (#816) |
| 2026-10-12 | minor | Catalog 276 → 277 / 292: Insert › WordArt (#784) |
| 2026-10-12 | minor | Numfmt cleanup: one built-in format table (gridcraft-xlsx), fewer allocations rendering numbers, dates and General, equality test against the previous output (#817); progress log row |
| 2026-10-12 | minor | The control server requires an auth token and closes bad connections (#777); progress log row |
| 2026-10-12 | minor | Rows auto-fit oversized fonts on open and whole-row/column formatting (#833); progress log row |
+1 -1
View File
@@ -26,7 +26,7 @@ use serde::{Deserialize, Serialize};
pub use axis::{Scale, format_number, nice_scale};
pub use raster::rasterize;
pub use render::{Swatch, legend_entries, render, supports_trendline};
pub use resolve::{default_palette, resolve, series_color, series_plot_kind};
pub use resolve::{default_palette, has_volatile_series, resolve, series_color, series_plot_kind};
pub use sparkline::render_sparkline;
/// Straight (non-premultiplied) RGBA colour.
+126 -5
View File
@@ -155,6 +155,131 @@ fn eval_list_in(wb: &Workbook, sheet: usize, formula: &str, limit: usize, depth:
}
}
/// Whether a series formula of `ch` (chart of `sheet`) calls a function whose result changes
/// between evaluations (`NOW`, `TODAY`, `RAND`, `RANDBETWEEN`, `RANDARRAY`), so a result resolved
/// earlier is stale. Defined names the formulas use (`=FreshClock`, `Sheet1!Rates`, a LAMBDA name)
/// are followed into their own formulas, the way the evaluator looks them up; each name once per
/// chart, so a cycle ends. Names nested deeper than [`MAX_NAME_DEPTH`], or more than
/// [`MAX_NAMES_FOLLOWED`] distinct names, count as volatile: a chart whose stability isn't
/// established is not cached.
pub fn has_volatile_series(wb: &Workbook, sheet: usize, ch: &Chart) -> bool {
let mut seen = Seen::new();
ch.series.iter().any(|s| {
[s.name.as_deref(), s.categories.as_deref(), Some(s.values.as_str()), s.bubble_sizes.as_deref()]
.into_iter()
.flatten()
.any(|f| formula_volatile(wb, sheet, f, 0, &mut seen))
})
}
/// Names followed inside names before a formula counts as volatile.
const MAX_NAME_DEPTH: u32 = 8;
/// Distinct defined names followed per chart before the chart counts as volatile.
const MAX_NAMES_FOLLOWED: usize = 64;
/// Defined names already followed for one chart, each with the sheet its formula is read on.
type Seen<'a> = Vec<(&'a gridcraft_model::DefinedName, usize)>;
fn formula_volatile<'a>(wb: &'a Workbook, sheet: usize, f: &str, depth: u32, seen: &mut Seen<'a>) -> bool {
if calls_volatile(f) {
return true;
}
name_tokens(f).into_iter().any(|t| {
let Some((n, at)) = defined_name(wb, sheet, t) else { return false };
// Followed already, or being followed (a cycle): a volatile call in it is found there.
if seen.iter().any(|&(m, s)| std::ptr::eq(m, n) && s == at) {
return false;
}
if depth >= MAX_NAME_DEPTH || seen.len() >= MAX_NAMES_FOLLOWED {
return true;
}
seen.push((n, at));
formula_volatile(wb, at, &n.formula, depth + 1, seen)
})
}
/// Whether formula text `f` calls one of the volatile functions by name.
fn calls_volatile(f: &str) -> bool {
const VOLATILE: [&str; 5] = ["NOW", "TODAY", "RAND", "RANDBETWEEN", "RANDARRAY"];
// Formulas read from a file may carry the `_xlfn.` prefix of newer functions.
let up = f.to_ascii_uppercase().replace("_XLFN.", "");
VOLATILE.iter().any(|name| {
up.match_indices(name).any(|(i, _)| {
let before = up[..i].chars().next_back();
let after = up[i + name.len()..].trim_start();
before.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.')) && after.starts_with('(')
})
})
}
fn is_ident_char(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '.' || c == '\\'
}
/// End of the identifier starting at byte `from` of `f`.
fn ident_end(f: &str, from: usize) -> usize {
f[from..].char_indices().find(|&(_, c)| !is_ident_char(c)).map_or(f.len(), |(j, _)| from + j)
}
/// End (past the closing quote) of the `q`-quoted text opening at byte `from`; a doubled quote
/// is an escaped one. `None` when it is not closed.
fn quoted_end(f: &str, from: usize, q: char) -> Option<usize> {
let mut i = from + q.len_utf8();
loop {
let j = i + f[i..].find(q)?;
if f[j + q.len_utf8()..].starts_with(q) {
i = j + 2 * q.len_utf8();
} else {
return Some(j + q.len_utf8());
}
}
}
/// The words of formula text `f` that may be defined names: `Name`, `Sheet1!Name`,
/// `'Bob''s'!Name`. String literals and numbers are skipped; function names, column letters and
/// the like come along too and simply aren't found as names.
fn name_tokens(f: &str) -> Vec<&str> {
let mut out = Vec::new();
let mut i = 0;
while let Some(c) = f[i..].chars().next() {
let start = i;
let end = match c {
'"' => {
i = quoted_end(f, i, '"').unwrap_or(f.len());
continue;
}
'\'' => quoted_end(f, i, '\'').unwrap_or(f.len()),
c if is_ident_char(c) => ident_end(f, i),
c => {
i += c.len_utf8();
continue;
}
};
i = end;
if f[end..].starts_with('!') {
let e = ident_end(f, end + 1);
if e > end + 1 {
out.push(&f[start..e]);
}
i = e;
} else if c != '\'' && !c.is_ascii_digit() {
out.push(&f[start..end]);
}
}
out
}
/// The defined name `t` (`Name` or `Sheet1!Name`) seen from `sheet`, and the sheet its own
/// formula is read on.
fn defined_name<'a>(wb: &'a Workbook, sheet: usize, t: &str) -> Option<(&'a gridcraft_model::DefinedName, usize)> {
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
let (at, bare) = match gridcraft_core::split_sheet(t) {
(Some(s), bare) => (wb.sheet_index(&s)?, bare),
(None, bare) => (sheet, bare),
};
wb.name(bare, at).map(|n| (n, n.scope.unwrap_or(at)))
}
fn to_num(v: &Value) -> Option<f64> {
match v {
Value::Number(n) if n.is_finite() => Some(*n),
@@ -305,11 +430,7 @@ fn category_table(wb: &Workbook, sheet: usize, formula: &str, values: &str, limi
/// Whether `t` names a defined name seen from `sheet` (`Name`, or `Sheet1!Name` looked up on
/// that sheet), the way the evaluator looks names up.
fn is_defined_name(wb: &Workbook, sheet: usize, t: &str) -> bool {
// `'Bob''s'!Name`: the sheet is unquoted as everywhere else (#2012).
match gridcraft_core::split_sheet(t) {
(Some(s), bare) => wb.sheet_index(&s).is_some_and(|i| wb.name(bare, i).is_some()),
(None, _) => wb.name(t, sheet).is_some(),
}
defined_name(wb, sheet, t).is_some()
}
/// A series name: a reference/formula or a defined name (`[0]!Title` in a file) is evaluated,
+93
View File
@@ -1680,3 +1680,96 @@ fn a_series_named_by_a_name_on_a_sheet_with_a_bang_resolves() {
let d = resolve(&wb, 0, &c);
assert_eq!(d.series.first().map(|s| s.name.as_str()), Some("Takings"));
}
#[test]
fn volatile_series_formulas_are_detected() {
let wb = Workbook::new();
let with = |values: &str, name: Option<&str>| {
let mut c = chart(ChartKind::Line);
c.series.push(Series {
name: name.map(Into::into),
categories: None,
values: values.into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
quartiles: Default::default(),
trendlines: vec![],
});
c
};
assert!(has_volatile_series(&wb, 0, &with("=NOW()", None)));
assert!(has_volatile_series(&wb, 0, &with("=SUM(A1:A3)*rand ()", None)));
assert!(has_volatile_series(&wb, 0, &with("Sheet1!$B$2:$B$9", Some("=TODAY()"))));
assert!(has_volatile_series(&wb, 0, &with("=RANDARRAY(5)", None)));
assert!(has_volatile_series(&wb, 0, &with("=_xlfn.RANDARRAY(5)", None)));
assert!(!has_volatile_series(&wb, 0, &with("Sheet1!$B$2:$B$9", Some("Sheet1!$B$1"))));
assert!(!has_volatile_series(&wb, 0, &with("=MYRAND(A1)", None)));
assert!(!has_volatile_series(&wb, 0, &with("=Sheet1!$NOW", None)));
assert!(!has_volatile_series(&wb, 0, &chart(ChartKind::Line)));
}
#[test]
fn volatile_defined_names_are_followed() {
let with = |values: &str| {
let mut c = chart(ChartKind::Line);
c.series.push(Series {
name: None,
categories: None,
values: values.into(),
bubble_sizes: None,
color: None,
secondary: false,
kind: None,
smooth: false,
quartiles: Default::default(),
trendlines: vec![],
});
c
};
let name = |name: &str, scope: Option<usize>, formula: &str| gridcraft_model::DefinedName {
name: name.into(),
scope,
formula: formula.into(),
comment: String::new(),
hidden: false,
};
let mut wb = Workbook::new();
wb.sheet_mut(0).unwrap().name = "Bob's".into();
wb.names.push(name("FreshClock", None, "NOW()"));
wb.names.push(name("Later", None, "FreshClock+1"));
wb.names.push(name("Dice", Some(0), "RANDBETWEEN(1,6)"));
wb.names.push(name("Rates", None, "'Bob''s'!$A$1:$A$3"));
wb.names.push(name("Loop", None, "Loop+1"));
wb.names.push(name("Spin", None, "Spin+NOW()"));
wb.names.push(name("Roll", None, "LAMBDA(n,n*RAND())"));
// A workbook-scoped name, a name used inside another, and a sheet-scoped one, bare or qualified.
assert!(has_volatile_series(&wb, 0, &with("=FreshClock")));
assert!(has_volatile_series(&wb, 0, &with("=SUM(Later, 1)")));
assert!(has_volatile_series(&wb, 0, &with("=Dice*2")));
assert!(has_volatile_series(&wb, 0, &with("='Bob''s'!Dice")));
assert!(has_volatile_series(&wb, 0, &with("=Roll(3)")));
// A self-referencing name ends (it evaluates to an error, not a changing value); one that
// also calls NOW is volatile.
assert!(!has_volatile_series(&wb, 0, &with("=Loop")));
assert!(has_volatile_series(&wb, 0, &with("=Spin")));
// Many series over the same stable name stay cacheable: each name is followed once.
let mut many = with("=Rates");
let first = many.series[0].clone();
many.series.extend(std::iter::repeat_n(first, 100));
assert!(!has_volatile_series(&wb, 0, &many));
// Names nested deeper than the limit are not known to be stable.
for i in 0..10 {
wb.names.push(name(&format!("Deep{i}"), None, &format!("Deep{}", i + 1)));
}
assert!(has_volatile_series(&wb, 0, &with("=Deep0")));
// A name that refers to cells, a name inside a string and a name of another scope are stable.
assert!(!has_volatile_series(&wb, 0, &with("=Rates")));
assert!(!has_volatile_series(&wb, 0, &with("=\"FreshClock\"")));
// Dice belongs to sheet 0: seen from another sheet it isn't defined.
wb.sheets.push(Arc::new(gridcraft_model::Sheet::new("Other")));
assert!(!has_volatile_series(&wb, 1, &with("=Dice")));
assert!(has_volatile_series(&wb, 1, &with("=FreshClock")));
}
+2
View File
@@ -26,6 +26,7 @@ pub mod spelling;
pub mod stash;
pub mod view;
pub mod whatif;
pub mod wordart;
use gridcraft_calc::Key;
use gridcraft_core::{CellRef, RangeRef};
@@ -176,6 +177,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(extra::specs());
v.extend(macros::specs());
v.extend(draw::specs());
v.extend(wordart::specs());
v.extend(spelling::specs());
v.extend(solver::specs());
v.extend(pivot::specs());
+46 -10
View File
@@ -63,6 +63,11 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
let d = s.doc_mut()?;
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let sel = &mut d.selection;
if extend && !jmp {
// Shift+arrows move the edge the anchor doesn't hold, keeping merged cells whole.
sel.extend_by(sheet, dr, dc);
return Ok(json!({"selection": sel.a1(), "active": sel.active.a1()}));
}
// Extension moves the far corner, not the active cell.
let from = if extend { sel.moving_end() } else { sel.active };
// Skip hidden rows/columns and step over merged areas.
@@ -77,23 +82,32 @@ pub(super) fn selection_move(s: &mut Session, p: &Json) -> Result<Json> {
jump(sheet, from, dr.signum(), dc.signum())
} else {
let mut t = from.offset_clamped(dr, dc);
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new(m.end.offset_clamped(dr, 0).row, t.col);
} else if dc > 0 {
t = CellRef::new(t.row, m.end.offset_clamped(0, dc).col);
}
}
let mut guard = 0;
while (sheet.is_row_hidden(t.row) || sheet.is_col_hidden(t.col)) && guard < 100_000 {
let n = t.offset_clamped(dr.signum(), dc.signum());
// Step each hidden axis along its own direction, so a diagonal exit from a merge
// (Tab-run return column) keeps its column while skipping hidden rows.
let mut n = CellRef::new(
if sheet.is_row_hidden(t.row) { t.offset_clamped(dr.signum(), 0).row } else { t.row },
if sheet.is_col_hidden(t.col) { t.offset_clamped(0, dc.signum()).col } else { t.col },
);
if n == t {
// The hidden axis has no direction (a move along the other axis): walk as before.
n = t.offset_clamped(dr.signum(), dc.signum());
}
if n == t {
break;
}
t = n;
guard += 1;
}
if !extend && let Some(m) = sheet.merge_at(from) {
// Leaving a merged cell moves from its edge.
if dr > 0 {
t = CellRef::new(m.end.offset_clamped(dr, 0).row, from.col);
} else if dc > 0 {
t = CellRef::new(from.row, m.end.offset_clamped(0, dc).col);
}
}
t
};
if let Some(m) = sheet.merge_at(to)
@@ -140,7 +154,29 @@ pub(super) fn selection_next(s: &mut Session, p: &Json) -> Result<Json> {
let fwd = bool_param(p, "forward").unwrap_or(true);
let by_row = bool_param(p, "byRow").unwrap_or(false);
let d = s.doc_mut()?;
d.selection.cycle(fwd, by_row);
let Some(sheet) = d.wb.active() else { return Err(EngineError::NoDocument) };
let sel = &d.selection;
if sel.is_one_merged_cell(sheet) {
// A selection that is just one merged cell is one cell, as in Excel: Enter and Tab leave
// it for the next cell instead of walking the cells it covers.
let step = if fwd { 1 } else { -1 };
let (dr, dc) = if by_row { (0, step) } else { (step, 0) };
selection_move(s, &json!({"dr": dr, "dc": dc}))?;
return Ok(json!({"active": s.doc()?.selection.active.a1()}));
}
// Inside a larger selection the cells a merged cell covers are skipped: it is visited once,
// at its top-left cell.
let area = sel.current();
let cells =
(u64::from(area.end.row.saturating_sub(area.start.row)) + 1).saturating_mul(u64::from(area.end.col.saturating_sub(area.start.col)) + 1);
let mut next = sel.clone();
for _ in 0..cells.min(1 << 20) {
next.cycle(fwd, by_row);
if sheet.merge_at(next.active).is_none_or(|m| m.start == next.active) {
break;
}
}
d.selection = next;
Ok(json!({"active": d.selection.active.a1()}))
}
+7 -1
View File
@@ -1222,9 +1222,15 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad("shape.setText", "no such text box"))?;
let shape = sh.shapes.iter().find(|shape| shape.id == id).ok_or_else(|| bad("shape.setText", "no such text box"))?;
if shape.kind != ShapeKind::TextBox {
if !matches!(shape.kind, ShapeKind::TextBox | ShapeKind::WordArt) {
return Err(bad("shape.setText", "the shape is not a text box"));
}
if shape.kind == ShapeKind::WordArt && text.is_empty() {
return Err(bad("shape.setText", "WordArt text must not be empty"));
}
if shape.kind == ShapeKind::WordArt && text.chars().nth(super::wordart::MAX_CHARS).is_some() {
return Err(bad("shape.setText", format!("`text` is longer than {} characters", super::wordart::MAX_CHARS)));
}
// Sheet protection locks drawing objects (there is no "edit objects" allowance yet).
if sh.is_protected() {
return Err(EngineError::Other(PROTECTED.into()));
+15
View File
@@ -213,6 +213,21 @@ fn draw_shape(pg: &mut Page, wb: &Workbook, shp: &gridcraft_model::Shape, b: &Bl
ShapeTextAlign::Center => x + (w - lw) / 2.0,
ShapeTextAlign::Right => x + w - 2.0 * b.k - lw,
};
// WordArt's drop shadow: the line again, offset, in the shadow colour (under the text).
if let Some(sc) = fm.text_shadow.and_then(|c| rgb_of(c, wb)) {
let off = (size * 0.06).max(0.5);
pg.text(lx + off, yy + off, size, font, sc, l);
}
// WordArt's text outline, as on screen: the line in the outline colour, offset in eight
// directions by half the outline width, under the filled text.
if let Some((c, ow)) = fm.text_outline
&& let Some(oc) = rgb_of(c, wb)
{
let d = (ow * b.scale / 2.0).clamp(0.25, 20.0);
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
pg.text(lx + dx * d, yy + dy * d, size, font, oc, l);
}
}
pg.text(lx, yy, size, font, color, l);
yy += size * 1.2;
}
+241
View File
@@ -0,0 +1,241 @@
//! Insert › Text › WordArt: decorative text as a floating shape (`ShapeKind::WordArt`) built on
//! the text-box machinery (anchor, move/resize/delete, undo, XLSX shape text body, printing).
//! The style palette below is original to GridCraft.
use gridcraft_model::{Anchor, Color, Shape, ShapeFormat, ShapeKind, ShapeTextAlign, ShapeTextAnchor};
use serde_json::{Value as Json, json};
use super::{
CommandSpec, EngineError, PROTECTED, Result, Session, anchor_mode_param, bad, bool_param, cell_param, cmd, edit, f64_param, has_doc, ok,
u32_param,
};
/// One entry of the style gallery.
pub struct WordArtStyle {
pub name: &'static str,
pub fill: Color,
pub outline: Option<(Color, f32)>,
pub shadow: Option<Color>,
pub bold: bool,
pub italic: bool,
/// Font size in points.
pub size: f32,
}
const fn rgb(v: u32) -> Color {
Color::Rgb(v)
}
/// GridCraft's own WordArt presets. Theme colours where it makes sense (they follow the theme),
/// fixed colours for the rest.
pub const STYLES: [WordArtStyle; 10] = [
WordArtStyle { name: "Classic", fill: Color::Theme(4, 0), outline: None, shadow: None, bold: true, italic: false, size: 36.0 },
WordArtStyle { name: "Midnight", fill: rgb(0x1B2A49), outline: None, shadow: Some(rgb(0x8A94A6)), bold: true, italic: false, size: 40.0 },
WordArtStyle { name: "Sunrise", fill: rgb(0xF2A33A), outline: Some((rgb(0xB5451B), 1.5)), shadow: None, bold: true, italic: false, size: 40.0 },
WordArtStyle {
name: "Lagoon",
fill: rgb(0x1F9E9A),
outline: Some((rgb(0x0B4F5C), 1.0)),
shadow: Some(rgb(0x9CC9C7)),
bold: true,
italic: true,
size: 36.0,
},
WordArtStyle { name: "Ember", fill: rgb(0xD64527), outline: None, shadow: Some(rgb(0x5A1A0E)), bold: true, italic: false, size: 44.0 },
WordArtStyle {
name: "Mint Outline",
fill: rgb(0xFFFFFF),
outline: Some((rgb(0x2E9B6A), 2.0)),
shadow: None,
bold: true,
italic: false,
size: 40.0,
},
WordArtStyle { name: "Ink Stamp", fill: rgb(0x2F3A8F), outline: Some((rgb(0x2F3A8F), 0.5)), shadow: None, bold: true, italic: false, size: 32.0 },
WordArtStyle { name: "Orchid", fill: rgb(0x8E4FB5), outline: None, shadow: Some(rgb(0xD9C2E8)), bold: false, italic: true, size: 40.0 },
WordArtStyle {
name: "Graphite",
fill: rgb(0x4A4F57),
outline: Some((rgb(0xFFFFFF), 1.0)),
shadow: Some(rgb(0x9AA0A8)),
bold: true,
italic: false,
size: 36.0,
},
WordArtStyle {
name: "Lime Pop",
fill: rgb(0xA6D61C),
outline: Some((rgb(0x3F5A00), 1.5)),
shadow: Some(rgb(0x6E8B2C)),
bold: true,
italic: true,
size: 40.0,
},
];
/// The text of a WordArt inserted without `text`.
pub const DEFAULT_TEXT: &str = "Your text here";
/// Longest WordArt text (characters).
pub const MAX_CHARS: usize = 1000;
const MAX_POINTS: f64 = 400.0;
const MAX_SIDE: f64 = 4000.0;
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.wordArt",
"WordArt",
["Insert", "Text"],
None,
"{style?: preset name or 0-based index (Classic, Midnight, Sunrise, Lagoon, Ember, Mint Outline, Ink Stamp, Orchid, Graphite, Lime Pop), text?, at?, width?, height?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
has_doc,
insert_word_art
),
cmd!(
"wordArt.set",
"Format WordArt",
[],
None,
"{id: WordArt id, style?, text?, fontSize?, bold?, italic?, fill?: hex, outline?: hex|false, outlineWidth?, shadow?: hex|bool}",
has_doc,
set_word_art
),
]
}
fn style_param(command: &str, p: &Json) -> Result<Option<&'static WordArtStyle>> {
match p.get("style") {
None | Some(Json::Null) => Ok(None),
Some(Json::Number(n)) => n
.as_u64()
.and_then(|i| STYLES.get(usize::try_from(i).ok()?))
.map(Some)
.ok_or_else(|| bad(command, format!("`style` index must be 0..{}", STYLES.len() - 1))),
Some(Json::String(name)) => STYLES
.iter()
.find(|s| s.name.eq_ignore_ascii_case(name.trim()))
.map(Some)
.ok_or_else(|| bad(command, format!("unknown WordArt style `{name}`"))),
Some(_) => Err(bad(command, "`style` must be a preset name or index")),
}
}
fn text_param(command: &str, p: &Json) -> Result<Option<String>> {
match p.get("text") {
None | Some(Json::Null) => Ok(None),
Some(Json::String(t)) if t.is_empty() => Err(bad(command, "`text` must not be empty")),
Some(Json::String(t)) if t.chars().nth(MAX_CHARS).is_some() => Err(bad(command, format!("`text` is longer than {MAX_CHARS} characters"))),
Some(Json::String(t)) => Ok(Some(t.clone())),
Some(_) => Err(bad(command, "`text` must be a string")),
}
}
/// Applies a preset, then explicit overrides, to a shape's format and fill.
fn apply(command: &str, shape: &mut Shape, style: Option<&WordArtStyle>, p: &Json) -> Result<()> {
if let Some(st) = style {
shape.format.text_color = Some(st.fill);
shape.format.text_outline = st.outline;
shape.format.text_shadow = st.shadow;
shape.format.bold = st.bold;
shape.format.italic = st.italic;
shape.format.font_size = Some(st.size);
}
let f = &mut shape.format;
if let Some(c) = p.get("fill").filter(|v| !v.is_null()) {
f.text_color = Some(super::format::color_param(Some(c)).ok_or_else(|| bad(command, "`fill` must be a hex colour"))?);
}
match p.get("outline") {
None | Some(Json::Null) => {}
Some(Json::Bool(false)) => f.text_outline = None,
Some(v) => {
let c = super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`outline` must be a hex colour or false"))?;
let w = f64_param(p, "outlineWidth").map_or(f.text_outline.map_or(1.5, |o| o.1), |w| w.clamp(0.25, 20.0) as f32);
f.text_outline = Some((c, w));
}
}
if let (Some(w), Some((c, _))) = (f64_param(p, "outlineWidth"), f.text_outline) {
f.text_outline = Some((c, w.clamp(0.25, 20.0) as f32));
}
match p.get("shadow") {
None | Some(Json::Null) => {}
Some(Json::Bool(false)) => f.text_shadow = None,
Some(Json::Bool(true)) => f.text_shadow = Some(f.text_shadow.unwrap_or(rgb(0x808080))),
Some(v) => {
f.text_shadow = Some(super::format::color_param(Some(v)).ok_or_else(|| bad(command, "`shadow` must be a hex colour or a boolean"))?)
}
}
if let Some(b) = bool_param(p, "bold") {
f.bold = b;
}
if let Some(b) = bool_param(p, "italic") {
f.italic = b;
}
if let Some(sz) = f64_param(p, "fontSize") {
f.font_size = Some(sz.clamp(1.0, MAX_POINTS) as f32);
}
Ok(())
}
fn insert_word_art(s: &mut Session, p: &Json) -> Result<Json> {
let style = style_param("insert.wordArt", p)?.unwrap_or(&STYLES[0]);
let text = text_param("insert.wordArt", p)?.unwrap_or_else(|| DEFAULT_TEXT.to_string());
let d = s.doc()?;
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
return Err(EngineError::Other(PROTECTED.into()));
}
let id = d.wb.next_object_id();
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let mut shape = Shape {
id,
kind: ShapeKind::WordArt,
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width: 0.0, height: 0.0, mode: anchor_mode_param(p).unwrap_or_default() },
fill: Color::Auto,
line: Color::Auto,
text: text.clone(),
format: ShapeFormat { no_line: true, align: Some(ShapeTextAlign::Center), anchor: Some(ShapeTextAnchor::Middle), ..Default::default() },
math: Default::default(),
};
apply("insert.wordArt", &mut shape, Some(style), p)?;
// Default box: the longest line at the font size (about 0.62 em per character) plus padding.
let pt = f64::from(shape.format.font_size.unwrap_or(36.0));
let longest = text.split('\n').map(|l| l.chars().count()).max().unwrap_or(1) as f64;
let lines = text.split('\n').count() as f64;
let width = f64_param(p, "width").unwrap_or(longest * pt * 0.62 * 96.0 / 72.0 + 24.0).clamp(8.0, MAX_SIDE);
let height = f64_param(p, "height").unwrap_or(lines * pt * 1.35 * 96.0 / 72.0 + 12.0).clamp(8.0, MAX_SIDE);
shape.anchor.width = width as f32;
shape.anchor.height = height as f32;
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"shape": id}))
})
}
fn set_word_art(s: &mut Session, p: &Json) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("wordArt.set", "`id` must be a WordArt id"))?;
let style = style_param("wordArt.set", p)?;
let text = text_param("wordArt.set", p)?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
let current = sh.shapes.iter().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
if current.kind != ShapeKind::WordArt {
return Err(bad("wordArt.set", "the shape is not WordArt"));
}
if sh.is_protected() {
return Err(EngineError::Other(PROTECTED.into()));
}
let mut updated = current.clone();
apply("wordArt.set", &mut updated, style, p)?;
if let Some(t) = text {
updated.text = t;
}
if &updated == current {
return ok();
}
edit(s, |cx| {
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad("wordArt.set", "no such WordArt"))?;
*shape = updated;
Ok(Json::Null)
})
}
+141 -17
View File
@@ -43,6 +43,10 @@ impl Selection {
pub fn is_single_cell(&self) -> bool {
self.ranges.len() == 1 && self.current().is_single()
}
/// Whether the selection is exactly one merged cell, which Enter and Tab treat as one cell.
pub fn is_one_merged_cell(&self, sheet: &Sheet) -> bool {
self.ranges.len() == 1 && sheet.merge_at(self.active).is_some_and(|m| m == self.current())
}
/// The corner of the current area opposite the anchor: the end that Shift+arrows move.
pub fn moving_end(&self) -> CellRef {
let r = self.current();
@@ -67,23 +71,28 @@ impl Selection {
/// they are: clicking column B's header selects just B even when B1:C1 is merged, as in Excel.
pub fn expand_merges(&mut self, sheet: &Sheet) {
for r in self.ranges.iter_mut() {
if r.is_full_cols() || r.is_full_rows() {
continue;
}
let mut cur = *r;
loop {
let mut grown = cur;
for m in &sheet.merges {
if m.intersects(&grown) {
grown = grown.union(m);
}
}
if grown == cur {
break;
}
cur = grown;
}
*r = cur;
*r = with_merges(sheet, *r);
}
}
/// Shift+arrows: moves the edge of the current area that the anchor doesn't hold by `dr`
/// rows and `dc` columns, keeping merged cells whole, as Excel does. The anchor is the whole
/// merged cell it is in, so from a selected merged cell Shift+Left and Shift+Up widen the
/// selection past its left and top edges. A step that would shrink the area onto the inside
/// of a merged cell (where keeping it whole would undo the step) goes on to the next line
/// that does shrink it, and when the area can't shrink any more it grows in that direction.
pub fn extend_by(&mut self, sheet: &Sheet, dr: i64, dc: i64) {
let anchor = sheet.merge_at(self.anchor).unwrap_or(RangeRef::cell(self.anchor));
let mut r = self.current();
if dc != 0 {
r = step_edge(sheet, r, anchor, dc, true);
}
if dr != 0 {
r = step_edge(sheet, r, anchor, dr, false);
}
if let Some(last) = self.ranges.last_mut() {
*last = r;
} else {
self.ranges.push(r);
}
}
/// Moves the active cell within the selection (Enter/Tab inside a multi-cell selection).
@@ -128,6 +137,91 @@ impl Selection {
}
}
/// `r` grown until every merged cell it touches is inside it. Whole columns and whole rows stay
/// as they are.
fn with_merges(sheet: &Sheet, r: RangeRef) -> RangeRef {
if r.is_full_cols() || r.is_full_rows() {
return r;
}
let mut cur = r;
loop {
let mut grown = cur;
for m in &sheet.merges {
if m.intersects(&grown) {
grown = grown.union(m);
}
}
if grown == cur {
return cur;
}
cur = grown;
}
}
/// One Shift+arrow step of [`Selection::extend_by`] along one axis (`cols`: columns, else rows).
fn step_edge(sheet: &Sheet, r: RangeRef, anchor: RangeRef, d: i64, cols: bool) -> RangeRef {
let max = i64::from(if cols { MAX_COLS } else { MAX_ROWS }) - 1;
let hidden = |i: i64| if cols { sheet.is_col_hidden(i as u32) } else { sheet.is_row_hidden(i as u32) };
// `from` moved by `by`, then on past hidden lines in the same direction (not off the sheet).
let land = |from: i64, by: i64| {
let mut t = from.saturating_add(by).clamp(0, max);
// Bounded like the arrow keys' walk over hidden lines.
for _ in 0..100_000 {
if !hidden(t) {
break;
}
let n = (t + by.signum()).clamp(0, max);
if n == t {
break;
}
t = n;
}
t
};
let span = |x: &RangeRef| {
if cols { (i64::from(x.start.col), i64::from(x.end.col)) } else { (i64::from(x.start.row), i64::from(x.end.row)) }
};
let with = |lo: i64, hi: i64| {
let (lo, hi) = (lo.clamp(0, max) as u32, hi.clamp(0, max) as u32);
let (s, e) = if cols {
(CellRef::new(r.start.row, lo), CellRef::new(r.end.row, hi))
} else {
(CellRef::new(lo, r.start.col), CellRef::new(hi, r.end.col))
};
with_merges(sheet, RangeRef::new(s, e))
};
let (lo, hi) = span(&r);
let (alo, ahi) = span(&anchor);
// The anchor holds the low edge unless the area reaches past it on that side.
let moving_hi = lo >= alo;
let shrinking = if moving_hi { d < 0 && hi > ahi } else { d > 0 && lo < alo };
if shrinking {
let mut t = if moving_hi { hi } else { lo };
let mut by = d;
loop {
t = if moving_hi { land(t, by).max(ahi) } else { land(t, by).min(alo) };
let cand = if moving_hi { with(lo, t) } else { with(t, hi) };
if cand != r {
return cand;
}
if t == if moving_hi { ahi } else { alo } {
break;
}
// The step was undone because a merged cell crosses the new edge, and so is every
// edge up to where that merged cell starts (ends, for the low edge): go on from there
// so a tall or wide merged cell costs one step, not one per line it covers.
let across = sheet.merges.iter().filter(|m| m.intersects(&r)).filter_map(|m| {
let (ms, me) = span(m);
if moving_hi { (ms <= t && me > t).then_some(ms) } else { (ms < t && me >= t).then_some(me) }
});
t = if moving_hi { across.min().map_or(t, |ms| ms.min(t)) } else { across.max().map_or(t, |me| me.max(t)) };
by = d.signum();
}
}
// Grow the area on the side the arrow points to.
if d > 0 { with(lo, land(hi, d)) } else { with(land(lo, d), hi) }
}
impl Selection {
/// Ctrl+. (Next Corner of Selection): the active cell moves clockwise to the next corner of
/// the current area (top-left, top-right, bottom-right, bottom-left) and becomes the anchor,
@@ -287,6 +381,36 @@ mod tests {
assert_eq!(current_region(&s, CellRef::new(5, 0)).a1(), "A1:B20000");
}
#[test]
fn shift_arrows_from_a_merged_cell() {
let mut s = Sheet::new("S");
s.merges.push(RangeRef::parse("B2:C3").unwrap());
let at = |s: &Sheet, a1: &str| {
let mut sel = Selection::at(CellRef::parse(a1).unwrap());
sel.expand_merges(s);
sel
};
for ((dr, dc), want) in [((0, -1), "A2:C3"), ((-1, 0), "B1:C3"), ((0, 1), "B2:D3"), ((1, 0), "B2:C4")] {
let mut sel = at(&s, "B2");
sel.extend_by(&s, dr, dc);
assert_eq!(sel.a1(), want);
}
// Shrinking onto the inside of the merged cell goes on past it.
let mut sel = at(&s, "A1");
sel.extend_by(&s, 1, 1);
assert_eq!(sel.a1(), "A1:C3");
sel.extend_by(&s, 0, -1);
assert_eq!(sel.a1(), "A1:A3");
// A hidden column is stepped over, and the sheet edge stops the area.
s.cols.entry(3).or_default().hidden = true;
let mut sel = at(&s, "B2");
sel.extend_by(&s, 0, 1);
assert_eq!(sel.a1(), "B2:E3");
let mut sel = at(&s, "A1");
sel.extend_by(&s, -1, -1);
assert_eq!(sel.a1(), "A1");
}
#[test]
fn cycling() {
let mut sel = Selection::range(RangeRef::parse("A1:B2").unwrap());
+112
View File
@@ -532,6 +532,118 @@ fn shape_text_edit_survives_xlsx_roundtrip() {
assert_eq!(shape.text, text);
}
#[test]
fn word_art_insert_respects_sheet_protection() {
let mut s = s();
s.execute("review.protectSheet", json!({})).unwrap();
let before = s.doc().unwrap().wb.clone();
let undo_len = s.doc().unwrap().undo.len();
assert!(s.execute("insert.wordArt", json!({})).is_err());
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before));
assert_eq!(s.doc().unwrap().undo.len(), undo_len);
s.execute("review.unprotectSheet", json!({})).unwrap();
s.execute("insert.wordArt", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1);
}
#[test]
fn word_art_inserts_with_defaults_and_undoes() {
use gridcraft_model::ShapeKind;
let mut s = s();
// Empty params: default style and text, no dialog.
let r = s.execute("insert.wordArt", json!({})).unwrap();
assert!(s.take_ui_requests().is_empty());
let shape = s.doc().unwrap().wb.active().unwrap().shapes[0].clone();
assert_eq!(shape.id, r["shape"].as_u64().unwrap() as u32);
assert_eq!(shape.kind, ShapeKind::WordArt);
assert_eq!(shape.text, crate::cmd::wordart::DEFAULT_TEXT);
assert!(shape.anchor.width > 8.0 && shape.format.text_color.is_some());
// Named style plus overrides; restyle and edit the text through wordArt.set.
s.execute("insert.wordArt", json!({"style": "ember", "text": "Q3", "fontSize": 60, "shadow": false, "outline": "#112233", "outlineWidth": 3}))
.unwrap();
let ember = s.doc().unwrap().wb.active().unwrap().shapes[1].clone();
assert_eq!(ember.format.font_size, Some(60.0));
assert_eq!((ember.format.text_shadow, ember.format.text_outline.map(|o| o.1)), (None, Some(3.0)));
s.execute("wordArt.set", json!({"id": ember.id, "style": 3, "text": "Q4"})).unwrap();
let styled = s.doc().unwrap().wb.active().unwrap().shapes[1].clone();
assert_eq!((styled.text.as_str(), styled.format.italic), ("Q4", true));
s.execute("shape.setText", json!({"id": ember.id, "text": "Q5"})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1], ember);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes[1].text, "Q4");
// Moves and deletes like any floating shape.
s.execute("object.move", json!({"kind": "shape", "id": ember.id, "at": "D4"})).unwrap();
s.execute("object.delete", json!({"kind": "shape", "id": ember.id})).unwrap();
assert_eq!(s.doc().unwrap().wb.active().unwrap().shapes.len(), 1);
}
#[test]
fn word_art_rejects_hostile_params() {
let mut s = s();
let id = s.execute("insert.wordArt", json!({})).unwrap()["shape"].clone();
let rectangle = s.execute("insert.shape", json!({"kind": "rectangle"})).unwrap()["shape"].clone();
let before = s.doc().unwrap().wb.clone();
for (cmd, params) in [
("insert.wordArt", json!({"style": "No Such Style"})),
("insert.wordArt", json!({"style": 99})),
("insert.wordArt", json!({"style": -1})),
("insert.wordArt", json!({"text": 5})),
("insert.wordArt", json!({"text": ""})),
("insert.wordArt", json!({"text": "x".repeat(crate::cmd::wordart::MAX_CHARS + 1)})),
("insert.wordArt", json!({"fill": "not a colour"})),
("wordArt.set", json!({})),
("wordArt.set", json!({"id": 999})),
("wordArt.set", json!({"id": rectangle})),
("shape.setText", json!({"id": id, "text": ""})),
] {
assert!(matches!(s.execute(cmd, params), Err(crate::EngineError::BadParams { .. })), "{cmd}");
assert!(std::sync::Arc::ptr_eq(&s.doc().unwrap().wb, &before));
}
// Absurd sizes are clamped, not trusted.
s.execute("insert.wordArt", json!({"fontSize": 1e12, "width": 1e300, "height": -5})).unwrap();
let big = &s.doc().unwrap().wb.active().unwrap().shapes[2];
assert!(big.anchor.width <= 4000.0 && big.anchor.height >= 8.0 && big.format.font_size == Some(400.0));
}
#[test]
fn word_art_survives_xlsx_roundtrip() {
use gridcraft_model::ShapeKind;
let mut s = s();
s.execute("insert.wordArt", json!({"style": "Lagoon", "text": "Sales <2026>\nOutlook & more"})).unwrap();
let before = s.doc().unwrap().wb.active().unwrap().shapes[0].clone();
let saved = s.execute("file.saveBytes", json!({"format": "xlsx"})).unwrap();
s.execute("file.open", json!({"name": "wordart.xlsx", "base64": saved["base64"]})).unwrap();
let after = &s.doc().unwrap().wb.active().unwrap().shapes[0];
assert_eq!(after.kind, ShapeKind::WordArt);
assert_eq!(after.text, before.text);
assert_eq!(after.format.text_color, before.format.text_color);
assert_eq!(after.format.text_outline, before.format.text_outline);
assert_eq!(after.format.text_shadow, before.format.text_shadow);
assert_eq!((after.format.bold, after.format.italic, after.format.font_size), (true, true, before.format.font_size));
assert!(after.format.no_line);
}
/// White text with a green outline (Mint Outline) prints its outline, so it is not invisible on a
/// white page.
#[test]
fn word_art_outline_prints() {
let mut s = s();
s.execute("insert.wordArt", json!({"style": "Mint Outline", "text": "VISIBLE", "at": "A1"})).unwrap();
let pdf = s.execute("file.exportPdf", json!({"range": "A1:G10"})).unwrap();
let data = crate::io::base64_decode(pdf["base64"].as_str().unwrap()).unwrap();
let text = String::from_utf8_lossy(&data);
let outline = text.matches("0.18 0.608 0.416 rg").count();
assert!(outline >= 1, "the outline colour (2E9B6A) is drawn");
assert_eq!(text.matches("(VISIBLE) Tj").count(), 9, "eight outline copies under the white text");
assert!(text.contains("1 1 1 rg"), "the white fill is drawn on top");
// The last copy drawn is the filled text, in white; every outline copy comes before it.
let before_fill = &text[..text.rfind("(VISIBLE) Tj").unwrap()];
assert!(before_fill.rfind("1 1 1 rg") > before_fill.rfind("0.18 0.608 0.416 rg"), "the white fill is drawn last");
assert_eq!(before_fill.matches("(VISIBLE) Tj").count(), 8);
}
#[test]
fn no_border_clears_only_shared_edges_and_undo_restores_them() {
use gridcraft_core::RangeRef;
+1
View File
@@ -18,6 +18,7 @@ mod hidden_sheet_navigation;
mod ink_roundtrip;
mod line_count_overflow;
mod long_operator_chains;
mod merged_selection_keys;
mod offset_overflow;
mod percent_entry;
mod pivot_unknown_func;
@@ -0,0 +1,162 @@
//! Shift+arrows and Enter/Tab on merged cells, as Excel does them.
use gridcraft_engine::Session;
use serde_json::json;
/// A new workbook with B2:C3 merged and nothing selected but A1.
fn merged_book() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:C3"})).unwrap();
s.execute("home.mergeCells", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
s
}
fn shift(s: &mut Session, dr: i64, dc: i64) -> String {
s.execute("selection.move", json!({"dr": dr, "dc": dc, "extend": true})).unwrap();
s.doc().unwrap().selection.a1()
}
fn enter(s: &mut Session, forward: bool, by_row: bool) -> (String, String) {
s.execute("selection.next", json!({"forward": forward, "byRow": by_row})).unwrap();
let sel = &s.doc().unwrap().selection;
(sel.active.a1(), sel.a1())
}
#[test]
fn shift_arrows_from_a_merged_cell_widen_past_each_edge() {
let mut s = merged_book();
for ((dr, dc), want) in [((0, -1), "A2:C3"), ((-1, 0), "B1:C3"), ((0, 1), "B2:D3"), ((1, 0), "B2:C4")] {
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
assert_eq!(s.doc().unwrap().selection.a1(), "B2:C3");
assert_eq!(shift(&mut s, dr, dc), want, "Shift+({dr},{dc}) from the merged cell B2:C3");
assert_eq!(s.doc().unwrap().selection.active.a1(), "B2", "the active cell stays");
}
}
#[test]
fn shift_arrows_keep_going_and_come_back_to_the_merged_cell() {
let mut s = merged_book();
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
assert_eq!(shift(&mut s, 1, 0), "B2:C4");
assert_eq!(shift(&mut s, 1, 0), "B2:C5");
assert_eq!(shift(&mut s, 1, 0), "B2:C6");
assert_eq!(shift(&mut s, -1, 0), "B2:C5");
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
assert_eq!(shift(&mut s, 0, -1), "A2:C3");
assert_eq!(shift(&mut s, 0, 1), "B2:C3", "back to just the merged cell");
assert_eq!(shift(&mut s, 0, 1), "B2:D3");
assert_eq!(shift(&mut s, -1, 0), "B1:D3");
assert_eq!(shift(&mut s, 1, 0), "B2:D3");
}
#[test]
fn shift_arrows_shrink_past_a_merged_cell_they_pulled_in() {
let mut s = merged_book();
assert_eq!(shift(&mut s, 0, 1), "A1:B1");
assert_eq!(shift(&mut s, 1, 0), "A1:C3", "B2 pulls in the whole merged cell");
assert_eq!(shift(&mut s, 0, -1), "A1:A3", "one step back leaves the merged cell out");
assert_eq!(shift(&mut s, 0, 1), "A1:C3");
assert_eq!(shift(&mut s, -1, 0), "A1:C1");
assert_eq!(shift(&mut s, 1, 0), "A1:C3");
assert_eq!(shift(&mut s, 1, 0), "A1:C4");
}
#[test]
fn enter_and_tab_leave_a_selected_merged_cell() {
let mut s = merged_book();
for (forward, by_row, want) in [(true, false, "B4"), (false, false, "B1"), (true, true, "D2"), (false, true, "A2")] {
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
assert_eq!(enter(&mut s, forward, by_row), (want.to_string(), want.to_string()), "forward {forward} byRow {by_row}");
}
}
#[test]
fn enter_and_tab_out_of_a_merged_cell_step_over_hidden_lines() {
let mut s = merged_book();
s.execute("selection.set", json!({"range": "A4"})).unwrap();
s.execute("home.hideRows", json!({})).unwrap();
s.execute("selection.set", json!({"range": "D1"})).unwrap();
s.execute("home.hideColumns", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
assert_eq!(enter(&mut s, true, false).0, "B5", "row 4 is hidden");
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
assert_eq!(enter(&mut s, true, true).0, "E2", "column D is hidden");
s.execute("selection.set", json!({"cell": "B2"})).unwrap();
s.execute("selection.move", json!({"dr": 1})).unwrap();
assert_eq!(s.doc().unwrap().selection.a1(), "B5", "the Down arrow too");
}
#[test]
fn enter_inside_a_selection_visits_a_merged_cell_once() {
let mut s = merged_book();
s.execute("selection.set", json!({"range": "A1:C3", "active": "A1"})).unwrap();
let seen: Vec<String> = (0..6).map(|_| enter(&mut s, true, false).0).collect();
assert_eq!(seen, ["A2", "A3", "B1", "B2", "C1", "A1"]);
assert_eq!(s.doc().unwrap().selection.a1(), "A1:C3", "the selection stays");
let back: Vec<String> = (0..2).map(|_| enter(&mut s, false, false).0).collect();
assert_eq!(back, ["C1", "B2"]);
}
#[test]
fn shift_arrow_that_cannot_shrink_the_area_grows_it() {
// A2:B3 merged, anchor B1: Shift+Down pulls the area out to column A, past the anchor.
// Shift+Right can't shrink it from the left (the merged cell would come back), so it grows.
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "A2:B3"})).unwrap();
s.execute("home.mergeCells", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "B1"})).unwrap();
assert_eq!(shift(&mut s, 1, 0), "A1:B3");
assert_eq!(shift(&mut s, 0, 1), "A1:C3");
assert_eq!(s.doc().unwrap().selection.active.a1(), "B1");
}
#[test]
fn shift_left_shrinks_past_a_chain_of_merged_cells_in_one_step() {
// B2:C3 and D3:E4 are both inside A1:E4. Shift+Left from E can't stop in column D (D3:E4
// would come back) and stops in C, where B2:C3 is whole.
let mut s = merged_book();
s.execute("selection.set", json!({"range": "D3:E4"})).unwrap();
s.execute("home.mergeCells", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
assert_eq!(shift(&mut s, 3, 4), "A1:E4");
assert_eq!(shift(&mut s, 0, -1), "A1:C4");
assert_eq!(shift(&mut s, -1, 0), "A1:C3", "row 4 holds nothing merged once column D is out");
assert_eq!(shift(&mut s, 0, -1), "A1:A3", "C can't stop inside B2:C3");
}
#[test]
fn shift_up_over_a_tall_merged_cell_takes_one_step() {
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "B2:C500000"})).unwrap();
s.execute("home.mergeCells", json!({})).unwrap();
s.execute("selection.set", json!({"cell": "A1"})).unwrap();
assert_eq!(shift(&mut s, 0, 1), "A1:B1");
assert_eq!(shift(&mut s, 1, 0), "A1:C500000");
assert_eq!(shift(&mut s, -1, 0), "A1:C1", "row 1 is the last row above the merged cell");
assert_eq!(shift(&mut s, 1, 0), "A1:C500000");
assert_eq!(shift(&mut s, 0, -1), "A1:A500000");
}
#[test]
fn shift_arrows_change_only_the_last_area() {
let mut s = merged_book();
s.execute("selection.set", json!({"range": "E5:F6,B2"})).unwrap();
assert_eq!(s.doc().unwrap().selection.a1(), "E5:F6,B2:C3");
assert_eq!(shift(&mut s, 0, -1), "E5:F6,A2:C3");
}
#[test]
fn enter_skips_a_merged_cell_whose_start_is_outside_the_selection() {
// A whole column isn't widened to the merged cells it touches. B1 is covered by A1:B1, whose
// top-left cell is outside column B, so Enter skips it.
let mut s = Session::new();
s.new_workbook();
s.execute("selection.set", json!({"range": "A1:B1"})).unwrap();
s.execute("home.mergeCells", json!({})).unwrap();
s.execute("selection.set", json!({"range": "B:B", "active": "B2"})).unwrap();
assert_eq!(enter(&mut s, false, false).0, "B1048576", "B1 is skipped, going up wraps to the bottom");
}
+145 -92
View File
@@ -55,12 +55,12 @@ fn strict_num(v: &Value) -> R<f64> {
}
}
/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The normal equations are a
/// k×k matrix inverted in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated
/// Most predictor columns LINEST, LOGEST, TREND and GROWTH accept. The fit works on a k×k
/// triangle in O(k³), so an unbounded k (one `SEQUENCE(1,16384)` argument) allocated
/// gigabytes and could abort the process.
pub(super) const MAX_REGRESSORS: usize = 255;
/// Most multiply-adds (rows × k²) a regression may spend building its normal equations.
/// Most multiply-adds (rows × k²) a regression may spend on its factorisation.
const MAX_REGRESSION_WORK: usize = 1_000_000_000;
/// Rejects regressions too large to fit: more than [`MAX_REGRESSORS`] predictors is `#VALUE!`,
@@ -121,59 +121,136 @@ struct Fit {
ssresid: f64,
}
/// Inverts a symmetric positive semi-definite matrix by Gauss–Jordan on the diagonal, dropping
/// (zeroing) collinear variables. Returns the inverse and the dropped flags.
fn sym_inverse(m: &[Vec<f64>]) -> (Vec<Vec<f64>>, Vec<bool>) {
let p = m.len();
let mut a: Vec<Vec<f64>> = m.to_vec();
let mut inv: Vec<Vec<f64>> = (0..p).map(|i| (0..p).map(|j| if i == j { 1.0 } else { 0.0 }).collect()).collect();
let mut dropped = vec![false; p];
let diag: Vec<f64> = (0..p).map(|i| m.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0).abs()).collect();
for j in 0..p {
let piv = a.get(j).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
let scale = diag.get(j).copied().unwrap_or(0.0);
if piv.abs() <= 1e-12 * scale || piv.abs() < 1e-300 {
if let Some(d) = dropped.get_mut(j) {
*d = true;
}
for row in a.iter_mut() {
if let Some(v) = row.get_mut(j) {
*v = 0.0;
}
}
if let Some(r) = a.get_mut(j) {
r.iter_mut().for_each(|v| *v = 0.0);
}
if let Some(r) = inv.get_mut(j) {
r.iter_mut().for_each(|v| *v = 0.0);
}
/// Euclidean norm without intermediate overflow or underflow.
fn norm2(v: &[f64]) -> f64 {
let m = v.iter().fold(0.0f64, |m, x| m.max(x.abs()));
if m == 0.0 || !m.is_finite() {
return m;
}
m * v.iter().map(|x| (x / m) * (x / m)).sum::<f64>().sqrt()
}
/// Result of [`qr_solve`]: the least-squares solution on the kept columns, in the caller's
/// column scale, and what the standard errors need.
struct QrFit {
coef: Vec<f64>,
dropped: Vec<bool>,
/// Residual sum of squares of the fit.
ssresid: f64,
/// Diagonal of `(X'X)^-1` over the kept columns (0 for dropped ones).
diag: Vec<f64>,
/// `xm' (X'X)^-1 xm` over the kept columns.
quad: f64,
}
/// Least squares by Householder QR on unit-norm columns, instead of the normal equations, whose
/// condition number is the square of the data's. Columns are taken left to right; one whose part
/// outside the span of the kept columns is under 1e-6 of its own length (the 1e-12 relative
/// pivot of the earlier normal-equation solver, on squares) is collinear: it is dropped, with
/// coefficient and standard error 0, as Excel does.
///
/// `constant[j]` says column `j` was constant before centring. Centring it leaves rounding
/// residue, not 0 (`[0.1; 3]` centres to about -1.4e-17): it is collinear with the intercept, so
/// it is dropped instead of being scaled up to unit length. Whether a column is constant is
/// decided on its raw values, not by comparing the centred length with the offset: a predictor
/// like `[1e13, 1e13 + 1, 1e13 + 2]` is far smaller than its offset and still varies.
fn qr_solve(mut cols: Vec<Vec<f64>>, mut y: Vec<f64>, xm: &[f64], constant: &[bool]) -> QrFit {
let n = y.len();
let k = cols.len();
let mut scale = vec![1.0; k];
let mut dropped = vec![false; k];
// Householder vectors (unit norm, acting on rows `at..n`) and the columns of R.
let mut refl: Vec<(usize, Vec<f64>)> = Vec::new();
let mut r_cols: Vec<Vec<f64>> = Vec::new();
let mut kept: Vec<usize> = Vec::new();
for (j, col) in cols.iter_mut().enumerate() {
let len = norm2(col);
if constant.get(j).copied().unwrap_or(false) || !(len > 1e-300 && len.is_finite()) {
dropped[j] = true;
continue;
}
let arow: Vec<f64> = a.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default();
let irow: Vec<f64> = inv.get(j).map(|r| r.iter().map(|v| v / piv).collect()).unwrap_or_default();
for i in 0..p {
if i == j {
continue;
}
let f = a.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
if f == 0.0 {
continue;
}
if let Some(r) = a.get_mut(i) {
r.iter_mut().zip(&arow).for_each(|(v, w)| *v -= f * w);
}
if let Some(r) = inv.get_mut(i) {
r.iter_mut().zip(&irow).for_each(|(v, w)| *v -= f * w);
}
col.iter_mut().for_each(|v| *v /= len);
scale[j] = len;
for (at, v) in &refl {
apply_reflector(*at, v, col);
}
if let Some(r) = a.get_mut(j) {
*r = arow;
let at = refl.len();
let tail = col.get(at..).unwrap_or(&[]);
let alpha = norm2(tail);
if alpha * alpha <= 1e-12 {
dropped[j] = true;
continue;
}
if let Some(r) = inv.get_mut(j) {
*r = irow;
let sign = if tail.first().is_some_and(|t| *t < 0.0) { -1.0 } else { 1.0 };
let mut v: Vec<f64> = tail.to_vec();
if let Some(h) = v.first_mut() {
*h += sign * alpha;
}
let vn = norm2(&v);
v.iter_mut().for_each(|x| *x /= vn);
let mut rc: Vec<f64> = col.get(..at).unwrap_or(&[]).to_vec();
rc.push(-sign * alpha);
r_cols.push(rc);
refl.push((at, v));
kept.push(j);
}
for (at, v) in &refl {
apply_reflector(*at, v, &mut y);
}
let r = kept.len();
// Back substitution R c = (Q'y)[..r], then undo the column scaling.
let mut c = vec![0.0; r];
for i in (0..r).rev() {
let mut t = y.get(i).copied().unwrap_or(0.0);
for jj in i + 1..r {
t -= r_cols.get(jj).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0) * c.get(jj).copied().unwrap_or(0.0);
}
if let Some(ci) = c.get_mut(i) {
*ci = t / r_cols.get(i).and_then(|rc| rc.get(i)).copied().unwrap_or(f64::NAN);
}
}
(inv, dropped)
let ssresid = y.get(r..n).unwrap_or(&[]).iter().map(|v| v * v).sum::<f64>();
// R^-1 (upper triangular), row i of the inverse at rinv[i].
let rij = |i: usize, j: usize| r_cols.get(j).and_then(|rc| rc.get(i)).copied().unwrap_or(0.0);
let mut rinv = vec![vec![0.0; r]; r];
for t in 0..r {
for i in (0..=t).rev() {
let mut v = if i == t { 1.0 } else { 0.0 };
for jj in i + 1..=t {
v -= rij(i, jj) * rinv.get(jj).and_then(|row| row.get(t)).copied().unwrap_or(0.0);
}
if let Some(cell) = rinv.get_mut(i).and_then(|row| row.get_mut(t)) {
*cell = v / rij(i, i);
}
}
}
let mut coef = vec![0.0; k];
let mut diag = vec![0.0; k];
for (i, &j) in kept.iter().enumerate() {
let s = scale.get(j).copied().unwrap_or(1.0);
if let Some(v) = coef.get_mut(j) {
*v = c.get(i).copied().unwrap_or(0.0) / s;
}
let row = rinv.get(i);
if let Some(v) = diag.get_mut(j) {
*v = row.map_or(0.0, |rw| rw.iter().map(|x| x * x).sum::<f64>()) / (s * s);
}
}
// xm' (S R'R S)^-1 xm = |R^-T S^-1 xm|².
let w: Vec<f64> = kept.iter().map(|&j| xm.get(j).copied().unwrap_or(0.0) / scale.get(j).copied().unwrap_or(1.0)).collect();
let quad = (0..r)
.map(|t| {
(0..=t).map(|i| rinv.get(i).and_then(|row| row.get(t)).copied().unwrap_or(0.0) * w.get(i).copied().unwrap_or(0.0)).sum::<f64>().powi(2)
})
.sum::<f64>();
QrFit { coef, dropped, ssresid, diag, quad }
}
/// Applies the Householder reflection `I - 2vv'` on rows `at..` of `x`.
fn apply_reflector(at: usize, v: &[f64], x: &mut [f64]) {
let Some(tail) = x.get_mut(at..) else { return };
let dot: f64 = v.iter().zip(tail.iter()).map(|(a, b)| a * b).sum();
tail.iter_mut().zip(v).for_each(|(t, a)| *t -= 2.0 * dot * a);
}
fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
@@ -189,35 +266,22 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
} else {
(vec![0.0; k], 0.0)
};
// Normal equations on (centred) data.
let mut xtx = vec![vec![0.0; k]; k];
let mut xty = vec![0.0; k];
for (row, yv) in x.iter().zip(y) {
let d: Vec<f64> = (0..k).map(|j| row.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect();
let yd = yv - ym;
for (i, di) in d.iter().enumerate() {
if let Some(t) = xty.get_mut(i) {
*t += di * yd;
}
if let Some(r) = xtx.get_mut(i) {
for (j, dj) in d.iter().enumerate() {
if let Some(v) = r.get_mut(j) {
*v += di * dj;
}
}
}
}
}
let (inv, dropped) = sym_inverse(&xtx);
let coef: Vec<f64> = (0..k).map(|i| inv.get(i).map(|r| r.iter().zip(&xty).map(|(a, b)| a * b).sum()).unwrap_or(0.0)).collect();
// (Centred) predictor columns and response.
let cols: Vec<Vec<f64>> =
(0..k).map(|j| x.iter().map(|r| r.get(j).copied().unwrap_or(0.0) - xm.get(j).copied().unwrap_or(0.0)).collect()).collect();
let yc: Vec<f64> = y.iter().map(|v| v - ym).collect();
let sstot: f64 = if constant { yc.iter().map(|v| v * v).sum() } else { y.iter().map(|v| v * v).sum() };
// Only centring leaves rounding residue behind; without an intercept a constant column is data.
// Constant means every raw value is the same double: distinct values, however close, vary.
let constant_cols: Vec<bool> = (0..k)
.map(|j| {
let mut col = x.iter().map(|r| r.get(j).copied().unwrap_or(0.0));
let first = col.next();
constant && first.is_some_and(|f| col.all(|v| v == f))
})
.collect();
let QrFit { coef, dropped, ssresid, diag, quad } = qr_solve(cols, yc, &xm, &constant_cols);
let b = if constant { ym - coef.iter().zip(&xm).map(|(c, m)| c * m).sum::<f64>() } else { 0.0 };
let mut ssresid = 0.0;
let mut sstot = 0.0;
for (row, yv) in x.iter().zip(y) {
let pred = b + coef.iter().zip(row).map(|(c, v)| c * v).sum::<f64>();
ssresid += (yv - pred).powi(2);
sstot += if constant { (yv - ym).powi(2) } else { yv * yv };
}
let used = dropped.iter().filter(|d| !**d).count() + usize::from(constant);
let df = nf - used as f64;
let ssreg = sstot - ssresid;
@@ -230,20 +294,9 @@ fn regress(y: &[f64], x: &[Vec<f64>], k: usize, constant: bool) -> R<Fit> {
_ => Err(CellError::Num),
};
let s2 = sey2.unwrap_or(f64::NAN);
let se: Vec<f64> = (0..k)
.map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (inv.get(i).and_then(|r| r.get(i)).copied().unwrap_or(0.0) * s2).sqrt() })
.collect();
let se_b = if constant {
let mut q = 0.0;
for (i, mi) in xm.iter().enumerate() {
for (j, mj) in xm.iter().enumerate() {
q += mi * mj * inv.get(i).and_then(|r| r.get(j)).copied().unwrap_or(0.0);
}
}
Some((s2 * (1.0 / nf + q)).sqrt())
} else {
None
};
let se: Vec<f64> =
(0..k).map(|i| if dropped.get(i).copied().unwrap_or(true) { 0.0 } else { (diag.get(i).copied().unwrap_or(0.0) * s2).sqrt() }).collect();
let se_b = if constant { Some((s2 * (1.0 / nf + quad)).sqrt()) } else { None };
Ok(Fit { coef, b, se, se_b, r2, sey, f, df, ssreg, ssresid })
}
+179
View File
@@ -433,6 +433,185 @@ fn linest_family() {
is_err(ev("LINEST", vec![c(&[1.0, 2.0]), c(&[1.0, 2.0, 3.0])]), CellError::Ref);
}
/// LINEST on columns of predictors, `stats` on, as rows of numbers (`None` for an error cell).
fn linest_rows(y: &[f64], xs: &[Vec<f64>], constant: bool) -> Vec<Vec<Option<f64>>> {
let x = arr((0..y.len()).map(|i| xs.iter().map(|col| nv(col[i])).collect()).collect());
let r = rows_of(&ev("LINEST", vec![c(y), av(x), b(constant), b(true)]));
r.iter().map(|row| row.iter().map(Value::as_f64).collect()).collect()
}
fn rel_close(got: Option<f64>, want: f64, tol: f64, what: &str) {
let got = got.unwrap_or(f64::NAN);
assert!((got - want).abs() <= tol * want.abs().max(1e-300), "{what}: got {got:e}, want {want:e}, rel tol {tol:e}");
}
// B2: LINEST solved the normal equations, whose condition number is the square of the data's.
// Expected values below come from exact rational arithmetic: the normal equations solved with
// Python `fractions.Fraction` (no rounding at all), rounded once to f64 (script in the PR text).
#[test]
fn linest_ill_conditioned_polynomial_is_exact() {
// y = 1 + x + x² + x³ + x⁴ + x⁵ on x = 1..12: every coefficient is exactly 1. The normal
// equations were off by up to 2.6e-6.
let xs: Vec<f64> = (1..=12).map(f64::from).collect();
let ys: Vec<f64> = xs.iter().map(|x| (0..=5).map(|j| x.powi(j)).sum()).collect();
let cols: Vec<Vec<f64>> = (1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect();
let r = linest_rows(&ys, &cols, true);
for (i, v) in r[0].iter().enumerate() {
rel_close(*v, 1.0, 1e-9, &format!("coefficient {i}"));
}
}
#[test]
fn linest_large_offset_regressors_recover_the_line() {
// y = 3x + 7 exactly with x = 1e6 + i and regressors [x, x²]: the true fit has x² coefficient
// 0, x coefficient 3, intercept 7. The normal equations returned 2.99976 and 251.14.
let xs: Vec<f64> = (0..12).map(|i| 1_000_000.0 + f64::from(i)).collect();
let ys: Vec<f64> = xs.iter().map(|x| 3.0 * x + 7.0).collect();
let r = linest_rows(&ys, &[xs.clone(), xs.iter().map(|x| x * x).collect()], true);
assert!(r[0][0].unwrap().abs() < 1e-12, "x² coefficient {:?}", r[0][0]);
rel_close(r[0][1], 3.0, 1e-9, "x coefficient");
// The intercept subtracts 3e6 from 3e6 + 7 and carries the x² coefficient times 1e12, so
// it is good to about 1e-4 in absolute terms, not better.
assert!((r[0][2].unwrap() - 7.0).abs() < 1e-3, "intercept {:?}", r[0][2]);
// One regressor: exact.
let one = linest_rows(&ys, std::slice::from_ref(&xs), true);
rel_close(one[0][0], 3.0, 1e-12, "slope");
rel_close(one[0][1], 7.0, 1e-9, "intercept");
}
#[test]
fn linest_noisy_fits_match_exact_rational_least_squares() {
// Two predictors with noise, constant fitted. Order of each row: x2, x1, intercept.
let x1 = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
let x2 = [2.0, 1.0, 4.0, 3.0, 7.0, 5.0, 9.0, 6.0];
let y = [5.0, 6.0, 11.0, 12.0, 20.0, 19.0, 30.0, 28.0];
let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], true);
let (coef, se) = ([1.4270152505446623, 2.452069716775599, -1.2592592592592593], [0.3376442444771937, 0.3679392851360571, 1.0847489958915801]);
for i in 0..3 {
rel_close(r[0][i], coef[i], 1e-12, "coef");
rel_close(r[1][i], se[i], 1e-12, "se");
}
rel_close(r[2][0], 0.985070139277778, 1e-12, "r2");
rel_close(r[2][1], 1.367057173604725, 1e-12, "sey");
rel_close(r[3][0], 164.94965318256004, 1e-11, "F");
assert_eq!(r[3][1], Some(5.0));
rel_close(r[4][0], 616.5307734204792, 1e-12, "ssreg");
rel_close(r[4][1], 9.344226579520697, 1e-11, "ssresid");
assert_eq!(r[2][2], None);
// const = FALSE: through the origin, R² and F from the uncentred sums.
let r = linest_rows(&y, &[x1.to_vec(), x2.to_vec()], false);
rel_close(r[0][0], 1.3529411764705883, 1e-12, "coef");
rel_close(r[0][1], 2.303921568627451, 1e-12, "coef");
assert_eq!(r[0][2], Some(0.0));
rel_close(r[1][0], 0.34102994374364876, 1e-12, "se");
rel_close(r[1][1], 0.35495521934550084, 1e-12, "se");
assert_eq!(r[1][2], None);
rel_close(r[2][0], 0.995718966041848, 1e-12, "r2");
rel_close(r[2][1], 1.4061024795535124, 1e-12, "sey");
rel_close(r[3][0], 697.7652892561983, 1e-11, "F");
assert_eq!(r[3][1], Some(6.0));
rel_close(r[4][0], 2759.1372549019607, 1e-12, "ssreg");
rel_close(r[4][1], 11.862745098039216, 1e-11, "ssresid");
// Cubic and quintic with noise on x = 1..12; order x^k .. x, intercept.
let xs: Vec<f64> = (1..=12).map(f64::from).collect();
let noise = [3.0, -2.0, 4.0, -1.0, 2.0, -3.0, 1.0, -4.0, 2.0, 3.0, -2.0, 1.0];
let cub: Vec<f64> = xs.iter().zip(noise).map(|(x, e)| 1.0 + x + x * x + x.powi(3) + e).collect();
let r = linest_rows(&cub, &(1..=3).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::<Vec<Vec<f64>>>(), true);
let coef = [0.9955969955969955, 1.1452991452991452, -0.33514633514633513, 4.373737373737374];
let se = [0.02755463331301788, 0.5434124199147191, 3.102549978874372, 4.851885058051787];
for i in 0..4 {
rel_close(r[0][i], coef[i], 1e-11, "cubic coef");
rel_close(r[1][i], se[i], 1e-10, "cubic se");
}
rel_close(r[2][0], 0.9999837292113533, 1e-12, "cubic r2");
rel_close(r[4][1], 70.35586635586635, 1e-9, "cubic ssresid");
let quint: Vec<f64> = xs.iter().zip(noise).map(|(x, e)| (0..=5).map(|j| x.powi(j)).sum::<f64>() + 50.0 * e).collect();
let r = linest_rows(&quint, &(1..=5).map(|j| xs.iter().map(|x| x.powi(j)).collect()).collect::<Vec<Vec<f64>>>(), true);
let coef = [0.9330693815987934, 3.022273070067188, -20.97398190045249, 108.20489681886741, -250.24940010969422, 271.45454545454544];
let se = [0.20046539191103213, 6.538253746916523, 78.39635356777087, 421.2608646012634, 976.3504976093745, 736.5866411764556];
for i in 0..6 {
rel_close(r[0][i], coef[i], 1e-8, "quintic coef");
rel_close(r[1][i], se[i], 1e-8, "quintic se");
}
rel_close(r[2][1], 168.58174051770763, 1e-9, "quintic sey");
rel_close(r[4][1], 170518.81941587824, 1e-7, "quintic ssresid");
}
#[test]
fn linest_drops_collinear_columns() {
// x2 = 2·x1: the later column is dropped (coefficient and standard error 0), the fit is the
// one on x1 alone, and the degrees of freedom count only the kept column.
let x1 = [1.0, 2.0, 3.0, 4.0, 5.0];
let y = [2.0, 4.0, 5.0, 4.0, 5.0];
let x2: Vec<f64> = x1.iter().map(|v| 2.0 * v).collect();
let r = linest_rows(&y, &[x1.to_vec(), x2], true);
// Exact: slope 0.6, intercept 2.2, ssresid 2.4, ssreg 3.6, df 3 (Fraction arithmetic).
assert_eq!(r[0][0], Some(0.0));
rel_close(r[0][1], 0.6, 1e-12, "slope");
rel_close(r[0][2], 2.2, 1e-12, "intercept");
assert_eq!(r[1][0], Some(0.0));
rel_close(r[1][1], (0.8f64 / 10.0).sqrt(), 1e-12, "se slope");
assert_eq!(r[3][1], Some(3.0));
rel_close(r[4][0], 3.6, 1e-12, "ssreg");
rel_close(r[4][1], 2.4, 1e-12, "ssresid");
// A constant column is collinear with the intercept and dropped as well.
let r = linest_rows(&y, &[vec![7.0; 5], x1.to_vec()], true);
assert_eq!(r[0][1], Some(0.0));
assert_eq!(r[1][1], Some(0.0));
rel_close(r[0][0], 0.6, 1e-12, "slope");
rel_close(r[0][2], 2.2, 1e-12, "intercept");
// A constant column whose mean does not centre to exactly 0 (the mean of three 0.1 is
// 0.10000000000000002) is just as collinear with the intercept: dropped, not scaled up to a
// unit vector. Some length hits a rounding mean for each constant.
for c in [0.1, 1.1, 1e6 + 0.3] {
for n in 3..=12 {
let xs: Vec<f64> = (1..=n).map(f64::from).collect();
let ys: Vec<f64> = (1..=n).map(|i| f64::from(i * i % 7) + 0.5 * f64::from(i)).collect();
let r = linest_rows(&ys, &[vec![c; n as usize], xs], true);
assert_eq!(r[0][1], Some(0.0), "coefficient of the constant {c}, n={n}");
assert_eq!(r[1][1], Some(0.0), "standard error of the constant {c}, n={n}");
assert_eq!(r[3][1], Some(f64::from(n) - 2.0), "df with the constant {c}, n={n}");
}
}
// A column that merely sits far from zero is not noise and is kept.
let off: Vec<f64> = x1.iter().map(|v| 1e9 + v).collect();
let r = linest_rows(&y, &[off], true);
rel_close(r[0][0], 0.6, 1e-6, "slope on an offset column");
assert_eq!(r[3][1], Some(3.0));
// Distinct values far from zero, whose spread is below 1e-12 of the offset, still vary.
let r = linest_rows(&[0.0, 1.0, 2.0], &[vec![1e13, 1e13 + 1.0, 1e13 + 2.0]], true);
rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e13 offset");
assert_eq!(r[3][1], Some(1.0), "df: the predictor is kept");
// Same at 1e15, where the spread is only a few ulps of the offset: still distinct doubles.
let x15 = [1e15, 1e15 + 1.0, 1e15 + 2.0];
let r = linest_rows(&[0.0, 1.0, 2.0], &[x15.to_vec()], true);
rel_close(r[0][0], 1.0, 1e-9, "slope on a predictor with a 1e15 offset");
rel_close(r[0][1], -1e15, 1e-9, "intercept on a predictor with a 1e15 offset");
assert_eq!(r[3][1], Some(1.0), "df: the 1e15 predictor is kept");
assert!(r[4][1].is_some_and(|v| v.abs() < 1e-9), "ssresid: the 1e15 line is exact, got {:?}", r[4][1]);
let fitted = rows_of(&ev("TREND", vec![c(&[0.0, 1.0, 2.0]), c(&x15)]));
close(fitted[2][0].clone(), 2.0);
// More predictors than observations: the surplus columns drop, no panic.
let r = linest_rows(&[1.0, 2.0], &[vec![1.0, 2.0], vec![2.0, 1.0], vec![0.0, 5.0]], true);
assert_eq!(r[0].len(), 4);
}
#[test]
fn logest_is_linest_of_ln_y() {
// y = 5·2^x: m = 2, b = 5.
let x = [0.0, 1.0, 2.0, 3.0, 4.0];
let y: Vec<f64> = x.iter().map(|v| 5.0 * 2f64.powf(*v)).collect();
let l = rows_of(&ev("LOGEST", vec![c(&y), c(&x)]));
close_tol(l[0][0].clone(), 2.0, 1e-12);
close_tol(l[0][1].clone(), 5.0, 1e-12);
// The statistics are those of the fit on ln y, untransformed.
let ln: Vec<f64> = y.iter().map(|v| v.ln()).collect();
let ls = rows_of(&ev("LOGEST", vec![c(&y), c(&x), b(true), b(true)]));
let ns = rows_of(&ev("LINEST", vec![c(&ln), c(&x), b(true), b(true)]));
assert_eq!(ls[1..], ns[1..]);
close_tol(ls[0][0].clone(), ns[0][0].as_f64().unwrap().exp(), 1e-14);
}
// #2177: one predictor row of 16,384 columns built three 16,384² matrices (6 GB) and an O(k³)
// inversion; a wider TOROW aborted the process. It is now refused before allocating.
#[test]
+5
View File
@@ -668,6 +668,9 @@ pub enum ShapeKind {
/// A freehand ink stroke; `Shape::text` holds the points as `x,y;x,y;…` relative to the
/// anchor (sheet points), `Shape::line` the ink colour and `fill` unused.
Ink,
/// Decorative text (`insert.wordArt`): a transparent shape whose `Shape::text` is drawn with
/// the large, filled/outlined/shadowed text properties of `Shape::format`.
WordArt,
/// An equation: `Shape::text` holds its LaTeX source, `Shape::line` its ink colour
/// (`Color::Auto` for the sheet's text colour) and `Shape::math` its cached layout.
Equation,
@@ -1026,6 +1029,8 @@ pub struct ShapeFormat {
pub text_color: Option<Color>,
/// Text outline (`a:rPr/a:ln`): colour and width in points.
pub text_outline: Option<(Color, f32)>,
/// Text drop shadow colour (`a:rPr/a:effectLst/a:outerShdw`); `None` is no shadow.
pub text_shadow: Option<Color>,
/// `None`: the shape's default (left for text boxes, centred for other shapes).
pub align: Option<ShapeTextAlign>,
/// `None`: the shape's default (top for text boxes, middle for other shapes).
+353 -134
View File
@@ -19,7 +19,10 @@ mod symbol;
pub use symbol::{symbol_code, symbol_width};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::fmt::Write as _;
use std::hash::{Hash, Hasher};
/// RGB colour, 0–255 per channel.
pub type Rgb = [u8; 3];
@@ -322,6 +325,31 @@ fn n(v: f32) -> String {
}
}
/// Appends `v / 255` with at most three decimals, exactly what `n(v as f32 / 255.0)` gives, but
/// without a float round trip or an allocation per channel.
fn push_unit(s: &mut String, v: u8) {
let m = (u32::from(v) * 1000 + 127) / 255;
match m {
0 => s.push('0'),
1000 => s.push('1'),
_ => {
let _ = write!(s, "0.{m:03}");
while s.ends_with('0') {
s.pop();
}
}
}
}
/// Appends a colour operator line without the trailing newline: `r g b rg` (fill) or `r g b RG`.
fn push_rgb(s: &mut String, c: Rgb, stroke: bool) {
for v in c {
push_unit(s, v);
s.push(' ');
}
s.push_str(if stroke { "RG" } else { "rg" });
}
/// True when every value is finite (NaN and infinities are rejected).
fn finite(v: &[f32]) -> bool {
v.iter().all(|x| x.is_finite())
@@ -348,16 +376,44 @@ fn dash_values(dash: &[f32]) -> String {
d.iter().map(|v| n(*v)).collect::<Vec<_>>().join(" ")
}
fn rgb_op(c: Rgb, stroke: bool) -> String {
let f = |v: u8| n(v as f32 / 255.0);
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
}
struct ImageData {
width: u32,
height: u32,
rgb: Vec<u8>,
/// `None` when every pixel is fully opaque (no soft mask is written).
alpha: Option<Vec<u8>>,
/// Hash of all of the above, to find duplicates quickly; equality still compares the bytes.
hash: u64,
}
impl ImageData {
fn new(width: u32, height: u32, rgb: Vec<u8>, alpha: Option<Vec<u8>>) -> ImageData {
let mut h = DefaultHasher::new();
width.hash(&mut h);
height.hash(&mut h);
rgb.hash(&mut h);
alpha.hash(&mut h);
ImageData { width, height, rgb, alpha, hash: h.finish() }
}
/// Same pixels (a hash match alone is never trusted).
fn same(&self, o: &ImageData) -> bool {
self.hash == o.hash && self.width == o.width && self.height == o.height && self.rgb == o.rgb && self.alpha == o.alpha
}
}
/// The part of the graphics state the writer tracks so that a style operator is only emitted
/// when it changes. `None` = not set yet on this page (or since the last restore that undid it).
#[derive(Clone, Debug, Default, PartialEq)]
struct Style {
fill: Option<Rgb>,
stroke: Option<Rgb>,
/// Formatted line width.
width: Option<String>,
/// Formatted dash array contents.
dash: Option<String>,
/// Line join; PDF's initial value is 0 (miter).
join: u8,
}
enum LinkTarget {
@@ -378,8 +434,18 @@ pub struct Page {
content: String,
fonts: [bool; FONT_SLOTS],
images: Vec<ImageData>,
/// Indexes into `images`, by content hash; a hash match is confirmed with [`ImageData::same`].
image_index: HashMap<u64, Vec<usize>>,
/// Pixel comparisons made while looking up duplicate pictures (test instrumentation).
#[cfg(test)]
image_comparisons: usize,
/// Style operators already in effect at the end of `content`.
style: Style,
/// One entry per accepted [`Page::save`], restored by [`Page::restore`].
saved: Vec<Style>,
/// `save` calls refused at the nesting limit; the same number of `restore` calls are ignored.
refused: u32,
links: Vec<LinkAnnotation>,
depth: u32,
}
/// Most bytes of content per page before further drawing is dropped (keeps hostile inputs from
@@ -387,6 +453,11 @@ pub struct Page {
const MAX_PAGE_CONTENT: usize = 64 * 1024 * 1024;
/// Largest image (pixels) embedded.
const MAX_IMAGE_PIXELS: u64 = 40_000_000;
/// Deepest `q` nesting a PDF consumer must support (the PDF specification's implementation limits).
const MAX_SAVE_DEPTH: usize = 28;
/// Line join styles, as the `j` operator takes them.
const JOIN_MITER: u8 = 0;
const JOIN_ROUND: u8 = 1;
const MAX_PAGE_LINKS: usize = 4096;
const MAX_LINK_URI_BYTES: usize = 4096;
@@ -410,6 +481,38 @@ impl Page {
}
}
/// Appends a fill colour operator to `s` unless that colour is already in effect.
fn set_fill(&mut self, s: &mut String, c: Rgb) {
if self.style.fill != Some(c) {
push_rgb(s, c, false);
s.push('\n');
self.style.fill = Some(c);
}
}
/// Appends the stroke colour, width, dash and join operators to `s` that differ from the
/// style already in effect.
fn set_stroke(&mut self, s: &mut String, c: Rgb, width: f32, dash: &str, join: u8) {
if self.style.stroke != Some(c) {
push_rgb(s, c, true);
s.push('\n');
self.style.stroke = Some(c);
}
let w = n(stroke_width(width));
if self.style.width.as_deref() != Some(w.as_str()) {
let _ = writeln!(s, "{w} w");
self.style.width = Some(w);
}
if self.style.dash.as_deref() != Some(dash) {
let _ = writeln!(s, "[{dash}] 0 d");
self.style.dash = Some(dash.to_string());
}
if self.style.join != join {
let _ = writeln!(s, "{join} j");
self.style.join = join;
}
}
fn link_rect(&self, rect: Rect) -> Option<[f32; 4]> {
let Rect { x, y, width, height } = rect;
if !finite(&[x, y, width, height]) || !extent(width) || !extent(height) || self.links.len() >= MAX_PAGE_LINKS {
@@ -447,15 +550,23 @@ impl Page {
}
}
/// Saves the graphics state (pair with [`Page::restore`]).
/// Saves the graphics state (pair with [`Page::restore`]). Nesting is limited to 28 levels, the
/// depth every PDF consumer must support; a `save` beyond that is ignored, and so is the
/// `restore` that would have matched it.
pub fn save(&mut self) {
self.depth = self.depth.saturating_add(1);
if self.saved.len() >= MAX_SAVE_DEPTH {
self.refused = self.refused.saturating_add(1);
return;
}
self.saved.push(self.style.clone());
self.content.push_str("q\n");
}
/// Restores the graphics state saved by [`Page::save`]; extra calls are ignored.
pub fn restore(&mut self) {
if self.depth > 0 {
self.depth -= 1;
if self.refused > 0 {
self.refused -= 1;
} else if let Some(style) = self.saved.pop() {
self.style = style;
self.content.push_str("Q\n");
}
}
@@ -466,18 +577,22 @@ impl Page {
}
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
if !(extent(w) && extent(h) && finite(&[x, y])) {
if !(extent(w) && extent(h) && finite(&[x, y])) || self.full() {
return;
}
let s = format!("{}\n{} {} {} {} re f", rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
let mut s = String::new();
self.set_fill(&mut s, color);
let _ = write!(s, "{} {} {} {} re f", n(x), n(self.y(y + h)), n(w), n(h));
self.op(&s);
}
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
if !finite(&[x, y, w, h]) {
if !finite(&[x, y, w, h]) || self.full() {
return;
}
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", rgb_op(color, true), n(stroke_width(width)), n(x), n(self.y(y + h)), n(w), n(h));
let mut s = String::new();
self.set_stroke(&mut s, color, width, "", JOIN_MITER);
let _ = write!(s, "{} {} {} {} re S", n(x), n(self.y(y + h)), n(w), n(h));
self.op(&s);
}
@@ -487,28 +602,22 @@ impl Page {
/// A line with a dash pattern (`[on, off, …]` in points; empty = solid).
pub fn stroke_line_dashed(&mut self, start: Point, end: Point, width: f32, color: Rgb, dash: &[f32]) {
if !finite(&[start.x, start.y, end.x, end.y]) {
if !finite(&[start.x, start.y, end.x, end.y]) || self.full() {
return;
}
let s = format!(
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
rgb_op(color, true),
n(stroke_width(width)),
dash_values(dash),
n(start.x),
n(self.y(start.y)),
n(end.x),
n(self.y(end.y))
);
let mut s = String::new();
self.set_stroke(&mut s, color, width, &dash_values(dash), JOIN_MITER);
let _ = write!(s, "{} {} m {} {} l S", n(start.x), n(self.y(start.y)), n(end.x), n(self.y(end.y)));
self.op(&s);
}
/// An open polyline.
/// An open polyline (round joins).
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) {
if pts.len() < 2 || !pts.iter().all(|p| finite(p)) || self.full() {
return;
}
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", rgb_op(color, true), n(stroke_width(width)), dash_values(dash));
let mut s = String::new();
self.set_stroke(&mut s, color, width, &dash_values(dash), JOIN_ROUND);
self.path(&mut s, pts, false);
s.push_str(" S");
self.op(&s);
@@ -523,18 +632,17 @@ impl Page {
}
}
/// A closed polygon, filled and/or stroked.
/// A closed polygon, filled and/or stroked (round joins).
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) {
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) || !pts.iter().all(|p| finite(p)) || self.full() {
return;
}
let mut s = String::new();
if let Some(f) = fill {
s.push_str(&rgb_op(f, false));
s.push('\n');
self.set_fill(&mut s, f);
}
if let Some((c, w)) = stroke {
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", rgb_op(c, true), n(stroke_width(w)));
self.set_stroke(&mut s, c, w, "", JOIN_ROUND);
}
self.path(&mut s, pts, true);
s.push_str(match (fill.is_some(), stroke.is_some()) {
@@ -547,7 +655,7 @@ impl Page {
/// A circle approximated by four cubic Béziers.
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) {
if !extent(r) || !finite(&[cx, cy]) || (fill.is_none() && stroke.is_none()) || self.full() {
return;
}
const K: f32 = 0.552_284_8;
@@ -555,11 +663,10 @@ impl Page {
let y = self.y(cy);
let mut s = String::new();
if let Some(f) = fill {
s.push_str(&rgb_op(f, false));
s.push('\n');
self.set_fill(&mut s, f);
}
if let Some((c, w)) = stroke {
let _ = writeln!(s, "{}\n{} w [] 0 d", rgb_op(c, true), n(stroke_width(w)));
self.set_stroke(&mut s, c, w, "", JOIN_MITER);
}
let _ = write!(
s,
@@ -614,19 +721,24 @@ impl Page {
let size = n(size.min(10_000.0));
let (sn, cs) = if angle.is_finite() { angle.sin_cos() } else { (0.0, 1.0) };
// Clockwise in y-down = counter-clockwise negated in PDF's y-up space.
let mut s = format!("BT\n{}\n{} {} {} {} {} {} Tm\n", rgb_op(color, false), n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y)));
let tm = format!("{} {} {} {} {} {} Tm\n", n(cs), n(-sn), n(sn), n(cs), n(origin.x), n(self.y(origin.y)));
// Symbol text is one run in Symbol's own encoding (the CID font has no maths symbols).
if font == Font::Symbol {
if let Some(f) = self.fonts.get_mut(fi) {
*f = true;
}
let _ = writeln!(s, "/F{} {} Tf\n{} Tj", fi + 1, size, pdf_string_in(text, true));
s.push_str("ET");
if self.full() {
return;
}
let mut s = String::new();
self.set_fill(&mut s, color);
let _ = write!(s, "BT\n{tm}/F{} {} Tf\n{} Tj\nET", fi + 1, size, pdf_string_in(text, true));
self.op(&s);
return;
}
// Runs of WinAnsi text use `font`; runs it cannot encode use the CID font. Each `Tj`
// advances the text position, so the runs follow each other on the baseline.
let mut runs = String::new();
let mut rest = text;
while let Some(first) = rest.chars().next() {
let cid = needs_cid(first);
@@ -636,10 +748,21 @@ impl Page {
if let Some(f) = self.fonts.get_mut(slot) {
*f = true;
}
let _ = writeln!(s, "/F{} {} Tf\n{} Tj", slot + 1, size, string);
let first_run = runs.is_empty();
let _ = writeln!(runs, "/F{} {} Tf", slot + 1, size);
if first_run {
runs.push_str(&tm);
}
let _ = writeln!(runs, "{string} Tj");
rest = tail;
}
s.push_str("ET");
if self.full() {
return;
}
// The colour goes in front of the text object, so it is tracked like any other style.
let mut colour = String::new();
self.set_fill(&mut colour, color);
let s = format!("{colour}BT\n{runs}ET");
self.op(&s);
}
@@ -652,16 +775,47 @@ impl Page {
if pw == 0 || ph == 0 || px > MAX_IMAGE_PIXELS || (rgba.len() as u64) < px * 4 || !(extent(w) && extent(h) && finite(&[x, y])) {
return;
}
// The image is drawn inside its own q/Q, which counts towards the nesting limit.
if self.saved.len() >= MAX_SAVE_DEPTH {
return;
}
let px = px as usize;
let mut rgb = Vec::with_capacity(px * 3);
let mut alpha = Vec::with_capacity(px);
for p in rgba.as_chunks::<4>().0.iter().take(px) {
// The alpha plane is only allocated once a non-opaque pixel shows up.
let mut alpha: Option<Vec<u8>> = None;
for (i, p) in rgba.as_chunks::<4>().0.iter().take(px).enumerate() {
rgb.extend_from_slice(&[p[0], p[1], p[2]]);
alpha.push(p[3]);
match &mut alpha {
Some(a) => a.push(p[3]),
None if p[3] != 255 => {
let mut a = Vec::with_capacity(px);
a.resize(i, 255);
a.push(p[3]);
alpha = Some(a);
}
None => {}
}
}
let alpha = if alpha.iter().all(|a| *a == 255) { None } else { Some(alpha) };
let idx = self.images.len();
self.images.push(ImageData { width: pw, height: ph, rgb, alpha });
let img = ImageData::new(pw, ph, rgb, alpha);
// The same picture drawn again on this page shares one XObject.
let candidates = self.image_index.entry(img.hash).or_default();
#[cfg(test)]
let comparisons = &mut self.image_comparisons;
let known = candidates.iter().copied().find(|&i| {
#[cfg(test)]
{
*comparisons += 1;
}
self.images.get(i).is_some_and(|o| o.same(&img))
});
let idx = match known {
Some(i) => i,
None => {
candidates.push(self.images.len());
self.images.push(img);
self.images.len() - 1
}
};
let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), idx + 1);
self.op(&s);
}
@@ -738,6 +892,49 @@ fn pdf_text_string(text: &str) -> String {
s
}
/// Starts object `id` at the current end of `out` and records its offset for the xref table.
fn begin_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32) {
let i = id as usize;
if offsets.len() <= i {
offsets.resize(i + 1, 0);
}
if let Some(slot) = offsets.get_mut(i) {
*slot = out.len();
}
let _ = writeln!(Utf8(out), "{id} 0 obj");
}
/// Writes `id 0 obj <body> endobj`.
fn dict_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32, body: &str) {
begin_obj(out, offsets, id);
out.extend_from_slice(body.as_bytes());
out.extend_from_slice(b"\nendobj\n");
}
/// Writes a stream object: `dict` (the dictionary entries without `<<`, `>>` and `/Length`, may be
/// empty) and `data` as the stream body.
fn stream_obj(out: &mut Vec<u8>, offsets: &mut Vec<usize>, id: u32, dict: &str, data: &[u8]) {
begin_obj(out, offsets, id);
out.extend_from_slice(b"<<");
if !dict.is_empty() {
out.push(b' ');
out.extend_from_slice(dict.as_bytes());
}
let _ = write!(Utf8(out), " /Length {} >>\nstream\n", data.len());
out.extend_from_slice(data);
out.extend_from_slice(b"\nendstream\nendobj\n");
}
/// Lets `write!` append text to a byte buffer.
struct Utf8<'a>(&'a mut Vec<u8>);
impl std::fmt::Write for Utf8<'_> {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.0.extend_from_slice(s.as_bytes());
Ok(())
}
}
impl PdfDoc {
pub fn new() -> PdfDoc {
PdfDoc::default()
@@ -747,7 +944,20 @@ impl PdfDoc {
pub fn add_page(&mut self, width_pt: f32, height_pt: f32) -> &mut Page {
let width = if width_pt.is_finite() { width_pt.clamp(3.0, 14_400.0) } else { 612.0 };
let height = if height_pt.is_finite() { height_pt.clamp(3.0, 14_400.0) } else { 792.0 };
self.pages.push(Page { width, height, content: String::new(), fonts: [false; FONT_SLOTS], images: Vec::new(), links: Vec::new(), depth: 0 });
self.pages.push(Page {
width,
height,
content: String::new(),
fonts: [false; FONT_SLOTS],
images: Vec::new(),
image_index: HashMap::new(),
#[cfg(test)]
image_comparisons: 0,
links: Vec::new(),
style: Style::default(),
saved: Vec::new(),
refused: 0,
});
let i = self.pages.len() - 1;
&mut self.pages[i]
}
@@ -762,10 +972,22 @@ impl PdfDoc {
}
/// Serializes the document. A document without pages gets one blank Letter page.
///
/// Every object is written straight into the output buffer (nothing is staged in an
/// intermediate list of bodies); only the xref table needs the offset of each object id, and
/// the ids are contiguous from 1. Objects are therefore in the file in the order they are
/// numbered during writing (fonts, then per page its new images, content stream and page
/// dictionary), with the catalog, page tree and info dictionary last.
pub fn finish(mut self) -> Vec<u8> {
if self.pages.is_empty() {
self.add_page(612.0, 792.0);
}
for page in &mut self.pages {
page.refused = 0;
while !page.saved.is_empty() {
page.restore();
}
}
// Object numbering: 1 catalog, 2 page tree, 3 info, then fonts, then per page.
let mut used = [false; FONT_SLOTS];
for p in &self.pages {
@@ -782,7 +1004,13 @@ impl PdfDoc {
next += if i == CID_FONT { 3 } else { 1 };
}
}
let mut objects: Vec<(u32, Vec<u8>)> = Vec::new();
// Page contents are moved out so each one is freed as soon as it is in the output.
let contents: Vec<String> = self.pages.iter_mut().map(|p| std::mem::take(&mut p.content)).collect();
let page_links: Vec<Vec<LinkAnnotation>> = self.pages.iter_mut().map(|p| std::mem::take(&mut p.links)).collect();
let reserve = contents.iter().fold(4096usize, |acc, c| acc.saturating_add(c.len()).saturating_add(1024));
let mut out: Vec<u8> = Vec::with_capacity(reserve);
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
let mut offsets: Vec<usize> = vec![0; 4];
let mut kids = Vec::new();
let mut annotations = Vec::new();
let page_count = self.pages.len();
@@ -792,86 +1020,87 @@ impl PdfDoc {
let _ = write!(font_dict, "/F{} {} 0 R ", i + 1, id);
if i == CID_FONT {
let (cid_id, desc_id) = (id + 1, id + 2);
objects.push((
dict_obj(
&mut out,
&mut offsets,
*id,
format!(
&format!(
"<< /Type /Font /Subtype /Type0 /BaseFont /{CID_BASE_FONT} /Encoding /UniGB-UTF16-H /DescendantFonts [{cid_id} 0 R] >>"
)
.into_bytes(),
));
objects.push((
),
);
dict_obj(
&mut out,
&mut offsets,
cid_id,
format!(
&format!(
"<< /Type /Font /Subtype /CIDFontType0 /BaseFont /{CID_BASE_FONT} /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /FontDescriptor {desc_id} 0 R /DW {CID_WIDTH} >>"
)
.into_bytes(),
));
objects.push((
),
);
dict_obj(
&mut out,
&mut offsets,
desc_id,
format!(
&format!(
"<< /Type /FontDescriptor /FontName /{CID_BASE_FONT} /Flags 6 /FontBBox [-25 -254 1000 880] /ItalicAngle 0 /Ascent 880 /Descent -120 /CapHeight 880 /StemV 93 >>"
)
.into_bytes(),
));
),
);
continue;
}
let name = Font::ALL.get(i).copied().unwrap_or_default().base_name();
let enc = if name.starts_with("Symbol") { "" } else { " /Encoding /WinAnsiEncoding" };
objects.push((*id, format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>").into_bytes()));
dict_obj(&mut out, &mut offsets, *id, &format!("<< /Type /Font /Subtype /Type1 /BaseFont /{name}{enc} >>"));
}
}
for page in &mut self.pages {
while page.depth > 0 {
page.restore();
}
// Images already written, by content hash: (page, image, object id). A hash match is
// confirmed by comparing the pixels before an object is shared.
let mut written: HashMap<u64, Vec<(usize, usize, u32)>> = HashMap::new();
for (pi, ((page, content), links)) in self.pages.iter().zip(contents).zip(page_links).enumerate() {
let mut xobjects = String::new();
for (k, img) in page.images.iter().enumerate() {
let img_id = next;
next += 1;
let smask = match &img.alpha {
Some(a) => {
let id = next;
let known = written.get(&img.hash).and_then(|c| {
c.iter().find_map(|(p, i, id)| {
let other = self.pages.get(*p)?.images.get(*i)?;
img.same(other).then_some(*id)
})
});
let img_id = match known {
Some(id) => id,
None => {
let img_id = next;
next += 1;
let data = run_length(a);
let mut o = format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode /Length {} >>\nstream\n",
img.width,
img.height,
data.len()
)
.into_bytes();
o.extend_from_slice(&data);
o.extend_from_slice(b"\nendstream");
objects.push((id, o));
format!(" /SMask {id} 0 R")
let mut dict = format!("/Type /XObject /Subtype /Image /Width {} /Height {} /BitsPerComponent 8", img.width, img.height);
let smask_id = img.alpha.as_ref().map(|_| {
let id = next;
next += 1;
id
});
dict.push_str(" /ColorSpace /DeviceRGB");
if let Some(id) = smask_id {
let _ = write!(dict, " /SMask {id} 0 R");
}
dict.push_str(" /Filter /RunLengthDecode");
stream_obj(&mut out, &mut offsets, img_id, &dict, &run_length(&img.rgb));
if let (Some(id), Some(a)) = (smask_id, &img.alpha) {
let dict = format!(
"/Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /RunLengthDecode",
img.width, img.height
);
stream_obj(&mut out, &mut offsets, id, &dict, &run_length(a));
}
written.entry(img.hash).or_default().push((pi, k, img_id));
img_id
}
None => String::new(),
};
let data = run_length(&img.rgb);
let mut o = format!(
"<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace /DeviceRGB /BitsPerComponent 8{} /Filter /RunLengthDecode /Length {} >>\nstream\n",
img.width,
img.height,
smask,
data.len()
)
.into_bytes();
o.extend_from_slice(&data);
o.extend_from_slice(b"\nendstream");
objects.push((img_id, o));
let _ = write!(xobjects, "/Im{} {} 0 R ", k + 1, img_id);
}
let content_id = next;
let page_id = next + 1;
next += 2;
let body = std::mem::take(&mut page.content);
let mut o = format!("<< /Length {} >>\nstream\n", body.len()).into_bytes();
o.extend_from_slice(body.as_bytes());
o.extend_from_slice(b"endstream");
objects.push((content_id, o));
stream_obj(&mut out, &mut offsets, content_id, "", content.as_bytes());
drop(content);
let xo = if xobjects.is_empty() { String::new() } else { format!(" /XObject << {xobjects}>>") };
let mut link_refs = String::new();
for link in std::mem::take(&mut page.links) {
for link in links {
if matches!(&link.target, LinkTarget::Page { index, .. } if *index >= page_count) {
continue;
}
@@ -881,19 +1110,20 @@ impl PdfDoc {
annotations.push((id, link));
}
let annots = if link_refs.is_empty() { String::new() } else { format!(" /Annots [{link_refs}]") };
objects.push((
dict_obj(
&mut out,
&mut offsets,
page_id,
format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /ProcSet [/PDF /Text /ImageB /ImageC] /Font << {}>>{} >> /Contents {} 0 R{} >>",
&format!(
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {} {}] /Resources << /Font << {}>>{} >> /Contents {} 0 R{} >>",
n(page.width),
n(page.height),
font_dict,
xo,
content_id,
annots
)
.into_bytes(),
));
),
);
kids.push(page_id);
}
for (id, link) in annotations {
@@ -906,14 +1136,16 @@ impl PdfDoc {
format!("/Dest [{page_id} 0 R /XYZ {} {} null]", n(x), n(height - y))
}
};
objects.push((
dict_obj(
&mut out,
&mut offsets,
id,
format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)).into_bytes(),
));
&format!("<< /Type /Annot /Subtype /Link /Rect [{} {} {} {}] /Border [0 0 0] {action} >>", n(x0), n(y0), n(x1), n(y1)),
);
}
objects.push((1, b"<< /Type /Catalog /Pages 2 0 R >>".to_vec()));
dict_obj(&mut out, &mut offsets, 1, "<< /Type /Catalog /Pages 2 0 R >>");
let kids_s: Vec<String> = kids.iter().map(|k| format!("{k} 0 R")).collect();
objects.push((2, format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()).into_bytes()));
dict_obj(&mut out, &mut offsets, 2, &format!("<< /Type /Pages /Kids [{}] /Count {} >>", kids_s.join(" "), kids.len()));
let creator = if self.info.creator.is_empty() { "GridCraft" } else { &self.info.creator };
let mut info = format!("<< /Producer {} /Creator {}", pdf_text_string("GridCraft PDF"), pdf_text_string(creator));
if !self.info.title.is_empty() {
@@ -923,27 +1155,14 @@ impl PdfDoc {
let _ = write!(info, " /Author {}", pdf_text_string(&self.info.author));
}
info.push_str(" >>");
objects.push((3, info.into_bytes()));
objects.sort_by_key(|(id, _)| *id);
dict_obj(&mut out, &mut offsets, 3, &info);
let mut out: Vec<u8> = Vec::new();
out.extend_from_slice(b"%PDF-1.7\n%\xE2\xE3\xCF\xD3\n");
let mut offsets = vec![0usize; next as usize];
for (id, body) in &objects {
if let Some(slot) = offsets.get_mut(*id as usize) {
*slot = out.len();
}
out.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
out.extend_from_slice(body);
out.extend_from_slice(b"\nendobj\n");
}
let xref = out.len();
let mut x = format!("xref\n0 {}\n0000000000 65535 f \n", next);
let _ = write!(Utf8(&mut out), "xref\n0 {}\n0000000000 65535 f \n", next);
for off in offsets.iter().skip(1) {
let _ = writeln!(x, "{off:010} 00000 n ");
let _ = writeln!(Utf8(&mut out), "{off:010} 00000 n ");
}
let _ = write!(x, "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
out.extend_from_slice(x.as_bytes());
let _ = write!(Utf8(&mut out), "trailer\n<< /Size {} /Root 1 0 R /Info 3 0 R >>\nstartxref\n{}\n%%EOF\n", next, xref);
out
}
}
+574 -2
View File
@@ -2,6 +2,9 @@ use super::*;
use std::error::Error;
use std::io;
mod reference;
use reference::{OldPage, interpret};
fn find(hay: &[u8], needle: &[u8]) -> Option<usize> {
hay.windows(needle.len()).position(|w| w == needle)
}
@@ -189,6 +192,558 @@ fn hostile_inputs_never_panic() -> Result<(), Box<dyn Error>> {
Ok(())
}
// ---------------------------------------------------------------------------------------------
// Writer cleanup: equivalence with the previous content writer, structure, and one test per
// behaviour change.
// ---------------------------------------------------------------------------------------------
/// Offsets of the in-use objects, by id, from the xref table.
fn xref_offsets(pdf: &[u8]) -> Vec<usize> {
let sx = rfind(pdf, b"startxref\n").unwrap();
let rest = std::str::from_utf8(&pdf[sx + 10..]).unwrap();
let off: usize = rest.lines().next().unwrap().trim().parse().unwrap();
let table = std::str::from_utf8(&pdf[off..sx]).unwrap();
table.lines().skip(2).take_while(|l| l.len() == 18 || l.len() == 19).map(|e| e[..10].parse().unwrap()).collect()
}
struct Obj {
dict: String,
stream: Option<Vec<u8>>,
}
/// Parses every object of a document written by this crate, checking that each stream's
/// `/Length` matches its data and is followed by `endstream`/`endobj`.
fn parse_objects(pdf: &[u8]) -> Vec<Obj> {
let offsets = xref_offsets(pdf);
let mut objs = Vec::new();
for (id, off) in offsets.iter().enumerate() {
if id == 0 {
objs.push(Obj { dict: String::new(), stream: None });
continue;
}
let tail = &pdf[*off..];
let head = format!("{id} 0 obj\n");
assert!(tail.starts_with(head.as_bytes()), "object {id}");
let body = &tail[head.len()..];
let end = find(body, b"\nendobj\n").unwrap();
match find(body, b"\nstream\n").filter(|s| *s < end) {
Some(s) => {
let dict = String::from_utf8(body[..s].to_vec()).unwrap();
let l = dict.find("/Length ").unwrap() + 8;
let len: usize = dict[l..].split(' ').next().unwrap().parse().unwrap();
let data = &body[s + 8..];
assert!(data[len..].starts_with(b"\nendstream\nendobj\n"), "object {id}: /Length {len} is wrong");
objs.push(Obj { dict, stream: Some(data[..len].to_vec()) });
}
None => objs.push(Obj { dict: String::from_utf8(body[..end].to_vec()).unwrap(), stream: None }),
}
}
objs
}
fn ref_of(dict: &str, key: &str) -> Option<usize> {
let i = dict.find(key)? + key.len();
dict[i..].trim_start().split(' ').next()?.parse().ok()
}
struct ParsedPage {
media: String,
content: String,
/// `(Im name, object id)`.
xobjects: Vec<(String, usize)>,
}
fn parse_pages(pdf: &[u8]) -> (Vec<Obj>, Vec<ParsedPage>) {
let objs = parse_objects(pdf);
let mut pages = Vec::new();
for o in &objs {
if !o.dict.contains("/Type /Page ") {
continue;
}
let m = o.dict.find("/MediaBox [").unwrap() + 10;
let media = o.dict[m..m + o.dict[m..].find(']').unwrap() + 1].to_string();
let content_id = ref_of(&o.dict, "/Contents").unwrap();
let content = String::from_utf8(objs[content_id].stream.clone().unwrap()).unwrap();
let mut xobjects = Vec::new();
if let Some(x) = o.dict.find("/XObject << ") {
let rest = &o.dict[x + 11..];
let rest = &rest[..rest.find(">>").unwrap()];
let toks: Vec<&str> = rest.split_whitespace().collect();
for c in toks.chunks(4) {
xobjects.push((c[0].to_string(), c[1].parse().unwrap()));
}
}
pages.push(ParsedPage { media, content, xobjects });
}
(objs, pages)
}
fn rle_decode(d: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
let mut i = 0;
while i < d.len() {
let l = d[i];
i += 1;
if l == 128 {
break;
} else if l < 128 {
out.extend_from_slice(&d[i..i + l as usize + 1]);
i += l as usize + 1;
} else {
out.extend(std::iter::repeat_n(d[i], 257 - l as usize));
i += 1;
}
}
out
}
/// Decodes an image XObject: `(width, height, rgb, alpha)`.
fn decode_image(objs: &[Obj], id: usize) -> (u32, u32, Vec<u8>, Option<Vec<u8>>) {
let o = &objs[id];
let w = ref_of(&o.dict, "/Width").unwrap() as u32;
let h = ref_of(&o.dict, "/Height").unwrap() as u32;
let rgb = rle_decode(o.stream.as_ref().unwrap());
let alpha = ref_of(&o.dict, "/SMask").map(|m| rle_decode(objs[m].stream.as_ref().unwrap()));
(w, h, rgb, alpha)
}
/// Deterministic pseudo-random numbers for the corpus.
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
self.0 >> 33
}
fn below(&mut self, n: u64) -> u64 {
self.next() % n
}
fn f(&mut self) -> f32 {
self.below(600) as f32 + self.below(4) as f32 * 0.25
}
}
const PALETTE: [Rgb; 4] = [[0, 0, 0], [255, 0, 0], [12, 200, 99], [255, 255, 255]];
const WIDTHS: [f32; 3] = [0.5, 1.0, 2.5];
const DASHES: [&[f32]; 3] = [&[], &[3.0, 2.0], &[1.0]];
/// A picture used by the corpus: `(rgba, w, h)`.
fn pictures() -> Vec<(Vec<u8>, u32, u32)> {
let mut with_alpha = Vec::new();
for i in 0..16u8 {
with_alpha.extend_from_slice(&[i * 10, 0, 7, if i < 8 { 255 } else { 128 }]);
}
let opaque: Vec<u8> = (0..4 * 9).map(|i| if i % 4 == 3 { 255 } else { (i * 7) as u8 }).collect();
let mut last_px = vec![200u8; 4 * 6];
for px in last_px.chunks_mut(4) {
px[3] = 255;
}
last_px[4 * 5 + 3] = 3; // only the very last pixel is translucent
vec![(with_alpha, 4, 4), (opaque, 3, 3), (last_px, 3, 2)]
}
/// Applies a random operation to both writers.
fn random_op(rng: &mut Lcg, new: &mut Page, old: &mut OldPage, pics: &[(Vec<u8>, u32, u32)], depth: &mut usize) {
let color = PALETTE[rng.below(4) as usize];
let width = WIDTHS[rng.below(3) as usize];
let dash = DASHES[rng.below(3) as usize];
let (x, y, w, h) = (rng.f(), rng.f(), rng.f() / 4.0 + 1.0, rng.f() / 4.0 + 1.0);
let pts = [[x, y], [x + w, y], [x + w, y + h]];
let stroke = if rng.below(2) == 0 { Some((PALETTE[rng.below(4) as usize], WIDTHS[rng.below(3) as usize])) } else { None };
let fill = if rng.below(2) == 0 { Some(color) } else { None };
match rng.below(14) {
0 | 1 => {
new.fill_rect(x, y, w, h, color);
old.fill_rect(x, y, w, h, color);
}
2 => {
new.rect_stroke(x, y, w, h, width, color);
old.rect_stroke(x, y, w, h, width, color);
}
3 => {
new.stroke_line_dashed(Point::new(x, y), Point::new(x + w, y + h), width, color, dash);
old.stroke_line_dashed(Point::new(x, y), Point::new(x + w, y + h), width, color, dash);
}
4 => {
new.polyline(&pts, width, color, dash);
old.polyline(&pts, width, color, dash);
}
5 => {
new.polygon(&pts, fill, stroke);
old.polygon(&pts, fill, stroke);
}
6 => {
new.circle(x, y, w, fill, stroke);
old.circle(x, y, w, fill, stroke);
}
7 | 8 => {
let font = Font::ALL[rng.below(12) as usize];
let angle = if rng.below(3) == 0 { 0.5 } else { 0.0 };
new.text_rotated(Point::new(x, y), 10.0, font, color, "Hi (there)", angle);
old.text_rotated(Point::new(x, y), 10.0, font, color, "Hi (there)", angle);
}
9 => {
new.clip_rect(x, y, w, h);
old.clip_rect(x, y, w, h);
}
10 if *depth < 20 => {
new.save();
old.save();
*depth += 1;
}
11 if *depth > 0 => {
new.restore();
old.restore();
*depth -= 1;
}
12 => {
let (px, pw, ph) = &pics[rng.below(3) as usize];
new.image_rgba(Rect::new(x, y, w, h), px, [*pw, *ph]);
old.image(x, y, w, h);
}
_ => {
new.text(x, y, 9.0, Font::Helvetica, color, "Plain");
old.text_rotated(Point::new(x, y), 9.0, Font::Helvetica, color, "Plain", 0.0);
}
}
}
/// Builds the same random multi-page document with the new writer and the old content writer.
fn corpus_doc(seed: u64) -> (Vec<u8>, Vec<String>) {
let mut rng = Lcg(seed);
let pics = pictures();
let mut doc = PdfDoc::new();
let mut old_pages = Vec::new();
for _ in 0..(1 + rng.below(4)) {
let mut old = OldPage::new(792.0);
let mut depth = 0usize;
let page = doc.add_page(612.0, 792.0);
for _ in 0..120 {
random_op(&mut rng, page, &mut old, &pics, &mut depth);
}
while depth > 0 {
page.restore();
old.restore();
depth -= 1;
}
old_pages.push(old.content);
}
(doc.finish(), old_pages)
}
#[test]
fn new_writer_is_equivalent_to_the_old_one() -> Result<(), Box<dyn Error>> {
let mut style_new = 0;
let mut style_old = 0;
for seed in 1..=40u64 {
let (pdf, old_pages) = corpus_doc(seed);
check_xref(&pdf)?;
let (_, pages) = parse_pages(&pdf);
assert_eq!(pages.len(), old_pages.len());
for (p, old) in pages.iter().zip(&old_pages) {
assert_eq!(p.media, "[0 0 612 792]");
let new = interpret(&p.content);
let old = interpret(old);
assert!(new.balanced && old.balanced, "q/Q balanced (seed {seed})");
assert_eq!(new.paints.len(), old.paints.len(), "seed {seed}");
for (i, (a, b)) in new.paints.iter().zip(&old.paints).enumerate() {
// Round joins leaking from polylines into later rectangles is the old bug (B7),
// compared separately in `join_does_not_leak_from_polylines`.
let mut a = a.clone();
let mut b = b.clone();
a.join.clear();
b.join.clear();
assert_eq!(a, b, "seed {seed}, painting op {i}");
}
style_new += new.style_ops;
style_old += old.style_ops;
}
}
// Sanity: the corpus really contains style changes, and the new writer emits fewer.
assert!(style_old > 1000, "corpus too small: {style_old}");
assert!(style_new < style_old, "{style_new} vs {style_old}");
Ok(())
}
#[test]
fn structure_is_valid_for_all_documents() -> Result<(), Box<dyn Error>> {
for seed in 1..=40u64 {
let (pdf, _) = corpus_doc(seed);
check_xref(&pdf)?;
parse_objects(&pdf); // every /Length matches its data
}
let empty = PdfDoc::new().finish();
check_xref(&empty)?;
parse_objects(&empty);
Ok(())
}
#[test]
fn images_decode_to_their_pixels() {
let pics = pictures();
let mut d = PdfDoc::new();
for _ in 0..2 {
let p = d.add_page(300.0, 300.0);
for (px, w, h) in &pics {
p.image_rgba(Rect::new(1.0, 1.0, 20.0, 20.0), px, [*w, *h]);
}
}
let pdf = d.finish();
let (objs, pages) = parse_pages(&pdf);
assert_eq!(pages.len(), 2);
for page in &pages {
assert_eq!(page.xobjects.len(), 3);
for ((px, w, h), (_, id)) in pics.iter().zip(&page.xobjects) {
let (dw, dh, rgb, alpha) = decode_image(&objs, *id);
assert_eq!((dw, dh), (*w, *h));
let want_rgb: Vec<u8> = px.chunks(4).flat_map(|c| [c[0], c[1], c[2]]).collect();
assert_eq!(rgb, want_rgb);
let want_alpha: Vec<u8> = px.chunks(4).map(|c| c[3]).collect();
match alpha {
Some(a) => assert_eq!(a, want_alpha),
None => assert!(want_alpha.iter().all(|a| *a == 255)),
}
}
}
}
/// O6: no alpha plane (and no SMask object) for an opaque image; a single translucent pixel
/// anywhere (first, middle, last) produces the full plane.
#[test]
fn opaque_images_have_no_soft_mask() {
let mut d = PdfDoc::new();
let p = d.add_page(100.0, 100.0);
p.image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &[255; 4 * 12], [4, 3]);
assert!(p.images[0].alpha.is_none());
let pdf = d.finish();
assert!(find(&pdf, b"/SMask").is_none());
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 1);
for translucent in [0usize, 5, 11] {
let mut px = vec![255u8; 4 * 12];
px[translucent * 4 + 3] = 7;
let mut d = PdfDoc::new();
d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 10.0, 10.0), &px, [4, 3]);
let (objs, pages) = parse_pages(&d.finish());
let (_, _, _, alpha) = decode_image(&objs, pages[0].xobjects[0].1);
let mut want = vec![255u8; 12];
want[translucent] = 7;
assert_eq!(alpha, Some(want));
}
}
/// O4: the same picture on five pages is stored once (image + mask), by every page.
#[test]
fn identical_images_are_stored_once() -> Result<(), Box<dyn Error>> {
let pics = pictures();
let (px, w, h) = &pics[0]; // has alpha: image object + mask object
let mut d = PdfDoc::new();
for _ in 0..5 {
let p = d.add_page(200.0, 200.0);
p.image_rgba(Rect::new(1.0, 1.0, 20.0, 20.0), px, [*w, *h]);
p.image_rgba(Rect::new(30.0, 1.0, 20.0, 20.0), px, [*w, *h]); // twice on one page
}
let pdf = d.finish();
check_xref(&pdf)?;
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 2, "one image + one soft mask");
let (_, pages) = parse_pages(&pdf);
let ids: Vec<usize> = pages.iter().flat_map(|p| p.xobjects.iter().map(|x| x.1)).collect();
assert_eq!(ids.len(), 5, "one XObject entry per page");
assert!(ids.iter().all(|i| *i == ids[0]));
for p in &pages {
assert_eq!(p.content.matches(" Do").count(), 2);
}
Ok(())
}
/// O4: a picture that differs in one byte, or only in size, is not merged.
#[test]
fn different_images_are_not_merged() {
let a = vec![50u8; 4 * 4];
let mut b = a.clone();
b[5] = 51;
let mut d = PdfDoc::new();
let p = d.add_page(100.0, 100.0);
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [4, 1]);
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &b, [4, 1]);
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [2, 2]);
d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &a, [4, 1]);
let pdf = d.finish();
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), 6, "three distinct pictures, each with a soft mask (alpha 50)");
}
/// O4: many distinct pictures on one page are looked up by hash, not by comparing each new
/// picture with every earlier one (which made dense picture exports quadratic).
#[test]
fn many_distinct_images_on_one_page_are_not_compared_pairwise() {
const N: u32 = 3000;
let mut d = PdfDoc::new();
let p = d.add_page(612.0, 792.0);
for i in 0..N {
let [a, b, c, _] = i.to_le_bytes();
p.image_rgba(Rect::new(0.0, 0.0, 1.0, 1.0), &[a, b, c, 255], [1, 1]);
}
// A repeat of the first picture still shares its XObject.
p.image_rgba(Rect::new(5.0, 5.0, 1.0, 1.0), &[0, 0, 0, 255], [1, 1]);
assert_eq!(p.images.len(), N as usize);
assert!(p.content.ends_with("/Im1 Do Q\n"), "the repeated picture reuses /Im1");
assert!(p.image_comparisons <= 1, "{} pixel comparisons for {N} distinct pictures", p.image_comparisons);
let pdf = d.finish();
assert_eq!(pdf.windows(15).filter(|w| *w == b"/Subtype /Image").count(), N as usize);
}
/// O4: equal hashes alone never merge images; the pixels are compared.
#[test]
fn hash_collisions_do_not_merge_images() {
let mut d = PdfDoc::new();
for v in [10u8, 20] {
d.add_page(100.0, 100.0).image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &[v; 16], [2, 2]);
}
for p in 0..2 {
d.page_mut(p).unwrap().images[0].hash = 42; // forced collision
}
let (objs, pages) = parse_pages(&d.finish());
let ids: Vec<usize> = pages.iter().map(|p| p.xobjects[0].1).collect();
assert_ne!(ids[0], ids[1]);
assert_eq!(decode_image(&objs, ids[0]).2, vec![10; 12]);
assert_eq!(decode_image(&objs, ids[1]).2, vec![20; 12]);
}
/// S5: the colour component table equals the old float formatting for every byte.
#[test]
fn colour_components_match_the_float_formatting() {
for v in 0..=255u8 {
let mut s = String::new();
push_unit(&mut s, v);
assert_eq!(s, n(v as f32 / 255.0), "byte {v}");
}
let mut s = String::new();
push_rgb(&mut s, [255, 128, 0], true);
assert_eq!(s, "1 0.502 0 RG");
}
/// S3: the obsolete /ProcSet entry is gone.
#[test]
fn no_procset() {
let (pdf, _) = corpus_doc(3);
assert!(find(&pdf, b"/ProcSet").is_none());
}
/// O3: a style that is already in effect is not emitted again.
#[test]
fn unchanged_style_is_emitted_once() {
let mut d = PdfDoc::new();
let p = d.add_page(200.0, 200.0);
for i in 0..50 {
p.rect_stroke(i as f32, 0.0, 5.0, 5.0, 1.5, [10, 20, 30]);
}
for i in 0..50 {
p.stroke_line_dashed(Point::new(0.0, i as f32), Point::new(9.0, 9.0), 1.5, [10, 20, 30], &[2.0, 1.0]);
}
for _ in 0..50 {
p.fill_rect(0.0, 0.0, 4.0, 4.0, [1, 2, 3]);
p.text(5.0, 5.0, 9.0, Font::Helvetica, [1, 2, 3], "x");
}
let s = interpret(&p.content);
assert_eq!(p.content.matches(" RG\n").count(), 1);
assert_eq!(p.content.matches(" rg\n").count(), 1);
assert_eq!(p.content.matches(" w\n").count(), 1);
assert_eq!(p.content.matches(" d\n").count(), 2, "solid, then [2 1]");
assert_eq!(s.style_ops, 5);
assert_eq!(s.paints.len(), 50 * 4);
}
/// O3: the tracked style follows q/Q, and a new page starts from scratch.
#[test]
fn style_tracking_follows_save_restore_and_pages() {
let red = [255, 0, 0];
let blue = [0, 0, 255];
let mut d = PdfDoc::new();
let p = d.add_page(100.0, 100.0);
p.fill_rect(0.0, 0.0, 5.0, 5.0, red);
p.save();
p.fill_rect(0.0, 0.0, 5.0, 5.0, red); // still red inside: nothing to emit
p.fill_rect(0.0, 0.0, 5.0, 5.0, blue);
p.restore();
p.fill_rect(0.0, 0.0, 5.0, 5.0, red); // red again after Q without a new operator
p.fill_rect(0.0, 0.0, 5.0, 5.0, blue); // but blue must be re-emitted
let fills: Vec<String> = interpret(&p.content).paints.iter().map(|p| p.fill.clone()).collect();
assert_eq!(fills, ["1 0 0", "1 0 0", "0 0 1", "1 0 0", "0 0 1"]);
assert_eq!(p.content.matches(" rg\n").count(), 3, "red once, blue twice");
let p2 = d.add_page(100.0, 100.0);
p2.fill_rect(0.0, 0.0, 5.0, 5.0, red);
assert_eq!(p2.content.matches(" rg\n").count(), 1, "a new page re-emits the colour");
}
/// B6: only 28 nested saves are written, the matching extra restores are no-ops.
#[test]
fn save_nesting_is_limited_to_28() -> Result<(), Box<dyn Error>> {
let mut d = PdfDoc::new();
let p = d.add_page(100.0, 100.0);
for _ in 0..40 {
p.save();
}
p.fill_rect(0.0, 0.0, 5.0, 5.0, [1, 1, 1]);
assert_eq!(p.content.matches("q\n").count(), 28);
// The 12 restores that match refused saves do nothing...
for _ in 0..12 {
p.restore();
}
assert_eq!(p.content.matches("Q\n").count(), 0);
assert_eq!(interpret(&p.content).max_depth, 28);
// ...the next 28 undo the accepted ones, and further restores are ignored.
for _ in 0..40 {
p.restore();
}
assert_eq!(p.content.matches("Q\n").count(), 28);
let i = interpret(&p.content);
assert!(i.balanced);
// An image needs its own q: dropped at the limit, drawn otherwise.
for _ in 0..28 {
p.save();
}
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &[9; 16], [2, 2]);
assert!(!p.content.contains(" Do"));
p.restore();
p.image_rgba(Rect::new(0.0, 0.0, 5.0, 5.0), &[9; 16], [2, 2]);
assert!(p.content.contains(" Do"));
let pdf = d.finish();
check_xref(&pdf)?;
Ok(())
}
/// B6: `finish()` closes what is still open, without exceeding the limit.
#[test]
fn finish_closes_open_saves() -> Result<(), Box<dyn Error>> {
let mut d = PdfDoc::new();
let p = d.add_page(100.0, 100.0);
for _ in 0..33 {
p.save();
}
let (_, pages) = parse_pages(&d.finish());
let i = interpret(&pages[0].content);
assert!(i.balanced);
assert_eq!(i.qs, 28);
Ok(())
}
/// B7: round joins set for a polyline do not leak into later rectangles, lines and circles.
#[test]
fn join_does_not_leak_from_polylines() {
let mut d = PdfDoc::new();
let p = d.add_page(200.0, 200.0);
let pts = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0]];
p.rect_stroke(0.0, 0.0, 5.0, 5.0, 1.0, [0, 0, 0]);
p.polyline(&pts, 1.0, [0, 0, 0], &[]);
p.polyline(&pts, 1.0, [0, 0, 0], &[]);
p.rect_stroke(0.0, 0.0, 5.0, 5.0, 1.0, [0, 0, 0]);
p.stroke_line(0.0, 0.0, 9.0, 9.0, 1.0, [0, 0, 0]);
p.polygon(&pts, None, Some(([0, 0, 0], 1.0)));
p.circle(50.0, 50.0, 5.0, None, Some(([0, 0, 0], 1.0)));
let joins: Vec<String> = interpret(&p.content).paints.iter().map(|p| p.join.clone()).collect();
assert_eq!(joins, ["0", "1", "1", "0", "0", "1", "0"]);
assert_eq!(p.content.matches(" j\n").count(), 4, "1, 0, 1, 0: only on change");
}
fn page_content(d: &PdfDoc) -> &str {
&d.pages[0].content
}
@@ -210,7 +765,10 @@ fn all_zero_dash_is_drawn_solid() {
p.polyline(&[[0.0, 0.0], [5.0, 5.0]], 1.0, [0, 0, 0], &[0.0, 0.0001, f32::NAN]);
p.stroke_line_dashed(Point::new(0.0, 0.0), Point::new(50.0, 0.0), 1.0, [0, 0, 0], &[3.0, 2.0]);
let c = page_content(&d);
assert_eq!(c.matches("[] 0 d").count(), 2);
// The writer emits style operators only on change: the two solid lines share one `[] 0 d`.
let dashes: Vec<String> = interpret(c).paints.iter().map(|p| p.dash.clone()).collect();
assert_eq!(dashes, ["", "", "3 2"]);
assert_eq!(c.matches("[] 0 d").count(), 1);
assert!(!c.contains("[0 0]"));
assert!(c.contains("[3 2] 0 d"));
}
@@ -224,7 +782,9 @@ fn negative_and_nan_stroke_widths_become_zero() {
p.polygon(&[[0.0, 0.0], [5.0, 0.0], [5.0, 5.0]], None, Some(([0, 0, 0], -1.0)));
p.circle(20.0, 20.0, 5.0, None, Some(([0, 0, 0], f32::NEG_INFINITY)));
let c = page_content(&d);
assert_eq!(c.matches("\n0 w ").count(), 4);
// Style operators are emitted only on change, so judge the width in effect for each stroke.
let widths: Vec<String> = interpret(c).paints.iter().map(|p| p.width.clone()).collect();
assert_eq!(widths, ["0", "0", "0", "0"]);
assert!(!c.contains("-1 w") && !c.contains("-3 w"));
}
@@ -333,6 +893,18 @@ fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box<dyn Error>> {
Ok(())
}
#[test]
fn many_alternating_runs_share_one_text_matrix() {
let mut doc = PdfDoc::new();
let p = doc.add_page(612.0, 792.0);
p.text(10.0, 20.0, 10.0, Font::Helvetica, [0, 0, 0], &"a\u{4e2d}".repeat(2000));
let pdf = doc.finish();
let count = |needle: &[u8]| pdf.windows(needle.len()).filter(|w| *w == needle).count();
assert_eq!(count(b" Tm\n"), 1, "the position is set once, before the first run");
assert_eq!(count(b" Tf\n"), 4000, "every run selects its font");
assert_eq!(count(b" Tj\n"), 4000);
}
#[test]
fn symbol_font_draws_maths_in_its_own_encoding() {
let mut doc = PdfDoc::new();
+351
View File
@@ -0,0 +1,351 @@
//! The content-stream emission of the writer before the cleanup (a verbatim copy of the old
//! per-operator formatting, which re-emitted the full style for every operator, had no `q` limit
//! and left `1 j` set after polylines), plus an interpreter that reduces a content stream to the
//! effective graphics state of every painting operator.
use super::super::*;
fn old_rgb_op(c: Rgb, stroke: bool) -> String {
let f = |v: u8| n(v as f32 / 255.0);
format!("{} {} {} {}", f(c[0]), f(c[1]), f(c[2]), if stroke { "RG" } else { "rg" })
}
/// The old content writer.
#[derive(Default)]
pub struct OldPage {
pub height: f32,
pub content: String,
depth: u32,
images: usize,
}
impl OldPage {
pub fn new(height: f32) -> OldPage {
OldPage { height, ..Default::default() }
}
fn y(&self, y: f32) -> f32 {
self.height - y
}
fn op(&mut self, s: &str) {
self.content.push_str(s);
self.content.push('\n');
}
pub fn save(&mut self) {
self.depth += 1;
self.content.push_str("q\n");
}
pub fn restore(&mut self) {
if self.depth > 0 {
self.depth -= 1;
self.content.push_str("Q\n");
}
}
pub fn clip_rect(&mut self, x: f32, y: f32, w: f32, h: f32) {
let s = format!("{} {} {} {} re W n", n(x), n(self.y(y + h)), n(w.max(0.0)), n(h.max(0.0)));
self.op(&s);
}
pub fn fill_rect(&mut self, x: f32, y: f32, w: f32, h: f32, color: Rgb) {
if w <= 0.0 || h <= 0.0 {
return;
}
let s = format!("{}\n{} {} {} {} re f", old_rgb_op(color, false), n(x), n(self.y(y + h)), n(w), n(h));
self.op(&s);
}
pub fn rect_stroke(&mut self, x: f32, y: f32, w: f32, h: f32, width: f32, color: Rgb) {
let s = format!("{}\n{} w [] 0 d\n{} {} {} {} re S", old_rgb_op(color, true), n(width), n(x), n(self.y(y + h)), n(w), n(h));
self.op(&s);
}
pub fn stroke_line_dashed(&mut self, start: Point, end: Point, width: f32, color: Rgb, dash: &[f32]) {
let (x0, y0, x1, y1) = (start.x, start.y, end.x, end.y);
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
let s = format!(
"{}\n{} w [{}] 0 d\n{} {} m {} {} l S",
old_rgb_op(color, true),
n(width),
d.join(" "),
n(x0),
n(self.y(y0)),
n(x1),
n(self.y(y1))
);
self.op(&s);
}
pub fn polyline(&mut self, pts: &[[f32; 2]], width: f32, color: Rgb, dash: &[f32]) {
if pts.len() < 2 {
return;
}
let d: Vec<String> = dash.iter().take(8).map(|v| n(v.max(0.0))).collect();
let mut s = format!("{}\n{} w [{}] 0 d 1 j\n", old_rgb_op(color, true), n(width), d.join(" "));
self.path(&mut s, pts, false);
s.push_str(" S");
self.op(&s);
}
fn path(&self, s: &mut String, pts: &[[f32; 2]], close: bool) {
for (i, p) in pts.iter().enumerate() {
let _ = write!(s, "{} {} {} ", n(p[0]), n(self.y(p[1])), if i == 0 { "m" } else { "l" });
}
if close {
s.push('h');
}
}
pub fn polygon(&mut self, pts: &[[f32; 2]], fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
if pts.len() < 2 || (fill.is_none() && stroke.is_none()) {
return;
}
let mut s = String::new();
if let Some(f) = fill {
s.push_str(&old_rgb_op(f, false));
s.push('\n');
}
if let Some((c, w)) = stroke {
let _ = writeln!(s, "{}\n{} w [] 0 d 1 j", old_rgb_op(c, true), n(w));
}
self.path(&mut s, pts, true);
s.push_str(match (fill.is_some(), stroke.is_some()) {
(true, true) => " B",
(true, false) => " f",
_ => " S",
});
self.op(&s);
}
pub fn circle(&mut self, cx: f32, cy: f32, r: f32, fill: Option<Rgb>, stroke: Option<(Rgb, f32)>) {
if r <= 0.0 || (fill.is_none() && stroke.is_none()) {
return;
}
let y = self.y(cy);
let mut s = String::new();
if let Some(f) = fill {
s.push_str(&old_rgb_op(f, false));
s.push('\n');
}
if let Some((c, w)) = stroke {
let _ = writeln!(s, "{}\n{} w [] 0 d", old_rgb_op(c, true), n(w));
}
const K: f32 = 0.552_284_8;
let k = r * K;
let _ = write!(
s,
"{} {} m {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c {} {} {} {} {} {} c h",
n(cx + r),
n(y),
n(cx + r),
n(y + k),
n(cx + k),
n(y + r),
n(cx),
n(y + r),
n(cx - k),
n(y + r),
n(cx - r),
n(y + k),
n(cx - r),
n(y),
n(cx - r),
n(y - k),
n(cx - k),
n(y - r),
n(cx),
n(y - r),
n(cx + k),
n(y - r),
n(cx + r),
n(y - k),
n(cx + r),
n(y)
);
s.push_str(match (fill.is_some(), stroke.is_some()) {
(true, true) => " B",
(true, false) => " f",
_ => " S",
});
self.op(&s);
}
pub fn text_rotated(&mut self, origin: Point, size: f32, font: Font, color: Rgb, text: &str, angle: f32) {
let (x, y) = (origin.x, origin.y);
let fi = font.index();
let (sn, cs) = angle.sin_cos();
let s = format!(
"BT\n{}\n/F{} {} Tf\n{} {} {} {} {} {} Tm\n{} Tj\nET",
old_rgb_op(color, false),
fi + 1,
n(size),
n(cs),
n(-sn),
n(sn),
n(cs),
n(x),
n(self.y(y)),
pdf_string(text)
);
self.op(&s);
}
pub fn image(&mut self, x: f32, y: f32, w: f32, h: f32) {
self.images += 1;
let s = format!("q {} 0 0 {} {} {} cm /Im{} Do Q", n(w), n(h), n(x), n(self.y(y + h)), self.images);
self.op(&s);
}
}
/// The effective state at one painting operator. Fields that cannot influence the operator
/// (the stroke state of a fill, the fill of a stroke) are left empty.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct Paint {
pub op: String,
/// Path construction / text operands since the previous painting operator.
pub geometry: String,
pub fill: String,
pub stroke: String,
pub width: String,
pub dash: String,
pub join: String,
/// Clip paths in effect (innermost last).
pub clip: Vec<String>,
}
#[derive(Default, Debug)]
pub struct Interp {
pub paints: Vec<Paint>,
pub max_depth: usize,
/// Number of `q` operators.
pub qs: usize,
/// Number of colour/width/dash/join operators.
pub style_ops: usize,
pub balanced: bool,
}
fn tokens(content: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut chars = content.chars();
while let Some(c) = chars.next() {
match c {
'(' => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
let mut s = String::from("(");
while let Some(d) = chars.next() {
s.push(d);
if d == '\\' {
if let Some(e) = chars.next() {
s.push(e);
}
} else if d == ')' {
break;
}
}
out.push(s);
}
'[' | ']' => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
out.push(c.to_string());
}
c if c.is_whitespace() => {
if !cur.is_empty() {
out.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if !cur.is_empty() {
out.push(cur);
}
out
}
#[derive(Clone)]
struct State {
fill: String,
stroke: String,
width: String,
dash: String,
join: String,
clip: Vec<String>,
}
/// Interprets a content stream: tracks q/Q, colours, width, dash, join and clips.
pub fn interpret(content: &str) -> Interp {
let mut st = State { fill: "0 0 0".into(), stroke: "0 0 0".into(), width: "1".into(), dash: String::new(), join: "0".into(), clip: Vec::new() };
let mut stack: Vec<State> = Vec::new();
let mut r = Interp { balanced: true, ..Default::default() };
let mut operands: Vec<String> = Vec::new();
let mut path: Vec<String> = Vec::new();
let mut pending_clip = false;
for t in tokens(content) {
let is_op = t.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '*') && !t.starts_with('/');
if !is_op {
operands.push(t);
continue;
}
let ops = std::mem::take(&mut operands);
match t.as_str() {
"q" => {
stack.push(st.clone());
r.qs += 1;
r.max_depth = r.max_depth.max(stack.len());
}
"Q" => match stack.pop() {
Some(s) => st = s,
None => r.balanced = false,
},
"rg" => {
st.fill = ops.join(" ");
r.style_ops += 1;
}
"RG" => {
st.stroke = ops.join(" ");
r.style_ops += 1;
}
"w" => {
st.width = ops.join(" ");
r.style_ops += 1;
}
"d" => {
// `[a b] 0` -> `a b`
st.dash = ops.iter().take_while(|o| *o != "]").skip(1).cloned().collect::<Vec<_>>().join(" ");
r.style_ops += 1;
}
"j" => {
st.join = ops.join(" ");
r.style_ops += 1;
}
"re" | "m" | "l" | "c" | "h" | "cm" | "Tf" | "Tm" => {
path.push(format!("{} {}", ops.join(" "), t));
}
"BT" | "ET" => {}
"W" => pending_clip = true,
"n" => {
if pending_clip {
st.clip.push(path.join(" "));
pending_clip = false;
}
path.clear();
}
"Tj" => {
path.push(format!("{} {}", ops.join(" "), t));
r.paints.push(Paint { op: t.clone(), geometry: path.join(" "), fill: st.fill.clone(), clip: st.clip.clone(), ..Default::default() });
path.clear();
}
"f" | "S" | "B" | "Do" => {
let mut p = Paint { op: t.clone(), geometry: path.join(" "), clip: st.clip.clone(), ..Default::default() };
if t == "f" || t == "B" {
p.fill = st.fill.clone();
}
if t == "S" || t == "B" {
p.stroke = st.stroke.clone();
p.width = st.width.clone();
p.dash = st.dash.clone();
p.join = st.join.clone();
}
r.paints.push(p);
path.clear();
}
_ => {}
}
}
r.balanced &= stack.is_empty();
r
}
+2
View File
@@ -71,6 +71,7 @@ impl SheetApp {
capture_geometry: None,
capture_geometry_from_raw: false,
selected_object: None,
chart_cache: Default::default(),
clipboard_text_at_copy: None,
cut_text: None,
tab_run: None,
@@ -114,6 +115,7 @@ impl SheetApp {
// Floating pictures' textures go with their workbook, even when no sheet is painted.
let open: Vec<u64> = self.session.documents().iter().map(|d| d.uid).collect();
floating_pictures::forget_closed(ctx, &open);
self.chart_cache.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner).retain_docs(open.iter().copied());
self.apply_font_state(ctx);
if let Some(answer) = self.desktop_theme.take_change() {
self.desktop_dark = Some(answer);
+63 -1
View File
@@ -409,7 +409,11 @@ impl SheetApp {
/// Enter/Tab move inside a multi-cell selection, otherwise to the next cell.
pub fn move_after_enter(&mut self, dr: i64, dc: i64) {
let multi = self.session.active().is_some_and(|d| !d.selection.is_single_cell());
// A selection that is just one merged cell moves like one cell (Enter leaves it).
let multi = self
.session
.active()
.is_some_and(|d| !d.selection.is_single_cell() && !d.wb.active().is_some_and(|sh| d.selection.is_one_merged_cell(sh)));
// Any other move (arrows, clicks, another sheet) leaves the selection off the cell the
// last Tab landed on, which ends the run.
let here = self.session.active().map(|d| (d.uid, d.wb.active_sheet(), d.selection.active));
@@ -508,3 +512,61 @@ mod protected_cell_tests {
assert!(app.run("file.saveBytes", json!({"format": "xlsx"})).is_ok());
}
}
#[cfg(test)]
mod merged_cell_enter_tests {
use super::*;
use gridcraft_engine::Session;
#[test]
fn enter_and_tab_leave_a_selected_merged_cell_and_keep_the_tab_run() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.run("selection.set", json!({"range": "B2:C3"})).unwrap();
app.run("home.mergeCells", json!({})).unwrap();
let sel = |app: &SheetApp| app.session.active().map(|d| d.selection.a1()).unwrap_or_default();
for ((dr, dc), want) in [((1, 0), "B4"), ((-1, 0), "B1"), ((0, 1), "D2"), ((0, -1), "A2")] {
app.run("selection.set", json!({"cell": "B2"})).unwrap();
assert_eq!(sel(&app), "B2:C3");
app.move_after_enter(dr, dc);
assert_eq!(sel(&app), want, "Enter/Tab ({dr},{dc}) from the merged cell B2:C3");
}
// A Tab run through the merged cell goes on past it: Enter then goes back to the column
// the run started in, on the next row.
app.tab_run = None; // the Shift+Tab above ended on A2 and started a run there
app.run("selection.set", json!({"cell": "A2"})).unwrap();
app.move_after_enter(0, 1);
assert_eq!(sel(&app), "B2:C3");
app.move_after_enter(0, 1);
assert_eq!(sel(&app), "D2");
app.move_after_enter(1, 0);
assert_eq!(sel(&app), "A3");
// Enter straight from the merged cell in a Tab run leaves it below its bottom edge and
// still goes back to the run's start column.
app.tab_run = None;
app.run("selection.set", json!({"cell": "A2"})).unwrap();
app.move_after_enter(0, 1);
assert_eq!(sel(&app), "B2:C3");
app.move_after_enter(1, 0);
assert_eq!(sel(&app), "A4");
}
#[test]
fn enter_from_a_merged_cell_in_a_tab_run_skips_a_hidden_row_and_keeps_the_column() {
let mut session = Session::new();
session.new_workbook();
let mut app = SheetApp::new(session, Default::default());
app.run("selection.set", json!({"range": "D2:E3"})).unwrap();
app.run("home.mergeCells", json!({})).unwrap();
app.run("selection.set", json!({"range": "A4"})).unwrap();
app.run("home.hideRows", json!({})).unwrap();
let sel = |app: &SheetApp| app.session.active().map(|d| d.selection.a1()).unwrap_or_default();
app.run("selection.set", json!({"cell": "C2"})).unwrap();
app.move_after_enter(0, 1);
assert_eq!(sel(&app), "D2:E3");
// Row 4 is hidden: Enter lands on C5, the run's column, not B5.
app.move_after_enter(1, 0);
assert_eq!(sel(&app), "C5");
}
}
+3
View File
@@ -65,6 +65,7 @@ impl SheetApp {
} else if self.fonts_rebind {
// A font-set change made last frame is active now.
self.fonts_rebind = false;
self.chart_cache.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
} else if !defer_font_change && !self.prepared_fonts && fonts::scan_done() {
self.rebind_workbook_fonts(ctx);
}
@@ -75,6 +76,8 @@ impl SheetApp {
fn start_font_setup(&mut self, ctx: &egui::Context) {
if self.fonts_set {
self.fonts_ready = true;
// The new set is active now: chart text was measured with the old one.
self.chart_cache.get_mut().unwrap_or_else(std::sync::PoisonError::into_inner).clear();
return;
}
if self.prepared_fonts {
+495
View File
@@ -0,0 +1,495 @@
//! Rendered charts kept between frames.
//!
//! Resolving a chart's series and laying it out is O(points). Doing it for every visible chart on
//! every frame kept the UI thread busy while scrolling a workbook with a few big charts (#1139,
//! part of #1098). The primitives a chart renders to depend on the workbook, the chart's place in
//! it, the zoom, the screen's pixels-per-point and the fonts text is measured with, so those are
//! what an entry is checked against.
//!
//! **Why "the workbook is unchanged" can be known without a change counter.** A document's
//! workbook is an `Arc<Workbook>` and its content is only reachable for writing through
//! `Arc::make_mut` (or by replacing the `Arc`), because the model has no interior mutability and
//! the code base forbids `unsafe`. While a `Weak` to the allocation exists, `make_mut` never
//! mutates it in place: it clones (other strong owners, e.g. an undo snapshot) or moves the value
//! to a new allocation (the `Weak` is cut loose). So if the document's current `Arc` still points
//! at the allocation the cache holds a `Weak` to, nothing was written since the entries were
//! built, however the write would have been made (command, undo, redo, recalculation, file load,
//! a direct edit from the UI, a future code path). The `Weak` also keeps the allocation from being
//! freed and reused, so a pointer match cannot be a stranger that landed on the same address.
use std::collections::HashMap;
use std::sync::{Arc, Weak};
use gridcraft_chart::Prim;
use gridcraft_engine::model::Workbook;
/// Most charts kept, over all documents.
const MAX_ENTRIES: usize = 64;
/// Most memory kept (estimated from the primitives), over all documents.
const MAX_BYTES: usize = 48 << 20;
struct Entry {
/// Zoom and pixels-per-point the primitives were measured for (`f32::to_bits`).
z: u32,
ppp: u32,
prims: Arc<[Prim]>,
bytes: usize,
/// Tick of the last use; the smallest is evicted first.
used: u64,
}
struct DocCharts {
/// The workbook the entries were built from.
wb: Weak<Workbook>,
/// (sheet index, index in the sheet's `charts`). The workbook is unchanged while the entries
/// live, so the pair names one chart.
charts: HashMap<(usize, usize), Entry>,
}
#[derive(Default)]
pub(crate) struct ChartCache {
docs: HashMap<u64, DocCharts>,
tick: u64,
bytes: usize,
/// Charts built (resolved and rendered) so far; for tests.
#[cfg(test)]
pub(crate) builds: usize,
}
fn prim_bytes(p: &Prim) -> usize {
std::mem::size_of::<Prim>()
+ match p {
Prim::Line { pts, .. } | Prim::Polygon { pts, .. } => pts.len().saturating_mul(std::mem::size_of::<[f32; 2]>()),
Prim::Text { text, .. } => text.len(),
_ => 0,
}
}
impl ChartCache {
/// The primitives of chart `index` of `sheet` in document `uid` whose workbook is `wb`, for
/// zoom and pixels per point `(z, ppp)`. `build` renders the chart when there is no valid entry.
pub(crate) fn get(
&mut self,
uid: u64,
wb: &Arc<Workbook>,
sheet: usize,
index: usize,
(z, ppp): (f32, f32),
build: impl FnOnce() -> Vec<Prim>,
) -> Arc<[Prim]> {
self.tick = self.tick.wrapping_add(1);
let tick = self.tick;
let doc = self.docs.entry(uid).or_insert_with(|| DocCharts { wb: Arc::downgrade(wb), charts: HashMap::new() });
if !std::ptr::eq(doc.wb.as_ptr(), Arc::as_ptr(wb)) {
// The document changed (or is another one with this uid): nothing built from the old
// workbook can be trusted.
let dropped: usize = doc.charts.values().map(|e| e.bytes).sum();
doc.charts.clear();
doc.wb = Arc::downgrade(wb);
self.bytes = self.bytes.saturating_sub(dropped);
}
let (zb, pb) = (z.to_bits(), ppp.to_bits());
if let Some(e) = doc.charts.get_mut(&(sheet, index))
&& e.z == zb
&& e.ppp == pb
{
e.used = tick;
return e.prims.clone();
}
#[cfg(test)]
{
self.builds += 1;
}
let prims: Arc<[Prim]> = build().into();
let bytes = prims.iter().map(prim_bytes).fold(0usize, usize::saturating_add);
let Some(doc) = self.docs.get_mut(&uid) else { return prims };
if let Some(old) = doc.charts.remove(&(sheet, index)) {
self.bytes = self.bytes.saturating_sub(old.bytes);
}
// A chart that alone would fill a quarter of the budget is not worth keeping.
if bytes <= MAX_BYTES / 4 {
doc.charts.insert((sheet, index), Entry { z: zb, ppp: pb, prims: prims.clone(), bytes, used: tick });
self.bytes = self.bytes.saturating_add(bytes);
self.evict();
}
prims
}
/// Drops least recently used entries until the caps hold.
fn evict(&mut self) {
loop {
let count: usize = self.docs.values().map(|d| d.charts.len()).sum();
if count <= MAX_ENTRIES && self.bytes <= MAX_BYTES {
return;
}
let oldest = self.docs.iter().flat_map(|(uid, d)| d.charts.iter().map(move |(k, e)| (e.used, *uid, *k))).min();
let Some((_, uid, key)) = oldest else { return };
if let Some(e) = self.docs.get_mut(&uid).and_then(|d| d.charts.remove(&key)) {
self.bytes = self.bytes.saturating_sub(e.bytes);
}
}
}
/// Forgets the documents that are not in `live` (closed workbooks).
pub(crate) fn retain_docs(&mut self, live: impl Iterator<Item = u64>) {
let live: Vec<u64> = live.collect();
let bytes = &mut self.bytes;
self.docs.retain(|uid, d| {
let keep = live.contains(uid);
if !keep {
*bytes = bytes.saturating_sub(d.charts.values().map(|e| e.bytes).sum());
}
keep
});
}
/// Forgets everything (the fonts text was measured with changed).
pub(crate) fn clear(&mut self) {
self.docs.clear();
self.bytes = 0;
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.docs.values().map(|d| d.charts.len()).sum()
}
#[cfg(test)]
pub(crate) fn docs(&self) -> usize {
self.docs.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::SheetApp;
use crate::chartview::tests::RENDERS;
use egui::{Event, vec2};
use gridcraft_chart::ApproxMeasure;
use gridcraft_engine::Session;
use serde_json::json;
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn wb() -> Arc<Workbook> {
Arc::new(Workbook::new())
}
fn rect_prim(w: f32) -> Vec<Prim> {
vec![Prim::Rect { x: 0.0, y: 0.0, w, h: 1.0, fill: None, stroke: None, radius: 0.0 }]
}
fn line_prim(points: usize) -> Vec<Prim> {
vec![Prim::Line { pts: vec![[0.0, 0.0]; points], color: [0, 0, 0, 255], width: 1.0, dash: false }]
}
#[test]
fn a_workbook_written_after_caching_is_never_taken_for_the_cached_one() {
let mut cache = ChartCache::default();
let mut w = wb();
let build = || rect_prim(1.0);
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
assert_eq!(cache.builds, 1, "same workbook: a hit");
// Sole owner of the Arc: without the Weak this write would happen in place, at the same address.
Arc::make_mut(&mut w).active_sheet.set(0);
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
assert_eq!(cache.builds, 2, "a write through make_mut moved the workbook to a new allocation");
// Shared with an undo snapshot: make_mut clones, the pointer changes as well.
let snapshot = w.clone();
Arc::make_mut(&mut w).active_sheet.set(0);
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
assert_eq!(cache.builds, 3);
// Replacing the Arc with an equal workbook (a reload) is a change too.
w = Arc::new((*snapshot).clone());
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
assert_eq!(cache.builds, 4);
// The old allocation is still held by the cache's Weak while its owner is gone, so a new
// workbook can't be mistaken for it by landing on the same address.
drop(snapshot);
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
assert_eq!(cache.builds, 4);
}
#[test]
fn zoom_scale_chart_and_document_are_part_of_the_key() {
let mut cache = ChartCache::default();
let w = wb();
let build = || rect_prim(1.0);
cache.get(1, &w, 0, 0, (1.0, 1.0), build);
cache.get(1, &w, 0, 0, (1.25, 1.0), build);
cache.get(1, &w, 0, 0, (1.25, 2.0), build);
cache.get(1, &w, 0, 1, (1.25, 2.0), build);
cache.get(1, &w, 1, 0, (1.25, 2.0), build);
cache.get(2, &w, 0, 0, (1.25, 2.0), build);
assert_eq!(cache.builds, 6);
cache.get(1, &w, 0, 1, (1.25, 2.0), build);
cache.get(2, &w, 0, 0, (1.25, 2.0), build);
assert_eq!(cache.builds, 6, "each of those is cached");
}
#[test]
fn the_cache_is_capped_in_entries_and_bytes_and_drops_the_least_recently_used() {
let mut cache = ChartCache::default();
let w = wb();
for i in 0..MAX_ENTRIES + 10 {
cache.get(1, &w, 0, i, (1.0, 1.0), || rect_prim(1.0));
// Chart 0 is used all the time, so it survives.
cache.get(1, &w, 0, 0, (1.0, 1.0), || rect_prim(1.0));
}
assert_eq!(cache.len(), MAX_ENTRIES);
let before = cache.builds;
cache.get(1, &w, 0, 0, (1.0, 1.0), || rect_prim(1.0));
assert_eq!(cache.builds, before, "the busy chart is still there");
cache.get(1, &w, 0, 1, (1.0, 1.0), || rect_prim(1.0));
assert_eq!(cache.builds, before + 1, "the idle one was evicted");
// Bytes: each of these is ~4 MiB of points; only a handful fit.
let mut cache = ChartCache::default();
let points = (4 << 20) / std::mem::size_of::<[f32; 2]>();
for i in 0..40 {
cache.get(1, &w, 0, i, (1.0, 1.0), || line_prim(points));
}
assert!(cache.bytes <= MAX_BYTES && cache.len() < 40, "{} entries, {} bytes", cache.len(), cache.bytes);
// One that would fill a quarter of the budget is not kept, and is still returned.
let mut cache = ChartCache::default();
let big = cache.get(1, &w, 0, 0, (1.0, 1.0), || line_prim(MAX_BYTES / 4 / std::mem::size_of::<[f32; 2]>() + 1000));
assert!(!big.is_empty());
assert_eq!((cache.len(), cache.bytes), (0, 0));
}
#[test]
fn closed_documents_are_forgotten() {
let mut cache = ChartCache::default();
let w = wb();
cache.get(1, &w, 0, 0, (1.0, 1.0), || line_prim(10));
cache.get(2, &w, 0, 0, (1.0, 1.0), || line_prim(10));
cache.retain_docs([2].into_iter());
assert_eq!((cache.docs(), cache.len()), (1, 1));
cache.retain_docs(std::iter::empty());
assert_eq!((cache.docs(), cache.len(), cache.bytes), (0, 0, 0));
}
/// A workbook with charts of several kinds over numbers, dates and categories.
fn corpus() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Date", "Sales", "Cost", "Share"]]})).unwrap();
for i in 0..40u32 {
let date = format!("2024-{:02}-{:02}", 1 + i / 28, 1 + i % 28);
s.execute("cell.set", json!({"cell": format!("A{}", i + 2), "input": date})).unwrap();
s.execute("cell.set", json!({"cell": format!("B{}", i + 2), "input": format!("{}", (i * 37) % 101)})).unwrap();
s.execute("cell.set", json!({"cell": format!("C{}", i + 2), "input": format!("=B{}/2+{}", i + 2, i)})).unwrap();
s.execute("cell.set", json!({"cell": format!("D{}", i + 2), "input": format!("{}", (i % 5) + 1)})).unwrap();
}
for (i, (kind, range)) in [
("column", "A1:C41"),
("line", "A1:C41"),
("scatter", "B1:C41"),
("pie", "A1:B6"),
("combo", "A1:C41"),
("area", "A1:B41"),
("bar", "A1:D41"),
("doughnut", "A1:B6"),
]
.into_iter()
.enumerate()
{
s.execute("insert.chart", json!({"range": range, "type": kind, "at": format!("H{}", 2 + i * 16), "width": 320, "height": 220})).unwrap();
}
s
}
#[test]
fn cached_output_equals_a_fresh_render_for_a_corpus_of_charts() {
let s = corpus();
let d = s.doc().unwrap();
let sh = d.wb.active().unwrap();
assert_eq!(sh.charts.len(), 8);
let mut cache = ChartCache::default();
for (i, ch) in sh.charts.iter().enumerate() {
let fresh = || {
let data = gridcraft_chart::resolve(&d.wb, d.wb.active_sheet.get(), ch);
gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &ApproxMeasure)
};
let expect = fresh();
assert!(expect.len() > 3, "{:?} renders something", ch.kind);
let first = cache.get(d.uid, &d.wb, d.wb.active_sheet.get(), i, (1.0, 1.0), fresh);
let second = cache.get(d.uid, &d.wb, d.wb.active_sheet.get(), i, (1.0, 1.0), || panic!("must be a hit"));
assert_eq!(&*first, &expect[..], "{:?}: built entry", ch.kind);
assert_eq!(&*second, &expect[..], "{:?}: cached entry", ch.kind);
}
assert_eq!(cache.builds, 8);
}
// --- frames ---
fn harness(s: Session) -> Harness {
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).build_ui_state(
|ui, app: &mut SheetApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(8);
h
}
/// Two charts side by side, both in view, over A1:B12 (B3 is `=B2*2`).
fn two_charts() -> Session {
let mut s = Session::new();
s.new_workbook();
s.execute("range.setValues", json!({"range": "A1", "values": [["Day", "Sales"]]})).unwrap();
for i in 2..=12u32 {
let input = if i == 3 { "=B2*2".to_string() } else { format!("{}", i * 3 % 17) };
s.execute("cell.set", json!({"cell": format!("A{i}"), "input": format!("2024-03-{i:02}")})).unwrap();
s.execute("cell.set", json!({"cell": format!("B{i}"), "input": input})).unwrap();
}
s.execute("insert.chart", json!({"range": "A1:B12", "type": "column", "at": "D2", "width": 300, "height": 200})).unwrap();
s.execute("insert.chart", json!({"range": "A1:B12", "type": "scatter", "at": "L2", "width": 300, "height": 200})).unwrap();
s
}
fn builds() -> usize {
RENDERS.with(|c| c.get())
}
/// Runs `change`, lets a few frames pass and returns how many charts were rendered meanwhile.
fn rendered_after(h: &mut Harness, change: impl FnOnce(&mut Harness)) -> usize {
let before = builds();
change(h);
h.run_steps(4);
builds() - before
}
fn exec(h: &mut Harness, id: &str, params: serde_json::Value) {
h.state_mut().session.execute(id, params).unwrap();
}
#[test]
fn frames_without_a_change_render_each_chart_once() {
let mut h = harness(two_charts());
assert!(builds() >= 2, "the charts were drawn");
let n = rendered_after(&mut h, |h| h.run_steps(30));
assert_eq!(n, 0, "30 idle frames render nothing again");
// Pointer movement and wheel scrolling (view state only) change no workbook.
let scroll_before = h.state().view().scroll;
let n = rendered_after(&mut h, |h| {
for i in 0..10 {
h.input_mut().events.push(Event::PointerMoved(egui::pos2(300.0 + i as f32 * 7.0, 400.0)));
h.input_mut().events.push(Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
source: egui::MouseWheelSource::Unknown,
delta: vec2(0.0, -2.0),
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::NONE,
});
h.run_steps(1);
}
});
assert_ne!(h.state().view().scroll, scroll_before, "the sheet did scroll");
assert_eq!(n, 0, "hover and scrolling reuse the rendered charts");
assert_eq!(h.state().chart_cache.lock().unwrap().len(), 2);
}
#[test]
fn every_kind_of_change_renders_the_charts_again_exactly_once() {
let mut h = harness(two_charts());
type Case<'a> = (&'a str, Box<dyn Fn(&mut Harness)>);
let cases: Vec<Case> = vec![
("cell edit", Box::new(|h| exec(h, "cell.set", json!({"cell": "B5", "input": "99"})))),
("recalc through a dependency", Box::new(|h| exec(h, "cell.set", json!({"cell": "B2", "input": "7"})))),
("calculate now", Box::new(|h| exec(h, "formulas.calculateNow", json!({})))),
("undo", Box::new(|h| exec(h, "edit.undo", json!({})))),
("redo", Box::new(|h| exec(h, "edit.redo", json!({})))),
("row hide", Box::new(|h| exec(h, "home.hideRows", json!({"rows": "6:7"})))),
("chart resize", Box::new(|h| exec(h, "chart.set", json!({"chart": 1, "width": 360, "height": 240})))),
("format change", Box::new(|h| exec(h, "home.bold", json!({"range": "A1:B1"})))),
("number format", Box::new(|h| exec(h, "home.numberFormat", json!({"range": "B2:B12", "format": "0.00"})))),
(
"document theme",
Box::new(|h| {
exec(
h,
"pageLayout.themeColors",
json!({"colors": ["#000000", "#FFFFFF", "#44546A", "#E7E6E6", "#C00000", "#ED7D31", "#A5A5A5", "#FFC000", "#5B9BD5", "#70AD47", "#0563C1", "#954F72"]}),
)
}),
),
];
for (what, change) in cases {
let n = rendered_after(&mut h, |h| change(h));
assert_eq!(n, 2, "{what}: both visible charts rendered once");
let n = rendered_after(&mut h, |h| h.run_steps(5));
assert_eq!(n, 0, "{what}: and then nothing more");
}
// Zoom is part of the key too.
let n = rendered_after(&mut h, |h| exec(h, "view.zoom", json!({"percent": 150})));
assert_eq!(n, 2, "zoom");
}
#[test]
fn switching_sheets_and_loading_files_render_the_new_charts_once() {
let mut h = harness(two_charts());
exec(&mut h, "home.insertSheet", json!({"before": 1}));
h.run_steps(4);
// The new sheet has no charts; coming back renders this sheet's two once.
let n = rendered_after(&mut h, |h| exec(h, "sheet.activate", json!({"sheet": 0})));
assert_eq!(n, 2, "sheet switch");
let n = rendered_after(&mut h, |h| h.run_steps(5));
assert_eq!(n, 0);
// File load: the same workbook opened from bytes is a new document with its own charts.
let bytes = h.state_mut().session.execute("file.saveBytes", json!({})).unwrap();
let b64 = bytes["base64"].as_str().unwrap().to_string();
let n = rendered_after(&mut h, |h| exec(h, "file.open", json!({"name": "again.xlsx", "base64": b64})));
assert_eq!(n, 2, "file load");
assert_eq!(h.state().session.documents().len(), 2);
let n = rendered_after(&mut h, |h| h.run_steps(5));
assert_eq!(n, 0);
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 2, "both open documents are cached");
// Back in the first document nothing is rendered: its entries were kept.
let n = rendered_after(&mut h, |h| h.state_mut().session.set_active(0));
assert_eq!(n, 0, "the first document's charts were still cached");
// Closing a document forgets its entries; the other one is shown (and rendered) now.
let n = rendered_after(&mut h, |h| exec(h, "file.close", json!({"force": true})));
assert_eq!(h.state().session.documents().len(), 1);
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 1);
assert_eq!(n, 0, "the remaining document's charts were cached too");
}
#[test]
fn a_chart_with_a_volatile_series_is_rendered_on_every_frame() {
let mut h = harness(two_charts());
{
let d = h.state_mut().session.active_mut().unwrap();
let sh = Arc::make_mut(&mut d.wb).sheet_mut(0).unwrap();
sh.charts[0].series[0].values = "=RAND()".into();
}
h.run_steps(4);
let n = rendered_after(&mut h, |h| h.run_steps(6));
assert!(n >= 6, "the volatile chart is resolved again on each frame, got {n}");
assert!(n < 12, "the other chart still comes from the cache, got {n}");
}
#[test]
fn closed_documents_are_forgotten_without_painting_a_chart() {
let mut h = harness(two_charts());
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 1);
exec(&mut h, "file.new", json!({}));
h.run_steps(4);
assert_eq!(h.state().session.documents().len(), 2);
// The charts' document is no longer shown, but still open.
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 1);
h.state_mut().session.set_active(0);
h.run_steps(2);
exec(&mut h, "file.close", json!({"force": true, "document": true}));
h.run_steps(2);
assert_eq!(h.state().session.documents().len(), 1);
assert_eq!(h.state().chart_cache.lock().unwrap().docs(), 0, "no chart is painted, yet the closed document's entries are gone");
}
}
+47 -7
View File
@@ -1,6 +1,8 @@
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
//! we paint with egui), pictures, shapes, equations, slicers and form controls (`form_controls.rs`).
use std::sync::Arc;
use egui::epaint::{PathShape, PathStroke};
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
use gridcraft_chart::{HAlign, Prim, VAlign};
@@ -198,10 +200,11 @@ fn area_mesh(v: &[Pos2], col: Color32) -> egui::Mesh {
fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Shape, r: Rect, z: f32) {
use gridcraft_engine::model::{ShapeTextAlign, ShapeTextAnchor};
let f = &sp.format;
let word_art = sp.kind == ShapeKind::WordArt;
let text_box = sp.kind == ShapeKind::TextBox;
let align = f.align.unwrap_or(if text_box { ShapeTextAlign::Left } else { ShapeTextAlign::Center });
let anchor = f.anchor.unwrap_or(if text_box { ShapeTextAnchor::Top } else { ShapeTextAnchor::Middle });
let default_color = if text_box { Color32::BLACK } else { Color32::WHITE };
let default_color = if text_box || word_art { Color32::BLACK } else { Color32::WHITE };
let color = f.text_color.and_then(|c| c.resolve(&wb.theme)).map(theme::color32).unwrap_or(default_color);
// Default 14 px (as before); a set size is in points.
let size = f.font_size.map_or(14.0, |pt| pt * 96.0 / 72.0).clamp(1.0, 2000.0) * z;
@@ -227,6 +230,11 @@ fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Sh
};
let pos = pos2(x, y);
let clip = p.with_clip_rect(r.intersect(p.clip_rect()));
// WordArt's drop shadow: the text again, offset down and right, under everything else.
if let Some(sc) = f.text_shadow.and_then(|c| c.resolve(&wb.theme)).map(theme::color32) {
let d = (size * 0.05).clamp(1.0, 24.0);
clip.galley_with_override_text_color(pos + vec2(d, d), g.clone(), sc);
}
// egui has no stroked text: the outline is the text drawn in the outline colour, offset in
// eight directions by the outline width, under the filled text.
if let Some((c, w)) = f.text_outline
@@ -240,6 +248,31 @@ fn paint_shape_text(p: &Painter, wb: &Workbook, sp: &gridcraft_engine::model::Sh
clip.galley(pos, g, color);
}
/// Resolves and renders `ch` (chart `ci` of sheet `si`), at zoom `z`.
fn render_chart(p: &Painter, z: f32, wb: &Workbook, si: usize, ch: &gridcraft_engine::model::Chart) -> Vec<Prim> {
#[cfg(test)]
tests::RENDERS.with(|c| c.set(c.get() + 1));
let data = gridcraft_chart::resolve(wb, si, ch);
let m = EguiMeasure { painter: p, z };
gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m)
}
/// The primitives of chart `ci` of sheet `si`: from the frame cache when the workbook, zoom and
/// screen scale are those it was rendered for, else rendered now (see [`crate::chartcache`]).
fn chart_prims(app: &SheetApp, p: &Painter, z: f32, wb: &Arc<Workbook>, si: usize, ci: usize, ch: &gridcraft_engine::model::Chart) -> Arc<[Prim]> {
// Only the active document's own workbook is cached: anything else cannot be tied to a uid.
let Some(d) = app.session.active().filter(|d| Arc::ptr_eq(&d.wb, wb)) else {
return render_chart(p, z, wb, si, ch).into();
};
// A series that calls NOW or RAND, directly or through a defined name, must be evaluated
// again on every paint.
if gridcraft_chart::has_volatile_series(wb, si, ch) {
return render_chart(p, z, wb, si, ch).into();
}
let mut cache = app.chart_cache.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
cache.get(d.uid, wb, si, ci, (z, p.ctx().pixels_per_point()), || render_chart(p, z, wb, si, ch))
}
pub(crate) fn anchor_rect(geo: &Geo, sh: &Sheet, a: &Anchor) -> Rect {
let x = geo.x(sh, a.cell.col) + a.dx * geo.z;
let y = geo.y(sh, a.cell.row) + a.dy * geo.z;
@@ -259,7 +292,7 @@ pub(crate) fn objects_hidden(wb: &Workbook) -> bool {
wb.show_objects == gridcraft_engine::model::ShowObjects::None
}
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workbook, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Arc<Workbook>, si: usize, sh: &Sheet) -> Vec<PaintedObject> {
let mut painted = Vec::new();
if objects_hidden(wb) {
return painted;
@@ -295,6 +328,8 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
let line =
if sp.format.no_line { Color32::TRANSPARENT } else { sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(Color32::DARK_GRAY) };
match sp.kind {
// WordArt is only its text (no fill or outline of its own).
ShapeKind::WordArt => {}
ShapeKind::Rectangle | ShapeKind::TextBox => {
p.rect_filled(r, 0.0, fill);
p.rect_stroke(r, 0.0, Stroke::new(1.0, line), StrokeKind::Inside);
@@ -365,7 +400,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
}
done(start, r);
}
for ch in &sh.charts {
for (ci, ch) in sh.charts.iter().enumerate() {
let r = anchor_rect(geo, sh, &ch.anchor);
if !r.intersects(p.clip_rect()) {
continue;
@@ -374,9 +409,7 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
// Soft shadow + white card.
p.rect_filled(r.translate(vec2(0.0, 1.5)), 2.0, Color32::from_black_alpha(18));
p.rect_filled(r, 0.0, Tokens::get(p.ctx()).grid_bg);
let data = gridcraft_chart::resolve(wb, si, ch);
let m = EguiMeasure { painter: p, z: geo.z };
let prims = gridcraft_chart::render(ch, &data, ch.anchor.width, ch.anchor.height, &m);
let prims = chart_prims(app, p, geo.z, wb, si, ci, ch);
paint_prims(&p.with_clip_rect(r.intersect(p.clip_rect())), r.min, &prims, geo.z);
done(start, r);
}
@@ -583,11 +616,18 @@ pub(crate) fn ink_color(line: Color32, width_pt: f32) -> Color32 {
}
#[cfg(test)]
mod tests {
pub(crate) mod tests {
use std::cell::Cell;
use gridcraft_chart::Measure;
use super::*;
thread_local! {
/// Charts resolved and rendered on this thread (a test runs on its own thread).
pub(crate) static RENDERS: Cell<usize> = const { Cell::new(0) };
}
/// The production measure is not monotone for labels with a line break: egui lays the text out
/// line by line, so ending a prefix with `\n…` can be narrower than the shorter `iii…`. The
/// chart's label fitting must not assume otherwise (#1152).
+10
View File
@@ -112,15 +112,19 @@ pub enum Drag {
source: RangeRef,
before: Option<u32>,
},
/// `to`: the previewed size once the pointer moved. Only the dragged line changes while the
/// button is held; the release applies the size to every selected line (Excel).
ResizeCol {
col: u32,
start: f32,
orig: f32,
to: Option<f32>,
},
ResizeRow {
row: u32,
start: f32,
orig: f32,
to: Option<f32>,
},
/// Dragging the fill handle; `menu` when the right button drags it (the fill menu opens on release).
Fill {
@@ -244,6 +248,12 @@ pub struct GridState {
/// active sheet's rules, kept the same way: they are read from that sheet's cells alone.
pub(crate) cf: Option<(std::sync::Weak<Sheet>, CfCache)>,
pub drag: Drag,
/// A header-boundary resize drag: the workbook as it was when the drag began. The drag's own
/// undo entries are the ones from the first entry that snapshots it; earlier edits with the
/// same label are not the drag's to merge or cancel.
pub resize_base: Option<Arc<Workbook>>,
/// The redo stack when that drag began; Esc puts it back (the preview commands clear it).
pub resize_redo: Vec<gridcraft_engine::HistoryEntry>,
pub ensure_visible: bool,
/// With `ensure_visible`: scroll to the moving end of the selection instead of the active cell
/// (Shift/Ctrl+Shift+arrows keep the active cell on the anchor while the far corner moves).
+124 -27
View File
@@ -325,7 +325,8 @@ fn handle_column_header_press(app: &mut SheetApp, input: &InteractionInput<'_>,
let in_col_header = input.in_column_header;
if in_col_header {
if let Some((c, x)) = col_edge(geo, sh, p.x) {
app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z };
begin_resize(app);
app.grid.drag = Drag::ResizeCol { col: c, start: p.x, orig: sh.col_width(c).max(0.0) * 0.0 + (x - geo.x(sh, c)) / geo.z, to: None };
return;
}
let c = geo.col_at(sh, p.x);
@@ -362,7 +363,8 @@ fn handle_row_header_press(app: &mut SheetApp, input: &InteractionInput<'_>, p:
let in_row_header = input.in_row_header;
if in_row_header {
if let Some((r, y)) = row_edge(geo, sh, p.y) {
app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z };
begin_resize(app);
app.grid.drag = Drag::ResizeRow { row: r, start: p.y, orig: (y - geo.y(sh, r)) / geo.z, to: None };
return;
}
let r = geo.row_at(sh, p.y);
@@ -631,6 +633,31 @@ fn handle_context_menu_and_keyboard(app: &mut SheetApp, input: &InteractionInput
fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
let (geo, sh, wb, sel, pos) = (input.geo, input.sheet, input.workbook, &input.selection, input.position);
let resp = input.response;
// Esc during a header-boundary drag cancels it: undo the preview, ignore the release. egui
// aborts the drag on Esc, so `resp.dragged()` is already false in this frame. The key is
// spent on the drag, so the grid's Esc doesn't also drop a pending copy or Format Painter.
if matches!(app.grid.drag, Drag::ResizeCol { .. } | Drag::ResizeRow { .. })
&& input.context.input_mut(|i| {
let pressed = i.key_pressed(egui::Key::Escape);
if pressed {
// With any modifiers, as before: the drag is cancelled and every Esc press spent.
i.events.retain(|e| !matches!(e, egui::Event::Key { key: egui::Key::Escape, pressed: true, .. }));
}
pressed
})
{
// Undo back to where the drag began, but never past it into an earlier edit.
let steps = resize_steps(app);
if steps > 0 {
let _ = app.session.run("edit.undo", json!({"steps": steps}));
}
// Undo pushes the cancelled preview onto the redo stack; put back what the drag began with.
if let Some(d) = app.session.active_mut() {
d.redo = std::mem::take(&mut app.grid.resize_redo);
}
app.grid.drag = Drag::None;
app.grid.resize_base = None;
}
if resp.dragged()
&& let Some(p) = pos
{
@@ -682,35 +709,22 @@ fn handle_dragging(app: &mut SheetApp, input: &InteractionInput<'_>) {
app.grid.drag = Drag::MoveCols { source, before };
scroll_by(app, vec2(auto.x, 0.0));
}
Drag::ResizeCol { col, start, orig } => {
let w = (orig + (p.x - start) / geo.z).max(0.0);
Drag::ResizeCol { col, start, orig, to } => {
let w = nan_safe_size(orig + (p.x - start) / geo.z);
app.toast =
Some((format!("Width: {:.2} ({} pixels)", gridcraft_engine::cmd::format::points_to_chars(w as f64), w.round()), crate::now_ms()));
// Inside a selection of whole columns, every selected column takes the new width
// (Ctrl-selected areas included); otherwise only the dragged one.
let cols = if sel.ranges.iter().any(|r| r.is_full_cols() && col >= r.start.col && col <= r.end.col) {
sel.ranges.iter().filter(|r| r.is_full_cols()).map(|r| r.a1()).collect::<Vec<_>>().join(",")
} else {
RangeRef::cols(col, col).a1()
};
let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()}));
coalesce_last_undo(app);
// Preview on the dragged column only; the release applies it to the selection. A
// rejected size leaves the last shown preview (and `to`) as it was.
let ok = app.session.run("home.columnWidth", json!({"cols": RangeRef::cols(col, col).a1(), "width": w.round()})).is_ok();
coalesce_resize_undo(app);
app.grid.drag = Drag::ResizeCol { col, start, orig, to: if ok { Some(w) } else { to } };
}
Drag::ResizeRow { row, start, orig } => {
let h = (orig + (p.y - start) / geo.z).max(0.0);
Drag::ResizeRow { row, start, orig, to } => {
let h = nan_safe_size(orig + (p.y - start) / geo.z);
app.toast = Some((format!("Height: {:.2} ({} pixels)", h * 0.75, h.round()), crate::now_ms()));
let rows = if sel.ranges.iter().any(|r| r.is_full_rows() && row >= r.start.row && row <= r.end.row) {
sel.ranges
.iter()
.filter(|r| r.is_full_rows())
.map(|r| format!("{}:{}", r.start.row + 1, r.end.row + 1))
.collect::<Vec<_>>()
.join(",")
} else {
format!("{}:{}", row + 1, row + 1)
};
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
coalesce_last_undo(app);
let ok = app.session.run("home.rowHeight", json!({"rows": format!("{}:{}", row + 1, row + 1), "height": h.round()})).is_ok();
coalesce_resize_undo(app);
app.grid.drag = Drag::ResizeRow { row, start, orig, to: if ok { Some(h) } else { to } };
}
Drag::Freeze { cols, .. } => {
app.grid.drag = Drag::Freeze { cols, to: Some(freeze_boundary(geo, sh, p, cols)) };
@@ -794,6 +808,21 @@ fn handle_release(app: &mut SheetApp, input: &InteractionInput<'_>) {
crate::macro_ui::call(app, "GridCraft", "range.move", json!({"range": src.a1(), "to": to.a1()}));
}
}
Drag::ResizeCol { col, to: Some(w), .. } => {
// The preview already sized the dragged column; the other selected ones follow now.
let cols = resize_targets(&sel.ranges, col, true);
if cols != RangeRef::cols(col, col).a1() {
let _ = app.session.run("home.columnWidth", json!({"cols": cols, "width": w.round()}));
coalesce_resize_undo(app);
}
}
Drag::ResizeRow { row, to: Some(h), .. } => {
let rows = resize_targets(&sel.ranges, row, false);
if rows != format!("{}:{}", row + 1, row + 1) {
let _ = app.session.run("home.rowHeight", json!({"rows": rows, "height": h.round()}));
coalesce_resize_undo(app);
}
}
Drag::MoveCols { source, before: Some(before) } => {
if let Err(e) = app.run("sheet.moveColumns", json!({"cols": source.a1(), "before": before})) {
app.message = Some(("GridCraft".into(), crate::clean_error(&e)));
@@ -936,6 +965,29 @@ fn select_to_pointer(app: &mut SheetApp, geo: &Geo, sh: &Sheet, wb: &Workbook, p
}
}
/// A drag size in points: NaN or negative becomes 0 (a hidden line).
fn nan_safe_size(v: f32) -> f32 {
if v.is_nan() { 0.0 } else { v.max(0.0) }
}
/// The lines a header-boundary drag resizes on release: every selected whole line when the
/// dragged one is part of the selection (Ctrl-selected areas included), otherwise only it.
fn resize_targets(ranges: &[RangeRef], index: u32, cols: bool) -> String {
let whole = |r: &RangeRef| if cols { r.is_full_cols() } else { r.is_full_rows() };
let span = |r: &RangeRef| if cols { (r.start.col, r.end.col) } else { (r.start.row, r.end.row) };
let name = |r: &RangeRef| {
let (a, b) = span(r);
if cols { RangeRef::cols(a, b).a1() } else { format!("{}:{}", a + 1, b + 1) }
};
if ranges.iter().any(|r| whole(r) && (span(r).0..=span(r).1).contains(&index)) {
ranges.iter().filter(|r| whole(r)).map(name).collect::<Vec<_>>().join(",")
} else if cols {
RangeRef::cols(index, index).a1()
} else {
format!("{}:{}", index + 1, index + 1)
}
}
fn coalesce_last_undo(app: &mut SheetApp) {
// Merge consecutive drag steps into one undo entry.
if let Some(d) = app.session.active_mut()
@@ -948,6 +1000,29 @@ fn coalesce_last_undo(app: &mut SheetApp) {
}
}
/// Starts a header resize drag: remembers the workbook and the redo stack it began with.
fn begin_resize(app: &mut SheetApp) {
app.grid.resize_base = app.session.active().map(|d| d.wb.clone());
app.grid.resize_redo = app.session.active().map(|d| d.redo.clone()).unwrap_or_default();
}
/// Undo entries a header resize drag has pushed so far: those from the first one that snapshots
/// the workbook the drag started from (`resize_base`). A failed command pushes none.
fn resize_steps(app: &SheetApp) -> usize {
let (Some(base), Some(d)) = (&app.grid.resize_base, app.session.active()) else { return 0 };
d.undo.iter().position(|e| std::sync::Arc::ptr_eq(&e.wb, base)).map_or(0, |first| d.undo.len() - first)
}
/// Like [`coalesce_last_undo`] for a resize drag: merges only the drag's own entries, so an
/// earlier edit with the same label ("Column width") stays a separate undo step.
fn coalesce_resize_undo(app: &mut SheetApp) {
if resize_steps(app) >= 2
&& let Some(d) = app.session.active_mut()
{
d.undo.pop();
}
}
fn scroll_by(app: &mut SheetApp, d: egui::Vec2) {
if d != egui::Vec2::ZERO
&& let Some(v) = app.view_mut()
@@ -1161,6 +1236,28 @@ pub fn point_move(app: &mut SheetApp, ed: &mut EditState, dr: i64, dc: i64, exte
app.grid.ensure_visible = true;
}
#[cfg(test)]
mod resize_tests {
use super::*;
#[test]
fn resize_targets_follow_the_selection_only_when_the_dragged_line_is_in_it() {
let sel = [RangeRef::cols(0, 0), RangeRef::cols(2, 3)];
assert_eq!(resize_targets(&sel, 3, true), "A:A,C:D");
assert_eq!(resize_targets(&sel, 1, true), "B:B");
assert_eq!(resize_targets(&[RangeRef::rows(1, 2)], 2, false), "2:3");
assert_eq!(resize_targets(&[RangeRef::rows(1, 2)], 5, false), "6:6");
assert_eq!(resize_targets(&[], 0, true), "A:A");
}
#[test]
fn drag_sizes_are_nan_safe() {
assert_eq!(nan_safe_size(f32::NAN), 0.0);
assert_eq!(nan_safe_size(-4.0), 0.0);
assert_eq!(nan_safe_size(12.5), 12.5);
}
}
#[cfg(test)]
mod tests {
use super::*;
+5
View File
@@ -103,6 +103,7 @@ pub enum Icon {
Comment,
Note,
TextBox,
WordArt,
Symbol,
/// An equation: a radical sign over an italic x.
Equation,
@@ -631,6 +632,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
g.rect(2.0, 3.0, 12.0, 10.0, None, Some(c));
g.text(8.0, 8.0, "A", 8.0, c, (false, false));
}
Icon::WordArt => {
g.text(9.0, 9.0, "A", 13.0, Color32::from_rgb(0x8E, 0x4F, 0xB5), (true, false));
g.text(8.0, 8.0, "A", 13.0, c, (true, false));
}
Icon::Symbol => g.text(8.0, 8.0, "Ω", 12.0, c, (false, false)),
Icon::Equation => {
g.line(&[(1.5, 9.0), (3.5, 8.0), (5.5, 13.5), (8.5, 2.5), (14.5, 2.5)], c, 1.2);
@@ -194,7 +194,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("NU", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Symbols; containment(Symbol)
("NZS", Entry::Finish(Action::Command("insert.symbol"))), // Excel: Excel: Symbol (modern)
("NV", Entry::Finish(Action::Command("insert.pivotTable"))), // Excel: PivotTable
("NW", Entry::Finish(Action::Pending("insert.wordArt"))), // pending; Excel: WordArt; WordArt; reserved until the command lands
("NW", Entry::Finish(Action::Command("insert.wordArt"))), // Excel: WordArt
("NX", Entry::Finish(Action::Command("insert.textBox"))), // Excel: Text Box
("NZK", Entry::Continue), // Excel: Sparklines Menu; group level:
("NZKSL", Entry::Finish(Action::Dialog("sparkline"))), // Excel: Line Sparkline
+1 -3
View File
@@ -1336,9 +1336,7 @@
"seq": "NW",
"kind": "command",
"target": "insert.wordArt",
"pending": true,
"excel": "WordArt",
"note": "WordArt; reserved until the command lands"
"excel": "WordArt"
},
{
"seq": "NX",
+4
View File
@@ -18,6 +18,7 @@ pub const UPDATE_APP: craft_update::app::App = craft_update::app::App {
pub mod backstage;
mod border_preview;
mod cell_pictures;
mod chartcache;
pub mod chartview;
pub mod code_editor;
pub mod control;
@@ -59,6 +60,7 @@ pub mod theme;
mod updates_ui;
pub mod widgets;
mod window_ui;
pub mod wordart;
pub use window_ui::WindowUi;
@@ -287,6 +289,8 @@ pub struct SheetApp {
pub(crate) capture_geometry_from_raw: bool,
/// The floating object selected on the sheet (chart, picture, shape, equation or slicer).
pub selected_object: Option<gridcraft_engine::model::ObjRef>,
/// Rendered charts kept between frames ([`chartcache`]).
pub(crate) chart_cache: std::sync::Mutex<chartcache::ChartCache>,
/// The system clipboard's text when GridCraft last copied without publishing to it (a
/// control-channel or MCP `edit.copy`/`edit.cut`). While the system clipboard still holds this
/// text, GridCraft's copy is the newer one and Paste uses it.
+3 -1
View File
@@ -128,7 +128,9 @@ pub fn open_action(sh: &Sheet, o: ObjRef) -> OpenAction {
match o.kind {
ObjKind::Chart => OpenAction::FormatChart(o.id),
ObjKind::Equation => OpenAction::EditEquation(o.id),
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && s.kind == ShapeKind::TextBox) => OpenAction::EditText(o.id),
ObjKind::Shape if sh.shapes.iter().any(|s| s.id == o.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) => {
OpenAction::EditText(o.id)
}
ObjKind::Control => OpenAction::EditControl(o.id),
ObjKind::Shape | ObjKind::Image | ObjKind::Slicer => OpenAction::Select,
}
+6
View File
@@ -208,6 +208,12 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
if __k.clicked() {
act(app, "insert.textBox", json!({}));
}
// Two lines like its neighbours, so the Insert tab stays as wide as before; the translated label is split, the catalog key is one line.
let label = app.ui.language.tr("Decorative Text").replacen(' ', "\n", 1);
let wa = big_button(ui, Icon::WordArt, &label, "Decorative Text", true);
anchor_keytip(app, ui, &wa, &["insert.wordArt"]);
// Menu invocation shows the style gallery; the command itself never opens a dialog.
egui::Popup::menu(&wa).show(|ui| crate::wordart::gallery(app, ui));
let __k = big_button(ui, Icon::PageLayout, "Header &\nFooter", "Header & Footer", false);
control_keytip(app, ui, &__k, "dialog:headerFooter");
if __k.clicked() {
+11 -3
View File
@@ -33,7 +33,7 @@ impl SheetApp {
}
let Some(d) = self.session.active() else { return false };
let Some(sh) = d.wb.active() else { return false };
let Some(shape) = sh.shapes.iter().find(|s| s.id == id && s.kind == ShapeKind::TextBox) else {
let Some(shape) = sh.shapes.iter().find(|s| s.id == id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else {
return false;
};
// shape.setText refuses edits on a protected sheet; don't open an editor that can't commit.
@@ -118,7 +118,7 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
app.text_box_editor = Some(ed);
return;
}
let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && s.kind == ShapeKind::TextBox) else {
let Some(shape) = sh.shapes.iter().find(|s| s.id == ed.id && matches!(s.kind, ShapeKind::TextBox | ShapeKind::WordArt)) else {
app.session.mode = Mode::Ready;
return;
};
@@ -153,7 +153,15 @@ pub(crate) fn show(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, sh: &gridcr
.add(
TextEdit::multiline(&mut ed.text)
.id(id)
.font(FontId::new(14.0 * geo.z, egui::FontFamily::Name(theme::CELL.into())))
.font(FontId::new(
// WordArt is edited at its own size (clamped so the box stays usable).
if shape.kind == ShapeKind::WordArt {
shape.format.font_size.map_or(14.0, |pt| (pt * 96.0 / 72.0).clamp(8.0, 200.0))
} else {
14.0
} * geo.z,
egui::FontFamily::Name(theme::CELL.into()),
))
.text_color(Color32::BLACK)
.frame(egui::Frame::NONE)
.margin(egui::Margin::ZERO)
+50
View File
@@ -0,0 +1,50 @@
//! Insert › Text › WordArt: the style gallery. Previews are drawn in code from the engine's
//! preset palette (`gridcraft_engine::cmd::wordart::STYLES`); picking one runs `insert.wordArt`.
use egui::{Align2, Color32, FontId, Painter, Rect, Sense, Ui, vec2};
use gridcraft_engine::cmd::wordart::{STYLES, WordArtStyle};
use gridcraft_engine::model::Theme;
use serde_json::json;
use crate::{SheetApp, theme};
/// Paints "Abc" in `style` centred in `r` (fill, outline and shadow as the sheet object has them).
pub(crate) fn paint_sample(p: &Painter, r: Rect, style: &WordArtStyle, theme: &Theme) {
let px = 24.0;
let family = theme::cell_family("", style.bold, style.italic);
let font = FontId::new(px, family);
let c = r.center();
let col = |c: gridcraft_engine::model::Color| c.resolve(theme).map(theme::color32).unwrap_or(Color32::BLACK);
if let Some(sh) = style.shadow {
p.text(c + vec2(1.5, 1.5), Align2::CENTER_CENTER, "Abc", font.clone(), col(sh));
}
if let Some((oc, w)) = style.outline {
let d = (w * 96.0 / 72.0 / 2.0).clamp(0.5, 3.0);
for (dx, dy) in [(-1.0, -1.0), (0.0, -1.0), (1.0, -1.0), (-1.0, 0.0), (1.0, 0.0), (-1.0, 1.0), (0.0, 1.0), (1.0, 1.0)] {
p.text(c + vec2(dx * d, dy * d), Align2::CENTER_CENTER, "Abc", font.clone(), col(oc));
}
}
p.text(c, Align2::CENTER_CENTER, "Abc", font, col(style.fill));
}
/// The gallery popup content: two rows of previews; a click inserts that style.
pub(crate) fn gallery(app: &mut SheetApp, ui: &mut Ui) {
let theme = app.session.active().map(|d| d.wb.theme.clone()).unwrap_or_default();
ui.set_width(5.0 * 72.0);
egui::Grid::new("wordart_gallery").spacing(vec2(4.0, 4.0)).show(ui, |ui| {
for (i, style) in STYLES.iter().enumerate() {
let (r, resp) = ui.allocate_exact_size(vec2(68.0, 44.0), Sense::click());
let t = theme::Tokens::get(ui.ctx());
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { Color32::WHITE });
ui.painter().rect_stroke(r, 4.0, egui::Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
paint_sample(&ui.painter().with_clip_rect(r), r, style, &theme);
if resp.on_hover_text(style.name).clicked() {
app.run_or_alert("insert.wordArt", json!({"style": style.name}));
ui.close();
}
if (i + 1) % 5 == 0 {
ui.end_row();
}
}
});
}
+224
View File
@@ -153,6 +153,110 @@ fn dragging_a_boundary_inside_a_multi_area_selection_resizes_every_selected_line
}
}
#[test]
fn a_multi_selection_resize_previews_on_the_dragged_line_and_applies_on_release() {
// Issue #1082: only the dragged line moves while the button is held; Esc cancels.
for rows in [false, true] {
let mut h = harness(if rows { "1:3" } else { "A:C" });
let before = sizes(&h, rows);
let (start, zoom) = edge(&h, rows, 1);
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
button(&mut h, start, true);
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
h.step();
h.input_mut().events.push(Event::PointerMoved(start + delta));
h.step();
let held = sizes(&h, rows);
assert_eq!(held[1], (before[1] + 30.0 / zoom).round(), "the dragged line previews");
for index in [0, 2, 3] {
assert_eq!(held[index], before[index], "line {index} waits for the release");
}
button(&mut h, start + delta, false);
h.run_steps(2);
let after = sizes(&h, rows);
for (index, size) in after.iter().take(3).enumerate() {
assert_eq!(*size, held[1], "line {index} takes the width on release");
}
assert_eq!(after[3], before[3]);
assert_eq!(h.state().session.active().unwrap().undo.len(), 1);
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(sizes(&h, rows), before);
// Esc during the drag cancels everything.
let mut h = harness(if rows { "1:3" } else { "A:C" });
button(&mut h, start, true);
h.input_mut().events.push(Event::PointerMoved(start + delta));
h.step();
h.input_mut().events.push(Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Modifiers::NONE,
});
h.step();
button(&mut h, start + delta, false);
h.run_steps(2);
assert_eq!(sizes(&h, rows), before, "Esc restores the sizes");
}
}
#[test]
fn escaping_a_resize_drag_keeps_an_earlier_size_edit() {
// A resize drag right after another resize shares its undo label; Esc must cancel only the drag.
for (rows, selection) in [(false, "B:B"), (true, "2:2"), (false, "A:C"), (true, "1:3")] {
let mut h = harness(selection);
let key = if rows { ("home.rowHeight", "rows", "2:2", "height") } else { ("home.columnWidth", "cols", "B:B", "width") };
h.state_mut().session.execute(key.0, json!({key.1: key.2, key.3: 80})).unwrap();
h.run_steps(2);
let earlier = sizes(&h, rows);
let undo_len = h.state().session.active().unwrap().undo.len();
let (start, _) = edge(&h, rows, 1);
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
button(&mut h, start, true);
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
h.step();
h.input_mut().events.push(Event::PointerMoved(start + delta));
h.step();
assert_ne!(sizes(&h, rows), earlier, "the drag previews");
h.input_mut().events.push(Event::Key {
key: egui::Key::Escape,
physical_key: None,
pressed: true,
repeat: false,
modifiers: Modifiers::NONE,
});
h.step();
button(&mut h, start + delta, false);
h.run_steps(2);
assert_eq!(sizes(&h, rows), earlier, "Esc cancels only the drag (rows: {rows}, selection {selection})");
assert_eq!(h.state().session.active().unwrap().undo.len(), undo_len, "the earlier edit stays on the undo stack");
}
}
#[test]
fn a_resize_drag_after_another_resize_is_its_own_undo_step() {
for (rows, selection) in [(false, "B:B"), (true, "2:2"), (false, "A:C"), (true, "1:3")] {
let mut h = harness(selection);
let key = if rows { ("home.rowHeight", "rows", "2:2", "height") } else { ("home.columnWidth", "cols", "B:B", "width") };
h.state_mut().session.execute(key.0, json!({key.1: key.2, key.3: 80})).unwrap();
h.run_steps(2);
let earlier = sizes(&h, rows);
let (start, _) = edge(&h, rows, 1);
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
button(&mut h, start, true);
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
h.step();
h.input_mut().events.push(Event::PointerMoved(start + delta));
h.step();
button(&mut h, start + delta, false);
h.run_steps(2);
assert_eq!(h.state().session.active().unwrap().undo.len(), 2, "selection {selection}: two undo steps");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(sizes(&h, rows), earlier, "selection {selection}: one undo reverts only the drag");
}
}
#[test]
fn table_filter_autofit_works_through_command_and_header_double_click() {
for zoom in [50, 100, 200] {
@@ -191,3 +295,123 @@ fn table_filter_autofit_works_through_command_and_header_double_click() {
}
}
}
fn press_escape(h: &mut egui_kittest::Harness<'_, SheetApp>) {
h.input_mut().events.push(Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
h.step();
}
#[test]
fn a_rejected_final_preview_releases_with_the_size_last_shown() {
// The engine refuses rows over 546 px and columns over 1800 px; the release must apply the last
// accepted preview to the whole selection, not retry the refused size.
for rows in [false, true] {
let mut h = harness(if rows { "1:3" } else { "A:C" });
let before = sizes(&h, rows);
let (start, zoom) = edge(&h, rows, 1);
let unit = |d: f32| if rows { vec2(0.0, d) } else { vec2(d, 0.0) };
button(&mut h, start, true);
h.input_mut().events.push(Event::PointerMoved(start + unit(15.0)));
h.step();
h.input_mut().events.push(Event::PointerMoved(start + unit(30.0)));
h.step();
let shown = sizes(&h, rows)[1];
assert_eq!(shown, (before[1] + 30.0 / zoom).round());
h.input_mut().events.push(Event::PointerMoved(start + unit(4000.0 * zoom)));
h.step();
assert_eq!(sizes(&h, rows)[1], shown, "the refused size is not shown");
button(&mut h, start + unit(4000.0 * zoom), false);
h.run_steps(2);
let after = sizes(&h, rows);
for (index, size) in after.iter().take(3).enumerate() {
assert_eq!(*size, shown, "line {index} takes the size that was shown (rows: {rows})");
}
assert_eq!(after[3], before[3]);
}
}
#[test]
fn escaping_a_resize_drag_leaves_redo_history_as_it_was() {
for rows in [false, true] {
for with_redo in [false, true] {
let mut h = harness(if rows { "1:3" } else { "A:C" });
let cmd = if rows { ("home.rowHeight", "rows", "4:4", "height") } else { ("home.columnWidth", "cols", "D:D", "width") };
let mut redo_len = 0;
let mut d_size = 0.0;
if with_redo {
h.state_mut().session.execute(cmd.0, json!({cmd.1: cmd.2, cmd.3: 80})).unwrap();
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
h.run_steps(2);
redo_len = h.state().session.active().unwrap().redo.len();
assert_eq!(redo_len, 1);
d_size = sizes(&h, rows)[3];
}
let before = sizes(&h, rows);
let (start, _) = edge(&h, rows, 1);
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
button(&mut h, start, true);
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
h.step();
h.input_mut().events.push(Event::PointerMoved(start + delta));
h.step();
assert_ne!(sizes(&h, rows), before, "the drag previews");
press_escape(&mut h);
button(&mut h, start + delta, false);
h.run_steps(2);
assert_eq!(sizes(&h, rows), before);
assert_eq!(h.state().session.active().unwrap().redo.len(), redo_len, "Esc leaves the redo stack alone (rows: {rows}, redo: {with_redo})");
// Ctrl+Y brings back only what was undone before the drag.
let redone = h.state_mut().session.execute("edit.redo", json!({}));
if with_redo {
redone.unwrap();
let redone = sizes(&h, rows);
assert_eq!(redone[3], 80.0);
assert_ne!(redone[3], d_size);
assert_eq!(redone[..3], before[..3], "the cancelled drag does not come back");
} else {
assert!(redone.is_err(), "nothing to redo after a cancelled drag");
}
}
}
}
#[test]
fn escaping_a_resize_drag_keeps_a_pending_copy_and_format_painter() {
// Esc cancels the drag only: the marked copy and Format Painter stay, whether the release comes
// in a later frame or in the same frame as Esc.
for rows in [false, true] {
for (same_frame, modifiers) in [(false, Modifiers::NONE), (true, Modifiers::NONE), (false, Modifiers::SHIFT)] {
let mut h = harness(if rows { "1:3" } else { "A:C" });
h.state_mut().session.execute("edit.copy", json!({"range": "A1:B2"})).unwrap();
h.state_mut().session.execute("edit.formatPainter", json!({"sticky": true})).unwrap();
h.run_steps(2);
assert!(h.state().session.clipboard.is_some() && h.state().session.format_painter.is_some());
let before = sizes(&h, rows);
let (start, _) = edge(&h, rows, 1);
let delta = if rows { vec2(0.0, 30.0) } else { vec2(30.0, 0.0) };
button(&mut h, start, true);
h.input_mut().events.push(Event::PointerMoved(start + delta / 2.0));
h.step();
h.input_mut().events.push(Event::PointerMoved(start + delta));
h.step();
assert_ne!(sizes(&h, rows), before, "the drag previews");
let pos = start + delta;
let esc = Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers };
if same_frame {
h.input_mut().events.extend([esc, Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }]);
} else {
h.input_mut().events.push(esc);
h.step();
h.input_mut()
.events
.extend([Event::PointerMoved(pos), Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers }]);
}
h.step();
h.run_steps(2);
let what = format!("rows: {rows}, same frame: {same_frame}, modifiers: {modifiers:?}");
assert_eq!(sizes(&h, rows), before, "the drag is cancelled ({what})");
assert!(h.state().session.clipboard.is_some(), "the copy is still marked ({what})");
assert!(h.state().session.format_painter.is_some(), "Format Painter is still on ({what})");
}
}
}
+1 -4
View File
@@ -1417,10 +1417,7 @@ fn keytip_nv_() {
fn keytip_nw_() {
let mut h = harness(blank());
sequence(&mut h, "NW", false);
assert!(h.state().dialog.is_none(), "NW must not open a dialog");
assert!(h.state().message.is_none(), "NW must not open a message box");
assert!(h.state().toast.is_none(), "NW must not raise a toast");
assert!(h.state().session.journal.is_empty(), "NW must not journal");
assert!(h.state().session.journal.iter().any(|(id, _)| id == "insert.wordArt"), "NW must journal insert.wordArt");
}
#[test]
+2 -2
View File
@@ -1064,8 +1064,8 @@ fn the_windows_cancel_with_an_unsavable_draft_closes_the_dialog_and_says_why() {
assert_eq!(draft_text(&h).as_deref(), Some("Picked note"), "the draft is kept");
assert_eq!(selection(&h).0, 2, "the selection stays where it is");
let wb = &h.state().session.active().unwrap().wb;
for sheet in 0..3 {
assert!(wb.sheets[sheet].value(CellRef::parse("H1").unwrap()).is_empty(), "nothing ran on sheet {sheet}");
for (index, sheet) in wb.sheets[..3].iter().enumerate() {
assert!(sheet.value(CellRef::parse("H1").unwrap()).is_empty(), "nothing ran on sheet {index}");
}
}
+1 -1
View File
@@ -495,7 +495,7 @@ fn vertical_alignment_keytips_run_their_commands() {
fn pending_sequences_are_silent_no_ops() {
let mut h = harness(blank());
let journal = h.state().session.journal.len();
sequence(&mut h, &[Key::N, Key::W], false); // NW -> insert.wordArt (missing)
sequence(&mut h, &[Key::N, Key::S, Key::C], false); // NSC -> insert.screenshot (missing)
assert!(h.state().dialog.is_none(), "no dialog for a pending sequence");
assert!(h.state().message.is_none(), "no message box for a pending sequence");
assert!(h.state().toast.is_none(), "no toast for a pending sequence");
+28 -3
View File
@@ -221,7 +221,16 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
let tx_box = obj.path(&["nvSpPr", "cNvSpPr"]).is_some_and(|c| c.flag("txBox", false));
let ln = sp.child("ln");
let arrow = ln.is_some_and(|l| l.child("tailEnd").or_else(|| l.child("headEnd")).is_some_and(|t| t.attr("type").is_some_and(|v| v != "none")));
// WordArt: our marker, a text body with the non-warping text warp `textNoShape`, on a rectangle
// that is a text box (as the writer saves it) or has no fill and no outline of its own (as
// WordArt usually comes). `textNoShape` is the standard "no warp" value, so on an ordinary
// shape (an ellipse, a filled rectangle) it, like any other warp, leaves the shape what its
// geometry says.
let word_art = prst == "rect"
&& (tx_box || (sp.child("noFill").is_some() && !ln.is_some_and(|l| l.child("solidFill").is_some())))
&& obj.path(&["txBody", "bodyPr", "prstTxWarp"]).is_some_and(|w| w.attr("prst") == Some("textNoShape"));
let kind = match prst {
_ if word_art => ShapeKind::WordArt,
_ if tx_box => ShapeKind::TextBox,
"roundRect" => ShapeKind::RoundedRectangle,
"ellipse" => ShapeKind::Ellipse,
@@ -294,6 +303,7 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat {
f.italic = r.flag("i", false);
f.font = r.child("latin").and_then(|l| l.attr("typeface")).filter(|t| !t.is_empty() && !t.starts_with('+')).map(str::to_string);
f.text_color = r.child("solidFill").and_then(dml_color);
f.text_shadow = r.child("effectLst").and_then(|e| e.child("outerShdw")).and_then(dml_color);
f.text_outline = r.child("ln").and_then(|l| {
let c = l.child("solidFill").and_then(dml_color)?;
// Width in EMU (12700 per point); Excel's default hairline is 0.75 pt.
@@ -520,7 +530,7 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
ShapeKind::Triangle => ("triangle", false),
ShapeKind::Line | ShapeKind::Arrow => ("line", false),
// Equations are written by `write_equation`; this arm keeps the match whole.
ShapeKind::TextBox | ShapeKind::Equation => ("rect", true),
ShapeKind::TextBox | ShapeKind::WordArt | ShapeKind::Equation => ("rect", true),
ShapeKind::Icon | ShapeKind::Ink => ("rect", false),
};
let _ = write!(
@@ -550,7 +560,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
s.push_str("</a:ln>");
}
s.push_str("</xdr:spPr>");
if !sh.text.is_empty() {
// A WordArt with no text still writes its body, which carries the WordArt marker.
if !sh.text.is_empty() || sh.kind == ShapeKind::WordArt {
let f = &sh.format;
let anchor = match f.anchor {
Some(ShapeTextAnchor::Top) => " anchor=\"t\"",
@@ -559,7 +570,15 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
None if sh.kind == ShapeKind::TextBox => " anchor=\"t\"",
None => "",
};
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
if sh.kind == ShapeKind::WordArt {
// Our WordArt marker: a (non-warping) text warp, which the reader maps back to WordArt.
let _ = write!(
s,
"<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}><a:prstTxWarp prst=\"textNoShape\"><a:avLst/></a:prstTxWarp></a:bodyPr><a:lstStyle/>"
);
} else {
let _ = write!(s, "<xdr:txBody><a:bodyPr wrap=\"square\" rtlCol=\"0\"{anchor}/><a:lstStyle/>");
}
let algn = match f.align {
None => "",
Some(ShapeTextAlign::Left) => " algn=\"l\"",
@@ -614,6 +633,12 @@ fn run_props(f: &ShapeFormat, theme: &Theme) -> (String, String) {
if let Some(c) = f.text_color.as_ref().and_then(|c| srgb(c, theme)) {
let _ = write!(body, "<a:solidFill>{c}</a:solidFill>");
}
if let Some(c) = f.text_shadow.as_ref().and_then(|c| srgb(c, theme)) {
let _ = write!(
body,
"<a:effectLst><a:outerShdw blurRad=\"38100\" dist=\"38100\" dir=\"2700000\" algn=\"tl\" rotWithShape=\"0\">{c}</a:outerShdw></a:effectLst>"
);
}
if let Some(font) = &f.font {
let _ = write!(body, "<a:latin typeface=\"{}\"/>", esc_attr(font));
}
+1
View File
@@ -32,6 +32,7 @@ mod sheet_data_pin;
mod sheet_names;
mod smooth_roundtrip;
mod trendline_roundtrip;
mod xls_dv_tests;
mod xls_style_tests;
mod xls_tests;
mod xlsb_tests;
+1
View File
@@ -395,6 +395,7 @@ fn sample() -> Workbook {
font: Some("Arial".into()),
text_color: Some(Color::Rgb(0x112233)),
text_outline: Some((Color::Rgb(0xFF0000), 1.5)),
text_shadow: Some(Color::Rgb(0x445566)),
align: Some(ShapeTextAlign::Right),
anchor: Some(ShapeTextAnchor::Bottom),
rtl: true,
+66
View File
@@ -86,6 +86,72 @@ fn shape_text_without_rtl_writes_unchanged_ppr() {
assert!(!xml.contains("<a:pPr"), "no paragraph properties without alignment or direction: {xml}");
}
#[test]
fn word_art_keeps_right_to_left_paragraphs() {
let mut wb = text_box("مرحبا\nبالعالم", ShapeFormat { rtl: true, align: Some(ShapeTextAlign::Right), ..Default::default() });
wb.sheet_mut(0).unwrap().shapes[0].kind = ShapeKind::WordArt;
let xml = written_drawing(&wb);
assert!(xml.contains(r#"<a:prstTxWarp prst="textNoShape">"#));
assert_eq!(xml.matches(r#"<a:pPr algn="r" rtl="1"/>"#).count(), 2);
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
let shape = &reopened.sheet(0).unwrap().shapes[0];
assert_eq!(shape.kind, ShapeKind::WordArt);
assert!(shape.format.rtl);
assert_eq!(shape.format.align, Some(ShapeTextAlign::Right));
assert_eq!(shape.text, "مرحبا\nبالعالم");
}
#[test]
fn word_art_with_empty_text_keeps_its_kind() {
let mut wb = text_box("", ShapeFormat::default());
wb.sheet_mut(0).unwrap().shapes[0].kind = ShapeKind::WordArt;
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
}
#[test]
fn a_text_warp_on_a_filled_ellipse_does_not_make_it_word_art() {
let drawing = ARABIC_TEXT_BOX
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
.replace(r#"prst="rect""#, r#"prst="ellipse""#)
.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textTriangle"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
let wb = read_drawing(&drawing);
assert_eq!(wb.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse);
}
/// `textNoShape` is the standard "no text warp" value, so on its own it does not make an ordinary
/// shape WordArt: a filled ellipse that carries it reads and saves as the same filled ellipse.
#[test]
fn an_explicit_no_warp_on_a_filled_ellipse_stays_an_ellipse() {
let drawing = ARABIC_TEXT_BOX
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
.replace(
r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#,
r#"<a:prstGeom prst="ellipse"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#,
)
.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
let wb = read_drawing(&drawing);
let shape = &wb.sheet(0).unwrap().shapes[0];
assert_eq!(shape.kind, ShapeKind::Ellipse);
assert_eq!(shape.fill, Color::Rgb(0xFF0000));
let xml = written_drawing(&wb);
assert!(xml.contains(r#"<a:prstGeom prst="ellipse">"#), "{xml}");
assert!(xml.contains(r#"<a:solidFill><a:srgbClr val="FF0000"/></a:solidFill>"#), "{xml}");
assert!(!xml.contains(r#"txBox="1""#), "{xml}");
let (reopened, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
assert_eq!(reopened.sheet(0).unwrap().shapes[0].kind, ShapeKind::Ellipse);
// The same warp on a filled rectangle that is not a text box keeps it a rectangle too.
let rect = drawing.replace(r#"prst="ellipse""#, r#"prst="rect""#);
assert_eq!(read_drawing(&rect).sheet(0).unwrap().shapes[0].kind, ShapeKind::Rectangle);
// WordArt as written (a text box) or as a fill-less, line-less rectangle still reads as WordArt.
let text_box = ARABIC_TEXT_BOX.replace(r#"anchor="t"/>"#, r#"anchor="t"><a:prstTxWarp prst="textNoShape"><a:avLst/></a:prstTxWarp></a:bodyPr>"#);
assert_eq!(read_drawing(&text_box).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
let bare = text_box
.replace(r#"<xdr:cNvSpPr txBox="1"/>"#, "<xdr:cNvSpPr/>")
.replace(r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>"#, r#"<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:noFill/>"#);
assert_eq!(read_drawing(&bare).sheet(0).unwrap().shapes[0].kind, ShapeKind::WordArt);
}
/// #2170: a shape font typeface holding `"`, `<` or `&` is escaped as an attribute, so it can't
/// close the `typeface` attribute early and inject others; it survives a save and reopen.
#[test]
+355
View File
@@ -0,0 +1,355 @@
//! `.xls` data validation: DVal and Dv records ([MS-XLS] 2.4.96, 2.4.95). Every record is
//! assembled here from the spec's structures; no Excel-produced file is used. The expected
//! rules are what the `.xlsx` reader gives for the same rule.
use gridcraft_core::{CellRef, RangeRef};
use gridcraft_model::{CfOperator, ErrorStyle, Validation, ValidationKind, Workbook};
use super::xls_tests::{cat, compound, rec, u16s};
use crate::{ReadReport, read_xls};
fn bof(dt: u16) -> Vec<u8> {
rec(0x0809, &cat(&[&u16s(&[0x0600, dt, 0x0DBB, 0x07CC]), &[0; 8]]))
}
fn eof() -> Vec<u8> {
rec(0x000A, &[])
}
/// A workbook of one worksheet, "Sheet1", holding `records`, with defined names (Lbl records,
/// each a name and its formula) in the globals.
fn workbook(names: &[(&str, &[u8])], records: &[Vec<u8>]) -> (Workbook, ReadReport) {
let lbl = |name: &str, rgce: &[u8]| {
rec(0x0018, &cat(&[&u16s(&[0]), &[0, name.len() as u8], &u16s(&[rgce.len() as u16, 0, 0]), &[0; 4], &[0], name.as_bytes(), rgce]))
};
let lbls: Vec<u8> = names.iter().flat_map(|(n, f)| lbl(n, f)).collect();
let globals = |pos: u32| cat(&[&bof(0x0005), &rec(0x0085, &cat(&[&pos.to_le_bytes(), &[0, 0, 6, 0], b"Sheet1"])), &lbls, &eof()]);
let body: Vec<u8> = records.concat();
let sheet = cat(&[&bof(0x0010), &body, &eof()]);
let stream = cat(&[&globals(globals(0).len() as u32), &sheet]);
read_xls(&compound("Workbook", &stream)).unwrap()
}
/// DVal: flags, the input window's position, no drop-down object (-1), then `count` rules.
fn dval(count: u32) -> Vec<u8> {
rec(0x01B2, &cat(&[&[0, 0], &[0; 8], &(-1i32).to_le_bytes(), &count.to_le_bytes()]))
}
// Dv flag bits ([MS-XLS] 2.4.95).
const STR_LOOKUP: u32 = 1 << 7;
const ALLOW_BLANK: u32 = 1 << 8;
const SUPPRESS_COMBO: u32 = 1 << 9;
const SHOW_INPUT: u32 = 1 << 18;
const SHOW_ERROR: u32 = 1 << 19;
/// What Excel sets on a new rule: blanks allowed, input message and error alert shown.
const DEFAULTS: u32 = ALLOW_BLANK | SHOW_INPUT | SHOW_ERROR;
/// Dv flags: rule type, alert style, operator, plus `bits`.
fn flags(kind: u32, alert: u32, op: u32, bits: u32) -> u32 {
kind | (alert << 4) | (op << 20) | bits
}
/// XLUnicodeString with compressed characters; "" is stored as Excel stores an empty
/// message, a single NUL character.
fn msg(s: &str) -> Vec<u8> {
let s = if s.is_empty() { "\0" } else { s };
cat(&[&(s.len() as u16).to_le_bytes(), &[0], s.as_bytes()])
}
/// XLUnicodeString with UTF-16 characters.
fn wide(s: &str) -> Vec<u8> {
let units: Vec<u16> = s.encode_utf16().collect();
cat(&[&(units.len() as u16).to_le_bytes(), &[1], &u16s(&units)])
}
/// DVParsedFormula: size, 2 unused bytes (Excel leaves stray data there), the tokens.
fn formula(rgce: &[u8]) -> Vec<u8> {
cat(&[&(rgce.len() as u16).to_le_bytes(), &[0x6E, 0x74], rgce])
}
/// SqRefU: a count, then each Ref8U as rows (first, last) and columns (first, last).
fn sqref(ranges: &[[u16; 4]]) -> Vec<u8> {
let list: Vec<u8> = ranges.iter().flat_map(|r| u16s(r)).collect();
cat(&[&(ranges.len() as u16).to_le_bytes(), &list])
}
/// A Dv record with its four messages (prompt title, error title, prompt, error).
fn dv(flags: u32, msgs: [Vec<u8>; 4], f1: &[u8], f2: &[u8], ranges: &[[u16; 4]]) -> Vec<u8> {
let [pt, et, p, e] = msgs;
rec(0x01BE, &cat(&[&flags.to_le_bytes(), &pt, &et, &p, &e, &formula(f1), &formula(f2), &sqref(ranges)]))
}
fn no_msgs() -> [Vec<u8>; 4] {
[msg(""), msg(""), msg(""), msg("")]
}
/// A Dv with Excel's default flags and no messages.
fn rule(kind: u32, alert: u32, op: u32, f1: &[u8], f2: &[u8], ranges: &[[u16; 4]]) -> Vec<u8> {
dv(flags(kind, alert, op, DEFAULTS), no_msgs(), f1, f2, ranges)
}
fn range(a1: &str) -> RangeRef {
RangeRef::parse(a1).unwrap()
}
/// The `.xlsx` reader's result for a rule with Excel's default flags and no messages.
fn expected(ranges: &[&str], kind: ValidationKind, op: CfOperator, f1: &str, f2: Option<&str>) -> Validation {
Validation { ranges: ranges.iter().map(|r| range(r)).collect(), kind, op, f1: f1.into(), f2: f2.map(Into::into), ..Validation::default() }
}
fn hex(s: &str) -> Vec<u8> {
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()).collect()
}
const SKIPPED: &str = "Some data validation rules in .xls files could not be read and were skipped.";
const IGNORED: &str = "Some data validation records in .xls files were ignored, as Excel ignores them: they have no DVal record before them, or come after the number of rules it gives.";
/// Each kind of rule, as Excel writes it, gives what the `.xlsx` reader gives for the same rule.
#[test]
fn reads_every_kind_of_rule() {
use CfOperator::*;
use ValidationKind::*;
let rules = [
// Whole number between 1 and 10, with all four messages and the stop alert.
dv(
flags(1, 0, 0, DEFAULTS),
[msg("Whole"), msg("Oops"), msg("Enter 1 to 10"), msg("Not 1..10")],
&hex("1e0100"),
&hex("1e0a00"),
&[[1, 5, 1, 1]],
),
// Decimal >= 0.5, warning alert.
rule(2, 1, 6, &hex("1f000000000000e03f"), &[], &[[1, 5, 2, 2]]),
// Literal list (NUL-separated items), information alert.
dv(flags(3, 2, 0, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170e0052656400477265656e00426c7565"), &[], &[[1, 5, 3, 3]]),
// List from an absolute range ($H$2:$H$5).
rule(3, 0, 0, &hex("250100040007000700"), &[], &[[1, 5, 4, 4]]),
// Date between two serials, time < 0.5, text length <= 5, whole not between 5 and 7.
rule(4, 0, 0, &hex("1e37ab"), &hex("1ea4ac"), &[[1, 5, 5, 5]]),
rule(5, 0, 5, &hex("1f000000000000e03f"), &[], &[[1, 5, 6, 6]]),
rule(6, 0, 7, &hex("1e0500"), &[], &[[1, 5, 8, 8]]),
rule(1, 0, 1, &hex("1e0500"), &hex("1e0700"), &[[1, 5, 9, 9]]),
// Custom formula K2>A2 on K2:K6 (relative PtgRefN offsets 0 and -10 columns).
rule(7, 0, 0, &hex("4c000000c04c0000f6c00d"), &[], &[[1, 5, 10, 10]]),
// Any value, with an input message only.
dv(flags(0, 0, 0, DEFAULTS), [msg("Note"), msg(""), msg("Anything goes"), msg("")], &[], &[], &[[1, 5, 13, 13]]),
// A list from a defined name.
rule(3, 0, 0, &hex("2301000000"), &[], &[[1, 5, 16, 16]]),
// A dependent list: INDIRECT($S2) on T2:T6.
rule(3, 0, 0, &hex("190100004c0000128022019400"), &[], &[[1, 5, 19, 19]]),
// Decimal between two absolute cells.
rule(2, 0, 0, &hex("4406000700"), &hex("4407000700"), &[[1, 5, 22, 22]]),
];
let mut records = vec![dval(rules.len() as u32)];
records.extend(rules);
let (wb, report) = workbook(&[("Colors", &[0x1E, 1, 0])], &records);
let got = &wb.sheets[0].validations;
let mut whole = expected(&["B2:B6"], Whole, Between, "1", Some("10"));
(whole.input_title, whole.error_title, whole.input_message, whole.error_message) =
("Whole".into(), "Oops".into(), "Enter 1 to 10".into(), "Not 1..10".into());
let mut decimal = expected(&["C2:C6"], Decimal, GreaterOrEqual, "0.5", None);
decimal.error_style = ErrorStyle::Warning;
let mut literal = expected(&["D2:D6"], List, Between, "\"Red,Green,Blue\"", None);
literal.error_style = ErrorStyle::Information;
let mut any = expected(&["N2:N6"], Any, Between, "", None);
(any.input_title, any.input_message) = ("Note".into(), "Anything goes".into());
let want = vec![
whole,
decimal,
literal,
expected(&["E2:E6"], List, Between, "=$H$2:$H$5", None),
expected(&["F2:F6"], Date, Between, "43831", Some("44196")),
expected(&["G2:G6"], Time, Less, "0.5", None),
expected(&["I2:I6"], TextLength, LessOrEqual, "5", None),
expected(&["J2:J6"], Whole, NotBetween, "5", Some("7")),
expected(&["K2:K6"], Custom, Between, "K2>A2", None),
any,
expected(&["Q2:Q6"], List, Between, "=Colors", None),
expected(&["T2:T6"], List, Between, "=INDIRECT($S2)", None),
expected(&["W2:W6"], Decimal, Between, "$H$7", Some("$H$8")),
];
assert_eq!(got.len(), want.len(), "{:?}", report.warnings);
for (g, w) in got.iter().zip(&want) {
assert_eq!(g, w);
}
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
assert!(report.warnings[0].contains("data validation were read"), "{:?}", report.warnings);
}
/// The flags that switch features off; an operator stored on a list (where it means nothing);
/// messages in UTF-16; literal items with quotes and spaces; a whole column.
#[test]
fn reads_flags_messages_lists_and_whole_columns() {
let off = flags(3, 0, 0, STR_LOOKUP | SUPPRESS_COMBO);
let records = [
dval(4),
dv(off, no_msgs(), &hex("170300610062"), &[], &[[1, 5, 14, 14]]),
dv(
flags(1, 0, 2, DEFAULTS),
[wide("Café"), wide("Ошибка"), wide("日本語のテスト é ü"), wide("Nur 3 ✓")],
&hex("1e0300"),
&[],
&[[1, 5, 20, 20]],
),
// typOperator 6 on a list, as found in real files: ignored.
dv(flags(3, 0, 6, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170c00736179202268692200627965"), &[], &[[1, 5, 23, 23]]),
// Rows 0-65535: every row of the BIFF8 grid.
dv(flags(3, 0, 0, DEFAULTS | STR_LOOKUP), no_msgs(), &hex("170500780079007a"), &[], &[[0, 0xFFFF, 21, 21]]),
];
let (wb, report) = workbook(&[], &records);
let got = &wb.sheets[0].validations;
assert_eq!(got.len(), 4, "{:?}", report.warnings);
let mut flags_off = expected(&["O2:O6"], ValidationKind::List, CfOperator::Between, "\"a,b\"", None);
(flags_off.allow_blank, flags_off.in_cell_dropdown, flags_off.show_input, flags_off.show_error) = (false, false, false, false);
assert_eq!(got[0], flags_off);
let mut unicode = expected(&["U2:U6"], ValidationKind::Whole, CfOperator::Equal, "3", None);
(unicode.input_title, unicode.error_title, unicode.input_message, unicode.error_message) =
("Café".into(), "Ошибка".into(), "日本語のテスト é ü".into(), "Nur 3 ✓".into());
assert_eq!(got[1], unicode);
// The item `say "hi"` keeps its quotes, doubled as in `.xlsx`; the operator is ignored.
assert_eq!(got[2], expected(&["X2:X6"], ValidationKind::List, CfOperator::Between, "\"say \"\"hi\"\",bye\"", None));
let column = &got[3];
assert_eq!(column.ranges, vec![RangeRef::cols(21, 21)]);
assert!(column.ranges[0].is_full_cols());
}
/// Relative references are offsets from the top-left cell of all the rule's ranges, whatever
/// order the ranges are listed in (AF5:AF9 then AG1:AG3 has its formula written for AF1). An
/// offset Excel couldn't keep inside the grid is stored as PtgRefErr and reads as #REF!.
#[test]
fn relative_references_start_from_the_top_left_of_all_ranges() {
let records = [
dval(3),
// =AE1>0 for AF5:AF9 AG1:AG3 (column offset -1 from AF1).
rule(7, 0, 0, &hex("4c0000ffc01e00000d"), &[], &[[4, 8, 31, 31], [0, 2, 32, 32]]),
// =AB1>0 for AC1:AC3 AA5:AA9 (column offset +1 from AA1).
rule(7, 0, 0, &hex("4c000001c01e00000d"), &[], &[[0, 2, 28, 28], [4, 8, 26, 26]]),
// PtgRefErr: a reference that fell off the grid.
rule(7, 0, 0, &hex("2a0000f4c01e00000d"), &[], &[[4, 8, 12, 12], [0, 2, 11, 11]]),
];
let (wb, report) = workbook(&[], &records);
let got = &wb.sheets[0].validations;
assert_eq!(got.len(), 3, "{:?}", report.warnings);
assert_eq!(got[0].ranges, vec![range("AF5:AF9"), range("AG1:AG3")]);
assert_eq!(got[0].f1, "AE1>0");
assert_eq!(got[1].f1, "AB1>0");
assert_eq!(got[2].f1, "#REF!>0");
}
/// As in Excel, a DVal's count is authoritative: Dv records past it, or with no DVal before
/// them, aren't read. That's said once, and not as a rule that failed to read.
#[test]
fn only_the_dvals_count_of_rules_is_read() {
let a = || rule(1, 0, 4, &hex("1e0000"), &[], &[[0, 0, 0, 0]]);
let b = || rule(1, 0, 4, &hex("1e0000"), &[], &[[1, 1, 0, 0]]);
let (wb, report) = workbook(&[], &[dval(1), a(), b(), b()]);
assert_eq!(wb.sheets[0].validations.iter().map(|v| v.ranges.clone()).collect::<Vec<_>>(), vec![vec![range("A1")]]);
assert_eq!(report.warnings.iter().filter(|w| *w == IGNORED).count(), 1, "{:?}", report.warnings);
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
let (wb, report) = workbook(&[], &[a(), b()]);
assert!(wb.sheets[0].validations.is_empty());
assert!(report.warnings.iter().any(|w| w == IGNORED), "{:?}", report.warnings);
assert!(!report.warnings.iter().any(|w| w == SKIPPED), "{:?}", report.warnings);
}
/// Malformed rules never fail the import. Where Excel opens such a file, the result is what
/// Excel shows; where Excel refuses the whole file, that one rule is skipped with a warning.
#[test]
fn malformed_rules_are_repaired_or_skipped() {
let ok = |row: u16| rule(1, 0, 4, &hex("1e0000"), &[], &[[row, row, 0, 0]]);
let cases: Vec<(&str, Vec<u8>, Option<&str>)> = vec![
// Excel opens an unknown type as "any value".
("unknown type", rule(9, 0, 0, &hex("1e0100"), &[], &[[1, 5, 1, 1]]), Some("B2:B6")),
// Excel refuses an unknown alert style, or an unknown operator on a rule that uses one.
("unknown alert", rule(1, 5, 0, &hex("1e0100"), &hex("1e0a00"), &[[1, 5, 1, 1]]), None),
("unknown operator", rule(1, 0, 9, &hex("1e0100"), &[], &[[1, 5, 1, 1]]), None),
// Excel drops a reversed range, and clamps columns past IV.
("reversed range", rule(1, 0, 4, &hex("1e0000"), &[], &[[5, 1, 2, 2]]), None),
("past column IV", rule(1, 0, 4, &hex("1e0000"), &[], &[[1, 5, 3, 0x100]]), Some("D2:IV6")),
// Excel refuses a range count or a formula size that runs past the record.
(
"too many ranges",
rec(
0x01BE,
&cat(&[
&flags(1, 0, 4, DEFAULTS).to_le_bytes(),
&no_msgs().concat(),
&formula(&hex("1e0000")),
&formula(&[]),
&u16s(&[2, 1, 5, 4, 4]),
]),
),
None,
),
(
"formula past the record",
rec(0x01BE, &cat(&[&flags(1, 0, 4, DEFAULTS).to_le_bytes(), &no_msgs().concat(), &u16s(&[200, 0]), &hex("1e0000")])),
None,
),
("truncated message", rec(0x01BE, &cat(&[&flags(1, 0, 4, DEFAULTS).to_le_bytes(), &u16s(&[40]), &[0], b"short"])), None),
("empty record", rec(0x01BE, &[]), None),
// A formula GridCraft can't decode (an unknown token).
("unknown token", rule(7, 0, 0, &[0xFF], &[], &[[1, 5, 1, 1]]), None),
// No ranges at all.
("no ranges", rule(1, 0, 4, &hex("1e0000"), &[], &[]), None),
];
for (what, bad, want) in cases {
let (wb, report) = workbook(&[], &[dval(3), ok(10), bad, ok(11)]);
let got = &wb.sheets[0].validations;
let ranges: Vec<RangeRef> = got.iter().flat_map(|v| v.ranges.clone()).collect();
let mut expect = vec![range("A11")];
expect.extend(want.map(range));
expect.push(range("A12"));
assert_eq!(ranges, expect, "{what}: {:?}", report.warnings);
assert_eq!(report.warnings.iter().any(|w| w == SKIPPED), want.is_none(), "{what}: {:?}", report.warnings);
if what == "unknown type" {
assert_eq!((got[1].kind, got[1].f1.as_str(), got[1].f2.as_deref()), (ValidationKind::Any, "", None));
}
}
}
/// Hostile counts and sizes never panic or allocate from the file's numbers.
#[test]
fn hostile_validation_records_never_panic() {
let f = flags(1, 0, 4, DEFAULTS).to_le_bytes();
let big = [
rec(0x01B2, &[0xFF; 18]),
rec(0x01BE, &cat(&[&f, &u16s(&[0xFFFF]), &[1]])),
rec(0x01BE, &cat(&[&f, &no_msgs().concat(), &u16s(&[0xFFFF, 0xFFFF])])),
rec(0x01BE, &cat(&[&f, &no_msgs().concat(), &formula(&[]), &formula(&[]), &u16s(&[0xFFFF])])),
// A literal list whose PtgStr claims 255 UTF-16 characters and holds none.
rec(
0x01BE,
&cat(&[
&flags(3, 0, 0, DEFAULTS | STR_LOOKUP).to_le_bytes(),
&no_msgs().concat(),
&formula(&[0x17, 0xFF, 1]),
&formula(&[]),
&sqref(&[[0, 0, 0, 0]]),
]),
),
// Every flag bit set: type 15, alert 7, operator 15.
rec(0x01BE, &cat(&[&[0xFF; 4], &no_msgs().concat(), &formula(&[]), &formula(&[]), &sqref(&[[0, 0, 0, 0]])])),
];
let (wb, _) = workbook(&[], &big);
assert!(wb.sheets[0].validations.is_empty());
let mut many = vec![dval(u32::MAX)];
many.extend((0..200u16).map(|r| rule(1, 0, 4, &hex("1e0000"), &[], &[[r, r, 0, 0]])));
let (wb, _) = workbook(&[], &many);
assert_eq!(wb.sheets[0].validations.len(), 200);
assert_eq!(wb.sheets[0].validations[199].ranges, vec![RangeRef::cell(CellRef::new(199, 0))]);
}
/// Rules with no messages cost no text, so a crafted file could otherwise keep millions of them:
/// the workbook keeps 65,536 and says it skipped the rest.
#[test]
fn rules_past_the_workbook_limit_are_skipped() {
let one = rule(0, 0, 0, &[], &[], &[[0, 0, 0, 0]]);
let mut records = vec![dval(65_537)];
records.extend(std::iter::repeat_n(one, 65_537));
let (wb, report) = workbook(&[], &records);
assert_eq!(wb.sheets[0].validations.len(), 65_536);
// craft-xls counts the rules past its cap with the ones it could not read.
assert_eq!(report.warnings.iter().filter(|w| *w == SKIPPED).count(), 1, "{:?}", report.warnings);
assert!(!report.warnings.iter().any(|w| w == IGNORED), "{:?}", report.warnings);
}
+3 -3
View File
@@ -133,18 +133,18 @@ pub(crate) fn compound(name: &str, data: &[u8]) -> Vec<u8> {
out
}
fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
pub(super) fn rec(id: u16, payload: &[u8]) -> Vec<u8> {
let mut v = id.to_le_bytes().to_vec();
v.extend_from_slice(&(payload.len() as u16).to_le_bytes());
v.extend_from_slice(payload);
v
}
fn cat(parts: &[&[u8]]) -> Vec<u8> {
pub(super) fn cat(parts: &[&[u8]]) -> Vec<u8> {
parts.concat()
}
fn u16s(v: &[u16]) -> Vec<u8> {
pub(super) fn u16s(v: &[u16]) -> Vec<u8> {
v.iter().flat_map(|x| x.to_le_bytes()).collect()
}
+69 -18
View File
@@ -13,8 +13,9 @@ use craft_xls as x;
use gridcraft_core::{CellError, CellRef, DateSystem, RangeRef, Value};
use gridcraft_model::style::Protection;
use gridcraft_model::{
Alignment, BorderLine, BorderStyle, Borders, CalcMode, CalcSettings, Cell, Color, DefinedName, Fill, Font, Formula, HAlign, LineInfo, NumFmt,
PatternType, Sheet, Style, StyleId, StyleTable, Underline, VAlign, VertAlign, Visibility, Workbook,
Alignment, BorderLine, BorderStyle, Borders, CalcMode, CalcSettings, Cell, CfOperator, Color, DefinedName, ErrorStyle, Fill, Font, Formula,
HAlign, LineInfo, NumFmt, PatternType, Sheet, Style, StyleId, StyleTable, Underline, VAlign, Validation, ValidationKind, VertAlign, Visibility,
Workbook,
};
use crate::tables::{col_width_to_px, pt_to_px};
@@ -72,8 +73,8 @@ fn warning(w: &x::Warning) -> String {
W::Charts => "Charts in .xls files are not imported yet.".into(),
W::PicturesSkipped => "Some pictures in .xls files were skipped.".into(),
W::ConditionalFormattingSkipped | W::ConditionalFormatting12 => "Some conditional formatting in .xls files was not imported.".into(),
W::DataValidationSkipped => "Some data validation in .xls files could not be read and was skipped.".into(),
W::DataValidationIgnored => "Some data validation records in .xls files were ignored, as Excel ignores them.".into(),
W::DataValidationSkipped => "Some data validation rules in .xls files could not be read and were skipped.".into(),
W::DataValidationIgnored => "Some data validation records in .xls files were ignored, as Excel ignores them: they have no DVal record before them, or come after the number of rules it gives.".into(),
W::Hyperlinks => "Hyperlinks in .xls files are not imported yet.".into(),
W::PivotTables => "PivotTables in .xls files are not imported yet; their values were kept.".into(),
W::CellsOutsideGrid(n) => format!("{n} cells outside the grid were skipped."),
@@ -113,23 +114,19 @@ pub(crate) fn read_xls_budgeted(bytes: &[u8], limits: x::Limits, functions: Func
}
let mut unparsed = false;
for s in &book.sheets {
// craft-xls keeps these rules in its read model, but GridCraft's XLS adapter does not
// import conditional formatting. Its own warnings cover rules it could not decode; this
// reports the additional rules that this adapter drops.
if !s.conditional_formats.is_empty() {
report.warn(warning(&x::Warning::ConditionalFormattingSkipped));
}
// Data validation is not yet imported by this adapter.
if !s.validations.is_empty() {
report.warn(warning(&x::Warning::DataValidationSkipped));
// Report rules craft-xls kept that the adapter cannot represent. craft-xls already warns
// about records it could not decode.
for (dropped, skipped) in [
(!s.conditional_formats.is_empty(), x::Warning::ConditionalFormattingSkipped),
(s.validations.iter().any(|v| validation_of(v).is_none()), x::Warning::DataValidationSkipped),
] {
if dropped {
report.warn(warning(&skipped));
}
}
let sheet = sheet_of(s, &styles, &mut unparsed);
wb.sheets.push(Arc::new(sheet));
}
// craft-xls reads the rules; GridCraft does not take them from .xls yet.
if book.sheets.iter().any(|s| !s.validations.is_empty()) {
report.warn("Data validation in .xls files is not imported yet.");
}
if unparsed {
report.warn("Some formulas use syntax GridCraft can't parse yet; their saved results were kept as values.");
}
@@ -149,7 +146,7 @@ pub(crate) fn read_xls_budgeted(bytes: &[u8], limits: x::Limits, functions: Func
// The overview comes first.
report.warnings.insert(
0,
"Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights and defined names were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
"Imported from an Excel 97-2003 (.xls) workbook: values, formulas, cell formatting, merged cells, column widths, row heights, defined names and data validation were read. Charts, pictures, comments and other features are not imported yet. Save as .xlsx; saving .xls is not supported.".into(),
);
Ok((wb, report))
}
@@ -261,6 +258,7 @@ fn sheet_of(s: &x::Sheet, styles: &[StyleId], unparsed: &mut bool) -> Sheet {
sheet.show_zeros = v.show_zeros;
sheet.right_to_left = v.right_to_left;
}
sheet.validations = s.validations.iter().filter_map(validation_of).collect();
if let Some(text) = &s.print_area {
sheet.print.print_area = crate::read::parse_print_area(text);
}
@@ -272,6 +270,59 @@ fn sheet_of(s: &x::Sheet, styles: &[StyleId], unparsed: &mut bool) -> Sheet {
sheet
}
/// A data validation rule, as `sheet_read` maps a SpreadsheetML `dataValidation`: a list that
/// is not a literal refers to cells (`=…`).
fn validation_of(v: &x::DataValidation) -> Option<Validation> {
let ranges: Vec<RangeRef> = v.ranges.iter().map(|r| range(*r)).collect();
if ranges.is_empty() {
return None;
}
let kind = match v.kind {
x::ValidationType::Whole => ValidationKind::Whole,
x::ValidationType::Decimal => ValidationKind::Decimal,
x::ValidationType::List => ValidationKind::List,
x::ValidationType::Date => ValidationKind::Date,
x::ValidationType::Time => ValidationKind::Time,
x::ValidationType::TextLength => ValidationKind::TextLength,
x::ValidationType::Custom => ValidationKind::Custom,
_ => ValidationKind::Any,
};
let f1 = if kind == ValidationKind::List && !v.formula1.is_empty() && !v.formula1.starts_with('"') {
format!("={}", v.formula1)
} else {
v.formula1.clone()
};
Some(Validation {
ranges,
kind,
op: match v.operator {
x::Operator::NotBetween => CfOperator::NotBetween,
x::Operator::Equal => CfOperator::Equal,
x::Operator::NotEqual => CfOperator::NotEqual,
x::Operator::Greater => CfOperator::Greater,
x::Operator::Less => CfOperator::Less,
x::Operator::GreaterOrEqual => CfOperator::GreaterOrEqual,
x::Operator::LessOrEqual => CfOperator::LessOrEqual,
_ => CfOperator::Between,
},
f1,
f2: v.formula2.clone(),
allow_blank: v.allow_blank,
in_cell_dropdown: v.in_cell_dropdown,
input_title: v.prompt_title.clone(),
input_message: v.prompt.clone(),
show_input: v.show_prompt,
error_title: v.error_title.clone(),
error_message: v.error.clone(),
error_style: match v.error_style {
x::ErrorStyle::Warning => ErrorStyle::Warning,
x::ErrorStyle::Information => ErrorStyle::Information,
_ => ErrorStyle::Stop,
},
show_error: v.show_error,
})
}
fn color(c: x::Color) -> Color {
match c {
x::Color::Rgb(rgb) => Color::Rgb(rgb),
+3 -2
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-12 · **Change:** minor (.xls data validation is read; earlier: .xls is read by the shared craft-xls crate: the workbook opens on the sheet it was saved on, and rich-text (RString) cells are read; earlier: form controls: buttons, scroll bars and spin buttons read and written; earlier: a web page's first table opens as a sheet, also when the page is named `.xls`; earlier: `workbookPr filterPrivacy` kept, and honoured on save; earlier: a workbook's R1C1 reference style, `calcPr refMode`, is kept through open and save; earlier: XLSX page margins are written as their decimals, `0.3` rather than `0.30000001192092896`; earlier: Python cells in `.xlsx` as [MS-XLSX] specifies, `_xlfn._xlws.PY(…)` naming code in the Python part, with cached values, and in GridCraft JSON, with limits on open; earlier: equations in xlsx as Office Math with a marker in `descr` so an edit made in Excel reads back, read back only up to the equation source cap, and a brace with a label and a script on its other side keeps both; Symbol font in PDF; earlier: XLSX reads and writes Page Setup's page order, `pageSetup pageOrder`, and PDF export follows it; earlier: chart data tables: native c:dTable import/export; earlier: XLSX workbook-view selection, normalized tab ratios and sheet-edit regressions; fidelity estimates unchanged; earlier: formulas from Google Sheets' XLSX export open as the original formula instead of `IFERROR(__xludf.DUMMYFUNCTION(…),…)` when GridCraft calculates every function in it, and save as it; `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; earlier: `file.exportCsv {values: true}` writes raw values for automation; .xls dialog sheets are skipped instead of read as worksheets; `.xls` import reads each sheet's display options from WINDOW2: right-to-left, gridlines, headings, zeros, formulas; XLSX text boxes and shapes keep their paragraphs' right-to-left direction on save; CSV/TSV/TXT can open with every field kept as text via `file.open {csvAsText}`; formulas over Excel's 8,192 characters, and `%` or call chains past the nesting limit, are parse errors instead of stack overflows when a file is opened; XLSX PivotTables read and write `rowGrandTotals`/`colGrandTotals` the way Excel does, so a table saved with only one grand total opens with the right one; HTML export keeps right-to-left; CSV and text import detects likely Windows-1256 Arabic when invalid UTF-8 has Arabic byte runs; other invalid UTF-8 remains Windows-1252; HTML export places pictures and charts, as inline SVG, at their anchors; CSV and text open detects each column's date order from values that read only one way, warns when every date is ambiguous, and keeps a column with both kinds as text (#2765)) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Every format Excel opens or saves, with GridCraft's read and write support, fidelity and tests.
Part of [target-app-parity.md](target-app-parity.md). **File formats: ~50% ready, 80–120 h**
@@ -92,7 +92,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real
| `.xlsm` Macro-Enabled Workbook | R/W | yes | yes, macros kept, not run | The VBA project is kept byte-for-byte and written back only into `.xlsm`/`.xltm` (#6); macros never run. Saved with the macro-enabled workbook content type | `roundtrip.rs` (`vba_project_roundtrip`, `package_kind_sets_workbook_content_type`), engine `io.rs` (`macros_are_saved_only_into_xlsm`) |
| `.xltx` / `.xltm` templates | R/W | yes | partial | Saved with the template (and macro-enabled template) content type, chosen by extension; `.xltm` keeps a VBA project, `.xltx` drops it; "new from template" behaviour missing | `roundtrip.rs` (`package_kind_sets_workbook_content_type`), engine `io.rs` |
| `.xlsb` Binary Workbook | R/W | **values only** (#101) | no | Names, visibility, numbers, text, booleans, errors, merges, cached formula results; no formulas, formatting, charts, names | `xlsb_tests.rs` |
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Every formula except a Ctrl+Shift+Enter array is a legacy one and opens with Excel's implicit `@`, as an `.xlsx` formula without `cm` does (#410); Ctrl+Shift+Enter arrays, one cell or several, stay arrays, and saving as `.xlsx` writes the formulas without `cm`. Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, validation, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs` |
| `.xls` Excel 97-2003 (BIFF8) | R/W | **yes, partial** (#121) | no (Save As `.xlsx`) | Clean-room from [MS-CFB] and [MS-XLS]. Read: worksheets and visibility, shared strings, numbers, text, booleans, errors, formulas (text for the common tokens, shared and array formulas, 3-D refs, names, newer functions called by name) with cached results, number formats (dates display as dates), cell formatting (fonts, solid and pattern fills, borders with diagonals, alignment with rotation, indent, wrap and shrink, locked and hidden, the custom colour palette, the Normal font as the workbook default, user-defined cell styles), 1904 date system, merges, column widths, row heights, defined names, print area and titles, calculation settings (mode, iteration with its maximum iterations and change, precision as displayed), data validation rules, sheet display options (right-to-left, gridlines, headings, zero values, formulas; a truncated WINDOW2 is ignored). Every formula except a Ctrl+Shift+Enter array is a legacy one and opens with Excel's implicit `@`, as an `.xlsx` formula without `cm` does (#410); Ctrl+Shift+Enter arrays, one cell or several, stay arrays, and saving as `.xlsx` writes the formulas without `cm`. Not read: exact colours beyond the 56-colour palette (XFExt: theme colours and tints show as the palette colour Excel stored next to them), rich-text runs inside a cell, charts, pictures, comments, CF, hyperlinks, freeze panes, external links (formula kept as its value). Chart and macro sheets skipped with a warning; encrypted (FILEPASS) and BIFF5 (Excel 5.0/95) files refused with a message. Opens as a new unsaved workbook. Unverified on a real-world corpus | `xls_tests.rs`, `xls_style_tests.rs`, `xls_dv_tests.rs` |
| `.xlt` Excel 97-2003 template | R/W | yes (read as `.xls`) | no | | — |
| `.xlam` / `.xla` add-ins | R/W | no | no | VBA; out of scope | — |
| `.xml` XML Spreadsheet 2003 | R/W | no | no | Simple to support | — |
@@ -161,6 +161,7 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-11-12 | minor | `.xls` files open with their data validation (drop-down lists, input messages, error alerts and the rules), read by craft-xls and mapped as the `.xlsx` reader maps the same rules. A rule Excel would refuse, or one past 65,536 in a workbook, is skipped with a warning (#3634, from #3636) |
| 2026-11-12 | minor | .xls import reads the file with craft-xls (storytold/craft-libs), the reader that moved there from this repository; GridCraft keeps the mapping onto its model. The workbook now opens on the sheet it was saved on (WINDOW1 `itabCur`), falling back to the first visible sheet as before (from #3551), and cells saved as RString records (text with formatting runs) are read as text instead of skipped |
| 2026-11-10 | minor | `.xls` import: every formula except a Ctrl+Shift+Enter array is read as a legacy one with Excel's implicit `@` (a title row `=Other!A1:G1` shows its first cell instead of `#SPILL!`), one-cell Ctrl+Shift+Enter arrays stay arrays, and `@` of a 2-D range meets the formula's row and column (#410, PR #1367) |
| 2026-11-02 | minor | XLSX: buttons, scroll bars and spin buttons read and written (control properties parts, `<controls>`, VML `x:ClientData`); other form controls stay with pass-through; a button's command, fixed input or output cell travels in a hidden defined name and comes back only while the file still assigns the button's macro (or none, for a command) |
+1 -1
View File
@@ -155,7 +155,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
### 17. Insert objects (F)
- Ink strokes now survive XLSX save/reopen as editable polylines. Inserted icons still lose their
identity/artwork on save (#551); native Office InkML import is not implemented.
- SmartArt, WordArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170;
- SmartArt, signature line, objects, 3-D models, screenshot; 8 shape kinds vs ~170;
pictures in cells (#61, PR #105). Equations exist (LaTeX source with a live preview, Office Math in xlsx); there is
no structured (WYSIWYG) equation editing and no ink equations. **Estimate:** 25–40 h.
+4 -4
View File
@@ -4,13 +4,13 @@
Each entry is an Excel ribbon or menu command (feature names only). It counts as implemented when GridCraft registers a command with the same id. This measures breadth only; the overall assessment (depth, fidelity, effort) is in [target-app-parity.md](target-app-parity.md).
**Overall: 281 / 296 commands implemented (94.9%).**
**Overall: 282 / 296 commands implemented (95.3%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
| File | 11 | 11 | 100% |
| Home | 94 | 94 | 100% |
| Insert | 18 | 25 | 72% |
| Insert | 19 | 25 | 76% |
| Draw | 4 | 4 | 100% |
| Page Layout | 23 | 23 | 100% |
| Formulas | 23 | 23 | 100% |
@@ -140,7 +140,7 @@ Implemented:
- `edit.goToSpecial` Go To Special (Editing)
- `home.addIns` Add-ins (Add-ins)
## Insert (18 / 25, 72%)
## Insert (19 / 25, 76%)
Implemented:
@@ -159,6 +159,7 @@ Implemented:
- `review.newComment` Comment (Comments)
- `insert.textBox` Text Box (Text)
- `pageLayout.headerFooter` Header & Footer (Text)
- `insert.wordArt` Decorative Text (Text)
- `insert.equation` Equation (Symbols)
- `insert.symbol` Symbol (Symbols)
- `insert.checkbox` Checkbox (Cell Controls)
@@ -169,7 +170,6 @@ Missing:
- `insert.screenshot` Screenshot (Illustrations)
- `insert.map` Maps (Charts)
- `insert.timeline` Timeline (Filters)
- `insert.wordArt` Decorative Text (Text)
- `insert.signatureLine` Signature Line (Text)
- `insert.object` Object (Text)
+1 -1
View File
@@ -90,7 +90,7 @@ performance 8, stability 8, localization 4, hardware 2, platforms 2, ecosystem 2
| Page layout, print, PDF | 5 | ~80% | ~55% | 12–20 | Page setup, print titles, breaks, page order, Page Break Preview, headers/footers, PDF export and printing. Paper sizes match Japanese Excel: B4/B5/B6 are the JIS sizes, ISO B4/B5/B6 stay reachable by name, and Japanese Postcard (はがき), Japanese Double Postcard (往復はがき), the chou/kaku envelopes and A6 are available. No true Page Layout view; the PDF writer uses only the 14 standard fonts without embedding (no CJK or other non-Latin text in PDF); web print only exports (#141, #143) |
| What-if and analysis | 4 | ~50% | ~40% | 22–35 | Goal Seek, Scenario Manager, data tables, Analysis ToolPak (all 19 tools, no charts), Solver (Simplex LP, a local nonlinear method and Evolutionary; Answer, Sensitivity and Limits reports; Load/Save Model, Reset All and Solver Options; no Population report), Forecast Sheet. Missing: Analyze Data |
| Get & Transform, external data, data model | 5 | ~15% | ~10% | 60–100 | Import of CSV/TSV, JSON, HTML. No Power Query editor, no From Web, database or folder sources, no connections or refresh, no relationships or data model, no external workbook links (dropped with a warning) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, WordArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Insert objects | 3 | ~55% | ~45% | 25–40 | Pictures, 8 shape kinds (Excel has ~170), text boxes, WordArt (10 own styles; no warps or gradient fills), own icon set, ink with Ink to Shape, hyperlinks, checkboxes, form buttons, scroll bars and spin buttons, equations (LaTeX with a live preview, Office Math in xlsx, Symbol font in PDF). Missing: SmartArt, signature line, objects, 3-D models, screenshot, pictures in cells (PR #105) |
| Review, comments, protection, collaboration | 4 | ~55% | ~40% | 35–55 | Threaded comments, notes, spelling (system dictionary), thesaurus, sheet and workbook protection, Allow Edit Ranges with Excel-compatible password hashes (#307). Missing: comment navigation, Show Changes, notes-to-comments conversion, accessibility checker, co-authoring, opening password-encrypted files (refused with a message since #17) |
| Automation | 4 | ~30% | ~20% | 12–20 | Recorded, editable GridCraft scripts (Automate tab). Typed macros in the engine (`macro.*`: recorder, runner with input checks and a footprint dry run, debugger; [macros.md](macros.md)), recorded from Automate › Macros, edited and debugged in a step editor, and run from the Macros dialog, a Ctrl+Shift shortcut or a form button (which can also run one command). VBA is out of scope by owner decision: an `.xlsm` keeps its VBA project unchanged on save and never runs it. No Office Scripts compatibility |
| **Weighted** | 100 | **~75%** | **~59%** | **300–485** | |