diff --git a/QUICKSTART.md b/QUICKSTART.md
index 7a314f1..8bcffe7 100644
--- a/QUICKSTART.md
+++ b/QUICKSTART.md
@@ -455,6 +455,24 @@ UNTRUSTED input: caps on source length, nesting depth, layout box count, and out
stripping so the output stays within the `svg/g/path/rect` alphabet — a cap trip surfaces
as `OUTPUT_CAP_EXCEEDED`, never an escaped error.
+SVG layout and MathML also preflight materialized work before allocating glyphs or
+markup. Each render allows 100,000 UTF-16 units of text/operator-name payload,
+counted per occurrence after macro expansion (including repeated array separators).
+Spaces and surrogate pairs count conservatively. A bounded AST walk also charges
+ancestor copying; MathML includes XML escaping and table/tag overhead, with a
+2,000,000-unit markup estimate and a separate copying allowance. These are
+conservative work limits, so deeply wrapped or markup-heavy input may be refused
+even when its final string would be smaller. Ordinary accepted output is unchanged;
+`toMathML` reports a typed resource-cap exception, and diagnostic SVG rendering
+reports `OUTPUT_CAP_EXCEEDED`. Each call has a fresh budget.
+
+SVG layout also charges actual primitive construction, including translated and
+colored copies: at most 400,000 glyph placements and 100,000 rules per render.
+This covers generated dashed rules and separators reused across table rows,
+whose geometry can grow without a large text payload. Excessive or non-finite
+rule geometry is refused before its producer loop. These are construction-count
+limits, not a guarantee that every admitted render fits an arbitrary heap size.
+
```java
try {
String svg = com.lattex.api.LatteX.render(userInput);
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index 617b43b..813beba 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -6,6 +6,18 @@ LatteX turns LaTeX math into clean, self-contained **SVG** — pure Java, zero d
## Unreleased
+### Render work is bounded before glyph and MathML allocation
+
+Repeated text macros and array separators now consume a fresh per-render materialized-work
+budget before glyph lists or MathML strings are built. Excessive work produces a typed resource
+cap instead of waiting for the final SVG-size check. The conservative budget also bounds traversal,
+XML escaping and ancestor copying; ordinary accepted output is unchanged.
+
+Layout additionally charges every generated glyph/rule and its copies against
+render-local construction limits. Dashed-rule geometry is checked before its
+producer loop, and repeated table-separator stamps share one cumulative allowance.
+This closes geometry amplification that text and AST counts alone cannot measure.
+
### `\begin{equation}` renders, and says it dropped the number
LatteX already rendered everything *inside* a display wrapper; only the outermost container was
diff --git a/src/main/java/com/lattex/api/LatteX.java b/src/main/java/com/lattex/api/LatteX.java
index 50df5a4..78ddbf3 100644
--- a/src/main/java/com/lattex/api/LatteX.java
+++ b/src/main/java/com/lattex/api/LatteX.java
@@ -1,5 +1,7 @@
package com.lattex.api;
+import com.lattex.layout.RenderWorkBudget;
+
import java.util.List;
import java.util.ArrayList;
@@ -1189,6 +1191,7 @@ public static String toMathML(String latex) {
* helper, so the two can never disagree on the root element.
*/
private static String mathmlDocument(MathNode node) {
+ RenderWorkBudget.checkMathML(node);
return "";
}
diff --git a/src/main/java/com/lattex/layout/Box.java b/src/main/java/com/lattex/layout/Box.java
index 1ab6e35..b45a1f1 100644
--- a/src/main/java/com/lattex/layout/Box.java
+++ b/src/main/java/com/lattex/layout/Box.java
@@ -46,6 +46,10 @@ static Box glue(double width) {
* coordinate space (dy positive = further down).
*/
void drawInto(List outGlyphs, List outRules, double dx, double dy) {
+ // Reject the entire stamp before translating/cloning any polygon arrays.
+ // Canonical constructors charge the actual copies, so these do not reserve.
+ RenderWorkBudget.requireGlyphs(glyphs.size());
+ RenderWorkBudget.requireRules(rules.size());
for (PositionedGlyph g : glyphs) {
// unmappedCodePoint rides through the translate: every author glyph reaches the final
// Layout via this copy, so dropping it here would leave the diagnostic reporting only
diff --git a/src/main/java/com/lattex/layout/LayoutEngine.java b/src/main/java/com/lattex/layout/LayoutEngine.java
index 2a5a565..8f8d697 100644
--- a/src/main/java/com/lattex/layout/LayoutEngine.java
+++ b/src/main/java/com/lattex/layout/LayoutEngine.java
@@ -114,6 +114,11 @@ private LayoutEngine() {
/** Lays out a math tree at the origin; returns positioned glyphs/rules + bbox. */
public static Layout layout(MathNode root, LayoutContext ctx) {
+ RenderWorkBudget.checkLayout(root);
+ return RenderWorkBudget.withLayoutBudget(() -> layoutWithBudget(root, ctx));
+ }
+
+ private static Layout layoutWithBudget(MathNode root, LayoutContext ctx) {
LAYOUT_BOXES.get()[0] = 0; // fresh L10 box budget per top-level layout
Box box = layoutBox(root, ctx);
List glyphs = new ArrayList<>();
@@ -2567,8 +2572,11 @@ private static void addVerticalRules(List rules, double regionStart, doubl
if (count <= 0) {
return;
}
+ RenderWorkBudget.requireRules(count);
double block = count * thick + (count - 1) * thick; // bars + inter-bar gaps
double x0 = regionStart + (gap - block) / 2.0;
+ requireFiniteRuleGeometry(regionStart, gap, thick, topY, spanH, block, x0,
+ x0 + (count - 1) * 2.0 * thick);
for (int k = 0; k < count; k++) {
rules.add(new Rule(x0 + k * 2.0 * thick, topY, thick, spanH));
}
@@ -2581,18 +2589,46 @@ private static void addVerticalRules(List rules, double regionStart, doubl
*/
private static void addHorizontalRule(List rules, double x, double y, double width,
double thick, boolean dashed, double em) {
+ requireFiniteRuleGeometry(x, y, width, thick);
if (!dashed) {
rules.add(new Rule(x, y, width, thick));
return;
}
double dash = 0.25 * em;
double gap = 0.18 * em;
+ double step = dash + gap;
double cursor = x;
double end = x + width;
+ requireFiniteRuleGeometry(x, y, width, thick, em, dash, step, end);
+ if (width <= 0) {
+ return;
+ }
+ // A finite bound on the requested dashes prevents an enormous partial list.
+ // One extra slot covers endpoint/subtraction rounding; the loop below keeps
+ // the original geometry for accepted input. Constructors still charge once.
+ double requested = Math.ceil(width / step) + 1;
+ if (!(dash > 0) || !(step > 0) || !(x + step > x)
+ || !(end + step > end) || !Double.isFinite(requested)
+ || requested > RenderWorkBudget.MAX_RULE_ALLOCATIONS) {
+ throw MathSyntaxException.capExceeded("output work budget exceeded: dashed rule geometry");
+ }
+ RenderWorkBudget.requireRules((long) requested);
while (cursor < end) {
+ double next = cursor + step;
+ if (!(next > cursor) || !Double.isFinite(next)) {
+ throw MathSyntaxException.capExceeded("output work budget exceeded: dashed rule progress");
+ }
double w = Math.min(dash, end - cursor);
rules.add(new Rule(cursor, y, w, thick));
- cursor += dash + gap;
+ cursor = next;
+ }
+ }
+
+ private static void requireFiniteRuleGeometry(double... values) {
+ for (double value : values) {
+ if (!Double.isFinite(value)) {
+ throw MathSyntaxException.capExceeded("output work budget exceeded: nonfinite rule geometry");
+ }
}
}
diff --git a/src/main/java/com/lattex/layout/PositionedGlyph.java b/src/main/java/com/lattex/layout/PositionedGlyph.java
index 1f55ed1..a17a745 100644
--- a/src/main/java/com/lattex/layout/PositionedGlyph.java
+++ b/src/main/java/com/lattex/layout/PositionedGlyph.java
@@ -44,6 +44,10 @@ public record PositionedGlyph(int glyphId, double originX, double baselineY, dou
Color color, int sourceCodePoint, int fenceDepth,
int unmappedCodePoint) {
+ public PositionedGlyph {
+ RenderWorkBudget.chargeGlyph();
+ }
+
/** A glyph with no source token — a construction glyph (not an author atom). */
public static final int NO_SOURCE = -1;
diff --git a/src/main/java/com/lattex/layout/RenderWorkBudget.java b/src/main/java/com/lattex/layout/RenderWorkBudget.java
new file mode 100644
index 0000000..e8e3e32
--- /dev/null
+++ b/src/main/java/com/lattex/layout/RenderWorkBudget.java
@@ -0,0 +1,220 @@
+package com.lattex.layout;
+
+import com.lattex.parse.MathNode;
+import com.lattex.parse.MathSyntaxException;
+import java.util.function.Supplier;
+
+/**
+ * Internal render preflight shared by SVG layout and MathML serialization. Public only
+ * so the API implementation can call into this unexported package; no mutable state
+ * survives a call. Layout additionally binds a cumulative allocation allowance for
+ * every primitive producer and copy, including geometry without text payload.
+ *
+ *
Tokens and boxes do not measure text: one TextRun token can request thousands of
+ * glyphs, and a shared array separator can be stamped on every row. Charge UTF-16
+ * units PER OCCURRENCE, including spaces and supplementary-character surrogate pairs
+ * (deliberately conservative), without expanding text or allocating output. The
+ * 100k text allowance is independent of the unchanged parser/token/box limits.
+ */
+public final class RenderWorkBudget {
+ private static final long MAX_TEXT_UNITS = 100_000;
+ private static final long MAX_VISITS = 100_000;
+ private static final int MAX_DEPTH = 512;
+ private static final long MAX_LAYOUT_COPY_UNITS = 2_000_000;
+ private static final long MAX_MATHML_UNITS = 2_000_000;
+ private static final long MAX_MATHML_COPY_UNITS = 16_000_000;
+
+ // Cumulative construction events, not the final retained primitive count. A flat
+ // 96k-atom layout creates 288k glyph placements (producer, row, final flattening);
+ // even one color-copy layer fits under 400k. Rules have their own 100k allowance
+ // because a tiny AST can generate or repeatedly stamp very large rule lists.
+ // These bound work and object counts, not bytes or a promise about arbitrary heaps.
+ static final long MAX_GLYPH_ALLOCATIONS = 400_000;
+ static final long MAX_RULE_ALLOCATIONS = 100_000;
+ private static final ScopedValue ALLOCATIONS = ScopedValue.newInstance();
+
+ private static final class Allocations {
+ long glyphs;
+ long rules;
+ }
+
+ /** Synchronous render-local binding; nested calls and failures restore the caller. */
+ static T withLayoutBudget(Supplier action) {
+ return ScopedValue.where(ALLOCATIONS, new Allocations()).call(action::get);
+ }
+
+ static void chargeGlyph() {
+ if (ALLOCATIONS.isBound()) {
+ Allocations current = ALLOCATIONS.get();
+ current.glyphs = charge(current.glyphs, 1, MAX_GLYPH_ALLOCATIONS,
+ "glyph allocations");
+ }
+ }
+
+ static void chargeRule() {
+ if (ALLOCATIONS.isBound()) {
+ Allocations current = ALLOCATIONS.get();
+ current.rules = charge(current.rules, 1, MAX_RULE_ALLOCATIONS,
+ "rule allocations");
+ }
+ }
+
+ // Capacity prechecks reserve nothing: canonical constructors charge each event
+ // exactly once. Used before producer loops and polygon clone/translation work.
+ static void requireGlyphs(long count) {
+ if (ALLOCATIONS.isBound()) {
+ charge(ALLOCATIONS.get().glyphs, count, MAX_GLYPH_ALLOCATIONS,
+ "glyph allocations");
+ }
+ }
+
+ static void requireRules(long count) {
+ if (ALLOCATIONS.isBound()) {
+ charge(ALLOCATIONS.get().rules, count, MAX_RULE_ALLOCATIONS,
+ "rule allocations");
+ }
+ }
+
+ private final boolean mathml;
+ private long textUnits;
+ private long visits;
+ private long outputUnits;
+ private long copyUnits;
+
+ private RenderWorkBudget(boolean mathml) {
+ this.mathml = mathml;
+ }
+
+ /** Refuses excessive materialized text/ancestor copying before any layout boxes. */
+ public static void checkLayout(MathNode root) {
+ new RenderWorkBudget(false).visit(root, 1);
+ }
+
+ /** Refuses excessive materialized markup/ancestor copying before serialization. */
+ public static void checkMathML(MathNode root) {
+ new RenderWorkBudget(true).visit(root, 1);
+ }
+
+ private static long charge(long used, long amount, long limit, String kind) {
+ // Subtraction avoids overflow even for a future caller with a much larger bound.
+ if (amount < 0 || amount > limit - used) {
+ throw MathSyntaxException.capExceeded("output work budget exceeded: " + kind);
+ }
+ return used + amount;
+ }
+
+ private void units(long payload, int depth) {
+ textUnits = charge(textUnits, payload, MAX_TEXT_UNITS, "materialized text");
+ if (mathml) {
+ // xmlEscape expands a UTF-16 unit by at most 6 ("). 256 units per
+ // node/cell/row cover current fixed tags, attributes, numeric widths and
+ // fences, plus the document root. Table columnalign is charged separately.
+ long markup = 256 + 6 * payload; // payload <= String.length(): no overflow
+ outputUnits = charge(outputUnits, markup, MAX_MATHML_UNITS, "MathML markup");
+ // Each ancestor copies its descendants into a concatenation or builder.
+ // Four copies per level also allow builder growth/toString and temporary
+ // helper wrappers (stack braces, negations, table separators). This is a
+ // conservative work policy, not an exact byte-size prediction.
+ copyUnits = charge(copyUnits, markup * 4 * (depth + 1),
+ MAX_MATHML_COPY_UNITS, "MathML copying");
+ } else {
+ // Box composition copies glyph placements through ancestors. Include one
+ // unit for each non-text node and four placements/copies per level; text
+ // counts even in phantoms, whose boxes are allocated before ink is hidden.
+ copyUnits = charge(copyUnits, (payload + 1) * 4 * (depth + 1),
+ MAX_LAYOUT_COPY_UNITS, "layout copying");
+ }
+ }
+
+ private void visit(MathNode node, int depth) {
+ if (node == null) {
+ return;
+ }
+ if (depth > MAX_DEPTH) {
+ throw MathSyntaxException.capExceeded("output work budget exceeded: nesting too deep");
+ }
+ if (++visits > MAX_VISITS) {
+ // Retain the existing direct-layout box-cap diagnostic for wide trees.
+ throw MathSyntaxException.capExceeded("layout box budget exceeded during output preflight");
+ }
+ long payload = switch (node) {
+ case MathNode.TextRun t -> t.text().length();
+ case MathNode.OperatorName o -> o.name().length();
+ default -> 0;
+ };
+ units(payload, depth);
+ int child = depth + 1;
+ // Exhaustiveness is intentional: a new text-bearing node must choose its
+ // materialized traversal here, rather than silently bypassing the budget.
+ switch (node) {
+ case MathNode.Atom _, MathNode.Spacing _, MathNode.MiddleDelim _,
+ MathNode.SizedDelim _, MathNode.TextRun _, MathNode.OperatorName _ -> { }
+ case MathNode.MathList n -> n.items().forEach(item -> visit(item, child));
+ case MathNode.SupSub n -> {
+ visit(n.base(), child); visit(n.sup(), child); visit(n.sub(), child);
+ }
+ case MathNode.Fraction n -> {
+ visit(n.numerator(), child); visit(n.denominator(), child);
+ }
+ case MathNode.Radical n -> {
+ visit(n.radicand(), child); visit(n.index(), child);
+ }
+ case MathNode.BigOperator n -> {
+ visit(n.op(), child); visit(n.lower(), child); visit(n.upper(), child);
+ }
+ case MathNode.Fenced n -> visit(n.body(), child);
+ case MathNode.Colored n -> visit(n.body(), child);
+ case MathNode.ClassOverride n -> visit(n.body(), child);
+ case MathNode.Boxed n -> visit(n.body(), child);
+ case MathNode.Negated n -> visit(n.body(), child);
+ case MathNode.Cancel n -> { visit(n.body(), child); visit(n.to(), child); }
+ case MathNode.Tagged n -> { visit(n.body(), child); visit(n.label(), child); }
+ case MathNode.Accent n -> visit(n.base(), child);
+ case MathNode.Phantom n -> visit(n.content(), child);
+ case MathNode.Lap n -> visit(n.body(), child);
+ case MathNode.StyledMath n -> visit(n.body(), child);
+ case MathNode.StyleSwitch n -> visit(n.body(), child);
+ case MathNode.Stack n -> {
+ visit(n.base(), child); visit(n.above(), child); visit(n.below(), child);
+ }
+ case MathNode.XArrow n -> { visit(n.above(), child); visit(n.below(), child); }
+ case MathNode.CdArrow n -> { visit(n.labelA(), child); visit(n.labelB(), child); }
+ case MathNode.Matrix n -> matrix(n, child);
+ case MathNode.BorderMatrix n -> {
+ visit(n.corner(), child);
+ n.columnLabels().forEach(label -> visit(label, child));
+ for (int r = 0; r < n.rowCount(); r++) {
+ units(0, child); // row markup
+ visit(n.rowLabels().get(r), child);
+ for (MathNode cell : n.body().get(r)) {
+ units(0, child); // cell markup/fences
+ visit(cell, child);
+ }
+ }
+ }
+ }
+ }
+
+ private void matrix(MathNode.Matrix matrix, int depth) {
+ for (int c = 0; c < matrix.columnCount(); c++) {
+ units(0, depth); // columnalign attribute, including empty tables
+ }
+ for (int r = 0; r < matrix.rows().size(); r++) {
+ units(0, depth); // row and optional label-cell markup
+ visit(matrix.rowTags().get(r), depth);
+ for (MathNode cell : matrix.rows().get(r)) {
+ units(0, depth); // cell/span/alignment attributes
+ visit(cell, depth);
+ }
+ // Do not deduplicate shared separator ASTs. Layout creates the boundary
+ // box even when a span hides it; MathML emits visible material per row.
+ // Counting every boundary per row safely covers both, conservatively
+ // charging hidden material too, without invoking spanAt's linear scan.
+ for (MathNode.ColumnSeparator separator : matrix.columnSeparators()) {
+ if (separator != null) {
+ visit(separator.material(), depth);
+ }
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/lattex/layout/Rule.java b/src/main/java/com/lattex/layout/Rule.java
index 8d8a5f0..cf9bb37 100644
--- a/src/main/java/com/lattex/layout/Rule.java
+++ b/src/main/java/com/lattex/layout/Rule.java
@@ -33,6 +33,7 @@ public record Rule(double x, double y, double width, double height, Color color,
double[] polygon) {
public Rule {
+ RenderWorkBudget.chargeRule();
// Defensive copy so a caller's array can't mutate the rule out from under us.
polygon = polygon == null ? null : polygon.clone();
}
diff --git a/src/test/java/com/lattex/api/MathMLTest.java b/src/test/java/com/lattex/api/MathMLTest.java
index 698e27e..913dca9 100644
--- a/src/test/java/com/lattex/api/MathMLTest.java
+++ b/src/test/java/com/lattex/api/MathMLTest.java
@@ -24,6 +24,43 @@
*/
class MathMLTest {
+ @Test
+ void repeatedTextPayloadIsAResourceCapAndNextCallRemainsHealthy() {
+ MathSyntaxException cap = assertThrows(MathSyntaxException.class,
+ () -> LatteX.toMathML(OutputWorkBudgetProbe.witness("text")));
+ assertTrue(cap.isCapExceeded());
+ assertTrue(cap.getMessage().contains("output work budget"), cap.getMessage());
+ assertWellFormed(LatteX.toMathML("\\text{healthy} + x^2"));
+ }
+
+ @Test
+ void escapedTextAndRecursiveConcatenationAreBudgetedBeforeMaterialization() {
+ // Under the text/source limits, and less than 2M final output characters:
+ // escaping plus repeatedly copying a long descendant is its own work axis.
+ String source = "\\boxed{".repeat(20) + "\\text{" + "\"".repeat(90_000) + "}"
+ + "}".repeat(20);
+ MathSyntaxException cap = assertThrows(MathSyntaxException.class,
+ () -> LatteX.toMathML(source));
+ assertTrue(cap.isCapExceeded());
+ assertTrue(cap.getMessage().contains("MathML copying"), cap.getMessage());
+ String healthy = LatteX.toMathML("\\boxed{\\text{\"<&>}} ");
+ assertTrue(healthy.contains(""<&>"));
+ assertWellFormed(healthy);
+ }
+
+ @Test
+ void arraySeparatorPayloadIsChargedOnEveryRow() {
+ String source = "\\begin{array}{c@{\\text{" + "a".repeat(5_000) + "}}}"
+ + "x\\\\".repeat(24) + "x\\end{array}";
+ MathSyntaxException cap = assertThrows(MathSyntaxException.class,
+ () -> LatteX.toMathML(source));
+ assertTrue(cap.isCapExceeded());
+ assertTrue(cap.getMessage().contains("output work budget"), cap.getMessage());
+ assertTrue(LatteX.renderWithDiagnostics(source).diagnostics().outcome()
+ == Outcome.OUTPUT_CAP_EXCEEDED);
+ assertWellFormed(LatteX.toMathML("\\begin{array}{c@{\\text{ok}}}x\\\\x\\end{array}"));
+ }
+
/// Overload carrying the corpus coordinate, so a sweep failure names the ROW that produced it
/// rather than only the emitted XML (plan bc50471c).
private static void assertWellFormed(String xml, String where) {
diff --git a/src/test/java/com/lattex/api/OutputWorkBudgetProbe.java b/src/test/java/com/lattex/api/OutputWorkBudgetProbe.java
new file mode 100644
index 0000000..826d396
--- /dev/null
+++ b/src/test/java/com/lattex/api/OutputWorkBudgetProbe.java
@@ -0,0 +1,73 @@
+package com.lattex.api;
+
+import com.lattex.parse.MathSyntaxException;
+
+/** Standalone small-heap public-API probe; an escaping Error must make the fork fail. */
+public final class OutputWorkBudgetProbe {
+ private OutputWorkBudgetProbe() { }
+
+ static String witness(String command) {
+ if (command.equals("dashed")) {
+ return "\\begin{array}{c}\\hdashline\\hspace{1000000000em}\\end{array}";
+ }
+ if (command.equals("separator")) {
+ String inner = "\\begin{array}{" + "|".repeat(10_000) + "c}x\\end{array}";
+ return "\\begin{array}{c@{" + inner + "}}"
+ + "x\\\\".repeat(999) + "x\\end{array}";
+ }
+ // ~9.5k source chars, 900 macro calls, ~900 text tokens/boxes, but 4.5M
+ // materialized characters. Parser splice volume is intentionally well below
+ // its limit: a TextRun's payload, rather than token count, is the multiplier.
+ return "\\newcommand{\\zzq}{\\" + command + "{" + "a".repeat(5_000) + "}}"
+ + "\\zzq ".repeat(900);
+ }
+
+ public static void main(String[] args) {
+ String mode = args[0];
+ String hostile = witness(args[1]);
+ if (hostile.length() >= 100_000) {
+ throw new AssertionError("witness must remain under the source cap");
+ }
+ // Repeat the refusal on the SAME thread, interleaved with a healthy render:
+ // no leaked per-render state may poison the next invocation.
+ for (int i = 0; i < 2; i++) {
+ try {
+ if (mode.equals("svg")) {
+ LatteX.render(hostile);
+ } else if (mode.equals("mathml")) {
+ LatteX.toMathML(hostile);
+ } else {
+ throw new AssertionError("unknown probe mode: " + mode);
+ }
+ throw new AssertionError("hostile output was accepted");
+ } catch (MathSyntaxException cap) {
+ if (!cap.isCapExceeded() || !cap.getMessage().contains("output work budget")) {
+ throw new AssertionError("expected allocation-time output work refusal", cap);
+ }
+ if ((args[1].equals("text") || args[1].equals("textbf"))
+ && !cap.getMessage().contains("materialized text")) {
+ throw new AssertionError("text amplification must fail in preflight", cap);
+ }
+ if (args[1].equals("dashed") && !cap.getMessage().contains("dashed rule geometry")) {
+ throw new AssertionError("dashes must refuse before their producer loop", cap);
+ }
+ if (args[1].equals("separator") && !cap.getMessage().contains("rule allocations")) {
+ throw new AssertionError("separator copies must hit cumulative rule accounting", cap);
+ }
+ }
+ if (!LatteX.render("\\frac{x^2}{2}+\\text{healthy}").startsWith("