Fix block LaTeX rendering with SwiftMath display mode - #153
Merged
luca-chen198 merged 2 commits intoSep 20, 2026
Merged
Conversation
manemajef
marked this pull request as ready for review
August 12, 2026 23:41
manemajef
force-pushed
the
fix/block-latex-display-style
branch
from
September 9, 2026 15:27
439cc81 to
b8cb75b
Compare
Contributor
Author
|
I rebased this onto the current I also removed unrelated documentation churn, reduced the SwiftMath regression |
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
force-pushed
the
fix/block-latex-display-style
branch
from
September 9, 2026 15:29
b8cb75b to
6051966
Compare
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
SwiftMathBridgecurrently 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.
What changed
The parser already distinguishes inline and block LaTeX. This PR carries that
information through
LatexRenderer:.inline.displaySwiftMathBridgemaps the mode to SwiftMath'slabelModeresults for the same formula from colliding
The Markdown source remains unchanged; no
\displaystyleinjection or contenttransformation is involved.
Compatibility
Existing
LatexRendererconformers remain source-compatible. The newmode-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 buildswift test --parallel— 473 core tests and the SwiftMath regression test pass