Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/main/java/com/lattex/api/LatteX.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.lattex.api;

import com.lattex.layout.RenderWorkBudget;

import java.util.List;

import java.util.ArrayList;
Expand Down Expand Up @@ -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 "<math xmlns=\"http://www.w3.org/1998/Math/MathML\">" + toMathML(node) + "</math>";
}

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/lattex/layout/Box.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ static Box glue(double width) {
* coordinate space (dy positive = further down).
*/
void drawInto(List<PositionedGlyph> outGlyphs, List<Rule> 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
Expand Down
38 changes: 37 additions & 1 deletion src/main/java/com/lattex/layout/LayoutEngine.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<PositionedGlyph> glyphs = new ArrayList<>();
Expand Down Expand Up @@ -2567,8 +2572,11 @@ private static void addVerticalRules(List<Rule> 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));
}
Expand All @@ -2581,18 +2589,46 @@ private static void addVerticalRules(List<Rule> rules, double regionStart, doubl
*/
private static void addHorizontalRule(List<Rule> 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");
}
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/main/java/com/lattex/layout/PositionedGlyph.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
220 changes: 220 additions & 0 deletions src/main/java/com/lattex/layout/RenderWorkBudget.java
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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> 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> T withLayoutBudget(Supplier<T> 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 (&quot;). 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);
}
}
}
}
}
1 change: 1 addition & 0 deletions src/main/java/com/lattex/layout/Rule.java
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Loading
Loading