diff --git a/CLAUDE.md b/CLAUDE.md index 7ee34f0..46fcb0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,6 +74,7 @@ src/ extensions/ – the custom TipTap/ProseMirror extensions, one file per feature utils/ – framework-free helpers (fontDetect, specialChars, wordCount, orderedListTypes, historyLog, colorDebug) + math/ – formula AST: LaTeX parser/serializer, MathML, OMML export/ import/ spell/ storage/ styles/ – ODF+DOCX I/O, spell-check, persistence, named styles styles/ – global.css + editor.css ``` @@ -95,4 +96,5 @@ Directory-level `CLAUDE.md` files load automatically when you touch that directo | Images, text boxes, wrap modes | `docs/architecture/frames.md` | | Table extensions + table styles | `docs/architecture/tables.md` | | Text effects, indents, tab stops, list markers, date fields | `docs/architecture/formatting.md` | +| Formulas (LaTeX model, MathML, OMML, the math font) | `docs/architecture/formulas.md` | | Headless browser + PDF-export debugging | `docs/headless-testing.md` | diff --git a/docs/architecture/formulas.md b/docs/architecture/formulas.md new file mode 100644 index 0000000..1647deb --- /dev/null +++ b/docs/architecture/formulas.md @@ -0,0 +1,106 @@ +# Formulas + +Insert/edit a mathematical formula (`editor/extensions/formula.ts`, `components/FormulaDialog.svelte`), +round-tripping to an embedded ODF formula object and to Word's OMML. + +## One LaTeX string is the model + +The `formula` node stores **`latex`** and a `display` flag — nothing else. Everything is derived: + +``` +latex ──parse──▶ AST ──▶ MathML (screen + .odt) + └──▶ OMML (.docx) +OMML ──parse──▶ AST ──▶ latex (.docx import) +MathML──parse──▶ AST ──▶ latex (.odt import) +``` + +Foreign markup is parsed into the AST and re-serialized, never passed through. That is why the +node view may set `innerHTML` — the MathML it renders is always our own output, so a foreign +`annotation-xml` can't smuggle in HTML. It also means an imported formula is always editable; +there is no read-only state to fall back to. + +The price: a construct outside the AST degrades. Unknown OMML/MathML elements recurse into +their `m:e`/child rows, so content survives even where structure doesn't — the same trade +LibreOffice makes routing OMML through StarMath. + +`src/lib/math/`: `latex.ts` (AST types, parser, serializer, symbol table), `mathml.ts`, +`omml.ts`. AST kinds: `row`, `txt` (`i`/`n`/`o`/`f` = identifier/number/operator/upright name), +`frac`, `script`, `sqrt`, `fence`, `nary`, `acc`, `matrix`, `space`. + +## The LaTeX subset + +`\frac`, `_`/`^`, `\sqrt[n]{}`, `\left…\right`, the n-ary operators with limits, the accents, +`matrix`/`pmatrix`/`bmatrix`/`Bmatrix`/`vmatrix`/`cases`, the upright function names, `\text{}` +/`\mathrm{}`, greek + the common operators and relations, `{}` grouping, `&` / `\\`, and the +spacing macros. **Not** supported, deliberately: `align` environments, `\substack`, user macros, +color. An unknown macro degrades to `\text{name}` rather than vanishing. + +Two invariants the tests pin (`tests/unit/math.test.ts`, `tests/unit/docx-formula.test.ts`): + +- `astToLatex(parseLatex(x)) === x` for every stored source. Without it, opening an imported + formula in the dialog and pressing Apply would silently rewrite it. +- A literal `^` or `_` in an imported run's **text** is escaped on the way out (Word documents + contain typed-in code like `corr.^2`), or re-parsing would read it as a script marker. +- Whitespace inside an OMML run is dropped (`omml.ts` `tokenize`): math layout ignores it, and + keeping it breaks the invariant above. + +## STIX Two Math is required, not decoration + +MathML is typeset by the browser natively — no KaTeX/MathJax. But Chromium needs a font with +an **OpenType MATH table**: measured in this container with only the bundled Liberation/Carlito +faces installed, `∑` and `ϕ` render as *nothing* and `(` `)` stay glyph-height beside a +fraction. `src/assets/fonts/STIXTwoMath-Regular.woff2` (OFL, the Fontsource latin subset — +the MATH table and the operators survive it) is bundled and wired in `global.css`; +`editor.css` puts it first in the `math` font stack. Word uses Cambria Math for the same job. + +## ODF + +Export (`export/odt.ts`, sentinel `MTH` U+E012, pass `applyFormulas` right after `applyImages`): +a `` holding +``, plus `Formula{n}/content.xml` — an ODF formula +sub-document whose root *is* the MathML — and two manifest entries (`Formula{n}/` as +`application/vnd.oasis.opendocument.formula`, its `content.xml` as `text/xml`). + +- **No `svg:width`/`svg:height` on the frame, on purpose.** With a size LibreOffice scales the + object to fill it (a frame measured at the dialog's preview font size magnified the formula + ~1.5×); without one it typesets at the natural size, matched to the body text. Verified by + rendering through `soffice`. +- `MthFr` carries `style:vertical-pos="middle" style:vertical-rel="text"`, what LibreOffice + writes for its own formula frames. `bottom`/`baseline` was tried and drops the formula below + the line. +- The LaTeX rides in `` so our own files re-import + exactly. **LibreOffice strips it** and substitutes a StarMath annotation, so the fallback + path (parse its MathML back to LaTeX) is the one that actually runs after a LO re-save — and + it reproduces the source exactly (`tests/lo-roundtrip.test.ts`). +- **`display` is read off the paragraph, not the object** (`import/odt.ts` `aloneInParagraph`): + ODF has no inline/display flag, and LibreOffice writes `display="block"` on every formula it + re-saves, so trusting the attribute turned every inline formula into a displayed one. + +## DOCX + +Export (`export/docx.ts`, sentinel `MTH`, post-pack pass `applyFormulasDocx` beside +`applyTextBoxesDocx`): the run holding the sentinel is replaced by ``, or by +`` when `display` — which is what centers it on its own line. The namespace is +declared inline on the `m:` element, like the `wps`/`a` namespaces in the text-box XML. + +Import: `m:oMath`/`m:oMathPara` sit **beside** the `w:r` runs in their own namespace, so +`convertInline` picks them up before its `namespaceURI !== W` guard — that guard is why every +formula in a Word document was silently dropped before this feature. `m:oMathPara` → +`display: true`. Word stores no size for a formula, and neither do we. + +## A pasted formula is a picture, not a formula + +Real documents mix both: a thesis will carry OMML formulas *and* equations its author pasted +as raster images. Those import as `image` nodes and offer the image toolbar's wrap options on +click — in Word and LibreOffice too. Nothing short of OCR changes that, so a click that offers +wrap options instead of the formula dialog is the document's doing, not a broken import. + +## Testing note + +`npm run test:lo`'s formula leg needs the **`libreoffice-math`** package — LibreOffice is +modular and Writer alone cannot hold a formula object, so without it every formula is dropped +on load without a word (measured: converting a formula-bearing `.docx` with a Writer-only +install yields an `.odt` with zero formulas). The leg then fails on "both objects survive", which +looks like a broken export but is the missing component. Check `dpkg -l | grep +libreoffice-math` before believing that failure. A desktop LibreOffice ships Math; only +stripped-down server/container installs don't. diff --git a/src/assets/fonts/LICENSE-STIX.txt b/src/assets/fonts/LICENSE-STIX.txt new file mode 100644 index 0000000..1315517 --- /dev/null +++ b/src/assets/fonts/LICENSE-STIX.txt @@ -0,0 +1,93 @@ +Copyright 2001-2021 The STIX Fonts Project Authors (https://github.com/stipub/stixfonts) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/src/assets/fonts/STIXTwoMath-Regular.woff2 b/src/assets/fonts/STIXTwoMath-Regular.woff2 new file mode 100644 index 0000000..00c3920 Binary files /dev/null and b/src/assets/fonts/STIXTwoMath-Regular.woff2 differ diff --git a/src/lib/components/FormulaDialog.svelte b/src/lib/components/FormulaDialog.svelte new file mode 100644 index 0000000..b34784f --- /dev/null +++ b/src/lib/components/FormulaDialog.svelte @@ -0,0 +1,301 @@ + + + (open = false)} + onclick={(e) => { if (e.target === dialogEl) open = false; }} + aria-label={t().formula.title} +> +
+
+

{t().formula.title}

+ +
+ +
{@html preview}
+ +
+ {#each GROUPS as group (group.key)} +
+ {t().formula.groups[group.key]} +
+ {#each group.items as item (item.snip)} + + {/each} +
+
+ {/each} +
+ + + +
+ + + + +
+
+
+ + diff --git a/src/lib/components/ToolbarExpanded.svelte b/src/lib/components/ToolbarExpanded.svelte index afc53d2..d7623ca 100644 --- a/src/lib/components/ToolbarExpanded.svelte +++ b/src/lib/components/ToolbarExpanded.svelte @@ -7,7 +7,9 @@ import SpecialCharPicker from './SpecialCharPicker.svelte'; import DateTimePicker from './DateTimePicker.svelte'; import LinkDialog from './LinkDialog.svelte'; + import FormulaDialog from './FormulaDialog.svelte'; import { OPEN_LINK_DIALOG_EVENT } from '../editor/extensions/link'; + import { EDIT_FORMULA_EVENT } from '../editor/extensions/formula'; import { detectInstalledFonts, queryLocalFontsIfAllowed, @@ -958,6 +960,40 @@ return () => window.removeEventListener(OPEN_LINK_DIALOG_EVENT, open); }); + // --- Formula (formula.ts / FormulaDialog.svelte) --- + let formulaOpen = $state(false); + let formulaLatex = $state(''); + let formulaDisplay = $state(false); + // Set while editing an existing formula; null means the dialog will insert a new one. + let formulaPos = $state(null); + + function openFormulaDialog() { + formulaPos = null; + formulaLatex = ''; + formulaDisplay = false; + formulaOpen = true; + } + + function applyFormula(latex: string, display: boolean) { + if (!editor) return; + if (formulaPos != null) editor.chain().focus().updateFormula(formulaPos, { latex, display }).run(); + else editor.chain().focus().insertFormula({ latex, display }).run(); + formulaPos = null; + } + + // Double-clicking a formula (its node view) reopens the dialog on that formula. + $effect(() => { + const open = (e: Event) => { + const d = (e as CustomEvent<{ pos: number; latex: string; display: boolean }>).detail; + formulaPos = d.pos; + formulaLatex = d.latex; + formulaDisplay = d.display; + formulaOpen = true; + }; + window.addEventListener(EDIT_FORMULA_EVENT, open); + return () => window.removeEventListener(EDIT_FORMULA_EVENT, open); + }); + // --- Indent (Einzug) --- // In a list, step the list point one level (indentListForward/Backward — the Tab // keymap's commands, see indent.ts); outside a list, step the paragraph's left indent. @@ -1490,6 +1526,15 @@ onOpen={onDateTimePickerOpen} onInsert={insertDateTime} /> +