Skip to content

1.5.0 — \nolimits, amsmath display style, and a parser that stops eating your formula

Choose a tag to compare

@clintecker clintecker released this 17 Jul 04:50
· 42 commits to main since this release
4c06aab

Correctness release: \nolimits, amsmath display style in aligned blocks, a parser that no longer eats your formula, and three accessibility bugs.

Minor bump rather than a patch: MathNode gains a case (.noLimitsOperator), which is source-breaking for any client that switches exhaustively over it. Most hosts never do — it's a layout-model type — and if you do, it's a transparent wrapper: delegate to its base.

Added

  • \nolimits works. (#20)
    In TeX it forces an operator's scripts to the side even in display style (\sum\nolimits_{i=1}^n) — the standard way to keep an operator compact inside a display equation. Vinculum recognized the command, consumed it, and threw the modifier away, so a display \sum stacked regardless. toLaTeX() dropped it too, so a round-trip silently re-stacked. \displaylimits remains a no-op, which is correct: it restores the current style's default, which an unmodified operator already uses.

Fixed

  • aligned / align / gather / split cells are display style, per amsmath. (#21)
    Every grid environment's cells were laid out at a hardcoded text style, so a \sum or \int inside an aligned block rendered text-size with side-set limits where real amsmath stacks display-size ones. matrix / array / cases correctly stay text style regardless of surroundings — that half was already right, and the report's "cells should inherit the surrounding style" framing would have broken it.

  • A missing } or ] no longer swallows the rest of the formula. (#8)
    Every argument reader scanned to its closer with an unbounded loop and no end-of-stream fallback, so one missing delimiter let a reader consume the whole remaining stream as a single argument — which is then discarded, since it's a colour/thickness name. \textcolor{red a+b produced colour reda+b and an empty body, and reported isFullySupported == true, so the host's fallback never fired and a+b vanished behind a successful-looking blank render. Braces are now balance-checked up front (escape-aware, so \left\{ x \right\} is untouched); brackets are checked per reader, since [0,1) is ordinary math.

  • iOS: an equation first rendered off-main lost its VoiceOver text forever. (#6)
    The render entry points are nonisolated precisely so hosts can pre-render off the main thread, but the accessibility stamp was gated behind Thread.isMainThread — and cache entries are immutable, so every later host reused the unlabeled bitmap. In the MathText document path the image's own label is the only per-equation carrier, so VoiceOver read "image". The guard's premise was wrong: UIImage's accessibility setters are not MainActor-isolated.

  • VinculumLabel no longer reads raw LaTeX to VoiceOver. (#7)
    On unsupported input with displayErrorInline off, the view drew nothing and reported zero size — yet stayed a live accessibility element labelled with the raw source, so VoiceOver landed on an invisible node spelling out "backslash n o t a command". Accessibility now follows the visible outcome.

  • MathView announces math as static text, not "image". (#26)
    It kept SwiftUI's default .isImage trait while the AppKit/UIKit sibling declared .staticText — the same equation announced two different ways depending on which view a host picked.

  • A truncated size-variant ladder is rejected instead of kept partial. (#36)
    On a short read the parser kept the partial ladder rather than discarding the record like every sibling parser does. Variant records are ordered smallest-first, so a truncated font silently lost a glyph's largest sizes — a \left( that stops growing and falls back to scaling. Only affects already-malformed fonts.

Under the hood

  • The rasterizers are now fuzzed (#12) — the fuzz corpus only ever drove a mock measurer, so the FreeType/Cairo/CoreText interop, the code most likely to hold a memory-safety bug, only saw clean equations. Both renderers gate on isFullySupported, so random garbage never reaches them; the new corpus is valid-by-construction but adversarial, and each test asserts it actually reached the rasterizer.
  • The render/measure caches are stress-tested concurrently (#46) — their comments made specific multi-thread claims that nothing exercised.
  • ARCHITECTURE's "how to add a new command" checklist was wrong and is now measured: five compiler-enforced exhaustive switches, not seven, and glyphTypography has a default: that swallows a missing case silently.

Performance was re-verified in release after two parser changes rather than assumed — parse 23.8 µs, warm cache hit 0.46 µs, headless layout 7.8 µs, ~77% of a cold render is still CoreText. The published numbers hold. See docs/PERFORMANCE.md.

Install

.package(url: "https://github.com/2389-research/Vinculum.git", from: "1.5.0")

Full detail: CHANGELOG.md · Docs & gallery