sympy-editor 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. sympy_editor-0.1.0/LICENSE +29 -0
  2. sympy_editor-0.1.0/PKG-INFO +475 -0
  3. sympy_editor-0.1.0/README.md +450 -0
  4. sympy_editor-0.1.0/pyproject.toml +39 -0
  5. sympy_editor-0.1.0/setup.cfg +4 -0
  6. sympy_editor-0.1.0/src/sympy_editor/__init__.py +110 -0
  7. sympy_editor-0.1.0/src/sympy_editor/document.py +1550 -0
  8. sympy_editor-0.1.0/src/sympy_editor/examples.py +39 -0
  9. sympy_editor-0.1.0/src/sympy_editor/history.py +132 -0
  10. sympy_editor-0.1.0/src/sympy_editor/html.py +382 -0
  11. sympy_editor-0.1.0/src/sympy_editor/ops.py +305 -0
  12. sympy_editor-0.1.0/src/sympy_editor/printer.py +867 -0
  13. sympy_editor-0.1.0/src/sympy_editor/server.py +203 -0
  14. sympy_editor-0.1.0/src/sympy_editor/static/editor.css +848 -0
  15. sympy_editor-0.1.0/src/sympy_editor/static/editor.js +4876 -0
  16. sympy_editor-0.1.0/src/sympy_editor/static/widget.js +43 -0
  17. sympy_editor-0.1.0/src/sympy_editor/widget.py +135 -0
  18. sympy_editor-0.1.0/src/sympy_editor.egg-info/PKG-INFO +475 -0
  19. sympy_editor-0.1.0/src/sympy_editor.egg-info/SOURCES.txt +31 -0
  20. sympy_editor-0.1.0/src/sympy_editor.egg-info/dependency_links.txt +1 -0
  21. sympy_editor-0.1.0/src/sympy_editor.egg-info/requires.txt +7 -0
  22. sympy_editor-0.1.0/src/sympy_editor.egg-info/top_level.txt +1 -0
  23. sympy_editor-0.1.0/tests/test_browser.py +3509 -0
  24. sympy_editor-0.1.0/tests/test_document.py +1193 -0
  25. sympy_editor-0.1.0/tests/test_examples.py +326 -0
  26. sympy_editor-0.1.0/tests/test_history.py +97 -0
  27. sympy_editor-0.1.0/tests/test_html.py +112 -0
  28. sympy_editor-0.1.0/tests/test_mobile.py +258 -0
  29. sympy_editor-0.1.0/tests/test_printer.py +279 -0
  30. sympy_editor-0.1.0/tests/test_server.py +118 -0
  31. sympy_editor-0.1.0/tests/test_special_types.py +93 -0
  32. sympy_editor-0.1.0/tests/test_webapp.py +108 -0
  33. sympy_editor-0.1.0/tests/test_widget.py +145 -0
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2026, Francesco Bonazzi
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,475 @@
1
+ Metadata-Version: 2.4
2
+ Name: sympy-editor
3
+ Version: 0.1.0
4
+ Summary: Click-to-edit WYSIWYG editor for SymPy expressions: Jupyter widget and standalone HTML
5
+ Author: Francesco Bonazzi
6
+ License: BSD-3-Clause
7
+ Project-URL: Homepage, https://upabjojr.github.io/sympy-editor/
8
+ Project-URL: Documentation, https://upabjojr.github.io/sympy-editor/
9
+ Keywords: sympy,latex,editor,jupyter,widget,katex
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: Jupyter
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: License :: OSI Approved :: BSD License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
16
+ Requires-Python: >=3.9
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE
19
+ Requires-Dist: sympy>=1.14
20
+ Provides-Extra: jupyter
21
+ Requires-Dist: anywidget>=0.9; extra == "jupyter"
22
+ Provides-Extra: test
23
+ Requires-Dist: pytest; extra == "test"
24
+ Dynamic: license-file
25
+
26
+ # sympy-editor
27
+
28
+ A click-to-edit, WYSIWYG-style editor for [SymPy](https://www.sympy.org)
29
+ expressions. Expressions are rendered as LaTeX (with [KaTeX](https://katex.org))
30
+ in HTML; every sub-expression is selectable, and a selection can be replaced by
31
+ typing new SymPy syntax or by applying SymPy transformations (`expand`,
32
+ `factor`, `simplify`, ...). The SymPy expression tree — not the LaTeX — is
33
+ always the source of truth.
34
+
35
+ Works as a **Jupyter widget** and as **standalone HTML** (self-contained file, or
36
+ a local server). Pure Python + plain JavaScript: no node.js, no bundler, no
37
+ GPL dependencies.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install sympy-editor # core: SymPy only
43
+ pip install "sympy-editor[jupyter]" # adds anywidget for the notebook widget
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ### Jupyter (JupyterLab, Notebook 7, VS Code, Colab...)
49
+
50
+ ```python
51
+ from sympy import symbols, sin
52
+ from sympy_editor import edit
53
+
54
+ x, y = symbols("x y")
55
+ w = edit(x**2 / y - sin(x))
56
+ w # display the widget and edit in place
57
+ w.expr # the current, edited expression (live)
58
+ w.on_change(lambda e: print("now:", e))
59
+ ```
60
+
61
+ The widget runs every edit in **the kernel's SymPy** (no Pyodide involved).
62
+ `edit(expr, backend="pyodide")` gives instead the self-contained HTML page
63
+ that runs its own SymPy in the browser — useful for notebooks exported with
64
+ `nbconvert`, but its edits do not reach the kernel; the default `"auto"`
65
+ picks the kernel widget when anywidget is installed and falls back to Pyodide
66
+ with a warning otherwise.
67
+
68
+ `on_change` is also how the editor drives another widget.
69
+ `examples/plot_alongside.ipynb` puts a graph beside the formula — every
70
+ committed edit redraws it, and every free symbol but `x` grows a slider —
71
+ and `examples/plot_surface.ipynb` does the same to a `plotly` surface you can
72
+ turn around while you edit, updated in place. Both are examples: the wire is
73
+ a callback and a traitlet, and the library knows nothing about plotting.
74
+
75
+ ### Standalone HTML file
76
+
77
+ ```python
78
+ from sympy_editor import save_html
79
+ save_html(expr, "expr.html") # open in any browser
80
+ ```
81
+
82
+ The file is self-contained: it renders immediately and loads
83
+ [Pyodide](https://pyodide.org) + SymPy from a CDN in the background to run
84
+ the editing logic inside the browser; a spinner overlay blocks the editor
85
+ until they are ready (`options={"preload": False}` defers that to the first
86
+ edit). Use `editable=False` for a view-only page (still
87
+ selectable).
88
+
89
+ ### Local server (scripts, plain Python sessions)
90
+
91
+ ```python
92
+ from sympy_editor import serve
93
+ new_expr = serve(expr) # opens the browser; returns when you press "Done"
94
+ ```
95
+
96
+ ### Editing
97
+
98
+ | Action | Mouse | Keyboard |
99
+ | --- | --- | --- |
100
+ | Select sub-expression | click its middle (its left/right edge places a caret before/after it instead; next to a matrix entry or a power's base the caret *extends* it: `+ 1` adds, `y` multiplies) | ↓ (enter children), ←/→ (siblings) |
101
+ | Previous / next sibling (or move the caret; with nothing selected, a caret at the start / the end) | **←** / **→** (toolbar and action bar) | ←/→ |
102
+ | Select enclosing expression | click again on the same spot, or **↑** | ↑ |
103
+ | Go inside: the sub-expression you came up from, or the first one (on an atom: a caret after it) | **↓** (toolbar or action bar) | ↓ |
104
+ | Select a range of adjacent terms / factors | drag across them (mouse, touch or pen) | Shift+→ / Shift+← grow and shrink the range; ←/→/↓ collapse it, ↑ selects the whole sum/product |
105
+ | Replace selection by typing | | just start typing (SymPy syntax) |
106
+ | Change the operator between two arguments | click the operator itself (`+`, `−`, `⋅`, `=`, the `−` of `x − y`...): it is selected and a small palette appears; pick `+ − × ÷ ^ =` or **Delete** (side by side, the two multiply: `x + y` → `xy`) | with the operator selected, type `+ - * / ^ = < > & \|`; Del removes it; Esc deselects; ←/→/↓ select an argument, ↑ the node. In a sum `*` binds just the two terms (`x + y + z` → `xy + z`); in a product `+` splits it there (`x·y·z` → `x + yz`). A lone operator typed at a caret does the same |
107
+ | Type at a caret | click **between** two terms, or at the edge of an object: a caret appears; what you type is spliced between its neighbours like in a text editor: operators you type are used as written, a missing one means juxtaposition (`cos(t)` after `x` gives `x cos(t)`), `+`/`-` bind at the sum level (`x z` with `+y+` typed between gives `x + y + z`), `, …` adds a function argument | Tab / Shift+Tab put the caret after / before the selection; ←/→ walk it through the formula like a text cursor (into a composite neighbour, out of a node at its ends); ↑ selects the object it is attached to (↓ does nothing at a caret); Enter opens an empty field; Esc removes it |
108
+ | LaTeX shortcuts in the field | | `\theta` becomes `θ` as you type (Greek letters, `\infty`, `\sin`, `\cdot`, `\le`...); Greek letters are SymPy's names (`θ` is `theta`, `λ` is `lamda`, `∞` is `oo`) |
109
+ | Edit selection's existing text | double-click / **Edit** | Enter |
110
+ | Apply / cancel an edit | click elsewhere applies | Enter / Esc |
111
+ | Remove the selection entirely (on the whole expression: the formula is emptied and a field takes its place - type the new expression there, it is previewed as you type, Enter applies it; Esc brings the old one back) | **Delete** | Del |
112
+ | Remove the node but keep its argument (`cos(θ)` → `θ`, `∫f dx` → `f`) | **Unwrap** | Backspace — a node with several arguments (`x²`: the base or the exponent, a sum, a fraction) asks which one to leave, with the one ↑ came from ready to confirm |
113
+ | Put the node inside a function (`x` → `cos(x)`, `f(x)`, `∫x dx`) | `Document.wrap(path, "cos")` / `{"action": "wrap"}` | — (the function box **calls** a function; wrap builds without computing) |
114
+ | Keep only the selection (it becomes the whole expression) | **Isolate** | Ctrl+Shift+I |
115
+ | Transform the selection | pick an operation in the **Transform ▾** menu (general) or the type menu ("Matrix ▾", "Array ▾"...): it applies at once, or asks for what it needs first (the array tools want their axes) | |
116
+ | Matrix ↔ array | "Matrix ▾ → As array"; "Array ▾ → As matrix (rank 2)" — a `MatrixSymbol` becomes an `ArraySymbol` (entries stay implicit), an explicit matrix an explicit array | |
117
+ | Array tools | "Array ▾" (for explicit arrays *and* array symbols): permute axes `(1, 0)`, contract axes `(0, 1)`, diagonal over axes, reshape, rank, explicit entries | |
118
+ | Reshape | "Matrix ▾" / "Array ▾" → Reshape… — a matrix reshaped to a rank other than 2 becomes an array | |
119
+ | Derive by array | **Transform ▾** → Derive by array… — by `x` or `[x, y]`, for an expression (its gradient), a matrix or an array, symbolic or explicit | |
120
+ | Copy / cut / paste a part | **Copy** / **Paste** (toolbar or action bar) | Ctrl+C / Ctrl+X copy the selection's SymPy source; Ctrl+V pastes over a selection or at a caret |
121
+ | Apply any SymPy function | the **function box** in the toolbar: type to search SymPy's functions, pick one; a function that needs parameters asks for them (symbol parameters offer the selection's free symbols — `solve` on `sin(x)cos(y)` asks x or y); `diff(x)`, `.T`, `det()` typed in full apply as written | |
122
+ | Call a method of the selection's class | the **Methods** menu lists the public methods and properties of the selected object's class (of the whole expression when nothing is selected) — `.det()`, `.T`, `.rref()` on a matrix, `.diff()`, `.as_poly()` on an expression; picking one calls it, and a method that needs parameters asks for them. A `Lambda` is itself a function: its menu starts with **( ) apply**, which asks for the arguments and evaluates it there (`(3)` in the function box does the same) | |
123
+ | Undo / redo | ↶ / ↷ | Ctrl+Z / Ctrl+Shift+Z |
124
+ | Zoom the formula | **−** / **100%** (reset) / **+**, Ctrl+mouse wheel, pinch with two fingers | Ctrl+plus / Ctrl+minus / Ctrl+0 |
125
+ | Scroll a formula wider than the view | the scrollbar, the mouse wheel over the formula, or drag its empty space (one finger on a phone) | |
126
+
127
+ A small action bar appears under whatever is selected — ↑ parent, ↓ inside,
128
+ Edit, Unwrap, Delete, Copy — so these actions are one click or one tap away
129
+ from the object; the same commands sit in the toolbar and on the keys.
130
+
131
+ Editing happens *inside* the formula: the selected node is swapped for a small
132
+ text field at its position, and the formula re-renders when you press Enter.
133
+ A selection and an insertion caret never coexist: with a selection, typing
134
+ replaces it; with a caret, typing only inserts. A range (`b + c` inside `a + b + c + d`) is
135
+ edited, deleted and transformed like a single node: typing replaces it, Del
136
+ removes its terms, an operation picked in a menu transforms just those terms.
137
+
138
+ On phones and tablets: tap to select, **tap the selected node again to edit
139
+ it**, tap a gap for a caret and tap it again to insert, tap an operator to
140
+ change it from its palette, drag to select a
141
+ range; the toolbar has ↑
142
+ for the parent and a ⌨ button that opens the keyboard for the selection, the
143
+ caret or the whole expression; the menus apply an operation as soon as it is
144
+ picked. Two fingers zoom the formula, a drag on its empty space scrolls it
145
+ sideways, and vertical swipes still scroll the page.
146
+ Transformations act on the selected sub-expression only (on the whole formula
147
+ when nothing is selected).
148
+
149
+ Typed input is parsed with `sympy.parsing.sympy_parser.parse_expr` in the
150
+ context of the expression, so existing symbols keep their assumptions and
151
+ undefined functions (and `MatrixSymbol`s / `IndexedBase`s) are reused. Names
152
+ that do not occur in the current expression become plain symbols - unless the
153
+ node being replaced is a matrix, in which case they become `MatrixSymbol`s of
154
+ its shape (so `C.T` typed over `B` in `A*B` works). Ancestors are rebuilt
155
+ with SymPy's normal automatic evaluation (replacing `y` by `-x` in `x + y`
156
+ gives `0`).
157
+
158
+ **Names vs. SymPy functions.** A typed name is resolved in this order: a
159
+ symbol declared in the Symbols panel (or passed as `symbols=`), a name already
160
+ in the expression, then SymPy's own names (`sin`, `pi`, `E`, `I`, `gamma`,
161
+ ...), and finally a new plain symbol. So a variable called `sin` is declared
162
+ once in the panel and wins from then on; for a one-off, write it in backticks
163
+ (`` `sin`*x ``); `\sin` is always the function. When a name you typed was
164
+ taken as SymPy's function or constant, the status line says so and points at
165
+ these two options.
166
+
167
+ What you select and edit is what you see, even where SymPy's tree differs
168
+ from the rendering: the `1` of `1/n` (the tree holds `Pow(n, -1)`) is a node
169
+ of its own and typing `x` there gives `x/n`; in `1/(2e)` (the tree's
170
+ `exp(-1)/2`) the `2` and the `e` of the denominator are editable, and so is
171
+ the denominator `2e` as a whole; a denominator raised to a power (`(x+1)**2`
172
+ in `x/(x+1)**2`, the tree's `Pow(x + 1, -2)`) is edited as shown, exponent
173
+ included; in `x - 2y` the `2` is the `2` you see.
174
+
175
+ The **Symbols** panel under the formula lists every name with what it stands
176
+ for (`Symbol` with its assumptions, `MatrixSymbol` with its shape, `Function`,
177
+ ...) and lets you change it throughout the expression: make `y` a 2×2
178
+ `MatrixSymbol`, an explicit `Matrix` of `y[i, j]` entries (symbolic dimensions
179
+ such as `n` are fine for a `MatrixSymbol`), or a positive real `Symbol`
180
+ (assumptions are a comma-separated list). Products and powers are rebuilt as
181
+ `MatMul`/`MatPow`; a change SymPy cannot represent (a matrix under a
182
+ transpose back to a scalar) is refused with its error. The last row of the
183
+ panel **declares a new name before you type it** — so `C` typed into a
184
+ scalar context can still be a 3×3 matrix symbol — and from Python the same is
185
+ `edit(expr, symbols=[MatrixSymbol("C", 3, 3)])` or
186
+ `w.document.declare("C", "MatrixSymbol", 3, 3)`.
187
+
188
+ The **Transform ▾** menu holds the general ops (simplify, expand, factor,
189
+ ...) and applies one as soon as it is picked. Operations specific to the selection's *type* appear in a
190
+ separate highlighted **type menu** next to it, labelled with the type
191
+ ("Matrix ▾", "Integral ▾", "Equation ▾"...), and apply as soon as you pick
192
+ one: transpose / inverse / trace / determinant / `as_explicit` for matrices,
193
+ evaluate / numeric value / expand or simplify the function inside for
194
+ integrals, sums, derivatives and limits, swap sides / move everything to the
195
+ left / simplify or expand both sides for equations, `tomatrix` for arrays.
196
+
197
+ Matrices (dense and sparse), `MatrixSymbol` expressions, block matrices,
198
+ determinants/traces and N-dimensional `Array`s are supported: every entry is
199
+ selectable and editable, and the container is rebuilt around the edit (see
200
+ `examples/demo_matrices.py` and `examples/demo_matrices.ipynb`).
201
+
202
+ Register your own transformations, for every selection or only for some
203
+ kinds (`"matrix"`, `"array"`, `"scalar"`; the mapping from kinds to SymPy
204
+ types is `sympy_editor.ops.KINDS`):
205
+
206
+ ```python
207
+ from sympy_editor import register_op
208
+
209
+ @register_op("my_op", label="My op")
210
+ def my_op(expr):
211
+ return ...
212
+
213
+ @register_op("gram", label="Gram matrix", kinds=("matrix",))
214
+ def gram(m):
215
+ return m.T * m
216
+ ```
217
+
218
+ ### Mobile apps
219
+
220
+ `mobile/` packages the same editor page as a minimal Android (Kotlin WebView)
221
+ and iOS (SwiftUI `WKWebView`) app: `python mobile/build_www.py` produces the
222
+ shared, offline-capable bundle, and each platform folder is a few files that
223
+ just display it.
224
+
225
+ The **Android app runs Python itself**: CPython 3.12 and SymPy are packaged in
226
+ the APK (Chaquopy) and the page edits through the app's own interpreter (the
227
+ `native` backend), so there is no WebAssembly runtime to start and no Pyodide
228
+ in the bundle - faster to open, and the same SymPy as on the desktop. It
229
+ needs Android 7.0 (API 24). The iOS app and the web app still run Python in
230
+ the page with Pyodide. See `mobile/README.md`.
231
+
232
+ ### Web app
233
+
234
+ `python webapp/build.py` builds the same page as an installable, offline-capable
235
+ web app (a PWA) in `webapp/dist/`: the bundle plus a manifest, icons and a
236
+ service worker that caches everything on the first visit. Sessions, history
237
+ and zoom live in the browser's storage, as in the app. `--serve` tries it at
238
+ `http://127.0.0.1:8000/`; `dist/` is a static site for any https host, and
239
+ `.github/workflows/webapp.yml` publishes it with GitHub Pages (enable Pages
240
+ with "GitHub Actions" as the source once).
241
+
242
+ ### The source line
243
+
244
+ The SymPy source under the formula and the formula itself are one document
245
+ seen twice, and the two follow each other both ways. Select a piece of the
246
+ text and the corresponding sub-expression is selected in the formula; select
247
+ in the formula and the matching source text is highlighted, a negated term
248
+ with its sign. Put the text cursor in the line and a caret appears at the
249
+ same place in the formula (whatever was selected is dropped, as it would be
250
+ in a text editor); move the caret in the formula and a cursor shows where it
251
+ stands in the text. The line
252
+ is editable and **previews as you type**: whenever the text parses, the
253
+ formula above shows it (a text that does not parse marks the line red and
254
+ leaves the formula alone); Enter commits it — as one undo step — and Esc
255
+ reverts. That is where whole-expression edits happen: the rendered formula
256
+ itself is never replaced by code.
257
+
258
+ ### What changed
259
+
260
+ A committed change is animated: the parts of the old formula that disappear
261
+ turn **red** and slide towards their replacements while fading out, the new
262
+ parts fade in in **green** (and stay green until you touch the formula
263
+ again), and the parts that survive slide to their new places
264
+ (`options={"animate": False}` turns it off; it is off under
265
+ `prefers-reduced-motion`). What changed also keeps a very faint tinted box
266
+ behind it - one box per changed sub-expression, covering it whole, however
267
+ tall it is (a fraction, a matrix with its brackets).
268
+
269
+ ### Evaluated or unevaluated
270
+
271
+ The **unevaluated** toggle next to the function box decides what a
272
+ transformation or a SymPy function produces: off, the result is computed
273
+ (the determinant of a numeric matrix is a number); on, its symbolic form is
274
+ built - `Determinant(M)`, `Inverse(A)`, `Transpose`, `Trace`, `Derivative(f,
275
+ x)` for `diff(x)`, `Integral(f, x)` for `integrate(x)`, `Limit`, `Sum`,
276
+ `Product`, `Subs`, and `sin(0)`, `sqrt(4)`, `exp(0)`... left as written - to
277
+ be evaluated later with **Evaluate (doit)**. A transformation that has no
278
+ such form (Simplify, Expand, Factor...) is applied as usual and the status
279
+ line says so. The history records the choice ("Transform: Determinant
280
+ (unevaluated)").
281
+
282
+ ### History view, report and script
283
+
284
+ **History** (toolbar, or **View…** in the drawer's History) shows the current
285
+ history in the page: every step rendered, what a step brought in green, and
286
+ between two steps an arrow with what produced the change (a transformation
287
+ from the menu, a SymPy function, an edit, typed text...) and the previous
288
+ formula with what went in red. Bold colour and a faint box mark exactly
289
+ what the step touched, nothing around it. Tapping a step opens it in the editor. From
290
+ there:
291
+
292
+ - **Play** runs the history as a slideshow: one slide per step, with the
293
+ change that produced it - what it was in red, what it became in green -
294
+ on the same screen, large and centred, advancing on its own.
295
+ - **◀ ▶** step through the slideshow while it is paused, and walk the whole
296
+ history when it is not playing at all: each press scrolls to the next step
297
+ and marks it, and **Play** carries on from there.
298
+ - **− / +** in the same strip set the size of the formulas, in the listing
299
+ and in the slideshow alike; Ctrl+wheel and two fingers do the same. On a
300
+ page of its own the whole panel can be dragged taller or shorter. Its controls sit in the
301
+ strip above, which stays put while the steps go by; inside the report the
302
+ same keys work (space pauses, ← → step, Esc shows the whole history
303
+ again). The player travels with the page, so a saved report plays on its
304
+ own too.
305
+ - **Save ▾** offers the two ways out. *As a web page* downloads the same
306
+ view as a self-contained HTML
307
+ file: KaTeX's rendering and fonts are inlined, so it works offline and can
308
+ be shared as it is.
309
+ - *As a Python script* downloads a script that rebuilds every step with SymPy
310
+ alone (`from sympy import *`, the declarations of the names used -
311
+ assumptions, matrix shapes, functions -, then one `expr = ...` per step
312
+ with what produced it as a comment, all collected in `steps`); run it or
313
+ import it.
314
+
315
+ Browsers download the files (or offer the share sheet where the Web Share
316
+ API takes files); the Android app saves them in Downloads and opens the
317
+ share sheet.
318
+
319
+ ### The history viewer on its own (no editor)
320
+
321
+ The step-by-step view is not part of the editor: it needs a list of
322
+ expressions and a word about what turned each into the next, whoever
323
+ computed them. So a derivation carried out in Python - the steps of an
324
+ integration, a chain of rewrites, the output of somebody else's algorithm -
325
+ is shown exactly the way the editor shows its own sessions, with the same
326
+ diffs:
327
+
328
+ ```python
329
+ from sympy import Integral, cos, sin, symbols
330
+ from sympy_editor import History, save_history_html
331
+
332
+ x = symbols("x")
333
+ steps = History([
334
+ Integral(x * sin(x), x),
335
+ (-x * cos(x) + Integral(cos(x), x), "by parts: u = x, dv = sin(x) dx"),
336
+ (-x * cos(x) + sin(x), "the remaining integral"),
337
+ ], title="∫ x sin(x) dx, by parts")
338
+
339
+ save_history_html(steps, "steps.html") # a page of its own
340
+ ```
341
+
342
+ - `History(steps, ...)` takes expressions or `(expr, "what produced it")`
343
+ pairs; `History.add(expr, action)` appends one at a time, so a loop can
344
+ build the history as it computes; `History.from_document(doc)` takes an
345
+ editing session's own.
346
+ - `to_history_html(steps, full_page=False)` gives an embeddable fragment and
347
+ `display_history(steps)` shows it in a notebook cell (no kernel round
348
+ trip: the page is static).
349
+ - Both accept a `History`, a `Document`, or just a list of expressions.
350
+
351
+ The page runs no Python: it renders the steps with KaTeX in the browser, and
352
+ its **Save as web page** button writes the whole thing - fonts included - to
353
+ one offline file. `examples/demo_history.py` builds one.
354
+
355
+ `python webapp/build.py --shelf DIR` writes that shelf as a page that
356
+ introduces the project with every derivation embedded in it, an editor
357
+ beside it and KaTeX with them: about 1.5 MB, a folder to drop into any site.
358
+ The full web app build puts the same page at `dist/derivations/`.
359
+
360
+ `examples/derivations/` is a shelf of worked derivations built this way -
361
+ the quadratic formula by completing the square, the Gaussian integral, the
362
+ geometric series, Euler's identity, a derivative from first principles,
363
+ partial fractions, the eigenvalues of a matrix, Gaussian elimination, the
364
+ harmonic oscillator from its Lagrangian, least squares. `python
365
+ examples/derivations/build.py --open` writes a page for each and an index
366
+ over them.
367
+
368
+ `examples/manualintegrate_steps.ipynb` shows the point of the separation: it
369
+ flattens the rule tree that `sympy.integrals.manualintegrate.integral_steps`
370
+ returns into a list of expressions - one rule per step, by parts, by
371
+ substitution, term by term - and hands it to the viewer, so SymPy's own
372
+ reasoning can be read (and played) like any other derivation. That code
373
+ lives in the notebook: the library knows nothing about `manualintegrate`.
374
+
375
+ ### Long computations
376
+
377
+ A transformation that takes a while does not freeze the page: after a moment
378
+ a spinner overlay names what is being computed, and after a couple of seconds
379
+ it offers an **Interrupt** button, which stops the computation and leaves the
380
+ expression as it was. In standalone pages Python runs in a Web Worker and is
381
+ restarted on interruption (the undo history of the page is lost then; a
382
+ `file://` page in Chromium cannot create the worker and runs Python in the
383
+ page instead, without interruption); the local server and the Jupyter widget
384
+ interrupt the thread doing the work (`interrupt_thread`), so nothing else is
385
+ lost.
386
+
387
+ ### Sessions and history (mobile app, or `options={"sessions": True}`)
388
+
389
+ The **☰** button opens a lateral drawer, out of the widget, listing your
390
+ **sessions** — expressions, each with its own undo history, kept in the
391
+ browser's storage. **New session…** offers an empty formula (the default:
392
+ you type it in the source line), a copy of the current expression, or one
393
+ of the built-in examples (`sympy_editor.examples`); **Open** switches (the
394
+ one you leave is saved first); **Delete** (click twice) removes one. The
395
+ current session is a card, and its **History** is a sub-tab inside that
396
+ card: one row per step, each shown as a diff - the previous formula with
397
+ what went in red, then this one with what came in green - and clicking a
398
+ row goes back (or forward) to that step. Tapping a session's row opens
399
+ it. Available on Pyodide-backed pages.
400
+
401
+ The formula sits on its own faintly outlined canvas, with the SymPy source
402
+ of the whole expression under it and the tools above. A quasi-transparent
403
+ button in the canvas's top-right corner gives the formula the **whole
404
+ screen**: the editor covers the page, everything else - the tools, the
405
+ source line, the Symbols panel - steps aside, and the formula alone is drawn
406
+ large in the middle. It is real full screen where the platform has one -
407
+ the browser's own (the Fullscreen API), and in the Android app the status and
408
+ navigation bars go too. The same button - or Esc, when nothing is selected -
409
+ comes back.
410
+
411
+ The toolbar's **?** opens this whole guide inside the editor - every
412
+ gesture, key and tool in one box (Esc closes it).
413
+
414
+ The toolbar is organised in three rows of related blocks: the session and
415
+ its timeline (sessions, undo/redo, History, ?, Done) with the zoom; the
416
+ selection (navigation arrows, then Edit/Unwrap/Delete/Isolate and the
417
+ clipboard); and what to apply (the Transform, type and Methods menus, the
418
+ function box, the unevaluated toggle). The blocks sit in columns - three
419
+ from 44rem, and on a narrow screen they spread across each line instead -
420
+ so the left column starts at the left edge, the right one ends at the right
421
+ edge, and the strip reads as a grid rather than a wall of buttons. A block
422
+ never breaks apart: what belongs together stays together, and the action bar
423
+ under a selection wraps the same way, so every button stays reachable.
424
+
425
+ ## How it works
426
+
427
+ `sympy_editor.AnnotatedLatexPrinter` extends SymPy's `LatexPrinter` so that
428
+ every printed sub-expression is wrapped in KaTeX's `\htmlData{path=/1/0}{...}`
429
+ (`AnnotatedStrPrinter` does the same for `str()`, and `latex_spans(expr)` /
430
+ `annotate_str(expr)` give the character spans of every node in both strings,
431
+ keyed by the same paths).
432
+ KaTeX turns that into `<span data-path="/1/0">`, so the DOM knows which node
433
+ of the expression tree each glyph belongs to. Editing operations
434
+ (`Document.replace/delete/insert/operator/apply/call/undo/redo`) rebuild the
435
+ tree and re-render.
436
+ See `AGENTS.md` for the architecture and design notes.
437
+
438
+ ## Dependencies and licences
439
+
440
+ | Component | Licence | How it is used |
441
+ | --- | --- | --- |
442
+ | SymPy | BSD-3 | required |
443
+ | anywidget (+ ipywidgets, traitlets) | MIT / BSD-3 | optional, Jupyter widget |
444
+ | KaTeX | MIT | loaded from a CDN by the browser (URL configurable) |
445
+ | Pyodide | MPL-2.0 | loaded from a CDN by the browser, standalone HTML only |
446
+ | SymPy wheel (PyPI) | BSD-3 | loaded by the browser into Pyodide (the newest SymPy, ahead of Pyodide's own package) |
447
+
448
+ sympy-editor itself is BSD-3-Clause.
449
+
450
+ ## Development
451
+
452
+ ```bash
453
+ pip install -e ".[jupyter,test]"
454
+ pytest # Python tests (printer, document, HTML, server, widget)
455
+ python examples/demo.py # writes examples/demo.html (regenerate after code changes:
456
+ python examples/demo_matrices.py # the pages embed the package; tests/test_examples.py checks they are current)
457
+ python examples/demo.py --serve # local-server mode
458
+ jupyter lab examples/demo.ipynb # notebook demo (plot_alongside.ipynb plots what you edit)
459
+ ```
460
+
461
+ Browser end-to-end tests of the JavaScript front end use
462
+ [Playwright](https://playwright.dev/python/) (dev-only, Apache-2.0, never
463
+ shipped) and a real headless Chromium:
464
+
465
+ ```bash
466
+ pip install playwright && python -m playwright install chromium
467
+ pytest tests/test_browser.py # needs network for the KaTeX CDN
468
+ SYMPY_EDITOR_SLOW_TESTS=1 pytest tests/test_browser.py # also the Pyodide page
469
+ ```
470
+
471
+ They are skipped automatically when Playwright, the browser or the network
472
+ are unavailable. Graphical edits are tested as user scenarios with the
473
+ `Scenario` helper in `tests/test_browser.py` (`scenario(expr).caret_after(path)
474
+ .type("+ B*A").enter()` then `.source`), run on both the HTTP backend and a
475
+ Pyodide page. `.github/workflows/ci.yml` runs everything on push.