feat: formulas can be inserted and edited, and survive both formats
A formula node stores only its LaTeX source; the MathML on screen, the ODF formula object and Word's OMML are derived from it, and both importers parse foreign markup back into that source, so an imported formula stays editable. Word documents were losing their formulas outright: m:oMath sits beside the w:r runs in its own namespace, past convertInline's `namespaceURI !== W` guard.
This commit is contained in:
1 parent
e9e543fe8c
commit
ee56a348ee
27 files changed
+1935
-4
No files matched your search
@@ -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` |
|
||||
@@ -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 `<draw:frame text:anchor-type="as-char" draw:style-name="MthFr">` holding
|
||||
`<draw:object xlink:href="./Formula{n}"/>`, 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 `<annotation encoding="application/x-tex">` 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 `<m:oMath>`, or by
|
||||
`<m:oMathPara>` 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.
|
||||
@@ -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.
|
||||
Binary file not shown.
@@ -0,0 +1,301 @@
|
||||
<script lang="ts">
|
||||
import { t } from '../i18n/i18n.svelte';
|
||||
import { formulaMathml } from '../editor/extensions/formula';
|
||||
// LibreOffice's Formula Editor, one dialog: typeset preview on top, LaTeX source
|
||||
// below, an element palette between them. The parent owns `open` and decides whether
|
||||
// Apply inserts a new formula or updates the one being edited.
|
||||
let {
|
||||
open = $bindable(false),
|
||||
initialLatex = '',
|
||||
initialDisplay = false,
|
||||
onApply,
|
||||
}: {
|
||||
open?: boolean;
|
||||
initialLatex?: string;
|
||||
initialDisplay?: boolean;
|
||||
onApply: (latex: string, display: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
let latex = $state('');
|
||||
let display = $state(false);
|
||||
let dialogEl: HTMLDialogElement | null = $state(null);
|
||||
let input: HTMLTextAreaElement | null = $state(null);
|
||||
|
||||
// Label glyphs for the greek palette; the snippet carries the macro name.
|
||||
const GREEK: Record<string, string> = {
|
||||
alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ϵ', theta: 'θ',
|
||||
lambda: 'λ', mu: 'μ', nu: 'ν', pi: 'π', rho: 'ρ', sigma: 'σ', tau: 'τ',
|
||||
phi: 'ϕ', chi: 'χ', psi: 'ψ', omega: 'ω', Gamma: 'Γ', Delta: 'Δ',
|
||||
Theta: 'Θ', Lambda: 'Λ', Pi: 'Π', Sigma: 'Σ', Phi: 'Φ', Omega: 'Ω',
|
||||
};
|
||||
|
||||
// Each palette entry inserts its snippet; `@` marks the placeholder the current
|
||||
// selection fills and where the caret lands.
|
||||
type Item = { label: string; snip: string };
|
||||
const GROUPS: { key: keyof ReturnType<typeof t>['formula']['groups']; items: Item[] }[] = [
|
||||
{ key: 'structures', items: [
|
||||
{ label: '⅟', snip: '\\frac{@}{}' },
|
||||
{ label: '√', snip: '\\sqrt{@}' },
|
||||
{ label: 'ⁿ√', snip: '\\sqrt[@]{}' },
|
||||
{ label: 'x²', snip: '^{@}' },
|
||||
{ label: 'xᵢ', snip: '_{@}' },
|
||||
{ label: 'x̄', snip: '\\bar{@}' },
|
||||
{ label: 'v⃗', snip: '\\vec{@}' },
|
||||
{ label: '(⋯)', snip: '\\left(@\\right)' },
|
||||
{ label: '[⋯]', snip: '\\left[@\\right]' },
|
||||
{ label: '|⋯|', snip: '\\left|@\\right|' },
|
||||
{ label: '⎡⎤', snip: '\\begin{pmatrix}@ & \\\\ & \\end{pmatrix}' },
|
||||
{ label: '⎧', snip: '\\begin{cases}@ & \\\\ & \\end{cases}' },
|
||||
] },
|
||||
{ key: 'operators', items: [
|
||||
{ label: '∑', snip: '\\sum_{@}^{} ' },
|
||||
{ label: '∏', snip: '\\prod_{@}^{} ' },
|
||||
{ label: '∫', snip: '\\int_{@}^{} ' },
|
||||
{ label: '∮', snip: '\\oint_{@}^{} ' },
|
||||
{ label: '⋃', snip: '\\bigcup_{@}^{} ' },
|
||||
{ label: 'lim', snip: '\\lim_{@} ' },
|
||||
{ label: 'sin', snip: '\\sin @' },
|
||||
{ label: 'cos', snip: '\\cos @' },
|
||||
{ label: 'log', snip: '\\log @' },
|
||||
{ label: 'ln', snip: '\\ln @' },
|
||||
{ label: '∂', snip: '\\partial ' },
|
||||
{ label: '∇', snip: '\\nabla ' },
|
||||
] },
|
||||
{ key: 'relations', items: [
|
||||
{ label: '≤', snip: '\\le ' }, { label: '≥', snip: '\\ge ' },
|
||||
{ label: '≠', snip: '\\ne ' }, { label: '≈', snip: '\\approx ' },
|
||||
{ label: '≡', snip: '\\equiv ' }, { label: '∝', snip: '\\propto ' },
|
||||
{ label: '±', snip: '\\pm ' }, { label: '×', snip: '\\times ' },
|
||||
{ label: '÷', snip: '\\div ' }, { label: '⋅', snip: '\\cdot ' },
|
||||
{ label: '∈', snip: '\\in ' }, { label: '⊂', snip: '\\subset ' },
|
||||
{ label: '∪', snip: '\\cup ' }, { label: '∩', snip: '\\cap ' },
|
||||
{ label: '→', snip: '\\rightarrow ' }, { label: '⇒', snip: '\\Rightarrow ' },
|
||||
{ label: '∞', snip: '\\infty ' }, { label: '…', snip: '\\dots ' },
|
||||
] },
|
||||
{ key: 'greek', items: [
|
||||
'alpha', 'beta', 'gamma', 'delta', 'epsilon', 'theta', 'lambda', 'mu', 'nu',
|
||||
'pi', 'rho', 'sigma', 'tau', 'phi', 'chi', 'psi', 'omega',
|
||||
'Gamma', 'Delta', 'Theta', 'Lambda', 'Pi', 'Sigma', 'Phi', 'Omega',
|
||||
].map((n) => ({ label: GREEK[n], snip: `\\${n} ` })) },
|
||||
];
|
||||
|
||||
// Reset to the formula being edited each time the dialog opens, then focus the source.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
latex = initialLatex;
|
||||
display = initialDisplay;
|
||||
queueMicrotask(() => { input?.focus(); input?.select(); });
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const el = dialogEl;
|
||||
if (!el) return;
|
||||
if (open && !el.open) el.showModal();
|
||||
else if (!open && el.open) el.close();
|
||||
});
|
||||
|
||||
const preview = $derived(formulaMathml(latex || '\\square ', display));
|
||||
|
||||
function insert(snip: string) {
|
||||
const el = input;
|
||||
const at = el ? el.selectionStart : latex.length;
|
||||
const to = el ? el.selectionEnd : latex.length;
|
||||
const selected = latex.slice(at, to);
|
||||
const body = snip.replace('@', selected);
|
||||
// The caret goes where the placeholder was, so the next keystroke fills the slot.
|
||||
const caret = at + (snip.indexOf('@') >= 0 ? snip.indexOf('@') + selected.length : body.length);
|
||||
latex = latex.slice(0, at) + body + latex.slice(to);
|
||||
queueMicrotask(() => { el?.focus(); el?.setSelectionRange(caret, caret); });
|
||||
}
|
||||
|
||||
function apply() {
|
||||
const src = latex.trim();
|
||||
if (!src) return;
|
||||
onApply(src, display);
|
||||
open = false;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); apply(); }
|
||||
}
|
||||
</script>
|
||||
|
||||
<dialog
|
||||
class="formula-dialog"
|
||||
bind:this={dialogEl}
|
||||
onclose={() => (open = false)}
|
||||
onclick={(e) => { if (e.target === dialogEl) open = false; }}
|
||||
aria-label={t().formula.title}
|
||||
>
|
||||
<div class="card">
|
||||
<header>
|
||||
<h2>{t().formula.title}</h2>
|
||||
<button class="close" onclick={() => (open = false)} aria-label={t().common.close}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
|
||||
<path d="M3 3l8 8M11 3l-8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="formula-preview">{@html preview}</div>
|
||||
|
||||
<div class="palette">
|
||||
{#each GROUPS as group (group.key)}
|
||||
<div class="palette-group">
|
||||
<span class="palette-label">{t().formula.groups[group.key]}</span>
|
||||
<div class="palette-items">
|
||||
{#each group.items as item (item.snip)}
|
||||
<button type="button" title={item.snip.trim()} onclick={() => insert(item.snip)}>{item.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<label class="source">
|
||||
<span>{t().formula.source}</span>
|
||||
<textarea
|
||||
bind:this={input}
|
||||
bind:value={latex}
|
||||
rows="3"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder={'\\frac{a+b}{2}'}
|
||||
onkeydown={onKeydown}
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<footer>
|
||||
<label class="display-toggle">
|
||||
<input type="checkbox" bind:checked={display} />
|
||||
<span>{t().formula.displayFormula}</span>
|
||||
</label>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={() => (open = false)}>{t().common.cancel}</button>
|
||||
<button class="primary" onclick={apply} disabled={!latex.trim()}>{t().common.apply}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<style>
|
||||
.formula-dialog {
|
||||
margin: auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
max-width: none;
|
||||
}
|
||||
.formula-dialog::backdrop {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(40rem, 94vw);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
padding: 0 1rem 1rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--island-radius);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0 0;
|
||||
}
|
||||
h2 { flex: 1; font-size: 0.95rem; font-weight: 600; }
|
||||
.close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.2rem;
|
||||
}
|
||||
|
||||
.formula-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 5rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 1.4rem;
|
||||
background: var(--color-page-bg, #fff);
|
||||
color: var(--color-page-text, #000);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
overflow: auto;
|
||||
}
|
||||
/* On <math> itself: the UA sheet's `math { font-family: math }` beats inheritance. */
|
||||
.formula-preview :global(math) {
|
||||
font-family: 'STIX Two Math', math, serif;
|
||||
}
|
||||
|
||||
.palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
max-height: 13rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.palette-group { display: flex; align-items: baseline; gap: 0.5rem; }
|
||||
.palette-label {
|
||||
flex: 0 0 5rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, #888);
|
||||
}
|
||||
.palette-items { display: flex; flex-wrap: wrap; gap: 0.2rem; }
|
||||
.palette-items button {
|
||||
min-width: 1.9rem;
|
||||
height: 1.7rem;
|
||||
padding: 0 0.3rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-family: 'STIX Two Math', math, serif;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.palette-items button:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
|
||||
.source { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.75rem; }
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-mono, ui-monospace, monospace);
|
||||
font-size: 0.85rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
footer { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.spacer { flex: 1; }
|
||||
.display-toggle { display: flex; align-items: center; gap: 0.35rem; font-size: 0.78rem; }
|
||||
footer button {
|
||||
height: 1.9rem;
|
||||
padding: 0 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
footer button:hover { background: var(--color-hover, rgba(0, 0, 0, 0.06)); }
|
||||
footer button.primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
footer button:disabled { opacity: 0.5; cursor: default; }
|
||||
</style>
|
||||
@@ -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<number | null>(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}
|
||||
/>
|
||||
<button
|
||||
onclick={openFormulaDialog}
|
||||
title={t().formula.insert}
|
||||
aria-label={t().formula.insert}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 3h7M4 3l4 5-4 5M4 13h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => imageInput?.click()}
|
||||
title={t().toolbarExpanded.insertImage}
|
||||
@@ -1683,6 +1728,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<FormulaDialog
|
||||
bind:open={formulaOpen}
|
||||
initialLatex={formulaLatex}
|
||||
initialDisplay={formulaDisplay}
|
||||
onApply={applyFormula}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* Inner content of the second toolbar row; the surrounding .toolbar-secondary
|
||||
(App.svelte) owns the background, divider and elevation. */
|
||||
|
||||
@@ -47,6 +47,7 @@ import { TableStyle } from './extensions/tableStyle';
|
||||
import { TrailingNode } from './extensions/trailingNode';
|
||||
import { Image } from './extensions/image';
|
||||
import { DateTimeField } from './extensions/dateTimeField';
|
||||
import { Formula } from './extensions/formula';
|
||||
import { TextBox } from './extensions/textBox';
|
||||
import { Columns } from './extensions/columns';
|
||||
import { ColumnsFlow } from './extensions/columnsFlow';
|
||||
@@ -87,6 +88,9 @@ export const extensions = [
|
||||
// Inline date/time field (fixed or auto-updating); round-trips to ODF
|
||||
// <text:date>/<text:time> and DOCX DATE/TIME fields.
|
||||
DateTimeField,
|
||||
// Mathematical formula; stores LaTeX, renders native MathML, round-trips to an ODF
|
||||
// embedded formula object and DOCX OMML. See docs/architecture/formulas.md.
|
||||
Formula,
|
||||
// Block-level text box / basic shape with editable content; round-trips to ODF
|
||||
// draw:frame/draw:text-box + draw:custom-shape and DOCX wps:wsp/wps:txbx.
|
||||
TextBox,
|
||||
|
||||
@@ -13,6 +13,7 @@ Five topics are large enough to have their own deep-dive — read the file befor
|
||||
| `image.ts`, `textBox.ts` | `docs/architecture/frames.md` |
|
||||
| `table*.ts`, `tableStyle.ts` | `docs/architecture/tables.md` |
|
||||
| `textEffects.ts`, `indent.ts`, `tabStops.ts`, `listMarker.ts`, `paragraphBox.ts`, `dateTimeField.ts` | `docs/architecture/formatting.md` |
|
||||
| `formula.ts` | `docs/architecture/formulas.md` |
|
||||
| `paragraphStyle.ts`, `characterStyle.ts` | `src/lib/styles/CLAUDE.md` |
|
||||
|
||||
## The rest, one line each
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Node, mergeAttributes } from '@tiptap/core';
|
||||
import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { parseLatex } from '../../math/latex';
|
||||
import { mathmlDocument } from '../../math/mathml';
|
||||
|
||||
// Inline atom for a mathematical formula. It stores only the LaTeX source; the MathML
|
||||
// the browser typesets, the ODF formula object and the DOCX OMML are all derived from
|
||||
// it. See docs/architecture/formulas.md.
|
||||
|
||||
// Event the toolbar listens for to open the formula dialog on an existing formula
|
||||
// (double-click); the extension can't reach Svelte state directly.
|
||||
export const EDIT_FORMULA_EVENT = 'odf-edit-formula';
|
||||
|
||||
export interface FormulaAttrs {
|
||||
latex: string;
|
||||
/** Display formula: its own centered line, as Word's m:oMathPara. */
|
||||
display: boolean;
|
||||
}
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
formula: {
|
||||
insertFormula: (attrs: FormulaAttrs) => ReturnType;
|
||||
updateFormula: (pos: number, attrs: FormulaAttrs) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// The <math> markup for a formula, built from our own AST — never from foreign XML,
|
||||
// so setting it as innerHTML can't smuggle in scripted annotation-xml.
|
||||
export function formulaMathml(latex: string, display: boolean): string {
|
||||
return mathmlDocument(parseLatex(latex), display);
|
||||
}
|
||||
|
||||
export const Formula = Node.create({
|
||||
name: 'formula',
|
||||
group: 'inline',
|
||||
inline: true,
|
||||
atom: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
latex: {
|
||||
default: '',
|
||||
parseHTML: (el) => el.getAttribute('data-formula') ?? '',
|
||||
renderHTML: (attrs) => ({ 'data-formula': attrs.latex }),
|
||||
},
|
||||
display: {
|
||||
default: false,
|
||||
parseHTML: (el) => el.getAttribute('data-display') === 'true',
|
||||
renderHTML: (attrs) => ({ 'data-display': attrs.display ? 'true' : 'false' }),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [{ tag: 'span[data-formula]' }];
|
||||
},
|
||||
|
||||
// The HTML face carries the source, so copy/paste between documents round-trips.
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return ['span', mergeAttributes(HTMLAttributes, { class: 'formula' }), node.attrs.latex as string];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return node.attrs.latex as string;
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
insertFormula: (attrs) => ({ commands, state }) => {
|
||||
// Adopt the cursor's marks so the formula sits in the run's font, as the
|
||||
// date/time field does.
|
||||
const marks = (state.storedMarks ?? state.selection.$to.marks())
|
||||
.map((m) => ({ type: m.type.name, attrs: m.attrs }));
|
||||
return commands.insertContent({
|
||||
type: this.name,
|
||||
attrs,
|
||||
...(marks.length ? { marks } : {}),
|
||||
});
|
||||
},
|
||||
updateFormula: (pos, attrs) => ({ state, dispatch }) => {
|
||||
const node = state.doc.nodeAt(pos);
|
||||
if (!node || node.type.name !== this.name) return false;
|
||||
if (dispatch) dispatch(state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...attrs }, node.marks));
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ({ node, getPos }) => new FormulaView(node as PMNode, getPos as () => number);
|
||||
},
|
||||
});
|
||||
|
||||
// Node view: the typeset <math>. Double-click reopens the dialog on this formula.
|
||||
class FormulaView {
|
||||
dom: HTMLElement;
|
||||
private node: PMNode;
|
||||
|
||||
constructor(node: PMNode, getPos: () => number) {
|
||||
this.node = node;
|
||||
this.dom = document.createElement('span');
|
||||
this.dom.className = 'formula';
|
||||
this.dom.setAttribute('data-formula', (node.attrs.latex as string) ?? '');
|
||||
if (node.attrs.display) this.dom.setAttribute('data-display', 'true');
|
||||
this.render();
|
||||
this.dom.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
window.dispatchEvent(new CustomEvent(EDIT_FORMULA_EVENT, {
|
||||
detail: { pos: getPos(), latex: this.node.attrs.latex, display: this.node.attrs.display },
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.dom.innerHTML = formulaMathml(this.node.attrs.latex as string, !!this.node.attrs.display);
|
||||
}
|
||||
|
||||
update(node: PMNode) {
|
||||
if (node.type.name !== 'formula') return false;
|
||||
if (node.attrs.latex !== this.node.attrs.latex || node.attrs.display !== this.node.attrs.display) {
|
||||
this.node = node;
|
||||
this.dom.setAttribute('data-formula', (node.attrs.latex as string) ?? '');
|
||||
if (node.attrs.display) this.dom.setAttribute('data-display', 'true');
|
||||
else this.dom.removeAttribute('data-display');
|
||||
this.render();
|
||||
}
|
||||
this.node = node;
|
||||
return true;
|
||||
}
|
||||
|
||||
selectNode() { this.dom.classList.add('selected'); }
|
||||
deselectNode() { this.dom.classList.remove('selected'); }
|
||||
stopEvent() { return false; }
|
||||
ignoreMutation() { return true; }
|
||||
}
|
||||
@@ -23,6 +23,7 @@ Frames, tables and pagination have their own deep-dives in `docs/architecture/`.
|
||||
- **Images** (`replaceImages`/`applyImages`): odf-kit's native image path rounds dimensions to whole cm and doesn't reach our cell path, so images bypass it. `replaceImages` (a pre-pass) swaps each `image` node for an `IMG`-sentinel text run and collects the bytes (decoded data-URI) + cm geometry (px→cm via `round3`); the sentinel rides every odf-kit path including table cells. `applyImages` (after `applyCellBlocks`) rewrites each sentinel to a `<draw:frame text:anchor-type="as-char" svg:width/height><draw:image xlink:href="Pictures/imageN.png"/></draw:frame>`, adds the binary `Pictures/` entries to the zip, and appends a `<manifest:file-entry>` per picture to `META-INF/manifest.xml`. Integer px ⇄ round3-cm ⇄ px is sub-pixel exact, so size is unchanged. A rotated image also gets a `draw:transform="rotate(<rad>) translate(...)"` (ODF rotate is CCW radians, ours CW degrees; the translate re-centres it on the unrotated box). A **floating** image (`wrap !== 'inline'`) instead uses `text:anchor-type="paragraph"` + a minted `<style:style style:family="graphic">` carrying `style:wrap` + `style:horizontal-pos` (image-left → `wrap="right" horizontal-pos="left"`; topBottom → `wrap="none" horizontal-pos="center"`) injected into content.xml automatic-styles.
|
||||
- **Tables:** column widths come from the editor's per-column weights (`tableColumnWidthsCm`, summing exactly to the text width, with `table:align="margins"`). **Table margins:** a table's `marginLeft`/`marginRight` shrink that width and are written by `applyTableMargins` into the table's automatic style (`style:width` + `fo:margin-left`/`-right`; odf-kit names table styles `Table1`, `Table2`, … in document order, matching the descriptor list `exportTable` fills). odf-kit serializes each cell to a single `<text:p>` of runs; `exportTable` emits all of a cell's content into that paragraph separated by a `SEG` sentinel and records a `CellBlock[]` descriptor, then `applyCellBlocks` splits on `SEG` and rebuilds real `<text:h>`/`<text:p>`/`<text:list>` (incl. nested lists) with minted automatic paragraph styles. **Merged cells:** `exportTable` passes each cell's `colspan`/`rowspan` as odf-kit `CellOptions.colSpan`/`rowSpan`; odf-kit emits `table:number-columns/rows-spanned` and auto-generates the `<table:covered-table-cell>` placeholders (which never match the `applyCellBlocks` cell regex, so `cellBlocks` stays one-per-real-cell aligned). Import already reads spans/covered cells in `convertTable`. **Cell shading:** a cell's `backgroundColor` attr is passed as `CellOptions.backgroundColor` (normalized), which odf-kit mints into the cell style as `fo:background-color`. **Cell borders:** the per-side border attrs (tableCellBorders.ts) are passed as `CellOptions.borderTop/Right/Bottom/Left` (`'none'` or `'<W>pt solid #RRGGBB'`) overriding the table-level default border per side.
|
||||
- **Text boxes / shapes** (`replaceTextBoxes`/`applyTextBoxes`): a top-level `textBox` is **hoisted** — `replaceTextBoxes` (after `replacePageBreaks`, before the inline passes) swaps it for `TBX`-sentinel marker paragraphs (`S{i}`…`E{i}`, U+E008) bracketing its child blocks at top level, so every existing pass (custom attrs, list styles, inline sentinels, images) serializes them unchanged and document-order-indexed passes stay aligned. `applyTextBoxes` (after `applyImages`) wraps each serialized S…E region into `<draw:frame><draw:text-box fo:min-height>` (plain box; `svg:height` also emitted for non-auto-grow consumers) or `<draw:custom-shape>` + preset `<draw:enhanced-geometry>` (roundRect/ellipse, static LibreOffice-style paths) inside a fresh anchor paragraph, minting a `TbxFr{n}` graphic style (fill/stroke/padding + the image wrap props; `draw:auto-grow-height="true"` for boxes, both auto-grows explicitly **false** for shapes or LibreOffice's autofit shrinks them to their text).
|
||||
- **Formulas** (`replaceFormulas`/`applyFormulas`): an `MTH`-sentinel run becomes an as-char `<draw:frame><draw:object xlink:href="./Formula{n}"/>` plus a `Formula{n}/content.xml` sub-document (MathML root) and its two manifest entries. **No `svg:width`/`svg:height`** — a sized frame makes LibreOffice scale the object instead of typesetting it. DOCX emits `<m:oMath>` (or `<m:oMathPara>` when displayed) from the same post-pack pass as the text boxes. See `docs/architecture/formulas.md`.
|
||||
- **`applyListItemStyles`** — per-item alignment/spacing/line-height for top-level lists (odf-kit's ListBuilder has no per-item options) via minted automatic styles.
|
||||
- **`applyListIndents`** — a top-level list's whole-list `indent` (cm) added to its `L#` list-style per-level `fo:margin-left`/`text:list-tab-stop-position` (label-alignment mode ignores the paragraph margin, so the shift must live in the list style). Import reverses it in `convertList` (level-1 margin minus the 1.27cm base).
|
||||
- **`applyNestedListTypes`** — odf-kit emits nested lists as bare `<text:list>` sharing the top-level list style, so a nested list of a different kind/format (e.g. ordered inside bullets) gets its own minted 6-level list style.
|
||||
@@ -42,4 +43,4 @@ Sentinel order matters — the passes assume it. Full list of sentinels in use:
|
||||
`LBR`/`TAB` (inline), `IMG`, `SEG` (cell blocks), `TBX` U+E008 (text boxes),
|
||||
U+E009 (columns), U+E00A (date/time fields), `STY` U+E00D (paragraph styles),
|
||||
`CST` U+E00E (character styles), `TEF` U+E00F (text effects), `PBX` (paragraph boxes), `SEC` U+E010
|
||||
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`).
|
||||
(section breaks), `LEAD` U+E011 (a tab stop's leader, inside `style:type`), `MTH` U+E012 (formulas).
|
||||
+45
-1
@@ -30,6 +30,8 @@ import { normalizeColor, MAX_HEADING_LEVEL, mergeJoinedParagraphsJson, twinFontN
|
||||
import { builtinStyleSheet, DEFAULT_STYLE, type Style, type StyleSheet, type TextProps } from '../styles/styleSheet';
|
||||
import { parseTableLook, regionText, type TableStyle } from '../styles/tableStyles';
|
||||
import { findFormat, renderFormat, docxPicture, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT } from '../utils/dateTime';
|
||||
import { parseLatex } from '../math/latex';
|
||||
import { ommlDocument, OMML_NS } from '../math/omml';
|
||||
|
||||
// BCP-47 tag for rendering a fixed field's cached text; set at buildDocx start from
|
||||
// the document language (Word recomputes an auto field on open using its own locale).
|
||||
@@ -86,6 +88,16 @@ const EMU_PER_PX = 9525;
|
||||
const EMU_PER_PT = 12700;
|
||||
const EMU_PER_CM = 360000;
|
||||
|
||||
// Sentinel wrapping a formula's index inside its own run; the library has no OMML, so
|
||||
// applyFormulasDocx swaps that run for <m:oMath> in the same post-pack pass. U+E012,
|
||||
// matching export/odt.ts's MTH.
|
||||
const MTH = '';
|
||||
|
||||
// Formulas collected while serializing, in document order (module-level like
|
||||
// docLangTag: inlineToRuns is reached from every block path without a collector).
|
||||
type FormulaDocx = { latex: string; display: boolean };
|
||||
let docFormulas: FormulaDocx[] = [];
|
||||
|
||||
// Sentinel wrapping a text box's index in a marker paragraph (same trick as
|
||||
// export/odt.ts): the docx library can't emit DrawingML shapes, so buildDocx swaps
|
||||
// the marker for a hand-built <w:drawing><wps:wsp> in a zip post-processing pass.
|
||||
@@ -308,6 +320,12 @@ function inlineToRuns(content: TiptapNode[] = [], force: TextProps = {}): Inline
|
||||
out.push(new TextRun({ children: [PageNumber.TOTAL_PAGES], ...runPropsFromMarks(node.marks) }));
|
||||
} else if (node.type === 'dateTimeField') {
|
||||
out.push(dateTimeRun(node));
|
||||
} else if (node.type === 'formula') {
|
||||
const latex = typeof node.attrs?.latex === 'string' ? node.attrs.latex : '';
|
||||
if (latex) {
|
||||
docFormulas.push({ latex, display: node.attrs?.display === true });
|
||||
out.push(new TextRun({ text: `${MTH}${docFormulas.length - 1}${MTH}` }));
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -627,6 +645,31 @@ function applyTextBoxesDocx(bytes: Uint8Array, boxes: TextBoxDocx[]): Uint8Array
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// Post-pack pass: swap each sentinel run in word/document.xml for its <m:oMath>. A
|
||||
// display formula additionally wraps its paragraph's content in <m:oMathPara>, which
|
||||
// is how Word centers a formula on its own line.
|
||||
function applyFormulasDocx(bytes: Uint8Array, formulas: FormulaDocx[]): Uint8Array {
|
||||
if (!formulas.length) return bytes;
|
||||
const files = unzipSync(bytes);
|
||||
const docBytes = files['word/document.xml'];
|
||||
if (!docBytes) return bytes;
|
||||
let xml = strFromU8(docBytes);
|
||||
// Tempered pattern: the match stays inside the one run that holds the sentinel.
|
||||
xml = xml.replace(
|
||||
new RegExp(`<w:r\\b[^>]*?>(?:(?!</w:r>)[\\s\\S])*?${MTH}(\\d+)${MTH}(?:(?!</w:r>)[\\s\\S])*?</w:r>`, 'g'),
|
||||
(_m, idx: string) => {
|
||||
const f = formulas[Number(idx)];
|
||||
if (!f) return '';
|
||||
const omath = ommlDocument(parseLatex(f.latex));
|
||||
return f.display ? `<m:oMathPara xmlns:m="${OMML_NS}">${omath}</m:oMathPara>` : omath;
|
||||
},
|
||||
);
|
||||
files['word/document.xml'] = strToU8(xml);
|
||||
const out: Record<string, [Uint8Array, { level: 6 }]> = {};
|
||||
for (const [path, data] of Object.entries(files)) out[path] = [data, { level: 6 }];
|
||||
return zipSync(out);
|
||||
}
|
||||
|
||||
// ---- paragraphs ------------------------------------------------------------
|
||||
function alignOf(attrs: TiptapNode['attrs']): (typeof AlignmentType)[keyof typeof AlignmentType] | undefined {
|
||||
switch (attrs?.textAlign) {
|
||||
@@ -1104,6 +1147,7 @@ export async function buildDocx(
|
||||
): Promise<Uint8Array> {
|
||||
docLangTag = localeTag(language ? language.language : 'en');
|
||||
exportSheet = styles;
|
||||
docFormulas = [];
|
||||
const num = new Numbering();
|
||||
const landscape = orientation === 'landscape';
|
||||
const { w: pageWidthCm, h: pageHeightCm } = pageDimsCm(pageFormat, orientation);
|
||||
@@ -1194,5 +1238,5 @@ export async function buildDocx(
|
||||
});
|
||||
|
||||
const blob = await Packer.toBlob(doc);
|
||||
return applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes);
|
||||
return applyFormulasDocx(applyTextBoxesDocx(new Uint8Array(await blob.arrayBuffer()), textBoxes), docFormulas);
|
||||
}
|
||||
+90
-2
@@ -22,6 +22,8 @@ import { charStyleProps, listMarkerFormat, type MarkerFormat } from '../editor/e
|
||||
import { orderedTypeDef, effectiveOrderedDef, effectiveOrderedDefAt, childCycle, ROOT_ORDERED_CYCLE, type OrderedCycle } from '../utils/orderedListTypes';
|
||||
import { DEFAULT_BULLET_CYCLE, defaultBulletChar } from '../utils/bulletListTypes';
|
||||
import { findFormat, renderFormat, odfNumberStyle, toDateValue, toTimeValue, localeTag, DEFAULT_DATE_FORMAT, DEFAULT_TIME_FORMAT, type DtFormat } from '../utils/dateTime';
|
||||
import { parseLatex } from '../math/latex';
|
||||
import { mathmlDocument } from '../math/mathml';
|
||||
|
||||
type AlignValue = 'left' | 'center' | 'right' | 'justify';
|
||||
|
||||
@@ -140,6 +142,11 @@ const SEC = '\uE010';
|
||||
// which odf-kit writes verbatim. U+E011.
|
||||
const LEAD = '\uE011';
|
||||
|
||||
// Sentinel wrapping a formula's index (MTH{i}MTH), emitted as plain run text by
|
||||
// replaceFormulas so it rides every odf-kit path; applyFormulas rewrites it to a
|
||||
// <draw:frame>/<draw:object> and writes the embedded formula object. U+E012.
|
||||
const MTH = '\uE012';
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
@@ -443,6 +450,30 @@ function replaceHfImages(para: TiptapNode | null, images: ImageExport[]): Tiptap
|
||||
return { ...para, content };
|
||||
}
|
||||
|
||||
// One formula, collected by replaceFormulas and emitted by applyFormulas as an
|
||||
// embedded ODF formula object (Formula{n}/content.xml) plus the frame referencing it.
|
||||
type FormulaExport = { latex: string; display: boolean };
|
||||
|
||||
// Replace every inline `formula` node with an MTH-sentinel text run (carrying the
|
||||
// node's marks) and collect its source; mirrors replaceImages so the sentinel rides
|
||||
// every odf-kit path. applyFormulas resolves it after serialization.
|
||||
function replaceFormulas(node: TiptapNode, formulas: FormulaExport[]): TiptapNode {
|
||||
if (!node.content?.length) return node;
|
||||
const content: TiptapNode[] = [];
|
||||
for (const child of node.content) {
|
||||
if (child.type === 'formula') {
|
||||
const a = child.attrs ?? {};
|
||||
const latex = typeof a.latex === 'string' ? a.latex : '';
|
||||
if (!latex) continue;
|
||||
formulas.push({ latex, display: a.display === true });
|
||||
content.push({ type: 'text', text: `${MTH}${formulas.length - 1}${MTH}`, marks: child.marks });
|
||||
continue;
|
||||
}
|
||||
content.push(replaceFormulas(child, formulas));
|
||||
}
|
||||
return { ...node, content };
|
||||
}
|
||||
|
||||
// One inserted date/time field, collected by replaceDateTimeFields and emitted by
|
||||
// applyDateTimeFields. `value` is the field's stored moment (ISO local datetime).
|
||||
type DateTimeFieldExport = { kind: 'date' | 'time'; format: string; fixed: boolean; value: string };
|
||||
@@ -2756,6 +2787,61 @@ function applyImages(odtBytes: Uint8Array, images: ImageExport[]): Uint8Array {
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// The formula sub-document: an ODF formula document's content.xml is the MathML root
|
||||
// itself. The LaTeX rides in an annotation so our own files re-import exactly; other
|
||||
// readers ignore it and typeset the presentation markup.
|
||||
function formulaObjectXml(f: FormulaExport): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>${mathmlDocument(parseLatex(f.latex), f.display, f.latex)}`;
|
||||
}
|
||||
|
||||
// Resolve formula sentinels: swap each MTH{i}MTH for a <draw:frame> pointing at an
|
||||
// embedded formula object, write that object's content.xml, and register both it and
|
||||
// its directory in META-INF/manifest.xml — the same shape as applyImages.
|
||||
function applyFormulas(odtBytes: Uint8Array, formulas: FormulaExport[]): Uint8Array {
|
||||
if (!formulas.length) return odtBytes;
|
||||
const files = unzipSync(odtBytes);
|
||||
const contentBytes = files['content.xml'];
|
||||
if (!contentBytes) return odtBytes;
|
||||
|
||||
let content = strFromU8(contentBytes);
|
||||
content = content.replace(new RegExp(`${MTH}(\\d+)${MTH}`, 'g'), (_m, idx: string) => {
|
||||
const i = Number(idx);
|
||||
const f = formulas[i];
|
||||
if (!f) return '';
|
||||
// No svg:width/height on purpose: with a size the consumer scales the object to
|
||||
// fit it (LibreOffice magnifies a too-wide frame); without one it typesets the
|
||||
// formula at its natural size, matched to the surrounding text.
|
||||
return (
|
||||
`<draw:frame draw:name="Formula${i + 1}" draw:style-name="MthFr" text:anchor-type="as-char" draw:z-index="${i}">` +
|
||||
`<draw:object xlink:href="./Formula${i + 1}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/></draw:frame>`
|
||||
);
|
||||
});
|
||||
// One shared graphic style: an as-char formula sits on the text baseline, which is
|
||||
// what LibreOffice writes for its own formula frames.
|
||||
content = injectAutomaticStyles(content,
|
||||
'<style:style style:name="MthFr" style:family="graphic">' +
|
||||
'<style:graphic-properties style:vertical-pos="middle" style:vertical-rel="text"' +
|
||||
' fo:padding="0cm" fo:border="none"/></style:style>');
|
||||
files['content.xml'] = strToU8(content);
|
||||
|
||||
for (const [i, f] of formulas.entries()) {
|
||||
files[`Formula${i + 1}/content.xml`] = strToU8(formulaObjectXml(f));
|
||||
}
|
||||
|
||||
const manifestBytes = files['META-INF/manifest.xml'];
|
||||
if (manifestBytes) {
|
||||
const entries = formulas
|
||||
.map((_f, i) =>
|
||||
`<manifest:file-entry manifest:full-path="Formula${i + 1}/" manifest:media-type="application/vnd.oasis.opendocument.formula"/>` +
|
||||
`<manifest:file-entry manifest:full-path="Formula${i + 1}/content.xml" manifest:media-type="text/xml"/>`)
|
||||
.join('');
|
||||
files['META-INF/manifest.xml'] = strToU8(
|
||||
strFromU8(manifestBytes).replace('</manifest:manifest>', `${entries}</manifest:manifest>`));
|
||||
}
|
||||
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// Ensure content.xml declares the number namespace (odf-kit may omit it); the minted
|
||||
// <number:date-style>/<number:time-style> and their references need the prefix.
|
||||
function ensureNumberNamespace(content: string): string {
|
||||
@@ -3060,7 +3146,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const textBoxes: TextBoxExport[] = [];
|
||||
const columns: ColumnsExport[] = [];
|
||||
const dateFields: DateTimeFieldExport[] = [];
|
||||
const sentinels = replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields);
|
||||
const formulas: FormulaExport[] = [];
|
||||
const sentinels = replaceFormulas(replaceDateTimeFields(replaceImages(replaceTabs(replaceHardBreaks(replaceSectionBreaks(replaceColumns(replaceTextBoxes(replacePageBreaks(replaceTableOfContents(docJson, tocs)), textBoxes), columns)))), images), dateFields), formulas);
|
||||
const raw = markTextEffects(bakeListCharStyles(sentinels, styles));
|
||||
let headerPara = hf && !hfIsEmpty(hf.header) ? (hf.header!.content![0] as TiptapNode) : null;
|
||||
let footerPara = hf && !hfIsEmpty(hf.footer) ? (hf.footer!.content![0] as TiptapNode) : null;
|
||||
@@ -3260,7 +3347,8 @@ export async function buildOdt(docJson: TiptapNode, margins: PageMargins = DEFAU
|
||||
const withBreaks = applyInlineSentinels(applyTabLeaders(cleaned));
|
||||
const withImages = applyImages(withBreaks, images);
|
||||
const withDateFields = applyDateTimeFields(withImages, dateFields, language ?? null);
|
||||
const withTextBoxes = applyTextBoxes(withDateFields, textBoxes);
|
||||
const withFormulas = applyFormulas(withDateFields, formulas);
|
||||
const withTextBoxes = applyTextBoxes(withFormulas, textBoxes);
|
||||
const withColumns = applyColumns(withTextBoxes, columns);
|
||||
const withToc = applyToc(withColumns, tocs, contentWidthCm);
|
||||
const withPageBreaks = applyPageBreaks(withToc);
|
||||
|
||||
@@ -386,6 +386,18 @@ const de: Messages = {
|
||||
updateAutomatically: 'Automatisch aktualisieren',
|
||||
updateHint: 'Beim Öffnen des Dokuments auf das aktuelle Datum/die Uhrzeit aktualisieren',
|
||||
},
|
||||
formula: {
|
||||
insert: 'Formel einfügen',
|
||||
title: 'Formel',
|
||||
source: 'Formel (LaTeX)',
|
||||
displayFormula: 'Abgesetzte Formel (eigene zentrierte Zeile)',
|
||||
groups: {
|
||||
structures: 'Strukturen',
|
||||
operators: 'Operatoren',
|
||||
relations: 'Zeichen',
|
||||
greek: 'Griechisch',
|
||||
},
|
||||
},
|
||||
hf: {
|
||||
addHeaderHint: 'Zum Hinzufügen einer Kopfzeile doppelklicken',
|
||||
addFooterHint: 'Zum Hinzufügen einer Fußzeile doppelklicken',
|
||||
|
||||
@@ -390,6 +390,18 @@ const en = {
|
||||
updateAutomatically: 'Update automatically',
|
||||
updateHint: 'Refresh to the current date/time each time the document is opened',
|
||||
},
|
||||
formula: {
|
||||
insert: 'Insert formula',
|
||||
title: 'Formula',
|
||||
source: 'Formula (LaTeX)',
|
||||
displayFormula: 'Display formula (own centered line)',
|
||||
groups: {
|
||||
structures: 'Structures',
|
||||
operators: 'Operators',
|
||||
relations: 'Symbols',
|
||||
greek: 'Greek',
|
||||
},
|
||||
},
|
||||
hf: {
|
||||
addHeaderHint: 'Double-click to add a header',
|
||||
addFooterHint: 'Double-click to add a footer',
|
||||
|
||||
@@ -11,6 +11,7 @@ The **Open .odt** button (`App.svelte`) parses an uploaded file with `importOdt(
|
||||
- **Default suppression** keeps round trips (editor → LibreOffice → editor) from accreting explicit attrs: values matching the editor's defaults (12pt body, `HEADING_STYLE_OVERRIDES` sizes/margins, **0 paragraph spacing**, list margin 0, Times New Roman/Liberation Serif, `#000000` text) are imported as `null`/no mark, with small tolerances for producer unit rounding (pt↔cm↔twips). This is why the editor's defaults must equal Word's/LibreOffice's (below) — an editor-only default is indistinguishable from a failed style resolution and silently lands in every imported document.
|
||||
- **Mapping:** `text:h` → heading (level clamped to `MAX_HEADING_LEVEL`), per-depth `text:list-level-style-*` defs decide bullet vs ordered and `listStyleType` (reverse lookup `orderedTypeFromFormat`), tables handle covered cells / `number-columns-repeated` / header rows / colspans / cell shading (`StyleResolver.cellBackgroundColor` → `backgroundColor`) / cell borders (`StyleResolver.cellBorders` + `borderAttrFromOdf` → the per-side border attrs) / table margins (`tableMargins` reads the table style's `fo:margin-*`, `style:width`/`style:rel-width` and `table:align` against `Ctx.contentWidthCm` — so a table narrower than the text width stays narrow instead of being stretched; DOCX does the same from `w:tblInd` + the `w:tblGrid`/`w:tblW` width), `text:s`/`text:tab`/`text:line-break` become spaces/`\t`/`hardBreak`, `draw:frame`/`draw:image` → an `image` node (`convertFrame`: bytes read from the zip's `Pictures/` entry into a data-URI, `svg:width`/`-height` cm → px, `svg:title` → `alt`, `draw:transform` rotate → `rotation`; a non-`as-char` anchor or a graphic style's `style:wrap` → floating `wrap` mode via `StyleResolver.graphicProps` — an **explicit `as-char` anchor is always inline**: LibreOffice's named Graphics style carries a `style:wrap` that would otherwise float every inherited frame), text fields keep their stored value. **Text boxes / shapes** (`convertDrawElement`): `draw:frame`+`draw:text-box` → a `textBox` node (`convertTextBoxFrame`: height = `fo:min-height` else `svg:height`; fill/stroke read straight from `graphicProps`' `draw:fill(-color)`/`draw:stroke`/`svg:stroke-*`, values equal to the editor defaults suppressed, none → explicit `null`); `draw:rect`/`draw:ellipse`/`draw:custom-shape` with a rect/round-rectangle/ellipse preset → a `textBox` with the matching `shapeKind` (`convertShape`; other presets → "Unsupported shapes were removed"). A box is a block node but its frame sits inside a `text:p`, so converted boxes ride `Ctx.pendingBlocks` and `convertBlocks` flushes them after the anchor paragraph (dropping our own export's empty anchor); in cells/other boxes their blocks are unwrapped in place with a warning. The DOCX importer mirrors all of this (`convertWpsShape` for DrawingML `wps:wsp` — probed before the image `a:blip` path, `convertPict` for legacy VML; Word's `mc:AlternateContent` uses only the `mc:Choice` branch so the VML fallback can't double-import).
|
||||
- **Image formats (`import/imageFormats.ts`, shared by both importers):** `<img>` only renders a fixed set of formats, so `imageDataUrl(bytes, path)` resolves the mime by extension **then** magic-number (mislabelled/extensionless entries still work) and returns `null` for anything unrenderable — the importer then skips it with a specific "format the browser can't display" warning instead of a broken `<img>`. A frame's multiple `<draw:image>` alternatives (ODF) and a Word `a:blip`'s `svgBlip` alternative are probed for the first *displayable* one, and `<draw:a>`-wrapped frames are unwrapped (the hyperlink dropped). **Non-web formats we can decode client-side** (currently **TIFF**) are converted to PNG up front by `convertUnsupportedImages(bytes)` — an async pass `applyImport` runs before the synchronous import, passing a `path → PNG data-URI` map (`ConvertedImages`) into `importOdt`/`importDocx` (default empty, so tests stay sync). The decoder (`utif2`) is a **dynamic `import()`** → its own lazy chunk, loaded only when a convertible image is actually present, so ordinary docs pay nothing. **SVM/WMF/EMF** have no JS decoder (SVM is StarOffice-proprietary) → skipped with the format warning; LibreOffice/WASM conversion was ruled out (no backend; WASM LO is ~300MB).
|
||||
- **Formulas** (`convertFormulaFrame`): a `<draw:frame>` whose `<draw:object>` resolves to a sub-document with a `math:math` root (or holds one inline) → a `formula` node. Our own `<annotation encoding="application/x-tex">` is preferred; LibreOffice strips it, so the MathML is parsed back to LaTeX instead. **`display` comes from the paragraph** (`aloneInParagraph`), not the object — LibreOffice writes `display="block"` on every formula it re-saves. DOCX: `m:oMath`/`m:oMathPara` sit beside the `w:r` runs in their own namespace, so `convertInline` probes them ahead of its `namespaceURI !== W` guard (that guard silently dropped every Word formula). See `docs/architecture/formulas.md`.
|
||||
- **Graceful degradation** (collected as `warnings`, surfaced once): footnotes/comments dropped, hyperlinks flattened to text, nested tables flattened to paragraphs, non-`Pictures` drawings dropped, unreadable pictures skipped (each with a warning). Page size is normalized to A4. Floating/wrapped images are imported as left/right/top-bottom wrap (free x/y positions collapse to the nearest side; see `image.ts`).
|
||||
|
||||
`tests/roundtrip.test.ts` (export→import) and `tests/lo-roundtrip.test.ts` (export→LibreOffice re-save→import, needs `soffice`) verify the round trip; run with `npm test` / `npm run test:lo`.
|
||||
|
||||
@@ -21,6 +21,8 @@ import { EMPTY_HF_SET, type HfDoc, type HfSet } from '../storage/headerFooter';
|
||||
import { applyUniformRunFont, type OdtImportResult } from './odt';
|
||||
import { deobfuscateOdttf, type EmbeddedFont } from '../fonts/embeddedFonts';
|
||||
import { cellPaddingAttr, DEFAULT_CELL_PADDING, type CellPadding } from '../editor/extensions/tableCellPadding';
|
||||
import { astToLatex } from '../math/latex';
|
||||
import { parseOmml, OMML_NS } from '../math/omml';
|
||||
|
||||
// .docx → TipTap JSON, inverting export/docx.ts. Editor-expressible OOXML becomes its
|
||||
// native node/mark/attr; values matching the editor's defaults are suppressed so round
|
||||
@@ -1001,6 +1003,13 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
};
|
||||
|
||||
for (const el of Array.from(p.children)) {
|
||||
// Math is its own namespace and sits beside the w:r runs, so it has to be picked
|
||||
// up before the w:-only guard below drops it.
|
||||
if (el.namespaceURI === OMML_NS && (el.localName === 'oMath' || el.localName === 'oMathPara')) {
|
||||
const formula = formulaNode(el, ctx);
|
||||
if (formula) out.push(formula);
|
||||
continue;
|
||||
}
|
||||
if (el.namespaceURI !== W) continue;
|
||||
switch (el.localName) {
|
||||
case 'r': handleRun(el); break;
|
||||
@@ -1035,6 +1044,17 @@ function convertInline(p: Element, ctx: Ctx, baseRun: RunProps, defaults: BlockD
|
||||
return mergeAdjacentText(out);
|
||||
}
|
||||
|
||||
// <m:oMath> / <m:oMathPara> → a formula node. Word stores no size for a formula, so
|
||||
// the node view measures it once on screen and writes the ODF frame geometry back.
|
||||
function formulaNode(el: Element, ctx: Ctx): Node | null {
|
||||
const { ast, display } = parseOmml(el);
|
||||
// Stored verbatim, not trimmed: a macro's own trailing space is what makes the
|
||||
// source re-serialize to itself, so trimming it would rewrite the formula on edit.
|
||||
const latex = astToLatex(ast);
|
||||
if (!latex.trim()) { ctx.warnings.add('Some formulas could not be read and were skipped'); return null; }
|
||||
return { type: 'formula', attrs: { latex, display } };
|
||||
}
|
||||
|
||||
function emitField(out: Node[], instr: string, hfFields: boolean, marks: Mark[] = []): void {
|
||||
if (!hfFields) return;
|
||||
// The atom carries the field run's marks so its digits render in the run's font/size.
|
||||
|
||||
@@ -11,6 +11,8 @@ import { orderedTypeFromFormat, orderedTypeAttrAt, childCycle, ROOT_ORDERED_CYCL
|
||||
import { bulletCharAttr, bulletCharFromOdf } from '../utils/bulletListTypes';
|
||||
import { matchFormat, toDateValue, type Token } from '../utils/dateTime';
|
||||
import { imageDataUrl, placeholderImage, type ConvertedImages } from './imageFormats';
|
||||
import { astToLatex } from '../math/latex';
|
||||
import { parseMathml } from '../math/mathml';
|
||||
import { PX_PER_CM, cmToPx, type PageMargins } from '../storage/pageMargins';
|
||||
import type { Orientation } from '../storage/pageOrientation';
|
||||
import { pageDimsCm, type PageFormat } from '../storage/pageFormat';
|
||||
@@ -280,6 +282,50 @@ function convertTextBoxFrame(frame: Element, textBoxEl: Element, ctx: Ctx): Node
|
||||
return { type: 'textBox', attrs, content: textBoxContent(Array.from(textBoxEl.children), ctx) };
|
||||
}
|
||||
|
||||
// A <draw:frame> holding a formula object → a formula node. The MathML lives either
|
||||
// in the referenced sub-document (LibreOffice, and our own export) or inline in the
|
||||
// <draw:object>; parseMathml prefers our LaTeX annotation when the file carries one.
|
||||
function convertFormulaFrame(frame: Element, ctx: Ctx): Node | null {
|
||||
const obj = Array.from(frame.children).find(
|
||||
c => c.namespaceURI === NS.draw && (c.localName === 'object' || c.localName === 'object-ole'),
|
||||
);
|
||||
if (!obj) return null;
|
||||
const inline = obj.getElementsByTagNameNS(NS.math, 'math')[0];
|
||||
const mathEl = inline ?? loadFormulaObject(obj.getAttributeNS(NS.xlink, 'href'), ctx);
|
||||
if (!mathEl) return null;
|
||||
const got = parseMathml(mathEl);
|
||||
const latex = got.latex ?? astToLatex(got.ast);
|
||||
if (!latex.trim()) return null;
|
||||
// The frame's svg geometry is ignored: a formula is laid out from its own markup,
|
||||
// so a stored box would only fight the renderer.
|
||||
return { type: 'formula', attrs: { latex, display: aloneInParagraph(frame) } };
|
||||
}
|
||||
|
||||
// A display formula is one that owns its line — ODF has no flag for it (LibreOffice
|
||||
// writes display="block" on every formula object it re-saves), so it is read off the
|
||||
// paragraph: nothing but this frame in it.
|
||||
function aloneInParagraph(frame: Element): boolean {
|
||||
const p = frame.parentElement;
|
||||
if (!p || p.namespaceURI !== NS.text || (p.localName !== 'p' && p.localName !== 'h')) return false;
|
||||
if ((p.textContent ?? '').trim()) return false;
|
||||
return Array.from(p.children).filter(c => c.namespaceURI === NS.draw).length === 1;
|
||||
}
|
||||
|
||||
// The embedded object's content.xml — an ODF formula document's root is the MathML
|
||||
// itself. A non-formula object (chart, spreadsheet) has no math root and falls through.
|
||||
function loadFormulaObject(href: string | null, ctx: Ctx): Element | null {
|
||||
const dir = (href ?? '').replace(/^\.\//, '').replace(/\/$/, '');
|
||||
if (!dir) return null;
|
||||
const bytes = ctx.files[`${dir}/content.xml`] ?? ctx.files[dir];
|
||||
if (!bytes) return null;
|
||||
const doc = new DOMParser().parseFromString(strFromU8(bytes), 'text/xml');
|
||||
const root = doc.documentElement;
|
||||
if (!root || doc.getElementsByTagName('parsererror').length) return null;
|
||||
return root.namespaceURI === NS.math && root.localName === 'math'
|
||||
? root
|
||||
: doc.getElementsByTagNameNS(NS.math, 'math')[0] ?? null;
|
||||
}
|
||||
|
||||
// draw:rect / draw:ellipse / draw:custom-shape (rect/round-rect/ellipse preset) → a
|
||||
// textBox with the matching shapeKind; the shape's text is its content. Other
|
||||
// custom-shape presets (stars, arrows, …) are dropped with a warning.
|
||||
@@ -318,6 +364,8 @@ function convertDrawElement(e: Element, ctx: Ctx): { inline?: Node; block?: Node
|
||||
c => c.namespaceURI === NS.draw && c.localName === 'text-box',
|
||||
);
|
||||
if (textBoxEl) return { block: convertTextBoxFrame(e, textBoxEl, ctx) };
|
||||
const formula = convertFormulaFrame(e, ctx);
|
||||
if (formula) return { inline: formula };
|
||||
const hasImage = !!e.getElementsByTagNameNS(NS.draw, 'image')[0];
|
||||
const img = convertFrame(e, ctx);
|
||||
if (img) return { inline: img };
|
||||
|
||||
@@ -19,6 +19,7 @@ export const NS = {
|
||||
xlink: 'http://www.w3.org/1999/xlink',
|
||||
number: 'urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0',
|
||||
loext: 'urn:org:documentfoundation:names:experimental:office:xmlns:loext:1.0',
|
||||
math: 'http://www.w3.org/1998/Math/MathML',
|
||||
} as const;
|
||||
|
||||
// One embedded-font binary referenced from a <style:font-face>: the CSS family runs use,
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// The formula AST and its LaTeX face. A formula node stores only its LaTeX source;
|
||||
// every other representation (MathML on screen and in .odt, OMML in .docx) is derived
|
||||
// from the tree this module parses. See docs/architecture/formulas.md.
|
||||
|
||||
export type TxtKind = 'i' | 'n' | 'o' | 'f'; // identifier, number, operator, upright name
|
||||
|
||||
export type MathNode =
|
||||
| { k: 'row'; xs: MathNode[] }
|
||||
| { k: 'txt'; s: string; kind: TxtKind }
|
||||
| { k: 'frac'; num: MathNode; den: MathNode }
|
||||
| { k: 'script'; base: MathNode; sub?: MathNode; sup?: MathNode }
|
||||
| { k: 'sqrt'; x: MathNode; index?: MathNode }
|
||||
| { k: 'fence'; open: string; close: string; x: MathNode }
|
||||
| { k: 'nary'; op: string; lo?: MathNode; hi?: MathNode; x: MathNode; under: boolean }
|
||||
| { k: 'acc'; ch: string; x: MathNode }
|
||||
| { k: 'matrix'; rows: MathNode[][]; open: string; close: string }
|
||||
| { k: 'space'; em: number };
|
||||
|
||||
export const row = (xs: MathNode[]): MathNode => (xs.length === 1 ? xs[0] : { k: 'row', xs });
|
||||
export const txt = (s: string, kind: TxtKind): MathNode => ({ k: 'txt', s, kind });
|
||||
const EMPTY: MathNode = { k: 'row', xs: [] };
|
||||
|
||||
// ---- symbols ---------------------------------------------------------------
|
||||
// One table drives both directions, so an imported α is written back as \alpha.
|
||||
export const SYMBOLS: Record<string, string> = {
|
||||
alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ϵ', varepsilon: 'ε',
|
||||
zeta: 'ζ', eta: 'η', theta: 'θ', vartheta: 'ϑ', iota: 'ι', kappa: 'κ',
|
||||
lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', pi: 'π', varpi: 'ϖ', rho: 'ρ',
|
||||
varrho: 'ϱ', sigma: 'σ', varsigma: 'ς', tau: 'τ', upsilon: 'υ', phi: 'ϕ',
|
||||
varphi: 'φ', chi: 'χ', psi: 'ψ', omega: 'ω',
|
||||
Gamma: 'Γ', Delta: 'Δ', Theta: 'Θ', Lambda: 'Λ', Xi: 'Ξ', Pi: 'Π',
|
||||
Sigma: 'Σ', Upsilon: 'Υ', Phi: 'Φ', Psi: 'Ψ', Omega: 'Ω',
|
||||
times: '×', div: '÷', pm: '±', mp: '∓', cdot: '⋅', ast: '∗', star: '⋆',
|
||||
circ: '∘', bullet: '∙', oplus: '⊕', ominus: '⊖', otimes: '⊗', odot: '⊙',
|
||||
le: '≤', leq: '≤', ge: '≥', geq: '≥', ne: '≠', neq: '≠', equiv: '≡',
|
||||
approx: '≈', sim: '∼', simeq: '≃', cong: '≅', propto: '∝', ll: '≪', gg: '≫',
|
||||
subset: '⊂', supset: '⊃', subseteq: '⊆', supseteq: '⊇', in: '∈', notin: '∉',
|
||||
cup: '∪', cap: '∩', setminus: '∖', emptyset: '∅', forall: '∀', exists: '∃',
|
||||
neg: '¬', wedge: '∧', vee: '∨', angle: '∠', perp: '⊥', parallel: '∥',
|
||||
infty: '∞', partial: '∂', nabla: '∇', hbar: 'ℏ', ell: 'ℓ', Re: 'ℜ', Im: 'ℑ',
|
||||
aleph: 'ℵ', prime: '′', degree: '°', dots: '…', ldots: '…', cdots: '⋯',
|
||||
vdots: '⋮', ddots: '⋱',
|
||||
rightarrow: '→', to: '→', leftarrow: '←', gets: '←', leftrightarrow: '↔',
|
||||
Rightarrow: '⇒', Leftarrow: '⇐', Leftrightarrow: '⇔', mapsto: '↦',
|
||||
uparrow: '↑', downarrow: '↓',
|
||||
};
|
||||
|
||||
// n-ary operators: the ones whose limits sit under/over the sign in display style.
|
||||
export const NARY: Record<string, string> = {
|
||||
sum: '∑', prod: '∏', coprod: '∐', int: '∫', iint: '∬', iiint: '∭',
|
||||
oint: '∮', bigcup: '⋃', bigcap: '⋂', bigoplus: '⨁', bigotimes: '⨂',
|
||||
bigwedge: '⋀', bigvee: '⋁', lim: 'lim',
|
||||
};
|
||||
const UNDER_LIMITS = new Set(['sum', 'prod', 'coprod', 'bigcup', 'bigcap', 'bigoplus', 'bigotimes', 'bigwedge', 'bigvee', 'lim']);
|
||||
|
||||
// Upright function names (LaTeX writes \sin, Word an m:func with m:sty="p").
|
||||
export const FUNCS = [
|
||||
'arccos', 'arcsin', 'arctan', 'arg', 'cosh', 'coth', 'cos', 'cot', 'csc',
|
||||
'deg', 'det', 'dim', 'exp', 'gcd', 'hom', 'inf', 'ker', 'lg', 'ln', 'log',
|
||||
'max', 'min', 'Pr', 'sec', 'sinh', 'sin', 'sup', 'tanh', 'tan',
|
||||
];
|
||||
|
||||
const ACCENTS: Record<string, string> = {
|
||||
bar: '‾', overline: '‾', hat: '^', widehat: '^', tilde: '~', widetilde: '~',
|
||||
vec: '⃗', dot: '˙', ddot: '¨', check: 'ˇ', breve: '˘', acute: '´', grave: '`',
|
||||
};
|
||||
const ACCENT_BY_CHAR: Record<string, string> = {
|
||||
'‾': 'bar', '¯': 'bar', '^': 'hat', 'ˆ': 'hat', '~': 'tilde', '˜': 'tilde',
|
||||
'⃗': 'vec', '→': 'vec', '˙': 'dot', '¨': 'ddot', 'ˇ': 'check', '˘': 'breve',
|
||||
'´': 'acute', '`': 'grave',
|
||||
};
|
||||
export const accentName = (ch: string): string => ACCENT_BY_CHAR[ch] ?? 'hat';
|
||||
|
||||
const OPEN_CLOSE: Record<string, string> = {
|
||||
'(': '(', ')': ')', '[': '[', ']': ']', '\\{': '{', '\\}': '}', '|': '|',
|
||||
'\\|': '‖', '\\langle': '⟨', '\\rangle': '⟩', '\\lfloor': '⌊', '\\rfloor': '⌋',
|
||||
'\\lceil': '⌈', '\\rceil': '⌉', '.': '',
|
||||
};
|
||||
|
||||
const MATRIX_FENCES: Record<string, [string, string]> = {
|
||||
matrix: ['', ''], pmatrix: ['(', ')'], bmatrix: ['[', ']'],
|
||||
Bmatrix: ['{', '}'], vmatrix: ['|', '|'], Vmatrix: ['‖', '‖'],
|
||||
cases: ['{', ''],
|
||||
};
|
||||
|
||||
const SPACES: Record<string, number> = { ',': 0.167, ':': 0.222, ';': 0.278, '!': -0.167, quad: 1, qquad: 2 };
|
||||
|
||||
// Reverse symbol lookup, first name wins so \le beats \leq when writing back.
|
||||
const NAME_BY_CHAR = new Map<string, string>();
|
||||
for (const [name, ch] of Object.entries(SYMBOLS)) if (!NAME_BY_CHAR.has(ch)) NAME_BY_CHAR.set(ch, name);
|
||||
const NARY_BY_CHAR = new Map<string, string>();
|
||||
for (const [name, ch] of Object.entries(NARY)) if (!NARY_BY_CHAR.has(ch)) NARY_BY_CHAR.set(ch, name);
|
||||
|
||||
export const naryIsUnder = (op: string): boolean => {
|
||||
const name = NARY_BY_CHAR.get(op);
|
||||
return !!name && UNDER_LIMITS.has(name);
|
||||
};
|
||||
|
||||
// The character class decides the MathML element (mi/mn/mo) and OMML styling.
|
||||
export function classify(ch: string): TxtKind {
|
||||
if (/[0-9.]/.test(ch)) return 'n';
|
||||
if (/[A-Za-z]/.test(ch)) return 'i';
|
||||
if (/[Ͱ-Ͽἀ-℀-⅏]/.test(ch)) return 'i'; // greek, letterlike
|
||||
return 'o';
|
||||
}
|
||||
|
||||
// ---- parser ----------------------------------------------------------------
|
||||
|
||||
class Parser {
|
||||
private i = 0;
|
||||
constructor(private readonly src: string) {}
|
||||
|
||||
parse(): MathNode {
|
||||
const xs = this.parseUntil(null);
|
||||
return row(xs);
|
||||
}
|
||||
|
||||
// Reads nodes until `stop` (a control word like `right` or `end`) or the input ends.
|
||||
// The terminator is consumed by the caller's dedicated reader, not here.
|
||||
private parseUntil(stop: string | null): MathNode[] {
|
||||
const out: MathNode[] = [];
|
||||
while (this.i < this.src.length) {
|
||||
const c = this.src[this.i];
|
||||
if (c === '}') break;
|
||||
if (stop && this.peekCmd() === stop) break;
|
||||
if (c === '&' || (c === '\\' && this.src.startsWith('\\\\', this.i))) break;
|
||||
const n = this.atom();
|
||||
if (!n) break;
|
||||
out.push(this.scripts(n, stop));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// A trailing _/^ binds to the atom just read; both orders and a bare `\limits` are
|
||||
// accepted, and an n-ary's scripts become its limits rather than a script pair.
|
||||
private scripts(base: MathNode, stop: string | null): MathNode {
|
||||
let sub: MathNode | undefined;
|
||||
let sup: MathNode | undefined;
|
||||
for (;;) {
|
||||
this.ws();
|
||||
const c = this.src[this.i];
|
||||
if (c === '_' && sub === undefined) { this.i++; sub = this.group(); }
|
||||
else if (c === '^' && sup === undefined) { this.i++; sup = this.group(); }
|
||||
else if (this.peekCmd() === 'limits' || this.peekCmd() === 'nolimits') { this.readCmd(); }
|
||||
else break;
|
||||
}
|
||||
if (sub === undefined && sup === undefined) return base;
|
||||
if (base.k === 'nary' && base.x.k === 'row' && base.x.xs.length === 0) {
|
||||
// \sum_{i=1}^n f — the limits attach to the operator, the body is the rest of
|
||||
// the enclosing group, so it has to stop where that group does (\right, \end).
|
||||
const body = this.parseUntil(stop);
|
||||
// Only the immediately following atoms belong to the operator; the parse loop
|
||||
// owns the rest, so put them back by returning a row.
|
||||
return { ...base, lo: sub, hi: sup, x: row(body) };
|
||||
}
|
||||
return { k: 'script', base, sub, sup };
|
||||
}
|
||||
|
||||
// A `{…}` group, a single character, or one command.
|
||||
private group(): MathNode {
|
||||
this.ws();
|
||||
if (this.src[this.i] === '{') {
|
||||
this.i++;
|
||||
const xs = this.parseUntil(null);
|
||||
if (this.src[this.i] === '}') this.i++;
|
||||
return row(xs);
|
||||
}
|
||||
return this.atom() ?? EMPTY;
|
||||
}
|
||||
|
||||
private ws() { while (this.i < this.src.length && /\s/.test(this.src[this.i])) this.i++; }
|
||||
|
||||
private peekCmd(): string | null {
|
||||
let j = this.i;
|
||||
while (j < this.src.length && /\s/.test(this.src[j])) j++;
|
||||
if (this.src[j] !== '\\') return null;
|
||||
const m = /^\\([A-Za-z]+)/.exec(this.src.slice(j));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
private readCmd(): string {
|
||||
this.ws();
|
||||
this.i++; // backslash
|
||||
const m = /^[A-Za-z]+/.exec(this.src.slice(this.i));
|
||||
if (!m) { const c = this.src[this.i] ?? ''; this.i++; return c; }
|
||||
this.i += m[0].length;
|
||||
return m[0];
|
||||
}
|
||||
|
||||
private atom(): MathNode | null {
|
||||
this.ws();
|
||||
if (this.i >= this.src.length) return null;
|
||||
const c = this.src[this.i];
|
||||
if (c === '}' || c === '&') return null;
|
||||
if (c === '\\') return this.command();
|
||||
if (c === '_' || c === '^') return null; // handled by scripts()
|
||||
if (c === '{') return this.group();
|
||||
this.i++;
|
||||
return txt(c, classify(c));
|
||||
}
|
||||
|
||||
private command(): MathNode | null {
|
||||
if (this.src.startsWith('\\\\', this.i)) return null;
|
||||
const name = this.readCmd();
|
||||
|
||||
if (name === 'frac' || name === 'dfrac' || name === 'tfrac') {
|
||||
return { k: 'frac', num: this.group(), den: this.group() };
|
||||
}
|
||||
if (name === 'sqrt') {
|
||||
let index: MathNode | undefined;
|
||||
this.ws();
|
||||
if (this.src[this.i] === '[') {
|
||||
const end = this.src.indexOf(']', this.i);
|
||||
const inner = end < 0 ? '' : this.src.slice(this.i + 1, end);
|
||||
this.i = end < 0 ? this.src.length : end + 1;
|
||||
index = parseLatex(inner);
|
||||
}
|
||||
return { k: 'sqrt', x: this.group(), index };
|
||||
}
|
||||
if (name === 'left') return this.fence();
|
||||
if (name === 'begin') return this.environment();
|
||||
if (name === 'text' || name === 'mathrm' || name === 'mathit' || name === 'operatorname') {
|
||||
const g = this.group();
|
||||
return { k: 'txt', s: flatText(g), kind: name === 'mathit' ? 'i' : 'f' };
|
||||
}
|
||||
if (name in ACCENTS) return { k: 'acc', ch: ACCENTS[name], x: this.group() };
|
||||
if (name in NARY) return { k: 'nary', op: NARY[name], x: EMPTY, under: UNDER_LIMITS.has(name) };
|
||||
if (FUNCS.includes(name)) return { k: 'txt', s: name, kind: 'f' };
|
||||
if (name in SYMBOLS) return txt(SYMBOLS[name], classify(SYMBOLS[name]));
|
||||
if (name in SPACES) return { k: 'space', em: SPACES[name] };
|
||||
if (name === '{' || name === '}' || name === '|') return txt(name === '|' ? '‖' : name, 'o');
|
||||
if (/^[^A-Za-z]$/.test(name)) return txt(name, classify(name)); // \$, \%, \&, …
|
||||
// An unknown macro degrades to its own name rather than vanishing.
|
||||
return { k: 'txt', s: name, kind: 'f' };
|
||||
}
|
||||
|
||||
private fence(): MathNode {
|
||||
const open = this.delimiter();
|
||||
const xs = this.parseUntil('right');
|
||||
let close = '';
|
||||
if (this.peekCmd() === 'right') { this.readCmd(); close = this.delimiter(); }
|
||||
return { k: 'fence', open, close, x: row(xs) };
|
||||
}
|
||||
|
||||
private delimiter(): string {
|
||||
this.ws();
|
||||
if (this.src[this.i] === '\\') {
|
||||
const name = this.readCmd();
|
||||
const key = `\\${name}`;
|
||||
return key in OPEN_CLOSE ? OPEN_CLOSE[key] : (SYMBOLS[name] ?? name);
|
||||
}
|
||||
const c = this.src[this.i] ?? '';
|
||||
this.i++;
|
||||
return OPEN_CLOSE[c] ?? c;
|
||||
}
|
||||
|
||||
private environment(): MathNode {
|
||||
const name = flatText(this.group());
|
||||
const fences = MATRIX_FENCES[name] ?? ['', ''];
|
||||
const rows: MathNode[][] = [];
|
||||
let cells: MathNode[] = [];
|
||||
for (;;) {
|
||||
if (this.peekCmd() === 'end') { this.readCmd(); this.group(); break; }
|
||||
if (this.i >= this.src.length) break;
|
||||
cells.push(row(this.parseUntil('end')));
|
||||
this.ws();
|
||||
if (this.src[this.i] === '&') { this.i++; continue; }
|
||||
if (this.src.startsWith('\\\\', this.i)) { this.i += 2; rows.push(cells); cells = []; continue; }
|
||||
if (this.peekCmd() === 'end') { this.readCmd(); this.group(); break; }
|
||||
break;
|
||||
}
|
||||
if (cells.length) rows.push(cells);
|
||||
return { k: 'matrix', rows, open: fences[0], close: fences[1] };
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLatex(src: string): MathNode {
|
||||
return new Parser(src ?? '').parse();
|
||||
}
|
||||
|
||||
// The plain text of a subtree — used for \text{…} bodies and environment names.
|
||||
function flatText(n: MathNode): string {
|
||||
switch (n.k) {
|
||||
case 'txt': return n.s;
|
||||
case 'row': return n.xs.map(flatText).join('');
|
||||
default: return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- serializer ------------------------------------------------------------
|
||||
|
||||
const FENCE_CMD: Record<string, string> = {
|
||||
'{': '\\{', '}': '\\}', '‖': '\\|', '⟨': '\\langle', '⟩': '\\rangle',
|
||||
'⌊': '\\lfloor', '⌋': '\\rfloor', '⌈': '\\lceil', '⌉': '\\rceil', '': '.',
|
||||
};
|
||||
const SPACE_CMD: Record<string, string> = { '0.167': '\\,', '0.222': '\\:', '0.278': '\\;', '-0.167': '\\!', '1': '\\quad', '2': '\\qquad' };
|
||||
|
||||
// A subtree needs braces wherever LaTeX takes a single token (fraction parts, scripts).
|
||||
const braced = (n: MathNode): string => `{${astToLatex(n)}}`;
|
||||
|
||||
export function astToLatex(n: MathNode): string {
|
||||
switch (n.k) {
|
||||
case 'row': return n.xs.map(astToLatex).join('');
|
||||
case 'txt': return n.kind === 'f' ? funcLatex(n.s) : n.s.split('').map(charLatex).join('');
|
||||
case 'frac': return `\\frac${braced(n.num)}${braced(n.den)}`;
|
||||
case 'script': {
|
||||
const base = needsBrace(n.base) ? braced(n.base) : astToLatex(n.base);
|
||||
return base + (n.sub ? `_${braced(n.sub)}` : '') + (n.sup ? `^${braced(n.sup)}` : '');
|
||||
}
|
||||
case 'sqrt': return `\\sqrt${n.index ? `[${astToLatex(n.index)}]` : ''}${braced(n.x)}`;
|
||||
case 'fence': return `\\left${fenceCmd(n.open)}${astToLatex(n.x)}\\right${fenceCmd(n.close)}`;
|
||||
case 'nary': {
|
||||
const op = NARY_BY_CHAR.get(n.op) ? `\\${NARY_BY_CHAR.get(n.op)}` : n.op;
|
||||
const lo = n.lo ? `_${braced(n.lo)}` : '';
|
||||
const hi = n.hi ? `^${braced(n.hi)}` : '';
|
||||
return `${op}${lo}${hi} ${astToLatex(n.x)}`;
|
||||
}
|
||||
case 'acc': return `\\${accentName(n.ch)}${braced(n.x)}`;
|
||||
case 'matrix': {
|
||||
const env = matrixEnv(n.open, n.close);
|
||||
const body = n.rows.map(r => r.map(astToLatex).join(' & ')).join(' \\\\ ');
|
||||
return `\\begin{${env}}${body}\\end{${env}}`;
|
||||
}
|
||||
case 'space': return SPACE_CMD[String(n.em)] ?? '\\,';
|
||||
}
|
||||
}
|
||||
|
||||
// A multi-character or structural base must be braced or `_`/`^` would bind to its last token.
|
||||
function needsBrace(n: MathNode): boolean {
|
||||
return !(n.k === 'txt' && n.s.length === 1) && n.k !== 'fence' && n.k !== 'sqrt' && n.k !== 'frac';
|
||||
}
|
||||
|
||||
// A literal ^ or _ from an imported formula's text must be escaped, or re-parsing
|
||||
// would read it as a script marker (Word documents do contain typed-in code).
|
||||
function charLatex(ch: string): string {
|
||||
const name = NAME_BY_CHAR.get(ch);
|
||||
if (name) return `\\${name} `;
|
||||
return /[#$%&_{}^]/.test(ch) ? `\\${ch}` : ch;
|
||||
}
|
||||
|
||||
function funcLatex(s: string): string {
|
||||
if (FUNCS.includes(s)) return `\\${s} `;
|
||||
return `\\text{${s}}`;
|
||||
}
|
||||
|
||||
function fenceCmd(ch: string): string {
|
||||
return FENCE_CMD[ch] ?? (ch || '.');
|
||||
}
|
||||
|
||||
function matrixEnv(open: string, close: string): string {
|
||||
for (const [name, [o, c]] of Object.entries(MATRIX_FENCES)) {
|
||||
if (name !== 'matrix' && o === open && c === close) return name;
|
||||
}
|
||||
return 'matrix';
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// MathML face of the formula AST: what the browser renders on screen and what goes
|
||||
// into the .odt's embedded formula object. Foreign MathML is parsed back into the AST
|
||||
// (never re-emitted verbatim), so nothing untrusted reaches the NodeView's innerHTML.
|
||||
|
||||
import {
|
||||
type MathNode, type TxtKind, row, txt, classify, naryIsUnder, FUNCS,
|
||||
} from './latex';
|
||||
|
||||
export const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const TAG_BY_KIND: Record<TxtKind, string> = { i: 'mi', n: 'mn', o: 'mo', f: 'mi' };
|
||||
|
||||
// A row that MathML needs as a single element (mfrac, msup, … each take exactly one
|
||||
// child per slot), so a multi-node row is wrapped in <mrow>.
|
||||
function slot(n: MathNode): string {
|
||||
const xml = astToMathml(n);
|
||||
return n.k === 'row' && n.xs.length !== 1 ? `<mrow>${xml}</mrow>` : xml;
|
||||
}
|
||||
|
||||
export function astToMathml(n: MathNode): string {
|
||||
switch (n.k) {
|
||||
case 'row': return n.xs.map(astToMathml).join('');
|
||||
case 'txt': {
|
||||
if (n.kind === 'f') return `<mi mathvariant="normal">${esc(n.s)}</mi>`;
|
||||
// Multi-letter identifiers stay upright in MathML unless split, and a number
|
||||
// like 3.14 is one token — so emit the run whole and let the class pick the tag.
|
||||
return `<${TAG_BY_KIND[n.kind]}>${esc(n.s)}</${TAG_BY_KIND[n.kind]}>`;
|
||||
}
|
||||
case 'frac': return `<mfrac>${slot(n.num)}${slot(n.den)}</mfrac>`;
|
||||
case 'script': {
|
||||
const base = slot(n.base);
|
||||
if (n.sub && n.sup) return `<msubsup>${base}${slot(n.sub)}${slot(n.sup)}</msubsup>`;
|
||||
if (n.sub) return `<msub>${base}${slot(n.sub)}</msub>`;
|
||||
return `<msup>${base}${slot(n.sup ?? row([]))}</msup>`;
|
||||
}
|
||||
case 'sqrt':
|
||||
return n.index
|
||||
? `<mroot>${slot(n.x)}${slot(n.index)}</mroot>`
|
||||
: `<msqrt>${astToMathml(n.x)}</msqrt>`;
|
||||
case 'fence': {
|
||||
const open = n.open ? `<mo stretchy="true">${esc(n.open)}</mo>` : '';
|
||||
const close = n.close ? `<mo stretchy="true">${esc(n.close)}</mo>` : '';
|
||||
return `<mrow>${open}${astToMathml(n.x)}${close}</mrow>`;
|
||||
}
|
||||
case 'nary': {
|
||||
const op = `<mo>${esc(n.op)}</mo>`;
|
||||
const under = n.under;
|
||||
let sign = op;
|
||||
if (n.lo && n.hi) sign = `<${under ? 'munderover' : 'msubsup'}>${op}${slot(n.lo)}${slot(n.hi)}</${under ? 'munderover' : 'msubsup'}>`;
|
||||
else if (n.lo) sign = `<${under ? 'munder' : 'msub'}>${op}${slot(n.lo)}</${under ? 'munder' : 'msub'}>`;
|
||||
else if (n.hi) sign = `<${under ? 'mover' : 'msup'}>${op}${slot(n.hi)}</${under ? 'mover' : 'msup'}>`;
|
||||
return `<mrow>${sign}${astToMathml(n.x)}</mrow>`;
|
||||
}
|
||||
case 'acc':
|
||||
return `<mover accent="true">${slot(n.x)}<mo stretchy="true">${esc(n.ch)}</mo></mover>`;
|
||||
case 'matrix': {
|
||||
const body = n.rows
|
||||
.map(r => `<mtr>${r.map(c => `<mtd>${astToMathml(c)}</mtd>`).join('')}</mtr>`)
|
||||
.join('');
|
||||
const open = n.open ? `<mo stretchy="true">${esc(n.open)}</mo>` : '';
|
||||
const close = n.close ? `<mo stretchy="true">${esc(n.close)}</mo>` : '';
|
||||
return `<mrow>${open}<mtable>${body}</mtable>${close}</mrow>`;
|
||||
}
|
||||
case 'space': return `<mspace width="${n.em}em"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
// The full <math> element for one formula.
|
||||
export function mathmlDocument(latexAst: MathNode, display: boolean, latex?: string): string {
|
||||
const body = `<mrow>${astToMathml(latexAst)}</mrow>`;
|
||||
// The LaTeX source rides along so our own documents re-import losslessly; a foreign
|
||||
// reader ignores the annotation and renders the presentation markup.
|
||||
const annotated = latex != null
|
||||
? `<semantics>${body}<annotation encoding="application/x-tex">${esc(latex)}</annotation></semantics>`
|
||||
: body;
|
||||
return `<math xmlns="${MATHML_NS}" display="${display ? 'block' : 'inline'}">${annotated}</math>`;
|
||||
}
|
||||
|
||||
// ---- parsing ---------------------------------------------------------------
|
||||
|
||||
const kids = (el: Element): Element[] =>
|
||||
Array.from(el.children).filter(c => c.localName !== 'annotation' && c.localName !== 'annotation-xml');
|
||||
|
||||
const one = (el: Element, i: number): MathNode => {
|
||||
const c = kids(el)[i];
|
||||
return c ? parseMathmlNode(c) : row([]);
|
||||
};
|
||||
|
||||
// Presentation MathML → AST. Unknown elements recurse into their children, so a
|
||||
// construct we don't model still contributes its text instead of disappearing.
|
||||
export function parseMathmlNode(el: Element): MathNode {
|
||||
const cs = kids(el);
|
||||
switch (el.localName) {
|
||||
case 'math':
|
||||
case 'semantics':
|
||||
case 'mrow':
|
||||
case 'mstyle':
|
||||
case 'mpadded':
|
||||
case 'mtd':
|
||||
return fence(cs.map(parseMathmlNode));
|
||||
case 'mi': {
|
||||
const s = el.textContent ?? '';
|
||||
const upright = el.getAttribute('mathvariant') === 'normal';
|
||||
return { k: 'txt', s, kind: upright && (s.length > 1 || FUNCS.includes(s)) ? 'f' : classify(s[0] ?? 'x') };
|
||||
}
|
||||
case 'mn': return txt(el.textContent ?? '', 'n');
|
||||
case 'mo': return txt(el.textContent ?? '', 'o');
|
||||
case 'mtext': return { k: 'txt', s: el.textContent ?? '', kind: 'f' };
|
||||
case 'mfrac': return { k: 'frac', num: one(el, 0), den: one(el, 1) };
|
||||
case 'msqrt': return { k: 'sqrt', x: fence(cs.map(parseMathmlNode)) };
|
||||
case 'mroot': return { k: 'sqrt', x: one(el, 0), index: one(el, 1) };
|
||||
case 'msub': return { k: 'script', base: one(el, 0), sub: one(el, 1) };
|
||||
case 'msup': return { k: 'script', base: one(el, 0), sup: one(el, 1) };
|
||||
case 'msubsup': return { k: 'script', base: one(el, 0), sub: one(el, 1), sup: one(el, 2) };
|
||||
case 'munder': return limits(one(el, 0), one(el, 1), undefined);
|
||||
case 'mover': return over(el, cs);
|
||||
case 'munderover': return limits(one(el, 0), one(el, 1), one(el, 2));
|
||||
case 'mtable':
|
||||
return {
|
||||
k: 'matrix',
|
||||
rows: cs.map(r => kids(r).map(parseMathmlNode)),
|
||||
open: '', close: '',
|
||||
};
|
||||
case 'mspace': {
|
||||
const w = parseFloat(el.getAttribute('width') ?? '0');
|
||||
return { k: 'space', em: Number.isFinite(w) ? w : 0.167 };
|
||||
}
|
||||
default:
|
||||
return cs.length ? fence(cs.map(parseMathmlNode)) : txt(el.textContent ?? '', 'o');
|
||||
}
|
||||
}
|
||||
|
||||
// mover is either an accent (\bar x) or an n-ary upper limit, told apart by @accent.
|
||||
function over(el: Element, cs: Element[]): MathNode {
|
||||
const base = one(el, 0);
|
||||
const mark = cs[1];
|
||||
if (el.getAttribute('accent') === 'true' || ACCENT_MARKS.test(mark?.textContent ?? '')) {
|
||||
return { k: 'acc', ch: mark?.textContent ?? '^', x: base };
|
||||
}
|
||||
return limits(base, undefined, one(el, 1));
|
||||
}
|
||||
|
||||
const ACCENT_MARKS = /^[\^ˆ~˜‾¯⃗˙¨ˇ˘´`]$/;
|
||||
|
||||
// A script on an operator is that operator's limit; on anything else it stays a script.
|
||||
function limits(base: MathNode, lo: MathNode | undefined, hi: MathNode | undefined): MathNode {
|
||||
if (base.k === 'txt' && base.kind === 'o') {
|
||||
return { k: 'nary', op: base.s, lo, hi, x: row([]), under: naryIsUnder(base.s) };
|
||||
}
|
||||
return { k: 'script', base, sub: lo, sup: hi };
|
||||
}
|
||||
|
||||
// An mrow whose first/last child is a stretchy bracket is a fence — recovering it keeps
|
||||
// the LaTeX readable (\left( … \right)) and lets the OMML side emit an m:d.
|
||||
function fence(xs: MathNode[]): MathNode {
|
||||
const isBracket = (n: MathNode | undefined) => n?.k === 'txt' && n.kind === 'o' && /^[([{|‖⟨⌊⌈]$/.test(n.s);
|
||||
const isClose = (n: MathNode | undefined) => n?.k === 'txt' && n.kind === 'o' && /^[)\]}|‖⟩⌋⌉]$/.test(n.s);
|
||||
if (xs.length >= 2 && isBracket(xs[0]) && isClose(xs[xs.length - 1])) {
|
||||
const open = (xs[0] as { s: string }).s;
|
||||
const close = (xs[xs.length - 1] as { s: string }).s;
|
||||
return { k: 'fence', open, close, x: row(xs.slice(1, -1)) };
|
||||
}
|
||||
return row(xs);
|
||||
}
|
||||
|
||||
// The <math> root of an ODF formula → AST. Prefers our own LaTeX annotation when the
|
||||
// document is one we wrote, so a round trip is exact rather than merely equivalent.
|
||||
export function parseMathml(mathEl: Element): { latex: string | null; ast: MathNode; display: boolean } {
|
||||
const ann = Array.from(mathEl.getElementsByTagName('*'))
|
||||
.find(e => e.localName === 'annotation' && e.getAttribute('encoding') === 'application/x-tex');
|
||||
return {
|
||||
latex: ann?.textContent?.trim() || null,
|
||||
ast: parseMathmlNode(mathEl),
|
||||
display: mathEl.getAttribute('display') === 'block',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
// OMML face of the formula AST — Word's math markup (`m:oMath`). Same contract as
|
||||
// mathml.ts: parse foreign OMML into the AST, never pass it through.
|
||||
|
||||
import { type MathNode, row, txt, classify, naryIsUnder, FUNCS } from './latex';
|
||||
|
||||
export const OMML_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/math';
|
||||
|
||||
const esc = (s: string) =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
const attr = (s: string) => esc(s).replace(/"/g, '"');
|
||||
|
||||
// `m:e` and friends take element content only, so every slot is serialized whole.
|
||||
const slot = (tag: string, n: MathNode) => `<${tag}>${astToOmml(n)}</${tag}>`;
|
||||
|
||||
export function astToOmml(n: MathNode): string {
|
||||
switch (n.k) {
|
||||
case 'row': return n.xs.map(astToOmml).join('');
|
||||
// m:sty="p" is Word's upright run; without it a function name comes back italic.
|
||||
case 'txt': return `<m:r>${n.kind === 'f' ? '<m:rPr><m:sty m:val="p"/></m:rPr>' : ''}<m:t xml:space="preserve">${esc(n.s)}</m:t></m:r>`;
|
||||
case 'frac': return `<m:f><m:fPr><m:ctrlPr/></m:fPr>${slot('m:num', n.num)}${slot('m:den', n.den)}</m:f>`;
|
||||
case 'script': {
|
||||
if (n.sub && n.sup) return `<m:sSubSup><m:sSubSupPr><m:ctrlPr/></m:sSubSupPr>${slot('m:e', n.base)}${slot('m:sub', n.sub)}${slot('m:sup', n.sup)}</m:sSubSup>`;
|
||||
if (n.sub) return `<m:sSub><m:sSubPr><m:ctrlPr/></m:sSubPr>${slot('m:e', n.base)}${slot('m:sub', n.sub)}</m:sSub>`;
|
||||
return `<m:sSup><m:sSupPr><m:ctrlPr/></m:sSupPr>${slot('m:e', n.base)}${slot('m:sup', n.sup ?? row([]))}</m:sSup>`;
|
||||
}
|
||||
case 'sqrt':
|
||||
return n.index
|
||||
? `<m:rad><m:radPr><m:degHide m:val="0"/><m:ctrlPr/></m:radPr>${slot('m:deg', n.index)}${slot('m:e', n.x)}</m:rad>`
|
||||
: `<m:rad><m:radPr><m:degHide m:val="1"/><m:ctrlPr/></m:radPr><m:deg/>${slot('m:e', n.x)}</m:rad>`;
|
||||
case 'fence':
|
||||
return `<m:d><m:dPr><m:begChr m:val="${attr(n.open)}"/><m:endChr m:val="${attr(n.close)}"/><m:ctrlPr/></m:dPr>${slot('m:e', n.x)}</m:d>`;
|
||||
case 'nary': {
|
||||
const pr =
|
||||
`<m:naryPr><m:chr m:val="${attr(n.op)}"/><m:limLoc m:val="${n.under ? 'undOvr' : 'subSup'}"/>` +
|
||||
`<m:subHide m:val="${n.lo ? 0 : 1}"/><m:supHide m:val="${n.hi ? 0 : 1}"/><m:ctrlPr/></m:naryPr>`;
|
||||
return `<m:nary>${pr}${slot('m:sub', n.lo ?? row([]))}${slot('m:sup', n.hi ?? row([]))}${slot('m:e', n.x)}</m:nary>`;
|
||||
}
|
||||
case 'acc': return `<m:acc><m:accPr><m:chr m:val="${attr(n.ch)}"/><m:ctrlPr/></m:accPr>${slot('m:e', n.x)}</m:acc>`;
|
||||
case 'matrix': {
|
||||
const cols = Math.max(1, ...n.rows.map(r => r.length));
|
||||
const props = `<m:mPr><m:mcs><m:mc><m:mcPr><m:count m:val="${cols}"/><m:mcJc m:val="center"/></m:mcPr></m:mc></m:mcs><m:ctrlPr/></m:mPr>`;
|
||||
const body = n.rows.map(r => `<m:mr>${r.map(c => slot('m:e', c)).join('')}</m:mr>`).join('');
|
||||
const table = `<m:m>${props}${body}</m:m>`;
|
||||
if (!n.open && !n.close) return table;
|
||||
return `<m:d><m:dPr><m:begChr m:val="${attr(n.open)}"/><m:endChr m:val="${attr(n.close)}"/><m:ctrlPr/></m:dPr><m:e>${table}</m:e></m:d>`;
|
||||
}
|
||||
// OMML has no measured space; a run of blanks is the closest Word equivalent.
|
||||
case 'space': return `<m:r><m:t xml:space="preserve">${n.em >= 1 ? ' ' : ' '}</m:t></m:r>`;
|
||||
}
|
||||
}
|
||||
|
||||
// One `<m:oMath>` element, namespace declared inline so the output never depends on
|
||||
// what the docx library put on the document root.
|
||||
export const ommlDocument = (ast: MathNode): string =>
|
||||
`<m:oMath xmlns:m="${OMML_NS}">${astToOmml(ast)}</m:oMath>`;
|
||||
|
||||
// ---- parsing ---------------------------------------------------------------
|
||||
|
||||
const mChild = (el: Element, name: string): Element | null => {
|
||||
for (const c of Array.from(el.children)) if (c.namespaceURI === OMML_NS && c.localName === name) return c;
|
||||
return null;
|
||||
};
|
||||
const mVal = (el: Element | null, name: string): string | null => {
|
||||
const c = el && mChild(el, name);
|
||||
return c ? c.getAttributeNS(OMML_NS, 'val') : null;
|
||||
};
|
||||
const part = (el: Element, name: string): MathNode => {
|
||||
const c = mChild(el, name);
|
||||
return c ? parseOmmlNode(c) : row([]);
|
||||
};
|
||||
// Element children, minus the property/control blocks that carry no content.
|
||||
const kids = (el: Element): Element[] =>
|
||||
Array.from(el.children).filter(c => c.namespaceURI === OMML_NS && !/Pr$/.test(c.localName));
|
||||
|
||||
// A run's text: `m:sty="p"` (or a Word `w:rPr` without italics on a word) means upright.
|
||||
function runNode(el: Element): MathNode {
|
||||
const text = Array.from(el.children)
|
||||
.filter(c => c.namespaceURI === OMML_NS && c.localName === 't')
|
||||
.map(c => c.textContent ?? '')
|
||||
.join('');
|
||||
if (!text) return row([]);
|
||||
const upright = mVal(mChild(el, 'rPr'), 'sty') === 'p';
|
||||
if (upright && (text.length > 1 || FUNCS.includes(text))) return { k: 'txt', s: text, kind: 'f' };
|
||||
// A run holds a whole expression ("2*L-1"), so split it into per-class tokens —
|
||||
// otherwise every digit and operator would inherit the first character's class.
|
||||
return row(tokenize(text));
|
||||
}
|
||||
|
||||
// Split a run's text into maximal same-class tokens (letters, digits, operators).
|
||||
// Whitespace is dropped: math layout ignores it (explicit gaps are \, and \quad), and
|
||||
// keeping it would make the LaTeX stop round-tripping through its own parser.
|
||||
function tokenize(text: string): MathNode[] {
|
||||
const out: MathNode[] = [];
|
||||
let buf = '';
|
||||
let kind = classify(text[0]);
|
||||
for (const ch of text) {
|
||||
if (/\s/.test(ch)) continue;
|
||||
const k = classify(ch);
|
||||
// Letters stay one token per character: `ab` is a·b in math, not the word "ab".
|
||||
if (k === kind && buf && k !== 'i' && k !== 'o') { buf += ch; continue; }
|
||||
if (buf) out.push(txt(buf, kind));
|
||||
buf = ch;
|
||||
kind = k;
|
||||
}
|
||||
if (buf) out.push(txt(buf, kind));
|
||||
return out;
|
||||
}
|
||||
|
||||
// OMML → AST. An element we don't model falls through to its `m:e` children, so its
|
||||
// content survives even when its structure doesn't.
|
||||
export function parseOmmlNode(el: Element): MathNode {
|
||||
switch (el.localName) {
|
||||
case 'oMath':
|
||||
case 'oMathPara':
|
||||
case 'e':
|
||||
case 'num':
|
||||
case 'den':
|
||||
case 'sub':
|
||||
case 'sup':
|
||||
case 'deg':
|
||||
case 'fName':
|
||||
return row(kids(el).map(parseOmmlNode));
|
||||
case 'r': return runNode(el);
|
||||
case 'f': return { k: 'frac', num: part(el, 'num'), den: part(el, 'den') };
|
||||
case 'sSub': return { k: 'script', base: part(el, 'e'), sub: part(el, 'sub') };
|
||||
case 'sSup': return { k: 'script', base: part(el, 'e'), sup: part(el, 'sup') };
|
||||
case 'sSubSup': return { k: 'script', base: part(el, 'e'), sub: part(el, 'sub'), sup: part(el, 'sup') };
|
||||
case 'rad': {
|
||||
const hidden = mVal(mChild(el, 'radPr'), 'degHide') !== '0';
|
||||
const deg = mChild(el, 'deg');
|
||||
const index = hidden || !deg || !deg.children.length ? undefined : parseOmmlNode(deg);
|
||||
return { k: 'sqrt', x: part(el, 'e'), index };
|
||||
}
|
||||
case 'd': {
|
||||
const pr = mChild(el, 'dPr');
|
||||
// Word omits begChr/endChr for the default round brackets.
|
||||
const open = mVal(pr, 'begChr') ?? '(';
|
||||
const close = mVal(pr, 'endChr') ?? ')';
|
||||
const inner = Array.from(el.children).filter(c => c.namespaceURI === OMML_NS && c.localName === 'e');
|
||||
const body = inner.length > 1
|
||||
// A multi-slot delimiter (m:sepChr) is a matrix-like list; keep it as a row.
|
||||
? row(inner.map(parseOmmlNode))
|
||||
: part(el, 'e');
|
||||
if (body.k === 'matrix') return { ...body, open, close };
|
||||
return { k: 'fence', open, close, x: body };
|
||||
}
|
||||
case 'nary': {
|
||||
const pr = mChild(el, 'naryPr');
|
||||
const op = mVal(pr, 'chr') ?? '∫';
|
||||
const under = (mVal(pr, 'limLoc') ?? (naryIsUnder(op) ? 'undOvr' : 'subSup')) === 'undOvr';
|
||||
const lo = mVal(pr, 'subHide') === '1' ? undefined : part(el, 'sub');
|
||||
const hi = mVal(pr, 'supHide') === '1' ? undefined : part(el, 'sup');
|
||||
return { k: 'nary', op, lo: nonEmpty(lo), hi: nonEmpty(hi), x: part(el, 'e'), under };
|
||||
}
|
||||
case 'func': return row([part(el, 'fName'), part(el, 'e')]);
|
||||
case 'acc': return { k: 'acc', ch: mVal(mChild(el, 'accPr'), 'chr') ?? '̂', x: part(el, 'e') };
|
||||
case 'bar': return { k: 'acc', ch: '‾', x: part(el, 'e') };
|
||||
case 'm': {
|
||||
const rows = Array.from(el.children)
|
||||
.filter(c => c.namespaceURI === OMML_NS && c.localName === 'mr')
|
||||
.map(r => kids(r).map(parseOmmlNode));
|
||||
return { k: 'matrix', rows, open: '', close: '' };
|
||||
}
|
||||
case 'limLow': return { k: 'nary', op: opOf(part(el, 'e')), lo: part(el, 'lim'), x: row([]), under: true };
|
||||
case 'limUpp': return { k: 'nary', op: opOf(part(el, 'e')), hi: part(el, 'lim'), x: row([]), under: true };
|
||||
default: return row(kids(el).map(parseOmmlNode));
|
||||
}
|
||||
}
|
||||
|
||||
const nonEmpty = (n: MathNode | undefined): MathNode | undefined =>
|
||||
n && n.k === 'row' && n.xs.length === 0 ? undefined : n;
|
||||
|
||||
const opOf = (n: MathNode): string => (n.k === 'txt' ? n.s : 'lim');
|
||||
|
||||
// `m:oMath` / `m:oMathPara` → the formula's LaTeX-ready AST plus its display flag.
|
||||
export function parseOmml(el: Element): { ast: MathNode; display: boolean } {
|
||||
return { ast: parseOmmlNode(el), display: el.localName === 'oMathPara' };
|
||||
}
|
||||
@@ -227,6 +227,32 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Formulas (formula.ts). MathML needs a font with an OpenType MATH table or Chromium
|
||||
drops ∑/ϕ and won't stretch brackets — STIX Two Math is bundled for that. */
|
||||
/* The family must be set on <math> itself: the UA stylesheet declares
|
||||
`math { font-family: math }` on the element, and an inherited value never beats it. */
|
||||
.paper .tiptap .formula math {
|
||||
font-family: 'STIX Two Math', math, serif;
|
||||
font-style: normal;
|
||||
}
|
||||
/* Baseline, not middle: an inline formula sits on the text baseline in LibreOffice and
|
||||
Word, and MathML already carries the right baseline for its own box. */
|
||||
.paper .tiptap span.formula {
|
||||
display: inline-block;
|
||||
vertical-align: baseline;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* A display formula owns its line, centered, as in LibreOffice and Word. */
|
||||
.paper .tiptap span.formula[data-display='true'] {
|
||||
display: block;
|
||||
text-align: center;
|
||||
}
|
||||
.paper .tiptap span.formula.selected {
|
||||
outline: 2px solid var(--color-primary, #52748b);
|
||||
outline-offset: 1px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding
|
||||
box; the rotor holds the <img>, resize handles and rotate grip, rotating together. */
|
||||
.paper .tiptap .image-node {
|
||||
|
||||
@@ -52,6 +52,18 @@
|
||||
src: url('../assets/fonts/EdenSymbols.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* STIX Two Math — a font with an OpenType MATH table is what makes MathML typeset:
|
||||
without one Chromium drops ∑ and ϕ entirely and leaves brackets glyph-height next
|
||||
to a fraction. Word uses Cambria Math for the same job. (OFL — see
|
||||
src/assets/fonts/LICENSE-STIX.txt.) */
|
||||
@font-face {
|
||||
font-family: 'STIX Two Math';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: local('STIX Two Math'), url('../assets/fonts/STIXTwoMath-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Metric-compatible substitutes for the common MS-Office fonts that imported
|
||||
.odt files reference. Each face is keyed to the MS name but prefers a real
|
||||
local copy, then a system twin, then the bundled open-source twin — so text
|
||||
|
||||
@@ -259,4 +259,37 @@ describe.skipIf(!SOFFICE)('LibreOffice round-trip (needs soffice on PATH)', () =
|
||||
const stops = (res.content.content ?? [])[0]?.attrs?.tabStops;
|
||||
check('LO tab: stops + leaders survive', stops === '6l.;12r_', stops);
|
||||
});
|
||||
|
||||
// Needs the libreoffice-math package: without it LibreOffice silently drops every
|
||||
// formula object on load, so this leg reports zero formulas instead of failing loudly.
|
||||
it('survives a `soffice` re-save of embedded formula objects', { timeout: 180000 }, async () => {
|
||||
const F = (latex: string, display: boolean): N => ({ type: 'formula', attrs: { latex, display } });
|
||||
const inline = '\\phi _{ref}=\\frac{a+1}{2\\pi }';
|
||||
const block = '\\sum_{i=1}^{n} \\sqrt{x_{i}^{2}+1}=\\left(\\frac{\\alpha }{\\beta }\\right)';
|
||||
const doc: N = { type: 'doc', content: [
|
||||
P(null, T('Inline: '), F(inline, false), T(' im Text.')),
|
||||
P(null, F(block, true)),
|
||||
] };
|
||||
mkdirSync('/tmp/lo-rt', { recursive: true });
|
||||
writeFileSync('/tmp/lo-rt/math.odt', await buildOdt(doc, margins, 'portrait'));
|
||||
execSync('soffice --headless --convert-to odt --outdir /tmp/lo-rt/mathout /tmp/lo-rt/math.odt', { stdio: 'pipe', timeout: 120000 });
|
||||
const resaved = new Uint8Array(readFileSync('/tmp/lo-rt/mathout/math.odt'));
|
||||
|
||||
// LibreOffice keeps them as real formula sub-documents (it adds its own settings.xml).
|
||||
const names = Object.keys(unzipSync(resaved)).filter((p) => /content\.xml$/.test(p) && p !== 'content.xml');
|
||||
check('LO math: both objects survive as sub-documents', names.length === 2, names);
|
||||
|
||||
const res = importOdt(resaved);
|
||||
check('LO math: no warnings', res.warnings.length === 0, res.warnings);
|
||||
const found: N[] = [];
|
||||
(function walk(n: N) { if (n.type === 'formula') found.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||||
check('LO math: both formulas come back', found.length === 2, found.length);
|
||||
check('LO math: inline source survives', found[0]?.attrs?.latex === inline, found[0]?.attrs?.latex);
|
||||
check('LO math: display source survives', found[1]?.attrs?.latex === block, found[1]?.attrs?.latex);
|
||||
// LibreOffice writes display="block" on every formula it re-saves, so the flag is
|
||||
// read off the paragraph instead — an inline formula must not come back displayed.
|
||||
check('LO math: inline stays inline, display stays display',
|
||||
found[0]?.attrs?.display === false && found[1]?.attrs?.display === true,
|
||||
found.map((f) => f.attrs?.display));
|
||||
});
|
||||
});
|
||||
@@ -364,6 +364,40 @@ describe('Leg 1a2: hardBreak font size (empty-line height)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 1a2b: formulas (embedded ODF formula objects)', () => {
|
||||
it('exports an inline and a display formula and re-imports both LaTeX sources', async () => {
|
||||
const F = (latex: string, display: boolean): N => ({ type: 'formula', attrs: { latex, display } });
|
||||
const inline = '\\phi _{ref}=\\frac{a+1}{2\\pi }';
|
||||
const block = '\\sum_{i=1}^{n} \\sqrt{x_{i}}';
|
||||
const doc: N = { type: 'doc', content: [
|
||||
P(null, T('Die Formel '), F(inline, false), T(' im Text.')),
|
||||
P(null, F(block, true)),
|
||||
] };
|
||||
const bytes = await buildOdt(doc, margins, 'portrait');
|
||||
const files = unzipSync(bytes);
|
||||
const content = strFromU8(files['content.xml']);
|
||||
check('content.xml anchors both frames as-char', (content.match(/<draw:object xlink:href="\.\/Formula\d"/g) ?? []).length === 2, content.match(/<draw:frame[^>]*Formula[^>]*>/g));
|
||||
// A sized frame makes LibreOffice scale the object to fit instead of typesetting
|
||||
// it at its natural size.
|
||||
check('the frames carry no svg geometry', !/<draw:frame[^>]*Formula[^>]*svg:width/.test(content), content.match(/<draw:frame[^>]*Formula[^>]*>/g));
|
||||
// The formula object is its own ODF sub-document; without the manifest entries
|
||||
// LibreOffice ignores it.
|
||||
const manifest = strFromU8(files['META-INF/manifest.xml']);
|
||||
check('manifest declares both formula objects', /Formula1\/" manifest:media-type="application\/vnd\.oasis\.opendocument\.formula"/.test(manifest) && /Formula2\/content\.xml/.test(manifest), manifest.match(/Formula\d\/[^"]*/g));
|
||||
const obj = strFromU8(files['Formula1/content.xml']);
|
||||
check('the object holds MathML, not just the source', /<mfrac>/.test(obj) && /<msub>/.test(obj), obj.slice(0, 200));
|
||||
|
||||
const res = importOdt(bytes);
|
||||
const found: N[] = [];
|
||||
(function walk(n: N) { if (n.type === 'formula') found.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||||
check('both formulas come back', found.length === 2, found.length);
|
||||
check('inline source is unchanged', found[0]?.attrs?.latex === inline, found[0]?.attrs?.latex);
|
||||
check('display source is unchanged', found[1]?.attrs?.latex === block, found[1]?.attrs?.latex);
|
||||
check('the display flag survives', found[0]?.attrs?.display === false && found[1]?.attrs?.display === true, found.map((f) => f.attrs?.display));
|
||||
check('surrounding text is untouched', JSON.stringify(res.content).includes('Die Formel '), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Leg 1a3: continued ordered-list start value', () => {
|
||||
it('round-trips a start > 1 (odf-kit drops it) via text:start-value', async () => {
|
||||
const olist = (start: number | null, t: string): N => ({
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { unzipSync, strFromU8 } from 'fflate';
|
||||
import { buildDocx } from '../../src/lib/export/docx';
|
||||
import { importDocx } from '../../src/lib/import/docx';
|
||||
import { parseLatex, astToLatex } from '../../src/lib/math/latex';
|
||||
|
||||
const formula = (latex: string, display = false) =>
|
||||
({ type: 'formula', attrs: { latex, display } });
|
||||
const text = (t: string) => ({ type: 'text', text: t });
|
||||
|
||||
const docXml = async (doc: any) =>
|
||||
strFromU8(unzipSync(await buildDocx(doc as any))['word/document.xml']);
|
||||
|
||||
// Formulas walk their own namespace beside the w:r runs; the docx library has no OMML,
|
||||
// so they ride a sentinel run that a post-pack pass swaps out.
|
||||
describe('DOCX formulas', () => {
|
||||
it('writes OMML for an inline formula and oMathPara for a display one', async () => {
|
||||
const xml = await docXml({ type: 'doc', content: [
|
||||
{ type: 'paragraph', content: [text('Es gilt '), formula('E=mc^{2}'), text(' immer.')] },
|
||||
{ type: 'paragraph', content: [formula('\\frac{a}{b}', true)] },
|
||||
] });
|
||||
expect(xml).toContain('<m:oMath');
|
||||
expect(xml).toContain('<m:sSup>');
|
||||
expect(xml).toContain('<m:oMathPara');
|
||||
expect(xml).toContain('<m:f>');
|
||||
// The sentinel run must be gone, not merely wrapped.
|
||||
expect(xml).not.toContain('');
|
||||
expect(xml).toContain('Es gilt ');
|
||||
});
|
||||
|
||||
it('round-trips both through import', async () => {
|
||||
const bytes = await buildDocx({ type: 'doc', content: [
|
||||
{ type: 'paragraph', content: [text('x '), formula('\\sum_{i=1}^{n} x_{i}')] },
|
||||
{ type: 'paragraph', content: [formula('\\sqrt{a+b}', true)] },
|
||||
] } as any);
|
||||
const res = importDocx(bytes);
|
||||
const found: any[] = [];
|
||||
(function walk(n: any) { if (n.type === 'formula') found.push(n); for (const c of n.content ?? []) walk(c); })(res.content);
|
||||
expect(found.map((f) => f.attrs.latex)).toEqual(['\\sum_{i=1}^{n} x_{i}', '\\sqrt{a+b}']);
|
||||
expect(found.map((f) => f.attrs.display)).toEqual([false, true]);
|
||||
});
|
||||
|
||||
// Sources harvested from a real Word thesis (the fixtures directory is gitignored, so
|
||||
// they live here as literals). Each one broke the parser once: the n-ary swallowing the
|
||||
// enclosing \right, a literal ^ from typed-in code, a run's trailing space.
|
||||
it('re-serializes messy real-world sources unchanged', () => {
|
||||
const REAL = [
|
||||
'\\sigma ^{2}=\\int_{-pi}^{pi} [\\phi -{E\\left\\{\\phi \\right\\}]}^{2}pdf(\\phi )d\\phi ',
|
||||
'res=1./sqrt(2*L).*sqrt(1-corr.\\^2)./corr',
|
||||
'\\phi _{inf}=\\phi _{ref}+\\phi _{top}+\\phi _{atm}',
|
||||
'C\\left(r\\right)=\\sigma ^{2}-S(r)/2',
|
||||
'\\frac{1}{2*\\left(L-1\\right)}*\\left\\{\\sum_{r=0}^{L-2} \\frac{\\Gamma *\\left(L-\\frac{1}{2}\\right)}{\\Gamma *\\left(L-1\\right)}\\right\\}',
|
||||
];
|
||||
// Editing an imported formula must not rewrite it: the dialog round-trips the source
|
||||
// through the parser on every Apply.
|
||||
for (const src of REAL) expect(astToLatex(parseLatex(src)), src).toBe(src);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { parseLatex, astToLatex } from '../../src/lib/math/latex';
|
||||
import { astToMathml, mathmlDocument, parseMathml } from '../../src/lib/math/mathml';
|
||||
import { astToOmml, ommlDocument, parseOmml } from '../../src/lib/math/omml';
|
||||
|
||||
const parseXml = (xml: string): Element => {
|
||||
const doc = new JSDOM(xml, { contentType: 'text/xml' }).window.document;
|
||||
return doc.documentElement;
|
||||
};
|
||||
|
||||
// LaTeX is the formula node's only stored form, so every other direction has to land
|
||||
// back on it unchanged — otherwise editing an imported formula rewrites it.
|
||||
const roundTrip = (src: string) => astToLatex(parseLatex(src));
|
||||
|
||||
describe('LaTeX parse ↔ serialize', () => {
|
||||
it('is idempotent over the constructs the editor offers', () => {
|
||||
for (const src of [
|
||||
'a+b',
|
||||
'\\frac{1}{2}',
|
||||
'\\frac{a+1}{2\\pi }',
|
||||
'x^{2}',
|
||||
'x_{i}^{2}',
|
||||
'\\phi _{ref}',
|
||||
'\\sqrt{x+y}',
|
||||
'\\sqrt[3]{x}',
|
||||
'\\left(\\frac{1}{2}\\right)',
|
||||
'\\left|\\gamma \\right|',
|
||||
'\\sum_{i=1}^{n} x_{i}',
|
||||
'\\int_{0}^{\\infty } f',
|
||||
'\\bar{x}',
|
||||
'\\vec{v}',
|
||||
'\\sin \\left(x\\right)',
|
||||
'\\begin{pmatrix}a & b \\\\ c & d\\end{pmatrix}',
|
||||
]) {
|
||||
expect(roundTrip(src), src).toBe(src);
|
||||
// A second pass must not drift either.
|
||||
expect(roundTrip(roundTrip(src)), src).toBe(src);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps an unknown macro as readable text instead of dropping it', () => {
|
||||
expect(roundTrip('\\wibble')).toBe('\\text{wibble}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MathML', () => {
|
||||
it('emits the elements the browser needs to typeset', () => {
|
||||
expect(astToMathml(parseLatex('\\frac{1}{2}'))).toBe('<mfrac><mn>1</mn><mn>2</mn></mfrac>');
|
||||
expect(astToMathml(parseLatex('x^2'))).toBe('<msup><mi>x</mi><mn>2</mn></msup>');
|
||||
expect(astToMathml(parseLatex('\\sqrt{x}'))).toBe('<msqrt><mi>x</mi></msqrt>');
|
||||
// A sum's limits sit under/over the sign, an integral's beside it.
|
||||
expect(astToMathml(parseLatex('\\sum_{i}^{n} x'))).toContain('<munderover>');
|
||||
expect(astToMathml(parseLatex('\\int_{0}^{1} x'))).toContain('<msubsup>');
|
||||
// Brackets must be stretchy or they stay glyph-height next to a fraction.
|
||||
expect(astToMathml(parseLatex('\\left(x\\right)'))).toContain('<mo stretchy="true">(</mo>');
|
||||
});
|
||||
|
||||
it('round-trips through the document element', () => {
|
||||
const src = '\\frac{a+1}{2\\pi }';
|
||||
const el = parseXml(mathmlDocument(parseLatex(src), false, src));
|
||||
const got = parseMathml(el);
|
||||
expect(got.latex).toBe(src); // our own annotation is preferred verbatim
|
||||
expect(astToLatex(got.ast)).toBe(src); // and the presentation markup agrees
|
||||
expect(got.display).toBe(false);
|
||||
});
|
||||
|
||||
it('reads foreign MathML that carries no annotation', () => {
|
||||
const el = parseXml(
|
||||
'<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"><mrow>' +
|
||||
'<msub><mi>ϕ</mi><mi mathvariant="normal">ref</mi></msub>' +
|
||||
'<mo>=</mo><mfrac><mn>1</mn><mi>π</mi></mfrac></mrow></math>',
|
||||
);
|
||||
const got = parseMathml(el);
|
||||
expect(got.latex).toBeNull();
|
||||
expect(astToLatex(got.ast)).toBe('\\phi _{\\text{ref}}=\\frac{1}{\\pi }');
|
||||
expect(got.display).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OMML', () => {
|
||||
it('emits Word\'s elements', () => {
|
||||
expect(astToOmml(parseLatex('\\frac{1}{2}'))).toContain('<m:f>');
|
||||
expect(astToOmml(parseLatex('x_i'))).toContain('<m:sSub>');
|
||||
expect(astToOmml(parseLatex('\\sqrt{x}'))).toContain('<m:rad>');
|
||||
expect(astToOmml(parseLatex('\\left[x\\right]'))).toContain('<m:begChr m:val="["/>');
|
||||
expect(astToOmml(parseLatex('\\sum_{i}^{n} x'))).toContain('<m:limLoc m:val="undOvr"/>');
|
||||
// A function name has to be marked upright or Word renders "sin" italic.
|
||||
expect(astToOmml(parseLatex('\\sin x'))).toContain('<m:sty m:val="p"/>');
|
||||
});
|
||||
|
||||
it('round-trips our own output', () => {
|
||||
for (const src of ['\\frac{a}{b}', 'x_{i}^{2}', '\\sqrt[3]{x}', '\\sum_{i=1}^{n} x', '\\left(a+b\\right)']) {
|
||||
const el = parseXml(ommlDocument(parseLatex(src)));
|
||||
expect(astToLatex(parseOmml(el).ast), src).toBe(src);
|
||||
}
|
||||
});
|
||||
|
||||
// Lifted verbatim from a real Word document; markup of this shape was dropped on
|
||||
// import before this feature existed.
|
||||
it('reads Word\'s own markup', () => {
|
||||
const NS = ' xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"' +
|
||||
' xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"';
|
||||
const sub = parseXml(
|
||||
`<m:oMath${NS}><m:sSub><m:sSubPr><m:ctrlPr><w:rPr><w:i/></w:rPr></m:ctrlPr></m:sSubPr>` +
|
||||
'<m:e><m:r><m:t>\u03D5</m:t></m:r></m:e><m:sub><m:r><m:t>ref</m:t></m:r></m:sub></m:sSub></m:oMath>',
|
||||
);
|
||||
expect(astToLatex(parseOmml(sub).ast)).toBe('\\phi _{ref}');
|
||||
|
||||
const frac = parseXml(
|
||||
`<m:oMathPara${NS}><m:oMath><m:f><m:fPr><m:ctrlPr/></m:fPr>` +
|
||||
'<m:num><m:sSup><m:sSupPr><m:ctrlPr/></m:sSupPr><m:e><m:d><m:dPr><m:begChr m:val="|"/><m:endChr m:val="|"/><m:ctrlPr/></m:dPr>' +
|
||||
'<m:e><m:r><m:t>\u03B3</m:t></m:r></m:e></m:d></m:e><m:sup><m:r><m:t>2</m:t></m:r></m:sup></m:sSup></m:num>' +
|
||||
'<m:den><m:r><m:t>2\u03C0</m:t></m:r></m:den></m:f>' +
|
||||
'<m:func><m:funcPr><m:ctrlPr/></m:funcPr><m:fName><m:r><m:rPr><m:sty m:val="p"/></m:rPr><m:t>arcsin</m:t></m:r></m:fName>' +
|
||||
'<m:e><m:d><m:dPr><m:ctrlPr/></m:dPr><m:e><m:r><m:t>\u03B2</m:t></m:r></m:e></m:d></m:e></m:func></m:oMath></m:oMathPara>',
|
||||
);
|
||||
const got = parseOmml(frac);
|
||||
expect(got.display).toBe(true);
|
||||
expect(astToLatex(got.ast)).toBe(
|
||||
'\\frac{\\left|\\gamma \\right|^{2}}{2\\pi }\\arcsin \\left(\\beta \\right)',
|
||||
);
|
||||
});
|
||||
|
||||
it('splits a run that holds a whole expression into typed tokens', () => {
|
||||
const NS = ' xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"';
|
||||
const el = parseXml(`<m:oMath${NS}><m:r><m:t>2*L-1</m:t></m:r></m:oMath>`);
|
||||
expect(astToMathml(parseOmml(el).ast))
|
||||
.toBe('<mn>2</mn><mo>*</mo><mi>L</mi><mo>-</mo><mn>1</mn>');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user