Skip to content

Fix block LaTeX rendering with SwiftMath display mode - #153

Merged
luca-chen198 merged 2 commits into
nodes-app:mainfrom
manemajef:fix/block-latex-display-style
Sep 20, 2026
Merged

luca-chen198 merged 2 commits into
nodes-app:mainfrom
manemajef:fix/block-latex-display-style

Conversation

@manemajef

@manemajef manemajef commented Aug 12, 2026 •

Copy link
Copy Markdown
Contributor

Summary

SwiftMathBridge currently renders both inline $ … $ and block $$ … $$
formulas using SwiftMath's text mode.

That gives block formulas inline typography: limits sit beside large operators,
fractions remain small, and operators do not scale as expected.

Comparison of inline and display LaTeX rendering

What changed

The parser already distinguishes inline and block LaTeX. This PR carries that
information through LatexRenderer:

  • inline and table formulas use .inline
  • block formulas use .display
  • SwiftMathBridge maps the mode to SwiftMath's labelMode
  • the render mode is part of the cache key, preventing inline and display
    results for the same formula from colliding

The Markdown source remains unchanged; no \displaystyle injection or content
transformation is involved.

Compatibility

Existing LatexRenderer conformers remain source-compatible. The new
mode-aware overload defaults to the original renderer method, so renderers that
do not distinguish the two modes continue working unchanged.

Direct calls to render(latex:fontSize:theme:) retain inline behavior.

Validation

  • swift build
  • swift test --parallel — 473 core tests and the SwiftMath regression test pass

@manemajef
manemajef marked this pull request as ready for review August 12, 2026 23:41
@manemajef
manemajef force-pushed the fix/block-latex-display-style branch from 439cc81 to b8cb75b Compare September 9, 2026 15:27
@manemajef manemajef changed the title Typeset block $$…$$ math in display style, not text style Fix block LaTeX rendering with SwiftMath display mode Sep 9, 2026
@manemajef

manemajef commented Sep 9, 2026 •

Copy link
Copy Markdown
Contributor Author

I rebased this onto the current main and tightened the patch after another
compatibility review. The original LatexRenderer method remains required, and
the new mode-aware overload has a one-way fallback to it, so existing conformers
continue to compile and behave as before.

I also removed unrelated documentation churn, reduced the SwiftMath regression
tests, added the required changelog entry, and reran swift build plus the full
test suite successfully. @luca-chen198, when you have a chance, could you take
another look?

The parser already distinguishes block and inline formulas, but that information was discarded before rendering. Carry the mode through the renderer boundary so block formulas use display typesetting, while legacy renderers continue to receive the existing call.

Include the mode in SwiftMath cache keys so an inline result cannot be reused for the same formula in display mode.
@manemajef
manemajef force-pushed the fix/block-latex-display-style branch from b8cb75b to 6051966 Compare September 9, 2026 15:29
Resolves the CHANGELOG conflict with nodes-app#152: both entries are kept, the
LaTeX one above the table one. The sources merged cleanly — nodes-app#152 touched
`MarkdownStyler+Tables.swift` for table width, this branch for the render
mode of `.inlineLatex` cells, in different functions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@luca-chen198
luca-chen198 merged commit dbebf06 into nodes-app:main Sep 20, 2026
1 check passed
wildthink pushed a commit to wildthink/swift-markdown-engine that referenced this pull request Sep 24, 2026
…nt .image sizing

The rest-of-the-call run's kern was computed for the whole run's width but
applied per-character by AppKit, over-negating an n-character run by
(n-1) widths — the same poisoning of usageBoundsForTextContainer the
table work hit and fixed. Reuse that fix's hiddenRunKern helper.

Also document that .image, unlike .symbol/.text, is drawn at its own
size rather than fit to the inherited font, so an oversized image is the
supplying directive's responsibility — existing tests (DirectiveGlyphTests)
already rely on a directive controlling its own image size (@Swatch), so
clamping or growing the line here would break an intentional feature
rather than a bug.

