Equations: Insert › Equation with a LaTeX editor, Office Math in xlsx and equations in PDF (#471)

* Objects: one selection for charts, pictures, shapes and slicers, with eight resize handles

The sheet tracked the selected object as a bare id in `selected_chart`, so
only charts and text boxes drew a selection frame, only the bottom-right
corner resized, and the kind of the selected object had to be guessed from
the id. The selection is now `SheetApp::selected_object: Option<ObjRef>`,
with `selected_chart()` kept for the Chart Design tab and the Format Chart
pane.

- Model: `ObjKind` (chart, image, shape, slicer) and `ObjRef { kind, id }`;
  `Sheet` helpers find, move and remove an object by `ObjRef` and walk every
  object's anchor in a fixed order. `next_object_id` saturates instead of
  overflowing on an id at `u32::MAX` read from a file.
- Commands: `object.delete`, `object.move` and `object.setAnchorMode` accept
  the four kinds through one parameter check, refuse an unknown kind or id
  instead of succeeding silently, and clamp hostile offsets. `object.delete`
  replies with the deleted object. Arrange and the Selection Pane include
  slicers, which stack above the other objects.
- Sheet: the selected object of any kind draws a frame with eight handles
  over every object. Each handle resizes only its own edges, which never
  cross; the body moves the object, and the first drag frame already applies
  the pointer's movement. The pointer shows the handle's resize cursor or a
  move cursor, with the diagonals swapped on a right-to-left sheet, where the
  frame moves with its object.
- Keys: Escape drops the object selection; Delete and Backspace keep
  deleting the selected object (#213). A selection whose object disappears
  (undo, redo, delete, sheet switch) is cleared.
- Double-click: `open_object` routes by kind (a text box edits in place, a
  chart opens Format Chart).
- Control protocol: `ui.inspect` reports `selectedObject {kind, id}` and
  keeps `selectedChart`; `ui.object.select` selects any object and replies
  with its rect. The snapshot example takes `--object <id>`.

Tests: engine tests for every kind by kind and id, the Selection Pane and
hostile parameters; UI tests for selecting each kind (a slicer over a chart
takes the click), all eight handles on four kinds, cursors, Delete,
Backspace and Escape, undo and redo, double-click routing, and resizing on a
right-to-left sheet. Existing tests use the new selection API.

* Objects: every kind follows edits; chart series follow cut-moves and sheet deletes

Structural edits and sorts kept three hand-indexed lists (charts, images,
shapes) in step in two places, and slicers were left out of both, so a
slicer stayed put when rows were inserted above it. Chart series were
rewritten for inserted and deleted lines and for sheet renames, but not for
cut-moves or sheet deletes, which left them pointing at the old cells.

- Inserting and deleting rows and columns and sorting walk
  `Sheet::object_anchors` and `object_anchors_mut`, one fixed order over
  every kind, so slicers now follow their cells by their anchor mode like
  the other objects, and a new kind cannot be forgotten in one of the
  passes.
- `rewrite_cell_links` is the one place that rewrites the references
  floating objects hold (chart series values, categories and names,
  and the source block a chart was made from).
  `rewrite_all_formulas` calls it, which covers inserting and deleting
  lines and cells and cut-moves on one sheet or across sheets; renaming and
  deleting a sheet call it too, so a series into a deleted sheet becomes
  #REF!, as a formula does.
- Copying a sheet gives its charts, pictures, shapes and slicers fresh ids,
  keeping ids unique across the workbook, which object selection and the
  `{kind, id}` commands rely on.

Tests: every kind through row and column inserts and deletes, an absolute
shape, a sort and undo; a slicer through inserted rows; chart series
through a cut-move, a sheet rename, a sheet delete and undo; ids after a
sheet copy.

* Objects: bubble sizes follow edits like the other series references

`rewrite_cell_links` rewrote a chart series' values, categories and name
but not its bubble sizes, so inserting or deleting lines, a cut-move or a
sheet rename left a bubble chart's sizes pointing at the old cells. They
are now rewritten with the rest.

Test: bubble sizes through an inserted row, a cut-move and a sheet rename.

* Objects: a resize handle under the headers leaves the press to them

A selected object's handles reach a few points outside it, and a press
was matched against them before any check of where it landed, so a press
on the column or row header beside an object at the sheet's edge resized
the object instead of selecting the column or row. The cursor already
ignored handles outside the cells.

`chartview::selected_handle` finds the selected object's handle under a
point only over the cell area, as `hit` does, and not while objects are
hidden; the press and the cursor both use it.

Test: presses within a handle's reach over the column header and the row
header neither move nor resize the object.

* Objects: each drag of an object is its own undo step

A drag merges its frames into one undo step by dropping a new step whose
label matches the one before it. Its first frame did the same, so a second
drag of an object (a move after a move, or a resize after a move) merged
into the first drag's "Move Object" step, and one undo took back both.

`Drag::Object` records whether the drag has made its step; only the later
frames of the same drag merge into it.

Test: a move and then a resize of a picture take two undos to reverse,
the first restoring the moved picture.

* Objects: the parity and gap docs no longer list chart resizing as missing

gaps.md (§4), target-app-parity.md (the Essentials tier's work, the
Mainstream chart row, the interaction discount and the Essentials chart
task) and chart-parity.md (the element table and the work list) still
described charts as impossible to resize (#190). Charts move and resize
from eight handles like every floating object, so those statements now
say so; the 2026-10-10 revision rows and the 10-10 re-check stay as
history. Scores and the 40-60 h chart estimate are unchanged (resizing
was 1-2 h, within the rounding).

* Objects: one chart evidence paragraph in gaps.md; trendlines are not missing

gaps.md §4 carried two evidence paragraphs, an older one saying PRs #158
and #161 were open and a newer one saying all 2-D kinds survive XLSX round
trip, both repeating the resize, smooth-line, Switch Row/Column and
trendline statements. It is one paragraph with the current status again.

The Mainstream chart row in target-app-parity.md listed trendlines as
missing; they are read, written and drawn (`Series::trendlines`, the XLSX
chart reader and writer, `chart.addElement`), as chart-parity.md records.
The row now says so and keeps "no axis options". No score change.

* Objects: only the selected object's handles resize; a press elsewhere on an object moves it

A primary drag looked for a handle under the press on whatever object it
grabbed, so pressing near the edge or corner of an object that was not
selected started a resize, while the pointer over that spot showed the
Move cursor (handles are only shown, and only offered by the cursor, on
the selected object). The press now takes its handle from
`chartview::selected_handle`, the same lookup the cursor uses; a press on
any other object selects and moves it.

Test: dragging an unselected shape by its bottom-right corner shows the
Move cursor, then selects and moves the shape without resizing it.

* Equations: gridcraft-math lays LaTeX out with RaTeX, behind caps and a panic guard

Equations need a LaTeX layout engine. RaTeX (ratex-parser, ratex-layout,
ratex-types, ratex-font; MIT) turns LaTeX into positioned glyphs, rules and
paths. Before adopting it, a gate compared it with writing an in-house
subset:

- Source scan: about 31,700 lines, 95 non-test panic sites (87 in the
  parser, mostly unwraps on arguments already counted and fixed regular
  expressions), no unsafe code, no clocks, threads, file or environment
  access.
- Fuzzing: 70,000 random token strings and 50,000 mutations of valid
  equations with no panic and no input slower than 5 ms. Macro bombs stop
  at 1,000 expansions; nesting deeper than 32 is refused, and the deepest
  accepted input needs under 256 KB of stack.
- The crates build for wasm32-unknown-unknown.
- Syntax coverage: 56 of 57 probes of everyday notation lay out.

The gate passes, so RaTeX is used, pinned exactly at 0.1.14. The new
gridcraft-math crate (layer 1, registered in the layering table) is the only
code that calls it:

- `layout` turns LaTeX into a `MathList` of plain glyph, rule and path data
  in em units, with faces (roman, italic, bold, sans, mono, large operator
  sizes), explicit colours and a plain fallback character for glyphs a font
  may lack. `check` only parses. Both cap the source at 4 KB and the nesting
  at 32 before RaTeX sees it, cap coordinates and item counts in the result,
  and run under `catch_unwind`, so a panic becomes a `MathError` with the
  offending span when RaTeX reports one.
- `to_omml` writes the common subset as Office Math (ECMA-376 Part 1, 22.1):
  runs and font styles, fractions and binomials, radicals, scripts, n-ary
  operators with limits, functions and limits, delimiters, matrices,
  accents, bars and braces. Anything else is an error, so the caller can
  save the equation as text instead.

crates/math/tests/gate.rs re-runs a smaller, deterministic version of the
gate (fuzzing, depth, macro bombs, coverage) on every test run, so a version
bump has to pass it. docs/decisions.md records the evidence and the guard
design. RaTeX positions glyphs with KaTeX's font metrics (MIT); no KaTeX
font files are bundled.

* Equations: Insert › Equation, an equation editor, and equations on the sheet

Equations are shapes (`ShapeKind::Equation`) whose text is their LaTeX
source; they select, open and delete as their own object kind
(`ObjKind::Equation`).

- Model: `Shape::math`, an `EquationCache` holding the layout of the
  source (or the reason it has none), keyed by a hash of the source. It is
  never saved and is ignored when shapes are compared.
  `Workbook::refresh_equations` lays out only the equations whose source
  changed, copying only the sheets that hold one. Every new or opened
  document (`DocState::new`, `DocState::from_saved`) and every commit
  call it, so files, pastes and undo snapshots always arrive with a layout
  and painting never lays anything out.
- Commands: `insert.equation {latex, cell?|at?, size?, color?}` sizes the
  frame for the requested font size (11 pt by default) and anchors it to
  move, but not size, with cells. `equation.edit {id, latex}` keeps the
  scale the equation is drawn at and refits the frame, in one undo step.
  Missing source, or source that does not lay out, is an error naming the
  character position ("equation error at character 5: Undefined control
  sequence: \foo"). An equation is drawn to fit its frame, so resizing the
  frame scales it.
- Painting reads only the cache: glyphs in system maths font roles
  (`theme::MATH`, `MATH_ITALIC`, `MATH_BOLD`: STIX Two, Cambria Math, Latin
  Modern, DejaVu or Liberation Serif, then Times; nothing is bundled), large
  operators and delimiters stretched to their TeX boxes, rules, and
  outlines filled by ear clipping. A glyph no face draws becomes a [?] box;
  since egui's `has_glyph` misreports the glyphs of the face that supplies
  the replacement glyph, a negative answer is confirmed by comparing atlas
  slots. Ink defaults to the sheet's text colour. Source that does not lay
  out (from a file) is shown as text in a dashed frame.
- Ribbon: Insert gains a Symbols group with an Equation split button before
  Symbol. The main half (access keys N E, previously reserved in
  keytips.json) opens the editor; the menu has Insert New Equation... and a
  gallery of GridCraft's own examples (`gridcraft_math::GALLERY`), each
  drawn as a preview and inserted with one click. The new labels are
  translated for every interface language, and the Equation icon is drawn
  in code.
- Editor (dialog "equation", also driven by `ui.dialog.set` and
  `ui.dialog.confirm`): quick-insert buttons for common constructs, Greek
  letters and relations, inserted at the caret with the caret moved into the
  first `{}`; the code editor in LaTeX mode with the error's span underlined
  (`code_editor_marked` and `code_editor::underline`); the error with its
  character position, or a live preview on a sheet-coloured card; OK is
  disabled while the source has an error, and Cmd/Ctrl+Enter runs
  `insert.equation` at the active cell or `equation.edit`. Double-clicking an
  equation opens the editor on its source.
- xlsx writes an equation as a text box holding its source for now.

The keytip test that used `insert.equation` as its stand-in for a pending
command now uses `insert.object`. Docs: architecture (the math crate and the
cache), UI, gaps and target-app parity, ROADMAP; the parity checklist is
regenerated.

Tests: engine tests for insert sizing, size and colour, undo, hostile and
bad source with positions, edit scale and refit, no-op edits, refusals,
protected sheets, the cache rebuilt on load and not saved, a bad source
from a file, and equations in the object tests; UI tests for opening the
editor from the ribbon and with N E, typing an equation in, the error
position blocking OK, quick-insert at the caret, double-click editing as
one undo step, the gallery, an equation in the selection and resize tests,
ear clipping, curve flattening and underline splitting.

* Equations: Office Math in xlsx, and the Symbol font in PDF

xlsx: an equation is written as mc:AlternateContent. The a14 choice is a
text box holding the equation as Office Math (a14:m with m:oMathPara, from
gridcraft_math::to_omml at the size the equation is drawn at, in its ink
colour); the fallback is the same text box with the LaTeX as plain text, so
other readers still show the source. Both carry
descr="gridcraft:latex:<source>". Source outside the Office Math subset is
written as the text box alone.

On reading, the descr source wins, exactly (quotes and newlines included).
Without it, as in files from other writers, the new xlsx::omml reader
converts the common subset back to LaTeX: runs (upright, text,
double-struck, script, Fraktur, sans and bold styles, operator names),
fractions, radicals, scripts, n-ary operators with limits, delimiters with
separators, matrices, equation arrays, accents, bars, group characters,
lower and upper limits, functions, border boxes and phantoms. Anything else,
or a result that does not parse, stays a text box. The reader is
depth-bounded. The drawing reader now takes an mc:Choice that holds Office
Math, not only chart frames. xlsx may now depend on gridcraft-math
(FORMAT_ALLOWED), a pure layer-1 crate.

PDF: crates/pdf gains Font::Symbol (the thirteenth standard font, written
without WinAnsiEncoding) with its Unicode-to-Symbol encoding table and the
advance widths of Adobe's Core 14 Symbol.afm, cited in symbol.rs as
metrics.rs cites the other faces; Font::can_encode tells callers what a face
can show. Symbol text is written as one run in Symbol's own encoding, not
split off to the CID font that the text faces use for characters WinAnsi
lacks. Printing and PDF export draw an equation's cached layout: letters
and digits in Times (italic for variables) or Helvetica and Courier for sans
and monospace styles, Greek letters and operators in Symbol, large operators
and delimiters stretched to their TeX boxes, the radical fitted to meet its
vinculum, rules as filled rectangles and outlines as filled polygons. A
glyph outside WinAnsi and Symbol (after its plain fallback, R for a
double-struck R) prints as a [?] box.

Docs: file-format parity, gaps, target-app parity and ROADMAP.

Tests: xlsx round trips (choice and fallback markup, descr, colour, anchor
mode, an equation outside the subset, hostile sources, a text box left
alone), Office Math without a source read back to the same glyphs, the
reader's subset, unsupported constructs and the depth bound, and gallery
write-then-read; Symbol encoding and widths for every mapped character; a
PDF with Symbol and Times-Italic, the summation and alpha codes, and a [?]
box.

* Equations: the error underline snaps to char boundaries, so a stale mark cannot crash the editor

The equation editor computes the error's byte range from the source as it
was before this frame's edit, and `code_editor::underline` only clamped the
range to the text's length. Typing a multibyte character in front of the
error (`a\foo` with its error at 1..5, then `é` at the start) put a section
boundary inside the `é`, and egui's layout panicked slicing the text there,
in the paint path, where no guard catches it.

`underline` now moves the mark's start down and its end up to the nearest
char boundaries, so any byte range is safe.

Test: a_stale_mark_inside_a_multibyte_char_snaps_to_char_boundaries marks
every range over texts with two-, three- and four-byte characters, checks
that every section is on char boundaries and covers the text, and lays each
job out; it fails without the fix.

* Equations: the editor focuses its source on every opening, however the last one closed

The editor kept a "focused once" flag in egui memory and removed it only
on OK, Cancel and Cmd/Ctrl+Enter. Escape and the window's close button
also close the dialog, and left the flag set, so the next Insert ›
Equation or Edit Equation opened without focus on the LaTeX source and
typing went nowhere.

The flag is now the dialog's own `focus_initialized` (made visible to the
crate), as Command Search uses it: every opening builds a new dialog with
it unset, whatever closed the previous one.

Test: reopening_after_escape_focuses_the_source_again opens the editor
twice, types into it and closes it with Escape each time; it fails
without the fix (the second round's typing is lost).

* Equations: Office Math the reader cannot convert keeps the writer's fallback

The drawing reader took an `mc:Choice` whenever its text box held Office
Math, before knowing whether that Office Math converts to LaTeX. Office
Math outside the reader's subset, without GridCraft's source in `descr`,
then became a text box with no text, and the `mc:Fallback` (the picture or
text the writer saved for readers like this one) was never read.

The choice is now taken only when `omml::to_latex` converts its Office
Math; otherwise the fallback is read. GridCraft's own files are
unaffected: their fallback carries the same source in `descr`.

Test: office_math_outside_the_subset_reads_the_fallback replaces a saved
equation's Office Math with a pre-subscript (outside the subset), removes
the source and gives the fallback its own text; it fails without the fix
(the shape is read with empty text).

* Equations: hostile LaTeX through the guarded entry points and the xlsx round trip

The gate fuzzes RaTeX itself. These tests fuzz what GridCraft's callers
reach:

- the_guarded_entry_points_survive_hostile_input runs `layout`, `check`
  and `to_omml` over random token soup (any Unicode scalar included),
  inputs at and past the nesting and length caps, huge rules and a macro
  bomb: none panics, every layout stays within `MAX_EM` and `MAX_ITEMS`,
  every error span is on char boundaries inside the source, and what lays
  out also checks.
- hostile_sources_round_trip_exactly saves 200 sources built from XML and
  LaTeX specials (quotes, `<`, `&`, `]]>`, comments, CR, LF, tab, emoji,
  CJK, unbalanced braces) to xlsx and reads each back as the same equation
  source.

Both pass on the current code; they guard the caps and the writer's
escaping against regressions.

* Equations: opening a file lays out a bounded amount of equation source

`Workbook::refresh_equations` laid out every stale equation on load and
on every commit. One 4 KB equation can take about a tenth of a second to
lay out, so a hostile or simply large file with thousands of equations
held the app for minutes before it showed anything.

One call now lays out at most `MAX_EQUATION_LAYOUT_BYTES` (128 KB, about
2,500 typical equations) of source. Equations past it get an error
(`EquationCache::refuse`, without running the layout engine), so they show
their source in the dashed frame and later commits do not retry them.
`equation.edit` with an unchanged source now lays out an equation that has
no layout, so OK in the editor brings one back. Architecture docs note the
budget.

Test: a_file_with_too_many_equations_lays_out_a_bounded_amount opens a
workbook whose equations exceed the budget, checks that the laid-out
source stays within it, that the rest carry the budget's error and are not
retried by a later edit, and that `equation.edit` lays one out; it fails
without the budget (every equation is laid out).

* Equations: the layout cache answers only for the source it was built from

`EquationCache` matched an entry by a 64-bit hash of the source
(`DefaultHasher`, fixed keys). A crafted pair of sources with the same
hash, for example one in a file and one typed by the user, would make the
cache return one source's drawing for the other.

An entry now keeps its source and compares it in full on every lookup;
the hash and length are only a quick first check. A source over the
4 KB length cap is not copied into the entry: it never lays out, so its
entry is always an error, and it is matched by length and hash, and only
by another source over the cap. Kept sources are shared (`Arc<str>`), so
copies of a sheet and undo snapshots do not duplicate them.

Tests: a_hash_collision_never_returns_another_sources_layout forges a
collision by giving an entry for `x^2` the hash of `y^3` (same length) and
checks that `y^3` misses, is laid out afresh, and that a refusal does not
answer for a colliding source either; it fails without the fix (`y^3` gets
the layout of `x^2`). sources_over_the_cap_are_not_copied_and_never_lay_out
checks the cap path.

* Equations: a text box with Office Math beside text or other maths stays a text box

The drawing reader made any shape whose text held Office Math into one
equation, taken from the first `m:oMath`. A text box from another writer
with text beside the maths ("Solve: " and an inline equation), or with two
equations, lost the text, the other equation and the shape's formatting.
The `mc:Choice` filter used the same check, so the writer's fallback was
skipped as well.

`omml::equation_source` now converts a text body only when it holds one
equation and nothing else: no text in its runs or fields and no second
`m:oMath`/`m:oMathPara`. The Choice filter and `read_shape` both use it, so
a mixed box reads the fallback (inside alternate content) or stays a text
box with its text.

Test: office_math_beside_text_or_other_math_is_not_an_equation covers a
text run before the maths in the a14 choice (the fallback's text is read),
the same box without alternate content (a text box that keeps "Solve:"),
and two equations in one box (not an equation). It fails without the fix
(the first case reads an equation); the two-equation case fails when only
the second-equation check is removed.

* Equations: a new object id is a free one even when a file used the largest id

`Workbook::next_object_id` returned one more than the largest id in use,
saturating at `u32::MAX`. A workbook read from a JSON file can carry an
object with that id; every later insert (an equation, chart, picture,
shape, table or slicer) then got `u32::MAX` again, and so did each object
of a copied sheet, whose ids also came from a saturating counter. Lookups
and selection take the first object with an id, and `object.delete`
removes every one.

`Workbook::free_object_ids(count)` now returns distinct unused ids: those
above the largest in use while they last, then the lowest free ones from
1. `next_object_id` takes the first of them, and the sheet copy takes one
per copied object (and refuses if the id space were ever exhausted). The
model test that recorded the saturation now checks the free ids instead.

Test: ids_stay_unique_when_a_file_used_the_largest_id opens a workbook
whose equation has id u32::MAX, inserts two equations and copies the
sheet; every id is distinct. It fails without the fix (both new equations
get 4294967295). object_ids_never_overflow_or_repeat covers the gaps and
the order.

* Equations: the control protocol and UI parity list equation among the object kinds

`ui.inspect` reports a selected equation as `selectedObject: {kind:
"equation", id}`, and `object.delete`, `object.move`,
`object.setAnchorMode` and the `arrange.*` commands take `equation` as a
`kind`, but docs/control-protocol.md listed only chart, image, shape and
slicer. It now lists equation too, as does the floating-objects row of
docs/ui-parity.md.

* Docs: ui.object.select says what it replies when it clears the selection

* Objects: a test pins that both anchor accessors visit the same objects in the same order

Sorting and row or column edits read every anchor through
`object_anchors`, compute the new ones and write them back through
`object_anchors_mut` in step. Nothing checked that the two accessors
visit the same objects in the same order; a kind of floating object
reaching only one of them would have paired objects with other objects'
anchors silently.

Test: two objects of every kind are inserted in a mixed order, each
anchor is marked with its position through the mutable accessor, the
marks are read back through the shared one in the same sequence, the
count matches the per-kind collections, and the mark is found on the
object `object_anchor` names by kind and id. The test's constructor
matches on `ObjKind` exhaustively, so a new kind does not compile until
the test covers it.

* Objects: sorting keeps the size of a "move and size" picture whose lines swap places

A picture anchored to move and size with cells and spanning two rows
(or columns) was re-fitted between its top-left and bottom-right lines
after a sort. When the sort put the bottom line above the top one, the
fitted extent was negative and the picture collapsed to a zero height
or width. It now keeps its old size on that axis and travels with its
top-left line, like a "move only" picture.

Tests: for a sort by rows and one by columns, "move and size" and
"move only" pictures inside one line travel with it (the first
re-fitted to the same line, the second at its own size), an absolute
picture stays while the cells move under it, a two-line picture whose
lines swap keeps its size, and undo restores every anchor.

* Equations: Office Math whose LaTeX passes the source cap reads the writer's fallback

The OMML reader (crates/xlsx/src/omml.rs) bounded nesting (`MAX_DEPTH`
64, `find_math` depth 8) but not the length of the LaTeX it produced: a
drawing with 100,000 sibling runs, or one run of megabytes of text, was
converted in full and only then refused by `gridcraft_math::check`,
which stops at `MAX_SOURCE_BYTES` (4,096). A hostile file paid for the
whole string, and each backslash in a `\text` run grew to
`\textbackslash ` first.

`convert` (the conversion before the final check) now stops with `None`
as soon as the LaTeX produced passes `MAX_SOURCE_BYTES`: after every
node in a sequence, and after every character of a run's text, in the
escaped `\text{…}` form as well as the plain one. The check reads the
output, not the run's byte length, because a character may become a
longer macro (`\alpha `) or a shorter sign (a minus sign becomes `-`).
The caller then keeps the writer's text fallback, as for any equation
outside the subset. An equation exactly at the cap still reads back.

Regression test: `omml::tests::latex_past_the_source_cap_stops_converting_and_falls_back`.
Without the checks it fails with `left: Some("xxx…")` (a 100,000-byte
string), `right: None`. The test reads the clock, which clippy
disallows for the sake of wasm; tests never run there, and the lint is
allowed on that function.

* Equations: the hostile-source test covers equation.edit as well as insert.equation

`insert.equation` and `equation.edit` both lay a source out through
`cmd::equation::lay_out`, so both refuse a source over 4,096 bytes or
nested deeper than 32 levels, but only the insert side was asserted.
`insert_refuses_missing_bad_or_hostile_source` now edits an equation
with the same over-long and over-deep sources, expects the same errors,
and checks that the equation's source is unchanged afterwards.

* Equations: ATTRIBUTION.md records the Core 14 AFM numbers behind the PDF Symbol font

PDF export draws an equation's Greek letters and operators with the
standard Symbol font and its letters with the Times faces; no font file
is embedded (the base-14 fonts are the reader's own, and text outside
WinAnsiEncoding uses a predefined, non-embedded Adobe-GB1 CID font). What
the repository holds are numbers: Symbol's character codes and advance
widths in crates/pdf/src/symbol.rs, and the Helvetica, Times and Courier
widths in crates/pdf/src/metrics.rs, typed in from Adobe's Core 14 AFM
metrics, which Adobe distributes for use, copying and distribution for
any purpose (the notice is quoted in metrics.rs). ATTRIBUTION.md now
carries a row for those numbers, their source and that notice, so the
licence is recorded where every other third-party input is.

* Equations: the gate times pathological sources at the cap, chemistry included

`ratex-parser` uses `fancy-regex`, a backtracking engine, for its port of
mhchem: anchored patterns with lookahead tokenise the argument of `\ce`
and `\pu`, so user LaTeX does reach them. The engine stops a match after
its default 1,000,000 backtracking steps and the match counts as none,
and `gridcraft-math` caps the source at 4,096 bytes before the parser
sees it, but nothing measured how long a source built to make those
patterns (or the hand-written parser) work hardest could take.

`pathological_source_at_the_cap_finishes_in_bounded_time` in
crates/math/tests/gate.rs lays out, checks and converts 38 sources of
exactly `MAX_SOURCE_BYTES`: groups, delimiters, fractions, roots,
scripts, accents, `\text` and matrices nested to `MAX_DEPTH`; runs of
`\\`, `\{`, `{}`, primes, thin spaces and alignment tabs; a `(a+)+b`
shape; wide and tall environments; macro definitions, colours and
combining marks; and chemistry (`\ce` with digit runs, decimals,
parentheses, arrows, scripts, dollars, formulas, nested `\ce`, charges
and fractions; `\pu` with charges, exponents and unit slashes). Each
must finish inside 10 s through the guarded entry points; the slowest,
`\ce{+-+-…}`, takes 0.8 s in a debug build on an Apple M series
machine, so catastrophic backtracking would fail the test without a
slow CI runner failing it. The test reads the clock, which clippy
disallows for the sake of wasm; tests never run there, and the lint is
allowed on that function. docs/decisions.md records the finding in the
RaTeX gate.

* Objects: a copied sheet's objects get free ids even when a file used the largest id

`Workbook::next_object_id` returned one more than the largest id in use,
saturating at `u32::MAX`. A workbook read from a file can carry an object
with that id; every later insert (a chart, picture, shape, table or
slicer) then got `u32::MAX` again, and so did each object of a copied
sheet, whose ids also came from a saturating counter. Lookups and
selection take the first object with an id, and `object.delete` removes
every one.

`Workbook::free_object_ids(count)` now returns distinct unused ids: those
above the largest in use while they last, then the lowest free ones from
1. `next_object_id` takes the first of them, and the sheet copy takes one
per copied object (and refuses if the id space were ever exhausted). The
model test that recorded the saturation now checks the free ids instead,
and a new one covers a file that used the largest ids: the free ids come
from below, 100,000 of them distinct and in order.

Test: object_ids_stay_unique_when_a_file_used_the_largest_id gives a
shape id u32::MAX, inserts two shapes and copies the sheet; every shape id
is distinct across both sheets. It fails without the fix (both new shapes
get 4294967295).

* Equations: PDF prints an equation without a layout one source line at a time

When an equation has no layout (its source does not parse, or the file
was past the open-time layout budget), PDF export printed the source in
one `pg.text` call. A newline is not a WinAnsi character, so each one
became a literal `?`, and a multi-line source ran on in one line. The
xlsx plain-text fallback already writes one paragraph per line.

`fallback_lines` in crates/engine/src/cmd/print/objects.rs splits the
source at newlines and yields the lines the frame holds at 12 pt per
line (at least the first), never more than `MAX_FALLBACK_LINES` (500);
the fallback arm prints each on its own baseline.

Regression test:
`cmd::print::tests::an_equation_without_a_layout_prints_its_source_one_line_per_line`.
Without the change it fails at "the first line is its own text run"
(the PDF holds `(frac{?second line) Tj`).

* Equations: Office Math runs drop the characters XML cannot carry

`esc` in crates/math/src/omml.rs dropped C0 controls other than tab and
newline but kept U+FFFE, U+FFFF and the carriage return as they were.
The xlsx writer embeds the OMML in the drawing part as it is, so a
source holding one of them produced XML no parser accepts, where the
rest of the drawing part (escaped through the xlsx crate's `esc`, which
filters through `is_xml_char`) would have dropped it.

`esc` now keeps exactly the XML 1.0 character set the xlsx escapes keep
(tab, newline, carriage return, U+0020 to U+D7FF, U+E000 to U+FFFD and
the supplementary planes), writes the carriage return as `&#13;` and
drops the rest. The predicate is a copy of the xlsx one: the math crate
sits below the xlsx crate and cannot import it.

Regression test: `omml::tests::characters_xml_cannot_carry_are_dropped_from_runs`.
Without the change it fails with `left: "a\u{fffe}b\u{ffff}cde\u{7f}f"`,
`right: "abcde\u{7f}f"`.

* Equations: the matrix writer writes every row RaTeX gives it

The OMML matrix writer skipped a row whose only cell was an empty
`OrdGroup` whenever the matrix had more than one row, meant to drop the
empty last row a trailing `\\` leaves. Read against RaTeX 0.1.14 the
branch was unreachable: the parser removes that trailing empty row
itself (its `environments.rs`, "Check for trailing empty row and remove
it"), and every cell it does give is a `Styling` node around the group,
never a bare `OrdGroup`. A blank row in the middle, written as `\\ \\`
or as `{}`, was therefore already kept, but the code said otherwise and
would have dropped such a row had the parser's shape matched.

The skip is gone; the writer writes every row it is given. The new
tests pin the behaviour so that a RaTeX release that stopped dropping
the trailing row would show up: `omml::tests::only_a_trailing_empty_row_is_dropped`
(two rows for `a \\ b \\`, three for `x \\ \\ y` and for `a \\ {} \\ b`,
one for a lone `\\`) and, through the xlsx reader,
`omml::tests::a_blank_middle_row_comes_back_and_a_trailing_one_does_not`.

* Equations: to_omml takes the ink colour instead of the xlsx writer patching its output

The xlsx writer coloured the Office Math runs by replacing the exact
text `<a:latin typeface="Cambria Math"/></a:rPr>` in `to_omml`'s output
with the fill in front of it. Had that text changed, `replace` would
have done nothing, the Office Math choice would have lost its colour,
and nothing would have reported it; the round-trip test could not have
caught it either, because the plain-text fallback carried the same
colour and the reader would have returned it.

`gridcraft_math::to_omml` now takes the ink as `Option<[u8; 3]>` and
writes `<a:solidFill><a:srgbClr …/></a:solidFill>` into every run's
properties itself; the xlsx writer passes the resolved shape colour and
the string patch is gone. Tests: `omml::tests::runs_carry_the_ink_colour_when_one_is_given`
(three coloured runs for `x+1`, none without an ink), and
`equations_write_office_math_with_a_text_fallback_and_read_back` now
asserts the colour inside the `<a14:m>` paragraph. Without the fill in
the writer both fail (`left: 0, right: 3`; "the Office Math runs carry
the ink colour").

* Equations: what shows a source as text shows its first 4,096 bytes

The PDF fallback for an equation without a layout capped the number of
lines it printed but not their length: `fallback_lines` scanned the
whole source and handed a complete line to `Page::text`, which formats
it before the page-content limit can refuse it. A source read from a
file is not bounded by `MAX_SOURCE_BYTES` (the editor and the commands
refuse a longer one, but a drawing part may be 512 MiB), so a hostile
file put megabytes into one text run. The sheet painter laid the whole
source out every frame for the same reason.

`cmd::equation::source_excerpt` returns the first `MAX_SOURCE_BYTES`
bytes of a source, cut at a char boundary; a longer source never lays
out, so that is all a reader of the text can use. `fallback_lines`
splits the excerpt, and the sheet painter's dashed frame shows it.

The stored source is left as the file wrote it: the xlsx reader keeps a
`descr` source whole, as it keeps every text box's text, so a file is
not changed by opening it, and `check_caps` refuses an over-long source
by its length before reading it, so opening costs nothing more.

Regression tests: `cmd::print::tests::an_equation_source_of_a_megabyte_prints_a_bounded_excerpt`
(a 1 MiB single-line source: the longest text run in the PDF is 4,096
bytes; without the excerpt it is 1,048,576) and
`tests::equation::the_source_excerpt_is_at_most_the_cap_and_whole_chars`.

* Equations: the layout cache answers for a source over the cap without reading it

`EquationCache::get` matched a source by length, then by a hash of the
whole source (`DefaultHasher`), then in full. For a source over
`MAX_SOURCE_BYTES` the entry is always the same error, yet every lookup
still hashed it: the sheet painter asks the cache every frame and the
PDF export every draw, and a source read from a file can be megabytes,
so a hostile file was rescanned on every repaint.

`get` now answers for a source over the cap at once, by its length,
with `gridcraft_math::SOURCE_TOO_LONG` (one shared value, the same error
`check_caps` returns), whatever the entry holds; `holds` and `store`
neither hash nor copy such a source. Within the cap the lookup is
unchanged. Both callers already route anything but `Some(Ok(_))` to the
4,096-byte excerpt, so the painter and the PDF fallback do no work on
the rest of the source. `Workbook::refresh_equations` no longer carries
a branch for over-cap sources: they are never stale, so it never saw
them. Nothing else reads a source per frame: `source_excerpt` slices at
most 4,096 bytes and `check_caps` tests the length first.

Tests: `equation_cache_tests::a_source_of_a_megabyte_is_looked_up_without_being_read`
(a 1 MiB source looked up 1,000 times: no hash, counted by a test-only
counter in `source_key`, and under 0.5 s; on the previous cache it fails
with `left: 1000, right: 0` after 0.20 s) and
`sources_over_the_cap_are_not_copied_hashed_or_laid_out`.

* Equations: the source cap's message says bytes, which is what is counted

check_caps compares the UTF-8 byte length with MAX_SOURCE_BYTES, so 2,049 two-byte characters are refused although the message said 4,096 characters were allowed. The message now names the unit the check uses.

* Equations: ATTRIBUTION.md records Unicode's terms for the Symbol encoding table

The Unicode values of Symbol's character codes in crates/pdf/src/symbol.rs
come from `symbol.txt` ("Adobe Symbol Encoding to Unicode", table version
1.0, 2011-07-12, at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/), which
carries Unicode, Inc.'s own copyright and terms, not Adobe's Core 14
metrics notice: Unicode grants the right to freely use the information
in the file in products supporting the Unicode Standard, and to copy the
file as long as its notice remains attached, as-is and without
warranties. ATTRIBUTION.md now has a row for that table with those
terms, separate from Adobe's row, which keeps to the AFM character codes
and widths; the module comment of symbol.rs credits both.

* Equations: a literal space in text mode reaches Office Math

RaTeX gives the space in `\text{if } x` as a spacing node whose text is
" ", and the OMML writer's spacing match mapped that to "", which `run`
omits, so the saved equation read `ifx`. The match now maps " " to a
space, and a run that starts or ends with whitespace is written with
`xml:space="preserve"` on its `m:t`, so Word keeps it; the reader never
trimmed, so it reads the run back as `\text{ }`.

Tests: `omml::tests::a_literal_space_in_text_mode_is_kept` (the space is
its own marked run in `\text{a b}` and `\text{if } x`; runs without
surrounding whitespace are not marked) and, through the xlsx reader,
`omml::tests::a_space_in_text_mode_survives_the_round_trip` (the same
number of space glyphs before and after). Without the mapping both fail;
the round trip came back as `\text{i}\text{f}x`.

* Equations: the PDF row says what the writer does with fonts and scripts

docs/file-format-parity.md said PDF export used "only the 14 standard
PDF fonts" and that "non-Latin text does not export". That wording
predates this branch, but the branch added the equation note to the same
row, and both halves were wrong against crates/pdf: the writer has 13
standard-font entries (the Helvetica, Times and Courier faces and
Symbol), none embedded, and sets characters outside WinAnsiEncoding in a
predefined, non-embedded CID font (STSong-Light, Adobe-GB1,
`UniGB-UTF16-H`), so Simplified Chinese displays where the reader
supplies that font, while scripts outside Adobe-GB1 export as code
points and show as the reader's missing-glyph box. The row and the
equation revision row now say so, with Symbol for the Greek letters and
operators it has, the Times faces for letters and digits, and `[?]` for
the rest. "Would pass the cap" reads "would exceed the cap" in the
revision row and the matching code comments.

* Equations: the gate checks nesting that macro expansion builds

`check_caps` counts the braces, `\left`s and `\begin`s of the source
text, so nesting that macros build reaches RaTeX, whose recursion guard
is the one that must hold. `nesting_built_by_macros_is_refused_by_the_parser`
builds `\left(`/`\right)` pairs and `\frac` levels through `\def`: 32
levels lay out, 64 are refused with "Recursion limit exceeded", inside
the 1,000-expansion limit, and the wrapper refuses the 64-level source
as well.

* Equations: the description carries a marker of the Office Math, so an edit made in Excel reads back

The xlsx reader took GridCraft's `gridcraft:latex:` description whenever
a shape had one. Excel edits an equation's Office Math and leaves the
description alone, so a file edited there came back as the old equation,
and the next save overwrote the edit.

The writer now puts a marker of the text body it writes into the
description, `gridcraft:latex:<marker>:<source>`: 16 hex digits of an
FNV-1a 64-bit hash (`crate::omml::body_marker`) over what carries the
equation's meaning, the OMML element names in document order and the
text of the runs. It leaves out what a re-serialisation may change
without changing the equation: property elements (`*Pr`), extension
lists, the DrawingML containers, attributes, namespace prefixes and
whitespace between elements; adjacent runs are joined, so a writer that
splits or merges runs changes nothing, while a text change, an added run
or a structural change does. The hash is computed over the parsed body,
the same way on both sides, and FNV is fixed, so the marker survives
Rust and library versions.

The reader takes the source only when the shape holds Office Math and
the marker matches the body now in the file; otherwise it converts the
Office Math with `equation_source` and drops the stale description,
falling back as before when the maths is outside the subset. A body
without Office Math (the plain-text fallback of an equation outside the
subset) keeps the source as it did, and every branch stays
`ShapeKind::Equation`. A description without a well-formed marker is not
GridCraft's source.

Tests: `office_math_edited_elsewhere_reads_back_as_edited` (an `m:t`
changed in the drawing part reads back converted, and a save writes the
edited equation with the old source gone; with the marker ignored it
fails with `left: "x^2", right: "{y}^{2}"`),
`a_marker_over_garbage_office_math_falls_back_without_panicking`,
`omml::tests::the_body_marker_follows_the_equation_not_its_serialisation`,
and the existing round trips, whose exact sources still come back.

* Equations: the PDF fallback text is clipped to the equation frame

The source lines printed for an equation without a layout were drawn at
full width under the block's clip alone, so a long line ran past the
frame over neighbouring content. The lines are now drawn inside a clip
of the frame, saved and restored around the loop, which nests in the
block's clip.

Test: `cmd::print::tests::the_fallback_text_is_clipped_to_the_equation_frame`
(a 40 by 100 px frame gives a `30 75 re W n` clip before the text run,
restored after it); without the clip it fails at "a clip the size of the
frame".

* Equations: the body marker hashes the properties the converter reads

`body_marker` skipped every `*Pr` element, yet `to_latex` reads several
property values: a run's `sty`, `scr` and `nor`, a fraction's `type`, a
radical's `degHide`, an operator's `chr`, `limLoc`, `subHide` and
`supHide`, a delimiter's `begChr`, `sepChr` and `endChr`, an accent's
`chr`, a bar's `pos` and a group character's `chr` and `pos`. An edit in
Excel to one of them (upright to bold, parentheses to brackets, a
hidden limit) left the marker unchanged, so the stale source won.

The properties the converter reads are one table, `PROPERTIES`, that
both sides use: `val` and `on` read a property only when the table lists
it (a read of an unlisted property is absent, which the conversion tests
would show), and `mark` hashes, for each property element, exactly the
listed properties present as `name=val` in the table's order, closing
the run text gathered so far, so the same property moving to another
run is a change too. `ctrlPr`, extension lists and properties the
converter never reads (DrawingML size, colour and fonts) stay out, so
Excel's re-serialisation still leaves the marker alone.

`the_body_marker_follows_the_equation_not_its_serialisation` now checks
that a changed `sty`, a style moved between runs, an accent or delimiter
character, and a hidden operator limit change the marker, while an
added `ctrlPr`, an extension list, DrawingML run properties and an
unread `rFonts` do not. Without the property hashing it fails at the
`sty` case with two equal markers.

* Equations: the clip test checks the graphics state at a later object

The test for the fallback's frame clip counted `q` lines in two ranges
that always added up, and looked for any `Q` after the text, which the
block's own restore supplies, so a missing `pg.restore()` in the
fallback passed it. It now puts a text box below the equation in the
same block and compares the net `q`/`Q` depth at three points: the
text is drawn one level inside the clip's state, and the later object
at the level before it. With the fallback's restore removed it fails
with `left: 2, right: 1` at the later object.

* Equations: the body marker hashes a styled run as a unit, as the converter converts it

The marker joined the text of adjacent runs so that Excel's splitting
and merging of runs would not change it. The converter, though,
converts each run on its own and recognises function names per run: an
upright `sin` becomes `\sin `, while an upright `s` beside `in`, styled
or not, becomes `\mathrm{s}in` or `\mathrm{s}\mathrm{in}`. An edit that
split a styled run therefore kept the marker equal and the stale `\sin `
source was reused.

A run with a property the converter reads is now hashed as a unit: the
property already closed the text gathered before it, and the run's end
closes its own text as well, so a styled run split in two, or a styled
run merged with a plain neighbour, changes the marker. Runs without
read properties convert character by character and stay joined, so
splitting or merging them still changes nothing. This mirrors the
converter exactly, including adjacent runs with identical properties,
which it converts separately.

`the_body_marker_follows_the_equation_not_its_serialisation` now checks
that upright `sin` differs from upright `s` plus plain `in` and from
upright `s` plus upright `in`, that `sin` + `x` differs from `s` +
`inx`, and that plain `ab` equals plain `a` + `b` beside a styled run or
alone. Without the after-run flush it fails at the first new case with
two equal markers.

* Equations: a brace keeps the script on its other side in Office Math

The writer put the label of `\underbrace{x}_{n}` in `m:limLow` and
that of `\overbrace{x}^{n}` in `m:limUpp`, then returned, so a script
on the brace's other side was dropped: `\underbrace{x}_{n}^{2}` lost
the exponent in the Office Math. The source in `descr` hid this in a
GridCraft-to-GridCraft round trip; an application reading the Office
Math saw an incomplete equation.

The other script now wraps the whole: `m:sSup` around `m:limLow` for
an underbrace with an exponent, `m:sSub` around `m:limUpp` for an
overbrace with a subscript. A brace with only the other script
(`\underbrace{x}^{2}`) gets the script around the bare `m:groupChr`,
with no empty `m:lim`; a brace with its label alone, or with neither
script, is written as before. Each wrapper is a level of the depth
accounting.

The reader converted a limit over a brace into `\underset`/`\overset`
around `\underbrace`/`\overbrace`. It now reads a limit whose base is a
brace alone as the brace with the label as its own script, the
writer's inverse, so `\underbrace{x}_{n}^{2}` reads back as
`{\underbrace{x}_{n}}^{2}`; a limit over any other base is still
`\underset` or `\overset`. The body marker hashes element names and
the properties the converter reads, which this does not change: the
stored source is still taken while the marker matches, and an
equation edited elsewhere still reads back as edited.

Tests: `an_underbrace_with_both_scripts_keeps_its_exponent` and
`an_underbrace_without_a_label_keeps_its_superscript` in the math
crate check the exact Office Math for both braces and for nested ones;
`a_labelled_brace_reads_back_with_a_script_on_its_other_side` checks
the reader on hand-written Office Math and the LaTeX round trip (same
glyphs); `a_brace_with_scripts_on_both_sides_round_trips` saves an
xlsx, reads it with the source (taken as written) and without it
(converted, exponent kept). Without the fix the first two fail on the
missing `m:sSup`, the third reads `\underset{n}{\underbrace{x}}`, and
the fourth finds no `m:sSup` in the drawing.

* Objects: main's chart data table compiles with the object selection and the model's object test

Two leftovers of the merge of origin/main (25881a8e), neither a textual
conflict: `Chart` gained a `data_table` field, which the `push_object`
helper of the model's object test did not set (it is `None`, as in
`insert.chart`); and chart_data_table.rs set the `selected_chart` field
this branch replaced by `selected_object`. The test selects its chart as
a floating object, as the branch's other tests do.

* Objects: object.move and chart.set keep a size a file made larger than 10,000

`object.move` clamped `width` and `height` to 10,000 points, the most it
gives a new size, while the XLSX reader accepts anchors far past that.
A resize of a picture a file made 12,000 wide passed 11,990 through the
clamp and jumped to 10,000; `chart.set`, whose Format Chart pane sends
the unchanged dimension back with the edited one, clamped the same way.

Each dimension is now capped at 10,000 or the object's current size,
whichever is larger (`size_cap`): such an object keeps its size when it
is moved, shrinks by what a resize asks and never grows further, and a
hostile size is still capped. The params doc of `object.move` and the
control protocol notes say so.

Tests: an_oversized_imported_object_keeps_its_size_when_moved_and_shrinks_only_by_a_resize
makes a picture 12,000 by 300, writes and reads it back as a file, then
moves it without a size (12,000 stays), asks 12,010 (stays 12,000),
11,990 (11,990, no jump) and 50,000 by 50,000 (11,990 by 10,000).
chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit does the
same through `chart.set`. Both fail without the fix (width 10,000).

* Objects: dragging an object's body sends its position only, not its size

`drag_object` sent `width` and `height` with every step of a body drag,
so moving a picture a file made 12,000 wide shrank it to the 10,000 that
`object.move` gives a new size. Only a handle drag sends a size now; a
body move leaves the engine's size alone.

Tests (real input): dragging_the_body_of_an_oversized_imported_object_keeps_its_size
reopens the workbook with a 12,000 by 60 picture, drags its body by
30 by 25 and checks the size is untouched, the picture moved with the
pointer and the journaled `object.move` carries no width or height; it
fails without the fix (the journaled command carries both, and without
the engine's cap the picture is 10,000 wide).
a_handle_drag_on_an_oversized_imported_object_resizes_by_the_drag_only
moves the left handle 10 px right: the picture is 10 points narrower in
the window and keeps a width near 12,000, with no jump to the limit.

* Objects: the Format Chart Size fields show a chart's real size and send only the edited dimension

The Width and Height fields ranged 40 to 4,000 and sent both values on
every edit. egui clamps an existing value into a field's range, so a
chart a file made 12,000 wide showed "Width 4000", and typing a new
height sent that 4,000 as the width: the chart shrank although only its
height changed, and the cap `chart.set` keeps for an existing size
cannot help once the pane itself sends 4,000. Each field's range now
reaches the larger of 4,000 and the chart's current size, so the field
shows the real size, and a commit sends only the dimension that differs
from the chart's (`chart.set` reads `width` and `height` independently).

Test (real input): the_size_fields_keep_an_oversized_imported_chart_width_when_the_height_changes
reopens a workbook with a 12,000-wide chart, opens Format Chart Area's
Size section, checks the Width field shows 12000 and types 201 as the
height: the width stays 12,000, the height is 201 and the journaled
chart.set carries no width. Without the fix the field shows
"Width 4000".

* Objects: arrange and align act on an equation only under its own kind

`arrange.bringForward`, `arrange.sendBackward`, `arrange.align` and
`arrange.group` sent `kind: "equation"`, `kind: "shape"` and any
unknown kind to the unfiltered list of shapes, so a plain shape's id
given as an equation reordered or aligned the plain shape, an unknown
kind acted on shapes, and an id of no object succeeded silently.

The kind is now parsed as the `object.*` commands parse it (`shape`
when absent; an unknown kind is an error with the same message), and
under `equation` only shapes of `ShapeKind::Equation` are matched.
Under `shape` an equation still matches, as `Sheet::object_anchor` has
it for `object.move` and `object.delete`: an equation is a shape too,
and a plain shape is not an equation. `arrange` refuses an id that is
not an object of the kind instead of doing nothing; `align` skips it,
as it skips any id it does not find, so the object is not moved.

`arrange_and_align_match_a_shape_id_to_its_kind` reorders and aligns
with the shape's id given as an equation, the equation's id given as a
shape, an unknown kind and a chart's id given as a shape. Without the
fix it fails at its first assertion: the shape's id under `equation`
is accepted and the shape reordered.

* Equations: a box without Office Math keeps its source only while its marker matches

The reader took the source in an equation's description without
checking the marker whenever the body held no Office Math. An
equation outside the subset (`\begin{CD}`) is written as a plain-text
box; a user who edited that text in another application, which leaves
the description alone as Excel does, got the old source back on
reopen, and the next save wrote over the edit. The check could not
simply be added: the fallback box of an equation with Office Math
carried the marker of the Office Math body, not of its own text, so a
reader left with the fallback alone would have lost the source.

Each box now carries the marker of its own body, the Office Math box
and the plain-text fallback alike, and the reader applies the marker
check to a body without Office Math too: the source while it matches,
else a text box with the edited text. Office Math whose marker does
not match is converted as before.

`a_text_edit_to_an_equation_without_office_math_reads_back_as_the_edit`
writes `\begin{CD} A @>f>> B \end{CD}`, reads it back unchanged, edits
the box's text in the drawing XML and expects a text box with the
edit; it also checks that the two boxes of `x^2` carry different
markers and that the fallback alone still reads as the equation.
Without the fix it fails with the stale source as an equation.
`a_marker_over_garbage_office_math_falls_back_without_panicking` and
`office_math_outside_the_subset_reads_the_fallback` pass unchanged.

* Equations: a box without Office Math keeps its source only while its paragraphs are the writer's

The body marker joins run text across paragraphs on purpose, so that
Excel's splitting and merging of runs changes nothing. A paragraph
split, though, is an edit: another application that split the one
paragraph of a plain-text equation box in two without changing a
character left the marker equal, the stale source was taken on reopen,
and the next save wrote over the edit.

A box without Office Math now takes its stored source only while its
text, paragraphs included, is what the writer wrote for that source:
the writer's paragraphs for the source are built by the same function
and read back through the same parser, then compared paragraph by
paragraph with the body. Office Math bodies keep the marker check
alone, as before.

`a_paragraph_split_in_an_equation_without_office_math_reads_back_as_the_edit`
writes `\begin{CD} A @>f>> B \end{CD}`, splits its paragraph at the
first space in the drawing XML and expects a text box with two lines
and the same characters. Without the fix it reads the equation with
the stale source.

* Objects: arrange and align take the shape default only for an absent kind

`kind_param` read the kind with `str_param`, which gives nothing for a
value that is not a string, so `{"kind": null}` or `{"kind": 7}` fell
back to `shape` and a shape's id was reordered or aligned under an
invalid parameter. The default now applies only when the parameter is
absent; a present value that is not one of the names is refused with
the same message as an unknown name. The `object.*` commands already
refused these values, their kind being required
(`hostile_object_params_are_refused`).

`arrange_refuses_a_kind_that_is_present_but_not_a_name` tries `null`,
a number, an array, an object and a boolean against
`arrange.bringForward`, `arrange.align` and `object.move`, checks that
nothing moved, and that an absent kind still means `shape`. Without
the fix it fails at its first assertion: `null` is accepted.

* Objects: align and group refuse an id that is not an object of the kind

`arrange.align` and `arrange.group` left out an id in `ids` that was
not an object of the kind, or not a number, aligned the rest and
reported success, so a stale or mismatched id succeeded silently, and
the control-protocol sentence that every `arrange.*` command refuses
such an id was not true of them.

Every entry of `ids` must now be an object id (`u32`, not a negative
number, a fraction, a string or null) and name an object of the kind;
otherwise the command is an error and nothing moves, as for `arrange`
and the `object.*` commands. With fewer than two ids the command still
changes nothing and succeeds, which the control-protocol note states.
The Selection Pane does not call these two commands; they are reached
from the control channel and MCP.

`align_refuses_an_id_that_is_not_an_object_of_the_kind` tries a
shape's id under `equation`, an unknown id and four non-ids against
both commands, checks that nothing moved, and that one id or none is a
no-op; `arrange_and_align_match_a_shape_id_to_its_kind` now expects the
mixed list to be refused. Without the fix both fail with the mixed
list accepted.

* Objects: align and group refuse an ids value that is not an array

`arrange.align` read `ids` with `as_array`, so a present value that was
not an array (`null`, a number, a string, an object) counted as no ids
and the command succeeded without doing anything. `arrange.group`
delegates to it. The field may still be absent, which means nothing to
align; a present value must be an array of object ids, and the one
message "ids must be an array of object ids" covers a non-array value
and an entry that is not an object id alike.

`align_refuses_ids_that_are_not_an_array` tries `null`, a number, a
string and an object against both commands, checks that nothing moved,
and that an absent `ids` still succeeds. Without the fix it fails at
its first assertion: `null` is accepted.

* Equations: the paragraph-split test asserts the exact text it reads back

The test's last assertion stripped spaces and newlines from both
sides, so a reader that moved the newline or dropped a space would
have passed it. The fixture cuts the one paragraph after its first
space, which stays in the first paragraph, so the text read back is
the source with a newline after that space; the test now asserts that
text exactly.

* Equations: preserve limits on the opposite side of braces

* Equations: match hosted formatting of brace regression

---------

Co-authored-by: FredApps <FredApps@users.noreply.github.com>
This commit is contained in:
PranavandFredApps authored and GitHub committed 2026-11-02 15:45:36 +01:00
1 parent 488f9982eb
commit 6f52cc5090
88 files changed
+6036 -167

No files matched your search

+2
View File
@@ -46,3 +46,5 @@ Generated-in-code art is original and has no file to list: the UI icon set (`cra
| `docs/images/pivot.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
| `docs/images/format-chart.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
| `docs/images/page-break.png` | GridCraft contributors | screenshot of GridCraft rendered offscreen (`crates/ui-egui/examples/snapshot.rs`) showing an original sample workbook | MIT OR Apache-2.0 | README screenshot; no third-party content |
| Core 14 AFM metric numbers in `crates/pdf/src/symbol.rs` (Symbol: character codes and advance widths) and `crates/pdf/src/metrics.rs` (Helvetica, Times and Courier advance widths); no font file | Adobe Systems Incorporated | Adobe's Core 14 AFM font metrics files (`Symbol.afm` 001.008 and the Helvetica, Times and Courier AFMs) | Adobe's Core 14 metrics notice: the files may be used, copied and distributed for any purpose and without charge, provided the notice is kept (quoted in `crates/pdf/src/metrics.rs`) | Numbers only, typed in from the public metrics. PDF export embeds no font: Symbol and the Times faces are base-14 fonts every PDF reader supplies, and text outside WinAnsiEncoding is set in a predefined, non-embedded Adobe-GB1 CID font |
| Unicode values of the Symbol encoding in `crates/pdf/src/symbol.rs` (the code-to-character map; numbers only, no file copied) | Unicode, Inc. | `symbol.txt`, "Adobe Symbol Encoding to Unicode", table version 1.0 (2011-07-12), at unicode.org/Public/MAPPINGS/VENDORS/ADOBE/ | Unicode, Inc.'s notice in the file: "Unicode, Inc. hereby grants the right to freely use the information supplied in this file in the creation of products supporting the Unicode Standard, and to make copies of this file in any form for internal or external distribution as long as this notice remains attached." The file is provided as-is, without warranties | Separate from Adobe's Core 14 notice (previous row), which covers the character codes and widths; the Unicode values come from this table |
Generated
+178 -13
View File
@@ -20,7 +20,7 @@ dependencies = [
"accesskit",
"accesskit_consumer",
"atspi-common",
"phf",
"phf 0.14.0",
"serde",
"zvariant",
]
@@ -468,15 +468,30 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec",
"bit-vec 0.9.1",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -1495,6 +1510,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fancy-regex"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e24cb5a94bcae1e5408b0effca5cd7172ea3c5755049c5f3af4cd283a165298"
dependencies = [
"bit-set 0.8.0",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -2017,6 +2043,7 @@ dependencies = [
"gridcraft-core",
"gridcraft-formula",
"gridcraft-functions",
"gridcraft-math",
"gridcraft-model",
"gridcraft-numfmt",
"gridcraft-pdf",
@@ -2047,6 +2074,16 @@ dependencies = [
"regex",
]
[[package]]
name = "gridcraft-math"
version = "0.15.0"
dependencies = [
"ratex-font",
"ratex-layout",
"ratex-parser",
"ratex-types",
]
[[package]]
name = "gridcraft-mcp"
version = "0.15.0"
@@ -2062,6 +2099,7 @@ version = "0.15.0"
dependencies = [
"gridcraft-core",
"gridcraft-formula",
"gridcraft-math",
"serde",
"serde_json",
"sha2 0.11.1",
@@ -2129,6 +2167,7 @@ dependencies = [
"gridcraft-core",
"gridcraft-formula",
"gridcraft-functions",
"gridcraft-math",
"gridcraft-model",
"quick-xml 0.41.0",
"sha1 0.11.0",
@@ -2967,7 +3006,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set",
"bit-set 0.10.0",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
@@ -3543,17 +3582,37 @@ dependencies = [
"indexmap",
]
[[package]]
name = "phf"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
dependencies = [
"phf_macros 0.11.3",
"phf_shared 0.11.3",
]
[[package]]
name = "phf"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
dependencies = [
"phf_macros",
"phf_shared",
"phf_macros 0.14.0",
"phf_shared 0.14.0",
"serde",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared 0.11.3",
"rand 0.8.8",
]
[[package]]
name = "phf_generator"
version = "0.14.0"
@@ -3561,7 +3620,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
dependencies = [
"fastrand",
"phf_shared",
"phf_shared 0.14.0",
]
[[package]]
name = "phf_macros"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
dependencies = [
"phf_generator 0.11.3",
"phf_shared 0.11.3",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
@@ -3570,13 +3642,22 @@ version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085"
dependencies = [
"phf_generator",
"phf_shared",
"phf_generator 0.14.0",
"phf_shared 0.14.0",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "phf_shared"
version = "0.14.0"
@@ -3852,7 +3933,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand 0.10.3",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
@@ -3900,6 +3981,15 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.10.3"
@@ -3938,6 +4028,66 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
[[package]]
name = "ratex-font"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a7b73531dabe934a3e725ed5c90d18dacf43778b7a208381390943a9a8c24db"
dependencies = [
"phf 0.11.3",
"ratex-types",
]
[[package]]
name = "ratex-layout"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1792738e2e36683cd20de742296a5233759565efc8cd03831064a0ccfb1fe0f7"
dependencies = [
"ratex-font",
"ratex-parser",
"ratex-types",
"serde_json",
]
[[package]]
name = "ratex-lexer"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f704add86d7bf4c4ecc7767f45ab38a44f1bbfa4b49725b2ae53c32e83b8a61e"
dependencies = [
"ratex-types",
"serde",
"thiserror 1.0.69",
]
[[package]]
name = "ratex-parser"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "079ec2e7ac6bc76137a6de093a173d1f901139a51318589ab9ce17139c21a6f0"
dependencies = [
"fancy-regex",
"ratex-font",
"ratex-lexer",
"ratex-types",
"regex",
"regex-lite",
"serde",
"serde_json",
"thiserror 1.0.69",
"unicode-normalization",
]
[[package]]
name = "ratex-types"
version = "0.1.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cf0be3d050525f0f7d37af55a443d7648c222ccc617639950e3de1f8e6f9900"
dependencies = [
"serde",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@@ -4037,6 +4187,12 @@ dependencies = [
"regex-syntax",
]
[[package]]
name = "regex-lite"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
[[package]]
name = "regex-syntax"
version = "0.8.11"
@@ -4617,7 +4773,7 @@ dependencies = [
"const-oid",
"der",
"hex",
"rand",
"rand 0.10.3",
"reqwest",
"rustls-pki-types",
"rustls-webpki",
@@ -5346,6 +5502,15 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
[[package]]
name = "unicode-normalization"
version = "0.1.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d"
dependencies = [
"tinyvec",
]
[[package]]
name = "unicode-properties"
version = "0.1.4"
@@ -5786,8 +5951,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set",
"bit-vec",
"bit-set 0.10.0",
"bit-vec 0.9.1",
"bitflags 2.13.2",
"bytemuck",
"cfg_aliases",
@@ -5859,7 +6024,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set",
"bit-set 0.10.0",
"bitflags 2.13.2",
"block2 0.6.2",
"bytemuck",
+8
View File
@@ -24,6 +24,7 @@ gridcraft-engine = { path = "crates/engine" }
gridcraft-ui-egui = { path = "crates/ui-egui" }
gridcraft-mcp = { path = "crates/mcp" }
gridcraft-pdf = { path = "crates/pdf" }
gridcraft-math = { path = "crates/math" }
# Every craft-libs crate (craft-update and craft-window here, craft-ui in ui-egui, craft-print in
# the app) comes from one storytold/craft-libs revision.
# craft-update: app-specific pretend versions apply to startup and manual checks.
@@ -47,6 +48,13 @@ aes = { version = "0.9", default-features = false }
sha1 = { version = "0.11", default-features = false }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
# equations: RaTeX (MIT) lays LaTeX out as positioned glyphs, rules and paths. Exact pins: it is
# young (0.1.x), and the gate recorded in docs/decisions.md vetted this release.
ratex-parser = "=0.1.14"
ratex-layout = "=0.1.14"
ratex-types = "=0.1.14"
ratex-font = "=0.1.14"
# ui
egui = "0.37"
egui_extras = { version = "0.37", default-features = false }
+4 -2
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~25 points of "ready for real work" and ~160–240 h away
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-02 · **Change:** minor (equations: Insert › Equation, editor, gridcraft-math; earlier: What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear; earlier: floating objects: one selection, eight resize handles, keyboard delete; earlier: Chart data tables: live source values beneath column, bar, line, area and compatible combo charts; legend keys and borders; XLSX round-trip and print/PDF; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and Insert Function categories; XLSX workbook-view selection, bounded tab ratios and sheet-edit regressions; readiness estimates unchanged; earlier: CSV and text open detects each column's date order, so day-first dates are not silently swapped) · **Target:** Microsoft Excel (Microsoft 365)
GridCraft aims at full Microsoft Excel parity, and at being better in places: faster, open (XLSX
is the native format), fully scriptable by agents (MCP, control channel, CLI), and on the web.
@@ -22,7 +22,7 @@ This page is the summary. The detail is in:
| Number | Value | Kind |
|---|---|---|
| 2026-10-14 | Editing a cell with text that would read back as a number, boolean, date or formula (entered with a leading apostrophe) shows the apostrophe in the editor and formula bar, so Enter unchanged keeps it text (#1362) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 276 / 292 (94.5%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Feature breadth** (weighted) | **~75%** | estimated, anchored on two measurements: ribbon catalog 277 / 292 (94.9%), worksheet functions 513 / 523 (98.1%; 504 working, 96.4%) |
| **Ready for real work** | **~50%** | estimated from written weights (51.6); [target-app-parity.md](docs/target-app-parity.md) |
| **Mainstream practitioner** | **~50%** | estimated: typical analyst/finance user, weekly features, × 0.90 interaction × 0.92 stability × 0.88 file exchange; [method](docs/target-app-parity.md#mainstream-practitioner) |
| **Essentials user** | **~63%** | estimated: core features only, × 0.92 launch/stability × 0.95 clarity × 0.90 opening sent files; [method](docs/target-app-parity.md#essentials-user) |
@@ -135,6 +135,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | What landed |
|---|---|
| 2026-11-02 | Equations: Insert › Equation (a split button with a gallery of examples) and an editor with quick-insert buttons, the error underlined at its position and a live preview; `insert.equation` and `equation.edit`; LaTeX laid out by RaTeX inside the new `gridcraft-math` crate, capped and panic-guarded, and cached on the shape so that painting never runs the layout engine; xlsx writes Office Math with a plain-text fallback and the source in `descr`, and reads Office Math back into LaTeX; PDF prints equations with the standard Symbol font and `[?]` for anything else |
| 2026-11-02 | Solver writes a Sensitivity report: Simplex LP shadow prices, reduced costs and allowable ranges from the final tableau; GRG Nonlinear reduced gradients and Lagrange multipliers from difference gradients, at a Karush-Kuhn-Tucker point only; Solver Results offers it where it applies (no score change) |
| 2026-11-02 | Floating objects: charts, pictures, shapes and slicers share one selection with a frame of eight resize handles (each moves only its own edges, also on right-to-left sheets); Delete or Backspace deletes the selected object and Escape drops the selection; slicers follow row and column edits and sorts like the other objects; chart series follow cut-moves and sheet renames and deletes; a copied sheet gives its objects fresh ids; `ui.inspect` reports `selectedObject` and `ui.object.select` selects any object (#190, #213) |
| 2026-11-01 | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
@@ -253,6 +254,7 @@ Ranked; estimates in Opus 5.5 agent-hours. Detail and the full beta-gate list in
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Progress log: equations (Insert › Equation, the editor, gridcraft-math) |
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report for Simplex LP and GRG Nonlinear (no score change) |
| 2026-11-02 | minor | Progress log: one selection and eight resize handles for every floating object |
| 2026-11-01 | minor | Chart data tables are available from Chart Design › Add Chart Element and Format Chart Area. Tables follow live source values and number formats, keep blank cells blank, offer legend keys and border settings, and survive XLSX save/reopen; the shared renderer includes them in print and PDF. Readiness estimates unchanged. |
+1
View File
@@ -23,6 +23,7 @@ log = { workspace = true }
gridcraft-xlsx = { workspace = true }
gridcraft-chart = { workspace = true }
gridcraft-pdf = { workspace = true }
gridcraft-math = { workspace = true }
image = { workspace = true }
[dev-dependencies]
+5
View File
@@ -5,6 +5,7 @@ pub mod analysis;
pub mod data;
pub mod draw;
pub mod edit;
pub mod equation;
pub mod extra;
pub mod file;
pub mod forecast;
@@ -163,6 +164,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
v.extend(format::specs());
v.extend(sheet::specs());
v.extend(insert::specs());
v.extend(equation::specs());
v.extend(data::specs());
v.extend(formulas::specs());
v.extend(review::specs());
@@ -401,6 +403,9 @@ pub(crate) fn commit<R>(d: &mut DocState, f: impl FnOnce(&mut Ctx) -> Result<R>)
};
let r = f(&mut ctx)?;
let Ctx { mut wb, changed, structural, mut fit_rows, protection_checked, edits, .. } = ctx;
// Equations whose source changed (or that arrived without a layout, as from a paste) are
// laid out here, so painting only ever reads the cache.
wb.refresh_equations();
if !protection_checked {
check_protection(&d.wb, &wb, &d.unlocked_ranges)?;
}
+1
View File
@@ -80,6 +80,7 @@ fn stroke(s: &mut Session, p: &Json) -> Result<Json> {
let text = pts.iter().map(|q| format!("{:.1},{:.1}", q[0] - minx, q[1] - miny)).collect::<Vec<_>>().join(";");
let id = d.wb.next_object_id();
let shape = Shape {
math: Default::default(),
id,
kind: ShapeKind::Ink,
anchor: Anchor {
+305
View File
@@ -0,0 +1,305 @@
//! Equations (Insert › Symbols › Equation): floating shapes holding LaTeX source, laid out by
//! `gridcraft-math` and drawn from the cached layout.
//!
//! An equation is drawn to fit its frame, so resizing the frame scales it. Inserting sizes the
//! frame for the requested font size; editing keeps the current scale and resizes the frame to
//! the new content.
use gridcraft_math::{MathError, MathList};
use gridcraft_model::{Anchor, AnchorMode, Color, EquationCache, Shape, ShapeKind};
use serde_json::{Value as Json, json};
use super::{CommandSpec, EngineError, PROTECTED, Result, Session, bad, cell_param, cmd, edit, f64_param, has_doc, ok, str_param, u32_param};
pub fn specs() -> Vec<CommandSpec> {
vec![
cmd!(
"insert.equation",
"Equation",
["Insert", "Symbols"],
None,
"{latex: \"x^2\", cell?|at?: \"B2\", size?: 11 (points), color?: hex|\"auto\"}",
has_doc,
insert_equation
),
cmd!("equation.edit", "Edit Equation", [], None, "{id, latex}", has_doc, edit_equation),
]
}
/// Default equation font size, in points.
pub const DEFAULT_SIZE_PT: f64 = 11.0;
/// Space between an equation's frame and its content, in sheet pixels.
pub const PADDING: f32 = 4.0;
/// Sheet pixels per point.
const PX_PER_PT: f64 = 96.0 / 72.0;
/// The scale (sheet pixels per em) at which `list` fits a frame of `w` by `h` pixels.
pub fn fit_scale(list: &MathList, w: f32, h: f32) -> f32 {
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
let s = ((w - 2.0 * PADDING) / cw).min((h - 2.0 * PADDING) / ch);
if s.is_finite() { s.max(0.0) } else { 0.0 }
}
/// The frame size for `list` at `scale` pixels per em.
fn frame_for(list: &MathList, scale: f32) -> (f32, f32) {
let w = list.width.max(0.5) * scale + 2.0 * PADDING;
let h = list.total_height().max(1.0) * scale + 2.0 * PADDING;
(w.clamp(8.0, 20_000.0), h.clamp(8.0, 20_000.0))
}
/// `latex` laid out, or a readable error with the position of the problem.
fn lay_out(cmd: &str, latex: &str) -> Result<MathList> {
gridcraft_math::layout(latex).map_err(|e| bad(cmd, describe(latex, &e)))
}
/// "Error at character 5: Undefined control sequence: \foo".
pub fn describe(latex: &str, e: &MathError) -> String {
match e.char_pos(latex) {
Some(p) => format!("equation error at character {p}: {}", e.message),
None => format!("equation error: {}", e.message),
}
}
fn insert_equation(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "insert.equation";
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source, such as \"x^2 + y^2 = r^2\""))?;
if latex.trim().is_empty() {
return Err(bad(CMD, "`latex` is empty"));
}
let size = f64_param(p, "size").unwrap_or(DEFAULT_SIZE_PT);
if !(1.0..=409.0).contains(&size) {
return Err(bad(CMD, "`size` must be between 1 and 409 points"));
}
let color = match p.get("color") {
None | Some(Json::Null) => Color::Auto,
Some(c) => super::format::color_param(Some(c)).ok_or_else(|| bad(CMD, "`color` must be a hex colour such as \"#1F4E79\", or \"auto\""))?,
};
let list = lay_out(CMD, latex)?;
let at = match cell_param(s, p, "cell")? {
Some(c) => Some(c),
None => cell_param(s, p, "at")?,
};
let d = s.doc()?;
if d.wb.active().is_some_and(|sh| sh.is_protected()) {
return Err(EngineError::Other(PROTECTED.into()));
}
let at = at.unwrap_or(d.selection.active);
let id = d.wb.next_object_id();
let (width, height) = frame_for(&list, (size * PX_PER_PT) as f32);
let mut shape = Shape {
id,
kind: ShapeKind::Equation,
anchor: Anchor { cell: at, dx: 0.0, dy: 0.0, width, height, mode: AnchorMode::MoveOnly },
fill: Color::Auto,
line: color,
text: latex.to_string(),
format: Default::default(),
math: Default::default(),
};
shape.math.refresh(latex);
let sheet = d.wb.active_sheet();
edit(s, |cx| {
cx.sheet_mut(sheet)?.shapes.push(shape);
Ok(json!({"equation": id}))
})
}
fn edit_equation(s: &mut Session, p: &Json) -> Result<Json> {
const CMD: &str = "equation.edit";
let id = u32_param(p, "id").ok_or_else(|| bad(CMD, "`id` must be an equation id"))?;
let latex = str_param(p, "latex").ok_or_else(|| bad(CMD, "`latex` must be the equation's LaTeX source"))?;
if latex.trim().is_empty() {
return Err(bad(CMD, "`latex` is empty; delete the equation instead"));
}
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let sh = d.wb.active().ok_or_else(|| bad(CMD, "no such equation"))?;
let shape = sh.shapes.iter().find(|x| x.id == id && x.kind == ShapeKind::Equation).ok_or_else(|| bad(CMD, "no such equation"))?;
if sh.is_protected() {
return Err(EngineError::Other(PROTECTED.into()));
}
// The same source is a no-op, unless it has no layout yet (a file with more equations than
// are laid out at once): then OK lays it out.
if shape.text == latex && matches!(shape.math.get(latex), Some(Ok(_))) {
return ok();
}
let list = lay_out(CMD, latex)?;
// Keep the size the equation is drawn at now; without a layout, use the default size.
let scale = match shape.math.get(&shape.text) {
Some(Ok(old)) => fit_scale(old, shape.anchor.width, shape.anchor.height),
_ => 0.0,
};
let scale = if scale > 0.1 { scale } else { (DEFAULT_SIZE_PT * PX_PER_PT) as f32 };
let (width, height) = frame_for(&list, scale);
edit(s, |cx| {
let shape = cx.sheet_mut(sheet)?.shapes.iter_mut().find(|x| x.id == id).ok_or_else(|| bad(CMD, "no such equation"))?;
shape.text = latex.to_string();
shape.math = EquationCache::default();
shape.math.refresh(latex);
shape.anchor.width = width;
shape.anchor.height = height;
Ok(json!({"equation": id}))
})
}
// ---------------------------------------------------------------- PDF
/// The first [`gridcraft_math::MAX_SOURCE_BYTES`] bytes of an equation's source, cut at a char
/// boundary. A longer source never lays out (the editor refuses it, and a file can carry one of
/// megabytes), so what shows it as text, the sheet painter and the PDF fallback, shows this much
/// and does no work on the rest.
pub fn source_excerpt(text: &str) -> &str {
let cap = gridcraft_math::MAX_SOURCE_BYTES;
if text.len() <= cap {
return text;
}
let mut end = cap;
while end > 0 && !text.is_char_boundary(end) {
end -= 1;
}
text.get(..end).unwrap_or("")
}
/// The PDF face for a glyph: Symbol for Greek letters and operators, else the Times (or
/// Helvetica, Courier) face for its style. `None` when no standard font can show `c`.
fn pdf_font(c: char, face: gridcraft_math::Face) -> Option<gridcraft_pdf::Font> {
use gridcraft_math::Face;
use gridcraft_pdf::Font;
let text = match face {
Face::Italic => Font::TimesItalic,
Face::Bold => Font::TimesBold,
Face::BoldItalic => Font::TimesBoldItalic,
Face::Sans => Font::Helvetica,
Face::SansBold => Font::HelveticaBold,
Face::SansItalic => Font::HelveticaOblique,
Face::Mono => Font::Courier,
Face::Roman | Face::Large(_) => Font::TimesRoman,
};
// Letters and digits stay in the text face; everything Symbol has goes to Symbol, whose
// operators (minus, equals, plus) match the maths better than the text faces'.
if c.is_ascii_alphanumeric() {
return Some(text);
}
if Font::Symbol.can_encode(c) {
return Some(Font::Symbol);
}
text.can_encode(c).then_some(text)
}
/// Draws an equation's layout into the box `(x, y, w, h)` (page points) of a PDF page, with
/// `pad` points of padding. Glyphs no standard font has are drawn as `[?]` boxes.
pub(crate) fn draw_pdf(pg: &mut gridcraft_pdf::Page, list: &MathList, rect: (f32, f32, f32, f32), pad: f32, ink: gridcraft_pdf::Rgb) {
use gridcraft_math::{Face, Item, PathCmd};
let (x, y, w, h) = rect;
let (cw, ch) = (list.width.max(0.01), list.total_height().max(0.01));
let s = ((w - 2.0 * pad) / cw).min((h - 2.0 * pad) / ch);
if !(s.is_finite() && s > 0.01) {
return;
}
let s = s.min(4000.0);
let (ox, oy) = (x + (w - cw * s) / 2.0, y + (h - ch * s) / 2.0);
let col = |c: Option<[u8; 3]>| c.unwrap_or(ink);
for item in list.items.iter().take(gridcraft_math::MAX_ITEMS) {
match item {
Item::Glyph(g) => {
let color = col(g.color);
let (gx, base) = (ox + g.x * s, oy + g.y * s);
// The radical sign: Symbol's glyph (AFM bounding box -147 to 917 high, right edge
// at 558, in 1/1000 em) fitted to the TeX box, so its top meets the vinculum.
if g.ch == '\u{221A}' {
let size = (g.height + g.depth) * s / 1.064;
if size.is_finite() && size >= 0.5 {
let top = base - g.height * s;
pg.text(gx + g.width * s - 0.558 * size, top + 0.917 * size, size, gridcraft_pdf::Font::Symbol, color, "\u{221A}");
}
continue;
}
let large = matches!(g.face, Face::Large(_));
let size = if large { (g.height + g.depth) * s } else { g.size * s };
if !(size.is_finite() && size >= 0.5) {
continue;
}
let pick = pdf_font(g.ch, g.face).map(|f| (g.ch, f)).or_else(|| g.alt.and_then(|a| pdf_font(a, g.face).map(|f| (a, f))));
match pick {
Some((c, font)) => {
let text = c.to_string();
if large {
// Centred on the glyph's box, like the screen.
let mid = oy + (g.y - (g.height - g.depth) / 2.0) * s;
let tw = gridcraft_pdf::text_width(font, size, &text);
pg.text(gx + (g.width * s - tw) / 2.0, mid + 0.25 * size, size, font, color, &text);
} else {
pg.text(gx, base, size, font, color, &text);
}
}
None => {
// [?]: the glyph's box with a question mark.
let (bw, bh) = ((g.width.max(0.3)) * s, (g.height + g.depth) * s);
pg.rect_stroke(gx, base - g.height * s, bw, bh, (0.05 * s).clamp(0.2, 1.0), color);
let qs = (bh * 0.6).min(bw * 1.2).max(1.0);
let qw = gridcraft_pdf::text_width(gridcraft_pdf::Font::Helvetica, qs, "?");
pg.text(gx + (bw - qw) / 2.0, base - g.height * s + (bh + qs * 0.7) / 2.0, qs, gridcraft_pdf::Font::Helvetica, color, "?");
}
}
}
Item::Rule(r) => {
if r.width > 0.0 && r.height > 0.0 {
pg.fill_rect(ox + r.x * s, oy + r.y * s, r.width * s, (r.height * s).max(0.25), col(r.color));
}
}
Item::Path(p) => {
let (px, py) = (ox + p.x * s, oy + p.y * s);
let mut subs: Vec<Vec<[f32; 2]>> = vec![];
let mut cur: Vec<[f32; 2]> = vec![];
let at = |a: f32, b: f32| [px + a * s, py + b * s];
for c in &p.commands {
match *c {
PathCmd::MoveTo(a, b) => {
if cur.len() > 1 {
subs.push(std::mem::take(&mut cur));
}
cur = vec![at(a, b)];
}
PathCmd::LineTo(a, b) => cur.push(at(a, b)),
PathCmd::QuadTo(x1, y1, a, b) => {
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
let (c1, p1) = (at(x1, y1), at(a, b));
for k in 1..=8 {
let t = k as f32 / 8.0;
let u = 1.0 - t;
cur.push([u * u * p0[0] + 2.0 * u * t * c1[0] + t * t * p1[0], u * u * p0[1] + 2.0 * u * t * c1[1] + t * t * p1[1]]);
}
}
PathCmd::CubicTo(x1, y1, x2, y2, a, b) => {
let p0 = cur.last().copied().unwrap_or(at(x1, y1));
let (c1, c2, p1) = (at(x1, y1), at(x2, y2), at(a, b));
for k in 1..=10 {
let t = k as f32 / 10.0;
let u = 1.0 - t;
let (a3, b3, c3, d3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
cur.push([a3 * p0[0] + b3 * c1[0] + c3 * c2[0] + d3 * p1[0], a3 * p0[1] + b3 * c1[1] + c3 * c2[1] + d3 * p1[1]]);
}
}
PathCmd::Close => {
if cur.len() > 1 {
subs.push(std::mem::take(&mut cur));
}
}
}
}
if cur.len() > 1 {
subs.push(cur);
}
let color = col(p.color);
for sub in subs.iter().filter(|v| v.len() <= 4000) {
if p.fill && sub.len() >= 3 {
// The PDF fill rule handles concave outlines itself.
pg.polygon(sub, Some(color), None);
} else {
pg.polyline(sub, (0.05 * s).clamp(0.2, 1.0), color, &[]);
}
}
}
}
}
}
+5 -5
View File
@@ -3,8 +3,8 @@
use gridcraft_core::{CellRef, RangeRef, Value};
use gridcraft_model::{
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline, TrendlineKind,
Workbook,
Anchor, Chart, Color, DefinedName, HAlign, LegendPos, NumFmt, Shape, ShapeKind, Sheet, SheetViewMode, TableColumn, TotalsFn, Trendline,
TrendlineKind, Workbook,
};
use serde_json::{Value as Json, json};
@@ -97,7 +97,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Bring Forward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer, id, toFront?: bool}",
"{kind: chart|image|shape|slicer|equation, id, toFront?: bool}",
has_doc,
|s, p| arrange(s, p, true)
),
@@ -106,7 +106,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Send Backward",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer, id, toBack?: bool}",
"{kind: chart|image|shape|slicer|equation, id, toBack?: bool}",
has_doc,
|s, p| arrange(s, p, false)
),
@@ -116,7 +116,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Page Layout", "Arrange"],
None,
"{kind: chart|image|shape|slicer, ids: [..], align: left|center|right|top|middle|bottom}",
"{kind: chart|image|shape|slicer|equation, ids: [..], align: left|center|right|top|middle|bottom}",
has_doc,
align
),
+57 -34
View File
@@ -1,8 +1,29 @@
use gridcraft_model::ObjKind;
use super::chart::format_series;
use super::{
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param, u32_param,
Anchor, CellRef, Color, EngineError, Json, Result, Session, Shape, ShapeKind, Sheet, anchor_mode_name, bad, bool_param, edit, json, str_param,
u32_param,
};
/// The kind of object a `kind` parameter names: `shape` when the parameter is absent; one that
/// is present but not one of the names (`null`, a number, an unknown word) is an error, as for
/// the `object.*` commands, where the kind is required.
fn kind_param(p: &Json, cmd: &str) -> Result<ObjKind> {
let err = || bad(cmd, "kind must be chart, image, shape, slicer or equation");
match p.get("kind") {
None => Ok(ObjKind::Shape),
Some(Json::String(k)) => ObjKind::parse(k).ok_or_else(err),
Some(_) => Err(err()),
}
}
/// Whether `kind` names the shape `sh`: an equation is a shape too, and a plain shape is not an
/// equation, as `Sheet::object_anchor` has it for the `object.*` commands.
fn shape_is(kind: ObjKind, sh: &Shape) -> bool {
kind != ObjKind::Equation || sh.kind == ShapeKind::Equation
}
pub(super) fn shape_fill(s: &mut Session, p: &Json) -> Result<Json> {
if str_param(p, "kind") == Some("shape") {
let color = str_param(p, "color").and_then(Color::from_hex).unwrap_or_default();
@@ -33,7 +54,7 @@ fn with_shape(s: &mut Session, p: &Json, f: impl FnOnce(&mut Shape)) -> Result<J
pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json> {
let id = u32_param(p, "id").ok_or_else(|| bad("arrange", "missing `id`"))?;
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let kind = kind_param(p, "arrange")?;
let extreme = bool_param(p, "toFront").or(bool_param(p, "toBack")).unwrap_or(false);
let si = s.doc()?.wb.active_sheet();
fn reorder<T>(v: &mut Vec<T>, i: usize, forward: bool, extreme: bool) {
@@ -51,28 +72,17 @@ pub(super) fn arrange(s: &mut Session, p: &Json, forward: bool) -> Result<Json>
}
edit(s, |cx| {
let sh = cx.sheet_mut(si)?;
match kind.as_str() {
"chart" => {
if let Some(i) = sh.charts.iter().position(|c| c.id == id) {
reorder(&mut sh.charts, i, forward, extreme);
}
// An id that is not an object of this kind is an error, so a stale id, or a shape's id
// given as an equation, never reorders something else or succeeds silently.
let found = match kind {
ObjKind::Chart => sh.charts.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.charts, i, forward, extreme)),
ObjKind::Image => sh.images.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.images, i, forward, extreme)),
ObjKind::Slicer => sh.slicers.iter().position(|c| c.id == id).map(|i| reorder(&mut sh.slicers, i, forward, extreme)),
ObjKind::Shape | ObjKind::Equation => {
sh.shapes.iter().position(|c| c.id == id && shape_is(kind, c)).map(|i| reorder(&mut sh.shapes, i, forward, extreme))
}
"image" => {
if let Some(i) = sh.images.iter().position(|c| c.id == id) {
reorder(&mut sh.images, i, forward, extreme);
}
}
"slicer" => {
if let Some(i) = sh.slicers.iter().position(|c| c.id == id) {
reorder(&mut sh.slicers, i, forward, extreme);
}
}
_ => {
if let Some(i) = sh.shapes.iter().position(|c| c.id == id) {
reorder(&mut sh.shapes, i, forward, extreme);
}
}
}
};
found.ok_or_else(|| bad("arrange", "no such object"))?;
Ok(Json::Null)
})
}
@@ -82,7 +92,8 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
let sh = d.wb.active().ok_or(EngineError::NoDocument)?;
let mut v = Vec::new();
for c in &sh.shapes {
v.push(json!({"kind": "shape", "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
let kind = if c.kind == ShapeKind::Equation { "equation" } else { "shape" };
v.push(json!({"kind": kind, "id": c.id, "name": format!("{:?} {}", c.kind, c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
}
for c in &sh.images {
v.push(json!({"kind": "image", "id": c.id, "name": format!("Picture {}", c.id), "at": c.anchor.cell.a1(), "anchorMode": anchor_mode_name(c.anchor.mode)}));
@@ -98,31 +109,43 @@ pub(super) fn selection_pane(s: &mut Session, _: &Json) -> Result<Json> {
Ok(Json::Array(v))
}
fn anchors_of<'a>(sh: &'a mut Sheet, kind: &str) -> Vec<(u32, &'a mut Anchor)> {
/// The anchors of every object of `kind` on the sheet; under `equation`, only the equations.
fn anchors_of(sh: &mut Sheet, kind: ObjKind) -> Vec<(u32, &mut Anchor)> {
match kind {
"chart" => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
"image" => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
"slicer" => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
_ => sh.shapes.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Chart => sh.charts.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Image => sh.images.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Slicer => sh.slicers.iter_mut().map(|c| (c.id, &mut c.anchor)).collect(),
ObjKind::Shape | ObjKind::Equation => sh.shapes.iter_mut().filter(|c| shape_is(kind, c)).map(|c| (c.id, &mut c.anchor)).collect(),
}
}
pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
let kind = str_param(p, "kind").unwrap_or("shape").to_string();
let ids: Vec<u32> =
p.get("ids").and_then(Json::as_array).map(|a| a.iter().filter_map(|v| v.as_u64().map(|x| x as u32)).collect()).unwrap_or_default();
let kind = kind_param(p, "arrange.align")?;
// `ids` may be absent (nothing to align) but, present, must be an array whose every entry is
// an object id naming an object of the kind: a stale or mismatched id, or a value of another
// shape, is an error, as for `arrange` and the `object.*` commands, not something silently
// left out.
let bad_ids = || bad("arrange.align", "ids must be an array of object ids");
let ids: Vec<u32> = match p.get("ids") {
None => Vec::new(),
Some(Json::Array(a)) => a.iter().map(|v| v.as_u64().and_then(|x| u32::try_from(x).ok()).ok_or_else(bad_ids)).collect::<Result<_>>()?,
Some(_) => return Err(bad_ids()),
};
let how = str_param(p, "align").unwrap_or("left").to_string();
let si = s.doc()?.wb.active_sheet();
edit(s, |cx| {
let sheet = cx.sheet_mut(si)?;
let geo: Vec<(u32, f64, f64, f32, f32)> = {
let shc = sheet.clone();
anchors_of(sheet, &kind)
anchors_of(sheet, kind)
.into_iter()
.filter(|(id, _)| ids.contains(id))
.map(|(id, a)| (id, shc.col_left(a.cell.col) + a.dx as f64, shc.row_top(a.cell.row) + a.dy as f64, a.width, a.height))
.collect()
};
if let Some(missing) = ids.iter().find(|id| !geo.iter().any(|g| g.0 == **id)) {
return Err(bad("arrange.align", format!("no such object: {missing}")));
}
if geo.len() < 2 {
return Ok(Json::Null);
}
@@ -131,7 +154,7 @@ pub(super) fn align(s: &mut Session, p: &Json) -> Result<Json> {
let top = geo.iter().map(|g| g.2).fold(f64::INFINITY, f64::min);
let bottom = geo.iter().map(|g| g.2 + g.4 as f64).fold(f64::NEG_INFINITY, f64::max);
let shc = sheet.clone();
for (id, a) in anchors_of(sheet, &kind) {
for (id, a) in anchors_of(sheet, kind) {
let Some(g) = geo.iter().find(|g| g.0 == id) else { continue };
let (mut x, mut y) = (g.1, g.2);
match how.as_str() {
+6 -4
View File
@@ -146,13 +146,13 @@ pub fn specs() -> Vec<CommandSpec> {
&with(p, "kind", json!("textBox"))
)),
cmd!("shape.setText", "Edit Text Box", [], None, "{id: textBox id, text: string}", has_doc, shape_set_text),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer, id}", has_doc, delete_object),
cmd!("object.delete", "Delete Object", [], None, "{kind: chart|image|shape|slicer|equation, id}", has_doc, delete_object),
cmd!(
"object.move",
"Move Object",
[],
None,
"{kind: chart|image|shape|slicer, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
"{kind: chart|image|shape|slicer|equation, id, at?: \"C3\", dx?, dy?, width?, height? (each at most 10,000 points, or the object's current size when a file made it larger)}",
has_doc,
move_object
),
@@ -161,7 +161,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Object Properties",
[],
None,
"{kind: chart|image|shape|slicer, id, mode: moveAndSize|moveOnly|absolute}",
"{kind: chart|image|shape|slicer|equation, id, mode: moveAndSize|moveOnly|absolute}",
has_doc,
set_anchor_mode
),
@@ -1170,6 +1170,7 @@ fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
Color::Theme(4, -250)
});
let shape = Shape {
math: Default::default(),
id,
kind,
anchor: Anchor {
@@ -1227,7 +1228,7 @@ fn shape_set_text(s: &mut Session, p: &Json) -> Result<Json> {
/// objects are errors, so a stale id never succeeds silently.
fn object_param(s: &Session, p: &Json, cmd: &str) -> Result<(usize, ObjRef)> {
let id = u32_param(p, "id").ok_or_else(|| bad(cmd, "missing `id`"))?;
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape or slicer"))?;
let kind = str_param(p, "kind").and_then(ObjKind::parse).ok_or_else(|| bad(cmd, "kind must be chart, image, shape, slicer or equation"))?;
let d = s.doc()?;
let sheet = d.wb.active_sheet();
let o = ObjRef::new(kind, id);
@@ -1784,6 +1785,7 @@ fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
let at = cell_param(s, p, "at")?.unwrap_or(d.selection.active);
let color = super::format::color_param(p.get("color")).unwrap_or(Color::Theme(1, 0));
let shape = Shape {
math: Default::default(),
id,
kind: ShapeKind::Icon,
anchor: Anchor { cell: at, dx: 4.0, dy: 4.0, width: size, height: size, mode: anchor_mode_param(p).unwrap_or_default() },
@@ -333,6 +333,7 @@ pub(super) fn insert_icon(s: &mut Session, p: &Json) -> Result<Json> {
line: Color::Auto,
text: name,
format: Default::default(),
math: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
+1
View File
@@ -153,6 +153,7 @@ pub(super) fn insert_shape(s: &mut Session, p: &Json) -> Result<Json> {
line,
text: str_param(p, "text").unwrap_or("").into(),
format: Default::default(),
math: Default::default(),
};
let sheet = d.wb.active_sheet();
edit(s, |cx| {
+39
View File
@@ -29,6 +29,26 @@ pub(super) fn draw_cell_picture(pg: &mut Page, data: &[u8], rect: (f32, f32, f32
}
/// Sheet pixel rectangle of an anchor.
/// Most source lines printed for an equation that has no layout.
pub(super) const MAX_FALLBACK_LINES: usize = 500;
/// The lines of `text` printed in place of an equation that has no layout: those of its first
/// `MAX_SOURCE_BYTES` (a longer source never lays out, and a file can carry megabytes) that fit a
/// frame `h` points tall at `line_h` points per line (at least the first), and never more than
/// [`MAX_FALLBACK_LINES`]. A single `pg.text` call would print every newline as `?`.
pub(super) fn fallback_lines(text: &str, h: f32, line_h: f32) -> impl Iterator<Item = &str> {
let text = crate::cmd::equation::source_excerpt(text);
let fit = h / line_h;
let n = if fit >= MAX_FALLBACK_LINES as f32 {
MAX_FALLBACK_LINES
} else if fit >= 1.0 {
fit as usize
} else {
1
};
text.lines().take(n)
}
fn anchor_rect(sh: &Sheet, a: &Anchor) -> (f64, f64, f64, f64) {
(sh.col_left(a.cell.col) + a.dx as f64, sh.row_top(a.cell.row) + a.dy as f64, a.width.max(0.0) as f64, a.height.max(0.0) as f64)
}
@@ -79,6 +99,25 @@ pub(super) fn draw_objects(pg: &mut Page, wb: &Workbook, si: usize, sh: &Sheet,
continue;
}
let (x, y, w, h) = place(r);
if shp.kind == ShapeKind::Equation {
let ink = rgb_of(shp.line, wb).unwrap_or([0, 0, 0]);
match shp.math.get(&shp.text) {
Some(Ok(list)) => crate::cmd::equation::draw_pdf(pg, list, (x, y, w, h), crate::cmd::equation::PADDING * b.k, ink),
_ => {
// No layout (the source does not parse, or the file was past the layout
// budget): the source, one text run per line, as many as the frame holds.
let line_h = 12.0 * b.scale;
// A long line stays inside the frame; this clip nests in the block's.
pg.save();
pg.clip_rect(x, y, w, h);
for (i, line) in fallback_lines(&shp.text, h, line_h).enumerate() {
pg.text(x + 2.0 * b.k, y + line_h * (i + 1) as f32, 10.0 * b.scale, Font::Courier, ink, line);
}
pg.restore();
}
}
continue;
}
draw_shape(pg, wb, shp, b, (x, y, w, h));
}
for ch in &sh.charts {
+118
View File
@@ -329,6 +329,14 @@ fn first_and_even_pages_print_their_own_headers() {
assert!(contains(&pdf, "(Odd 1) Tj") && contains(&pdf, "(Odd 2) Tj") && !contains(&pdf, "(Cover) Tj"));
}
/// The y position of the first `(text) Tj` in a PDF (PDF space: larger is higher on the page).
fn text_y(pdf: &[u8], text: &str) -> Option<f32> {
let s = String::from_utf8_lossy(pdf);
let before = &s[..s.find(&format!("({text}) Tj"))?];
let tm = before.rfind(" Tm")?;
before[..tm].rsplit([' ', '\n']).next()?.parse().ok()
}
/// The x position and font size of the first `(text) Tj` in a PDF.
fn text_at(pdf: &[u8], text: &str) -> Option<(f32, f32)> {
let s = String::from_utf8_lossy(pdf);
@@ -741,3 +749,113 @@ fn checkbox_cells_print_as_boxes_not_true_false() {
assert!(contains(&pdf, "0.063 0.486 0.255 rg"), "the checked box is not filled");
assert!(contains(&pdf, "0.431 0.431 0.431 RG"), "the unchecked box is not outlined");
}
#[test]
fn an_equation_without_a_layout_prints_its_source_one_line_per_line() {
use super::objects::{MAX_FALLBACK_LINES, fallback_lines};
let mut s = s();
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
// A file can carry a source the layout engine refuses: two lines here, in a frame 100 px
// (75 pt) tall, which holds six 12 pt lines.
let with_source = |text: &str| {
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
for x in sh.shapes.iter_mut() {
x.text = text.into();
x.anchor.height = 100.0;
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
pdf_of(&mut t, json!({})).0
};
let pdf = with_source("frac{\nsecond line");
assert!(contains(&pdf, "(frac{) Tj"), "the first line is its own text run");
assert!(contains(&pdf, "(second line) Tj"), "and so is the second");
assert!(!contains(&pdf, "(frac{?second line) Tj"), "the newline is not printed as ?");
let (y1, y2) = (text_y(&pdf, "frac{").unwrap(), text_y(&pdf, "second line").unwrap());
assert!(y1 > y2 && y1 - y2 < 13.0, "the second line sits one line below the first: {y1} {y2}");
// Lines the frame cannot hold are not printed.
let pdf = with_source(&"l\n".repeat(10_000));
assert_eq!(String::from_utf8_lossy(&pdf).matches("(l) Tj").count(), 6);
// Whatever the frame, the count is capped; a frame too low for one line still shows the first.
let tall = "l\n".repeat(10_000);
assert_eq!(fallback_lines(&tall, f32::INFINITY, 12.0).count(), MAX_FALLBACK_LINES);
assert_eq!(fallback_lines(&tall, 1.0e9, 12.0).count(), MAX_FALLBACK_LINES);
assert_eq!(fallback_lines("a\nb\nc", 30.0, 12.0).count(), 2);
assert_eq!(fallback_lines("a\nb", 0.0, 12.0).count(), 1);
assert_eq!(fallback_lines("a\nb", f32::NAN, 12.0).count(), 1);
}
#[test]
fn the_fallback_text_is_clipped_to_the_equation_frame() {
// A long line of a source that does not lay out, in a narrow frame (40 by 100 px, 30 by
// 75 pt): the lines are drawn inside a clip of the frame, nested in the block's, so they
// cannot run over neighbours, and the clip is lifted again before the next object (a text
// box below the equation in the same block) is drawn.
let mut s = s();
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
s.execute("insert.textBox", json!({"at": "B12", "text": "later box"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
for x in sh.shapes.iter_mut().filter(|x| x.kind == gridcraft_model::ShapeKind::Equation) {
x.text = format!("frac{{{}", "l".repeat(200));
x.anchor.width = 40.0;
x.anchor.height = 100.0;
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let (pdf, _) = pdf_of(&mut t, json!({}));
let text = String::from_utf8_lossy(&pdf);
let clip = text.find("30 75 re W n").expect("a clip the size of the frame");
let run = text.find("(frac{llll").expect("the source line");
let later = text.find("(later box) Tj").expect("the text box after the equation");
assert!(clip < run && run < later, "clip, then the text, then the later object");
// The net number of saved graphics states (`q` less `Q`) before an operator.
let depth = |at: usize| text[..at].lines().filter(|l| *l == "q").count() as i64 - text[..at].lines().filter(|l| *l == "Q").count() as i64;
assert_eq!(depth(run), depth(clip), "the text is drawn under the frame's clip");
assert_eq!(depth(later), depth(clip) - 1, "the later object is drawn with the frame's state restored");
}
#[test]
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
fn an_equation_source_of_a_megabyte_prints_a_bounded_excerpt() {
// A file can carry a source far past `MAX_SOURCE_BYTES` (the drawing part may be 512 MiB). It
// never lays out, so the fallback prints the first 4,096 bytes and does no work on the rest:
// the PDF holds one run of that length, not the megabyte, and export takes bounded time.
let mut s = s();
s.execute("insert.equation", json!({"latex": "x", "cell": "B2"})).unwrap();
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
for x in sh.shapes.iter_mut() {
x.text = "l".repeat(1 << 20);
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let start = std::time::Instant::now();
let (pdf, _) = pdf_of(&mut t, json!({}));
let took = start.elapsed();
let text = String::from_utf8_lossy(&pdf);
let longest = text.split(|c| c != 'l').map(str::len).max().unwrap_or(0);
assert_eq!(longest, gridcraft_math::MAX_SOURCE_BYTES, "the run is the excerpt and no more");
assert!(pdf.len() < 64 * 1024, "the PDF holds the excerpt, not the megabyte: {} bytes", pdf.len());
assert!(took < std::time::Duration::from_secs(5), "{took:?}");
}
#[test]
fn equations_print_with_the_symbol_font() {
let mut s = s();
s.execute("cell.set", json!({"cell": "A1", "input": "x"})).unwrap();
s.execute("insert.equation", json!({"latex": "\\sum_{i=1}^{n} \\alpha_i \\le \\infty \\pm \\sqrt{x} \\times \\partial y", "cell": "B2"}))
.unwrap();
s.execute("insert.equation", json!({"latex": "\\mathbb{R} \\oplus \\text{\u{4E2D}}", "cell": "B8"})).unwrap();
let (pdf, _) = pdf_of(&mut s, json!({}));
assert!(contains(&pdf, "/BaseFont /Symbol >>"), "maths symbols use the Symbol font");
assert!(contains(&pdf, "/BaseFont /Times-Italic"), "variables are italic");
// The summation (0xE5) and alpha (0x61) codes in Symbol.
assert!(contains(&pdf, "(\\345) Tj") && contains(&pdf, "(a) Tj"));
// A glyph no standard font has is a [?] box.
assert!(contains(&pdf, "(?) Tj"));
}
+4 -1
View File
@@ -39,6 +39,7 @@ pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command, register_te
pub use gridcraft_calc as calc;
pub use gridcraft_core as core;
pub use gridcraft_formula as formula;
pub use gridcraft_math as math;
pub use gridcraft_model as model;
pub use gridcraft_numfmt as numfmt;
pub use selection::Selection;
@@ -304,7 +305,9 @@ impl DocState {
calc.rebuild(&wb);
Self::with_calc(wb, calc, path, title, trust)
}
fn with_calc(wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
fn with_calc(mut wb: Workbook, calc: Calc, path: Option<String>, title: String, trust: Trust) -> DocState {
// Equations arrive without a layout (files never store one); painting reads only the cache.
wb.refresh_equations();
let scripts_trusted = !cmd::extra::has_scripts(&wb);
let wb = Arc::new(wb);
let sel = Selection::at(wb.active().map(|s| s.view_active).unwrap_or_default());
+1
View File
@@ -5243,6 +5243,7 @@ fn a_script_cannot_switch_windows() {
assert_eq!(v(&s, "A1"), Value::number(2.0));
}
mod equation;
mod macros;
mod objects;
mod seams;
+227
View File
@@ -0,0 +1,227 @@
//! Equations: insert, edit, the layout cache and its rebuild on load.
use gridcraft_model::{Color, Shape, ShapeKind};
use serde_json::json;
use super::s;
use crate::Session;
fn equation(s: &Session, id: u32) -> Shape {
s.doc().unwrap().wb.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap()
}
fn insert(s: &mut Session, p: serde_json::Value) -> u32 {
s.execute("insert.equation", p).unwrap()["equation"].as_u64().unwrap() as u32
}
#[test]
fn insert_lays_the_equation_out_and_sizes_its_frame() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "cell": "C3"}));
let e = equation(&s, id);
assert_eq!(e.kind, ShapeKind::Equation);
assert_eq!(e.anchor.cell.a1(), "C3");
assert_eq!(e.line, Color::Auto, "Automatic ink");
let list = e.math.get(&e.text).unwrap().unwrap();
let scale = crate::cmd::equation::fit_scale(list, e.anchor.width, e.anchor.height);
assert!((scale - 11.0 * 96.0 / 72.0).abs() < 0.01, "11 pt: {scale}");
// A bigger size makes a bigger frame; `at` works like `cell`.
let big = insert(&mut s, json!({"latex": "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", "at": "H3", "size": 22, "color": "#1F4E79"}));
let b = equation(&s, big);
assert!((b.anchor.width - 8.0) > 1.9 * (e.anchor.width - 8.0));
assert_eq!(b.line, Color::Rgb(0x1F4E79));
assert_eq!(b.anchor.cell.a1(), "H3");
// One undo step each.
s.execute("edit.undo", json!({})).unwrap();
assert!(s.doc().unwrap().wb.active().unwrap().shapes.iter().all(|x| x.id != big));
}
#[test]
fn insert_refuses_missing_bad_or_hostile_source() {
let mut s = s();
for p in [
json!({}),
json!({"latex": ""}),
json!({"latex": " "}),
json!({"latex": 5}),
json!({"latex": "x", "size": 0}),
json!({"latex": "x", "size": f64::MAX}),
json!({"latex": "x", "color": "nope"}),
] {
assert!(s.execute("insert.equation", p.clone()).is_err(), "{p}");
}
let e = s.execute("insert.equation", json!({"latex": "a + \\foo"})).unwrap_err().to_string();
assert!(e.contains("character 5") && e.contains("\\foo"), "{e}");
let e = s.execute("insert.equation", json!({"latex": "x".repeat(5000)})).unwrap_err().to_string();
assert!(e.contains("4096"), "{e}");
let e = s.execute("insert.equation", json!({"latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
assert!(e.contains("nest"), "{e}");
assert!(s.doc().unwrap().wb.active().unwrap().shapes.is_empty());
// `equation.edit` is the other way a source reaches the layout engine; it has the same caps,
// and a refused edit leaves the equation as it was.
let id = insert(&mut s, json!({"latex": "x"}));
let e = s.execute("equation.edit", json!({"id": id, "latex": "x".repeat(5000)})).unwrap_err().to_string();
assert!(e.contains("4096"), "{e}");
let e = s.execute("equation.edit", json!({"id": id, "latex": format!("{}x{}", "{".repeat(40), "}".repeat(40))})).unwrap_err().to_string();
assert!(e.contains("nest"), "{e}");
assert_eq!(equation(&s, id).text, "x");
}
#[test]
fn edit_keeps_the_scale_and_fits_the_frame() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x", "size": 20}));
let before = equation(&s, id);
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
let after = equation(&s, id);
assert_eq!(after.text, "x + y + z");
let list = after.math.get(&after.text).unwrap().unwrap();
let scale = crate::cmd::equation::fit_scale(list, after.anchor.width, after.anchor.height);
assert!((scale - 20.0 * 96.0 / 72.0).abs() < 0.05, "{scale}");
assert!(after.anchor.width > before.anchor.width);
// A frame the user resized keeps its new scale through an edit.
s.execute("object.move", json!({"kind": "equation", "id": id, "width": after.anchor.width * 2.0, "height": after.anchor.height * 2.0})).unwrap();
s.execute("equation.edit", json!({"id": id, "latex": "x + y"})).unwrap();
let e = equation(&s, id);
let scale = crate::cmd::equation::fit_scale(e.math.get(&e.text).unwrap().unwrap(), e.anchor.width, e.anchor.height);
assert!(scale > 2.0 * 20.0 * 96.0 / 72.0 * 0.95, "{scale}");
// Undo restores the source and its layout.
s.execute("edit.undo", json!({})).unwrap();
let e = equation(&s, id);
assert_eq!(e.text, "x + y + z");
assert!(e.math.get(&e.text).unwrap().is_ok());
// Same source: nothing to do. Bad source: refused, nothing changes.
let depth = s.doc().unwrap().undo.len();
s.execute("equation.edit", json!({"id": id, "latex": "x + y + z"})).unwrap();
assert_eq!(s.doc().unwrap().undo.len(), depth);
assert!(s.execute("equation.edit", json!({"id": id, "latex": "\\frac{"})).is_err());
assert!(s.execute("equation.edit", json!({"id": id, "latex": ""})).is_err());
assert_eq!(equation(&s, id).text, "x + y + z");
}
#[test]
fn the_source_excerpt_is_at_most_the_cap_and_whole_chars() {
use crate::cmd::equation::source_excerpt;
use gridcraft_math::MAX_SOURCE_BYTES as CAP;
assert_eq!(source_excerpt(""), "");
assert_eq!(source_excerpt("x+y"), "x+y");
let at = "x".repeat(CAP);
assert_eq!(source_excerpt(&at), at, "a source at the cap is whole");
assert_eq!(source_excerpt(&format!("{at}tail")), at);
// A three-byte character straddling the cap is left out rather than cut in two.
let s = format!("{}\u{20AC}", "x".repeat(CAP - 1));
assert_eq!(source_excerpt(&s), "x".repeat(CAP - 1));
let s = format!("{}\u{20AC}\u{20AC}", "x".repeat(CAP - 2));
assert_eq!(source_excerpt(&s).len(), CAP - 2);
assert_eq!(source_excerpt(&"\u{1F600}".repeat(2000)).len(), CAP);
}
#[test]
fn edit_refuses_what_is_not_an_equation() {
let mut s = s();
let tb = s.execute("insert.textBox", json!({"text": "hi"})).unwrap()["shape"].as_u64().unwrap();
assert!(s.execute("equation.edit", json!({"id": tb, "latex": "x"})).is_err());
assert!(s.execute("equation.edit", json!({"id": 999, "latex": "x"})).is_err());
assert!(s.execute("equation.edit", json!({"latex": "x"})).is_err());
assert!(s.execute("equation.edit", json!({"id": -1, "latex": "x"})).is_err());
}
#[test]
fn layouts_are_rebuilt_on_load_and_not_saved() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "e^{i\\pi} + 1 = 0"}));
let wb = s.doc().unwrap().wb.clone();
let bytes = serde_json::to_vec(&*wb).unwrap();
assert!(!String::from_utf8_lossy(&bytes).contains("\"math\""), "the cache is not saved");
let (opened, _) = crate::io::open_bytes("book.json", &bytes).unwrap();
let raw = opened.active().unwrap().shapes.iter().find(|x| x.id == id).cloned().unwrap();
assert!(raw.math.get(&raw.text).is_none(), "a freshly read shape has no layout");
let mut t = Session::new();
t.add_document(crate::DocState::new(opened, None, "Book".into(), crate::Trust::FromFile));
let e = equation(&t, id);
assert!(e.math.get(&e.text).unwrap().is_ok(), "opening lays it out");
}
#[test]
fn a_bad_source_from_a_file_keeps_its_error() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x"}));
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
sh.shapes.iter_mut().for_each(|x| x.text = "\\frac{".into());
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let e = equation(&t, id);
assert!(e.math.get(&e.text).unwrap().is_err());
}
#[test]
fn equations_on_a_protected_sheet_are_locked() {
let mut s = s();
let id = insert(&mut s, json!({"latex": "x"}));
s.execute("review.protectSheet", json!({})).unwrap();
assert!(s.execute("equation.edit", json!({"id": id, "latex": "y"})).is_err());
assert!(s.execute("insert.equation", json!({"latex": "y"})).is_err());
}
#[test]
fn a_file_with_too_many_equations_lays_out_a_bounded_amount() {
// About 1 KB each: past the budget, equations are refused without running the layout engine,
// so opening the file takes bounded time.
let source = format!("x{}", "+x".repeat(500));
let count = gridcraft_model::MAX_EQUATION_LAYOUT_BYTES / source.len() + 40;
let mut s = s();
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
let template = sh.shapes.iter().find(|x| x.id == first).cloned().unwrap();
sh.shapes.clear();
for i in 0..count {
let mut e = template.clone();
e.id = 100 + i as u32;
e.text = source.clone();
e.math = Default::default();
sh.shapes.push(e);
}
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let shapes = t.doc().unwrap().wb.active().unwrap().shapes.clone();
let laid = shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Ok(_)))).count();
let refused: Vec<&Shape> =
shapes.iter().filter(|x| matches!(x.math.get(&x.text), Some(Err(e)) if e.message == gridcraft_model::EQUATION_BUDGET_MESSAGE)).collect();
assert_eq!(laid + refused.len(), count, "every equation has a layout or the budget's error");
assert!(laid * source.len() <= gridcraft_model::MAX_EQUATION_LAYOUT_BYTES, "{laid} laid out");
assert!(refused.len() >= 40, "{} refused", refused.len());
// A later edit does not retry them all, and OK on one, unchanged, lays it out.
let id = refused[0].id;
t.execute("cell.set", json!({"cell": "A1", "input": "1"})).unwrap();
assert!(matches!(equation(&t, id).math.get(&source), Some(Err(_))));
t.execute("equation.edit", json!({"id": id, "latex": source})).unwrap();
assert!(matches!(equation(&t, id).math.get(&source), Some(Ok(_))), "editing lays it out");
}
#[test]
fn ids_stay_unique_when_a_file_used_the_largest_id() {
// A workbook (from a JSON file, say) whose shape has id u32::MAX: new equations and the
// objects of a copied sheet still get ids no other object has.
let mut s = s();
let first = insert(&mut s, json!({"latex": "x", "cell": "A1"}));
let mut wb = (*s.doc().unwrap().wb).clone();
if let Some(sh) = wb.sheet_mut(0) {
sh.shapes.iter_mut().filter(|x| x.id == first).for_each(|x| x.id = u32::MAX);
}
let mut t = Session::new();
t.add_document(crate::DocState::new(wb, None, "Book".into(), crate::Trust::FromFile));
let a = insert(&mut t, json!({"latex": "y", "cell": "C1"}));
let b = insert(&mut t, json!({"latex": "z", "cell": "E1"}));
t.execute("sheet.move", json!({"sheet": 0, "to": 1, "copy": true})).unwrap();
let wb = &t.doc().unwrap().wb;
let mut ids: Vec<u32> = wb.sheets.iter().flat_map(|sh| sh.shapes.iter().map(|x| x.id)).collect();
assert_eq!(ids.len(), 6, "three equations on each sheet");
assert!(a != u32::MAX && b != u32::MAX && a != b, "{a} {b}");
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), 6, "every id is distinct: {ids:?}");
}
+129 -14
View File
@@ -19,9 +19,9 @@ fn id_of(v: &serde_json::Value, key: &str) -> u32 {
v[key].as_u64().unwrap() as u32
}
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape and a
/// slicer, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 4] {
/// A table with one object of each kind on the first sheet: a chart, a picture, a shape, a
/// slicer and an equation, in `ObjKind::ALL` order.
fn every_kind(s: &mut Session) -> [ObjRef; 5] {
s.execute("range.setValues", json!({"range": "A1", "values": [["Region", "Qty"], ["East", 3], ["West", 5], ["East", 2], ["North", 8]]})).unwrap();
s.execute("insert.table", json!({"range": "A1:B5", "name": "Sales"})).unwrap();
let chart = id_of(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D2"})).unwrap(), "chart");
@@ -29,7 +29,14 @@ fn every_kind(s: &mut Session) -> [ObjRef; 4] {
let shape = id_of(&s.execute("insert.shape", json!({"kind": "ellipse", "at": "H18"})).unwrap(), "shape");
s.execute("selection.set", json!({"cell": "A2"})).unwrap();
let slicer = id_of(&s.execute("insert.slicer", json!({"column": "Region", "at": "L2"})).unwrap(), "slicer");
[ObjRef::new(ObjKind::Chart, chart), ObjRef::new(ObjKind::Image, image), ObjRef::new(ObjKind::Shape, shape), ObjRef::new(ObjKind::Slicer, slicer)]
let equation = id_of(&s.execute("insert.equation", json!({"latex": "x^2", "cell": "L18"})).unwrap(), "equation");
[
ObjRef::new(ObjKind::Chart, chart),
ObjRef::new(ObjKind::Image, image),
ObjRef::new(ObjKind::Shape, shape),
ObjRef::new(ObjKind::Slicer, slicer),
ObjRef::new(ObjKind::Equation, equation),
]
}
#[test]
@@ -45,6 +52,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
s.execute("object.setAnchorMode", json!({"kind": o.kind.name(), "id": o.id, "mode": "absolute"})).unwrap();
assert_eq!(sheet(&s, 0).object_anchor(o).unwrap().mode, AnchorMode::Absolute);
}
// An equation is a shape too; a plain shape is not an equation.
assert!(sheet(&s, 0).has_object(ObjRef::new(ObjKind::Shape, objs[4].id)));
assert!(s.execute("object.delete", json!({"kind": "equation", "id": objs[2].id})).is_err());
// A kind that does not match the id, an unknown id or kind, and a missing kind are refused.
assert!(s.execute("object.delete", json!({"kind": "slicer", "id": objs[0].id})).is_err());
assert!(s.execute("object.delete", json!({"kind": "chart", "id": 9999})).is_err());
@@ -58,9 +68,9 @@ fn every_kind_is_found_moved_and_deleted_by_kind_and_id() {
assert!(sheet(&s, 0).has_object(*later), "deleting {o:?} leaves {later:?}");
}
}
// One undo step per delete brings the slicer back.
// One undo step per delete brings the equation back.
s.execute("edit.undo", json!({})).unwrap();
assert!(sheet(&s, 0).has_object(objs[3]));
assert!(sheet(&s, 0).has_object(objs[4]));
}
#[test]
@@ -112,17 +122,17 @@ fn cell_of(s: &Session, o: ObjRef) -> String {
fn every_kind_follows_row_and_column_edits_and_sorts() {
let mut s = s();
let objs = every_kind(&mut s);
// Anchors: chart D2, picture D18, shape H18, slicer L2.
// Anchors: chart D2, picture D18, shape H18, slicer L2, equation L18.
s.execute("object.setAnchorMode", json!({"kind": "shape", "id": objs[2].id, "mode": "absolute"})).unwrap();
let cells = |s: &Session| objs.map(|o| cell_of(s, o));
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
s.execute("home.insertRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2"], "rows inserted above move the objects below them; an absolute shape stays");
assert_eq!(cells(&s), ["D2", "D20", "H18", "L2", "L20"], "rows inserted above move the objects below them; an absolute shape stays");
s.execute("home.insertColumns", json!({"cols": "C:C"})).unwrap();
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2"]);
assert_eq!(cells(&s), ["E2", "E20", "H18", "M2", "M20"]);
s.execute("home.deleteColumns", json!({"cols": "C:C"})).unwrap();
s.execute("home.deleteRows", json!({"rows": "10:11"})).unwrap();
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2"]);
assert_eq!(cells(&s), ["D2", "D18", "H18", "L2", "L18"]);
// Sorting moves a picture anchored inside the sorted block with its row.
s.execute("range.setValues", json!({"range": "T1", "values": [["k"], [3], [1], [2]]})).unwrap();
let pic = ObjRef::new(ObjKind::Image, super::add_image(&mut s, "T2", AnchorMode::MoveAndSize));
@@ -259,9 +269,11 @@ fn a_copied_sheet_gives_its_objects_fresh_ids() {
ids.dedup();
assert_eq!(ids.len(), 2 * objs.len(), "every object id stays unique across the workbook");
let copy = sheet(&s, 1);
for (o, id) in objs.iter().zip(ids_of(copy)) {
assert_eq!(copy.object_ref(id).map(|c| c.kind), Some(o.kind));
}
let mut kinds: Vec<&str> = ids_of(copy).into_iter().filter_map(|id| copy.object_ref(id)).map(|o| o.kind.name()).collect();
let mut want: Vec<&str> = objs.iter().map(|o| o.kind.name()).collect();
kinds.sort_unstable();
want.sort_unstable();
assert_eq!(kinds, want, "the copies keep their kinds");
assert!(s.doc().unwrap().wb.next_object_id() > *ids.last().unwrap_or(&0));
}
@@ -359,3 +371,106 @@ fn chart_set_keeps_a_chart_size_a_file_made_larger_than_the_limit() {
s.execute("chart.set", json!({"chart": chart, "width": 50_000, "height": 50_000})).unwrap();
assert_eq!(size(&s), (11_990.0, 10_000.0), "hostile sizes are capped at the current width and at 10,000");
}
#[test]
fn arrange_and_align_match_a_shape_id_to_its_kind() {
// `arrange.*` and `arrange.align` under `kind: "equation"` act on equations only: an
// ordinary shape's id given as an equation is refused by `arrange` and left in place by
// `align`, as `object.delete` refuses it. An equation is a shape too, so its id works under
// `shape`, as for the other object commands; an unknown kind, and an id of no object of the
// kind, are refused instead of acting on whatever shape has the id.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
assert_eq!(order(&s), vec![shape.id, equation.id]);
assert!(s.execute("arrange.bringForward", json!({"kind": "equation", "id": shape.id, "toFront": true})).is_err());
assert_eq!(order(&s), vec![shape.id, equation.id], "the shape is not reordered as an equation");
s.execute("arrange.sendBackward", json!({"kind": "equation", "id": equation.id, "toBack": true})).unwrap();
assert_eq!(order(&s), vec![equation.id, shape.id]);
s.execute("arrange.bringForward", json!({"kind": "shape", "id": equation.id, "toFront": true})).unwrap();
assert_eq!(order(&s), vec![shape.id, equation.id], "an equation is a shape too");
assert!(s.execute("arrange.bringForward", json!({"kind": "widget", "id": shape.id})).is_err());
assert!(s.execute("arrange.bringForward", json!({"kind": "chart", "id": 9999})).is_err());
assert!(s.execute("arrange.bringForward", json!({"kind": "shape", "id": objs[0].id})).is_err(), "a chart's id is not a shape");
// Align: the shape's id given as an equation is refused and nothing moves; the two equations
// line up on their own.
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": [shape.id, equation.id, second.id], "align": "left"})).is_err());
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing is aligned with a refused id");
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
assert!(s.execute("arrange.align", json!({"kind": "widget", "ids": [shape.id, equation.id], "align": "left"})).is_err());
}
#[test]
fn arrange_refuses_a_kind_that_is_present_but_not_a_name() {
// `kind` is `shape` only when the parameter is absent: `null`, a number or another non-name
// is refused, so a shape's id is not reordered or aligned under it. The `object.*` commands
// refuse the same values, their kind being required.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let order = |s: &Session| -> Vec<u32> { sheet(s, 0).shapes.iter().map(|c| c.id).collect() };
let before = (order(&s), anchor_of(&s, shape));
for kind in [json!(null), json!(7), json!(["shape"]), json!({"kind": "shape"}), json!(true)] {
assert!(s.execute("arrange.bringForward", json!({"kind": kind, "id": shape.id, "toFront": true})).is_err(), "{kind}");
assert!(s.execute("arrange.align", json!({"kind": kind, "ids": [shape.id, equation.id], "align": "left"})).is_err(), "{kind}");
assert!(s.execute("object.move", json!({"kind": kind, "id": shape.id, "at": "A1"})).is_err(), "{kind}");
}
assert_eq!((order(&s), anchor_of(&s, shape)), before, "nothing moved");
// Absent, the kind is `shape`, as before.
s.execute("arrange.bringForward", json!({"id": shape.id, "toFront": true})).unwrap();
assert_eq!(order(&s).last(), Some(&shape.id));
}
#[test]
fn align_refuses_an_id_that_is_not_an_object_of_the_kind() {
// `arrange.align` and `arrange.group` refuse an id in `ids` that is not an object of the
// kind, or not a number, as `arrange` and the `object.*` commands refuse it, instead of
// aligning the rest and reporting success; nothing moves. With fewer than two ids they
// change nothing and succeed.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let second = ObjRef::new(ObjKind::Equation, id_of(&s.execute("insert.equation", json!({"latex": "y", "cell": "B30"})).unwrap(), "equation"));
let before = (anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second));
for ids in [
json!([shape.id, equation.id, second.id]),
json!([equation.id, second.id, 9999]),
json!([equation.id, second.id, -1]),
json!([equation.id, second.id, "x"]),
json!([equation.id, second.id, 1e12]),
json!([equation.id, second.id, null]),
] {
assert!(s.execute("arrange.align", json!({"kind": "equation", "ids": ids, "align": "left"})).is_err(), "{ids}");
assert!(s.execute("arrange.group", json!({"kind": "equation", "ids": ids})).is_err(), "{ids}");
}
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation), anchor_of(&s, second)), before, "nothing moved");
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id], "align": "left"})).unwrap();
s.execute("arrange.align", json!({"kind": "equation", "align": "left"})).unwrap();
assert_eq!(anchor_of(&s, equation), before.1, "fewer than two ids change nothing");
s.execute("arrange.align", json!({"kind": "equation", "ids": [equation.id, second.id], "align": "left"})).unwrap();
let (a, b) = (sheet(&s, 0).object_anchor(equation).unwrap(), sheet(&s, 0).object_anchor(second).unwrap());
assert_eq!((a.cell.col, a.dx), (b.cell.col, b.dx), "the equations line up");
}
#[test]
fn align_refuses_ids_that_are_not_an_array() {
// `ids` may be absent (nothing to align) but, present, must be an array of object ids: `null`,
// a number, a string or an object is refused by `arrange.align` and `arrange.group`, and
// nothing moves.
let mut s = s();
let objs = every_kind(&mut s);
let (shape, equation) = (objs[2], objs[4]);
let before = (anchor_of(&s, shape), anchor_of(&s, equation));
for ids in [json!(null), json!(7), json!("x"), json!({"ids": [1]})] {
assert!(s.execute("arrange.align", json!({"kind": "shape", "ids": ids, "align": "left"})).is_err(), "{ids}");
assert!(s.execute("arrange.group", json!({"kind": "shape", "ids": ids})).is_err(), "{ids}");
}
s.execute("arrange.align", json!({"kind": "shape", "align": "left"})).unwrap();
s.execute("arrange.group", json!({"kind": "shape"})).unwrap();
assert_eq!((anchor_of(&s, shape), anchor_of(&s, equation)), before, "nothing moved");
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "gridcraft-math"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "GridCraft equations: LaTeX laid out as plain glyph, rule and path data, and OMML for xlsx"
[lints]
workspace = true
[dependencies]
ratex-parser = { workspace = true }
ratex-layout = { workspace = true }
ratex-types = { workspace = true }
ratex-font = { workspace = true }
+533
View File
@@ -0,0 +1,533 @@
//! GridCraft equations. LaTeX source is laid out by RaTeX (`ratex-parser`, `ratex-layout`; MIT)
//! into a [`MathList`]: plain glyph, rule and path data that the UI and the PDF writer paint
//! without calling RaTeX again.
//!
//! RaTeX is young (0.1.x), so it only ever runs inside this crate's entry points ([`layout`],
//! [`to_omml`], [`check`]). Each one caps its input (4 KB, nesting 32) and runs under
//! `catch_unwind`, so a stray panic becomes a [`MathError`]. See the RaTeX gate in
//! `docs/decisions.md` and `tests/gate.rs`.
//!
//! Units are em: one em is the equation's font size. Coordinates run right and down from the
//! top-left of the list's box; the baseline is at `y = height`.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
mod omml;
use std::ops::Range;
use std::panic::{AssertUnwindSafe, catch_unwind};
use ratex_font::FontId;
use ratex_types::color::Color;
use ratex_types::display_item::{DisplayItem, DisplayList};
use ratex_types::path_command::PathCommand;
pub use omml::to_omml;
/// Longest source accepted, in bytes.
pub const MAX_SOURCE_BYTES: usize = 4096;
/// Deepest nesting of `{…}`, `\left…\right` and `\begin…\end` accepted.
pub const MAX_DEPTH: usize = 32;
/// Largest coordinate or size accepted in a layout, in em. `\rule{1e308em}{1em}` lays out; this
/// stops it from reaching a painter.
pub const MAX_EM: f32 = 10_000.0;
/// Most items one equation may produce.
pub const MAX_ITEMS: usize = 50_000;
/// The error for a source over [`MAX_SOURCE_BYTES`]. It is one shared value so that a cache can
/// answer for such a source by its length alone, without reading it.
pub static SOURCE_TOO_LONG: std::sync::LazyLock<MathError> =
std::sync::LazyLock::new(|| MathError::new(format!("An equation can be at most {MAX_SOURCE_BYTES} bytes long."), None));
/// Why an equation could not be laid out.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MathError {
/// The offending source, as a byte range on char boundaries, when known.
pub span: Option<Range<usize>>,
pub message: String,
}
impl MathError {
fn new(message: impl Into<String>, span: Option<Range<usize>>) -> MathError {
MathError { span, message: message.into() }
}
/// The 1-based character position of the error in `source`, when known.
pub fn char_pos(&self, source: &str) -> Option<usize> {
let start = self.span.as_ref()?.start;
Some(source.char_indices().take_while(|(i, _)| *i < start).count() + 1)
}
}
impl std::fmt::Display for MathError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl std::error::Error for MathError {}
/// A laid-out equation.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct MathList {
pub items: Vec<Item>,
/// Advance width, in em.
pub width: f32,
/// Height above the baseline, in em.
pub height: f32,
/// Depth below the baseline, in em.
pub depth: f32,
}
impl MathList {
/// Total height (height plus depth), in em.
pub fn total_height(&self) -> f32 {
self.height + self.depth
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Item {
Glyph(Glyph),
Rule(Rule),
Path(Path),
}
/// The typeface a glyph asks for. Painters map it onto the fonts they have.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Face {
#[default]
Roman,
Italic,
Bold,
BoldItalic,
Sans,
SansBold,
SansItalic,
Mono,
/// A large operator or delimiter (TeX's Size1 to Size4 fonts): drawn stretched to the glyph's
/// box rather than at the run's font size.
Large(u8),
}
impl Face {
pub fn is_italic(self) -> bool {
matches!(self, Face::Italic | Face::BoldItalic | Face::SansItalic)
}
pub fn is_bold(self) -> bool {
matches!(self, Face::Bold | Face::BoldItalic | Face::SansBold)
}
}
/// One character.
#[derive(Clone, Debug, PartialEq)]
pub struct Glyph {
/// Left edge of the glyph's box.
pub x: f32,
/// The glyph's baseline.
pub y: f32,
/// Font size, in em of the equation (1 for the main line, smaller in scripts).
pub size: f32,
/// The character to draw.
pub ch: char,
/// A plainer character to draw when no font has `ch` (`R` for `ℝ`), before giving up.
pub alt: Option<char>,
pub face: Face,
/// The glyph's box (TeX metrics), already scaled by `size`: advance, height above and depth
/// below the baseline. A painter without the glyph draws a `[?]` box this size.
pub width: f32,
pub height: f32,
pub depth: f32,
/// An explicit colour (`\color`); `None` is the equation's own ("Automatic") colour.
pub color: Option<[u8; 3]>,
}
/// A filled rectangle (fraction bars, radical vinculums, table rules, box borders).
#[derive(Clone, Debug, PartialEq)]
pub struct Rule {
pub x: f32,
/// Top edge.
pub y: f32,
pub width: f32,
pub height: f32,
pub color: Option<[u8; 3]>,
}
/// A vector outline (stretchy braces, arrows, radicals), relative to `(x, y)`.
#[derive(Clone, Debug, PartialEq)]
pub struct Path {
pub x: f32,
pub y: f32,
pub commands: Vec<PathCmd>,
pub fill: bool,
pub color: Option<[u8; 3]>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PathCmd {
MoveTo(f32, f32),
LineTo(f32, f32),
QuadTo(f32, f32, f32, f32),
CubicTo(f32, f32, f32, f32, f32, f32),
Close,
}
/// Lays `latex` out. Errors carry the offending span when RaTeX reports one.
pub fn layout(latex: &str) -> Result<MathList, MathError> {
check_caps(latex)?;
guarded(|| {
let nodes = parse(latex)?;
let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default());
convert(&ratex_layout::to_display_list(&lb))
})
}
/// Whether `latex` parses, without laying it out (cheap enough to run on every keystroke).
pub fn check(latex: &str) -> Result<(), MathError> {
check_caps(latex)?;
guarded(|| parse(latex).map(|_| ()))
}
/// The equation gallery (Insert › Equation): GridCraft's own examples, as (name, LaTeX).
pub const GALLERY: &[(&str, &str)] = &[
("Quadratic formula", "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"),
("Pythagoras", "a^2 + b^2 = c^2"),
("Binomial theorem", "(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}"),
("Area of a circle", "A = \\pi r^2"),
("Euler's identity", "e^{i\\pi} + 1 = 0"),
("2\u{00D7}2 matrix", "\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}"),
];
/// Runs `f` so that a panic inside it becomes an error.
fn guarded<T>(f: impl FnOnce() -> Result<T, MathError>) -> Result<T, MathError> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(MathError::new("This equation could not be laid out.", None)))
}
#[cfg(test)]
thread_local! {
/// Test hook: makes the next guarded parse panic, to prove the guard holds.
static INJECT_PANIC: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
}
fn parse(latex: &str) -> Result<Vec<ratex_parser::ParseNode>, MathError> {
#[cfg(test)]
if INJECT_PANIC.with(|p| p.replace(false)) {
panic!("injected");
}
ratex_parser::parse(latex).map_err(|e| {
let span = e.loc.and_then(|l| {
let (s, t) = (l.start.min(latex.len()), l.end.min(latex.len()));
(latex.is_char_boundary(s) && latex.is_char_boundary(t)).then_some(s..t.max(s))
});
MathError::new(e.message, span)
})
}
/// The size and nesting caps, checked before RaTeX sees the source.
fn check_caps(latex: &str) -> Result<(), MathError> {
if latex.len() > MAX_SOURCE_BYTES {
return Err(SOURCE_TOO_LONG.clone());
}
let mut depth = 0usize;
let mut chars = latex.char_indices().peekable();
while let Some((i, c)) = chars.next() {
let open = match c {
'\\' => {
// A control word, or an escaped character (`\{` is not a group).
let mut word = String::new();
while let Some(&(_, n)) = chars.peek() {
if n.is_ascii_alphabetic() {
word.push(n);
chars.next();
} else {
break;
}
}
if word.is_empty() {
chars.next();
continue;
}
match word.as_str() {
"left" | "begin" => Some(true),
"right" | "end" => Some(false),
_ => None,
}
}
'%' => {
// A comment runs to the end of the line.
for (_, n) in chars.by_ref() {
if n == '\n' {
break;
}
}
None
}
'{' => Some(true),
'}' => Some(false),
_ => None,
};
match open {
Some(true) => {
depth += 1;
if depth > MAX_DEPTH {
return Err(MathError::new(format!("Equations can nest at most {MAX_DEPTH} levels deep."), Some(i..i + c.len_utf8())));
}
}
Some(false) => depth = depth.saturating_sub(1),
None => {}
}
}
Ok(())
}
fn color(c: &Color) -> Option<[u8; 3]> {
let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
let rgb = [q(c.r), q(c.g), q(c.b)];
(rgb != [0, 0, 0]).then_some(rgb)
}
/// A finite value within the caps, as `f32`.
fn em(v: f64) -> Result<f32, MathError> {
if v.is_finite() && v.abs() <= f64::from(MAX_EM) { Ok(v as f32) } else { Err(MathError::new("This equation is too large to draw.", None)) }
}
fn convert(dl: &DisplayList) -> Result<MathList, MathError> {
if dl.items.len() > MAX_ITEMS {
return Err(MathError::new("This equation is too large to draw.", None));
}
let mut items = Vec::with_capacity(dl.items.len());
for it in &dl.items {
items.push(match it {
DisplayItem::GlyphPath { x, y, scale, font, char_code, color: c } => {
Item::Glyph(glyph(em(*x)?, em(*y)?, em(*scale)?, font, *char_code, color(c))?)
}
DisplayItem::Line { x, y, width, thickness, color: c, .. } => {
let t = em(*thickness)?;
Item::Rule(Rule { x: em(*x)?, y: em(*y)? - t / 2.0, width: em(*width)?, height: t, color: color(c) })
}
DisplayItem::Rect { x, y, width, height, color: c } => {
Item::Rule(Rule { x: em(*x)?, y: em(*y)?, width: em(*width)?, height: em(*height)?, color: color(c) })
}
DisplayItem::Path { x, y, commands, fill, color: c } => {
let mut cmds = Vec::with_capacity(commands.len());
for pc in commands {
cmds.push(match *pc {
PathCommand::MoveTo { x, y } => PathCmd::MoveTo(em(x)?, em(y)?),
PathCommand::LineTo { x, y } => PathCmd::LineTo(em(x)?, em(y)?),
PathCommand::QuadTo { x1, y1, x, y } => PathCmd::QuadTo(em(x1)?, em(y1)?, em(x)?, em(y)?),
PathCommand::CubicTo { x1, y1, x2, y2, x, y } => PathCmd::CubicTo(em(x1)?, em(y1)?, em(x2)?, em(y2)?, em(x)?, em(y)?),
PathCommand::Close => PathCmd::Close,
});
}
Item::Path(Path { x: em(*x)?, y: em(*y)?, commands: cmds, fill: *fill, color: color(c) })
}
});
}
Ok(MathList { items, width: em(dl.width)?.max(0.0), height: em(dl.height)?, depth: em(dl.depth)? })
}
fn glyph(x: f32, y: f32, size: f32, font: &str, code: u32, color: Option<[u8; 3]>) -> Result<Glyph, MathError> {
let fid = FontId::parse(font).unwrap_or(FontId::MainRegular);
let face = match fid {
FontId::MainBold | FontId::FrakturBold => Face::Bold,
FontId::MainItalic | FontId::MathItalic => Face::Italic,
FontId::MainBoldItalic | FontId::MathBoldItalic => Face::BoldItalic,
FontId::SansSerifRegular => Face::Sans,
FontId::SansSerifBold => Face::SansBold,
FontId::SansSerifItalic => Face::SansItalic,
FontId::TypewriterRegular => Face::Mono,
FontId::Size1Regular => Face::Large(1),
FontId::Size2Regular => Face::Large(2),
FontId::Size3Regular => Face::Large(3),
FontId::Size4Regular => Face::Large(4),
_ => Face::Roman,
};
let raw = char::from_u32(code).unwrap_or('\u{FFFD}');
// Blackboard, script and Fraktur letters sit at ASCII slots in TeX's fonts; draw the Unicode
// letter, with the ASCII one as the fallback.
let (ch, alt) = match (fid, raw) {
(FontId::AmsRegular, c @ ('A'..='Z' | 'k')) => (styled(c, Style::DoubleStruck), Some(c)),
(FontId::CaligraphicRegular | FontId::ScriptRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Script), Some(c)),
(FontId::FrakturRegular, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::Fraktur), Some(c)),
(FontId::FrakturBold, c @ ('A'..='Z' | 'a'..='z')) => (styled(c, Style::BoldFraktur), Some(c)),
(_, c) if (0x1D400..=0x1D7FF).contains(&code) => (c, Some(ratex_font::katex_ttf_glyph_char(fid, code))),
(_, c) => (c, None),
};
let m = ratex_font::font_and_metric_for_mathematical_alphanumeric(code)
.and_then(|(f, mc)| ratex_font::get_char_metrics(f, mc))
.or_else(|| ratex_font::get_char_metrics(fid, code))
.or_else(|| ratex_font::get_char_metrics_with_fallback(fid, code));
let (w, h, d) = m.map_or((0.5, 0.7, 0.0), |m| (m.width + m.italic, m.height, m.depth));
Ok(Glyph { x, y, size, ch, alt, face, width: em(w)? * size, height: em(h)? * size, depth: em(d)? * size, color })
}
#[derive(Clone, Copy)]
enum Style {
DoubleStruck,
Script,
Fraktur,
BoldFraktur,
}
/// The Mathematical Alphanumeric Symbols letter for `c` (Unicode 15, chapter 22.2), using the
/// Letterlike Symbols block where Unicode put a letter before the alphanumeric block existed.
fn styled(c: char, style: Style) -> char {
let letterlike = match (style, c) {
(Style::DoubleStruck, 'C') => Some('\u{2102}'),
(Style::DoubleStruck, 'H') => Some('\u{210D}'),
(Style::DoubleStruck, 'N') => Some('\u{2115}'),
(Style::DoubleStruck, 'P') => Some('\u{2119}'),
(Style::DoubleStruck, 'Q') => Some('\u{211A}'),
(Style::DoubleStruck, 'R') => Some('\u{211D}'),
(Style::DoubleStruck, 'Z') => Some('\u{2124}'),
(Style::Script, 'B') => Some('\u{212C}'),
(Style::Script, 'E') => Some('\u{2130}'),
(Style::Script, 'F') => Some('\u{2131}'),
(Style::Script, 'H') => Some('\u{210B}'),
(Style::Script, 'I') => Some('\u{2110}'),
(Style::Script, 'L') => Some('\u{2112}'),
(Style::Script, 'M') => Some('\u{2133}'),
(Style::Script, 'R') => Some('\u{211B}'),
(Style::Script, 'e') => Some('\u{212F}'),
(Style::Script, 'g') => Some('\u{210A}'),
(Style::Script, 'o') => Some('\u{2134}'),
(Style::Fraktur, 'C') => Some('\u{212D}'),
(Style::Fraktur, 'H') => Some('\u{210C}'),
(Style::Fraktur, 'I') => Some('\u{2111}'),
(Style::Fraktur, 'R') => Some('\u{211C}'),
(Style::Fraktur, 'Z') => Some('\u{2128}'),
_ => None,
};
if let Some(l) = letterlike {
return l;
}
let (upper, lower) = match style {
Style::DoubleStruck => (0x1D538, 0x1D552),
Style::Script => (0x1D49C, 0x1D4B6),
Style::Fraktur => (0x1D504, 0x1D51E),
Style::BoldFraktur => (0x1D56C, 0x1D586),
};
let code = match c {
'A'..='Z' => upper + (c as u32 - 'A' as u32),
'a'..='z' => lower + (c as u32 - 'a' as u32),
_ => return c,
};
char::from_u32(code).unwrap_or(c)
}
#[cfg(test)]
mod tests {
use super::*;
fn glyphs(l: &MathList) -> String {
l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.ch) } else { None }).collect()
}
#[test]
fn boxes_of_a_simple_line() {
let l = layout("x+1").unwrap();
assert_eq!(glyphs(&l), "x+1");
assert!(l.width > 1.5 && l.width < 3.0, "{}", l.width);
assert!(l.height > 0.4 && l.height < 1.0);
let Some(Item::Glyph(x)) = l.items.first() else { panic!("a glyph first") };
assert_eq!(x.face, Face::Italic, "a letter is a math italic");
assert!((x.y - l.height).abs() < 1e-4, "on the baseline");
assert!(x.width > 0.3 && x.height > 0.3);
let one = l.items.iter().find_map(|i| if let Item::Glyph(g) = i { (g.ch == '1').then_some(g) } else { None }).unwrap();
assert_eq!(one.face, Face::Roman);
}
#[test]
fn fractions_roots_and_scripts() {
let f = layout("\\frac{a}{b}").unwrap();
let rule = f.items.iter().find_map(|i| if let Item::Rule(r) = i { Some(r) } else { None }).expect("a fraction bar");
assert!(rule.width > 0.3 && rule.height > 0.0 && rule.height < 0.1);
let (a, b) = match &f.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.clone()) } else { None }).collect::<Vec<_>>()[..] {
[a, b] => (a.clone(), b.clone()),
other => panic!("{other:?}"),
};
assert!(a.y < rule.y && b.y > rule.y + rule.height, "numerator above, denominator below");
assert!(f.depth > 0.3);
let s = layout("x^2").unwrap();
let g: Vec<&Glyph> = s.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g) } else { None }).collect();
assert!(g[1].size < 1.0 && g[1].y < g[0].y, "the exponent is smaller and raised");
let r = layout("\\sqrt{x}").unwrap();
assert!(glyphs(&r).contains('x'));
assert!(r.items.len() >= 2);
let big = layout("\\displaystyle\\sum_{i=1}^n i").unwrap();
assert!(big.items.iter().any(|i| matches!(i, Item::Glyph(Glyph { ch: '∑', face: Face::Large(_), .. }))));
}
#[test]
fn styled_letters_map_to_unicode_with_a_plain_fallback() {
let l = layout("\\mathbb{R}\\mathbb{Q}\\mathcal{L}\\mathfrak{g}").unwrap();
let g: Vec<(char, Option<char>)> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some((g.ch, g.alt)) } else { None }).collect();
assert_eq!(g, vec![('ℝ', Some('R')), ('ℚ', Some('Q')), ('ℒ', Some('L')), ('𝔤', Some('g'))]);
assert_eq!(styled('A', Style::DoubleStruck), '𝔸');
assert_eq!(styled('b', Style::Script), '𝒷');
assert_eq!(styled('7', Style::Fraktur), '7');
}
#[test]
fn colours_other_than_black_are_kept() {
let l = layout("{\\color{red}x}y").unwrap();
let c: Vec<Option<[u8; 3]>> = l.items.iter().filter_map(|i| if let Item::Glyph(g) = i { Some(g.color) } else { None }).collect();
assert_eq!(c, vec![Some([255, 0, 0]), None]);
}
#[test]
fn errors_point_at_the_source() {
let e = layout("a + \\frac{1}{").unwrap_err();
assert!(!e.message.is_empty());
let e = layout("x + \\foo").unwrap_err();
assert_eq!(e.span, Some(4..8), "{e:?}");
assert_eq!(e.char_pos("x + \\foo"), Some(5));
let e = layout("é + \\foo").unwrap_err();
assert_eq!(e.char_pos("é + \\foo"), Some(5), "positions count characters, not bytes");
assert!(check("\\frac{a}{b}").is_ok());
assert!(check("\\frac{a}{").is_err());
}
#[test]
fn hostile_input_is_refused_without_panicking() {
let e = layout(&"x".repeat(MAX_SOURCE_BYTES + 1)).unwrap_err();
assert!(e.message.contains("4096"));
assert!(layout(&"x".repeat(MAX_SOURCE_BYTES)).is_ok());
let deep = format!("{}x{}", "{".repeat(33), "}".repeat(33));
assert_eq!(layout(&deep).unwrap_err().span, Some(32..33));
assert!(layout(&format!("{}x{}", "\\left(".repeat(40), "\\right)".repeat(40))).is_err());
// Escaped braces and comments are not groups.
assert!(check_caps(&"\\{".repeat(100)).is_ok());
assert!(check_caps(&format!("x % {}\n", "{".repeat(100))).is_ok());
assert!(layout("\\rule{99999999em}{1em}").unwrap_err().message.contains("too large"));
assert!(layout("\\kern99999999em x").is_err());
assert!(layout("\\def\\a{\\a\\a}\\a").is_err());
for s in ["", " ", "\\", "{", "}", "^", "_", "&", "\\\\", "\u{0}", "\u{FFFF}", "\u{1F600}", "$", "#", "\\begin{matrix}", "\\end{matrix}"] {
let _ = layout(s);
let _ = check(s);
let _ = to_omml(s, 11.0, None);
}
}
#[test]
fn an_injected_panic_is_an_error_not_a_crash() {
INJECT_PANIC.with(|p| p.set(true));
let e = layout("x").unwrap_err();
assert_eq!(e.message, "This equation could not be laid out.");
assert!(layout("x").is_ok(), "the next call is unaffected");
}
#[test]
fn the_gallery_lays_out_and_converts() {
for (name, src) in GALLERY {
assert!(layout(src).is_ok_and(|l| !l.items.is_empty()), "{name}");
assert!(to_omml(src, 11.0, None).is_ok(), "{name}");
}
}
#[test]
fn empty_source_is_an_empty_list() {
let l = layout("").unwrap();
assert!(l.items.is_empty());
}
}
+669
View File
@@ -0,0 +1,669 @@
//! LaTeX to OMML, the open Office Math Markup of ECMA-376 Part 1 §22.1, for the common subset:
//! runs, fractions and binomials, roots, scripts, big operators with limits, functions and
//! limits, delimiters, matrices, accents, bars, braces and font styles. Anything else is an
//! error, and the caller saves the equation as text only.
use ratex_parser::ParseNode;
use ratex_parser::parse_node::Mode;
use crate::MathError;
/// Deepest tree walked (RaTeX already refuses input nested deeper than 32).
const MAX_WALK: usize = 96;
/// `latex` as an `<m:oMath>` element (prefix `m` for the OMML namespace and `a` for DrawingML,
/// declared by the caller), with runs at `size_pt` points in the `ink` colour (none: the
/// paragraph's own).
pub fn to_omml(latex: &str, size_pt: f32, ink: Option<[u8; 3]>) -> Result<String, MathError> {
crate::check_caps(latex)?;
crate::guarded(|| {
let nodes = crate::parse(latex)?;
let sz = if size_pt.is_finite() { (size_pt * 100.0).round().clamp(100.0, 400_000.0) as u32 } else { 1100 };
let fill = ink.map(|[r, g, b]| format!("<a:solidFill><a:srgbClr val=\"{r:02X}{g:02X}{b:02X}\"/></a:solidFill>")).unwrap_or_default();
let mut w = Writer { out: String::from("<m:oMath>"), sz, fill };
w.seq(&nodes, &Style::default(), 0)?;
w.out.push_str("</m:oMath>");
Ok(w.out)
})
}
#[derive(Clone, Default)]
struct Style {
/// `m:scr` (script) value: double-struck, script, fraktur, sans-serif, monospace.
scr: Option<&'static str>,
/// `m:sty` value: p (plain), b (bold), i, bi.
sty: Option<&'static str>,
/// Ordinary text (`\text`): `m:nor`.
normal: bool,
}
struct Writer {
out: String,
sz: u32,
/// The DrawingML fill of every run (`<a:solidFill>…</a:solidFill>`), or empty.
fill: String,
}
fn unsupported(what: &str) -> MathError {
MathError::new(format!("{what} can't be saved as an Office equation."), None)
}
/// Whether XML 1.0 can carry `c`: the same set the xlsx crate's escapes keep, so the OMML is
/// well formed wherever the drawing part embeds it.
fn is_xml_char(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
}
/// `s` escaped for element text; characters XML cannot carry (C0 controls other than tab, newline
/// and carriage return, and U+FFFE and U+FFFF) are dropped, as the xlsx crate's `esc` drops them.
fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\r' => o.push_str("&#13;"),
c if is_xml_char(c) => o.push(c),
_ => {}
}
}
o
}
/// The character a symbol name (`\alpha`, `(`, `\langle`) stands for.
fn symbol(name: &str, mode: Mode) -> Option<String> {
let m = if mode == Mode::Text { ratex_font::symbols::Mode::Text } else { ratex_font::symbols::Mode::Math };
if let Some(c) = ratex_font::symbols::get_symbol(name, m).and_then(|s| s.codepoint) {
return Some(c.to_string());
}
if !name.starts_with('\\') {
return Some(name.to_string());
}
match name {
"\\{" | "\\lbrace" => Some("{".into()),
"\\}" | "\\rbrace" => Some("}".into()),
"\\|" | "\\Vert" => Some("‖".into()),
"\\vert" => Some("|".into()),
"\\langle" => Some("⟨".into()),
"\\rangle" => Some("⟩".into()),
"\\lfloor" => Some("⌊".into()),
"\\rfloor" => Some("⌋".into()),
"\\lceil" => Some("⌈".into()),
"\\rceil" => Some("⌉".into()),
_ => None,
}
}
/// A delimiter's character; `.` is no delimiter.
fn delim(d: &str) -> Result<String, MathError> {
if d == "." {
return Ok(String::new());
}
if d.len() == 1 && d.is_ascii() {
return Ok(d.to_string());
}
symbol(d, Mode::Math).ok_or_else(|| unsupported(&format!("The delimiter {d}")))
}
fn is_big_op(n: &ParseNode) -> Option<String> {
match n {
ParseNode::Op { symbol: true, name: Some(name), .. } => symbol(name, Mode::Math),
_ => None,
}
}
fn is_rel(n: &ParseNode) -> bool {
matches!(n, ParseNode::Atom { family: ratex_parser::parse_node::AtomFamily::Rel, .. })
}
impl Writer {
fn run(&mut self, text: &str, st: &Style) {
if text.is_empty() {
return;
}
self.out.push_str("<m:r>");
if st.scr.is_some() || st.sty.is_some() || st.normal {
self.out.push_str("<m:rPr>");
if st.normal {
self.out.push_str("<m:nor/>");
}
if let Some(s) = st.scr {
self.out.push_str(&format!("<m:scr m:val=\"{s}\"/>"));
}
if let Some(s) = st.sty {
self.out.push_str(&format!("<m:sty m:val=\"{s}\"/>"));
}
self.out.push_str("</m:rPr>");
}
// Word trims a run's text unless it is marked, so a run that is or ends in whitespace
// (the space after `\text{if }`) says so.
let space = if text.starts_with(char::is_whitespace) || text.ends_with(char::is_whitespace) { " xml:space=\"preserve\"" } else { "" };
self.out.push_str(&format!(
"<a:rPr lang=\"en-US\" sz=\"{}\">{}<a:latin typeface=\"Cambria Math\"/></a:rPr><m:t{}>{}</m:t></m:r>",
self.sz,
self.fill,
space,
esc(text)
));
}
/// `<tag>…</tag>` around the nodes.
fn wrap(&mut self, tag: &str, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> {
self.out.push_str(&format!("<{tag}>"));
self.seq(nodes, st, depth)?;
self.out.push_str(&format!("</{tag}>"));
Ok(())
}
fn wrap1(&mut self, tag: &str, node: Option<&ParseNode>, st: &Style, depth: usize) -> Result<(), MathError> {
match node {
Some(n) => self.wrap(tag, std::slice::from_ref(n), st, depth),
None => {
self.out.push_str(&format!("<{tag}/>"));
Ok(())
}
}
}
fn seq(&mut self, nodes: &[ParseNode], st: &Style, depth: usize) -> Result<(), MathError> {
if depth > MAX_WALK {
return Err(unsupported("An equation this deep"));
}
let mut i = 0;
while let Some(n) = nodes.get(i) {
i += 1;
// A big operator takes what follows, up to the next relation, as its operand.
let (op, sub, sup) = match n {
ParseNode::SupSub { base: Some(b), sub, sup, .. } => (is_big_op(b), sub.as_deref(), sup.as_deref()),
other => (is_big_op(other), None, None),
};
if let Some(chr) = op {
let end = nodes.get(i..).and_then(|rest| rest.iter().position(is_rel)).map_or(nodes.len(), |p| i + p);
let limits = match n {
ParseNode::SupSub { base: Some(b), .. } => matches!(**b, ParseNode::Op { limits: true, .. }),
_ => false,
};
self.out.push_str(&format!(
"<m:nary><m:naryPr><m:chr m:val=\"{}\"/><m:limLoc m:val=\"{}\"/>{}{}</m:naryPr>",
esc(&chr),
if limits { "undOvr" } else { "subSup" },
if sub.is_none() { "<m:subHide m:val=\"1\"/>" } else { "" },
if sup.is_none() { "<m:supHide m:val=\"1\"/>" } else { "" },
));
self.wrap1("m:sub", sub, st, depth + 1)?;
self.wrap1("m:sup", sup, st, depth + 1)?;
self.wrap("m:e", nodes.get(i..end).unwrap_or_default(), st, depth + 1)?;
self.out.push_str("</m:nary>");
i = end;
continue;
}
self.node(n, st, depth + 1)?;
}
Ok(())
}
fn node(&mut self, n: &ParseNode, st: &Style, depth: usize) -> Result<(), MathError> {
if depth > MAX_WALK {
return Err(unsupported("An equation this deep"));
}
match n {
ParseNode::Atom { text, mode, .. }
| ParseNode::MathOrd { text, mode, .. }
| ParseNode::TextOrd { text, mode, .. }
| ParseNode::OpToken { text, mode, .. } => {
let t = symbol(text, *mode).ok_or_else(|| unsupported(text))?;
self.run(&t, st);
}
ParseNode::SpacingNode { text, .. } => {
let t = match text.as_str() {
// A literal space in text mode (`\text{if } x`) is a spacing node of " ".
" " | "\\ " | "~" | "\\space" | "\\nobreakspace" => " ",
"\\quad" => "\u{2003}",
"\\qquad" => "\u{2003}\u{2003}",
"\\," | "\\thinspace" => "\u{2009}",
"\\:" | "\\>" | "\\medspace" => "\u{205F}",
"\\;" | "\\thickspace" => "\u{2004}",
_ => "",
};
self.run(t, st);
}
ParseNode::Kern { .. } => {}
ParseNode::OrdGroup { body, .. }
| ParseNode::Styling { body, .. }
| ParseNode::Sizing { body, .. }
| ParseNode::MClass { body, .. }
| ParseNode::Href { body, .. }
| ParseNode::HBox { body, .. }
| ParseNode::Pmb { body, .. } => self.seq(body, st, depth)?,
ParseNode::Color { body, .. } => self.seq(body, st, depth)?,
ParseNode::Text { body, font, .. } => {
let mut s = Style { normal: true, ..st.clone() };
if let Some(f) = font {
s.sty = match f.as_str() {
"\\textbf" => Some("b"),
"\\textit" => Some("i"),
_ => s.sty,
};
}
self.seq(body, &s, depth)?;
}
ParseNode::Font { font, body, .. } => {
let mut s = st.clone();
match font.trim_start_matches('\\') {
"mathbb" | "Bbb" => s.scr = Some("double-struck"),
"mathcal" | "mathscr" => s.scr = Some("script"),
"mathfrak" | "frak" => s.scr = Some("fraktur"),
"mathsf" | "textsf" => s.scr = Some("sans-serif"),
"mathtt" | "texttt" => s.scr = Some("monospace"),
"mathbf" | "bf" | "textbf" => s.sty = Some("b"),
"boldsymbol" | "bm" => s.sty = Some("bi"),
"mathrm" | "rm" | "textrm" | "mathnormal" => s.sty = Some("p"),
"mathit" | "it" | "textit" => s.sty = Some("i"),
other => return Err(unsupported(&format!("\\{other}"))),
}
self.node(body, &s, depth + 1)?;
}
ParseNode::SupSub { base, sup, sub, .. } => {
// A function with limits under it (\lim_{x\to 0}, \max_i).
if let Some(b) = base.as_deref()
&& let ParseNode::Op { limits: true, symbol: false, .. } | ParseNode::OperatorName { limits: true, .. } = b
&& sup.is_none()
{
self.out.push_str("<m:limLow><m:e>");
self.node(b, st, depth + 1)?;
self.out.push_str("</m:e>");
self.wrap1("m:lim", sub.as_deref(), st, depth + 1)?;
self.out.push_str("</m:limLow>");
return Ok(());
}
// A brace with a label over or under it (`\overbrace{x}^{n}`, `\underbrace{x}_{n}`):
// the label is the limit on the brace's side. A script on the other side
// (`\underbrace{x}_{n}^{2}`) is a script around the whole, `m:sSup` or `m:sSub`,
// so that it reaches Office Math too; without a label it goes around the bare
// brace, with no empty limit.
if let Some(b @ ParseNode::HorizBrace { is_over, .. }) = base.as_deref() {
let (lim_tag, label, (script_tag, script_arg), script) = if *is_over {
("m:limUpp", sup.as_deref(), ("m:sSub", "m:sub"), sub.as_deref())
} else {
("m:limLow", sub.as_deref(), ("m:sSup", "m:sup"), sup.as_deref())
};
// Each wrapper around the brace is a level of its own.
let inner = if script.is_some() { depth + 1 } else { depth };
if script.is_some() {
self.out.push_str(&format!("<{script_tag}><m:e>"));
}
if label.is_some() {
self.out.push_str(&format!("<{lim_tag}><m:e>"));
self.node(b, st, inner + 1)?;
self.out.push_str("</m:e>");
self.wrap1("m:lim", label, st, inner + 1)?;
self.out.push_str(&format!("</{lim_tag}>"));
} else {
self.node(b, st, inner)?;
}
if script.is_some() {
self.out.push_str("</m:e>");
self.wrap1(script_arg, script, st, depth + 1)?;
self.out.push_str(&format!("</{script_tag}>"));
}
return Ok(());
}
let tag = match (sub.is_some(), sup.is_some()) {
(true, true) => "m:sSubSup",
(true, false) => "m:sSub",
_ => "m:sSup",
};
self.out.push_str(&format!("<{tag}>"));
self.wrap1("m:e", base.as_deref(), st, depth + 1)?;
if sub.is_some() {
self.wrap1("m:sub", sub.as_deref(), st, depth + 1)?;
}
if sup.is_some() || sub.is_none() {
self.wrap1("m:sup", sup.as_deref(), st, depth + 1)?;
}
self.out.push_str(&format!("</{tag}>"));
}
ParseNode::GenFrac { numer, denom, has_bar_line, left_delim, right_delim, .. } => {
let delims = left_delim.is_some() || right_delim.is_some();
if delims {
self.out.push_str(&format!(
"<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr><m:e>",
esc(&delim(left_delim.as_deref().unwrap_or("."))?),
esc(&delim(right_delim.as_deref().unwrap_or("."))?)
));
}
self.out.push_str("<m:f>");
if !has_bar_line {
self.out.push_str("<m:fPr><m:type m:val=\"noBar\"/></m:fPr>");
}
self.wrap1("m:num", Some(numer), st, depth + 1)?;
self.wrap1("m:den", Some(denom), st, depth + 1)?;
self.out.push_str("</m:f>");
if delims {
self.out.push_str("</m:e></m:d>");
}
}
ParseNode::Sqrt { body, index, .. } => {
self.out.push_str("<m:rad>");
match index {
Some(ix) => {
self.wrap1("m:deg", Some(ix), st, depth + 1)?;
}
None => self.out.push_str("<m:radPr><m:degHide m:val=\"1\"/></m:radPr><m:deg/>"),
}
self.wrap1("m:e", Some(body), st, depth + 1)?;
self.out.push_str("</m:rad>");
}
ParseNode::Op { name: Some(name), symbol: false, .. } => {
let t = name.trim_start_matches('\\');
self.run(t, &Style { sty: Some("p"), ..st.clone() });
}
ParseNode::Op { name: Some(name), .. } => {
let t = symbol(name, Mode::Math).ok_or_else(|| unsupported(name))?;
self.run(&t, st);
}
ParseNode::Op { body: Some(body), .. } => self.seq(body, st, depth)?,
ParseNode::OperatorName { body, .. } => self.seq(body, &Style { sty: Some("p"), ..st.clone() }, depth)?,
ParseNode::LeftRight { body, left, right, .. } => {
self.out.push_str(&format!(
"<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr>",
esc(&delim(left)?),
esc(&delim(right)?)
));
// \middle splits the contents into separated elements.
let mut parts: Vec<&[ParseNode]> = Vec::new();
let mut start = 0;
for (k, b) in body.iter().enumerate() {
if matches!(b, ParseNode::Middle { .. }) {
parts.push(body.get(start..k).unwrap_or_default());
start = k + 1;
}
}
parts.push(body.get(start..).unwrap_or_default());
if parts.len() > 1 {
let sep =
body.iter().find_map(|b| if let ParseNode::Middle { delim: d, .. } = b { delim(d).ok() } else { None }).unwrap_or_default();
// Rewrite the dPr with the separator: drop what was pushed and start again.
let cut = self.out.rfind("<m:d>").unwrap_or(self.out.len());
self.out.truncate(cut);
self.out.push_str(&format!(
"<m:d><m:dPr><m:begChr m:val=\"{}\"/><m:sepChr m:val=\"{}\"/><m:endChr m:val=\"{}\"/></m:dPr>",
esc(&delim(left)?),
esc(&sep),
esc(&delim(right)?)
));
}
for p in parts {
self.wrap("m:e", p, st, depth + 1)?;
}
self.out.push_str("</m:d>");
}
ParseNode::DelimSizing { delim: d, .. } => {
let t = delim(d)?;
self.run(&t, st);
}
ParseNode::Array { body, is_cd: None | Some(false), .. } => {
let cols = body.iter().map(Vec::len).max().unwrap_or(0).max(1);
self.out.push_str(&format!(
"<m:m><m:mPr><m:mcs><m:mc><m:mcPr><m:count m:val=\"{cols}\"/><m:mcJc m:val=\"center\"/></m:mcPr></m:mc></m:mcs></m:mPr>"
));
// Every row the parser gives is written: RaTeX itself drops the empty last row a
// trailing `\\` leaves, and an empty row anywhere else is the author's blank line.
for row in body {
self.out.push_str("<m:mr>");
for k in 0..cols {
self.wrap1("m:e", row.get(k), st, depth + 1)?;
}
self.out.push_str("</m:mr>");
}
self.out.push_str("</m:m>");
}
ParseNode::Accent { label, base, .. } => {
let chr = match label.as_str() {
"\\hat" | "\\widehat" => "\u{0302}",
"\\check" | "\\widecheck" => "\u{030C}",
"\\tilde" | "\\widetilde" => "\u{0303}",
"\\acute" => "\u{0301}",
"\\grave" => "\u{0300}",
"\\dot" => "\u{0307}",
"\\ddot" => "\u{0308}",
"\\breve" => "\u{0306}",
"\\bar" => "\u{0305}",
"\\vec" | "\\overrightarrow" => "\u{20D7}",
"\\overleftarrow" => "\u{20D6}",
"\\mathring" => "\u{030A}",
other => return Err(unsupported(other)),
};
self.out.push_str(&format!("<m:acc><m:accPr><m:chr m:val=\"{chr}\"/></m:accPr>"));
self.wrap1("m:e", Some(base), st, depth + 1)?;
self.out.push_str("</m:acc>");
}
ParseNode::Overline { body, .. } | ParseNode::Underline { body, .. } => {
let pos = if matches!(n, ParseNode::Overline { .. }) { "top" } else { "bot" };
self.out.push_str(&format!("<m:bar><m:barPr><m:pos m:val=\"{pos}\"/></m:barPr>"));
self.wrap1("m:e", Some(body), st, depth + 1)?;
self.out.push_str("</m:bar>");
}
ParseNode::HorizBrace { is_over, base, .. } => {
let (chr, pos, vert) = if *is_over { ("\u{23DE}", "top", "bot") } else { ("\u{23DF}", "bot", "top") };
self.out.push_str(&format!(
"<m:groupChr><m:groupChrPr><m:chr m:val=\"{chr}\"/><m:pos m:val=\"{pos}\"/><m:vertJc m:val=\"{vert}\"/></m:groupChrPr>"
));
self.wrap1("m:e", Some(base), st, depth + 1)?;
self.out.push_str("</m:groupChr>");
}
ParseNode::Enclose { label, body, .. } if label == "\\boxed" || label == "\\fbox" => {
self.out.push_str("<m:borderBox>");
self.wrap1("m:e", Some(body), st, depth + 1)?;
self.out.push_str("</m:borderBox>");
}
ParseNode::Phantom { body, .. } => {
self.out.push_str("<m:phant><m:phantPr><m:show m:val=\"0\"/></m:phantPr>");
self.wrap("m:e", body, st, depth + 1)?;
self.out.push_str("</m:phant>");
}
ParseNode::Internal { .. } | ParseNode::NoNumber { .. } | ParseNode::ColorToken { .. } => {}
other => return Err(unsupported(&format!("\"{}\"", node_name(other)))),
}
Ok(())
}
}
/// A short name for an unsupported construct, for the error.
fn node_name(n: &ParseNode) -> String {
let dbg = format!("{n:?}");
dbg.split([' ', '{', '(']).next().unwrap_or("This construct").to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn om(s: &str) -> String {
to_omml(s, 11.0, None).unwrap()
}
/// The markup without the per-run DrawingML properties, to keep assertions readable.
fn bare(s: &str) -> String {
let mut o = om(s);
while let Some(a) = o.find("<a:rPr") {
let Some(b) = o[a..].find("</a:rPr>") else { break };
o.replace_range(a..a + b + "</a:rPr>".len(), "");
}
o
}
#[test]
fn runs_fractions_and_roots() {
assert_eq!(bare("x+1"), "<m:oMath><m:r><m:t>x</m:t></m:r><m:r><m:t>+</m:t></m:r><m:r><m:t>1</m:t></m:r></m:oMath>");
assert!(om("x").contains("sz=\"1100\""));
assert_eq!(
bare("\\frac{a}{b}"),
"<m:oMath><m:f><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f></m:oMath>"
);
assert!(
bare("\\binom{n}{k}")
.contains("<m:d><m:dPr><m:begChr m:val=\"(\"/><m:endChr m:val=\")\"/></m:dPr><m:e><m:f><m:fPr><m:type m:val=\"noBar\"/>")
);
assert!(bare("\\sqrt{x}").starts_with("<m:oMath><m:rad><m:radPr><m:degHide m:val=\"1\"/></m:radPr><m:deg/><m:e>"));
assert!(bare("\\sqrt[3]{x}").contains("<m:deg><m:r><m:t>3</m:t></m:r></m:deg>"));
assert!(bare("\\alpha \\le \\infty").contains("<m:t>α</m:t></m:r><m:r><m:t>≤</m:t></m:r><m:r><m:t>∞</m:t>"));
}
#[test]
fn scripts_operators_and_limits() {
assert_eq!(bare("x^2"), "<m:oMath><m:sSup><m:e><m:r><m:t>x</m:t></m:r></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>");
assert!(bare("a_{i}^{2}").starts_with("<m:oMath><m:sSubSup><m:e>"));
let s = bare("\\sum_{k=0}^{n} k = m");
assert!(s.starts_with("<m:oMath><m:nary><m:naryPr><m:chr m:val=\"∑\"/><m:limLoc m:val=\"undOvr\"/></m:naryPr><m:sub>"), "{s}");
assert!(s.contains("<m:e><m:r><m:t>k</m:t></m:r></m:e></m:nary><m:r><m:t>=</m:t>"), "the operand stops at the relation: {s}");
assert!(bare("\\int x").contains("<m:subHide m:val=\"1\"/><m:supHide m:val=\"1\"/>"));
let l = bare("\\lim_{n\\to\\infty} a");
assert!(l.starts_with("<m:oMath><m:limLow><m:e><m:r><m:rPr><m:sty m:val=\"p\"/></m:rPr><m:t>lim</m:t>"), "{l}");
assert!(bare("\\sin x").contains("<m:sty m:val=\"p\"/></m:rPr><m:t>sin</m:t>"));
}
#[test]
fn delimiters_matrices_accents_and_fonts() {
let p = bare("\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}");
assert!(p.contains("<m:d><m:dPr><m:begChr m:val=\"(\"/><m:endChr m:val=\")\"/></m:dPr><m:e><m:m>"), "{p}");
assert_eq!(p.matches("<m:mr>").count(), 2);
assert_eq!(p.matches("<m:e>").count(), 5);
assert!(bare("\\left\\{ x \\right.").contains("<m:begChr m:val=\"{\"/><m:endChr m:val=\"\"/>"));
assert!(bare("\\left\\langle a \\middle| b \\right\\rangle").contains("<m:sepChr m:val=\"|\"/>"));
assert!(bare("\\hat{x}").contains("<m:acc><m:accPr><m:chr m:val=\"\u{0302}\"/>"));
assert!(bare("\\overline{z}").contains("<m:bar><m:barPr><m:pos m:val=\"top\"/>"));
assert!(bare("\\mathbb{R}").contains("<m:rPr><m:scr m:val=\"double-struck\"/></m:rPr>"));
assert!(bare("\\text{if } x").contains("<m:rPr><m:nor/></m:rPr>"));
assert!(bare("\\overbrace{a+b}^{n}").contains("<m:limUpp><m:e><m:groupChr>"));
assert!(bare("a < b \\& c").contains("<m:t>&lt;</m:t>") && bare("a \\& b").contains("<m:t>&amp;</m:t>"));
}
#[test]
fn an_underbrace_with_both_scripts_keeps_its_exponent() {
// The label under the brace is its limit; the exponent on the other side is a
// superscript around the whole, so that an application reading the Office Math sees it
// (the source in `descr` hid its loss in a GridCraft-to-GridCraft round trip).
let brace = |chr: &str, pos: &str, vert: &str| {
format!(
"<m:groupChr><m:groupChrPr><m:chr m:val=\"{chr}\"/><m:pos m:val=\"{pos}\"/><m:vertJc m:val=\"{vert}\"/></m:groupChrPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:groupChr>"
)
};
let under = brace("\u{23DF}", "bot", "top");
let over = brace("\u{23DE}", "top", "bot");
assert_eq!(
bare("\\underbrace{x}_{n}^{2}"),
format!(
"<m:oMath><m:sSup><m:e><m:limLow><m:e>{under}</m:e><m:lim><m:r><m:t>n</m:t></m:r></m:lim></m:limLow></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>"
)
);
// The mirror: the label over the brace, the subscript around the whole.
assert_eq!(
bare("\\overbrace{x}^{n}_{m}"),
format!(
"<m:oMath><m:sSub><m:e><m:limUpp><m:e>{over}</m:e><m:lim><m:r><m:t>n</m:t></m:r></m:lim></m:limUpp></m:e><m:sub><m:r><m:t>m</m:t></m:r></m:sub></m:sSub></m:oMath>"
)
);
// Nested braces keep every script, one wrapper per level.
let nested = bare("\\underbrace{\\underbrace{x}_{a}^{b}}_{c}^{d}");
assert_eq!(nested.matches("<m:limLow>").count(), 2, "{nested}");
assert_eq!(nested.matches("<m:sup>").count(), 2, "{nested}");
for t in ["a", "b", "c", "d"] {
assert!(nested.contains(&format!("<m:t>{t}</m:t>")), "{nested}");
}
}
#[test]
fn an_underbrace_without_a_label_keeps_its_superscript() {
// No label: the script goes around the bare brace, and there is no empty limit.
let under = bare("\\underbrace{x}^{2}");
assert!(under.starts_with("<m:oMath><m:sSup><m:e><m:groupChr>"), "{under}");
assert!(under.ends_with("</m:groupChr></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:oMath>"), "{under}");
assert!(!under.contains("<m:lim"), "{under}");
let over = bare("\\overbrace{x}_{m}");
assert!(over.starts_with("<m:oMath><m:sSub><m:e><m:groupChr>"), "{over}");
assert!(over.ends_with("</m:groupChr></m:e><m:sub><m:r><m:t>m</m:t></m:r></m:sub></m:sSub></m:oMath>"), "{over}");
assert!(!over.contains("<m:lim"), "{over}");
// A brace with neither script, and one with its label alone, are as they were.
let plain = bare("\\underbrace{x}");
assert!(plain.starts_with("<m:oMath><m:groupChr>") && !plain.contains("<m:sSup>") && !plain.contains("<m:lim"), "{plain}");
let labelled = bare("\\underbrace{x}_{n}");
assert!(labelled.starts_with("<m:oMath><m:limLow><m:e><m:groupChr>") && !labelled.contains("<m:sSup>"), "{labelled}");
}
#[test]
fn the_unsupported_subset_is_an_error() {
for s in ["\\begin{CD} A @>>> B \\end{CD}", "\\rule{1em}{1em}", "\\xrightarrow{f}", "\\verb|x|"] {
assert!(to_omml(s, 11.0, None).is_err(), "{s}");
}
assert!(to_omml("\\frac{1}{", 11.0, None).is_err());
assert!(to_omml(&"x".repeat(5000), 11.0, None).is_err());
assert!(om("x").starts_with("<m:oMath>"));
assert!(to_omml("x", f32::NAN, None).unwrap().contains("sz=\"1100\""));
}
#[test]
fn a_literal_space_in_text_mode_is_kept() {
// RaTeX gives a text-mode space as a spacing node whose text is " "; it is a run of its
// own, marked `xml:space="preserve"` so that Word keeps it, and `\text{if } x` does not
// come out as `ifx`.
let o = bare("\\text{a b}");
assert!(o.contains("<m:t xml:space=\"preserve\"> </m:t>"), "{o}");
let o = bare("\\text{if } x");
assert!(o.contains("<m:t xml:space=\"preserve\"> </m:t></m:r><m:r><m:t>x</m:t>"), "{o}");
assert!(!bare("x+1").contains("xml:space"), "runs without surrounding whitespace are not marked");
}
#[test]
fn runs_carry_the_ink_colour_when_one_is_given() {
let o = to_omml("x+1", 11.0, Some([0x1F, 0x4E, 0x79])).unwrap();
let run =
"<a:rPr lang=\"en-US\" sz=\"1100\"><a:solidFill><a:srgbClr val=\"1F4E79\"/></a:solidFill><a:latin typeface=\"Cambria Math\"/></a:rPr>";
assert_eq!(o.matches(run).count(), 3, "{o}");
assert!(!om("x+1").contains("<a:solidFill>"), "no ink: the paragraph's colour applies");
}
#[test]
fn characters_xml_cannot_carry_are_dropped_from_runs() {
// U+FFFE and U+FFFF are not XML characters, nor are C0 controls other than tab, newline
// and carriage return; the xlsx escapes drop them and this writer does the same, so the
// OMML it hands the drawing part is well formed. U+FDD0 is a Unicode noncharacter but an
// XML character, kept as the xlsx escapes keep it.
assert_eq!(esc("a\u{FFFE}b\u{FFFF}c\u{1}d\u{B}e\u{7F}f"), "abcde\u{7F}f");
assert_eq!(esc("a\tb\nc\rd\u{FDD0}e"), "a\tb\nc&#13;d\u{FDD0}e");
for (src, bad) in [("x\u{FFFE}y", '\u{FFFE}'), ("x\u{FFFF}y", '\u{FFFF}'), ("\\text{a\u{FFFF}b}", '\u{FFFF}')] {
if let Ok(o) = to_omml(src, 11.0, None) {
assert!(!o.contains(bad), "{src:?}: {o}");
}
}
}
#[test]
fn only_a_trailing_empty_row_is_dropped() {
// RaTeX drops the empty last row a trailing `\\` leaves (its `environments.rs`), and the
// writer keeps every row it is given, so a blank row in the middle, written as `\\ \\` or
// as `{}`, reaches Office as the sheet shows it. A RaTeX release that stopped dropping the
// trailing row would show up here as a third row.
assert_eq!(bare("\\begin{matrix} a \\\\ b \\\\ \\end{matrix}").matches("<m:mr>").count(), 2);
assert_eq!(bare("\\begin{cases} x \\\\ \\\\ y \\end{cases}").matches("<m:mr>").count(), 3);
assert_eq!(bare("\\begin{matrix} a \\\\ {} \\\\ b \\end{matrix}").matches("<m:mr>").count(), 3);
assert_eq!(bare("\\begin{matrix} \\\\ \\end{matrix}").matches("<m:mr>").count(), 1, "one blank row is still a row");
}
#[test]
fn every_gallery_example_converts() {
for s in [
"x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}",
"a^2 + b^2 = c^2",
"(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}",
"A = \\pi r^2",
"e^{i\\pi} + 1 = 0",
"\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}",
"f(x) = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}",
] {
assert!(to_omml(s, 11.0, None).is_ok(), "{s}: {:?}", to_omml(s, 11.0, None));
}
}
}
+506
View File
@@ -0,0 +1,506 @@
//! The RaTeX gate, kept as a regression suite so that a version bump re-runs it: hostile and
//! random input never panics, nesting is bounded below the stack, macro bombs stop, and the
//! syntax an equation editor needs is covered. Every call goes through `catch_unwind`, so a panic
//! is counted, not fatal. `docs/decisions.md` holds the full gate record.
use std::panic::{AssertUnwindSafe, catch_unwind};
#[derive(Debug, PartialEq)]
enum Outcome {
Laid(usize),
Rejected(String),
Panicked(String),
}
fn run(src: &str) -> Outcome {
let r = catch_unwind(AssertUnwindSafe(|| match ratex_parser::parse(src) {
Ok(nodes) => {
let lb = ratex_layout::layout(&nodes, &ratex_layout::LayoutOptions::default());
Ok(ratex_layout::to_display_list(&lb).items.len())
}
Err(e) => Err(e.message),
}));
match r {
Ok(Ok(n)) => Outcome::Laid(n),
Ok(Err(m)) => Outcome::Rejected(m),
Err(p) => {
Outcome::Panicked(p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::<String>().cloned()).unwrap_or_default())
}
}
}
/// xorshift64: deterministic, so a failure reproduces.
struct Rng(u64);
impl Rng {
fn next(&mut self) -> u64 {
self.0 ^= self.0 << 13;
self.0 ^= self.0 >> 7;
self.0 ^= self.0 << 17;
self.0
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n.max(1) as u64) as usize
}
fn pick<'a>(&mut self, v: &[&'a str]) -> &'a str {
v[self.below(v.len())]
}
}
const TOKENS: &[&str] = &[
"{",
"}",
"[",
"]",
"(",
")",
"^",
"_",
"&",
"\\\\",
"x",
"1",
"+",
"=",
"\\frac",
"\\sqrt",
"\\left",
"\\right",
"\\middle",
"\\sum",
"\\int",
"\\begin{matrix}",
"\\end{matrix}",
"\\begin{pmatrix}",
"\\end{pmatrix}",
"\\begin{cases}",
"\\end{cases}",
"\\begin{aligned}",
"\\end{aligned}",
"\\begin{array}{cc}",
"\\end{array}",
"\\text{",
"\\mathbb",
"\\hat",
"\\overline",
"\\underbrace",
"\\overbrace",
"\\binom",
"\\color{red}",
"\\def\\a{\\a}",
"\\a",
"\\newcommand",
"\\boxed",
"\\xrightarrow",
"\\stackrel",
"\\over",
"\\atop",
"\\choose",
"\\displaystyle",
"\\big",
"\\Bigg",
"|",
"\\|",
"\\langle",
".",
"\\ce{",
"\\pu{",
"H2O",
"->",
"\\operatorname",
"\\mathrm",
"\\verb|x|",
"\\char",
"\\kern",
"\\hspace{",
"1em",
"-1000em",
"\\rule{",
"\\raisebox{",
"\\phantom",
"\\cancel",
"\\not",
"\\limits",
"\\tag{",
"$",
"%",
"#",
"~",
"'",
"\\vec",
"\\mathchoice",
"\\href{",
"\\includegraphics",
"\\cr",
"\\hline",
"\\multicolumn",
"\\begin{CD}",
"\\end{CD}",
"@>>>",
"\\infty",
"\\alpha",
"\\partial",
"\u{1F600}",
"\u{0301}",
"\u{0000}",
"\u{FFFF}",
"\\sideset",
"\\genfrac",
"\\substack{",
"\\smash",
"\\llap",
"\\textcolor{",
"#ff0000",
"\\colorbox{",
"\\sqrt[",
"\\lim",
"\\overset",
"\\underset",
"\\Huge",
"\\begin{align}",
"\\end{align}",
"\\begin{alignat}{2}",
"\\end{alignat}",
"\\begin{rcases}",
"\\expandafter",
"\\noexpand",
"\\let",
"\\gdef",
"\\global",
"\\edef",
"\\boldsymbol",
"\\mathcal",
"\\mathfrak",
"\\char\"41",
"\\unicode",
"\\infer",
];
/// What the editor's gallery and quick-insert buttons produce, plus everyday notation.
const COVERAGE: &[&str] = &[
"x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}",
"a^2 + b^2 = c^2",
"(x + a)^n = \\sum_{k=0}^{n} \\binom{n}{k} x^k a^{n-k}",
"A = \\pi r^2",
"e^{i\\pi} + 1 = 0",
"\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}",
"\\begin{bmatrix}1&0\\\\0&1\\end{bmatrix}",
"\\begin{vmatrix}a&b\\\\c&d\\end{vmatrix}",
"\\int_0^\\infty e^{-x^2}\\,dx",
"\\iint \\oint \\prod_{i} \\coprod",
"\\lim_{n\\to\\infty}\\left(1+\\frac1n\\right)^n",
"f(x) = \\begin{cases} x & x \\ge 0 \\\\ -x & x < 0 \\end{cases}",
"\\begin{aligned} a &= b \\\\ c &= d \\end{aligned}",
"\\overbrace{a+b}^{n} \\underbrace{c}_{m}",
"\\vec v \\cdot \\hat n \\times \\overline{z} \\tilde a \\dot x \\ddot y \\bar b",
"\\mathbb{R}^n \\mathcal{L} \\mathfrak g \\mathrm{d} \\mathbf{v} \\mathit{x} \\mathsf{s} \\mathtt{t}",
"\\sqrt[3]{x}",
"\\left\\langle \\psi \\middle| \\phi \\right\\rangle",
"\\left\\{ x \\right\\} \\left[ y \\right] \\left| z \\right| \\left\\lfloor w \\right\\rceil",
"\\color{red}{x}\\textcolor{blue}{y}",
"\\text{if } x \\in A",
"\\xrightarrow[below]{above} \\xleftarrow{a}",
"\\begin{array}{c|c} 1 & 2 \\\\ \\hline 3 & 4 \\end{array}",
"\\alpha\\beta\\gamma\\delta\\epsilon\\varepsilon\\zeta\\eta\\theta\\vartheta\\iota\\kappa\\lambda\\mu\\nu\\xi\\pi\\rho\\sigma\\tau\\upsilon\\phi\\varphi\\chi\\psi\\omega",
"\\Gamma\\Delta\\Theta\\Lambda\\Xi\\Pi\\Sigma\\Upsilon\\Phi\\Psi\\Omega",
"\\le\\ge\\ne\\approx\\equiv\\sim\\simeq\\cong\\propto\\ll\\gg\\subset\\supseteq\\in\\notin\\ni",
"\\pm\\mp\\times\\div\\cdot\\circ\\bullet\\oplus\\otimes\\cup\\cap\\wedge\\vee\\setminus",
"\\infty\\partial\\nabla\\forall\\exists\\emptyset\\angle\\triangle\\hbar\\ell\\Re\\Im\\aleph",
"\\to\\leftarrow\\Rightarrow\\Leftrightarrow\\mapsto\\uparrow\\iff\\implies",
"\\sin\\cos\\tan\\log\\ln\\exp\\max\\min\\det\\gcd\\operatorname{sgn}",
"\\displaystyle\\sum_{i=1}^n i = \\frac{n(n+1)}{2}",
"\\binom{n}{k} \\dbinom{a}{b} \\tfrac12 \\dfrac{1}{2} \\cfrac{1}{1+\\cfrac{1}{2}}",
"x_{i,j}^{(k)} x' x''",
"\\boxed{E=mc^2}",
"\\cancel{x} \\bcancel{y}",
"\\overset{!}{=} \\underset{x}{\\arg\\max} \\stackrel{def}{=}",
"\\begin{matrix}1&2\\\\3&4\\end{matrix}",
"\\begin{smallmatrix}1&2\\end{smallmatrix}",
"\\begin{gather} a \\\\ b \\end{gather}",
"\\begin{align} a &= b \\end{align}",
"\\ldots\\cdots\\vdots\\ddots",
"a\\quad b\\qquad c\\,d\\;e\\!f",
"\\langle x \\rangle \\lVert v \\rVert",
"\\frac{\\partial f}{\\partial x}",
"\\int\\limits_a^b",
"a\\phantom{x}b\\hphantom{y}c",
"\\widehat{abc} \\widetilde{xyz} \\overrightarrow{AB}",
"a \\pmod{n} \\bmod b",
"\\Big( \\bigg[ \\Bigg\\{",
"\\not= \\not\\in",
"1{,}000 \\%\\$\\#\\&\\_\\{\\}",
"\\textbf{bold} \\textit{it}",
"\\sum_{\\substack{a\\\\b}}",
];
#[test]
fn random_token_soup_never_panics() {
let mut rng = Rng(0x9e37_79b9_7f4a_7c15);
let mut panics = Vec::new();
for _ in 0..3000 {
let mut s = String::new();
for _ in 0..1 + rng.below(24) {
if rng.below(10) == 0 {
s.push(char::from_u32(rng.below(0x3000) as u32).unwrap_or('?'));
} else {
s.push_str(rng.pick(TOKENS));
}
}
if let Outcome::Panicked(p) = run(&s) {
panics.push((s, p));
}
}
assert!(panics.is_empty(), "RaTeX panicked: {panics:?}");
}
#[test]
fn mutated_valid_input_never_panics() {
let mut rng = Rng(0x2545_f491_4f6c_dd1d);
let mut panics = Vec::new();
for _ in 0..3000 {
let mut v: Vec<char> = rng.pick(COVERAGE).chars().collect();
for _ in 0..1 + rng.below(4) {
if v.is_empty() {
break;
}
let at = rng.below(v.len() + 1);
match rng.below(4) {
0 if at < v.len() => {
v.remove(at);
}
1 => {
for c in rng.pick(TOKENS).chars().rev() {
v.insert(at.min(v.len()), c);
}
}
2 if at < v.len() => v[at] = char::from_u32(rng.below(0x2200) as u32).unwrap_or('?'),
_ => {
let b = rng.below(v.len());
let e = (b + rng.below(12)).min(v.len());
let seg: Vec<char> = v[b..e].to_vec();
for c in seg.into_iter().rev() {
v.insert(at.min(v.len()), c);
}
}
}
}
let s: String = v.into_iter().collect();
if let Outcome::Panicked(p) = run(&s) {
panics.push((s, p));
}
}
assert!(panics.is_empty(), "RaTeX panicked: {panics:?}");
}
#[test]
fn nesting_is_refused_before_the_stack_runs_out() {
for (open, close) in [("{", "}"), ("\\frac{", "}{1}"), ("\\sqrt{", "}"), ("\\left(", "\\right)"), ("x^{", "}")] {
let at = |d: usize| format!("{}x{}", open.repeat(d), close.repeat(d));
assert!(matches!(run(&at(32)), Outcome::Laid(_)), "{open}: 32 levels lay out");
for d in [64, 2000] {
assert_eq!(run(&at(d)), Outcome::Rejected("Recursion limit exceeded".into()), "{open}: {d} levels");
}
}
}
#[test]
fn nesting_built_by_macros_is_refused_by_the_parser() {
// `check_caps` in the wrapper counts the braces, `\left`s and `\begin`s of the source text,
// so nesting that macro expansion builds reaches the parser; its own recursion guard refuses
// it, well within the 1,000-expansion limit (64 levels take 128 expansions).
let built = |d: usize| format!("\\def\\o{{\\left(}}\\def\\c{{\\right)}}{}x{}", "\\o ".repeat(d), "\\c".repeat(d));
assert!(matches!(run(&built(32)), Outcome::Laid(_)), "32 macro-built levels lay out");
assert_eq!(run(&built(64)), Outcome::Rejected("Recursion limit exceeded".into()), "64 macro-built levels");
let frac = |d: usize| format!("\\def\\o#1{{\\frac{{#1}}{{1}}}}{}x{}", "\\o{".repeat(d), "}".repeat(d));
assert!(matches!(run(&frac(32)), Outcome::Laid(_)), "32 macro-built fractions lay out");
assert_eq!(run(&frac(64)), Outcome::Rejected("Recursion limit exceeded".into()), "64 macro-built fractions");
// The wrapper's text count sees no nesting in the first source, so the parser's guard is the
// one that holds there too.
assert!(gridcraft_math::layout(&built(64)).is_err());
}
#[test]
fn macro_bombs_and_huge_dimensions_stop() {
for bomb in [
"\\def\\a{\\a\\a}\\a",
"\\def\\a{xxxxxxxxxx}\\def\\b{\\a\\a\\a\\a\\a\\a\\a\\a\\a\\a}\\def\\c{\\b\\b\\b\\b\\b\\b\\b\\b\\b\\b}\\def\\d{\\c\\c\\c\\c\\c\\c\\c\\c\\c\\c}\\d",
] {
assert!(matches!(run(bomb), Outcome::Rejected(m) if m.contains("Too many expansions")));
}
// Absurd sizes lay out (the wrapper in this crate caps them); NaN is refused.
for s in ["\\kern99999999em x", "\\rule{1e308em}{1e308em}", "\\hspace{-99999em}x"] {
assert!(matches!(run(s), Outcome::Laid(_)), "{s}");
}
assert!(matches!(run("\\rule{nanem}{1em}"), Outcome::Rejected(_)));
assert!(matches!(run(&"x+".repeat(2048)), Outcome::Laid(4096)));
}
#[test]
fn editor_syntax_is_covered() {
let missing: Vec<(&str, Outcome)> = COVERAGE.iter().map(|s| (*s, run(s))).filter(|(_, o)| !matches!(o, Outcome::Laid(n) if *n > 0)).collect();
assert!(missing.is_empty(), "unsupported: {missing:?}");
}
/// Whether every number in `list` is finite and within the caps the wrapper promises.
fn within_caps(list: &gridcraft_math::MathList) -> bool {
use gridcraft_math::{Item, MAX_EM, MAX_ITEMS, PathCmd};
let ok = |v: f32| v.is_finite() && v.abs() <= MAX_EM;
let path_ok = |c: &PathCmd| match *c {
PathCmd::MoveTo(a, b) | PathCmd::LineTo(a, b) => ok(a) && ok(b),
PathCmd::QuadTo(a, b, c, d) => [a, b, c, d].into_iter().all(ok),
PathCmd::CubicTo(a, b, c, d, e, f) => [a, b, c, d, e, f].into_iter().all(ok),
PathCmd::Close => true,
};
list.items.len() <= MAX_ITEMS
&& [list.width, list.height, list.depth].into_iter().all(ok)
&& list.items.iter().all(|i| match i {
Item::Glyph(g) => [g.x, g.y, g.size, g.width, g.height, g.depth].into_iter().all(ok),
Item::Rule(r) => [r.x, r.y, r.width, r.height].into_iter().all(ok),
Item::Path(p) => ok(p.x) && ok(p.y) && p.commands.iter().all(path_ok),
})
}
#[test]
fn the_guarded_entry_points_survive_hostile_input() {
// What every caller (commands, the editor's preview, the xlsx writer) reaches: `layout`,
// `check` and `to_omml`, with their caps. Random token soup, mutations of valid input, and
// inputs at and past the caps; none may panic, a layout stays within its caps, and an error's
// span stays on char boundaries inside the source.
let mut rng = Rng(0x6a09_e667_f3bc_c909);
let mut inputs: Vec<String> = Vec::new();
for _ in 0..1500 {
let mut s = String::new();
for _ in 0..1 + rng.below(32) {
match rng.below(12) {
0 => s.push(char::from_u32(rng.below(0x11_0000) as u32).unwrap_or('\u{FFFD}')),
1 => s.push_str(rng.pick(COVERAGE)),
_ => s.push_str(rng.pick(TOKENS)),
}
}
inputs.push(s);
}
for (open, close) in [("{", "}"), ("\\left(", "\\right)"), ("\\begin{matrix}", "\\end{matrix}"), ("\\sqrt{", "}")] {
for d in [31, 32, 33, 500] {
inputs.push(format!("{}x{}", open.repeat(d), close.repeat(d)));
}
}
let cap = gridcraft_math::MAX_SOURCE_BYTES;
inputs.push("x".repeat(cap));
inputs.push("x".repeat(cap + 1));
inputs.push(format!("{}\u{e9}", "x".repeat(cap - 1)));
inputs.push("\\rule{1e308em}{1e308em}".into());
inputs.push("\\def\\a{\\a\\a}\\a".into());
let mut failures = Vec::new();
for s in &inputs {
let r = catch_unwind(AssertUnwindSafe(|| {
let laid = gridcraft_math::layout(s);
let checked = gridcraft_math::check(s);
let omml = gridcraft_math::to_omml(s, 11.0, None);
(laid, checked, omml)
}));
let Ok((laid, checked, omml)) = r else {
failures.push(format!("panicked: {s:?}"));
continue;
};
if let Ok(list) = &laid
&& !within_caps(list)
{
failures.push(format!("outside the caps: {s:?}"));
}
for e in [laid.as_ref().err(), checked.as_ref().err(), omml.as_ref().err()].into_iter().flatten() {
if let Some(span) = &e.span
&& !(span.start <= span.end && span.end <= s.len() && s.is_char_boundary(span.start) && s.is_char_boundary(span.end))
{
failures.push(format!("span {span:?} off the source: {s:?}"));
}
}
if laid.is_ok() && checked.is_err() {
failures.push(format!("lays out but does not check: {s:?}"));
}
}
assert!(failures.is_empty(), "{failures:#?}");
}
#[test]
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
fn pathological_source_at_the_cap_finishes_in_bounded_time() {
// Sources of exactly `MAX_SOURCE_BYTES` built to make a parser or a regular expression work
// hardest: groups and delimiters nested to the depth cap, long runs of backslashes, braces and
// scripts, a `(a+)+b` shape, wide and tall environments, and chemistry. `\ce` and `\pu` are
// the one place RaTeX runs regular expressions over the source (its mhchem port uses
// `fancy-regex`, which stops a match after a million backtracking steps); the rest of the
// parser is hand written. Each source lays out, checks and converts, or is refused, well
// inside the bound (the slowest, `\ce{+-+-...}`, takes 0.8 s in a debug build on an Apple M
// series machine; catastrophic backtracking would take minutes), so a regression in RaTeX or
// its dependencies shows up here without making a slow CI runner fail.
use gridcraft_math::{MAX_DEPTH, MAX_SOURCE_BYTES, check, layout, to_omml};
use std::time::{Duration, Instant};
let fill = |head: &str, body: &str, tail: &str| {
let room = MAX_SOURCE_BYTES.saturating_sub(head.len() + tail.len());
let mut s = String::from(head);
while s.len() + body.len() <= head.len() + room {
s.push_str(body);
}
while s.len() < MAX_SOURCE_BYTES - tail.len() {
s.push(' ');
}
s.push_str(tail);
assert_eq!(s.len(), MAX_SOURCE_BYTES);
s
};
let deep = |open: &str, close: &str| fill(&open.repeat(MAX_DEPTH), "x", &close.repeat(MAX_DEPTH));
let sources = [
deep("{", "}"),
deep("\\left(", "\\right)"),
deep("\\frac{", "}{1}"),
deep("\\sqrt{", "}"),
deep("x^{", "}"),
deep("\\hat{", "}"),
deep("\\text{", "}"),
deep("\\begin{matrix}", "\\end{matrix}"),
fill("", "\\\\", ""),
fill("", "\\{", ""),
fill("", "{}", ""),
fill("", "(a+)+", "b"),
fill("", "x^", "x"),
fill("", "'", ""),
fill("", "\\,", ""),
fill("", "&", ""),
fill("\\begin{matrix}", "&", "\\end{matrix}"),
fill("\\begin{aligned}", "\\\\", "\\end{aligned}"),
fill("\\begin{cases}", "x&\\\\", "\\end{cases}"),
fill("", "\\def\\a{x}", ""),
fill("", "\\color{red}", ""),
fill("", "\u{0301}", ""),
fill("", "\\mathbb{R}", ""),
fill("", "\\operatorname{f}", ""),
fill("\\ce{", "1", "}"),
fill("\\ce{", "0.", "}"),
fill("\\ce{", "(a", "}"),
fill("\\ce{", "->", "}"),
fill("\\ce{", "^_", "}"),
fill("\\ce{", "$", "}"),
fill("\\ce{", "H2O ", "}"),
fill("\\ce{", "\\ce{", "}"),
fill("\\ce{", "+-", "}"),
fill("\\ce{", "1/2", "}"),
fill("\\pu{", "+-", "}"),
fill("\\pu{", "1e", "}"),
fill("\\pu{", "10^", "}"),
fill("\\pu{", "kJ//", "}"),
];
let bound = Duration::from_secs(10);
let mut slow = Vec::new();
for s in &sources {
let start = Instant::now();
let r = catch_unwind(AssertUnwindSafe(|| (layout(s).is_ok(), check(s).is_ok(), to_omml(s, 11.0, None).is_ok())));
let took = start.elapsed();
let head: String = s.chars().take(24).collect();
match r {
Ok(_) if took <= bound => {}
Ok(_) => slow.push(format!("{head:?}: {took:?}")),
Err(_) => slow.push(format!("{head:?}: panicked")),
}
}
assert!(slow.is_empty(), "over {bound:?}: {slow:#?}");
}
+1
View File
@@ -12,6 +12,7 @@ workspace = true
gridcraft-core = { workspace = true }
sha2 = { workspace = true }
gridcraft-formula = { workspace = true }
gridcraft-math = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
+171 -1
View File
@@ -631,6 +631,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,
/// 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,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
@@ -644,10 +647,103 @@ pub struct Shape {
/// Outline visibility and text formatting (DrawingML `a:ln`, `a:bodyPr`, `a:pPr`, `a:rPr`).
#[serde(default)]
pub format: ShapeFormat,
/// An equation's layout, rebuilt from `text` and never saved.
#[serde(skip)]
pub math: EquationCache,
}
/// The laid-out form of an equation's LaTeX source. It is derived data: rebuilt by
/// [`EquationCache::refresh`] whenever the source changes, never saved, and ignored when shapes
/// are compared. Painters read it and never lay equations out themselves, so nothing in a paint
/// path can reach the layout engine.
///
/// An entry answers only for the source it was built from, compared in full: a hash match alone
/// is not enough, so two sources whose hashes collide never share a layout. A source over
/// [`gridcraft_math::MAX_SOURCE_BYTES`] never lays out, so [`EquationCache::get`] answers for it
/// by its length alone, with [`gridcraft_math::SOURCE_TOO_LONG`], and an entry built from one holds
/// neither the source nor its hash: a painter asking every frame about a source of megabytes read
/// from a file does no work on it.
#[derive(Clone, Debug, Default)]
pub struct EquationCache {
/// A hash of the source the entry was built from (a quick first check).
key: u64,
/// The source's length in bytes.
len: usize,
/// The source itself, when it is within the length cap.
source: Option<std::sync::Arc<str>>,
layout: Option<Result<std::sync::Arc<gridcraft_math::MathList>, gridcraft_math::MathError>>,
}
impl PartialEq for EquationCache {
fn eq(&self, _: &Self) -> bool {
true
}
}
fn source_key(src: &str) -> u64 {
use std::hash::{Hash, Hasher};
#[cfg(test)]
equation_cache_tests::HASHES.with(|h| h.set(h.get() + 1));
let mut h = std::collections::hash_map::DefaultHasher::new();
src.hash(&mut h);
h.finish()
}
impl EquationCache {
/// Whether this entry was built from `src`. Over the cap the length is the match (an entry for
/// such a source is always the same error); within it, the hash and then the source itself.
fn holds(&self, src: &str) -> bool {
if self.layout.is_none() || self.len != src.len() {
return false;
}
if src.len() > gridcraft_math::MAX_SOURCE_BYTES {
return self.source.is_none();
}
self.key == source_key(src) && self.source.as_deref() == Some(src)
}
/// Makes this the entry for `src`, with `layout`. A source over the cap is neither copied nor
/// hashed.
fn store(&mut self, src: &str, layout: Result<std::sync::Arc<gridcraft_math::MathList>, gridcraft_math::MathError>) {
let within = src.len() <= gridcraft_math::MAX_SOURCE_BYTES;
self.key = if within { source_key(src) } else { 0 };
self.len = src.len();
self.source = within.then(|| std::sync::Arc::from(src));
self.layout = Some(layout);
}
/// The layout of `src`, or why it has none; `None` until [`EquationCache::refresh`] has run
/// for this source. A source over [`gridcraft_math::MAX_SOURCE_BYTES`] is answered at once,
/// whatever the entry holds, by its length alone.
pub fn get(&self, src: &str) -> Option<Result<&gridcraft_math::MathList, &gridcraft_math::MathError>> {
if src.len() > gridcraft_math::MAX_SOURCE_BYTES {
return Some(Err(&*gridcraft_math::SOURCE_TOO_LONG));
}
if !self.holds(src) {
return None;
}
self.layout.as_ref().map(|r| r.as_ref().map(|l| l.as_ref()))
}
/// Lays `src` out again unless the cache already holds its layout. True when it did.
pub fn refresh(&mut self, src: &str) -> bool {
if self.holds(src) {
return false;
}
self.store(src, gridcraft_math::layout(src).map(std::sync::Arc::new));
true
}
/// Records that `src` was not laid out, and why, without running the layout engine.
pub fn refuse(&mut self, src: &str, message: &str) {
self.store(src, Err(gridcraft_math::MathError { span: None, message: message.to_string() }));
}
}
/// The kinds of floating object a sheet holds. Commands name an object by kind and
/// workbook-unique id (`{kind: "chart", id}`); [`ObjKind::name`] is that parameter's spelling.
/// Equations are stored as shapes ([`ShapeKind::Equation`]) but select, open and delete as their
/// own kind.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ObjKind {
@@ -655,10 +751,11 @@ pub enum ObjKind {
Image,
Shape,
Slicer,
Equation,
}
impl ObjKind {
pub const ALL: [ObjKind; 4] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer];
pub const ALL: [ObjKind; 5] = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer, ObjKind::Equation];
/// The name used in command parameters.
pub fn name(self) -> &'static str {
@@ -667,6 +764,7 @@ impl ObjKind {
ObjKind::Image => "image",
ObjKind::Shape => "shape",
ObjKind::Slicer => "slicer",
ObjKind::Equation => "equation",
}
}
@@ -822,3 +920,75 @@ mod trendline_forecast_tests {
assert_eq!(Trendline::clamp_forecast(1e300), Trendline::MAX_FORECAST);
}
}
#[cfg(test)]
mod equation_cache_tests {
use super::{EquationCache, source_key};
thread_local! {
/// How many sources `source_key` has hashed on this thread.
pub(super) static HASHES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
fn glyphs(c: &EquationCache, src: &str) -> Option<String> {
let list = c.get(src)?.ok()?;
Some(list.items.iter().filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None }).collect())
}
#[test]
fn a_hash_collision_never_returns_another_sources_layout() {
let (a, b) = ("x^2", "y^3");
let mut c = EquationCache::default();
assert!(c.refresh(a));
assert_eq!(glyphs(&c, a).as_deref(), Some("x2"));
// Forge a collision: the entry claims `b`'s hash; `b` has `a`'s length, so only the
// full comparison tells them apart.
c.key = source_key(b);
assert!(c.get(b).is_none(), "`b` is a miss, not `a`'s layout");
assert!(c.refresh(b), "`b` is laid out afresh");
assert_eq!(glyphs(&c, b).as_deref(), Some("y3"));
assert!(c.get(a).is_none(), "the entry is `b`'s now");
// A refusal does not answer for a colliding source either.
c.refuse(a, "not laid out");
c.key = source_key(b);
assert!(c.get(b).is_none());
}
#[test]
fn sources_over_the_cap_are_not_copied_hashed_or_laid_out() {
let long = "x".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1);
let mut c = EquationCache::default();
HASHES.with(|h| h.set(0));
assert!(matches!(c.get(&long), Some(Err(e)) if e.message.contains("4096")), "answered before any refresh");
assert!(c.refresh(&long));
assert!(c.source.is_none(), "an over-cap source is not kept");
assert_eq!(c.key, 0, "nor hashed");
assert!(matches!(c.get(&long), Some(Err(_))));
assert!(!c.refresh(&long), "still cached");
let other = "y".repeat(gridcraft_math::MAX_SOURCE_BYTES + 1);
assert!(matches!(c.get(&other), Some(Err(_))), "another over-cap source gets the same answer");
assert_eq!(HASHES.with(|h| h.get()), 0, "nothing over the cap is hashed");
// Within the cap the lookup is unchanged: a hash, then the source compared in full.
assert!(c.refresh("x"));
assert!(c.get("x").is_some_and(|r| r.is_ok()));
assert!(HASHES.with(|h| h.get()) >= 2);
}
#[test]
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
fn a_source_of_a_megabyte_is_looked_up_without_being_read() {
// The sheet painter asks the cache every frame and the PDF export every draw; a source
// read from a file can be megabytes, and the lookup must not scan it each time.
let big = "l".repeat(1 << 20);
let mut c = EquationCache::default();
c.refuse(&big, "budget");
HASHES.with(|h| h.set(0));
let start = std::time::Instant::now();
for _ in 0..1000 {
assert!(matches!(c.get(&big), Some(Err(_))));
}
let took = start.elapsed();
assert_eq!(HASHES.with(|h| h.get()), 0, "a thousand lookups read nothing");
assert!(took < std::time::Duration::from_millis(500), "{took:?}");
}
}
+4 -1
View File
@@ -27,7 +27,10 @@ pub use style::{
Alignment, BorderLine, BorderStyle, Borders, Color, Fill, Font, HAlign, NumFmt, PatternType, Style, StyleId, StyleTable, Theme, Underline,
VAlign, VertAlign,
};
pub use workbook::{CalcMode, CalcSettings, DefinedName, DocProps, PhoneticProps, PhoneticRun, PhoneticString, ShowObjects, Workbook, WorkbookView};
pub use workbook::{
CalcMode, CalcSettings, DefinedName, DocProps, EQUATION_BUDGET_MESSAGE, MAX_EQUATION_LAYOUT_BYTES, PhoneticProps, PhoneticRun, PhoneticString,
ShowObjects, Workbook, WorkbookView,
};
/// Font used for new workbooks. Only a name: renderers substitute an available open font
/// (e.g. the metric-compatible Carlito) when it isn't installed.
+23 -12
View File
@@ -8,8 +8,8 @@ use serde::{Deserialize, Serialize};
use crate::cell::{Cell, CellPicture};
use crate::features::{
Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, SheetProtection,
Slicer, Sparkline, Table, Validation,
Anchor, AutoFilter, Chart, Comment, CondFormat, Hyperlink, Image, LineInfo, ObjKind, ObjRef, PivotTable, PrintSettings, Shape, ShapeKind,
SheetProtection, Slicer, Sparkline, Table, Validation,
};
use crate::store::CellStore;
use crate::style::{Color, StyleId};
@@ -208,8 +208,8 @@ impl Sheet {
ObjKind::Chart
} else if self.images.iter().any(|o| o.id == id) {
ObjKind::Image
} else if self.shapes.iter().any(|o| o.id == id) {
ObjKind::Shape
} else if let Some(s) = self.shapes.iter().find(|o| o.id == id) {
if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape }
} else if self.slicers.iter().any(|o| o.id == id) {
ObjKind::Slicer
} else {
@@ -218,7 +218,8 @@ impl Sheet {
Some(ObjRef::new(kind, id))
}
/// Whether the floating object `o` exists on this sheet.
/// Whether the floating object `o` exists on this sheet. `Shape` accepts any shape (an
/// equation included); `Equation` only an equation.
pub fn has_object(&self, o: ObjRef) -> bool {
self.object_anchor(o).is_some()
}
@@ -230,6 +231,7 @@ impl Sheet {
ObjKind::Image => self.images.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Shape => self.shapes.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Slicer => self.slicers.iter().find(|c| c.id == o.id).map(|c| c.anchor),
ObjKind::Equation => self.shapes.iter().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| c.anchor),
}
}
@@ -239,6 +241,7 @@ impl Sheet {
ObjKind::Image => self.images.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Shape => self.shapes.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Slicer => self.slicers.iter_mut().find(|c| c.id == o.id).map(|c| &mut c.anchor),
ObjKind::Equation => self.shapes.iter_mut().find(|c| c.id == o.id && c.kind == ShapeKind::Equation).map(|c| &mut c.anchor),
}
}
@@ -254,6 +257,7 @@ impl Sheet {
ObjKind::Image => take(&mut self.images, |c| c.id == o.id),
ObjKind::Shape => take(&mut self.shapes, |c| c.id == o.id),
ObjKind::Slicer => take(&mut self.slicers, |c| c.id == o.id),
ObjKind::Equation => take(&mut self.shapes, |c| c.id == o.id && c.kind == ShapeKind::Equation),
}
}
@@ -702,12 +706,13 @@ mod tests {
assert_eq!(s.col_left(2), 128.0);
}
use crate::features::{ChartKind, LegendPos, ShapeFormat, ShapeKind};
use crate::features::{ChartKind, EquationCache, LegendPos, ShapeFormat, ShapeKind};
/// Adds a floating object of `kind` with id `id` at `anchor`. The match is exhaustive on
/// purpose: a new kind of floating object does not compile here until this test covers it,
/// and the counts in `object_anchor_accessors_visit_the_same_objects_in_the_same_order`
/// then catch an accessor that was not taught about it.
/// then catch an accessor that was not taught about it. An equation is a shape of
/// `ShapeKind::Equation`, so it lives in `shapes` and is visited among them.
fn push_object(s: &mut Sheet, kind: ObjKind, id: u32, anchor: Anchor) {
match kind {
ObjKind::Chart => s.charts.push(Chart {
@@ -728,14 +733,15 @@ mod tests {
pivot: None,
}),
ObjKind::Image => s.images.push(Image { id, anchor, data: Arc::new(vec![]), mime: String::new(), alt: String::new() }),
ObjKind::Shape => s.shapes.push(Shape {
ObjKind::Shape | ObjKind::Equation => s.shapes.push(Shape {
id,
kind: ShapeKind::default(),
kind: if kind == ObjKind::Equation { ShapeKind::Equation } else { ShapeKind::default() },
anchor,
fill: Color::default(),
line: Color::default(),
text: String::new(),
format: ShapeFormat::default(),
math: EquationCache::default(),
}),
ObjKind::Slicer => {
s.slicers.push(Slicer { id, table: String::new(), column: String::new(), caption: String::new(), anchor, selected: None, columns: 1 })
@@ -760,7 +766,9 @@ mod tests {
(ObjKind::Chart, 9),
(ObjKind::Slicer, 1),
(ObjKind::Image, 4),
(ObjKind::Equation, 6),
(ObjKind::Shape, 12),
(ObjKind::Equation, 8),
];
for &(kind, id) in &inserted {
push_object(&mut s, kind, id, Anchor { cell: CellRef::new(id, 0), ..Default::default() });
@@ -777,9 +785,12 @@ mod tests {
let marks: Vec<f32> = s.object_anchors().map(|a| a.dx).collect();
assert_eq!(marks, (0..per_kind).map(|i| i as f32).collect::<Vec<f32>>());
// The sequence is the documented one (charts, images, shapes, slicers, each in insertion
// order) and every object appears in it once.
let documented: Vec<u32> =
ObjKind::ALL.iter().flat_map(|&k| inserted.iter().filter(move |(kind, _)| *kind == k).map(|&(_, id)| id)).collect();
// order; equations are shapes) and every object appears in it once.
let collection = |k: ObjKind| if k == ObjKind::Equation { ObjKind::Shape } else { k };
let documented: Vec<u32> = [ObjKind::Chart, ObjKind::Image, ObjKind::Shape, ObjKind::Slicer]
.iter()
.flat_map(|&k| inserted.iter().filter(move |(kind, _)| collection(*kind) == k).map(|&(_, id)| id))
.collect();
let visited: Vec<u32> = s.object_anchors().map(|a| a.cell.row).collect();
assert_eq!(visited, documented);
// The mark written through the bulk accessor landed on the object `object_anchor` finds
+31
View File
@@ -11,6 +11,13 @@ use crate::cell::Formula;
use crate::sheet::Sheet;
use crate::style::{StyleTable, Theme};
/// Most equation source, in bytes, one [`Workbook::refresh_equations`] lays out: about 2,500
/// typical equations, or a few seconds of the slowest 4 KB sources.
pub const MAX_EQUATION_LAYOUT_BYTES: usize = 128 * 1024;
/// Why an equation past [`MAX_EQUATION_LAYOUT_BYTES`] has no layout.
pub const EQUATION_BUDGET_MESSAGE: &str = "This workbook has more equations than are laid out at once; edit this equation to lay it out.";
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CalcMode {
#[default]
@@ -463,6 +470,30 @@ impl Workbook {
}
None
}
/// Rebuilds the cached layout of every equation whose source changed (or was never laid
/// out, as after loading a file). One call lays out at most [`MAX_EQUATION_LAYOUT_BYTES`] of
/// source: one equation can take a tenth of a second to lay out, so a file with thousands of
/// them would otherwise hold the app for minutes. Equations past the budget get an error
/// instead (they show their source; editing one lays it out).
pub fn refresh_equations(&mut self) {
let stale = |s: &crate::Shape| s.kind == crate::ShapeKind::Equation && s.math.get(&s.text).is_none();
let mut budget = MAX_EQUATION_LAYOUT_BYTES;
// Only sheets with a stale equation are copied (sheets are shared with undo snapshots).
for sh in self.sheets.iter_mut().filter(|sh| sh.shapes.iter().any(stale)) {
for s in Arc::make_mut(sh).shapes.iter_mut().filter(|s| stale(s)) {
// A source over the length cap never gets here: the cache answers for it without
// a lookup. An empty source still costs a call.
let cost = s.text.len().max(1);
if cost > budget {
s.math.refuse(&s.text, EQUATION_BUDGET_MESSAGE);
continue;
}
budget -= cost;
s.math.refresh(&s.text);
}
}
}
/// A free id for a new chart, picture, shape, table or slicer: one more than the largest in
/// use, or, when that is `u32::MAX` (a file can carry any id), the lowest free one.
pub fn next_object_id(&self) -> u32 {
+43 -4
View File
@@ -3,7 +3,8 @@
//! - The 14 standard fonts (no embedding) with WinAnsiEncoding, plus approximate width tables
//! ([`text_width`]) so callers can measure, align and wrap text. Characters outside
//! WinAnsiEncoding (CJK and other scripts) are set in a predefined, non-embedded Adobe-GB1 CID
//! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`.
//! font with the `UniGB-UTF16-H` CMap instead of being replaced by `?`. Symbol uses its own
//! built-in encoding ([`symbol_code`]), for Greek letters and mathematical operators.
//! - Vector drawing: rectangles, lines (solid or dashed), polygons, circles (Bézier), clipping.
//! - RGBA images as RGB image XObjects with a soft mask for alpha (run-length encoded).
//! - Coordinates are points with a **top-left origin** (y grows down); text `y` is the baseline.
@@ -14,6 +15,9 @@
#![forbid(unsafe_code)]
mod metrics;
mod symbol;
pub use symbol::{symbol_code, symbol_width};
use std::fmt::Write as _;
@@ -48,7 +52,7 @@ impl Rect {
}
}
/// The standard 14 fonts minus Symbol and ZapfDingbats.
/// The standard 14 fonts minus ZapfDingbats.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum Font {
#[default]
@@ -64,8 +68,13 @@ pub enum Font {
CourierBold,
CourierOblique,
CourierBoldOblique,
/// Greek letters and mathematical symbols, in the font's own encoding.
Symbol,
}
/// How many fonts a document can use.
const FONT_COUNT: usize = 13;
/// A font family for [`Font::styled`].
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum Family {
@@ -76,7 +85,7 @@ pub enum Family {
}
impl Font {
pub const ALL: [Font; 12] = [
pub const ALL: [Font; FONT_COUNT] = [
Font::Helvetica,
Font::HelveticaBold,
Font::HelveticaOblique,
@@ -89,6 +98,7 @@ impl Font {
Font::CourierBold,
Font::CourierOblique,
Font::CourierBoldOblique,
Font::Symbol,
];
/// PostScript base font name.
@@ -106,6 +116,7 @@ impl Font {
Font::CourierBold => "Courier-Bold",
Font::CourierOblique => "Courier-Oblique",
Font::CourierBoldOblique => "Courier-BoldOblique",
Font::Symbol => "Symbol",
}
}
@@ -146,8 +157,20 @@ impl Font {
}
}
/// Whether this font can show `c` by itself (in its own encoding; for the text faces,
/// without the CID fallback).
pub fn can_encode(self, c: char) -> bool {
match self {
Font::Symbol => symbol_code(c).is_some(),
_ => c == '?' || win_ansi(c) != b'?',
}
}
/// Advance width of a character in 1/1000 em.
pub fn char_width(self, c: char) -> u16 {
if self == Font::Symbol {
return symbol_code(c).and_then(symbol_width).unwrap_or(500);
}
let table: Option<&[u16; 95]> = match self {
Font::Helvetica | Font::HelveticaOblique => Some(&metrics::HELVETICA),
Font::HelveticaBold | Font::HelveticaBoldOblique => Some(&metrics::HELVETICA_BOLD),
@@ -249,10 +272,16 @@ fn pdf_utf16_string(text: &str) -> String {
/// A PDF literal string `( … )` for `text` in WinAnsiEncoding.
fn pdf_string(text: &str) -> String {
pdf_string_in(text, false)
}
/// A PDF literal string for `text` in WinAnsiEncoding, or in Symbol's encoding (where a
/// character Symbol lacks becomes `?`).
fn pdf_string_in(text: &str, symbol: bool) -> String {
let mut s = String::with_capacity(text.len() + 2);
s.push('(');
for c in text.chars() {
let b = win_ansi(c);
let b = if symbol { symbol_code(c).unwrap_or(b'?') } else { win_ansi(c) };
match b {
b'(' | b')' | b'\\' => {
s.push('\\');
@@ -586,6 +615,16 @@ impl Page {
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)));
// 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");
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 rest = text;
+239
View File
@@ -0,0 +1,239 @@
//! The standard Symbol font: its built-in encoding and advance widths.
//!
//! Source: the character codes and glyph advance widths ("C" and "WX" values) of Adobe's Core 14
//! AFM metrics file for Symbol (`Symbol.afm`, version 001.008), and the Unicode values of its glyph
//! names from Unicode, Inc.'s `symbol.txt` ("Adobe Symbol Encoding to Unicode", table version
//! 1.0). Adobe distributes the Core 14 metrics with permission to use, copy and distribute them
//! for any purpose (the notice is quoted in `metrics.rs`); Unicode, Inc. grants the right to use
//! the information in its file in products supporting the Unicode Standard (its notice is
//! recorded in `ATTRIBUTION.md`). Only the numbers are reproduced here, typed in from the public
//! tables; neither file is included. The stretchable
//! bracket, brace and integral pieces (codes 0xE6 to 0xEF and 0xF3 to 0xFE) and the sans-serif
//! duplicates of the registered, copyright and trademark signs are left out of the Unicode map:
//! they have no Unicode counterpart of their own.
/// The Symbol-encoding code for `c`, or `None` when Symbol has no such glyph.
pub fn symbol_code(c: char) -> Option<u8> {
let u = c as u32;
// Characters Symbol encodes at their ASCII code.
if matches!(
c,
' ' | '!' | '#' | '%' | '&' | '(' | ')' | '+' | ',' | '.' | '/' | '0'
..='9' | ':' | ';' | '<' | '=' | '>' | '?' | '[' | ']' | '_' | '{' | '|' | '}'
) {
return Some(u as u8);
}
Some(match c {
'\u{2200}' => 0x22, // for all
'\u{2203}' => 0x24, // there exists
'\u{220B}' => 0x27, // such that (contains as member)
'\u{2217}' | '*' => 0x2A,
'\u{2212}' | '-' => 0x2D, // minus
'\u{2245}' => 0x40, // approximately equal (congruent)
'\u{0391}' => 0x41,
'\u{0392}' => 0x42,
'\u{03A7}' => 0x43,
'\u{0394}' | '\u{2206}' => 0x44,
'\u{0395}' => 0x45,
'\u{03A6}' => 0x46,
'\u{0393}' => 0x47,
'\u{0397}' => 0x48,
'\u{0399}' => 0x49,
'\u{03D1}' => 0x4A, // theta1
'\u{039A}' => 0x4B,
'\u{039B}' => 0x4C,
'\u{039C}' => 0x4D,
'\u{039D}' => 0x4E,
'\u{039F}' => 0x4F,
'\u{03A0}' => 0x50,
'\u{0398}' => 0x51,
'\u{03A1}' => 0x52,
'\u{03A3}' => 0x53,
'\u{03A4}' => 0x54,
'\u{03A5}' => 0x55,
'\u{03C2}' => 0x56, // final sigma (sigma1)
'\u{03A9}' | '\u{2126}' => 0x57,
'\u{039E}' => 0x58,
'\u{03A8}' => 0x59,
'\u{0396}' => 0x5A,
'\u{2234}' => 0x5C, // therefore
'\u{22A5}' => 0x5E, // perpendicular
'\u{03B1}' => 0x61,
'\u{03B2}' => 0x62,
'\u{03C7}' => 0x63,
'\u{03B4}' => 0x64,
'\u{03B5}' | '\u{03F5}' => 0x65,
'\u{03C6}' => 0x66,
'\u{03B3}' => 0x67,
'\u{03B7}' => 0x68,
'\u{03B9}' => 0x69,
'\u{03D5}' => 0x6A, // phi1
'\u{03BA}' => 0x6B,
'\u{03BB}' => 0x6C,
'\u{03BC}' | '\u{00B5}' => 0x6D,
'\u{03BD}' => 0x6E,
'\u{03BF}' => 0x6F,
'\u{03C0}' => 0x70,
'\u{03B8}' => 0x71,
'\u{03C1}' => 0x72,
'\u{03C3}' => 0x73,
'\u{03C4}' => 0x74,
'\u{03C5}' => 0x75,
'\u{03D6}' => 0x76, // omega1 (pi symbol)
'\u{03C9}' => 0x77,
'\u{03BE}' => 0x78,
'\u{03C8}' => 0x79,
'\u{03B6}' => 0x7A,
'\u{223C}' | '~' => 0x7E, // similar
'\u{20AC}' => 0xA0, // euro
'\u{03D2}' => 0xA1, // Upsilon1
'\u{2032}' => 0xA2, // minute (prime)
'\u{2264}' => 0xA3,
'\u{2044}' => 0xA4, // fraction slash
'\u{221E}' => 0xA5,
'\u{0192}' => 0xA6,
'\u{2663}' => 0xA7,
'\u{2666}' => 0xA8,
'\u{2665}' => 0xA9,
'\u{2660}' => 0xAA,
'\u{2194}' => 0xAB,
'\u{2190}' => 0xAC,
'\u{2191}' => 0xAD,
'\u{2192}' => 0xAE,
'\u{2193}' => 0xAF,
'\u{00B0}' => 0xB0,
'\u{00B1}' => 0xB1,
'\u{2033}' => 0xB2, // second (double prime)
'\u{2265}' => 0xB3,
'\u{00D7}' => 0xB4,
'\u{221D}' => 0xB5,
'\u{2202}' => 0xB6,
'\u{2022}' | '\u{2219}' => 0xB7,
'\u{00F7}' => 0xB8,
'\u{2260}' => 0xB9,
'\u{2261}' => 0xBA,
'\u{2248}' => 0xBB,
'\u{2026}' => 0xBC,
'\u{21B5}' => 0xBF,
'\u{2135}' => 0xC0,
'\u{2111}' => 0xC1,
'\u{211C}' => 0xC2,
'\u{2118}' => 0xC3,
'\u{2297}' => 0xC4,
'\u{2295}' => 0xC5,
'\u{2205}' => 0xC6,
'\u{2229}' => 0xC7,
'\u{222A}' => 0xC8,
'\u{2283}' => 0xC9,
'\u{2287}' => 0xCA,
'\u{2284}' => 0xCB,
'\u{2282}' => 0xCC,
'\u{2286}' => 0xCD,
'\u{2208}' => 0xCE,
'\u{2209}' => 0xCF,
'\u{2220}' => 0xD0,
'\u{2207}' => 0xD1,
'\u{00AE}' => 0xD2,
'\u{00A9}' => 0xD3,
'\u{2122}' => 0xD4,
'\u{220F}' => 0xD5,
'\u{221A}' => 0xD6,
'\u{22C5}' | '\u{00B7}' => 0xD7, // dot operator
'\u{00AC}' => 0xD8,
'\u{2227}' => 0xD9,
'\u{2228}' => 0xDA,
'\u{21D4}' => 0xDB,
'\u{21D0}' => 0xDC,
'\u{21D1}' => 0xDD,
'\u{21D2}' => 0xDE,
'\u{21D3}' => 0xDF,
'\u{25CA}' => 0xE0,
'\u{2329}' | '\u{27E8}' => 0xE1, // angle bracket left
'\u{2211}' => 0xE5,
'\u{232A}' | '\u{27E9}' => 0xF1, // angle bracket right
'\u{222B}' => 0xF2,
_ => return None,
})
}
/// Advance widths in 1/1000 em for codes 0x20..=0xFF (0 where Symbol has no glyph).
const WIDTHS: [u16; 224] = [
// 0x20
250, 333, 713, 500, 549, 833, 778, 439, 333, 333, 500, 549, 250, 549, 250, 278, //
// 0x30
500, 500, 500, 500, 500, 500, 500, 500, 500, 500, 278, 278, 549, 549, 549, 444, //
// 0x40
549, 722, 667, 722, 612, 611, 763, 603, 722, 333, 631, 722, 686, 889, 722, 722, //
// 0x50
768, 741, 556, 592, 611, 690, 439, 768, 645, 795, 611, 333, 863, 333, 658, 500, //
// 0x60
500, 631, 549, 549, 494, 439, 521, 411, 603, 329, 603, 549, 549, 576, 521, 549, //
// 0x70
549, 521, 549, 603, 439, 576, 713, 686, 493, 686, 494, 480, 200, 480, 549, 0, //
// 0x80
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //
// 0x90
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, //
// 0xA0
750, 620, 247, 549, 167, 713, 500, 753, 753, 753, 753, 1042, 987, 603, 987, 603, //
// 0xB0
400, 549, 411, 549, 549, 713, 494, 460, 549, 549, 549, 549, 1000, 603, 1000, 658, //
// 0xC0
823, 686, 795, 987, 768, 768, 823, 768, 768, 713, 713, 713, 713, 713, 713, 713, //
// 0xD0
768, 713, 790, 790, 890, 823, 549, 250, 713, 603, 603, 1042, 987, 603, 987, 603, //
// 0xE0
494, 329, 790, 790, 786, 713, 384, 384, 384, 384, 384, 384, 494, 494, 494, 494, //
// 0xF0
0, 329, 274, 686, 686, 686, 384, 384, 384, 384, 384, 384, 494, 494, 494, 0, //
];
/// The advance width of code `code` in 1/1000 em, or `None` for an empty slot.
pub fn symbol_width(code: u8) -> Option<u16> {
let w = *WIDTHS.get(usize::from(code).checked_sub(0x20)?)?;
(w > 0).then_some(w)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn maths_symbols_are_encoded_with_their_widths() {
for (c, code, w) in [
('α', 0x61, 631),
('π', 0x70, 549),
('Σ', 0x53, 592),
('∑', 0xE5, 713),
('∫', 0xF2, 274),
('√', 0xD6, 549),
('≤', 0xA3, 549),
('≥', 0xB3, 549),
('±', 0xB1, 549),
('×', 0xB4, 549),
('÷', 0xB8, 549),
('∞', 0xA5, 713),
('∂', 0xB6, 494),
('−', 0x2D, 549),
('=', 0x3D, 549),
('2', 0x32, 500),
] {
assert_eq!(symbol_code(c), Some(code), "{c}");
assert_eq!(symbol_width(code), Some(w), "{c}");
}
assert_eq!(symbol_code('A'), None, "Latin letters belong to the text faces");
assert_eq!(symbol_code('ℝ'), None);
assert_eq!(symbol_width(0x7F), None);
assert_eq!(symbol_width(0x10), None);
assert_eq!(symbol_width(0xFF), None);
}
#[test]
fn every_encoded_character_has_a_width() {
for u in 0..0x3000u32 {
if let Some(code) = char::from_u32(u).and_then(symbol_code) {
assert!(symbol_width(code).is_some(), "U+{u:04X} -> {code:#x}");
}
}
}
}
+18
View File
@@ -332,3 +332,21 @@ fn cjk_text_is_not_replaced_by_question_marks() -> Result<(), Box<dyn Error>> {
assert_eq!(text_width(Font::Helvetica, 10.0, "签字"), 20.0);
Ok(())
}
#[test]
fn symbol_font_draws_maths_in_its_own_encoding() {
let mut doc = PdfDoc::new();
let p = doc.add_page(200.0, 200.0);
p.text(10.0, 50.0, 12.0, Font::Symbol, [0, 0, 0], "\u{2211}\u{03B1}\u{2264}\u{221E}x");
p.text(10.0, 80.0, 12.0, Font::TimesItalic, [0, 0, 0], "x");
let pdf = doc.finish();
let s = String::from_utf8_lossy(&pdf);
assert!(s.contains("/BaseFont /Symbol >>"), "Symbol has no WinAnsi encoding");
assert!(s.contains("/BaseFont /Times-Italic /Encoding /WinAnsiEncoding"));
// The sum, alpha, less-or-equal and infinity codes; x is not in Symbol and becomes `?`.
assert!(s.contains("(\\345a\\243\\245?) Tj"), "{s}");
check_xref(&pdf).unwrap();
assert!(Font::Symbol.can_encode('\u{2211}') && !Font::Symbol.can_encode('x'));
assert!(Font::TimesRoman.can_encode('x') && !Font::TimesRoman.can_encode('\u{2211}'));
assert_eq!(text_width(Font::Symbol, 10.0, "\u{2211}"), 7.13);
}
+8 -3
View File
@@ -1,5 +1,5 @@
//! Floating objects on the sheet: charts (rendered by `gridcraft-chart` into primitives that
//! we paint with egui), pictures, shapes and slicers.
//! we paint with egui), pictures, shapes, equations and slicers.
use egui::epaint::{PathShape, PathStroke};
use egui::{Align2, Color32, FontId, Painter, Pos2, Rect, Stroke, StrokeKind, pos2, vec2};
@@ -332,6 +332,10 @@ pub(crate) fn paint_objects(app: &SheetApp, p: &Painter, geo: &Geo, wb: &Workboo
}
label = false;
}
ShapeKind::Equation => {
crate::equation::paint_shape(p, r, sp, wb, geo.z);
label = false;
}
ShapeKind::Line | ShapeKind::Arrow => {
p.line_segment([r.left_top(), r.right_bottom()], Stroke::new(2.0, line));
if sp.kind == ShapeKind::Arrow {
@@ -519,7 +523,7 @@ pub fn selected_handle(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option
}
/// The topmost object under `p` and its rect: slicers paint on top, so they are hit first, then
/// charts, pictures and shapes, each from the top of its stack. Nothing is hit outside the cell
/// charts, pictures and shapes (equations included), each from the top of its stack. Nothing is hit outside the cell
/// area or while objects are hidden.
pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Rect)> {
if !geo.cells.contains(p) || app.session.active().is_some_and(|d| objects_hidden(&d.wb)) {
@@ -546,7 +550,8 @@ pub fn hit(app: &SheetApp, geo: &Geo, sh: &Sheet, p: Pos2) -> Option<(ObjRef, Re
for s in sh.shapes.iter().rev() {
let r = anchor_rect(geo, sh, &s.anchor);
if r.expand(3.0).contains(p) {
return Some((ObjRef::new(ObjKind::Shape, s.id), r));
let kind = if s.kind == ShapeKind::Equation { ObjKind::Equation } else { ObjKind::Shape };
return Some((ObjRef::new(kind, s.id), r));
}
}
None
+49 -1
View File
@@ -226,6 +226,45 @@ pub fn layout_job(text: &str, lang: Lang, font: &FontId, plain: egui::Color32, s
job
}
/// Underlines the byte range `mark` of `job` in `color` (an error's position), splitting the
/// sections it crosses. An empty range marks the character before it (the end of the text, for
/// an error about missing input). A range past the end of the text is clamped to it, and its
/// ends are moved out to char boundaries: the mark may come from an earlier version of the text
/// (the equation editor computes it before this frame's edit), and egui slices the text at every
/// section boundary.
pub fn underline(job: &mut egui::text::LayoutJob, mark: Range<usize>, color: egui::Color32) {
let text = job.text.as_str();
let floor = |i: usize| (0..=i.min(text.len())).rev().find(|&b| text.is_char_boundary(b)).unwrap_or(0);
let ceil = |i: usize| (i.min(text.len())..=text.len()).find(|&b| text.is_char_boundary(b)).unwrap_or(text.len());
let start = floor(mark.start);
let mut mark = start..ceil(mark.end).max(start);
if mark.is_empty() {
let start = text.get(..mark.start).and_then(|t| t.char_indices().next_back()).map_or(0, |(i, _)| i);
mark = start..mark.start.max(start + text.get(start..).and_then(|t| t.chars().next()).map_or(0, char::len_utf8));
}
let mut out = Vec::with_capacity(job.sections.len() + 2);
for sec in job.sections.drain(..) {
let r = sec.byte_range.start.0..sec.byte_range.end.0;
let (a, b) = (mark.start.clamp(r.start, r.end), mark.end.clamp(r.start, r.end));
for (piece, marked) in [(r.start..a, false), (a..b, true), (b..r.end, false)] {
if piece.is_empty() {
continue;
}
let mut s = sec.clone();
s.byte_range = egui::text::ByteIndex(piece.start)..egui::text::ByteIndex(piece.end);
if marked {
s.format.underline = egui::Stroke::new(1.5, color);
s.format.color = color;
}
if s.byte_range.start.0 != r.start {
s.leading_space = 0.0;
}
out.push(s);
}
}
job.sections = out;
}
fn byte_of(text: &str, char_index: usize) -> usize {
text.char_indices().nth(char_index).map(|(b, _)| b).unwrap_or(text.len())
}
@@ -501,6 +540,11 @@ pub fn indent(text: &mut String, sel: (usize, usize), dedent: bool) -> Edit {
/// The code editor. `max_lines` rows are shown before it scrolls; the scroll area follows the
/// caret.
pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize) -> Response {
code_editor_marked(ui, id, text, lang, max_lines, None)
}
/// [`code_editor`] with the byte range `mark` underlined in the error colour (see [`underline`]).
pub fn code_editor_marked(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max_lines: usize, mark: Option<Range<usize>>) -> Response {
let t = Tokens::get(ui.ctx());
let font = font();
let row_h = ui.ctx().fonts_mut(|f| f.row_height(&font));
@@ -534,8 +578,12 @@ pub fn code_editor(ui: &mut Ui, id: egui::Id, text: &mut String, lang: Lang, max
}
let syntax = t.syntax;
let plain = t.text;
let danger = t.danger;
let mut layouter = |ui: &Ui, buf: &dyn egui::TextBuffer, wrap_width: f32| {
let job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width);
let mut job = layout_job(buf.as_str(), lang, &self::font(), plain, &syntax, wrap_width);
if let Some(m) = mark.clone() {
underline(&mut job, m, danger);
}
ui.fonts_mut(|f| f.layout_job(job))
};
let frame_h = row_h * max_lines as f32 + 8.0;
+58
View File
@@ -25,6 +25,64 @@ fn python_tokens() {
assert_eq!(spans("hello", Lang::Plain), vec![]);
}
#[test]
fn underline_splits_sections_at_the_mark() {
let t = Tokens::light();
let font = FontId::new(12.0, FontFamily::Monospace);
let marked = |job: &egui::text::LayoutJob| -> Vec<String> {
job.sections
.iter()
.filter(|s| s.format.underline.width > 0.0)
.map(|s| job.text[s.byte_range.start.0..s.byte_range.end.0].to_string())
.collect()
};
let mut job = layout_job("a + \\foo", Lang::Latex, &font, t.text, &t.syntax, 100.0);
underline(&mut job, 4..8, t.danger);
assert_eq!(marked(&job), vec!["\\foo"]);
let total: usize = job.sections.iter().map(|s| s.byte_range.end.0 - s.byte_range.start.0).sum();
assert_eq!(total, job.text.len(), "sections still cover the text");
// An empty mark at the end underlines the last character; out-of-range marks are clamped.
let mut job = layout_job("x^", Lang::Latex, &font, t.text, &t.syntax, 100.0);
underline(&mut job, 2..2, t.danger);
assert_eq!(marked(&job), vec!["^"]);
let mut job = layout_job("\u{e9}", Lang::Latex, &font, t.text, &t.syntax, 100.0);
underline(&mut job, 50..99, t.danger);
assert!(job.sections.iter().any(|s| s.format.underline.width > 0.0));
}
#[test]
fn a_stale_mark_inside_a_multibyte_char_snaps_to_char_boundaries() {
// The editor's mark comes from the text before this frame's edit: `a\foo` had its error at
// 1..5, then an `é` (two bytes) was typed at the start, so byte 1 is inside it.
let t = Tokens::light();
let font = FontId::new(12.0, FontFamily::Monospace);
let ctx = egui::Context::default();
for text in ["\u{e9}\\foo", "\u{e9}\u{e9}\u{4e2d}x", "\u{1F600}", "a\u{e9}"] {
for start in 0..=text.len() + 1 {
for end in 0..=text.len() + 1 {
let mut job = layout_job(text, Lang::Latex, &font, t.text, &t.syntax, 200.0);
underline(&mut job, start..end, t.danger);
for s in &job.sections {
let r = s.byte_range.start.0..s.byte_range.end.0;
assert!(text.is_char_boundary(r.start) && text.is_char_boundary(r.end), "{text:?} {start}..{end}: {r:?}");
}
let total: usize = job.sections.iter().map(|s| s.byte_range.end.0 - s.byte_range.start.0).sum();
assert_eq!(total, text.len(), "sections still cover {text:?}");
// Laying the job out slices the text at every section: it must not panic.
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
let _ = ui.fonts_mut(|f| f.layout_job(job.clone()));
});
out.textures_delta.clear();
}
}
}
let mut job = layout_job("\u{e9}\\foo", Lang::Latex, &font, t.text, &t.syntax, 200.0);
underline(&mut job, 1..5, t.danger);
let marked: Vec<&str> =
job.sections.iter().filter(|s| s.format.underline.width > 0.0).map(|s| &job.text[s.byte_range.start.0..s.byte_range.end.0]).collect();
assert_eq!(marked.concat(), "\u{e9}\\fo", "the mark grows out to the whole characters it touches");
}
#[test]
fn latex_tokens() {
let s = spans("\\frac{a^2}{3} % half\n\\{", Lang::Latex);
+2 -1
View File
@@ -69,7 +69,7 @@ pub struct Dialog {
picture_target: Option<(u64, usize)>,
/// The dialog this one was opened from (Name Manager › New…): it comes back when this closes.
back: Option<Box<Dialog>>,
focus_initialized: bool,
pub(crate) focus_initialized: bool,
/// The formula box that takes references clicked or dragged on the grid (New Formatting Rule).
pick: Option<RefPick>,
/// The linked cell's initial display text; unchanged text must not replace a number or formula.
@@ -627,6 +627,7 @@ impl Dialog {
}
d
}
"equation" => return crate::equation::open_editor(app, &params),
"goalSeek" => Dialog::custom("goalSeek", "Goal Seek", json!({"set": active, "to": "0", "changing": ""})),
"forecastSheet" => Dialog::form(
"forecastSheet",
+2 -1
View File
@@ -148,7 +148,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
let width = match d.name.as_str() {
"formatCells" => 560.0,
"about" => 660.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" => 520.0,
"insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "equation" => 520.0,
"macros" => 480.0,
"recommendedCharts" => 600.0,
"analysisToolPak" => 420.0,
@@ -189,6 +189,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) {
"selectData" => select_data(app, ui, &mut d, &mut confirm, &mut open),
"commandSearch" => command_search(app, ui, &mut d, &mut confirm),
"goalSeek" => goal_seek(app, ui, &mut d, &mut confirm),
"equation" => crate::equation::editor(app, ui, &mut d, &mut confirm, &mut open),
"pasteSpecial" => paste_special(app, ui, &mut d, &mut confirm, &mut open),
"analysisToolPak" => analysis_toolpak(app, ui, &mut d, &mut confirm, &mut open),
"solver" => solver(app, ui, &mut d, &mut confirm, &mut open),
+533
View File
@@ -0,0 +1,533 @@
//! Equations: painting a laid-out [`MathList`] with egui.
//!
//! Painting only ever reads the layout cached on the shape (`Shape::math`); it never lays an
//! equation out, so nothing here can reach the layout engine. Glyphs are drawn with the system's
//! maths fonts (`theme::MATH*`); a glyph no font has becomes a `[?]` box the size of its TeX box.
use std::sync::Arc;
use egui::epaint::{Mesh, PathShape, PathStroke};
use egui::text::{CCursor, CCursorRange};
use egui::{Color32, FontFamily, FontId, Key, Painter, Pos2, Rect, Stroke, StrokeKind, TextEdit, pos2, vec2};
use gridcraft_engine::math::{Face, Item, MathError, MathList, PathCmd};
use gridcraft_engine::model::{Shape, ShapeKind, Workbook};
use serde_json::{Value as Json, json};
use crate::SheetApp;
use crate::code_editor::{Lang, code_editor_marked};
use crate::dialogs::Dialog;
use crate::theme::{self, Tokens};
/// Most points one outline may have; longer ones are stroked instead of filled.
const MAX_FILL_POINTS: usize = 4000;
fn family(face: Face) -> FontFamily {
FontFamily::Name(
match face {
Face::Roman | Face::Large(_) => theme::MATH,
Face::Italic => theme::MATH_ITALIC,
Face::Bold | Face::BoldItalic => theme::MATH_BOLD,
Face::Sans => theme::CELL,
Face::SansBold => theme::CELL_BOLD,
Face::SansItalic => theme::CELL_ITALIC,
Face::Mono => theme::MONO,
}
.into(),
)
}
// ---------------------------------------------------------------- editor
/// The editor's quick-insert buttons: (label, LaTeX, tooltip). The caret lands in the first `{}`.
pub const QUICK: &[(&str, &str, &str)] = &[
("a/b", "\\frac{}{}", "Fraction"),
("\u{221A}x", "\\sqrt{}", "Square root"),
("x^n", "^{}", "Superscript"),
("x_i", "_{}", "Subscript"),
("\u{2211}", "\\sum_{i=1}^{n} ", "Summation"),
("\u{222B}", "\\int_{a}^{b} ", "Integral"),
("lim", "\\lim_{x \\to {}} ", "Limit"),
("( )", "\\left( {} \\right)", "Brackets that grow"),
("[ ]", "\\begin{bmatrix} {} & b \\\\ c & d \\end{bmatrix}", "Matrix"),
("\u{03B1}", "\\alpha ", "alpha"),
("\u{03B2}", "\\beta ", "beta"),
("\u{03B8}", "\\theta ", "theta"),
("\u{03C0}", "\\pi ", "pi"),
("\u{0394}", "\\Delta ", "Delta"),
("\u{00B1}", "\\pm ", "Plus or minus"),
("\u{00D7}", "\\times ", "Times"),
("\u{00F7}", "\\div ", "Divided by"),
("\u{2264}", "\\le ", "Less than or equal to"),
("\u{2265}", "\\ge ", "Greater than or equal to"),
("\u{2260}", "\\ne ", "Not equal to"),
("\u{221E}", "\\infty ", "Infinity"),
("\u{2192}", "\\to ", "Tends to"),
];
/// The editor dialog: a new equation at the active cell, or `{id}` to edit one.
pub fn open_editor(app: &SheetApp, params: &Json) -> Option<Dialog> {
let id = params.get("id").and_then(Json::as_u64);
let mut d = match id {
Some(id) => {
let source = app
.session
.active()
.and_then(|doc| doc.wb.active())
.and_then(|sh| sh.shapes.iter().find(|s| s.id as u64 == id && s.kind == ShapeKind::Equation))
.map(|s| s.text.clone())?;
let mut d = Dialog::custom("equation", "Edit Equation", json!({"id": id, "latex": source}));
d.command = Some("equation.edit");
d
}
None => {
let at = app.session.active().map(|doc| doc.selection.active.a1()).unwrap_or_default();
let latex = params.get("latex").and_then(Json::as_str).unwrap_or("");
let mut d = Dialog::custom("equation", "Insert Equation", json!({"at": at, "latex": latex}));
d.command = Some("insert.equation");
d
}
};
d.result = None;
Some(d)
}
/// The preview's layout, kept in egui memory and rebuilt only when the source changes.
#[derive(Clone)]
struct Preview {
source: String,
result: Result<Arc<MathList>, MathError>,
}
/// The source laid out (guarded, capped) for the preview; the editor is one of the two places,
/// with commands, that may lay equations out.
fn preview(ctx: &egui::Context, id: egui::Id, source: &str) -> Preview {
if let Some(p) = ctx.data(|d| d.get_temp::<Preview>(id)).filter(|p| p.source == source) {
return p;
}
let p = Preview { source: source.to_string(), result: gridcraft_engine::math::layout(source).map(Arc::new) };
ctx.data_mut(|d| d.insert_temp(id, p.clone()));
p
}
/// Inserts `snippet` at the editor's caret (replacing a selection) and puts the caret in its
/// first `{}`.
fn insert_snippet(ctx: &egui::Context, id: egui::Id, text: &mut String, snippet: &str) {
let mut state = TextEdit::load_state(ctx, id).unwrap_or_default();
let count = text.chars().count();
let (a, b) = state
.cursor
.char_range()
.map(|r| -> (usize, usize) { (r.primary.index.into(), r.secondary.index.into()) })
.map_or((count, count), |(p, q)| (p.min(q), p.max(q)));
let byte = |c: usize| text.char_indices().nth(c).map_or(text.len(), |(i, _)| i);
let (ba, bb) = (byte(a.min(count)), byte(b.min(count)));
text.replace_range(ba..bb, snippet);
let caret = a + snippet.find("{}").map_or(snippet.chars().count(), |i| snippet.get(..i).map_or(0, |s| s.chars().count()) + 1);
state.cursor.set_char_range(Some(CCursorRange::one(CCursor::new(caret))));
state.store(ctx, id);
// Give the source the focus back once it has been drawn this frame.
ctx.data_mut(|m| m.insert_temp(id.with("refocus"), true));
}
/// The editor's body: quick-insert buttons, the LaTeX source with its error underlined, a live
/// preview on a sheet-coloured card, and OK/Cancel.
pub fn editor(app: &mut SheetApp, ui: &mut egui::Ui, d: &mut Dialog, confirm: &mut bool, open: &mut bool) {
let _ = app;
let t = Tokens::get(ui.ctx());
let edit_id = ui.scope_id().with("equation-source");
let mut text = d.values.get("latex").and_then(Json::as_str).unwrap_or("").to_string();
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 3.0);
for (label, snippet, tip) in QUICK {
let b = ui.add(egui::Button::new(*label).min_size(vec2(30.0, 22.0))).on_hover_text(*tip);
if b.clicked() {
insert_snippet(ui.ctx(), edit_id, &mut text, snippet);
}
}
});
ui.add_space(6.0);
ui.label("LaTeX:");
let pv = preview(ui.ctx(), ui.scope_id().with("equation-preview"), &text);
let mark = pv.result.as_ref().err().and_then(|e| e.span.clone()).or_else(|| pv.result.as_ref().err().map(|_| text.len()..text.len()));
let mark = mark.filter(|_| !text.trim().is_empty());
let r = code_editor_marked(ui, edit_id, &mut text, Lang::Latex, 6, mark);
// Focus the source when the dialog opens and after a quick-insert button. The "opened" flag
// lives on the dialog, so every way of closing it (OK, Cancel, Escape, the close button)
// leaves the next one to focus its source again.
let refocus = ui.ctx().data_mut(|m| m.remove_temp::<bool>(edit_id.with("refocus")).unwrap_or(false));
if refocus || !d.focus_initialized {
r.request_focus();
d.focus_initialized = true;
}
let pv = preview(ui.ctx(), ui.scope_id().with("equation-preview"), &text);
d.values.insert("latex".into(), json!(text));
ui.add_space(4.0);
match &pv.result {
Err(e) if !text.trim().is_empty() => {
let msg = match e.char_pos(&text) {
Some(p) => format!("Error at character {p}: {}", e.message),
None => e.message.clone(),
};
ui.colored_label(t.danger, msg);
}
_ => {
ui.label(egui::RichText::new("Preview").color(t.text_dim));
}
}
// The preview card is the sheet's colour, with the sheet's ink, in both themes.
let (card, _) = ui.allocate_exact_size(vec2(ui.available_width(), 110.0), egui::Sense::hover());
ui.painter().rect_filled(card, 4.0, t.grid_bg);
ui.painter().rect_stroke(card, 4.0, Stroke::new(1.0, t.input_border), StrokeKind::Inside);
match &pv.result {
Ok(list) if !list.items.is_empty() => {
// Fit, but never larger than 24 pt.
let s = fit(list, card, 10.0).min(24.0 * 96.0 / 72.0);
let o = card.center() - vec2(list.width, list.total_height()) * s / 2.0;
paint_at(&ui.painter().with_clip_rect(card), o, list, t.cell_text, s);
}
_ => {
let hint = if text.trim().is_empty() { "Type an equation in LaTeX, such as x^2 + y^2 = r^2" } else { "" };
ui.painter().text(card.center(), egui::Align2::CENTER_CENTER, hint, theme::ui_font(12.0), t.sheet_control_text);
}
}
ui.add_space(8.0);
let valid = pv.result.as_ref().is_ok_and(|l| !l.items.is_empty());
let cmd_enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::COMMAND, Key::Enter));
ui.allocate_ui_with_layout(vec2(ui.available_width(), 28.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
let ok = ui.add_enabled(valid, egui::Button::new(egui::RichText::new("OK").color(t.on_accent)).fill(t.accent).min_size(vec2(64.0, 0.0)));
if ok.clicked() || (valid && cmd_enter) {
*confirm = true;
}
if ui.add(egui::Button::new("Cancel").min_size(vec2(64.0, 0.0))).clicked() {
*open = false;
}
let verb = if d.command == Some("equation.edit") { "apply" } else { "insert" };
ui.label(egui::RichText::new(format!("\u{2318}/Ctrl+Enter to {verb}")).small().color(t.text_dim));
});
}
/// The gallery's layouts, made once (guarded, like every layout) the first time the menu opens.
fn gallery_layouts() -> &'static [(&'static str, &'static str, Option<MathList>)] {
static G: std::sync::OnceLock<Vec<(&'static str, &'static str, Option<MathList>)>> = std::sync::OnceLock::new();
G.get_or_init(|| gridcraft_engine::math::GALLERY.iter().map(|(name, src)| (*name, *src, gridcraft_engine::math::layout(src).ok())).collect())
}
/// The gallery as menu rows: a name over a preview on a sheet-coloured card. Returns the source
/// of the row clicked.
pub fn gallery(ui: &mut egui::Ui) -> Option<&'static str> {
let t = Tokens::get(ui.ctx());
let mut picked = None;
for (name, src, list) in gallery_layouts() {
let (r, resp) = ui.allocate_exact_size(vec2(290.0, 62.0), egui::Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, 4.0, t.hover);
}
ui.painter().text(r.left_top() + vec2(6.0, 4.0), egui::Align2::LEFT_TOP, *name, theme::ui_font(12.0), t.text);
let card = Rect::from_min_max(r.left_top() + vec2(6.0, 21.0), r.right_bottom() - vec2(6.0, 3.0));
ui.painter().rect_filled(card, 3.0, t.grid_bg);
if let Some(list) = list {
let s = fit(list, card, 3.0).min(16.0);
paint_at(&ui.painter().with_clip_rect(card), card.center() - vec2(list.width, list.total_height()) * s / 2.0, list, t.cell_text, s);
}
if resp.on_hover_text(*src).clicked() {
picked = Some(*src);
}
}
picked
}
/// Draws an equation shape in its frame `r` (screen pixels, zoom `z`).
pub fn paint_shape(p: &Painter, r: Rect, sp: &Shape, wb: &Workbook, z: f32) {
let t = Tokens::get(p.ctx());
let ink = sp.line.resolve(&wb.theme).map(theme::color32).unwrap_or(t.cell_text);
let p = p.with_clip_rect(r.intersect(p.clip_rect()));
match sp.math.get(&sp.text) {
Some(Ok(list)) => {
paint_list(&p, r, list, ink, gridcraft_engine::cmd::equation::PADDING * z);
}
// No layout (a source that doesn't parse, from a file): show the source in a dashed
// frame so it can be found and fixed.
other => {
let font = FontId::new(13.0 * z, FontFamily::Name(theme::MONO.into()));
let source = gridcraft_engine::cmd::equation::source_excerpt(&sp.text).to_owned();
let g = p.layout(source, font, t.cell_text, (r.width() - 8.0 * z).max(1.0));
p.galley(r.min + vec2(4.0, 4.0) * z, g, t.cell_text);
if matches!(other, Some(Err(_))) {
let dash = egui::Shape::dashed_line(
&[r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()],
Stroke::new(1.0, t.sheet_control_border),
4.0,
3.0,
);
p.extend(dash);
}
}
}
}
/// The scale (screen pixels per em) at which `list` fits `r` inside `pad`.
pub fn fit(list: &MathList, r: Rect, pad: f32) -> f32 {
let (w, h) = (list.width.max(0.01), list.total_height().max(0.01));
let s = ((r.width() - 2.0 * pad) / w).min((r.height() - 2.0 * pad) / h);
if s.is_finite() { s.clamp(0.0, 4000.0) } else { 0.0 }
}
/// Draws `list` centred in `r` at the largest scale that fits inside `pad`. Returns the scale.
pub fn paint_list(p: &Painter, r: Rect, list: &MathList, ink: Color32, pad: f32) -> f32 {
let s = fit(list, r, pad);
paint_at(p, r.center() - vec2(list.width, list.total_height()) * s / 2.0, list, ink, s);
s
}
/// Draws `list` with its box's top-left at `o`, at `s` screen pixels per em.
pub fn paint_at(p: &Painter, o: Pos2, list: &MathList, ink: Color32, s: f32) {
if s < 0.05 {
return;
}
let col = |c: Option<[u8; 3]>| c.map_or(ink, |[r, g, b]| Color32::from_rgb(r, g, b));
for item in &list.items {
match item {
Item::Glyph(g) => {
let color = col(g.color);
let (x, baseline) = (o.x + g.x * s, o.y + g.y * s);
let large = matches!(g.face, Face::Large(_));
// A large operator or delimiter is drawn at the height of its TeX box.
let px = if large { (g.height + g.depth) * s } else { g.size * s }.clamp(0.0, 4000.0);
if px < 1.0 {
continue;
}
let font = FontId::new(px, family(g.face));
let ch = if renders(p, &font, g.ch) { Some(g.ch) } else { g.alt.filter(|a| renders(p, &font, *a)) };
let Some(ch) = ch else {
unknown_box(
p,
Rect::from_min_max(pos2(x, baseline - g.height * s), pos2(x + g.width.max(0.3) * s, baseline + g.depth * s)),
color,
);
continue;
};
let galley = p.layout_no_wrap(ch.to_string(), font, color);
let ascent = galley.rows.first().and_then(|row| row.glyphs.first().map(|gl| row.pos.y + gl.pos.y)).unwrap_or(px * 0.8);
let (gx, gy) = if large {
// Centre the glyph on its box: maths fonts centre these on the maths axis,
// about a quarter em above the baseline.
let mid = o.y + (g.y - (g.height - g.depth) / 2.0) * s;
(x + (g.width * s - galley.size().x) / 2.0, mid + 0.25 * px - ascent)
} else {
(x, baseline - ascent)
};
p.galley(pos2(gx, gy), galley, color);
}
Item::Rule(rule) => {
let h = if rule.height > 0.0 { (rule.height * s).max(1.0) } else { 0.0 };
let w = if rule.width > 0.0 { (rule.width * s).max(1.0) } else { 0.0 };
if w > 0.0 && h > 0.0 {
p.rect_filled(Rect::from_min_size(pos2(o.x + rule.x * s, o.y + rule.y * s), vec2(w, h)), 0.0, col(rule.color));
}
}
Item::Path(path) => {
let at = pos2(o.x + path.x * s, o.y + path.y * s);
let color = col(path.color);
for sub in flatten(&path.commands, at, s) {
if path.fill && sub.len() >= 3 && sub.len() <= MAX_FILL_POINTS {
p.add(fill_polygon(&sub, color));
} else if sub.len() >= 2 {
p.add(PathShape::line(sub, PathStroke::new(1.0, color)));
}
}
}
}
}
}
/// Whether some face in `font`'s family draws `c` (rather than the replacement glyph).
///
/// `Fonts::has_glyph` alone reports a false negative for every character of the face that also
/// supplies the replacement glyph (a maths font usually has `◻`), so a "no" is double-checked by
/// comparing the glyph's atlas slot with the replacement glyph's.
pub fn renders(p: &Painter, font: &FontId, c: char) -> bool {
if p.ctx().fonts_mut(|f| f.has_glyph(font, c)) {
return true;
}
let uv = |c: char| {
let g = p.layout_no_wrap(c.to_string(), font.clone(), Color32::WHITE);
g.rows.first().and_then(|r| r.glyphs.first().map(|gl| gl.uv_rect))
};
let Some(mine) = uv(c) else { return false };
if mine.is_nothing() {
return false;
}
['\u{25FB}', '?'].iter().all(|r| *r == c || uv(*r) != Some(mine))
}
/// A glyph no font has: a box the size of its TeX box with a question mark.
fn unknown_box(p: &Painter, r: Rect, color: Color32) {
if r.width() < 4.0 || r.height() < 4.0 {
p.rect_filled(r, 0.0, color.gamma_multiply(0.4));
return;
}
p.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, color), StrokeKind::Inside);
let size = (r.height() * 0.6).min(r.width() * 0.9).max(4.0);
p.text(r.center(), egui::Align2::CENTER_CENTER, "?", FontId::new(size, FontFamily::Name(theme::CELL.into())), color);
}
/// The outline as screen-space polygons, one per subpath, with curves as short lines.
fn flatten(cmds: &[PathCmd], at: Pos2, s: f32) -> Vec<Vec<Pos2>> {
let pt = |x: f32, y: f32| pos2(at.x + x * s, at.y + y * s);
let mut out: Vec<Vec<Pos2>> = Vec::new();
let mut cur: Vec<Pos2> = Vec::new();
for c in cmds {
match *c {
PathCmd::MoveTo(x, y) => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
cur.clear();
cur.push(pt(x, y));
}
PathCmd::LineTo(x, y) => cur.push(pt(x, y)),
PathCmd::QuadTo(x1, y1, x, y) => {
let p0 = cur.last().copied().unwrap_or(pt(x1, y1));
let (c1, p1) = (pt(x1, y1), pt(x, y));
for k in 1..=8 {
let t = k as f32 / 8.0;
let u = 1.0 - t;
cur.push(pos2(u * u * p0.x + 2.0 * u * t * c1.x + t * t * p1.x, u * u * p0.y + 2.0 * u * t * c1.y + t * t * p1.y));
}
}
PathCmd::CubicTo(x1, y1, x2, y2, x, y) => {
let p0 = cur.last().copied().unwrap_or(pt(x1, y1));
let (c1, c2, p1) = (pt(x1, y1), pt(x2, y2), pt(x, y));
for k in 1..=10 {
let t = k as f32 / 10.0;
let u = 1.0 - t;
let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
cur.push(pos2(a * p0.x + b * c1.x + c * c2.x + d * p1.x, a * p0.y + b * c1.y + c * c2.y + d * p1.y));
}
}
PathCmd::Close => {
if cur.len() > 1 {
out.push(std::mem::take(&mut cur));
}
}
}
}
if cur.len() > 1 {
out.push(cur);
}
out
}
/// A filled simple polygon (convex or not), triangulated by ear clipping.
fn fill_polygon(pts: &[Pos2], color: Color32) -> Mesh {
let mut mesh = Mesh::default();
let mut pts: Vec<Pos2> = pts.to_vec();
pts.dedup_by(|a, b| (*a - *b).length_sq() < 1e-6);
if pts.len() > 2 && pts.first().zip(pts.last()).is_some_and(|(a, b)| (*a - *b).length_sq() < 1e-6) {
pts.pop();
}
for q in &pts {
mesh.colored_vertex(*q, color);
}
for [a, b, c] in triangulate(&pts) {
mesh.add_triangle(a as u32, b as u32, c as u32);
}
mesh
}
fn cross(o: Pos2, a: Pos2, b: Pos2) -> f32 {
(a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x)
}
/// Ear clipping of a simple polygon. A degenerate polygon that has no ear left is finished as a
/// fan, so the result is always a valid (if imperfect) set of triangles.
fn triangulate(pts: &[Pos2]) -> Vec<[usize; 3]> {
let n = pts.len();
if n < 3 {
return vec![];
}
let area: f32 =
(0..n).map(|i| cross(Pos2::ZERO, pts.get(i).copied().unwrap_or_default(), pts.get((i + 1) % n).copied().unwrap_or_default())).sum();
let ccw = area > 0.0;
let at = |i: usize| pts.get(i).copied().unwrap_or_default();
let mut idx: Vec<usize> = (0..n).collect();
let mut tris = Vec::with_capacity(n);
let mut guard = 0usize;
while idx.len() > 3 && guard < n * n {
guard += 1;
let m = idx.len();
let mut clipped = false;
for k in 0..m {
let (ia, ib, ic) =
(idx.get((k + m - 1) % m).copied().unwrap_or(0), idx.get(k).copied().unwrap_or(0), idx.get((k + 1) % m).copied().unwrap_or(0));
let (a, b, c) = (at(ia), at(ib), at(ic));
let turn = cross(a, b, c);
if (turn > 0.0) != ccw || turn.abs() < 1e-9 {
continue;
}
let inside = idx.iter().any(|&j| {
if j == ia || j == ib || j == ic {
return false;
}
let q = at(j);
let (d1, d2, d3) = (cross(a, b, q), cross(b, c, q), cross(c, a, q));
if ccw { d1 >= 0.0 && d2 >= 0.0 && d3 >= 0.0 } else { d1 <= 0.0 && d2 <= 0.0 && d3 <= 0.0 }
});
if inside {
continue;
}
tris.push([ia, ib, ic]);
idx.remove(k);
clipped = true;
break;
}
if !clipped {
break;
}
}
// Whatever is left (a triangle, or a degenerate remainder) as a fan.
if let Some((&first, rest)) = idx.split_first() {
for w in rest.windows(2) {
if let [b, c] = *w {
tris.push([first, b, c]);
}
}
}
tris
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ear_clipping_covers_a_concave_polygon() {
// An L shape: area 3 unit squares.
let l = [pos2(0.0, 0.0), pos2(2.0, 0.0), pos2(2.0, 1.0), pos2(1.0, 1.0), pos2(1.0, 2.0), pos2(0.0, 2.0)];
for pts in [l.to_vec(), l.iter().rev().copied().collect::<Vec<_>>()] {
let t = triangulate(&pts);
assert_eq!(t.len(), 4);
let area: f32 = t.iter().map(|[a, b, c]| cross(pts[*a], pts[*b], pts[*c]).abs() / 2.0).sum();
assert!((area - 3.0).abs() < 1e-4, "{area}");
}
assert!(triangulate(&[pos2(0.0, 0.0), pos2(1.0, 1.0)]).is_empty());
// Collinear points still come back as (degenerate) triangles rather than looping.
assert!(triangulate(&[pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(2.0, 0.0), pos2(3.0, 0.0)]).len() <= 2);
}
#[test]
fn curves_are_flattened_per_subpath() {
let cmds = [
PathCmd::MoveTo(0.0, 0.0),
PathCmd::QuadTo(1.0, 1.0, 2.0, 0.0),
PathCmd::Close,
PathCmd::MoveTo(5.0, 5.0),
PathCmd::CubicTo(6.0, 6.0, 7.0, 6.0, 8.0, 5.0),
];
let subs = flatten(&cmds, Pos2::ZERO, 10.0);
assert_eq!(subs.len(), 2);
assert_eq!(subs[0].len(), 9);
assert_eq!(subs[1].len(), 11);
assert_eq!(subs[1].last().copied(), Some(pos2(80.0, 50.0)));
}
}
+2
View File
@@ -105,6 +105,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Text do\nsloupců",
"Link" => "Odkaz",
"Symbol" => "Symbol",
"Equation" => "Rovnice",
"Insert New Equation..." => "Vložit novou rovnici",
"Sparklines" => "Minigrafy",
"Header & Footer" => "Záhlaví a zápatí",
"Draw with ink" => "Kreslit od ruky",
+2
View File
@@ -109,6 +109,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Text in\nSpalten",
"Link" => "Link",
"Symbol" => "Symbol",
"Equation" => "Formel",
"Insert New Equation..." => "Neue Formel einfügen",
"Sparklines" => "Sparklines",
"Header & Footer" => "Kopf- und Fußzeile",
"Draw with ink" => "Mit Freihand zeichnen",
+2
View File
@@ -104,6 +104,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Κείμενο σε\nστήλες",
"Link" => "Σύνδεση",
"Symbol" => "Σύμβολο",
"Equation" => "Εξίσωση",
"Insert New Equation..." => "Εισαγωγή νέας εξίσωσης",
"Sparklines" => "Μικρογραφήματα",
"Header & Footer" => "Κεφαλίδα και υποσέλιδο",
"Draw with ink" => "Σχεδίαση με μελάνι",
+2
View File
@@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Texto en\ncolumnas",
"Link" => "Vínculo",
"Symbol" => "Símbolo",
"Equation" => "Ecuación",
"Insert New Equation..." => "Insertar nueva ecuación",
"Sparklines" => "Minigráficos",
"Header & Footer" => "Encabezado y pie de página",
"Draw with ink" => "Dibujar con lápiz",
+2
View File
@@ -115,6 +115,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Convertir le texte\nen colonnes",
"Link" => "Lien",
"Symbol" => "Symbole",
"Equation" => "Équation",
"Insert New Equation..." => "Insérer une nouvelle équation",
"Sparklines" => "Courbes de tendance",
"Checkbox" => "Case à cocher",
"Insert checkboxes in the selected cells" => "Insérer des cases à cocher dans les cellules sélectionnées",
+2
View File
@@ -108,6 +108,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Testo in\ncolonne",
"Link" => "Collegamento",
"Symbol" => "Simbolo",
"Equation" => "Equazione",
"Insert New Equation..." => "Inserisci nuova equazione",
"Sparklines" => "Sparkline",
"Checkbox" => "Casella di controllo",
"Insert checkboxes in the selected cells" => "Inserisci caselle di controllo nelle celle selezionate",
+2
View File
@@ -112,6 +112,8 @@ pub(super) const CORE: &[(&str, &str)] = &[
("Text to\nColumns", "区切り位置"),
("Link", "リンク"),
("Symbol", "記号"),
("Equation", "数式"),
("Insert New Equation...", "新しい数式の挿入"),
("Sparklines", "スパークライン"),
("Checkbox", "チェックボックス"),
("Insert checkboxes in the selected cells", "選択したセルにチェックボックスを挿入"),
+2
View File
@@ -110,6 +110,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "텍스트\n나누기",
"Link" => "링크",
"Symbol" => "기호",
"Equation" => "수식",
"Insert New Equation..." => "새 수식 삽입",
"Sparklines" => "스파크라인",
"Checkbox" => "확인란",
"Insert checkboxes in the selected cells" => "선택한 셀에 확인란 삽입",
+2
View File
@@ -109,6 +109,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Tekst naar\nkolommen",
"Link" => "Link",
"Symbol" => "Symbool",
"Equation" => "Vergelijking",
"Insert New Equation..." => "Nieuwe vergelijking invoegen",
"Sparklines" => "Sparklines",
"Checkbox" => "Checkbox",
"Insert checkboxes in the selected cells" => "Checkboxes invoegen in de geselecteerde cellen",
+2
View File
@@ -104,6 +104,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Tekst jako\nkolumny",
"Link" => "Łącze",
"Symbol" => "Symbol",
"Equation" => "Równanie",
"Insert New Equation..." => "Wstaw nowe równanie",
"Sparklines" => "Wykresy przebiegu w czasie",
"Checkbox" => "Pole wyboru",
"Insert checkboxes in the selected cells" => "Wstaw pola wyboru w zaznaczonych komórkach",
+2
View File
@@ -107,6 +107,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Texto para\nColunas",
"Link" => "Vínculo",
"Symbol" => "Símbolo",
"Equation" => "Equação",
"Insert New Equation..." => "Inserir Nova Equação",
"Sparklines" => "Minigráficos",
"Checkbox" => "Caixa de Seleção",
"Insert checkboxes in the selected cells" => "Inserir caixas de seleção nas células selecionadas",
+2
View File
@@ -111,6 +111,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Text în\ncoloane",
"Link" => "Link",
"Symbol" => "Simbol",
"Equation" => "Ecuație",
"Insert New Equation..." => "Inserare ecuație nouă",
"Sparklines" => "Diagrame sparkline",
"Checkbox" => "Casetă de selectare",
"Insert checkboxes in the selected cells" => "Inserați casete de selectare în celulele selectate",
+2
View File
@@ -110,6 +110,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Текст\nпо столбцам",
"Link" => "Ссылка",
"Symbol" => "Символ",
"Equation" => "Уравнение",
"Insert New Equation..." => "Вставить новое уравнение",
"Sparklines" => "Спарклайны",
"Checkbox" => "Флажок",
"Insert checkboxes in the selected cells" => "Вставить флажки в выбранные ячейки",
+2
View File
@@ -111,6 +111,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Tekst u\nkolone",
"Link" => "Veza",
"Symbol" => "Simbol",
"Equation" => "Jednačina",
"Insert New Equation..." => "Umetni novu jednačinu",
"Sparklines" => "Mini grafikoni",
"Checkbox" => "Polje za potvrdu",
"Insert checkboxes in the selected cells" => "Umetni polja za potvrdu u izabrane ćelije",
+2
View File
@@ -107,6 +107,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Metni Sütunlara\nDönüştür",
"Link" => "Köprü",
"Symbol" => "Simge",
"Equation" => "Denklem",
"Insert New Equation..." => "Yeni Denklem Ekle",
"Sparklines" => "Mini Grafikler",
"Header & Footer" => "Üstbilgi ve Altbilgi",
"Draw with ink" => "Mürekkeple Çiz",
+2
View File
@@ -106,6 +106,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Текст\nу стовпці",
"Link" => "Посилання",
"Symbol" => "Символ",
"Equation" => "Рівняння",
"Insert New Equation..." => "Вставити нове рівняння",
"Sparklines" => "Спарклайни",
"Checkbox" => "Прапорець",
"Insert checkboxes in the selected cells" => "Вставити прапорці у виділені комірки",
+2
View File
@@ -112,6 +112,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "Tách\ncột",
"Link" => "Liên kết",
"Symbol" => "Ký hiệu",
"Equation" => "Phương trình",
"Insert New Equation..." => "Chèn phương trình mới",
"Sparklines" => "Biểu đồ thu nhỏ",
"Checkbox" => "Hộp kiểm",
"Insert checkboxes in the selected cells" => "Chèn hộp kiểm vào các ô đã chọn",
+2
View File
@@ -110,6 +110,8 @@ pub(super) fn translate(text: &str) -> Option<&'static str> {
"Text to\nColumns" => "分列",
"Link" => "链接",
"Symbol" => "符号",
"Equation" => "公式",
"Insert New Equation..." => "插入新公式",
"Sparklines" => "迷你图",
"Checkbox" => "复选框",
"Insert checkboxes in the selected cells" => "在所选单元格中插入复选框",
+6
View File
@@ -104,6 +104,8 @@ pub enum Icon {
Note,
TextBox,
Symbol,
/// An equation: a radical sign over an italic x.
Equation,
Sparkline,
Checkbox,
Function,
@@ -607,6 +609,10 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, c: Color32, pal: &Palette) {
g.text(8.0, 8.0, "A", 8.0, c, (false, 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);
g.text(11.0, 8.5, "x", 8.0, blue, (false, true));
}
Icon::Function => g.text(8.0, 8.0, "fx", 11.0, c, (false, true)),
Icon::Name => {
g.rect(1.5, 4.0, 13.0, 8.0, None, Some(c));
@@ -158,7 +158,7 @@ pub const SEQUENCES: &[(&str, Entry)] = &[
("NCM", Entry::Finish(Action::Dialog("comment"))), // Excel: Excel: New Comment (modern)
("NCB", Entry::Finish(Action::Command("insert.checkbox"))), // Excel: Checkbox
("ND", Entry::Finish(Action::Control("insert.chart.scatter"))), // Excel: Scatter Chart; Scatter (kind TBD)
("NE", Entry::Finish(Action::Pending("insert.equation"))), // pending; Excel: Equations; Equations; reserved until the command lands
("NE", Entry::Finish(Action::Dialog("equation"))), // Excel: Equation; the Equation split button's main half opens the editor
("NF", Entry::Continue), // Excel: Online Pictures; group level: containment(Picture)
("NFP", Entry::Continue), // Excel: From Picture Menu; group level: containment(Picture)
("NFPF", Entry::Finish(Action::Command("insert.picture"))), // Excel: Picture From File; containment(Picture)
+4 -5
View File
@@ -1081,11 +1081,10 @@
},
{
"seq": "NE",
"kind": "command",
"target": "insert.equation",
"pending": true,
"excel": "Equations",
"note": "Equations; reserved until the command lands"
"kind": "dialog",
"target": "equation",
"excel": "Equation",
"note": "the Equation split button's main half opens the editor"
},
{
"seq": "NF",
+2 -1
View File
@@ -28,6 +28,7 @@ pub mod dnd;
#[cfg(not(target_arch = "wasm32"))]
pub mod doc_windows;
pub mod editor;
pub mod equation;
mod floating_pictures;
pub mod fonts;
pub mod formula_bar;
@@ -267,7 +268,7 @@ pub struct SheetApp {
/// false (a bare test/embedder that never runs the hook) capture code falls back to egui's
/// current input instead of reusing a stale recorded value.
pub(crate) capture_geometry_from_raw: bool,
/// The floating object selected on the sheet (chart, picture, shape or slicer).
/// The floating object selected on the sheet (chart, picture, shape, equation or slicer).
pub selected_object: Option<gridcraft_engine::model::ObjRef>,
/// 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
+8 -3
View File
@@ -1,5 +1,5 @@
//! Selecting, resizing, deleting and opening floating objects (charts, pictures, shapes and
//! slicers). Painting and hit-testing live in `chartview`.
//! Selecting, resizing, deleting and opening floating objects (charts, pictures, shapes,
//! equations and slicers). Painting and hit-testing live in `chartview`.
use egui::{CursorIcon, Pos2, Rect, Vec2};
use gridcraft_engine::model::{ObjKind, ObjRef, ShapeKind, Sheet};
@@ -116,6 +116,8 @@ pub enum OpenAction {
EditText(u32),
/// The Format Chart pane.
FormatChart(u32),
/// The equation editor.
EditEquation(u32),
/// Nothing beyond selecting it.
Select,
}
@@ -123,13 +125,15 @@ pub enum OpenAction {
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 | ObjKind::Image | ObjKind::Slicer => OpenAction::Select,
}
}
impl SheetApp {
/// Double-click on an object: selects it and opens what edits it.
/// Double-click on an object: selects it and opens what edits it (a text box in place, the
/// Format Chart pane, the equation editor).
pub fn open_object(&mut self, o: ObjRef) -> OpenAction {
self.selected_object = Some(o);
let action = self.session.active().and_then(|d| d.wb.active()).map_or(OpenAction::Select, |sh| open_action(sh, o));
@@ -138,6 +142,7 @@ impl SheetApp {
self.begin_text_box_edit(id);
}
OpenAction::FormatChart(_) => self.grid.pane = Some("formatChart".into()),
OpenAction::EditEquation(id) => self.open_dialog("equation", json!({"id": id})),
OpenAction::Select => {}
}
action
+20 -1
View File
@@ -1,5 +1,5 @@
use super::controls::{act, anchor_keytip, control_keytip, invoke, popup_command, sep};
use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, icons, json, small_button, vec2};
use super::{Icon, Sense, SheetApp, Stroke, StrokeKind, Tokens, Ui, big_button, big_split_button, icons, json, small_button, vec2};
/// Draws the Insert tab: PivotTable, Table, Pictures, Shapes, Icons, charts, slicer, sparklines, checkbox, links, comments, text box, header/footer and symbols.
pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
@@ -192,6 +192,25 @@ pub(super) fn insert(app: &mut SheetApp, ui: &mut Ui) {
// As in Excel: Page Layout view, editing the header in place (the dialog is under Page Setup).
crate::paper_view::start_editing(app);
}
// Symbols: Equation, then Symbol.
sep(ui);
let (eq, eq_menu) = big_split_button(ui, Icon::Equation, "Equation", "Insert an equation, or pick one from the gallery");
control_keytip(app, ui, &eq, "dialog:equation");
if eq.clicked() {
app.open_dialog("equation", json!({}));
}
egui::Popup::menu(&eq_menu).show(|ui| {
ui.set_width(300.0);
if ui.add(egui::Button::new(app.ui.language.tr("Insert New Equation...")).frame(false).min_size(vec2(290.0, 24.0))).clicked() {
app.open_dialog("equation", json!({}));
ui.close();
}
ui.separator();
if let Some(latex) = crate::equation::gallery(ui) {
act(app, "insert.equation", json!({"latex": latex}));
ui.close();
}
});
let sym = big_button(ui, Icon::Symbol, "Symbol", "Symbol", true);
control_keytip(app, ui, &sym, "insert.symbol");
egui::Popup::menu(&sym).show(|ui| {
+3 -3
View File
@@ -28,9 +28,9 @@ pub use font_fallback::symbol_face_installed;
use font_fallback::{cjk_han_japanese, cjk_han_simplified, cjk_hangul, fallback_faces, hebrew, lao, symbols_more, thai};
use font_setup::pick;
pub use font_setup::{
CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MONO, SERIF, UI, UI_BOLD, available_text, cell_family, cell_text_width,
chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required, font_definitions,
font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font,
CELL, CELL_BOLD, CELL_BOLD_ITALIC, CELL_ITALIC, CraftFont, HanOrder, MATH, MATH_BOLD, MATH_ITALIC, MONO, SERIF, UI, UI_BOLD, available_text,
cell_family, cell_text_width, chinese_font_loaded, craft_font_scripts, craft_fonts_cover, craft_fonts_loaded, craft_fonts_required,
font_definitions, font_definitions_for_language, font_definitions_with, pinned_font_definitions, symbol_fallback, ui_bold, ui_font,
};
#[cfg(test)]
use font_setup::{candidates, craft_font_faces, try_load};
+54 -1
View File
@@ -41,6 +41,14 @@ pub const SERIF: &str = "serif";
pub const MONO: &str = "mono";
/// Equation faces: a maths font (upright letters, operators, Greek, double-struck and script
/// letters), its italic for variables, and a bold. System fonts only; nothing is bundled.
pub const MATH: &str = "math";
pub const MATH_ITALIC: &str = "math-italic";
pub const MATH_BOLD: &str = "math-bold";
pub fn ui_font(size: f32) -> FontId {
FontId::new(size, FontFamily::Name(UI.into()))
}
@@ -365,6 +373,43 @@ pub(super) fn candidates(role: &str) -> Vec<(String, u32)> {
add("/usr/share/fonts/liberation-serif-fonts/LiberationSerif-Regular.ttf".into(), 0);
add("/usr/share/fonts/dejavu-serif-fonts/DejaVuSerif.ttf".into(), 0);
}
"math" => {
add(format!("{mac_sup}/STIXTwoMath.otf"), 0);
add(format!("{win}\\cambria.ttc"), 1);
for p in in_dirs(
&lin,
&[
"opentype/stix/STIXTwoMath-Regular.otf",
"stix/STIXTwoMath-Regular.otf",
"opentype/lmodern/latinmodern-math.otf",
"dejavu/DejaVuSerif.ttf",
"liberation/LiberationSerif-Regular.ttf",
],
) {
add(p, 0);
}
add(format!("{mac_sup}/Times New Roman.ttf"), 0);
add(format!("{win}\\times.ttf"), 0);
}
"math-italic" => {
add(format!("{mac_sup}/STIXTwoText-Italic.ttf"), 0);
add(format!("{mac_sup}/Times New Roman Italic.ttf"), 0);
add(format!("{win}\\cambriai.ttf"), 0);
add(format!("{win}\\timesi.ttf"), 0);
for p in
in_dirs(&lin, &["opentype/stix/STIXTwoText-Italic.otf", "liberation/LiberationSerif-Italic.ttf", "dejavu/DejaVuSerif-Italic.ttf"])
{
add(p, 0);
}
}
"math-bold" => {
add(format!("{mac_sup}/Times New Roman Bold.ttf"), 0);
add(format!("{win}\\cambriab.ttf"), 0);
add(format!("{win}\\timesbd.ttf"), 0);
for p in in_dirs(&lin, &["opentype/stix/STIXTwoText-Bold.otf", "liberation/LiberationSerif-Bold.ttf", "dejavu/DejaVuSerif-Bold.ttf"]) {
add(p, 0);
}
}
"mono" => {
add(format!("{mac_sys}/SFNSMono.ttf"), 0);
add(format!("{mac_sys}/Menlo.ttc"), 0);
@@ -441,7 +486,7 @@ fn font_definitions_impl(language: crate::i18n::Language, used: &[String], syste
// and epaint picks the first face that has the glyph, so they only catch their own scripts.
let fallback = if system { load_fallback(&mut fonts, HanOrder::of(language)) } else { Vec::new() };
let hack = fonts.font_data.contains_key(HACK).then_some(HACK);
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO, MATH, MATH_ITALIC, MATH_BOLD] {
let chain = build_role_chain(&mut fonts, role, &base_prop, &base_mono, &fallback, hack, system);
fonts.families.insert(FontFamily::Name(role.into()), chain);
bound.push(role.to_string());
@@ -503,6 +548,14 @@ fn build_role_chain(
} else if role.starts_with("cell-") {
chain.push(format!("sys-{CELL}"));
}
// Equation faces fall back to the maths font (for the symbols a text face lacks), then to the
// serif face.
if role == MATH_ITALIC || role == MATH_BOLD {
chain.push(format!("sys-{MATH}"));
}
if role.starts_with("math") {
chain.push(format!("sys-{SERIF}"));
}
chain.extend(if role == MONO { base_mono.to_vec() } else { base_prop.to_vec() });
// egui bundles Hack, a monospace face rich in arrows, triangles and keyboard symbols, but
// 0.37 only puts it in the Monospace family. The interface draws ↑, ▲, ▼, ▸, ▾ and ↳ as
+1 -1
View File
@@ -13,7 +13,7 @@ fn fallback_faces_are_appended_to_every_family() {
if fb_keys.is_empty() {
return; // no fallback face on this machine (minimal Linux)
}
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO] {
for role in [UI, UI_BOLD, CELL, CELL_BOLD, CELL_ITALIC, CELL_BOLD_ITALIC, SERIF, MONO, MATH, MATH_ITALIC, MATH_BOLD] {
let chain = fonts.families.get(&FontFamily::Name(role.into())).map(Vec::as_slice).unwrap_or_default();
// The optional craft-fonts faces follow the system fallbacks at the very end.
let end = chain.iter().rposition(|k| !k.starts_with("craft-")).map_or(0, |i| i + 1);
+201
View File
@@ -0,0 +1,201 @@
//! The equation editor through real input: opening it from the ribbon and its access keys,
//! typing LaTeX, the quick-insert buttons, the error in the preview, inserting, and editing an
//! equation by double-clicking it.
use egui::{Event, Key, Modifiers, PointerButton, Pos2, os::OperatingSystem, vec2};
use egui_kittest::kittest::Queryable as _;
use gridcraft_engine::Session;
use gridcraft_engine::model::{ObjKind, ObjRef, Shape, ShapeKind};
use gridcraft_ui_egui::{SheetApp, chartview, grid::Geo};
use serde_json::{Value as Json, json};
type Harness = egui_kittest::Harness<'static, SheetApp>;
fn harness(s: Session) -> Harness {
let mut h =
egui_kittest::Harness::builder().with_os(OperatingSystem::Mac).with_size(vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state(
|ui, app: &mut SheetApp| {
app.logic(ui.ctx());
app.ui(ui);
},
SheetApp::new(s, Default::default()),
);
h.run_steps(4);
h
}
fn blank() -> Session {
let mut s = Session::new();
s.new_workbook();
s
}
fn key_event(key: Key, pressed: bool, modifiers: Modifiers) -> Event {
Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }
}
fn type_text(h: &mut Harness, text: &str) {
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(2);
}
fn cmd_enter(h: &mut Harness) {
h.input_mut().events.extend([key_event(Key::Enter, true, Modifiers::COMMAND), key_event(Key::Enter, false, Modifiers::COMMAND)]);
h.run_steps(3);
}
fn dialog_latex(h: &Harness) -> Option<String> {
h.state().dialog.as_ref().filter(|d| d.name() == "equation").map(|d| d.values.get("latex").and_then(Json::as_str).unwrap_or("").to_string())
}
fn equations(h: &Harness) -> Vec<Shape> {
h.state().session.doc().unwrap().wb.active().unwrap().shapes.iter().filter(|s| s.kind == ShapeKind::Equation).cloned().collect()
}
#[test]
fn the_ribbon_button_opens_the_editor_and_typing_inserts_an_equation() {
let mut h = harness(blank());
h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap();
h.run_steps(2);
h.get_by_label("Equation").click();
h.run_steps(3);
assert_eq!(dialog_latex(&h).as_deref(), Some(""), "a new, empty equation");
// The source has focus: typing goes straight in.
type_text(&mut h, "x^2 + y^2 = r^2");
assert_eq!(dialog_latex(&h).as_deref(), Some("x^2 + y^2 = r^2"));
assert!(h.query_by_label_contains("Error at character").is_none());
cmd_enter(&mut h);
assert!(h.state().dialog.is_none(), "Cmd+Enter inserts and closes");
let eqs = equations(&h);
assert_eq!(eqs.len(), 1);
assert_eq!(eqs[0].text, "x^2 + y^2 = r^2");
assert_eq!(eqs[0].anchor.cell.a1(), "A1", "at the active cell");
}
#[test]
fn errors_show_their_position_and_block_ok() {
let mut h = harness(blank());
h.state_mut().open_dialog("equation", json!({}));
h.run_steps(3);
type_text(&mut h, "a + \\foo");
assert!(h.query_by_label_contains("Error at character 5").is_some(), "the error names the position");
cmd_enter(&mut h);
assert!(h.state().dialog.is_some(), "an equation with an error can't be inserted");
assert!(equations(&h).is_empty());
// The formula bar's cancel button shares the label; the dialog's comes last.
h.get_all_by_label("Cancel").last().expect("the dialog's Cancel").click();
h.run_steps(3);
assert!(h.state().dialog.is_none());
assert!(equations(&h).is_empty());
}
#[test]
fn quick_insert_buttons_insert_at_the_caret() {
let mut h = harness(blank());
h.state_mut().open_dialog("equation", json!({}));
h.run_steps(3);
type_text(&mut h, "y = ");
h.get_by_label("a/b").click();
h.run_steps(3);
assert_eq!(dialog_latex(&h).as_deref(), Some("y = \\frac{}{}"));
// The caret sits in the numerator.
type_text(&mut h, "1");
assert_eq!(dialog_latex(&h).as_deref(), Some("y = \\frac{1}{}"));
h.get_by_label("\u{03C0}").click();
h.run_steps(3);
assert_eq!(dialog_latex(&h).as_deref(), Some("y = \\frac{1\\pi }{}"));
}
#[test]
fn the_access_keys_open_the_editor() {
let mut h = harness(blank());
h.input_mut().events.extend([key_event(Key::F10, true, Modifiers::NONE), key_event(Key::F10, false, Modifiers::NONE)]);
h.run_steps(2);
for k in [Key::N, Key::E] {
h.input_mut().events.extend([
key_event(k, true, Modifiers::NONE),
Event::Text(k.name().to_ascii_lowercase()),
key_event(k, false, Modifiers::NONE),
]);
h.run_steps(2);
}
h.run_steps(2);
assert_eq!(dialog_latex(&h).as_deref(), Some(""), "N, E opens Insert Equation");
}
#[test]
fn double_click_edits_an_equation_in_one_undo_step() {
let mut s = blank();
let id =
s.execute("insert.equation", json!({"latex": "a^2 + b^2 = c^2", "cell": "C4", "size": 18})).unwrap()["equation"].as_u64().unwrap() as u32;
let mut h = harness(s);
let o = ObjRef::new(ObjKind::Equation, id);
let r = {
let app = h.state();
let sh = app.session.doc().unwrap().wb.active().unwrap();
chartview::object_rect(&Geo::new(sh, app.grid.rect.unwrap(), app.view().scroll), sh, o).unwrap()
};
h.run_steps(45);
for _ in 0..2 {
for pressed in [true, false] {
let p: Pos2 = r.center();
h.input_mut().events.push(Event::PointerMoved(p));
h.input_mut().events.push(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(2);
}
assert_eq!(h.state().selected_object, Some(o));
assert_eq!(dialog_latex(&h).as_deref(), Some("a^2 + b^2 = c^2"), "the editor opens on the equation's source");
assert_eq!(h.state().dialog.as_ref().unwrap().title, "Edit Equation");
h.state_mut().dialog.as_mut().unwrap().values.insert("latex".into(), json!("a^2 + b^2 = c^2 + d^2"));
h.run_steps(2);
cmd_enter(&mut h);
assert!(h.state().dialog.is_none());
assert_eq!(equations(&h)[0].text, "a^2 + b^2 = c^2 + d^2");
h.state_mut().run("edit.undo", json!({})).unwrap();
h.run_steps(2);
assert_eq!(equations(&h)[0].text, "a^2 + b^2 = c^2");
}
#[test]
fn the_gallery_inserts_its_examples() {
let mut h = harness(blank());
h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap();
h.run_steps(2);
h.get_by_label("Equation options").click();
h.run_steps(3);
assert!(h.query_by_label("Insert New Equation...").is_some());
// Gallery rows are painted previews; each carries its source as hover text. Click the first.
let first = h.get_by_label("Insert New Equation...").rect();
let row = first.center() + vec2(0.0, first.height() / 2.0 + 40.0);
h.input_mut().events.push(Event::PointerMoved(row));
h.run_steps(2);
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: row, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.step();
}
h.run_steps(3);
let eqs = equations(&h);
assert_eq!(eqs.len(), 1, "one gallery example inserted");
assert_eq!(eqs[0].text, gridcraft_engine::math::GALLERY[0].1);
}
#[test]
fn reopening_after_escape_focuses_the_source_again() {
let mut h = harness(blank());
h.state_mut().run("ui.ribbonTab", json!({"tab": "Insert"})).unwrap();
h.run_steps(2);
for round in 0..2 {
h.get_by_label("Equation").click();
h.run_steps(3);
assert_eq!(dialog_latex(&h).as_deref(), Some(""), "round {round}: the editor is open");
type_text(&mut h, "x");
assert_eq!(dialog_latex(&h).as_deref(), Some("x"), "round {round}: typing goes into the source");
// Escape closes the dialog without OK or Cancel.
h.input_mut().events.extend([key_event(Key::Escape, true, Modifiers::NONE), key_event(Key::Escape, false, Modifiers::NONE)]);
h.run_steps(3);
assert!(h.state().dialog.is_none(), "round {round}: Escape closes the editor");
}
assert!(equations(&h).is_empty(), "nothing was inserted");
}
+1 -4
View File
@@ -1143,10 +1143,7 @@ fn keytip_nd_() {
fn keytip_ne_() {
let mut h = harness(blank());
sequence(&mut h, "NE", false);
assert!(h.state().dialog.is_none(), "NE must not open a dialog");
assert!(h.state().message.is_none(), "NE must not open a message box");
assert!(h.state().toast.is_none(), "NE must not raise a toast");
assert!(h.state().session.journal.is_empty(), "NE must not journal");
assert!(h.state().dialog.is_some(), "NE must open the equation dialog");
}
#[test]
+1
View File
@@ -29,6 +29,7 @@ mod dialog_confirm;
mod dialog_focus;
mod dialog_move;
mod drag_cursor;
mod equation_editor;
mod error_checking_menu;
mod file_menu;
mod fill_handle_double_click;
+12 -2
View File
@@ -16,6 +16,7 @@ struct Objects {
image: ObjRef,
shape: ObjRef,
text_box: ObjRef,
equation: ObjRef,
/// A slicer lying over the chart's right edge: slicers paint on top and take the click.
slicer: ObjRef,
}
@@ -41,12 +42,14 @@ fn fixture() -> (Harness, Objects) {
id(&s.execute("insert.chart", json!({"range": "B1:B5", "type": "column", "at": "D8", "width": 220, "height": 140})).unwrap(), "chart");
let shape = id(&s.execute("insert.shape", json!({"kind": "rectangle", "at": "C20", "width": 120, "height": 80})).unwrap(), "shape");
let text_box = id(&s.execute("insert.textBox", json!({"at": "H20", "width": 140, "height": 60, "text": "Note"})).unwrap(), "shape");
let equation = id(&s.execute("insert.equation", json!({"latex": "a^2+b^2=c^2", "cell": "C26", "size": 16})).unwrap(), "equation");
let image = id(&s.execute("insert.picture", json!({"base64": tiny_png(), "at": "M8", "width": 60, "height": 60})).unwrap(), "image");
let objs = Objects {
chart: ObjRef::new(ObjKind::Chart, chart),
image: ObjRef::new(ObjKind::Image, image),
shape: ObjRef::new(ObjKind::Shape, shape),
text_box: ObjRef::new(ObjKind::Shape, text_box),
equation: ObjRef::new(ObjKind::Equation, equation),
slicer: ObjRef::new(ObjKind::Slicer, slicer),
};
let mut h = egui_kittest::Harness::builder()
@@ -139,7 +142,7 @@ fn kept(h: &Harness) -> Value {
#[test]
fn a_click_selects_every_kind_and_slicers_win_over_charts() {
let (mut h, o) = fixture();
for obj in [o.chart, o.image, o.shape, o.text_box, o.slicer] {
for obj in [o.chart, o.image, o.shape, o.text_box, o.equation, o.slicer] {
let p = grab_point(&h, &o, obj);
click(&mut h, p);
assert_eq!(h.state().selected_object, Some(obj), "{obj:?}");
@@ -155,7 +158,7 @@ fn a_click_selects_every_kind_and_slicers_win_over_charts() {
#[test]
fn every_handle_resizes_only_its_own_edges() {
let (mut h, o) = fixture();
for obj in [o.shape, o.image, o.slicer, o.chart] {
for obj in [o.shape, o.image, o.equation, o.slicer, o.chart] {
let p = grab_point(&h, &o, obj);
click(&mut h, p);
for handle in Handle::ALL {
@@ -251,6 +254,7 @@ fn double_clicks_route_by_kind() {
let sh = h.state().session.doc().unwrap().wb.active().unwrap();
assert_eq!(open_action(sh, o.text_box), OpenAction::EditText(o.text_box.id));
assert_eq!(open_action(sh, o.chart), OpenAction::FormatChart(o.chart.id));
assert_eq!(open_action(sh, o.equation), OpenAction::EditEquation(o.equation.id));
for obj in [o.shape, o.image, o.slicer] {
assert_eq!(open_action(sh, obj), OpenAction::Select, "{obj:?}");
}
@@ -267,6 +271,12 @@ fn double_clicks_route_by_kind() {
assert_eq!(h.state().grid.pane.as_deref(), Some("formatChart"));
assert_eq!(h.state().selected_object, Some(o.chart));
let p = grab_point(&h, &o, o.equation);
double_click(&mut h, p);
assert_eq!(h.state().selected_object, Some(o.equation));
assert_eq!(h.state().dialog.as_ref().map(|d| (d.name(), d.title.as_str())), Some(("equation", "Edit Equation")), "equations open the editor");
h.state_mut().dialog = None;
for obj in [o.shape, o.image, o.slicer] {
let p = grab_point(&h, &o, obj);
double_click(&mut h, p);
+3 -3
View File
@@ -507,15 +507,15 @@ fn pending_sequences_are_silent_no_ops() {
fn pending_sequences_go_live_when_the_command_lands() {
// The command lands in the catalog (here: a test-registered stand-in).
// The sequence must run it with no table edit.
gridcraft_engine::register_test_command("insert.equation", |s, _| {
gridcraft_engine::register_test_command("insert.object", |s, _| {
s.execute("cell.set", json!({"cell": "Z9", "input": "live"}))?;
Ok(json!({}))
});
let mut h = harness(blank());
sequence(&mut h, &[Key::N, Key::E], false); // NE -> insert.equation
sequence(&mut h, &[Key::N, Key::J], false); // NJ -> insert.object
assert_eq!(value(&h, "Z9"), Value::from("live"), "the pending sequence ran the landed command");
assert_eq!(
h.state().session.journal.iter().filter(|(id, _)| id == "insert.equation").count(),
h.state().session.journal.iter().filter(|(id, _)| id == "insert.object").count(),
1,
"the landed command is journaled like any live command"
);
+1
View File
@@ -16,6 +16,7 @@ test-support = []
[dependencies]
gridcraft-core = { workspace = true }
gridcraft-formula = { workspace = true }
gridcraft-math = { workspace = true }
gridcraft-model = { workspace = true }
thiserror = { workspace = true }
quick-xml = { workspace = true }
+151 -13
View File
@@ -1,5 +1,10 @@
//! DrawingML spreadsheet drawings (`xl/drawings/drawingN.xml`): anchors, pictures, shapes and
//! chart frames.
//! DrawingML spreadsheet drawings (`xl/drawings/drawingN.xml`): anchors, pictures, shapes,
//! equations and chart frames.
//!
//! An equation is written as `mc:AlternateContent`: the `a14` choice is a text box holding the
//! equation as Office Math (`a14:m`), the fallback the same text box holding the LaTeX as plain
//! text. Both carry the source in `descr="gridcraft:latex:…"`, which wins on reading; without it,
//! Office Math in the subset `omml` reads is converted back to LaTeX.
use std::fmt::Write as _;
@@ -127,15 +132,21 @@ pub fn read_drawing(cx: &mut Ctx<'_>, part: &str, sheet: &mut Sheet) -> Result<(
for (n, a) in x.children.iter().enumerate() {
let Some(anchor) = read_anchor(a, sheet) else { continue };
let n = n + 1;
// Objects in markup-compatibility blocks: the first choice (chartex frames), else the
// fallback.
// Objects in markup-compatibility blocks: the first choice (chartex frames, or Office Math
// that converts to LaTeX), else the fallback.
let objs: Vec<&El> = a
.children
.iter()
.flat_map(|o| match o.name.as_str() {
"AlternateContent" => o
.child("Choice")
.filter(|c| c.child("graphicFrame").is_some())
.filter(|c| {
// Office Math is taken only when the box holds one equation that
// converts: otherwise the fallback (a picture or text from the writer)
// keeps everything visible.
c.child("graphicFrame").is_some()
|| c.child("sp").and_then(|sp| sp.child("txBody")).and_then(crate::omml::equation_source).is_some()
})
.or_else(|| o.child("Fallback"))
.map(|c| c.children.iter().collect())
.unwrap_or_default(),
@@ -220,17 +231,41 @@ fn read_shape(obj: &El, anchor: Anchor) -> Option<Shape> {
"rightArrow" => ShapeKind::Arrow,
_ => ShapeKind::Rectangle,
};
// An equation: GridCraft's own source while the marker in the description still matches the
// body (another application that edits the Office Math, or the text of a box without Office
// Math, leaves the description alone, as Excel does), else the Office Math converted back to
// LaTeX. A box without Office Math whose text was edited is a text box with the edited text,
// not the stale source, which the next save would write over the edit; as the marker joins
// run text across paragraphs, such a box must also hold the paragraphs the writer wrote.
let descr = obj.path(&["nvSpPr", "cNvPr"]).and_then(|c| c.attr("descr")).unwrap_or("");
let tb = obj.child("txBody");
let has_math = tb.is_some_and(|tb| crate::omml::find_math(tb, 0).is_some());
let source = match (has_math, described_source(descr)) {
(_, Some((marker, src))) if tb.is_some_and(|tb| crate::omml::body_marker(tb) == marker && (has_math || holds_written_text(tb, src))) => {
Some(src.to_string())
}
(true, _) => tb.and_then(crate::omml::equation_source),
(false, _) => None,
};
if let Some(text) = source {
let ink = obj.child("txBody").and_then(|tb| first_run_color(tb, 0)).unwrap_or_default();
return Some(Shape {
id: 0,
kind: ShapeKind::Equation,
anchor,
fill: Color::Auto,
line: ink,
text,
format: Default::default(),
math: Default::default(),
});
}
let fill = sp.child("solidFill").and_then(dml_color).unwrap_or_default();
let line = ln.and_then(|l| l.child("solidFill")).and_then(dml_color).unwrap_or_default();
let text = obj
.child("txBody")
.map(|tb| {
tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::<String>()).collect::<Vec<_>>().join("\n")
})
.unwrap_or_default();
let text = obj.child("txBody").map(body_text).unwrap_or_default();
let mut format = read_text_format(obj.child("txBody"));
format.no_line = ln.is_some_and(|l| l.child("noFill").is_some());
Some(Shape { id: 0, kind, anchor, fill, line, text, format })
Some(Shape { id: 0, kind, anchor, fill, line, text, format, math: Default::default() })
}
/// A shape's text formatting: vertical anchor (`a:bodyPr anchor`), the first paragraph's
@@ -269,8 +304,104 @@ fn read_text_format(body: Option<&El>) -> ShapeFormat {
f
}
/// The `descr` prefix that carries an equation's LaTeX source, as
/// `gridcraft:latex:<marker>:<source>`: the marker is [`crate::omml::body_marker`] of the text
/// body written beside it, 16 hex digits.
const LATEX_DESCR: &str = "gridcraft:latex:";
/// The marker and source of a `gridcraft:latex:<marker>:<source>` description; `None` for any
/// other description, a malformed marker or a blank source.
fn described_source(descr: &str) -> Option<(&str, &str)> {
let rest = descr.strip_prefix(LATEX_DESCR)?;
let (marker, src) = (rest.get(..16)?, rest.get(17..)?);
(rest.as_bytes().get(16) == Some(&b':') && marker.bytes().all(|b| b.is_ascii_hexdigit()) && !src.trim().is_empty()).then_some((marker, src))
}
/// The colour of the first run (DrawingML `rPr` with a fill) under `e`.
fn first_run_color(e: &El, depth: usize) -> Option<Color> {
if depth > 12 {
return None;
}
if e.name == "rPr"
&& let Some(c) = e.child("solidFill").and_then(dml_color)
{
return Some(c);
}
e.children.iter().find_map(|c| first_run_color(c, depth + 1))
}
// ---------------------------------------------------------------- writing
/// An equation shape (inside the anchor): Office Math with a plain-text fallback when the source
/// is in the subset OMML covers, else the plain-text box alone. Both carry the source.
fn write_equation(s: &mut String, sh: &Shape, id: usize, theme: &Theme) {
// The font size the equation is drawn at (it scales with its frame), in points.
let size_pt = sh
.math
.get(&sh.text)
.and_then(Result::ok)
.map(|l| {
let (w, h) = (l.width.max(0.01), l.total_height().max(0.01));
((sh.anchor.width - 8.0) / w).min((sh.anchor.height - 8.0) / h) * 72.0 / 96.0
})
.filter(|v| v.is_finite() && *v >= 1.0)
.unwrap_or(11.0)
.min(409.0);
let fill = srgb(&sh.line, theme).map(|c| format!("<a:solidFill>{c}</a:solidFill>")).unwrap_or_default();
let sz = (size_pt * 100.0).round() as u32;
// Each box carries the marker of its own body beside the source in its description, the
// Office Math box and the plain-text fallback alike; the reader takes the source only while
// the body it reads still matches its marker.
let tx = |body: &str| {
format!(
"<xdr:txBody><a:bodyPr wrap=\"none\" lIns=\"0\" tIns=\"0\" rIns=\"0\" bIns=\"0\" rtlCol=\"0\" anchor=\"ctr\" anchorCtr=\"1\"/><a:lstStyle/>{body}</xdr:txBody>"
)
};
let marker = |tx: &str| crate::xml::parse(tx.as_bytes()).map(|e| crate::omml::body_marker(&e)).unwrap_or_else(|_| "0".repeat(16));
let sp = |tx: &str, marker: &str| {
format!(
"<xdr:sp macro=\"\" textlink=\"\"><xdr:nvSpPr><xdr:cNvPr id=\"{id}\" name=\"Equation {}\" descr=\"{}\"/><xdr:cNvSpPr txBox=\"1\"/></xdr:nvSpPr><xdr:spPr>{}<a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom><a:noFill/></xdr:spPr>{tx}</xdr:sp>",
id - 1,
esc_attr(&format!("{LATEX_DESCR}{marker}:{}", sh.text)),
xfrm(&sh.anchor, "a:xfrm")
)
};
let plain = plain_paragraphs(&sh.text, &format!("<a:rPr lang=\"en-US\" sz=\"{sz}\">{fill}</a:rPr>"));
match gridcraft_math::to_omml(&sh.text, size_pt, sh.line.resolve(theme)) {
Ok(omml) => {
let math = format!(
"<a:p><a:pPr algn=\"ctr\"/><a14:m><m:oMathPara xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\"><m:oMathParaPr><m:jc m:val=\"centerGroup\"/></m:oMathParaPr>{omml}</m:oMathPara></a14:m><a:endParaRPr lang=\"en-US\" sz=\"{sz}\"/></a:p>"
);
let _ = write!(
s,
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\"><mc:Choice xmlns:a14=\"http://schemas.microsoft.com/office/drawing/2010/main\" Requires=\"a14\">{}</mc:Choice><mc:Fallback>{}</mc:Fallback></mc:AlternateContent>",
sp(&tx(&math), &marker(&tx(&math))),
sp(&tx(&plain), &marker(&tx(&plain)))
);
}
Err(_) => s.push_str(&sp(&tx(&plain), &marker(&tx(&plain)))),
}
}
/// The paragraphs of an equation's plain text, one per line of `text`, each a run with `rpr` as
/// its properties: the fallback body, and the body of an equation outside the Office Math subset.
fn plain_paragraphs(text: &str, rpr: &str) -> String {
text.split('\n').map(|line| format!("<a:p><a:pPr algn=\"ctr\"/><a:r>{rpr}<a:t>{}</a:t></a:r></a:p>", esc(line))).collect()
}
/// The text of a text body: the runs of each paragraph, paragraphs joined with newlines.
fn body_text(tb: &El) -> String {
tb.kids("p").map(|p| p.kids("r").filter_map(|r| r.child("t")).map(|t| t.text.as_str()).collect::<String>()).collect::<Vec<_>>().join("\n")
}
/// Whether a body without Office Math still holds the text the writer wrote for `src`: the same
/// paragraphs, read back through the same parser. The marker joins run text across paragraphs,
/// so a paragraph split that changes no character passes it; the text does not.
fn holds_written_text(tb: &El, src: &str) -> bool {
let written = format!("<xdr:txBody xmlns:xdr=\"x\" xmlns:a=\"a\">{}</xdr:txBody>", plain_paragraphs(src, ""));
crate::xml::parse(written.as_bytes()).is_ok_and(|w| body_text(&w) == body_text(tb))
}
/// The opening `<xdr:twoCellAnchor>` for a model anchor, carrying Excel's `editAs` when the mode
/// is not the default: omitted for "Move and size with cells", `editAs="oneCell"` for "Move but
/// don't size", `editAs="absolute"` for "Don't move or size". (Excel writes pictures this way;
@@ -366,6 +497,12 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
xfrm(&ch.anchor, "xdr:xfrm")
);
}
Obj::Shape(sh) if sh.kind == ShapeKind::Equation => {
let (open, close) = anchor_open(sheet, &sh.anchor);
close_tag = close;
s.push_str(&open);
write_equation(&mut s, sh, id, theme);
}
Obj::Shape(sh) => {
let (open, close) = anchor_open(sheet, &sh.anchor);
close_tag = close;
@@ -382,7 +519,8 @@ pub fn write_drawing(sheet: &Sheet, theme: &Theme, objs: &[Obj<'_>]) -> String {
ShapeKind::Ellipse => ("ellipse", false),
ShapeKind::Triangle => ("triangle", false),
ShapeKind::Line | ShapeKind::Arrow => ("line", false),
ShapeKind::TextBox => ("rect", true),
// Equations are written by `write_equation`; this arm keeps the match whole.
ShapeKind::TextBox | ShapeKind::Equation => ("rect", true),
ShapeKind::Icon | ShapeKind::Ink => ("rect", false),
};
let _ = write!(
+10 -1
View File
@@ -179,7 +179,16 @@ pub(super) fn read(obj: &El, anchor: Anchor) -> Option<Shape> {
let (path, w, h) = validate_cust_geom(sp)?;
let (width, line) = validate_line(sp)?;
let text = parse_points(path, &anchor, w, h)?;
Some(Shape { id: 0, kind: ShapeKind::Ink, anchor, fill: Color::Rgb((width as u32) << 16), line, text, format: Default::default() })
Some(Shape {
id: 0,
kind: ShapeKind::Ink,
anchor,
fill: Color::Rgb((width as u32) << 16),
line,
text,
format: Default::default(),
math: Default::default(),
})
}
/// Invalid/native out-of-range data uses the existing rectangle fallback. A successful
+1
View File
@@ -21,6 +21,7 @@ mod drawing;
mod feature_bags;
mod fmla;
mod ods;
mod omml;
mod package;
mod passthrough;
mod pivot;
+932
View File
@@ -0,0 +1,932 @@
//! Equations in drawings: Office Math Markup (ECMA-376 Part 1 §22.1) read back into LaTeX for the
//! common subset, the inverse of `gridcraft_math::to_omml`. A construct outside the subset makes
//! the whole equation unreadable as maths (`None`), and the caller keeps it as a text box.
use crate::xml::El;
/// Deepest OMML nesting converted.
const MAX_DEPTH: usize = 64;
/// Longest LaTeX the conversion produces before it gives up. `gridcraft_math::check` refuses a
/// longer source anyway, so converting the rest of a wide or long equation would only spend time
/// and memory on a hostile file.
const MAX_OUT_BYTES: usize = gridcraft_math::MAX_SOURCE_BYTES;
/// The LaTeX for an `m:oMath` (or `m:oMathPara`) element, if every part is in the subset and the
/// result lays out.
pub fn to_latex(e: &El) -> Option<String> {
let out = convert(e)?;
(!out.is_empty() && gridcraft_math::check(&out).is_ok()).then_some(out)
}
/// The conversion itself, before the final check: `None` for a construct outside the subset, an
/// equation nested past [`MAX_DEPTH`] or one whose LaTeX exceeds [`MAX_OUT_BYTES`].
fn convert(e: &El) -> Option<String> {
let mut out = String::new();
match e.name.as_str() {
"oMathPara" => {
let maths: Vec<&El> = e.kids("oMath").collect();
for (i, m) in maths.iter().enumerate() {
if i > 0 {
out.push_str(" \\\\ ");
}
seq(m, &mut out, 0)?;
}
}
"oMath" => seq(e, &mut out, 0)?,
_ => return None,
}
Some(out.trim().to_string())
}
/// The first `m:oMath`/`m:oMathPara` under `e` (an `a14:m` element or a paragraph), searched to a
/// bounded depth.
pub fn find_math(e: &El, depth: usize) -> Option<&El> {
if depth > 8 {
return None;
}
if e.name == "oMathPara" || e.name == "oMath" {
return Some(e);
}
e.children.iter().find_map(|c| find_math(c, depth + 1))
}
/// The LaTeX of a DrawingML text body (`a:txBody`) that holds one equation and nothing else: no
/// text in its runs or fields, and no second `m:oMath`/`m:oMathPara`. A body that mixes text and
/// maths, or holds several equations, is `None`, so the caller keeps it as a text box (or reads
/// the writer's fallback) instead of dropping the rest.
pub fn equation_source(tb: &El) -> Option<String> {
let has_text = tb
.kids("p")
.any(|p| p.children.iter().any(|c| matches!(c.name.as_str(), "r" | "fld") && c.child("t").is_some_and(|t| !t.text.trim().is_empty())));
if has_text || count_math(tb, 0) != 1 {
return None;
}
find_math(tb, 0).and_then(to_latex)
}
/// How many `m:oMath`/`m:oMathPara` elements are under `e` (not counting those inside one), to
/// the same bounded depth as [`find_math`], stopping at two.
fn count_math(e: &El, depth: usize) -> usize {
if depth > 8 {
return 0;
}
if e.name == "oMathPara" || e.name == "oMath" {
return 1;
}
let mut n = 0;
for c in &e.children {
n += count_math(c, depth + 1);
if n > 1 {
break;
}
}
n
}
/// A marker for the equation a text body holds: 16 hex digits of an FNV-1a 64-bit hash over what
/// carries the equation's meaning, the OMML element names in document order, the text of the
/// runs, and the properties the converter reads ([`PROPERTIES`]: a run's `sty`, a delimiter's
/// characters, an operator's limits and so on). The writer puts it in the shape's description
/// beside the LaTeX source; the reader takes the source only while the marker still matches the
/// body, so an equation whose Office Math another application edited (Excel leaves the
/// description alone) is converted from that Office Math instead of coming back as the old
/// source. What a re-serialisation may change without changing the equation is left out: every
/// other property (`ctrlPr`, DrawingML run properties such as size and colour), extension lists,
/// the DrawingML containers (`txBody`, `p`, `r`, `fld`, `lstStyle`), attributes, namespace
/// prefixes and whitespace between elements; adjacent runs without read properties are joined,
/// so splitting or merging them changes nothing, while a run with one is a unit of its own, as
/// the converter converts it (it recognises function names per run). A text change, an added
/// run, a structural change, a change to a property the converter reads or a styled run split in
/// two does.
pub fn body_marker(tb: &El) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
let mut text = String::new();
mark(tb, &mut h, &mut text, 0);
mark_text(&mut h, &mut text);
format!("{h:016x}")
}
fn mark(e: &El, h: &mut u64, text: &mut String, depth: usize) {
if depth > MAX_DEPTH {
return;
}
for c in &e.children {
if c.name.ends_with("Pr") {
// Only the properties the converter reads, each as `name=val`, in a fixed order; they
// close the run text gathered so far, so a property moving between runs is a change.
let read: Vec<(&str, &str)> =
read_properties(&c.name).iter().filter_map(|n| c.child(n).map(|p| (*n, p.attr("val").unwrap_or("")))).collect();
if !read.is_empty() {
mark_text(h, text);
mark_bytes(h, b"[");
mark_bytes(h, c.name.as_bytes());
for (n, v) in read {
mark_bytes(h, b" ");
mark_bytes(h, n.as_bytes());
mark_bytes(h, b"=");
mark_bytes(h, v.as_bytes());
}
mark_bytes(h, b"]");
}
continue;
}
if c.name == "extLst" {
continue;
}
match c.name.as_str() {
"r" => {
// A run with a property the converter reads is converted on its own (a function
// name such as `sin` is recognised per run), so its text is hashed as a unit: the
// property closes the text before it, and the run's end closes its own. Runs
// without such properties convert character by character and stay joined.
let styled = c.children.iter().any(|p| p.name.ends_with("Pr") && read_properties(&p.name).iter().any(|n| p.child(n).is_some()));
mark(c, h, text, depth + 1);
if styled {
mark_text(h, text);
}
}
"txBody" | "p" | "fld" | "lstStyle" => mark(c, h, text, depth + 1),
"t" => text.push_str(&c.text),
"br" => text.push('\n'),
name => {
mark_text(h, text);
mark_bytes(h, b"<");
mark_bytes(h, name.as_bytes());
mark(c, h, text, depth + 1);
mark_text(h, text);
mark_bytes(h, b">");
}
}
}
}
/// Hashes the run text gathered so far, if any, and clears it.
fn mark_text(h: &mut u64, text: &mut String) {
if !text.is_empty() {
mark_bytes(h, b"\"");
mark_bytes(h, text.as_bytes());
mark_bytes(h, b"\"");
text.clear();
}
}
fn mark_bytes(h: &mut u64, bytes: &[u8]) {
for b in bytes {
*h ^= u64::from(*b);
*h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
}
/// Children in order, skipping property elements.
fn seq(e: &El, out: &mut String, depth: usize) -> Option<()> {
if depth > MAX_DEPTH {
return None;
}
for c in &e.children {
node(c, out, depth + 1)?;
if out.len() > MAX_OUT_BYTES {
return None;
}
}
Some(())
}
/// `{…}` around a child's contents (empty group when the child is missing).
fn group(e: Option<&El>, out: &mut String, depth: usize) -> Option<()> {
out.push('{');
if let Some(e) = e {
seq(e, out, depth)?;
}
out.push('}');
Some(())
}
/// The property elements and the properties in them that this converter reads, so the ones
/// that decide what an equation says beyond its structure and text. [`body_marker`] hashes
/// exactly these, and [`val`] and [`on`] read only these, so the two cannot drift: a property
/// read here without a row in this table reads as absent, which the conversion tests show.
const PROPERTIES: &[(&str, &[&str])] = &[
("rPr", &["nor", "sty", "scr"]),
("fPr", &["type"]),
("radPr", &["degHide"]),
("naryPr", &["chr", "limLoc", "subHide", "supHide"]),
("dPr", &["begChr", "sepChr", "endChr"]),
("accPr", &["chr"]),
("barPr", &["pos"]),
("groupChrPr", &["chr", "pos"]),
];
/// The properties of the property element `pr` that the converter reads.
fn read_properties(pr: &str) -> &'static [&'static str] {
PROPERTIES.iter().find(|(name, _)| *name == pr).map_or(&[], |(_, names)| names)
}
/// The `val` of property `name` in the property element `props`, when the converter reads it.
fn val<'a>(props: Option<&'a El>, name: &str) -> Option<&'a str> {
props.filter(|p| read_properties(&p.name).contains(&name)).and_then(|p| p.child(name)).and_then(|c| c.attr("val"))
}
/// Whether the switch `name` in the property element `props` is on, when the converter reads it.
fn on(props: Option<&El>, name: &str) -> bool {
props
.filter(|p| read_properties(&p.name).contains(&name))
.and_then(|p| p.child(name))
.is_some_and(|c| c.attr("val").is_none_or(|v| v == "1" || v == "on" || v == "true"))
}
/// LaTeX for one character of a math run.
fn char_latex(c: char) -> Option<&'static str> {
Some(match c {
'α' => "\\alpha ",
'β' => "\\beta ",
'γ' => "\\gamma ",
'δ' => "\\delta ",
'ε' => "\\varepsilon ",
'ϵ' => "\\epsilon ",
'ζ' => "\\zeta ",
'η' => "\\eta ",
'θ' => "\\theta ",
'ϑ' => "\\vartheta ",
'ι' => "\\iota ",
'κ' => "\\kappa ",
'λ' => "\\lambda ",
'μ' => "\\mu ",
'ν' => "\\nu ",
'ξ' => "\\xi ",
'π' => "\\pi ",
'ρ' => "\\rho ",
'σ' => "\\sigma ",
'τ' => "\\tau ",
'υ' => "\\upsilon ",
'φ' => "\\varphi ",
'ϕ' => "\\phi ",
'χ' => "\\chi ",
'ψ' => "\\psi ",
'ω' => "\\omega ",
'Γ' => "\\Gamma ",
'Δ' => "\\Delta ",
'Θ' => "\\Theta ",
'Λ' => "\\Lambda ",
'Ξ' => "\\Xi ",
'Π' => "\\Pi ",
'Σ' => "\\Sigma ",
'Υ' => "\\Upsilon ",
'Φ' => "\\Phi ",
'Ψ' => "\\Psi ",
'Ω' => "\\Omega ",
'±' => "\\pm ",
'∓' => "\\mp ",
'×' => "\\times ",
'÷' => "\\div ",
'⋅' | '·' => "\\cdot ",
'−' => "-",
'≤' => "\\le ",
'≥' => "\\ge ",
'≠' => "\\ne ",
'≈' => "\\approx ",
'≡' => "\\equiv ",
'∼' => "\\sim ",
'∝' => "\\propto ",
'∞' => "\\infty ",
'∂' => "\\partial ",
'∇' => "\\nabla ",
'∀' => "\\forall ",
'∃' => "\\exists ",
'∈' => "\\in ",
'∉' => "\\notin ",
'⊂' => "\\subset ",
'⊆' => "\\subseteq ",
'⊃' => "\\supset ",
'∪' => "\\cup ",
'∩' => "\\cap ",
'∅' => "\\emptyset ",
'→' => "\\to ",
'←' => "\\leftarrow ",
'⇒' => "\\Rightarrow ",
'⇔' => "\\Leftrightarrow ",
'…' => "\\ldots ",
'⋯' => "\\cdots ",
'∘' => "\\circ ",
'{' => "\\{",
'}' => "\\}",
'%' => "\\%",
'#' => "\\#",
'&' => "\\&",
'$' => "\\$",
'_' => "\\_",
'\\' => "\\backslash ",
'~' => "\\sim ",
'^' => "\\hat{}",
_ => return None,
})
}
fn run_text(r: &El) -> String {
r.kids("t").map(|t| t.text.as_str()).collect()
}
/// Appends `s` escaped for `\text{…}`; `None` once the LaTeX exceeds [`MAX_OUT_BYTES`], checked
/// after every character so that a long run never expands in full.
fn escape_text(s: &str, out: &mut String) -> Option<()> {
for c in s.chars() {
match c {
'{' | '}' | '%' | '#' | '&' | '$' | '_' => {
out.push('\\');
out.push(c);
}
'\\' => out.push_str("\\textbackslash "),
'^' | '~' => out.push(' '),
c => out.push(c),
}
if out.len() > MAX_OUT_BYTES {
return None;
}
}
Some(())
}
/// Known operator names written upright in a run (`sin`, `log`, `lim`).
const FUNCTIONS: &[&str] = &[
"sin", "cos", "tan", "cot", "sec", "csc", "arcsin", "arccos", "arctan", "sinh", "cosh", "tanh", "log", "ln", "lg", "exp", "lim", "max", "min",
"sup", "inf", "det", "gcd", "deg", "dim", "ker", "arg", "Pr", "liminf", "limsup",
];
fn run(r: &El, out: &mut String) -> Option<()> {
let text = run_text(r);
if text.is_empty() {
return Some(());
}
let rpr = r.child("rPr");
if on(rpr, "nor") {
out.push_str("\\text{");
escape_text(&text, out)?;
out.push('}');
return Some(());
}
let plain = val(rpr, "sty") == Some("p");
if plain && FUNCTIONS.contains(&text.trim()) {
out.push('\\');
out.push_str(text.trim());
out.push(' ');
return Some(());
}
let wrap = match (val(rpr, "scr"), val(rpr, "sty")) {
(Some("double-struck"), _) => Some("\\mathbb{"),
(Some("script"), _) => Some("\\mathcal{"),
(Some("fraktur"), _) => Some("\\mathfrak{"),
(Some("sans-serif"), _) => Some("\\mathsf{"),
(Some("monospace"), _) => Some("\\mathtt{"),
(_, Some("b")) => Some("\\mathbf{"),
(_, Some("bi")) => Some("\\boldsymbol{"),
(_, Some("p")) if text.chars().any(|c| c.is_alphabetic()) => Some("\\mathrm{"),
_ => None,
};
if let Some(w) = wrap {
out.push_str(w);
}
for c in text.chars() {
match char_latex(c) {
Some(l) => out.push_str(l),
None => out.push(c),
}
// Checked per character, not from the run's byte length: a character may map to a
// longer macro or a shorter sign, so only the LaTeX produced counts.
if out.len() > MAX_OUT_BYTES {
return None;
}
}
if wrap.is_some() {
out.push('}');
}
Some(())
}
/// A delimiter character as a `\left`/`\right` argument.
fn delim(c: Option<&str>, default: &str) -> String {
let c = c.unwrap_or(default);
match c {
"" => ".".into(),
"{" => "\\{".into(),
"}" => "\\}".into(),
"⟨" | "〈" => "\\langle".into(),
"⟩" | "〉" => "\\rangle".into(),
"⌊" => "\\lfloor".into(),
"⌋" => "\\rfloor".into(),
"⌈" => "\\lceil".into(),
"⌉" => "\\rceil".into(),
"‖" => "\\|".into(),
"∣" => "|".into(),
other => other.into(),
}
}
fn nary_op(chr: &str) -> Option<&'static str> {
Some(match chr {
"∑" => "\\sum",
"∏" => "\\prod",
"∐" => "\\coprod",
"∫" => "\\int",
"∬" => "\\iint",
"∭" => "\\iiint",
"∮" => "\\oint",
"⋃" => "\\bigcup",
"⋂" => "\\bigcap",
"⋁" => "\\bigvee",
"⋀" => "\\bigwedge",
"⨁" => "\\bigoplus",
"⨂" => "\\bigotimes",
_ => return None,
})
}
fn accent(chr: &str) -> Option<&'static str> {
Some(match chr {
"\u{0302}" | "^" => "\\hat",
"\u{030C}" => "\\check",
"\u{0303}" | "~" => "\\tilde",
"\u{0301}" => "\\acute",
"\u{0300}" => "\\grave",
"\u{0307}" => "\\dot",
"\u{0308}" => "\\ddot",
"\u{0306}" => "\\breve",
"\u{0305}" | "\u{0304}" | "¯" => "\\bar",
"\u{20D7}" | "→" => "\\vec",
"\u{20D6}" => "\\overleftarrow",
"\u{030A}" => "\\mathring",
_ => return None,
})
}
fn node(e: &El, out: &mut String, depth: usize) -> Option<()> {
if depth > MAX_DEPTH {
return None;
}
match e.name.as_str() {
// Properties and run-level DrawingML are not content.
n if n.ends_with("Pr") => {}
"r" => run(e, out)?,
"f" => fraction(e, out, depth)?,
"rad" => radical(e, out, depth)?,
"sSup" | "sSub" | "sSubSup" => scripts(e, out, depth)?,
"nary" => nary(e, out, depth)?,
"d" => delimiters(e, out, depth)?,
"m" => matrix(e, out, depth)?,
"eqArr" => equation_array(e, out, depth)?,
"acc" => {
out.push_str(accent(val(e.child("accPr"), "chr").unwrap_or("\u{0302}"))?);
group(e.child("e"), out, depth)?;
}
"bar" => {
out.push_str(if val(e.child("barPr"), "pos") == Some("top") { "\\overline" } else { "\\underline" });
group(e.child("e"), out, depth)?;
}
"groupChr" => group_char(e, out, depth)?,
"limLow" | "limUpp" => limit(e, out, depth)?,
"func" => {
seq(e.child("fName")?, out, depth)?;
out.push(' ');
group(e.child("e"), out, depth)?;
}
"borderBox" => {
out.push_str("\\boxed");
group(e.child("e"), out, depth)?;
}
"phant" => {
out.push_str("\\phantom");
group(e.child("e"), out, depth)?;
}
"box" => group(e.child("e"), out, depth)?,
_ => return None,
}
Some(())
}
/// `m:f`: a fraction, or a binomial-style stack without a bar.
fn fraction(e: &El, out: &mut String, depth: usize) -> Option<()> {
if val(e.child("fPr"), "type") == Some("noBar") {
out.push_str("\\genfrac{}{}{0pt}{}");
} else {
out.push_str("\\frac");
}
group(e.child("num"), out, depth)?;
group(e.child("den"), out, depth)
}
/// `m:rad`: a square root, or a root with a degree.
fn radical(e: &El, out: &mut String, depth: usize) -> Option<()> {
let hide = on(e.child("radPr"), "degHide");
match e.child("deg").filter(|d| !hide && !d.children.is_empty()) {
Some(d) => {
out.push_str("\\sqrt[");
seq(d, out, depth)?;
out.push(']');
}
None => out.push_str("\\sqrt"),
}
group(e.child("e"), out, depth)
}
/// `m:sSup`, `m:sSub`, `m:sSubSup`: a base with scripts.
fn scripts(e: &El, out: &mut String, depth: usize) -> Option<()> {
group(e.child("e"), out, depth)?;
if let Some(sub) = e.child("sub") {
out.push('_');
group(Some(sub), out, depth)?;
}
if let Some(sup) = e.child("sup") {
out.push('^');
group(Some(sup), out, depth)?;
}
Some(())
}
/// `m:nary`: a large operator with its limits and operand.
fn nary(e: &El, out: &mut String, depth: usize) -> Option<()> {
let p = e.child("naryPr");
let op = nary_op(val(p, "chr").unwrap_or("∫"))?;
out.push_str(op);
if val(p, "limLoc") == Some("undOvr") && op.contains("int") {
out.push_str("\\limits");
}
if !on(p, "subHide") {
out.push('_');
group(e.child("sub"), out, depth)?;
}
if !on(p, "supHide") {
out.push('^');
group(e.child("sup"), out, depth)?;
}
out.push(' ');
group(e.child("e"), out, depth)
}
/// `m:d`: delimiters around one or more parts, with a separator between them.
fn delimiters(e: &El, out: &mut String, depth: usize) -> Option<()> {
let p = e.child("dPr");
out.push_str("\\left");
out.push_str(&delim(val(p, "begChr"), "("));
out.push(' ');
let sep = delim(val(p, "sepChr"), "|");
for (i, part) in e.kids("e").enumerate() {
if i > 0 {
out.push_str(" \\middle");
out.push_str(&sep);
out.push(' ');
}
seq(part, out, depth)?;
}
out.push_str(" \\right");
out.push_str(&delim(val(p, "endChr"), ")"));
Some(())
}
/// `m:m`: a matrix, row by row.
fn matrix(e: &El, out: &mut String, depth: usize) -> Option<()> {
out.push_str("\\begin{matrix}");
for (i, row) in e.kids("mr").enumerate() {
if i > 0 {
out.push_str(" \\\\ ");
}
for (k, cell) in row.kids("e").enumerate() {
if k > 0 {
out.push_str(" & ");
}
seq(cell, out, depth)?;
}
}
out.push_str("\\end{matrix}");
Some(())
}
/// `m:eqArr`: equations stacked one per row.
fn equation_array(e: &El, out: &mut String, depth: usize) -> Option<()> {
out.push_str("\\begin{gathered}");
for (i, row) in e.kids("e").enumerate() {
if i > 0 {
out.push_str(" \\\\ ");
}
seq(row, out, depth)?;
}
out.push_str("\\end{gathered}");
Some(())
}
/// Effective brace orientation, including the character default and its precedence over position.
fn group_char_is_over(e: &El) -> bool {
let p = e.child("groupChrPr");
match val(p, "chr").unwrap_or("\u{23DF}") {
"\u{23DE}" => true,
"\u{23DF}" => false,
_ => val(p, "pos") == Some("top"),
}
}
/// `m:groupChr`: a brace over or under its content.
fn group_char(e: &El, out: &mut String, depth: usize) -> Option<()> {
out.push_str(if group_char_is_over(e) { "\\overbrace" } else { "\\underbrace" });
group(e.child("e"), out, depth)
}
/// `m:limLow`, `m:limUpp`: a limit under or over its base.
fn limit(e: &El, out: &mut String, depth: usize) -> Option<()> {
let low = e.name == "limLow";
let base = e.child("e");
// A function name with a limit (lim, max) takes it as a subscript with \limits.
let base_is_fn = base.is_some_and(|b| {
let t: String = b.kids("r").map(run_text).collect();
b.children.iter().all(|c| c.name == "r") && FUNCTIONS.contains(&t.trim())
});
// A brace alone under the limit is a labelled brace, as `gridcraft_math::to_omml` writes
// `\underbrace{x}_{n}` and `\overbrace{x}^{n}`: the label is the brace's own script, below
// for an underbrace and above for an overbrace. An opposite-side limit keeps its placement.
let brace = base
.and_then(|b| {
let mut content = b.children.iter().filter(|c| !c.name.ends_with("Pr"));
content.next().filter(|c| c.name == "groupChr" && content.next().is_none())
})
.filter(|brace| low != group_char_is_over(brace));
if base_is_fn {
seq(base?, out, depth)?;
out.push_str(if low { "\\limits_" } else { "\\limits^" });
group(e.child("lim"), out, depth)?;
} else if let Some(brace) = brace {
group_char(brace, out, depth + 1)?;
out.push(if low { '_' } else { '^' });
group(e.child("lim"), out, depth)?;
} else {
out.push_str(if low { "\\underset" } else { "\\overset" });
group(e.child("lim"), out, depth)?;
group(base, out, depth)?;
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
fn el(xml: &str) -> El {
crate::xml::parse(format!("<m:oMath xmlns:m=\"m\" xmlns:a=\"a\">{xml}</m:oMath>").as_bytes()).unwrap()
}
fn latex(xml: &str) -> Option<String> {
to_latex(&el(xml))
}
#[test]
fn the_subset_reads_back() {
assert_eq!(latex("<m:r><m:t>x+1</m:t></m:r>").as_deref(), Some("x+1"));
assert_eq!(latex("<m:f><m:num><m:r><m:t>a</m:t></m:r></m:num><m:den><m:r><m:t>b</m:t></m:r></m:den></m:f>").as_deref(), Some("\\frac{a}{b}"));
assert_eq!(latex("<m:sSup><m:e><m:r><m:t>x</m:t></m:r></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup>").as_deref(), Some("{x}^{2}"));
assert_eq!(
latex("<m:rad><m:radPr><m:degHide m:val=\"1\"/></m:radPr><m:deg/><m:e><m:r><m:t>π</m:t></m:r></m:e></m:rad>").as_deref(),
Some("\\sqrt{\\pi }")
);
let sum = latex(
"<m:nary><m:naryPr><m:chr m:val=\"∑\"/><m:limLoc m:val=\"undOvr\"/></m:naryPr><m:sub><m:r><m:t>i=1</m:t></m:r></m:sub><m:sup><m:r><m:t>n</m:t></m:r></m:sup><m:e><m:r><m:t>i</m:t></m:r></m:e></m:nary>",
);
assert_eq!(sum.as_deref(), Some("\\sum_{i=1}^{n} {i}"));
// An integral without a character, with hidden limits.
assert_eq!(latex("<m:nary><m:naryPr><m:subHide m:val=\"1\"/><m:supHide m:val=\"1\"/></m:naryPr><m:sub/><m:sup/><m:e><m:r><m:t>f</m:t></m:r></m:e></m:nary>").as_deref(), Some("\\int {f}"));
let m = latex(
"<m:d><m:dPr><m:begChr m:val=\"[\"/><m:endChr m:val=\"]\"/></m:dPr><m:e><m:m><m:mr><m:e><m:r><m:t>1</m:t></m:r></m:e><m:e><m:r><m:t>0</m:t></m:r></m:e></m:mr><m:mr><m:e><m:r><m:t>0</m:t></m:r></m:e><m:e><m:r><m:t>1</m:t></m:r></m:e></m:mr></m:m></m:e></m:d>",
);
assert_eq!(m.as_deref(), Some("\\left[ \\begin{matrix}1 & 0 \\\\ 0 & 1\\end{matrix} \\right]"));
assert_eq!(latex("<m:r><m:rPr><m:sty m:val=\"p\"/></m:rPr><m:t>sin</m:t></m:r><m:r><m:t>x</m:t></m:r>").as_deref(), Some("\\sin x"));
assert_eq!(latex("<m:r><m:rPr><m:scr m:val=\"double-struck\"/></m:rPr><m:t>R</m:t></m:r>").as_deref(), Some("\\mathbb{R}"));
assert_eq!(latex("<m:r><m:rPr><m:nor/></m:rPr><m:t>if {x}</m:t></m:r>").as_deref(), Some("\\text{if \\{x\\}}"));
assert_eq!(
latex("<m:acc><m:accPr><m:chr m:val=\"\u{0302}\"/></m:accPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:acc>").as_deref(),
Some("\\hat{x}")
);
}
#[test]
fn what_is_outside_the_subset_is_none() {
assert_eq!(latex("<m:sPre><m:sub/><m:sup/><m:e/></m:sPre>"), None);
assert_eq!(latex(""), None, "an empty equation is not maths");
assert_eq!(latex("<m:nary><m:naryPr><m:chr m:val=\"☃\"/></m:naryPr><m:e/></m:nary>"), None);
// Deep nesting stops instead of recursing without bound.
let deep = format!("{}<m:r><m:t>x</m:t></m:r>{}", "<m:box><m:e>".repeat(100), "</m:e></m:box>".repeat(100));
assert_eq!(latex(&deep), None);
}
#[test]
#[allow(clippy::disallowed_methods)] // tests run only off wasm, where the clock exists
fn latex_past_the_source_cap_stops_converting_and_falls_back() {
// 100,000 sibling runs: the conversion stops once its LaTeX exceeds the cap that
// `gridcraft_math::check` enforces, instead of building the whole string and then
// refusing it, so the caller falls back to the writer's text in bounded time.
let wide = el(&"<m:r><m:t>x</m:t></m:r>".repeat(100_000));
let start = std::time::Instant::now();
assert_eq!(convert(&wide), None);
assert_eq!(to_latex(&wide), None);
assert!(start.elapsed() < std::time::Duration::from_secs(5), "{:?}", start.elapsed());
// One run whose text alone is past the cap is refused before it is escaped (each
// backslash would otherwise grow to `\textbackslash `).
let long = el(&format!("<m:r><m:rPr><m:nor/></m:rPr><m:t>{}</m:t></m:r>", "\\".repeat(MAX_OUT_BYTES + 1)));
assert_eq!(convert(&long), None);
// Groups count the braces they add even when no run adds text.
let groups = el(&"<m:box><m:e/></m:box>".repeat(MAX_OUT_BYTES / 2 + 1));
assert_eq!(convert(&groups), None);
// At the cap the equation still reads back, and the cap counts the LaTeX produced, not
// the run's bytes: a minus sign (three bytes of UTF-8) becomes a one-byte `-`.
let at_cap = el(&"<m:r><m:t>x</m:t></m:r>".repeat(MAX_OUT_BYTES));
assert_eq!(to_latex(&at_cap).map(|s| s.len()), Some(MAX_OUT_BYTES));
let minus = el(&format!("<m:r><m:t>{}</m:t></m:r>", "\u{2212}".repeat(MAX_OUT_BYTES)));
assert_eq!(to_latex(&minus), Some("-".repeat(MAX_OUT_BYTES)));
}
#[test]
fn a_blank_middle_row_comes_back_and_a_trailing_one_does_not() {
// The writer keeps an author's blank row and drops only the empty row a trailing `\\`
// leaves; the reader joins rows with `\\`, so the count of `\\` is the row count less one.
let back = |src: &str| {
let omml = gridcraft_math::to_omml(src, 11.0, None).unwrap();
to_latex(&crate::xml::parse(format!("<x xmlns:m=\"m\" xmlns:a=\"a\">{omml}</x>").as_bytes()).unwrap().children[0].clone()).unwrap()
};
let cases = back("\\begin{cases} x \\\\ \\\\ y \\end{cases}");
assert_eq!(cases.matches("\\\\").count(), 2, "three rows: {cases}");
let trailing = back("\\begin{matrix} a \\\\ b \\\\ \\end{matrix}");
assert_eq!(trailing.matches("\\\\").count(), 1, "two rows: {trailing}");
}
#[test]
fn a_space_in_text_mode_survives_the_round_trip() {
let omml = gridcraft_math::to_omml("\\text{if } x", 11.0, None).unwrap();
let back =
to_latex(&crate::xml::parse(format!("<x xmlns:m=\"m\" xmlns:a=\"a\">{omml}</x>").as_bytes()).unwrap().children[0].clone()).unwrap();
assert!(back.contains("\\text{ }") || back.contains("if }"), "the space after `if` is still there: {back}");
let spaces = |l: &gridcraft_math::MathList| l.items.iter().filter(|i| matches!(i, gridcraft_math::Item::Glyph(g) if g.ch == ' ')).count();
let (a, b) = (gridcraft_math::layout("\\text{if } x").unwrap(), gridcraft_math::layout(&back).unwrap());
assert_eq!(spaces(&a), spaces(&b), "{back}");
}
#[test]
fn a_limit_on_the_opposite_side_of_a_brace_keeps_its_placement() {
for (properties, brace, opposite, same, script) in [
(r#"<m:chr m:val="&#x23DF;"/><m:pos m:val="top"/>"#, "underbrace", "limUpp", "limLow", '_'),
(r#"<m:chr m:val="&#x23DE;"/><m:pos m:val="bot"/>"#, "overbrace", "limLow", "limUpp", '^'),
(r#"<m:pos m:val="top"/>"#, "underbrace", "limUpp", "limLow", '_'),
(r#"<m:chr m:val="?"/><m:pos m:val="top"/>"#, "overbrace", "limLow", "limUpp", '^'),
] {
let base = format!("<m:groupChr><m:groupChrPr>{properties}</m:groupChrPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:groupChr>");
let limit = |kind: &str| format!("<m:{kind}><m:e>{base}</m:e><m:lim><m:r><m:t>n</m:t></m:r></m:lim></m:{kind}>");
let placement = if opposite == "limLow" { "underset" } else { "overset" };
assert_eq!(latex(&limit(opposite)), Some(format!("\\{placement}{{n}}{{\\{brace}{{x}}}}")), "{properties}");
assert_eq!(latex(&limit(same)), Some(format!("\\{brace}{{x}}{script}{{n}}")), "{properties}");
}
}
#[test]
fn a_labelled_brace_reads_back_with_a_script_on_its_other_side() {
// A limit over a brace alone is the brace's label, which LaTeX writes as the brace's own
// script: `\underbrace{x}_{n}` and `\overbrace{x}^{n}`, as the writer writes them. A
// script around the whole (`m:sSup` around `m:limLow`) then comes back as well, so
// `\underbrace{x}_{n}^{2}` keeps its exponent through the round trip. A limit over any
// other base is still \underset or \overset.
let brace = |chr: &str, pos: &str| {
format!(
"<m:groupChr><m:groupChrPr><m:chr m:val=\"{chr}\"/><m:pos m:val=\"{pos}\"/></m:groupChrPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:groupChr>"
)
};
let lim = "<m:lim><m:r><m:t>n</m:t></m:r></m:lim>";
let under = format!("<m:limLow><m:e>{}</m:e>{lim}</m:limLow>", brace("\u{23DF}", "bot"));
assert_eq!(latex(&under).as_deref(), Some("\\underbrace{x}_{n}"));
assert_eq!(
latex(&format!("<m:sSup><m:e>{under}</m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup>")).as_deref(),
Some("{\\underbrace{x}_{n}}^{2}")
);
assert_eq!(latex(&format!("<m:limUpp><m:e>{}</m:e>{lim}</m:limUpp>", brace("\u{23DE}", "top"))).as_deref(), Some("\\overbrace{x}^{n}"));
// Control properties beside the brace do not make it a second child.
assert_eq!(
latex(&format!("<m:limLow><m:e><m:ctrlPr/>{}</m:e>{lim}</m:limLow>", brace("\u{23DF}", "bot"))).as_deref(),
Some("\\underbrace{x}_{n}")
);
assert_eq!(latex(&format!("<m:limLow><m:e><m:r><m:t>a</m:t></m:r></m:e>{lim}</m:limLow>")).as_deref(), Some("\\underset{n}{a}"));
assert_eq!(
latex(&format!("<m:limLow><m:e>{}<m:r><m:t>+1</m:t></m:r></m:e>{lim}</m:limLow>", brace("\u{23DF}", "bot"))).as_deref(),
Some("\\underset{n}{\\underbrace{x}+1}")
);
// Written by GridCraft and read back, both scripts are there and the glyphs are the same.
let back = |src: &str| {
let omml = gridcraft_math::to_omml(src, 11.0, None).unwrap();
to_latex(&crate::xml::parse(format!("<x xmlns:m=\"m\" xmlns:a=\"a\">{omml}</x>").as_bytes()).unwrap().children[0].clone()).unwrap()
};
let glyphs = |l: &str| -> Vec<char> {
let mut g: Vec<char> = gridcraft_math::layout(l)
.unwrap()
.items
.iter()
.filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None })
.filter(|c| !c.is_whitespace())
.collect();
g.sort_unstable();
g
};
for (src, expected) in [
("\\underbrace{x}_{n}^{2}", "{\\underbrace{x}_{n}}^{2}"),
("\\overbrace{x}^{n}_{m}", "{\\overbrace{x}^{n}}_{m}"),
("\\underbrace{x}^{2}", "{\\underbrace{x}}^{2}"),
("\\underbrace{x}_{n}", "\\underbrace{x}_{n}"),
] {
let b = back(src);
assert_eq!(b, expected);
assert_eq!(glyphs(&b), glyphs(src), "{src} came back as {b}");
}
}
#[test]
fn the_body_marker_follows_the_equation_not_its_serialisation() {
let tb = |inner: &str| {
crate::xml::parse(
format!(
"<xdr:txBody xmlns:xdr=\"x\" xmlns:a=\"a\" xmlns:m=\"m\" xmlns:a14=\"a14\"><a:bodyPr/><a:lstStyle/><a:p><a:pPr algn=\"ctr\"/><a14:m><m:oMathPara><m:oMathParaPr/><m:oMath>{inner}</m:oMath></m:oMathPara></a14:m><a:endParaRPr/></a:p></xdr:txBody>"
)
.as_bytes(),
)
.unwrap()
};
let base = body_marker(&tb("<m:r><m:t>x+1</m:t></m:r>"));
assert_eq!(base.len(), 16);
assert!(base.bytes().all(|b| b.is_ascii_hexdigit()));
// Run boundaries, whitespace between elements, attributes, and properties the converter
// does not read (`ctrlPr`, an extension list, DrawingML size, colour and font) are not
// the equation: what Excel re-serialises differently leaves the marker alone.
assert_eq!(body_marker(&tb("<m:r><m:t>x</m:t></m:r><m:r><m:t>+1</m:t></m:r>")), base);
assert_eq!(body_marker(&tb("\n <m:r>\n <a:rPr lang=\"en-US\" sz=\"1100\"/><m:t xml:space=\"preserve\">x+1</m:t></m:r>\n")), base);
assert_eq!(body_marker(&tb("<m:r><m:t>x+1</m:t></m:r><m:ctrlPr><a:rPr lang=\"en-US\" i=\"1\"/></m:ctrlPr>")), base);
assert_eq!(body_marker(&tb("<m:r><m:t>x+1</m:t></m:r><a:extLst><a:ext uri=\"{x}\"><a:z/></a:ext></a:extLst>")), base);
assert_eq!(
body_marker(&tb(
"<m:r><a:rPr sz=\"2400\"><a:solidFill><a:srgbClr val=\"FF0000\"/></a:solidFill><a:latin typeface=\"Cambria Math\"/></a:rPr><m:t>x+1</m:t></m:r>"
)),
base
);
assert_eq!(
body_marker(&tb("<m:r><m:rPr><m:rFonts m:ascii=\"Cambria\"/></m:rPr><m:t>x+1</m:t></m:r>")),
base,
"a property the converter never reads"
);
// Properties the converter reads are: a run's style, an accent's character, a delimiter's
// brackets, an operator's limits; the same property on another run is a change too.
let styled = |sty: &str| body_marker(&tb(&format!("<m:r><m:rPr><m:sty m:val=\"{sty}\"/></m:rPr><m:t>x</m:t></m:r><m:r><m:t>+1</m:t></m:r>")));
assert_ne!(styled("p"), base);
assert_ne!(styled("p"), styled("b"));
assert_ne!(styled("p"), body_marker(&tb("<m:r><m:t>x</m:t></m:r><m:r><m:rPr><m:sty m:val=\"p\"/></m:rPr><m:t>+1</m:t></m:r>")));
// The converter converts a run with a read property on its own and recognises `sin` per
// run, so a styled `sin` split into `s` and `in` (the second styled or not) is a change,
// while plain runs convert character by character and may be split or joined freely.
let upright = |runs: &[(&str, bool)]| {
let xml: String = runs
.iter()
.map(|(t, p)| format!("<m:r>{}<m:t>{t}</m:t></m:r>", if *p { "<m:rPr><m:sty m:val=\"p\"/></m:rPr>" } else { "" }))
.collect();
body_marker(&tb(&xml))
};
assert_ne!(upright(&[("sin", true)]), upright(&[("s", true), ("in", false)]));
assert_ne!(upright(&[("sin", true)]), upright(&[("s", true), ("in", true)]));
assert_ne!(upright(&[("sin", true), ("x", false)]), upright(&[("s", true), ("inx", false)]));
assert_eq!(upright(&[("ab", false)]), upright(&[("a", false), ("b", false)]));
assert_eq!(upright(&[("sin", true), ("xy", false)]), upright(&[("sin", true), ("x", false), ("y", false)]));
let accented =
|chr: &str| body_marker(&tb(&format!("<m:acc><m:accPr><m:chr m:val=\"{chr}\"/></m:accPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:acc>")));
assert_ne!(accented("\u{0302}"), accented("\u{0303}"));
let bracketed = |b: &str, e: &str| {
body_marker(&tb(&format!(
"<m:d><m:dPr><m:begChr m:val=\"{b}\"/><m:endChr m:val=\"{e}\"/></m:dPr><m:e><m:r><m:t>x</m:t></m:r></m:e></m:d>"
)))
};
assert_ne!(bracketed("(", ")"), bracketed("[", "]"));
assert_ne!(
body_marker(&tb("<m:nary><m:naryPr><m:chr m:val=\"∑\"/></m:naryPr><m:sub/><m:sup/><m:e><m:r><m:t>i</m:t></m:r></m:e></m:nary>")),
body_marker(&tb(
"<m:nary><m:naryPr><m:chr m:val=\"∑\"/><m:subHide m:val=\"1\"/></m:naryPr><m:sub/><m:sup/><m:e><m:r><m:t>i</m:t></m:r></m:e></m:nary>"
))
);
// Text and structure are the equation.
assert_ne!(body_marker(&tb("<m:r><m:t>x+2</m:t></m:r>")), base);
assert_ne!(body_marker(&tb("<m:r><m:t>x+1</m:t></m:r><m:r><m:t> </m:t></m:r>")), base, "a space added is a change");
assert_ne!(body_marker(&tb("<m:sSup><m:e><m:r><m:t>x</m:t></m:r></m:e><m:sup><m:r><m:t>+1</m:t></m:r></m:sup></m:sSup>")), base);
assert_ne!(body_marker(&tb("<m:f><m:num><m:r><m:t>x</m:t></m:r></m:num><m:den><m:r><m:t>+1</m:t></m:r></m:den></m:f>")), base);
assert_ne!(body_marker(&tb("<m:r><m:t>x+1</m:t></m:r>")), body_marker(&tb("")));
}
#[test]
fn writing_then_reading_keeps_the_maths() {
for (_, src) in gridcraft_math::GALLERY {
let omml = gridcraft_math::to_omml(src, 11.0, None).unwrap();
let back =
to_latex(&crate::xml::parse(format!("<x xmlns:m=\"m\" xmlns:a=\"a\">{omml}</x>").as_bytes()).unwrap().children[0].clone()).unwrap();
let (a, b) = (gridcraft_math::layout(src).unwrap(), gridcraft_math::layout(&back).unwrap());
let glyphs = |l: &gridcraft_math::MathList| -> String {
l.items
.iter()
.filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None })
.filter(|c| !c.is_whitespace())
.collect()
};
assert_eq!(glyphs(&a), glyphs(&b), "{src} came back as {back}");
}
}
}
+1
View File
@@ -9,6 +9,7 @@ mod chart_data_table;
mod chart_ref_offsets;
mod csv_tests;
mod encrypted_tests;
mod equation_tests;
mod fixtures;
mod google_sheets_dummy;
mod ink_roundtrip;
+471
View File
@@ -0,0 +1,471 @@
//! Equations in xlsx: Office Math with a plain-text fallback, the LaTeX source in `descr`, and
//! Office Math alone (as other writers save it) read back into LaTeX.
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::sync::Arc;
use gridcraft_core::CellRef;
use gridcraft_model::*;
use crate::{read_xlsx, write_xlsx};
fn equation(id: u32, cell: &str, latex: &str, ink: Color) -> Shape {
Shape {
id,
kind: ShapeKind::Equation,
anchor: Anchor { cell: CellRef::parse(cell).unwrap(), width: 200.0, height: 60.0, mode: AnchorMode::MoveOnly, ..Default::default() },
fill: Color::Auto,
line: ink,
text: latex.into(),
format: Default::default(),
math: Default::default(),
}
}
fn book(shapes: Vec<Shape>) -> Workbook {
let mut wb = Workbook::new();
let mut sh = (*wb.sheets[0]).clone();
for mut s in shapes {
s.math.refresh(&s.text.clone());
sh.shapes.push(s);
}
wb.sheets[0] = Arc::new(sh);
wb
}
fn unzip(bytes: &[u8]) -> BTreeMap<String, Vec<u8>> {
let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
let mut m = BTreeMap::new();
for i in 0..z.len() {
let mut f = z.by_index(i).unwrap();
let mut b = Vec::new();
f.read_to_end(&mut b).unwrap();
m.insert(f.name().to_string(), b);
}
m
}
fn rezip(parts: &BTreeMap<String, Vec<u8>>) -> Vec<u8> {
let mut out = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
for (name, data) in parts {
out.start_file(name.as_str(), zip::write::SimpleFileOptions::default()).unwrap();
out.write_all(data).unwrap();
}
out.finish().unwrap().into_inner()
}
fn drawing(parts: &BTreeMap<String, Vec<u8>>) -> String {
String::from_utf8(parts.iter().find(|(k, _)| k.starts_with("xl/drawings/drawing")).unwrap().1.clone()).unwrap()
}
fn shapes(wb: &Workbook) -> Vec<Shape> {
wb.sheets[0].shapes.clone()
}
/// Whether the drawing `d` describes an equation with `src`: `descr="gridcraft:latex:<16 hex>:src"`.
fn described(d: &str, src: &str) -> bool {
d.match_indices("descr=\"gridcraft:latex:").any(|(i, m)| {
let rest = &d[i + m.len()..];
rest.len() > 17
&& rest.as_bytes()[..16].iter().all(u8::is_ascii_hexdigit)
&& rest[16..].starts_with(':')
&& rest[17..].starts_with(&format!("{src}\""))
})
}
#[test]
fn equations_write_office_math_with_a_text_fallback_and_read_back() {
let quad = "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}";
let cd = "\\begin{CD} A @>f>> B \\end{CD}";
let wb = book(vec![
equation(1, "B2", quad, Color::Rgb(0x1F4E79)),
equation(2, "B8", cd, Color::Auto),
equation(3, "B12", "a < b \\text{ \"q\" }\nc", Color::Auto),
]);
let bytes = write_xlsx(&wb).unwrap();
let parts = unzip(&bytes);
let d = drawing(&parts);
// The quadratic formula: a choice with Office Math and a fallback with the source as text.
assert!(d.contains("<mc:Choice xmlns:a14=\"http://schemas.microsoft.com/office/drawing/2010/main\" Requires=\"a14\">"));
assert!(d.contains("<a14:m><m:oMathPara xmlns:m=\"http://schemas.openxmlformats.org/officeDocument/2006/math\">"));
assert!(d.contains("<m:f><m:num>") && d.contains("<m:rad>"));
assert!(described(&d, "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}"), "{d}");
assert!(d.contains("<a:t>x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}</a:t>"), "the fallback shows the source");
assert!(d.contains("<a:srgbClr val=\"1F4E79\"/>"), "the ink colour is kept");
// The colour sits on the Office Math runs themselves, not only on the text fallback (which
// the reader would take if the Choice were ever filtered out).
let math = &d[d.find("<a14:m>").unwrap()..d.find("</a14:m>").unwrap()];
assert!(
math.contains("<a:solidFill><a:srgbClr val=\"1F4E79\"/></a:solidFill><a:latin typeface=\"Cambria Math\"/></a:rPr>"),
"the Office Math runs carry the ink colour: {math}"
);
// A commutative diagram is outside the Office Math subset: text only, no choice.
assert_eq!(d.matches("<mc:AlternateContent").count(), 2, "{d}");
assert!(described(&d, "\\begin{CD} A @&gt;f&gt;&gt; B \\end{CD}"), "{d}");
// Read back: the source wins, exactly, including quotes and a newline.
let (back, _) = read_xlsx(&bytes).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 3);
assert!(s.iter().all(|x| x.kind == ShapeKind::Equation));
assert_eq!(s[0].text, quad);
assert_eq!(s[0].line, Color::Rgb(0x1F4E79));
assert_eq!(s[1].text, cd);
assert_eq!(s[2].text, "a < b \\text{ \"q\" }\nc");
assert_eq!(s[0].anchor.mode, AnchorMode::MoveOnly);
assert!((s[0].anchor.width - 200.0).abs() < 1.0);
}
#[test]
fn office_math_without_a_source_is_converted_to_latex() {
let quad = "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}";
let wb = book(vec![equation(1, "B2", quad, Color::Auto), equation(2, "B8", "\\sum_{k=0}^{n} \\binom{n}{k} \\le \\infty", Color::Auto)]);
let mut parts = unzip(&write_xlsx(&wb).unwrap());
// Strip GridCraft's source, as a file saved by another application would be.
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
while let Some(a) = d.find(" descr=\"gridcraft:latex:") {
let b = d[a + 8..].find('"').unwrap() + a + 9;
d.replace_range(a..b, "");
}
assert!(!d.contains("gridcraft:latex"));
parts.insert(name, d.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 2);
for (x, src) in s.iter().zip([quad, "\\sum_{k=0}^{n} \\binom{n}{k} \\le \\infty"]) {
assert_eq!(x.kind, ShapeKind::Equation, "{}", x.text);
// The conversion lays out to the same glyphs as the original.
let glyphs = |t: &str| -> String {
gridcraft_math::layout(t)
.unwrap()
.items
.iter()
.filter_map(|i| if let gridcraft_math::Item::Glyph(g) = i { Some(g.ch) } else { None })
.collect()
};
assert_eq!(glyphs(&x.text), glyphs(src), "{} from {src}", x.text);
}
}
#[test]
fn a_text_box_stays_a_text_box_and_hostile_sources_survive() {
let mut wb = book(vec![equation(1, "B2", &"{".repeat(40), Color::Auto), equation(2, "B6", "\u{0}\u{1F600} & ]]> <!-- -->", Color::Auto)]);
let mut sh = (*wb.sheets[0]).clone();
sh.shapes.push(Shape {
id: 3,
kind: ShapeKind::TextBox,
anchor: Anchor { cell: CellRef::parse("D2").unwrap(), width: 80.0, height: 30.0, ..Default::default() },
fill: Color::Auto,
line: Color::Auto,
text: "plain".into(),
format: Default::default(),
math: Default::default(),
});
wb.sheets[0] = Arc::new(sh);
let (back, _) = read_xlsx(&write_xlsx(&wb).unwrap()).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 3);
assert_eq!(s[0].text, "{".repeat(40));
assert_eq!(s[0].kind, ShapeKind::Equation);
assert_eq!(s[1].text, "\u{1F600} & ]]> <!-- -->", "XML-illegal characters are dropped, the rest kept");
assert_eq!(s[2].kind, ShapeKind::TextBox);
assert_eq!(s[2].text, "plain");
}
#[test]
fn office_math_edited_elsewhere_reads_back_as_edited() {
// GridCraft writes the source in the description and Office Math in the body. Another
// application that edits the Office Math (Excel) leaves the description alone; the marker in
// the description no longer matches the body, so the Office Math is converted and the stale
// source dropped, and the next save writes the edited equation.
let wb = book(vec![equation(1, "B2", "x^2", Color::Rgb(0x1F4E79))]);
let mut parts = unzip(&write_xlsx(&wb).unwrap());
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
assert!(described(&d, "x^2"));
// Excel's edit: the base `x` becomes `y`, in the Office Math only.
let (m0, m1) = (d.find("<m:oMath>").unwrap(), d.find("</m:oMath>").unwrap());
let edited = d[m0..m1].replacen("<m:t>x</m:t>", "<m:t>y</m:t>", 1);
assert_ne!(edited, d[m0..m1]);
d.replace_range(m0..m1, &edited);
parts.insert(name, d.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, ShapeKind::Equation);
assert_eq!(s[0].text, "{y}^{2}", "the edited Office Math, converted");
assert_eq!(s[0].line, Color::Rgb(0x1F4E79));
// Saving writes the edited equation; the old source is gone.
let again = drawing(&unzip(&write_xlsx(&back).unwrap()));
assert!(described(&again, "{y}^{2}") && !again.contains("x^2"), "{again}");
}
#[test]
fn a_marker_over_garbage_office_math_falls_back_without_panicking() {
// Office Math that is not the marker's and not in the subset either: the Choice is left
// alone and the fallback text box, which carries the description, is read as the equation.
let wb = book(vec![equation(1, "B2", "x^2", Color::Auto)]);
let mut parts = unzip(&write_xlsx(&wb).unwrap());
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
let (m0, m1) = (d.find("<m:oMath>").unwrap(), d.find("</m:oMath>").unwrap());
d.replace_range(m0..m1 + "</m:oMath>".len(), "<m:oMath><m:sPre/><m:f><m:num/></m:f><m:r/><m:t>junk</m:t><m:oMath/><m:e/></m:oMath>");
parts.insert(name.clone(), d.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, ShapeKind::Equation);
assert_eq!(s[0].text, "x^2");
// A description whose marker is malformed is not GridCraft's source: with Office Math the
// maths is converted; without it the box is text.
let d2 = drawing(&unzip(&write_xlsx(&wb).unwrap())).replace("descr=\"gridcraft:latex:", "descr=\"gridcraft:latex:zz:");
parts.insert(name, d2.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 1);
assert_eq!((s[0].kind, s[0].text.as_str()), (ShapeKind::Equation, "{x}^{2}"));
}
#[test]
fn office_math_outside_the_subset_reads_the_fallback() {
let wb = book(vec![equation(1, "B2", "x^2", Color::Auto)]);
let mut parts = unzip(&write_xlsx(&wb).unwrap());
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
// Another writer: no GridCraft source, Office Math the reader does not convert (a
// pre-subscript), and a fallback text box with its own text.
while let Some(a) = d.find(" descr=\"gridcraft:latex:") {
let b = d[a + 8..].find('"').unwrap() + a + 9;
d.replace_range(a..b, "");
}
let (m0, m1) = (d.find("<m:oMath>").unwrap(), d.find("</m:oMath>").unwrap());
d.replace_range(
m0..m1 + "</m:oMath>".len(),
"<m:oMath><m:sPre><m:sub><m:r><m:t>1</m:t></m:r></m:sub><m:sup><m:r><m:t>2</m:t></m:r></m:sup><m:e><m:r><m:t>y</m:t></m:r></m:e></m:sPre></m:oMath>",
);
let fb = d.find("<mc:Fallback>").unwrap();
let t = d[fb..].find("<a:t>x^2</a:t>").unwrap() + fb;
d.replace_range(t..t + "<a:t>x^2</a:t>".len(), "<a:t>the writer's fallback</a:t>");
parts.insert(name, d.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, ShapeKind::TextBox, "not an equation");
assert_eq!(s[0].text, "the writer's fallback", "the fallback is read, not the empty Office Math box");
}
#[test]
fn hostile_sources_round_trip_exactly() {
// Sources built from the characters XML and LaTeX treat specially: each is saved (as Office
// Math with a fallback, or as text) and read back as the same equation source.
const PARTS: &[&str] = &[
"x",
"\\frac{a}{b}",
"\"",
"'",
"<",
">",
"&",
"&amp;",
"]]>",
"<!--",
"\n",
"\t",
"\r",
"\r\n",
" ",
"\u{e9}",
"\u{1F600}",
"\u{4E2D}",
"{",
"}",
"^",
"_",
"\\\\",
"%",
];
let mut state = 0x9e37_79b9_7f4a_7c15_u64;
let mut next = |n: usize| {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
(state % n as u64) as usize
};
let mut sources = Vec::new();
for _ in 0..200 {
let mut s = String::from("x");
for _ in 0..1 + next(10) {
s.push_str(PARTS[next(PARTS.len())]);
}
sources.push(s);
}
let shapes_in: Vec<Shape> = sources.iter().enumerate().map(|(i, s)| equation(i as u32 + 1, &format!("B{}", i * 4 + 2), s, Color::Auto)).collect();
let (back, _) = read_xlsx(&write_xlsx(&book(shapes_in)).unwrap()).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), sources.len());
for (x, src) in s.iter().zip(&sources) {
assert_eq!(x.kind, ShapeKind::Equation, "{src:?}");
assert_eq!(&x.text, src, "the source survives exactly");
}
}
#[test]
fn office_math_beside_text_or_other_math_is_not_an_equation() {
// Another writer's text box: no GridCraft source, and Office Math the reader converts, but
// sharing the box with ordinary text or with a second equation. Taking it as one equation
// would drop the rest, so it stays a text box (or the writer's fallback is read).
let saved = || {
let wb = book(vec![equation(1, "B2", "x^2", Color::Auto)]);
let parts = unzip(&write_xlsx(&wb).unwrap());
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
while let Some(a) = d.find(" descr=\"gridcraft:latex:") {
let b = d[a + 8..].find('"').unwrap() + a + 9;
d.replace_range(a..b, "");
}
(parts, name, d)
};
let read = |mut parts: BTreeMap<String, Vec<u8>>, name: String, d: String| {
parts.insert(name, d.into_bytes());
shapes(&read_xlsx(&rezip(&parts)).unwrap().0)
};
let math_para = |d: &str| {
let a = d.find("<a:p><a:pPr algn=\"ctr\"/><a14:m>").unwrap();
let b = d[a..].find("</a:p>").unwrap() + a + "</a:p>".len();
d[a..b].to_string()
};
// 1. In the a14 choice, a text run before the maths: the fallback is read instead.
let (parts, name, mut d) = saved();
let at = d.find("<a14:m>").unwrap();
d.insert_str(at, "<a:r><a:rPr lang=\"en-US\"/><a:t>Solve: </a:t></a:r>");
let fb = d.find("<mc:Fallback>").unwrap();
let t = d[fb..].find("<a:t>x^2</a:t>").unwrap() + fb;
d.replace_range(t..t + "<a:t>x^2</a:t>".len(), "<a:t>Solve: x squared</a:t>");
let s = read(parts, name, d);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, ShapeKind::TextBox, "{:?}", s[0].text);
assert_eq!(s[0].text, "Solve: x squared", "the writer's fallback keeps the text");
// 2. A plain text box (no alternate content) with the same paragraph keeps its text.
let (parts, name, d) = saved();
let (c0, c1) = (d.find("<mc:Choice").unwrap(), d.find("</mc:Choice>").unwrap());
let choice = &d[c0..c1];
let sp = choice[choice.find("<xdr:sp ").unwrap()..].to_string();
let sp = sp.replacen("<a14:m>", "<a:r><a:rPr lang=\"en-US\"/><a:t>Solve: </a:t></a:r><a14:m>", 1);
let (a0, a1) = (d.find("<mc:AlternateContent").unwrap(), d.find("</mc:AlternateContent>").unwrap() + "</mc:AlternateContent>".len());
let mut d2 = d.clone();
d2.replace_range(a0..a1, &sp);
let s = read(parts, name, d2);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, ShapeKind::TextBox, "{:?}", s[0].text);
assert!(s[0].text.contains("Solve:"), "the text is kept: {:?}", s[0].text);
// 3. Two equations in one box: not read as the first one alone.
let (parts, name, d) = saved();
let para = math_para(&d);
let at = d.find(&para).unwrap() + para.len();
let mut d3 = d.clone();
d3.insert_str(at, &para);
let s = read(parts, name, d3);
assert_eq!(s.len(), 1);
assert_ne!(s[0].kind, ShapeKind::Equation, "two equations are not one");
}
#[test]
fn a_brace_with_scripts_on_both_sides_round_trips() {
// `\underbrace{x}_{n}^{2}`: the label is the brace's limit and the exponent a superscript
// around the whole. With GridCraft's source in the description, the marker matches the body
// and the source comes back as written; with the source stripped (a file another application
// saved) the Office Math is converted and the exponent is still there.
let src = "\\underbrace{x}_{n}^{2}";
let wb = book(vec![equation(1, "B2", src, Color::Auto)]);
let bytes = write_xlsx(&wb).unwrap();
let mut parts = unzip(&bytes);
let mut d = drawing(&parts);
assert!(d.contains("<m:sSup><m:e><m:limLow><m:e><m:groupChr>") && d.contains("</m:limLow></m:e><m:sup><m:r>"), "{d}");
assert!(described(&d, src), "{d}");
let (back, _) = read_xlsx(&bytes).unwrap();
assert_eq!(shapes(&back)[0].text, src, "the marker matches its own Office Math, so the source is taken");
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
while let Some(a) = d.find(" descr=\"gridcraft:latex:") {
let b = d[a + 8..].find('"').unwrap() + a + 9;
d.replace_range(a..b, "");
}
parts.insert(name, d.into_bytes());
let (converted, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&converted);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, ShapeKind::Equation);
assert_eq!(s[0].text, "{\\underbrace{x}_{n}}^{2}", "converted from the Office Math, with both scripts");
}
#[test]
fn a_text_edit_to_an_equation_without_office_math_reads_back_as_the_edit() {
// An equation outside the subset (`\begin{CD}`) is written as a text box with the source as
// its text and the marker of that box in its description. Another application that edits the
// text leaves the description alone, as Excel does; the marker then no longer matches, so the
// box is read as a text box with the edited text, not as the stale source, which the next
// save would have written over the edit.
let cd = "\\begin{CD} A @>f>> B \\end{CD}";
let wb = book(vec![equation(1, "B2", cd, Color::Auto)]);
let mut parts = unzip(&write_xlsx(&wb).unwrap());
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
assert!(!d.contains("<m:oMath>"), "{d}");
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
assert_eq!((shapes(&back)[0].kind, shapes(&back)[0].text.as_str()), (ShapeKind::Equation, cd), "unedited, the source is taken");
// Excel's edit: the text of the box, not the description.
let t0 = d.find("<a:t>").unwrap();
let t1 = d[t0..].find("</a:t>").unwrap() + t0 + "</a:t>".len();
d.replace_range(t0..t1, "<a:t>edited in Excel</a:t>");
parts.insert(name, d.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 1);
assert_eq!((s[0].kind, s[0].text.as_str()), (ShapeKind::TextBox, "edited in Excel"), "the edit, not the stale source");
// The fallback box of an equation with Office Math carries the marker of its own text, not
// the Office Math's, so a reader left with the fallback alone (the Choice gone) still takes
// the source.
let mut parts = unzip(&write_xlsx(&book(vec![equation(1, "B2", "x^2", Color::Auto)])).unwrap());
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
let markers: Vec<String> = d.match_indices("descr=\"gridcraft:latex:").map(|(i, m)| d[i + m.len()..i + m.len() + 16].to_string()).collect();
assert_eq!(markers.len(), 2, "{d}");
assert_ne!(markers[0], markers[1], "the Choice and the Fallback each carry their own body's marker");
let (c0, c1) = (d.find("<mc:Choice").unwrap(), d.find("</mc:Choice>").unwrap() + "</mc:Choice>".len());
d.replace_range(c0..c1, "");
parts.insert(name, d.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!((s.len(), s[0].kind, s[0].text.as_str()), (1, ShapeKind::Equation, "x^2"), "the fallback alone still reads as the equation");
}
#[test]
fn a_paragraph_split_in_an_equation_without_office_math_reads_back_as_the_edit() {
// The marker joins run text across paragraphs, so splitting the one paragraph of a plain-text
// equation box into two without changing a character leaves it equal to the description's.
// The box's text, paragraphs included, is compared with what the writer wrote as well, so
// the split reads back as a text box with two paragraphs instead of the stale source.
let cd = "\\begin{CD} A @>f>> B \\end{CD}";
let wb = book(vec![equation(1, "B2", cd, Color::Auto)]);
let mut parts = unzip(&write_xlsx(&wb).unwrap());
let name = parts.keys().find(|k| k.starts_with("xl/drawings/drawing")).unwrap().clone();
let mut d = drawing(&parts);
let (p0, p1) = (d.find("<a:p>").unwrap(), d.find("</a:p>").unwrap() + "</a:p>".len());
let para = d[p0..p1].to_string();
let (t0, t1) = (para.find("<a:t>").unwrap() + "<a:t>".len(), para.find("</a:t>").unwrap());
let cut = t0 + para[t0..t1].find(' ').unwrap() + 1;
let split = format!("{}{}{}{}", &para[..cut], &para[t1..], &para[..t0], &para[cut..]);
assert_eq!(split.matches("<a:p>").count(), 2);
d.replace_range(p0..p1, &split);
parts.insert(name, d.into_bytes());
let (back, _) = read_xlsx(&rezip(&parts)).unwrap();
let s = shapes(&back);
assert_eq!(s.len(), 1);
assert_eq!(s[0].kind, ShapeKind::TextBox, "the split is an edit: {}", s[0].text);
// The cut falls after the first space, which stays in the first paragraph, so the text read
// back is the source with a newline after that space, and nothing else moved or dropped.
assert_eq!(s[0].text, cd.replacen(' ', " \n", 1), "the split text, exactly");
}
+1
View File
@@ -62,6 +62,7 @@ fn exported_ink_has_a_visible_unfilled_path_and_correct_stroke() {
line: Color::Rgb(0xA12BCD),
text: "-10.0,5.0;50.0,40.0;90.0,10.0".into(),
format: Default::default(),
math: Default::default(),
});
}
let wb = Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() };
+3
View File
@@ -352,6 +352,7 @@ fn sample() -> Workbook {
alt: "Logo \"x\"".into(),
});
s.shapes.push(Shape {
math: Default::default(),
id: 11,
kind: ShapeKind::Ellipse,
anchor: Anchor { cell: at("E10"), dx: 0.0, dy: 0.0, width: 64.0, height: 40.0, mode: AnchorMode::Absolute },
@@ -361,6 +362,7 @@ fn sample() -> Workbook {
format: Default::default(),
});
s.shapes.push(Shape {
math: Default::default(),
id: 12,
kind: ShapeKind::Arrow,
anchor: Anchor { cell: at("E14"), dx: 0.0, dy: 0.0, width: 64.0, height: 0.0, ..Default::default() },
@@ -370,6 +372,7 @@ fn sample() -> Workbook {
format: Default::default(),
});
s.shapes.push(Shape {
math: Default::default(),
id: 13,
kind: ShapeKind::TextBox,
anchor: Anchor { cell: at("E16"), dx: 0.0, dy: 0.0, width: 64.0, height: 40.0, ..Default::default() },
+1
View File
@@ -45,6 +45,7 @@ fn text_box(text: &str, format: ShapeFormat) -> Workbook {
line: Color::Auto,
text: text.into(),
format,
math: Default::default(),
});
Workbook { sheets: vec![Arc::new(sheet)], ..Default::default() }
}
+20 -1
View File
@@ -1,6 +1,6 @@
# Architecture
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-31 · **Change:** minor (recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-01 · **Change:** minor (equations: the `math` crate and the layout cache; earlier: recently used functions kept in the preferences; Function Library module; accent icons; earlier: the hidden names that hold macros) · **Target:** Microsoft Excel (Microsoft 365)
How GridCraft is built today. Rules for contributors are in [`AGENTS.md`](../AGENTS.md).
@@ -16,6 +16,7 @@ including tests, 2026-10-10.
| L0 | `numfmt` | ~2.1k | Excel number-format codes (sections, conditions, colours, dates, elapsed time, fractions, scientific, fill), General, `TEXT()`; en-US only |
| L1 | `formula` | ~1.7k | Tokenizer, parser, AST, printer (A1 and R1C1), reference adjustment for copy, fill and structural edits |
| L1 | `functions` | ~13.4k | The function library: `FnSpec` registry (name, arity, category, scalar args, signature, description) and pure implementations; automatic array lifting |
| L1 | `math` | ~1.1k | Equations: LaTeX laid out by RaTeX (MIT, pinned) into plain glyph, rule and path data, and LaTeX to Office Math; every entry point capped and panic-guarded |
| L2 | `model` | ~2.2k | Workbook, sheets, copy-on-write cell store (64-row bands behind `Arc`), interned styles, names, tables, CF, validation, comments, charts, shapes, images, sparklines, print settings, protection |
| L2 | `calc` | ~2.9k | Evaluator (references, names, tables, LET/LAMBDA, special forms like OFFSET/INDIRECT/SUBTOTAL) and dependency-graph recalculation with dynamic arrays, spills and cycle detection; single-threaded |
| L2 | `xlsx` | ~10.7k | XLSX read/write (transitional and strict read), CSV/TSV, XLSB and ODS data import, PivotTable caches, DrawingML charts and drawings; byte slices only (WASM-safe); lenient reader that reports warnings |
@@ -83,6 +84,8 @@ drawing its own:
ahead of its input, so the field never gets a Tab or an Enter of a focused editor; and it
scrolls past `max_lines` with the caret in view. The tokenizer and the key edits are pure functions with tests, including
hostile input.
- **Code editor marks**: `code_editor_marked` underlines a byte range in the error colour (the
equation editor marks the position of a LaTeX error with it).
- **Formula bar**: grows with its content from one line to `formula_bar::MAX_LINES` (five), then
scrolls; one line is Excel's 30 px unless the font's rows need more (`formula_bar::one_line`;
the Windows UI font's row is 17.28 px), so every counted line fits whatever the font;
@@ -149,6 +152,20 @@ The macro engine (`engine/src/macros/`, commands in `cmd/macros.rs`, described i
recording buffer, the policy asks before a macro in a `FromFile` workbook runs, and xlsx keeps
macros as chunked hidden names (`_gc_macro_<n>_<k>`: macro `n`, chunk `k`).
## Equations
An equation is a shape (`ShapeKind::Equation`) whose `text` is its LaTeX source; it selects,
opens and deletes as its own object kind (`ObjKind::Equation`). `gridcraft-math` is the only
code that calls the layout engine (RaTeX), and only through `layout`, `check` and `to_omml`,
which cap the source (4 KB, nesting 32) and run under `catch_unwind`. The result is cached on
the shape (`Shape::math`, never saved); `Workbook::refresh_equations` rebuilds stale layouts when
a document is created and on every commit, so painting only reads the cache and nothing in a
paint path can reach the layout engine. One call lays out at most 128 KB of source
(`MAX_EQUATION_LAYOUT_BYTES`); equations past it keep an error and show their source until edited,
so a file with thousands of equations opens in bounded time. The equation editor's preview and the gallery are the
only other callers, through the same guarded entry points. The gate that admitted RaTeX is
recorded in [`decisions.md`](decisions.md) and re-run by `crates/math/tests/gate.rs`.
## Agent control
- Desktop control channel: `gridcraft --control 7979`, JSON lines on localhost
@@ -174,6 +191,8 @@ rpm, tarball), FreeBSD, and web (WASM; WebGPU or WebGL2). Release workflows in
| Date | Change | Summary |
|---|---|---|
| 2026-11-01 | trivial | Equations: the layout cache answers for a source over the cap by its length alone, without hashing it |
| 2026-11-01 | minor | Equations: the `math` crate (L1), the layout cache and code editor marks |
| 2026-10-31 | minor | Recently used functions (`recent.rs`, `Prefs::recent_functions`) behind `formulas.recentlyUsed` |
| 2026-10-31 | minor | Function Library module (`function_library.rs`); accent icons (`icons::paint_accent`, `widgets::small_button_accent`) |
| 2026-10-25 | trivial | The hidden names that hold macros are `_gc_macro_<n>_<k>` |
+6 -2
View File
@@ -92,8 +92,12 @@ include `window` or `doc` to run it in that window, as with `engine.execute`; th
them as their own parameters.
`ui.inspect` reports the selected floating object as `selectedObject: {kind, id}` (or `null`),
where `kind` is `chart`, `image`, `shape` or `slicer`; the same names go in the `kind` parameter
of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands.
where `kind` is `chart`, `image`, `shape`, `equation` or `slicer`; the same names go in the `kind` parameter
of `object.delete`, `object.move`, `object.setAnchorMode` and the `arrange.*` commands. An
equation is a shape too, so its id works under `shape`; a plain shape's id given as `equation`,
a `kind` that is present but not one of these names (`null` included), or an id of no object of
the kind, is an error. `arrange.align` and `arrange.group` check every id in `ids` the same way;
with fewer than two ids they change nothing.
`object.move` and `chart.set` cap `width` and `height` at 10,000 points, or at the object's
current size when a file made it larger: such an object keeps its size when it is moved and
shrinks by what a resize asks, but does not grow further.
+45 -1
View File
@@ -79,4 +79,48 @@ to 115% or 100%. That's a large relative step at low zoom and a small one near 4
relative steps keep each notch feeling the same, and the doublings land on 25, 50, 100, 200 and
400%. The status bar's Zoom In / Zoom Out buttons and Ctrl+Alt+= / Ctrl+Alt+- keep their 10%
steps, so the wheel and the buttons now step differently. Whether they should share one ladder
(this one, Excel's, or another) is left to the maintainers.
(this one, Excel's, or another) is left to the maintainers.
## Equations are laid out by RaTeX, behind a guard
GridCraft lays equations out with RaTeX (`ratex-parser`, `ratex-layout`, `ratex-types`,
`ratex-font`, MIT), pinned exactly at 0.1.14. The gate that chose it, rather than an in-house
LaTeX subset:
- **Source scan.** The five crates hold about 31,700 lines. Outside tests and the bundled
command-line tools there are 95 sites that can panic (`unwrap`, `expect`, `panic!`,
`unreachable!`), 87 of them in the parser. Most take an argument the parser has already
counted (`args.into_iter().next().unwrap()`) or compile a fixed regular expression. There is
no `unsafe`, no clock (`Instant::now`, `SystemTime`), no threads, and no file or environment
access. Indexing is not counted; the fuzz run covers it.
- **Fuzzing.** 70,000 random token strings and 50,000 mutations of valid equations: no panic,
no input slower than 5 ms. Self-referencing and exponential macros stop at the parser's
1,000-expansion limit. Nesting deeper than 32 is refused ("Recursion limit exceeded"), and
the deepest accepted input needs under 256 KB of stack in a release build, well inside the
smallest main-thread stack we run on (1 MB on Windows). Absurd sizes (`\rule{1e308em}{1em}`)
lay out with huge dimensions, so the wrapper caps them.
- **wasm.** The crates build for `wasm32-unknown-unknown`.
- **Regular expressions.** `ratex-parser` pulls in `fancy-regex` (with `bit-set` 0.8 and a
second `bit-vec`) for one module, its port of mhchem: 62 anchored patterns in
`src/mhchem/data/patterns_regex.json` and a dozen in `src/mhchem/patterns.rs` tokenise the
argument of `\ce{…}` and `\pu{…}`, and need lookahead, which the linear-time `regex` crate
does not have (`regex` and `regex-lite` serve the parser's other, fixed patterns). Every
pattern is compiled with `fancy-regex`'s default backtrack limit of 1,000,000 steps per
match; past it the match is reported as no match, so the macro fails instead of hanging.
The limit is not reachable from outside the crate. `gate.rs` times 38 sources of exactly
4,096 bytes shaped to stress the parser and those patterns (nesting at the depth cap, runs of
backslashes and braces, `(a+)+b`, wide and tall environments, chemistry such as `\ce{+-+-…}`,
`\ce{111…}` and `\pu{kJ//…}`): the slowest takes 0.8 s in a debug build, with a 10 s bound
per source.
- **Syntax.** Fractions, roots, scripts, big operators and limits, `\left`/`\middle`/`\right`,
matrices (`matrix`, `pmatrix`, `bmatrix`, `vmatrix`, `cases`, `aligned`, `array`), accents,
braces, arrows, `\mathbb`/`\mathcal`/`\mathfrak`, `\text`, colours and mhchem all lay out.
Because a stray panic is still possible, RaTeX runs only inside `gridcraft-math`, under
`catch_unwind`, with input capped at 4 KB and depth 32. Its output is converted to plain data and
cached on the shape; painting (egui and PDF) reads only that cache, so nothing in a paint path
can reach RaTeX. On the web build (`wasm32-unknown-unknown`) a panic aborts instead of unwinding,
so there the caps and the fuzzed input space are the protection. `crates/math/tests/gate.rs` re-runs a smaller, deterministic version of the
gate on every test run, so a version bump has to pass it. RaTeX positions glyphs with KaTeX
font metrics; GridCraft never bundles the KaTeX fonts, so glyphs are drawn with system fonts
and a glyph no font has is drawn as a `[?]` box.
+20 -11
View File
@@ -1,6 +1,6 @@
# File-format parity
> **Last reviewed:** 2026-11-01 · **Last updated:** 2026-11-01 · **Change:** minor (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-02 · **Change:** minor (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**
@@ -76,15 +76,15 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real
| Format | Excel | GridCraft read | GridCraft write | Fidelity / notes | Tests |
|---|---|---|---|---|---|
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state and their box's place and size, #1608) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, shared strings (with their Japanese phonetic runs — ふりがな — kept through open + save: `<rPh>` offsets and readings and `<phoneticPr>` are written back next to the same string; the table holds each text once, so duplicate `<si>` items whose text is the same but whose readings differ keep only the first reading, with a warning on open), styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings, show objects (`workbookPr showObjects`). **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) A picture used on several sheets is written once (#985). | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), pictures in headers and footers (`&G`, #1257: their VML drawing and images are written back unchanged; they are not shown, and can't be inserted), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, notes (with their shown/hidden state) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes (the major/minor `latin`, East Asian `ea` and complex-script `cs` typefaces and the per-script `<a:font script>` lists are kept through a save), merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Kept unchanged on save (pass-through, `passthrough.rs`):** custom document properties, ribbon customisation and add-in task panes, custom XML, links to external workbooks (never followed or refreshed), data connections, sheet query tables, printer settings (#324), form controls, ActiveX controls, OLE objects and sheet backgrounds (with their VML shapes), and chart sheets (#727: the sheet, drawing, chart, style and colour parts are written back unchanged in their place, and left out when a sheet they plot was deleted or renamed or a worksheet took their name; they can't be edited or created); dropped when their sheet is deleted or their parts point at content GridCraft rewrites. **Not preserved:** slicers and slicer caches, timelines, data model, query tables attached to tables, sheet and workbook `extLst` entries we don't model, chart details; Strict Open XML files keep nothing. Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, notes (with their shown/hidden state and their box's place and size, #1608) and threaded comments with replies, resolved state and timestamps (`threadedComments` and `persons` parts, #300), hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Excel Workbook (transitional) | R/W, native | yes | yes | Cells, shared/array/dynamic formulas, styles, themes, merges, CF, validation, tables, comments and notes, hyperlinks, names, freeze panes, outline, print settings, pictures, shapes, equations (Office Math with a plain-text fallback and the LaTeX source; Office Math from other writers read for a common subset), charts (simplified, see [chart-parity.md](chart-parity.md)), sparklines, PivotTables (caches and tables), protection, Allow Edit Ranges (`protectedRanges` with their password hashes, #307), calc settings. **Not preserved:** unmodelled parts and extensions (form controls, slicer caches, timelines, external links, OLE objects, custom XML, data model, query tables, chart details). Never checked in Excel across a corpus: repair prompts unknown. 100 MB files fail (#175) | `roundtrip.rs`, `fixtures.rs`, `malformed.rs`, `pivot_tests.rs` |
| `.xlsx` Strict Open XML | R/W | yes | no (writes transitional) | Excel also saves transitional by default | fixtures |
| `.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` |
@@ -116,7 +116,7 @@ past the parser's limit, and a wrapper inside the text. **Saving writes the real
| Format | Excel | GridCraft | Notes |
|---|---|---|---|
| PDF | Save As / Export | yes (own PDF 1.7 writer; CLI `convert` output) | Printed cell hyperlinks export as clickable HTTP(S)/mailto links or destinations within printed pages (#954); unsafe schemes and destinations outside the print range are omitted. Only the 14 standard PDF fonts, no embedding: **non-Latin text (CJK, Arabic, Devanagari, Thai) does not export**; web build exports without a file prompt (#141) |
| PDF | Save As / Export | yes (own PDF 1.7 writer; CLI `convert` output) | Printed cell hyperlinks export as clickable HTTP(S)/mailto links or destinations within printed pages (#954); unsafe schemes and destinations outside the print range are omitted. No font is embedded: text uses the standard Helvetica, Times and Courier faces (13 base-14 entries with Symbol), and characters outside WinAnsiEncoding are set in a predefined, non-embedded CID font (STSong-Light, Adobe-GB1, `UniGB-UTF16-H`), so **Simplified Chinese displays where the reader supplies that font, and scripts outside Adobe-GB1 (Arabic, Devanagari, Thai) export as code points but show as the reader's missing-glyph box**; equations use Symbol for the Greek letters and operators it has, the Times faces for letters and digits, and a `[?]` box for the rest; web build exports without a file prompt (#141) |
| XPS | Windows | no | Low priority |
| Print | yes | yes (via PDF, system dialog on desktop; PR #143 for web) | Pages print in the sheet's page order: down, then over (the default) or over, then down |
| Images of ranges/charts (Copy as Picture) | yes | chart raster in `crates/chart` | No range-to-image command |
@@ -145,6 +145,15 @@ refresh. Counted under features (Get & Transform, ~10% ready), not here.
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | trivial | An equation without Office Math takes its stored source only while its text, paragraphs included, is what the writer wrote for it; a paragraph split that changes no character reads back as a text box with the split text |
| 2026-11-02 | trivial | Each equation box carries the marker of its own body in its description, the Office Math box and the plain-text fallback alike, and the reader takes the stored source only while the body it reads matches the marker: the text of an equation without Office Math that another application edited reads back as a text box with the edited text instead of the stale source |
| 2026-11-02 | trivial | A brace with a label and a script on its other side (`\underbrace{x}_{n}^{2}`) writes the label as the brace's limit and the other script around the whole (`m:sSup` or `m:sSub`), so an application reading the Office Math sees both; without a label the script goes around the bare brace, with no empty limit. A limit over a brace alone reads back as the brace with the label as its script (`\underbrace{x}_{n}`), so both scripts return from Office Math |
| 2026-11-02 | trivial | An equation's `descr` carries a marker (16 hex digits) of the Office Math written beside it; when another application edits the Office Math and leaves the description alone, as Excel does, the file reads back as the edited equation, converted from Office Math, and the next save writes that. The PDF fallback text of an equation without a layout is clipped to its frame |
| 2026-11-02 | trivial | The PDF row describes the fonts as the writer uses them: no embedding, the standard faces with Symbol, and the non-embedded Adobe-GB1 CID font for characters outside WinAnsiEncoding (Simplified Chinese displays; other scripts export as code points without glyphs) |
| 2026-11-02 | trivial | PDF export prints an equation that has no layout (a source the engine refuses, or one past the open-time budget) one source line per text run from the first 4,096 bytes of the source (a longer source never lays out), as many as its frame holds and never more than 500, instead of one run with `?` for each newline |
| 2026-11-02 | trivial | Office Math runs drop the characters XML cannot carry (C0 controls other than tab, newline and carriage return, U+FFFE and U+FFFF), as the rest of the drawing part does |
| 2026-11-02 | trivial | Office Math whose LaTeX would exceed the equation source cap (4,096 bytes) stops converting and reads the writer's fallback, so a wide or long equation in a hostile file costs bounded time and memory |
| 2026-11-02 | minor | Equations: xlsx writes Office Math (`a14:m`) with a plain-text fallback and the LaTeX source in `descr` behind a marker of the Office Math written, and reads Office Math from other writers back into LaTeX for a common subset; PDF draws equations with Symbol for the Greek letters and operators it has, the Times faces for letters and digits, and a `[?]` box for the rest |
| 2026-11-01 | minor | Chart data tables preserve showKeys, showHorzBorder, showVertBorder and showOutline through native c:dTable XLSX import/export; synthetic tests cover all 16 flag combinations and boolean defaults. Excel-side verification remains outstanding. |
| 2026-11-01 | minor | XLSX reads and writes Page Setup's page order: `<pageSetup pageOrder="overThenDown">` for Over, then down, and no attribute for Down, then over (the default), as Excel writes them; PDF export and printing number and order the pages by it |
| 2026-11-01 | trivial | XLSX selects the first visible workbook view as one unit and bounds imported/exported tab ratios; isolated attributes and sheet-edit round trips covered, with firstSheet left to #2264 (#3057) |
+5 -3
View File
@@ -1,6 +1,6 @@
# Gaps
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (§13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (§17: equations; earlier: §13: Solver Sensitivity report; earlier: §4: chart resizing is no longer a gap; earlier: §13: Analysis ToolPak has all 19 tools; earlier: §4: chart data tables implemented with XLSX round-trip and print/PDF; earlier: §13: Solver Options; earlier: §13: Solver Load/Save Model and Reset All) · **Target:** Microsoft Excel (Microsoft 365)
Every known shortfall against Excel, one entry each, ranked by user impact (what blocks someone
using GridCraft for real work first). This is the work list: pick from the top, check the open
@@ -148,8 +148,9 @@ 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, equations, signature line, objects, 3-D models, screenshot; 8 shape kinds vs
~170; pictures in cells (#61, PR #105). **Estimate:** 25–40 h.
- SmartArt, WordArt, 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.
### 18. Accessibility (U)
- AccessKit on, but no accessible grid/ribbon tree, no high contrast, no checker. **Estimate:** 12–18 h.
@@ -185,6 +186,7 @@ No alpha blockers: every core workflow passes the [alpha gate](roadmap.md#alpha-
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | §17: equations (Insert › Equation, LaTeX source, gallery, editor) |
| 2026-11-02 | minor | §13: Solver Sensitivity report: shadow prices, reduced costs and allowable ranges for Simplex LP; Lagrange multipliers and reduced gradients for GRG Nonlinear |
| 2026-11-02 | minor | §4: charts, like every floating object, move and resize from eight handles (#190), so resizing is no longer listed as missing; the evidence is one paragraph again, without the stale "PRs #158, #161 open" |
| 2026-11-01 | minor | §13: the Analysis ToolPak has all 19 of Excel's tools (two-factor Anova with and without replication, z-Test, Fourier Analysis, Random Number Generation, Sampling); its chart output remains. The stale "`data.solver`, `data.analysisToolPak` missing" and "no Forecast Start" sentences removed |
+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: 276 / 292 commands implemented (94.5%).**
**Overall: 277 / 292 commands implemented (94.9%).**
| Tab | Implemented | Total | Parity |
|---|---|---|---|
| File | 11 | 11 | 100% |
| Home | 94 | 94 | 100% |
| Insert | 17 | 25 | 68% |
| Insert | 18 | 25 | 72% |
| Draw | 4 | 4 | 100% |
| Page Layout | 23 | 23 | 100% |
| Formulas | 23 | 23 | 100% |
@@ -139,7 +139,7 @@ Implemented:
- `edit.goToSpecial` Go To Special (Editing)
- `home.addIns` Add-ins (Add-ins)
## Insert (17 / 25, 68%)
## Insert (18 / 25, 72%)
Implemented:
@@ -158,6 +158,7 @@ Implemented:
- `review.newComment` Comment (Comments)
- `insert.textBox` Text Box (Text)
- `pageLayout.headerFooter` Header & Footer (Text)
- `insert.equation` Equation (Symbols)
- `insert.symbol` Symbol (Symbols)
- `insert.checkbox` Checkbox (Cell Controls)
@@ -170,7 +171,6 @@ Missing:
- `insert.wordArt` WordArt (Text)
- `insert.signatureLine` Signature Line (Text)
- `insert.object` Object (Text)
- `insert.equation` Equation (Symbols)
## Draw (4 / 4, 100%)
+4 -3
View File
@@ -1,6 +1,6 @@
# Target-app parity: GridCraft vs Microsoft Excel
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (what-if and analysis: Solver Sensitivity report; no score change; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-11-02 · **Change:** minor (insert objects: equations; earlier: what-if and analysis: Solver Sensitivity report; no score change; earlier: chart resizing no longer listed as missing; no score change; earlier: what-if and analysis: Analysis ToolPak has all 19 tools; earlier: page layout and print: Page Setup's page order; no score change; earlier: charts: data tables with XLSX round-trip and print/PDF; no score change; earlier: what-if and analysis: Solver Options; the Insert Function dialog is named as in Excel; no score change; earlier: Solver Load/Save Model and Reset All; automation: macro interface; catalog count 276 / 292, as `cargo xtask parity` measures; automation: typed macros in the engine; several windows: a native window per workbook window, New Window on the same workbook, Arrange All; no score change) · **Target:** Microsoft Excel (Microsoft 365)
This is the authoritative parity assessment. [`ROADMAP.md`](../ROADMAP.md) summarizes it,
[`gaps.md`](gaps.md) lists every shortfall, and the area checklists hold the detail:
@@ -13,7 +13,7 @@ generated ribbon checklist is [`parity-checklist.md`](parity-checklist.md).
| Number | Value | Kind |
|---|---|---|
| Feature breadth (weighted by use) | **~75%** | estimated from the area table below; anchored on two measurements |
| Ribbon and menu catalog | **276 / 292 (94.5%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Ribbon and menu catalog | **277 / 292 (94.9%)** | measured: `cargo xtask parity` (`crates/engine/src/catalog.rs` ∩ registered command ids) |
| Worksheet functions, by name | **513 / 523 (98.1%)** | measured: registered functions vs Microsoft's published function list (method below) |
| Worksheet functions that do real work | **504 / 523 (96.4%)** | measured: excludes 9 stubs (7 CUBE functions, WEBSERVICE, FILTERXML) |
| **Ready for real work** | **~50%** | estimated; written weights below (51.6 computed) |
@@ -91,7 +91,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. 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. Missing: SmartArt, WordArt, equations, 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, own icon set, ink with Ink to Shape, hyperlinks, checkboxes, 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) |
| 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 or a Ctrl+Shift shortcut. 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** | |
@@ -334,6 +334,7 @@ The inventory carried over from the 2026-10-07 ROADMAP.md, updated for what land
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Insert objects: equations; catalog count 277 / 292 (no score change) |
| 2026-11-02 | minor | What-if and analysis: Solver Sensitivity report (no score change) |
| 2026-11-02 | minor | Charts move and resize from eight handles like every floating object (#190): the Essentials chart work is polish only (5–8 h), and the chart rows and the interaction discount no longer list resizing as missing; the Mainstream chart row no longer lists trendlines as missing (no score change) |
| 2026-11-01 | minor | What-if and analysis: Analysis ToolPak has all 19 tools (was 13 of 19; no charts); no score change |
+5 -4
View File
@@ -1,10 +1,10 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (Insert › Equation and the equation editor; earlier: every floating object selects, resizes from eight handles and deletes with the keyboard; earlier: Formulas › Error Checking › Trace Error; trace arrows stay through a zoom (#3097); earlier: Page Setup sets the page order, Down, then over or Over, then down, which printing, the print preview, Page Break Preview and Page Layout view follow; earlier: Formulas › Function Library as labelled buttons with category menus; Recently Used and the Insert Function categories; Esc leaves a File-page text field before closing the view; earlier: checkbox cells print as boxes; Ctrl+arrow and Ctrl+Shift+arrow while pointing a formula reference; editing a hyperlink loads its existing fields; AutoSum over a selected row puts its total to the right; ribbon catalog count 276 / 292) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
Interaction, controls and feel, against Excel. Part of [target-app-parity.md](target-app-parity.md).
**UI/UX fidelity: ~55% ready, 50–80 h** (estimated). Ribbon command coverage is measured
separately in [parity-checklist.md](parity-checklist.md) (276 / 292).
separately in [parity-checklist.md](parity-checklist.md) (277 / 292).
Sources: `crates/ui-egui/src` (ribbon, grid, editor, formula bar, dialogs, panes),
`crates/engine/src/cmd` (shortcuts in command specs), the owner's black-box notes
@@ -68,7 +68,7 @@ this logic, with one undo step.
| A Table's header names and filter buttons in the column headings once its header row scrolls away | done (#1423): with the active cell in the Table, as in Excel; the button opens the column's filter menu, the rest of the heading still selects the column. Plain AutoFilter ranges keep their letters (Excel does the same) |
| Copy as HTML table for other apps | done (#36, 10-10) |
| Context menu on cells | 16 items (Cut, Copy, Paste, Paste Special, Insert, Delete, Clear Contents, Filter by value, Sort, New Comment, New Note, Format Cells, Pick From Drop-down List, Define Name, Link). Insert Copied Cells (Insert Cut Cells for whole rows/columns) while the clipboard holds a copy (#21). Show Details on a PivotTable's value, subtotal or grand total cell (`pivot.showDetails`). Missing: Insert Cut Cells for a cell block, paste-option icons, Quick Analysis, Get Data from Table, Show Changes, Smart Lookup |
| Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing |
| Move and resize charts, pictures and shapes | done (#190, #213): charts, pictures, shapes, equations and slicers select with a frame of eight handles; each handle resizes only its own edges (they never cross), the body moves the object, and the pointer shows a resize or move cursor; Delete or Backspace deletes the selected object and Escape drops the selection; double-click edits a text box, opens Format Chart for a chart. Rotation handle, Shift for proportional resizing, Alt to snap to cells and multi-object selection missing |
| Context menus on row/column headers, sheet tabs, charts, shapes | headers and tabs done; objects partial |
| Quick Analysis (Ctrl+Q) | missing |
| Smooth scrolling, scrollbars | done; vertical scrollbar added 10-09 (#73); horizontal scrollbar in the tab bar (60/40 split, drag, track click pages) 10-13 (#176, #840); dragging the handle at the scrollbar's left edge gives the tabs more or less of the bar; each workbook keeps its share while it is open (not saved in the file) |
@@ -163,7 +163,7 @@ Name Manager, Data Validation, Paste Special, Conditional Formatting manager, Pa
Seek, Scenario Manager, Data Analysis, Forecast Sheet, Macros (macros, Automate scripts and a
note on a VBA project; Run, Step Into, Edit, Create, Delete, Options), Record Macro, the macro step
editor (Excel's VBA editor has no counterpart here) and others exist. Missing or partial: Options,
Solver, Power Query editor, Insert Object, Equation editor, Protect/Allow Edit Ranges,
Solver, Power Query editor, Insert Object, Protect/Allow Edit Ranges,
Workbook Links, Accessibility checker. Like Excel's, every dialog opens centred and moves by its
title bar, keeping its place while it is open (#1250).
Go To lists the named ranges of every sheet (a name scoped to another sheet as `Sheet2!Name`) and
@@ -196,6 +196,7 @@ scrollbar bugs 2–4 · pixel/feel pass against observed Excel 7–12.
| Date | Change | Summary |
|---|---|---|
| 2026-11-02 | minor | Insert › Symbols: Equation split button (access keys N E), a gallery of examples, and an equation editor with quick-insert buttons, the error underlined at its position and a live preview; double-clicking an equation edits it |
| 2026-11-02 | minor | Floating objects: one selection for charts, pictures, shapes and slicers, eight resize handles with matching cursors (right-to-left sheets included), Escape drops the selection |
| 2026-11-01 | minor | Chart Design › Add Chart Element offers Data Table with/no legend keys and None; Format Chart Area has a Data Table section with visibility, legend keys, horizontal/vertical borders and outline. Both use engine commands. |
| 2026-11-01 | minor | Page Setup has a Page order choice (Down, then over, the default, or Over, then down) with a small picture of the order, also when Print Titles opens it on its Sheet tab; OK sets it with the margins in one undo step. Printing, PDF export, the print preview, Page Break Preview's page numbers and Page Layout view's headers and footers follow the order. The dialog is in German now too |
+5 -2
View File
@@ -31,6 +31,8 @@ pub const TABLE: &[(&str, Class)] = &[
("pdf", Class::Layer(0)),
("formula", Class::Layer(1)),
("functions", Class::Layer(1)),
// Equation layout (LaTeX to glyphs, rules and paths) and OMML, on top of RaTeX.
("math", Class::Layer(1)),
("model", Class::Layer(2)),
("calc", Class::Layer(2)),
("xlsx", Class::Layer(2)),
@@ -58,8 +60,9 @@ fn intra_layer_allowed(from: &str, to: &str) -> bool {
}
/// Format crates may only depend on these workspace crates (on top of the layer rule), so a
/// file format never pulls in calculation or engine code.
pub const FORMAT_ALLOWED: &[(&str, &[&str])] = &[("xlsx", &["core", "formula", "model"])];
/// file format never pulls in calculation or engine code. `math` converts equations to and from
/// OMML.
pub const FORMAT_ALLOWED: &[(&str, &[&str])] = &[("xlsx", &["core", "formula", "math", "model"])];
/// External crates that constitute a UI toolkit / windowing dependency.
/// Entries ending in `*` are prefixes.