chore: sync upstream (89 Commits) #15
No reviewers
Labels
No labels
herkunft/alpha-feedback
status
blockiert
status
zu-testen
typ
bug
typ
docs
typ
enhancement
typ
feature
typ
idea
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
iris/gridcraft!15
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "chore/sync-upstream-20261102"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Upstream-Sync von
upstream/main(89 Commits). Fast-Forward.6f52cc50Equations: Insert › Equation with a LaTeX editor, Office Math in xlsx and equations in PDF (#471)488f9982Review fix for #2729: document the portable shortcut notation and the Mac-only overridec1bc0980Merge PR #27293379f12aData: Solver Sensitivity report (#501)f6a0556bObjects: one selection for every floating object, eight resize handles, and objects that follow edits (#470)b408b709fix(ui): preserve alignment shortcuts for CLI consumerse945d7b7fix(ui): preserve portable shortcuts and macOS overrides25881a8eUI: an unsaved macro-editor draft counts as unsaved work when its workbook closes (#492)aedbbba8Data: Analysis ToolPak two-factor Anova, z-Test, Fourier Analysis, Random Number Generation and Sampling (#495)94f8410cfix(ui): centralize platform-specific command shortcuts and test palette092e5819Docs: collapse duplicated target-app-parity rows from merging #3123 and #313063607f88Merge PR #3133e361f922Merge PR #3130c3e3e55fMerge PR #3123ea2dfe66Review fix for #3135: clip only whole-column and whole-row chart sources; bounded areas keep their extentGoogle Sheets' XLSX export writes a formula that uses a function it thinks Excel lacks as IFERROR(__xludf.DUMMYFUNCTION("<formula, quotes doubled>"), <cached value>). GridCraft showed the wrapper as the formula. fmla::from_file, which every cell formula read goes through (plain, shared master and t="array"), now recognises exactly that shape: IFERROR (with or without _xlfn.) of a single-string __xludf.DUMMYFUNCTION call, any case. If the string parses, the formula inside replaces the wrapper, as the whole formula or nested in a larger one (parenthesised unless it is a single operand). The cell keeps the file's cached value. Bounded on hostile input: inner text over MAX_FORMULA_LEN is not parsed, only one level is unwrapped, and a result that no longer parses (nesting past the parser's limit) keeps the file's formula. Text that doesn't parse keeps the wrapper. Saving writes the real formula with Excel's _xlfn. prefixes, as Excel does with the _xlfn functions it knows. Keeping cached results for functions GridCraft can't calculate is #2770 and not part of this change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>* Data: Solver Load/Save Model and Reset All data.solverSaveModel writes a model (the sheet's saved model, or one given as data.solver params) down a column of its sheet as formulas: the objective and goal (=MAX(...), =MIN(...) or =cell=value), the variable cells (=COUNT(...)), one =AND(relation) per constraint, which shows TRUE while the constraint holds, and the method and its options as an array constant. It refuses a range that overlaps the model's cells or runs off the sheet. data.solverLoadModel reads such a range (or reads down from its first cell), checks the model and makes it the active sheet's model. data.solverReset (Reset All) removes the active sheet's model. Each is one undo step. The Solver Parameters dialog has Reset All and Load/Save buttons; the dialog can also be opened with fields that go over the saved model. * Data: Solver Reset All also clears a model read from workbook-level names A sheet's Solver model can come from workbook-level names, which every sheet without a model of its own reads. Reset All removed only the sheet-level names, so such a model stayed and the dialog showed it again. Removing the workbook-level names would change every other sheet that reads them. Reset All instead leaves them in place and writes an empty sheet-level solver_opt over them on the active sheet only; the result says masked: true. saved_model now reads a sheet's own names when the sheet has a solver_opt, and the workbook-level names only otherwise, never a mix of both, and treats an empty solver_opt as no model. The mask survives XLSX and is replaced when a model is saved on the sheet. * Data: Solver Save Model clears the rest of a longer model saved at the same cell Saving a model over a longer one saved at the same cell left the old model's last rows on the sheet, so Load Model from that cell read the new options row followed by stale constraint rows and refused the range. Save Model now clears what is left of a model saved there before (only when those cells hold a complete model written by Save Model, and never the new model's own cells), keeping the cells' formatting, in the same undo step. Load Model reading down from one cell also stops at the options row, so formulas just below a saved model no longer break it. * Data: Solver Load Model keeps the Evolutionary convergence and generations Save Model writes Convergence and the generations without improvement in its options row and Load Model reads them, but the sheet's saved model (hidden names) did not hold them, so after Load Model, or any solve, they fell back to their defaults. The saved model now keeps them for the Evolutionary method: Convergence as solver_cvg, which means the same in Excel, and the generations as gridcraft_solver_stall, because Excel's solver_mni is a time in seconds. Saving a model again and Reset All remove the GridCraft name with the solver_* names. * Data: Solver Load Model refuses a range with a gap Load Model from a range of several cells kept only the cells that hold a formula, so the rows below a cell without one moved up. A constraint cell overwritten by a constant gave a model without that constraint, and an empty cell before the options row gave the default options, both without an error. Load Model now refuses a range in which a cell before the last formula holds no formula, and names that cell. Empty cells after the model are still allowed. * Data: Solver removes only its own names when it saves or resets a model Saving a model (by a solve or Load Model) and Reset All removed every name of the sheet that starts with solver_, so a user's own name such as solver_budget was deleted with the model. They now remove only the Solver's names: the ones Excel's Solver writes (solver_opt, solver_adj, solver_lhs1, solver_rel1, solver_rhs1 and so on) and GridCraft's own. The names are matched by name rather than by the hidden flag, so a model whose names lost that flag is still replaced in full. * Data: Solver Save Model refuses a place where the options cannot spill The options row that Save Model writes is an array constant of eight values, which spills into the seven cells to the right of its first cell. Content in any of those cells blocked the spill and the options cell showed #SPILL!. Save Model now checks the whole spill range before it writes anything and refuses, naming the cell, when a cell there holds content or another formula's spilled values or is a cell of the model, when merged cells or a table meet the row (a dynamic array cannot spill into a table), or when the range runs past the last column. It also refuses a place where the model's column would cover another formula's spilled values, which that formula would lose to #SPILL!. The options of a model saved at the same cell before may be replaced, as before. * Data: Solver Reset All and Load Model drop the old model's options Reset All cleared the model fields of the Solver Parameters form but kept the Evolutionary options (Convergence, Mutation Rate, Population Size, Random Seed, generations without improvement and Require Bounds), and Load Model kept any of them that the loaded model omits, such as all of them for a GRG Nonlinear or Simplex LP model. A new Evolutionary model then solved with the old options, for example with Require Bounds off. Both now remove the model's options from the form first (one list of their keys), so an option the model doesn't set takes its default. * Data: Solver Save Model asks before it replaces data Save Model wrote its formulas over whatever the model range held. It is now refused, with the sheet unchanged, when a cell of the range holds a value or a formula (a model saved there before included), unless `overwrite: true` is passed. The message names the first such cell and carries the same "already holds data" phrase as Data Analysis, so the Solver Parameters dialog asks "Replace it with the model?" with Replace and Don't Replace. The question belongs to the range it asked about: editing the range, Load or Reset All drops it. The check runs after the spill checks, so the question is asked only when replacing would succeed. A save remains one undo step. Tests: the engine test refuses a value, a formula and an earlier model, accepts only a true boolean, and undoes and redoes the replacing save in one step; the dialog test covers Don't Replace, an edited range and Replace. Both fail without the change. * Data: Solver Load Model and Reset All tests for limits and undo Tests for behaviour already in place: reading down from one cell of a column of 5,000 formulas stops after the most rows a model takes, a range larger than a model is refused before any cell is read, and malformed or hostile cells and ranges (unbalanced brackets, an overflowing number, an unknown sheet, 10,000 nested brackets, a non-text `from`) each give an error and leave no model. Undoing Reset All brings the model back with its options (the population size). * Data: Solver Load Model refuses a model without its options row Load Model from one cell read down to the first cell without a formula, and a range dropped its trailing cells without formulas. A constraint cleared in the middle of a saved model, or its options row cleared or replaced by a constant, therefore left a prefix of the model, which was loaded as the whole model with default options and replaced the sheet's model. Every load now requires the model's last row to be the options row ({...}) that Save Model writes last; otherwise it is refused, naming the cell without a formula when reading down from one cell, and the sheet's model stays as it was. The test clears a middle constraint, clears the options row and replaces it by a constant, loads from the first cell and as two ranges, and checks after each refusal that the old model (three constraints, Simplex LP) is unchanged. It fails without the change. * Data: Solver Load Model refuses an options row cut short The options row of a saved model was read leniently: a missing value or one that isn't a number took its default, and values after the sixteenth were not read at all. A row cut short or edited by hand, such as ={1}, therefore loaded the model with default options in place of the saved ones (here: GRG Nonlinear in place of Simplex LP). The row must now hold at least the eight numbers Save Model writes (method, non-negative, population size, mutation rate, random seed, require bounds, convergence, generations without improvement), and every value in it, however many, must be a number; otherwise Load Model is refused and the sheet's model stays as it was. Only the first sixteen values are kept. More numbers than eight are still read, so a row with options a later version adds loads too. The test loads rows of one value, seven values, a text value among the eight, after them and after the sixteenth, and a TRUE, from the first cell and as a range, checking the old model after each refusal, and a row of ten numbers, which loads. It fails without the change. --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>* Data: Solver Load/Save Model and Reset All data.solverSaveModel writes a model (the sheet's saved model, or one given as data.solver params) down a column of its sheet as formulas: the objective and goal (=MAX(...), =MIN(...) or =cell=value), the variable cells (=COUNT(...)), one =AND(relation) per constraint, which shows TRUE while the constraint holds, and the method and its options as an array constant. It refuses a range that overlaps the model's cells or runs off the sheet. data.solverLoadModel reads such a range (or reads down from its first cell), checks the model and makes it the active sheet's model. data.solverReset (Reset All) removes the active sheet's model. Each is one undo step. The Solver Parameters dialog has Reset All and Load/Save buttons; the dialog can also be opened with fields that go over the saved model. * Data: Solver Reset All also clears a model read from workbook-level names A sheet's Solver model can come from workbook-level names, which every sheet without a model of its own reads. Reset All removed only the sheet-level names, so such a model stayed and the dialog showed it again. Removing the workbook-level names would change every other sheet that reads them. Reset All instead leaves them in place and writes an empty sheet-level solver_opt over them on the active sheet only; the result says masked: true. saved_model now reads a sheet's own names when the sheet has a solver_opt, and the workbook-level names only otherwise, never a mix of both, and treats an empty solver_opt as no model. The mask survives XLSX and is replaced when a model is saved on the sheet. * Data: Solver Save Model clears the rest of a longer model saved at the same cell Saving a model over a longer one saved at the same cell left the old model's last rows on the sheet, so Load Model from that cell read the new options row followed by stale constraint rows and refused the range. Save Model now clears what is left of a model saved there before (only when those cells hold a complete model written by Save Model, and never the new model's own cells), keeping the cells' formatting, in the same undo step. Load Model reading down from one cell also stops at the options row, so formulas just below a saved model no longer break it. * Data: Solver Load Model keeps the Evolutionary convergence and generations Save Model writes Convergence and the generations without improvement in its options row and Load Model reads them, but the sheet's saved model (hidden names) did not hold them, so after Load Model, or any solve, they fell back to their defaults. The saved model now keeps them for the Evolutionary method: Convergence as solver_cvg, which means the same in Excel, and the generations as gridcraft_solver_stall, because Excel's solver_mni is a time in seconds. Saving a model again and Reset All remove the GridCraft name with the solver_* names. * Data: Solver Load Model refuses a range with a gap Load Model from a range of several cells kept only the cells that hold a formula, so the rows below a cell without one moved up. A constraint cell overwritten by a constant gave a model without that constraint, and an empty cell before the options row gave the default options, both without an error. Load Model now refuses a range in which a cell before the last formula holds no formula, and names that cell. Empty cells after the model are still allowed. * Data: Solver removes only its own names when it saves or resets a model Saving a model (by a solve or Load Model) and Reset All removed every name of the sheet that starts with solver_, so a user's own name such as solver_budget was deleted with the model. They now remove only the Solver's names: the ones Excel's Solver writes (solver_opt, solver_adj, solver_lhs1, solver_rel1, solver_rhs1 and so on) and GridCraft's own. The names are matched by name rather than by the hidden flag, so a model whose names lost that flag is still replaced in full. * Data: Solver Save Model refuses a place where the options cannot spill The options row that Save Model writes is an array constant of eight values, which spills into the seven cells to the right of its first cell. Content in any of those cells blocked the spill and the options cell showed #SPILL!. Save Model now checks the whole spill range before it writes anything and refuses, naming the cell, when a cell there holds content or another formula's spilled values or is a cell of the model, when merged cells or a table meet the row (a dynamic array cannot spill into a table), or when the range runs past the last column. It also refuses a place where the model's column would cover another formula's spilled values, which that formula would lose to #SPILL!. The options of a model saved at the same cell before may be replaced, as before. * Data: Solver Reset All and Load Model drop the old model's options Reset All cleared the model fields of the Solver Parameters form but kept the Evolutionary options (Convergence, Mutation Rate, Population Size, Random Seed, generations without improvement and Require Bounds), and Load Model kept any of them that the loaded model omits, such as all of them for a GRG Nonlinear or Simplex LP model. A new Evolutionary model then solved with the old options, for example with Require Bounds off. Both now remove the model's options from the form first (one list of their keys), so an option the model doesn't set takes its default. * Data: Solver Save Model asks before it replaces data Save Model wrote its formulas over whatever the model range held. It is now refused, with the sheet unchanged, when a cell of the range holds a value or a formula (a model saved there before included), unless `overwrite: true` is passed. The message names the first such cell and carries the same "already holds data" phrase as Data Analysis, so the Solver Parameters dialog asks "Replace it with the model?" with Replace and Don't Replace. The question belongs to the range it asked about: editing the range, Load or Reset All drops it. The check runs after the spill checks, so the question is asked only when replacing would succeed. A save remains one undo step. Tests: the engine test refuses a value, a formula and an earlier model, accepts only a true boolean, and undoes and redoes the replacing save in one step; the dialog test covers Don't Replace, an edited range and Replace. Both fail without the change. * Data: Solver Load Model and Reset All tests for limits and undo Tests for behaviour already in place: reading down from one cell of a column of 5,000 formulas stops after the most rows a model takes, a range larger than a model is refused before any cell is read, and malformed or hostile cells and ranges (unbalanced brackets, an overflowing number, an unknown sheet, 10,000 nested brackets, a non-text `from`) each give an error and leave no model. Undoing Reset All brings the model back with its options (the population size). * Data: Solver Load Model refuses a model without its options row Load Model from one cell read down to the first cell without a formula, and a range dropped its trailing cells without formulas. A constraint cleared in the middle of a saved model, or its options row cleared or replaced by a constant, therefore left a prefix of the model, which was loaded as the whole model with default options and replaced the sheet's model. Every load now requires the model's last row to be the options row ({...}) that Save Model writes last; otherwise it is refused, naming the cell without a formula when reading down from one cell, and the sheet's model stays as it was. The test clears a middle constraint, clears the options row and replaces it by a constant, loads from the first cell and as two ranges, and checks after each refusal that the old model (three constraints, Simplex LP) is unchanged. It fails without the change. * Data: Solver Options The Solver Parameters dialog's Options button shows the Solver Options in the same window: Max Evaluations and Integer Optimality for all methods, and Convergence, Mutation Rate, Population Size, Random Seed, Maximum Generations without Improvement and Require Bounds on Variables for Evolutionary. OK keeps the changes for the next solve; Cancel restores them. The evaluation limit and Integer Optimality are now part of the model: they are saved in the hidden names (solver_itr, which counts evaluations in place of iterations, and solver_tol) and in the options row of Save Model. Both accept numbers or text, as the other options do. * Data: Solver keeps its own limits out of Excel's Solver names The evaluation limit was saved as solver_itr. Excel uses that name for its iteration limit (100 by default), so a workbook saved by Excel would give GridCraft 100 evaluations and stop its search early. GridCraft now saves the evaluation limit as gridcraft_solver_evals, which Excel ignores, and no longer reads solver_itr. Saving a model again and Reset All remove it with the other model names. Integer Optimality (solver_tol) keeps Excel's name, as its meaning is the same. * Data: Solver Reset All also resets the Solver Options Reset All removes the model's options from the Solver Parameters form, the Solver Options fields (the evaluation limit, Integer Optimality and the Evolutionary options) and Require Bounds, so the next Solve uses the defaults. It now also removes the copy of the options that Options keeps for Cancel. A test checks that the options are gone after Reset All and that Options then shows the defaults. * Data: Solver reads Integer Optimality with a percent sign as percent Integer Optimality is given in percent, but text such as "5%" was read by the number parser as 0.05 and then divided by 100 again, so the solver used 0.05 % instead of 5 %. A trailing percent sign is now dropped before the number is read, so "5", "5%" and 5 all mean 5 %. * Data: Solver Options closes with Escape back to the Solver Parameters Escape in the Solver Options view went to the shared dialog handler, which closed the whole Solver dialog: the options were not restored and parameter edits made before opening Options were lost. Escape (and closing the window) in Solver Options now does what Cancel does: it restores the options and returns to the Solver Parameters. Escape in the parameters still closes the dialog. * Data: Solver keeps the Evolutionary options when another method solves Solver Options sets the Evolutionary options whatever the solving method, but the saved model and the model in data.solver's result held them only for the Evolutionary method. A GRG Nonlinear or Simplex LP solve (which saves the model by default) dropped them, and switching back to Evolutionary brought back the defaults. They are now saved and returned for every method. Two tests that expected them to be absent for other methods now check their values. * Data: Solver Options keeps its OK and Cancel row at button height The Solver Options' OK and Cancel row was a right-to-left layout placed straight in the window, which takes all the height it is offered, so the buttons sat well below the last option and the window was taller than its content. The row is now wrapped in a horizontal row as tall as the buttons, as the other form dialogs do (ok_cancel). The window's height now changes when it switches between the parameters and the options, and the window re-centres over the next frames, so the test that switches back and forth lets a few frames pass after each switch before it clicks. * Data: Solver Parameters keeps its Solve and Close row at button height The Solve and Close row was a right-to-left layout placed straight in the window. The window keeps its size when its content gets shorter, as it does when the Load/Save area or a note is hidden again, and that layout then filled the space left over, so Solve moved further below the last row. The row is now a horizontal row as tall as the buttons, as ok_cancel and the Solver Options do. * Data: Solver dialog opened in its options sets them up as the Options button does Opening the Solver dialog straight in the Solver Options (optionsView: true) only changed the title. It skipped what the Options button does: filling each option the form lacks with its default and keeping the current values for Cancel. With no saved model the fields were blank, and Cancel or Escape found nothing to restore, so the edits stayed and the next Solve used them. The button and the dialog now share one function for this. * Data: Solver reads Integer Optimality back without floating-point noise Integer Optimality is stored as a fraction, and the product with 100 is not exact for many percents (0.07 * 100 is 7.000000000000001). The percent is now rounded to 1E-10 % wherever it is read back: the saved model, the solve's model params and Save Model's options row. * Data: Solver reads whole-number options back as integers The model's numeric options were returned as floating-point JSON, so after a solve the Solver Options showed 20000.0, 100.0, 0.0 and 50.0. Values read from the hidden names, and Integer Optimality in the solve's result, are now JSON integers where they are whole, and the dialog shows 20000, 100, 0 and 50. * Data: Solver test checks the model saved by the second solve The test of Excel's iteration and time limits ignored the result of its second `data.solver` call and checked only names the first call had already created. It now reads the saved model afterwards and checks the values the second call changed (Simplex LP and an evaluation limit of 700). The call itself still fails, as Simplex LP refuses the nonlinear model, after the model is saved. * Data: Solver Load Model refuses an options row cut short The options row of a saved model was read leniently: a missing value or one that isn't a number took its default, and values after the sixteenth were not read at all. A row cut short or edited by hand, such as ={1}, therefore loaded the model with default options in place of the saved ones (here: GRG Nonlinear in place of Simplex LP). The row must now hold at least the eight numbers Save Model writes (method, non-negative, population size, mutation rate, random seed, require bounds, convergence, generations without improvement), and every value in it, however many, must be a number; otherwise Load Model is refused and the sheet's model stays as it was. Only the first sixteen values are kept. More numbers than eight are still read, so a row with options a later version adds loads too. The test loads rows of one value, seven values, a text value among the eight, after them and after the sixteenth, and a TRUE, from the first cell and as a range, checking the old model after each refusal, and a row of ten numbers, which loads. It fails without the change. --------- Co-authored-by: FredApps <FredApps@users.noreply.github.com>* 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. * 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. * 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). * 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: 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 ` ` 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>