Also merges upstream/main (0.13.0 release + nodes-app#146/nodes-app#152/nodes-app#153/nodes-app#160-nodes-app#162) so
the changelog entry lands under a fresh [Unreleased] section instead of
inside the already-released [0.13.0].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
luca-chen198 pushed a commit that referenced this pull request Sep 29, 2026
* Draw a self-contained directive as a glyph

Phase 3 of the directive seam. A self-contained call has no body, so until now
it parsed, claimed its span, and then rendered as its own literal source —
`@pagebreak` looked exactly like the text `@pagebreak`. This gives it
something to draw.

A directive returns a `DirectivePresentation` — an SF Symbol, replacement
text, or an NSImage — and the styler collapses the source behind it. The
mechanism is the one inline LaTeX already uses, not a new one: the characters
stay in the storage, the first carries the image plus enough kern to occupy
its width, the rest collapse to zero width via clear colour and the shrunk
marker font. `MarkdownTextLayoutFragment` draws it.

That is what keeps "markers shrink, they don't disappear" true here. Selection,
find, copy, and undo all still see the real characters, and the caret entering
the call reveals the source muted — the same flip every other construct does.

Failure is visible rather than silent: `.literal`, or a symbol name the system
doesn't know, leaves the source on screen instead of collapsing it to a gap the
user can neither see nor fix.

Rasterised glyphs are cached in an NSCache keyed by everything that determines
the pixels — presentation re-runs for every visible directive on every
keystroke, and rasterising text each time is the one part of this path
expensive enough to matter.

The parser is untouched: this is styling only, and the 4000-input corpus
fingerprint is unchanged. `DirectiveScanner`'s diff is comment-only — it
already emitted the geometry this needs, and its comments pointed forward to
this change.

DirectiveStylingTests asserted that a self-contained call renders as literal
text with nothing collapsing it. That was Phase 1 stating its own limit, and
it is exactly what this changes, so it becomes a collapse/reveal pair rather
than being deleted.

`FontDirective` and `ColorDirective` are both containers and draw no glyph, so
the engine still ships no self-contained directive. Demo/ gains `@icon`,
`@flag`, `@emoji`, and `@pagebreak` as embedder-side examples — curated data
and print semantics are app concerns. `@flag` computes its glyph from
regional-indicator scalars and carries no dataset.

463 tests pass, demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(directives): per-character kern on the collapsed rest-run, document .image sizing

The rest-of-the-call run's kern was computed for the whole run's width but
applied per-character by AppKit, over-negating an n-character run by
(n-1) widths — the same poisoning of usageBoundsForTextContainer the
table work hit and fixed. Reuse that fix's hiddenRunKern helper.

Also document that .image, unlike .symbol/.text, is drawn at its own
size rather than fit to the inherited font, so an oversized image is the
supplying directive's responsibility — existing tests (DirectiveGlyphTests)
already rely on a directive controlling its own image size (@Swatch), so
clamping or growing the line here would break an intentional feature
rather than a bug.

Also merges upstream/main (0.13.0 release + #146/#152/#153/#160-#162) so
the changelog entry lands under a fresh [Unreleased] section instead of
inside the already-released [0.13.0].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Jason Jobe <box2019@jasonjobe.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
luca-chen198 pushed a commit that referenced this pull request Oct 4, 2026
* Draw a self-contained directive as a glyph

Phase 3 of the directive seam. A self-contained call has no body, so until now
it parsed, claimed its span, and then rendered as its own literal source —
`@pagebreak` looked exactly like the text `@pagebreak`. This gives it
something to draw.

A directive returns a `DirectivePresentation` — an SF Symbol, replacement
text, or an NSImage — and the styler collapses the source behind it. The
mechanism is the one inline LaTeX already uses, not a new one: the characters
stay in the storage, the first carries the image plus enough kern to occupy
its width, the rest collapse to zero width via clear colour and the shrunk
marker font. `MarkdownTextLayoutFragment` draws it.

That is what keeps "markers shrink, they don't disappear" true here. Selection,
find, copy, and undo all still see the real characters, and the caret entering
the call reveals the source muted — the same flip every other construct does.

Failure is visible rather than silent: `.literal`, or a symbol name the system
doesn't know, leaves the source on screen instead of collapsing it to a gap the
user can neither see nor fix.

Rasterised glyphs are cached in an NSCache keyed by everything that determines
the pixels — presentation re-runs for every visible directive on every
keystroke, and rasterising text each time is the one part of this path
expensive enough to matter.

The parser is untouched: this is styling only, and the 4000-input corpus
fingerprint is unchanged. `DirectiveScanner`'s diff is comment-only — it
already emitted the geometry this needs, and its comments pointed forward to
this change.

DirectiveStylingTests asserted that a self-contained call renders as literal
text with nothing collapsing it. That was Phase 1 stating its own limit, and
it is exactly what this changes, so it becomes a collapse/reveal pair rather
than being deleted.

`FontDirective` and `ColorDirective` are both containers and draw no glyph, so
the engine still ships no self-contained directive. Demo/ gains `@icon`,
`@flag`, `@emoji`, and `@pagebreak` as embedder-side examples — curated data
and print semantics are app concerns. `@flag` computes its glyph from
regional-indicator scalars and carries no dataset.

463 tests pass, demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(directives): per-character kern on the collapsed rest-run, document .image sizing

The rest-of-the-call run's kern was computed for the whole run's width but
applied per-character by AppKit, over-negating an n-character run by
(n-1) widths — the same poisoning of usageBoundsForTextContainer the
table work hit and fixed. Reuse that fix's hiddenRunKern helper.

Also document that .image, unlike .symbol/.text, is drawn at its own
size rather than fit to the inherited font, so an oversized image is the
supplying directive's responsibility — existing tests (DirectiveGlyphTests)
already rely on a directive controlling its own image size (@Swatch), so
clamping or growing the line here would break an intentional feature
rather than a bug.

Also merges upstream/main (0.13.0 release + #146/#152/#153/#160-#162) so
the changelog entry lands under a fresh [Unreleased] section instead of
inside the already-released [0.13.0].

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Complete directive names and argument values

Phase 4, the last of the seam. Autocomplete for directive NAMES (`@fo`) and
for their ARGUMENT VALUES (`@icon(sta`, `@flag(jap`), riding the seam the
[[wiki-link]] picker already established: the engine detects the trigger,
ranks the candidates, reports the anchor rect, and routes up/down/return/
escape. The embedder draws the list — no picker UI ships in the engine.

Autocomplete cannot read the AST. Mid-typing, `@ico` and `@icon(sta` are
exactly what the parser REJECTS — no body, no closing paren — which is correct
for styling and useless for completion. So `DirectiveCompletionScanner` is a
separate, forgiving backwards scan over the current line, bounded to 256
characters per caret move. It reuses the parser's boundary rule, so it can
never offer a directive the parser would then refuse.

The engine owns the candidates because it owns the registry. Values come from
`MarkdownDirective.valueCompletions(for:prefix:)`, whose default already
answers anything the declared schema can — closed keyword sets and booleans —
so a directive implements it only when its domain is dynamic or too large to
declare. That is what makes a `@flag`-style command clean rather than special:
the schema was already there. A newly registered directive appears in the
picker with no embedder change.

The commit path is deliberately NOT `applyInlineReplacement` — that runs the
wiki-link storage/display transform and stamps `.wikiLinkID`, neither of which
means anything here.

Pickers stay shut where they must: inside code spans and fenced blocks, on a
selection rather than a caret, when not typing, mid-IME composition, in raw
source mode, and after an email address.

Changes to existing files are 47 lines across 3; detection, commit, scanning,
and the completion types live in their own files. Demo gains a caret-anchored
picker (~60 lines) serving both names and values through one context type.

488 tests pass, demo builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(directives): caret-bounded replacement range, Enter capture, gate coverage

Three real issues from review, plus smaller ones flagged alongside:

1. Replacement range only covered marker-to-caret (name) or valueStart-to-
   caret (argument value), so a pick made mid-token left the characters
   after the caret behind: `@fo|nt` picking "font" produced
   `@font(size: ){}nt`. Both ranges now extend to the actual end of the
   token, scanning forward past the caret the same way the existing
   backward scan already does.

2. Enter was captured as "confirm" outside anything a user would
   recognize as mid-completion: a bare `@` before ordinary prose opened
   a picker with every registered directive (this branch's own test
   asserted that), and a fully-typed self-contained call like
   `@pagebreak` kept its context open once finished. Both now return no
   context — a bare marker until at least one character is typed, and an
   exact match to a directive needing no further arguments. Updated
   DirectiveCompletionTests accordingly (the old "bare marker offers
   every directive" assertion was itself testing the bug).

3. Added coordinator-level tests for every gate the PR description
   claimed was tested: code span, fenced block, non-empty selection, not
   typing, mid-IME composition, raw source mode. Only the email-address
   boundary rule (a scanner-level concern) actually had one before.

Smaller:
- Name candidates now come from the registry's winning entries per
  marker, not the raw configured directive list, so a directive dropped
  for an empty name or a name that lost "first registration wins" can no
  longer be offered by the picker.
- The snippet's `|` caret marker is now measured in UTF-16 units instead
  of Characters, since it's added onto an NSRange location — a snippet
  carrying a non-BMP character before the marker previously landed the
  caret mid-surrogate.
- A wiki-link/image-embed context claiming the caret this pass now
  suppresses the directive picker instead of letting both go live at
  once with no way for an embedder's key handler to tell them apart.

Full suite (561 tests) and the Demo target build both clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(directives): stop name picks duplicating existing calls, tighten the forward name scan, gate on a wired handler, unwrap quoted values

Review round 2 on #159, trial-merged against current main:

- A name pick on `@fo|nt(size: 18){x}` was appending its full snippet
  after the existing call instead of just replacing the name, duplicating
  the arguments and body. When a `(` or `{` already follows the name,
  insert just the marker and name.
- The forward name scan accepted `.` unconditionally, unlike the parser
  (which only continues a name across a dot when an identifier-start
  character follows). This both absorbed a trailing sentence period into
  the replacement range and, for an exact match, kept the picker open
  since the exact-match check only fires when the scan ends at the caret.
- `updateDirectiveCompletion` set `isDirectiveCompletionActive` whenever a
  context existed, even with no `onDirectiveCompletion` handler wired up —
  an embedder that registered directives but never adopted completion
  could have its wiki-link keys silently swallowed. Gate on the handler
  being set.
- Smaller: a `.container` directive with only optional parameters
  (`@font`) closed the picker on an exact match even though its body is
  still required; a quoted value (`@glyph("sta|r")`) filtered against the
  quote character itself and matched nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Jason Jobe <box2019@jasonjobe.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants