Skip to content

feat(react-native): baseline alignment + getTexMetrics()#139

Merged
erweixin merged 3 commits into
erweixin:mainfrom
istarkov:feat/baseline-align
Jul 15, 2026
Merged

feat(react-native): baseline alignment + getTexMetrics()#139
erweixin merged 3 commits into
erweixin:mainfrom
istarkov:feat/baseline-align

Conversation

@istarkov

Copy link
Copy Markdown
Contributor

closes #130

feat(react-native): baseline alignment + getTexMetrics()

Inline math should sit on the text baseline like a glyph. This PR makes
alignSelf: 'baseline' work in both host contexts RN offers, and exposes the
formula's ink metrics for hosts that do their own text layout.

1. Flex row — alignItems: 'baseline'

<View style={{ flexDirection: 'row', alignItems: 'baseline' }}>
  <Text>f(x) =</Text>
  <RaTeXView latex={'\\frac{a}{b}'} fontSize={16} displayMode={false} />
</View>

Works perfectly — Yoga lines the formula's ink baseline up with the text
baseline, at any font size and under maxWidth clamping. One platform
limitation (not this library's): the row is separate native nodes, so text
selection can't flow across it.

2. Inside <Text>alignSelf: 'baseline'

<Text>
  compare y with{' '}
  <RaTeXView latex="y" fontSize={16} displayMode={false}
             style={{ alignSelf: 'baseline' }} />{' '}
  mid-sentence
</Text>

The formula sits on the line's baseline like a glyph, line spacing matches
what a baseline flex row produces, and the paragraph stays real selectable
text. Same style as the flex case — no new public prop; center /
flex-start / flex-end work too.

Both cases are fully opt-in: no new props, and nothing changes unless you
set alignSelf. They also cover what InlineTeX can't — per-span styling,
nested components, and composing with an app-owned <Text> tree (e.g. a
markdown renderer).

Known issue (bottom side, not fixable library-side). RN's inline-view
protocol has no notion of depth: a line reserves only its font's descender
below the baseline. A formula much deeper than the text keeps its correct
baseline but its tail can overdraw the next line — where a flex row (blue)
reserves the full depth. Everything above the baseline is exact. Fixing this
needs depth in RN's attachment protocol itself.

iOS Android
deep-ios deep-android

3. getTexMetrics() — text engines without the limitations

The two cases above are RN layout. A host that renders text itself — a
markdown renderer or editor built on TextKit (iOS) / Spannables (Android)
— has none of RN's inline limitations (real selection, real line breaking,
depth-aware lines), but it needs the formula's depth as a number to place
the attachment box. That's exactly what KaTeX ships on the web with every
formula (.depth, emitted as vertical-align: -depth).

On a mounted view — drawn metrics at the committed layout:

const m = ref.current?.getTexMetrics();
// { depth, scale, width, height } | null
// depth: view bottom edge → drawn ink baseline, dp — apply directly

And the global function the ref method uses internally — natural metrics for
any formula, no view needed:

import { getTexMetrics } from 'ratex-react-native';

const m = getTexMetrics('\\frac{a}{b}', 16, false);
// { width, height, depth } | null — natural (unscaled) ink size, dp

Both are sync (callable in useLayoutEffect) and served from the parse cache
the measure/render passes share, so an on-screen formula never re-parses.
Cold parse is ~0.2 ms per formula (release builds); a cache hit is
microseconds.

This is enough to build your own InlineTeX-style engine with none of the
limitations above. The screenshot below is such a host (TextKit/Spannables +
getTexMetrics): selectable, per-span styling, and the line holding the deep
formula reserves its full depth — no overdraw:

custom engine

Screenshots

All examples from demo/react-native-expo (iPhone 17 Pro simulator / Pixel 9a
emulator, RN 0.86, new arch). Amber = inline in <Text>, blue = flex
baseline (ground truth), red = no alignment. Full-size images in
istarkov/RaTeX#1.

Example iOS Android
Flex row: Yoga aligns
Inside <Text> vs flex (full card)
3-line wrap: plain line above the formula line
Known issue: deep descender
In-<Text> alignSelf options
Baseline × font size mix
maxWidth autoscaling × baseline
getTexMetrics() depth ruler
getTexMetrics() perf card

Design decisions

  • No new public prop for alignment — the standard alignSelf style works
    in both host contexts; native applies the placement itself.
  • Depth is exposed as a method, not a prop/event — it's a value you pull
    at layout time; both forms are sync so they work inside useLayoutEffect.
  • Two shapes on purpose: the ref method returns drawn metrics (fit
    scale + centering applied — apply depth directly at the committed size);
    the global function returns natural metrics for engines that lay out
    before mounting.
  • Everything rides the existing parse cache — measure, render, baseline
    and metrics share one parsed representation per formula; no extra parses.
  • Prior art: KaTeX's .depth / vertical-align: -depth on the web.

Technical details (brief)

  • Flex baseline: the Fabric shadow node implements baseline() — drawn ink
    baseline (fit scale ≤ 1 + centering gap + TeX depth), pixel-grid snapped.
  • Inside <Text>: RN reserves an inline view's full height as line ascent,
    so for 'baseline' the node measures the ascent-only box (height − depth)
    and draws bottom-anchored, descender overflowing — line spacing matches the
    flex row. JS forwards alignSelf through an internal codegen prop
    (inlineAlign) gated by TextAncestorContext.
  • iOS additionally compensates a stock-RN TextKit sink bug at runtime
    (inline attachment run has no font attribute); self-disabling on a fixed RN.
  • getTexMetrics is a thin sync TurboModule (RaTeXModule, old + new arch)
    over the existing RaTeXMeasure / RaTeXMetrics backends; the ref stays a
    genuine host instance (measure etc. untouched).
  • No breaking changes. Verified on stock RN 0.86, both platforms, old + new
    arch (metrics fall back to natural size on old arch, where measure is
    async).

istarkov added 3 commits July 14, 2026 08:37
alignSelf:'baseline' (also center/flex-start/flex-end) now works in both
host contexts, no new public prop:

- Flex row (alignItems:'baseline'): the Fabric shadow node reports the
  drawn ink baseline via baseline() — fit-scale + centering gap + exact
  depth, snapped to the physical pixel grid.
- Inside <Text>: the text engine pins the view bottom to the baseline and
  reserves the whole view height as line ASCENT — so for 'baseline' the
  measure reports the ascent-only box (height − depth), giving the line
  above flex-equal spacing, and the view draws natural-size ink anchored
  to the frame bottom with the descender overflowing below (iOS: unequal
  top/bottom constraint constants; Android: no-clip bottom-anchored draw).
  JS forwards alignSelf as an internal codegen prop (inlineAlign), gated
  by TextAncestorContext — native cannot detect the text host itself.
  center/start/end keep the full-height translate model.
- iOS: compensates stock RN's missing font attribute on inline attachment
  runs at runtime (stockAttachmentSink), replacing a react-native patch;
  self-disabling on a fixed RN.
- Android: RaTeXMetrics — sync natural metrics (width/height/depth)
  backed by the engine parse cache shared with measure and render.
…xMetrics)

ref.getTexMetrics() -> {depth, scale, width, height} | null, sync
(callable from useLayoutEffect). For hosts that lay out themselves
(markdown renderers with attachment overlays, canvas typesetters):
flex baseline and inline <Text> place the formula natively; everyone
else needs the number — the native analogue of KaTeX's .depth /
vertical-align: -depth on web.

- depth is DRAWN: view bottom edge -> ink baseline for the committed
  frame (fit scale + centering gap applied), valid at any maxWidth
  clamp. width/height are natural ink size; scale is the fit k.
- Backed by a new sync TurboModule (RaTeXModule) over the existing
  RaTeXMeasure / RaTeXMetrics parse-cache-backed backends — an
  on-screen formula never re-parses. Android passes the render color
  (its cache is color-keyed, shared with measure/render); iOS metrics
  are color-blind and ignore it.
- useImperativeHandle returns the genuine host instance augmented with
  the method — host identity, measure(), findNodeHandle all survive.
  Old arch: measure is async there; metrics fall back to natural size.
- Demo: 'ink baseline for custom hosts' card draws a ruler positioned
  only from getTexMetrics().depth across several maxWidth clamps.
@istarkov

Copy link
Copy Markdown
Contributor Author

P.S.
For my needs only getTexMetrics() matters: I'm making a very simple markdown renderer that uses TextKit (iOS) / Spannables (Android) purely for text rendering, while everything else — tables, images, formulas — lives on the React side. The engine only reserves space for each attachment, and depth affects its placement relative to the baseline.

The flex and in- alignment paths cover simpler renderers well — you get per-span colors, fonts, and weights from plain RN — at the cost of the caveats above: the deep-descender overdraw in , and selection that can't cross a flex row.

@erweixin

Copy link
Copy Markdown
Owner

Thank you for the excellent and thorough implementation and documentation! This looks great overall. One small suggestion: should we use forwardRef to preserve compatibility with React 18.x?

@istarkov

Copy link
Copy Markdown
Contributor Author

React 19 landed Dec 5, 2024. RN shipped it in 0.78 on Feb 19, 2025 - a year and a half ago.

RN itself now marks everything below 0.84 as unsupported (https://reactnative.dev/versions).
The RN team already made this call for all of us, so maybe let's follow it: min RN 0.84, min React 19, and clean house - remove non-Fabric support and the rest of the legacy (300-400 lines of code) baggage ;-)

PS: My opinion - In the AI era, what excuses can people even have for sitting on an outdated RN or React? ;-) When all that stands between them and an upgrade is a single prompt.

@erweixin

Copy link
Copy Markdown
Owner

PS: My opinion - In the AI era, what excuses can people even have for sitting on an outdated RN or React? ;-) When all that stands between them and an upgrade is a single prompt.

Couldn't agree more. 😄

@erweixin
erweixin merged commit ebda802 into erweixin:main Jul 15, 2026
6 checks passed
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.

Baseline alignment

2 participants