Skip to content

Text layout driver: native text or Parley, per block

Generated from docs/text-driver.md when the site was built at 7aa23e2.

Status: spike (design plus standalone prototypes). Nothing here is wired into niso. Prototypes and raw results are in prototypes/text-native/{ios,android}.

DECISIONS “Text” says niso owns text layout on every platform with Parley, drawn by the platform rasterizer, and that matching native text is not a goal. This spike asks whether each inline formatting context (IFC) should instead be able to become one native text object (TextKit 2 on Apple, StaticLayout on Android), while niso keeps owning inline layout around it. It answers three questions:

  1. Which platform text API levels follow the user’s text settings on their own (Bold Text, Dynamic Type / font scale, Increase Contrast / outline text), and which of them a lower-level draw path picks up while Parley keeps laying out.
  2. What a native layout of one niso paragraph (two styled spans and an inline box) looks like, and what it costs.
  3. How a driver that lets each block choose native or Parley fits niso-core, blitz inline layout, Taffy and the widget templates.

Keep Parley as the default engine everywhere, and make it follow the user’s settings through the lower-level path the probes found: system-resolved fonts on Apple, and on Android drawTextRun for outline text plus fontWeightAdjustment and the sp size mapping fed into the computed style. That covers every setting tested except Android 16’s rounded rect behind high-contrast text, which only Layout.draw paints and niso can paint itself from line rects.

Build the driver interface anyway, with Parley as the first engine behind it. Widgets need a native engine at publish time no matter what live hosts do, and the interface keeps the native live engine a per-app, then per-block, choice without a second rewrite. Native live layout costs about 3× Parley per layout on iOS, does not follow Dynamic Type or font scale by itself, and needs explicit line-height and baseline mapping before it matches CSS, so it should be opt-in, not the default.

Both probes are tiny apps with no niso: one string drawn through every API level, with screenshots and numbers read back.

  • iOS: iPhone 17 Pro simulator, iOS 27.0 (24A434). Sample “Settings Hamburgefonstiv” at 17 pt. “ink” is dark pixel coverage in pt².
  • Android: emulator, Android 16 (API 36, BE2A.250530.026.F3), Pixel 7 profile, density 2.625. Samples at 14 dp and 48 dp converted to px, as niso does today (CSS px × density).
Level Bold Text Dynamic Type AX XL Increase Contrast
1 CTFontDrawGlyphs, font from file bytes at wght 400 (niso today) no no no
2 CTFontDrawGlyphs, CTFontCreateUIFontForLanguage(.system, 17) yes, Semibold, ink 725→986, 197→208 pt wide no no
3 CTFontDrawGlyphs, UIFont.systemFont(ofSize: 17) yes no no
4 NSAttributedString.draw yes no no
5 UILabel, systemFont(ofSize:) yes no no
6 UILabel, preferredFont(.body), adjusts yes yes, 40 pt no
7 UILabel, UIFontMetrics(.body).scaledFont(17), adjusts yes yes, 37 pt no
8 UITextView (TextKit 2), systemFont(ofSize:) yes no no
9 SwiftUI Text, .system(size: 17) yes no no
10 SwiftUI Text, .body yes yes, 40 pt no
  • Bold Text acts when a font is resolved, not when text is laid out. Every API that asks the system for its font gets the heavier instance (Regular→Semibold, Bold→Black), down to CoreText. Only a font built from file bytes at an explicit wght misses it.
  • Dynamic Type never scales a fixed size, in UIKit, TextKit or SwiftUI. Only text styles and UIFontMetrics-scaled sizes follow it. A native text object adds nothing here: a CSS px size follows Dynamic Type only if something maps it, through UIFontMetrics or niso’s root font scale.
  • Increase Contrast changes colors, never text drawing: ink is identical at every level, secondaryLabel goes from alpha 0.6 to 0.8 and isDarkerSystemColorsEnabled turns on. It reaches pages through prefers-contrast and the semantic colors.
Level Outline text (high_text_contrast_enabled) Bold text (font_weight_adjustment 300) Font scale 2.0
1 drawGlyphs, Font.Builder(bytes of Roboto-Regular.ttf) (niso today) no no no
2 drawGlyphs, Font from TextRunShaper.shapeTextRun(...).getFont() no no no
3 Canvas.drawText (default, or Typeface.create("sans-serif", 400, false)) yes, dark text + white outline no no
3c Canvas.drawTextRun, one word at a time at an x we choose yes, outline no no
4 StaticLayout(TextPaint).draw yes, outline + rounded light rect per line no only if the size goes through applyDimension(SP)
5a TextView, setTextSize(PX) yes, rect + outline yes, paint weight 400→700 no
5b TextView, setTextSize(SP) yes yes yes, nonlinear
6 TextView + SpannableString (bold span, color, ReplacementSpan box) yes; span color darkened, the span’s own drawRect untouched yes for regular text; the BOLD span stays 700 yes
  • Outline text is an HWUI text-draw feature. drawText, drawTextRun and Layout get it; drawGlyphs never does, even with the platform’s own Font. The Android 16 rect behind each line is Java, android.text.Layout.drawHighContrastBackground behind the highContrastTextSmallTextRect flag, so only Layout.draw (StaticLayout, TextView) paints it. Apps can detect the setting with AccessibilityManager.isHighContrastTextEnabled() and its listener.
  • Bold text is Configuration.fontWeightAdjustment, applied only by TextView.setTypeface. Typeface.create, Paint and StaticLayout ignore it.
  • Font scale applies only to sizes given in sp, through the nonlinear converter behind TypedValue.applyDimension(COMPLEX_UNIT_SP, …). At 2.0, sp→dp is 8→16, 14→26, 16→28, 24→36, 48→53.94, 100→100: 14 sp grows 1.86× and 48 sp only 1.12×.

2. Lower-level draw paths (separate finding)

Section titled “2. Lower-level draw paths (separate finding)”

The question: can Parley keep laying out while a lower-level draw path picks up the settings, so that measuring with the same platform font objects is enough? Mostly yes.

iOS: yes for Bold Text, and nothing to pick up for the others. CTLine shaping drawn with CTFontDrawGlyphs from a system-resolved CTFont/UIFont gets the heavier face (levels 2 and 3). The change is in which font instance the system hands out, so Parley follows it by measuring with that same instance: resolve system-ui and the ui-* families through CTFontCreateUIFontForLanguage / UIFont.systemFont(ofSize:weight:) and read back its weight and variation coordinates (or its data), instead of building the font from file bytes at the CSS weight. Measuring and drawing then stay one font, which is what DECISIONS asks for. Not tested as its own level: a font taken from preferredFont(forTextStyle:compatibleWith: traitCollection) and drawn through CTLine; levels 2–4 resolve the same way, so it very likely behaves the same. Dynamic Type is a size mapping (root font scale or UIFontMetrics) and Increase Contrast is colors, so no draw path matters for them.

Android: yes for outline, no for bold and font scale. With Typeface.create("sans-serif", 400, false) the platform resolves /system/fonts/Roboto-Regular.ttf, the same file niso loads, and the advance of “Outline Hg” at 126 px is 588.0 px from TextRunShaper, measureText and getTextRunAdvances alike. Ticks drawn at x + getTextRunAdvances land on the ink end of every drawTextRun word in every state. So niso can keep Parley’s positions per run, draw each run with drawTextRun at Parley’s run origin, and get outline text with no layout change. Bold and font scale never apply below TextView, so niso has to feed them into Parley itself: weight + fontWeightAdjustment, and sizes and line heights through applyDimension(SP) instead of × density. When it does, the numbers match TextView exactly: a StaticLayout built that way equals TextView field for field in the base, bold and font-scale states (§4).

Caveats: drawTextRun reshapes inside each run (Minikin/HarfBuzz), so only the run origin is Parley’s. Glyph positions inside a run match only if font, size, variations, features and letter-spacing match, and kerning across run boundaries is lost. Recording layout.draw costs 3–4× recording the same runs with drawGlyphs; recording drawTextRun was not measured separately. The Android 16 rect is not reachable this way; niso would paint it from line rects when isHighContrastTextEnabled() is true.

3. Mapping CSS to platform fonts without losing automatic behaviour

Section titled “3. Mapping CSS to platform fonts without losing automatic behaviour”
CSS iOS Android
font-size: Npx N × rootScale (the root font scale from Dynamic Type, as now), or UIFontMetrics(forTextStyle: .body).scaledValue(for: N) for the native engine; never a bare fixed size, which never scales applyDimension(COMPLEX_UNIT_SP, N, dm) for font-size and line-height; N × density loses font scale
text styles preferredFont(forTextStyle:) sp sizes of the Material type scale
font-weight resolve through the system (systemFont(ofSize:weight:)); Bold Text then applies by itself Typeface.create(family, min(1000, w + fontWeightAdjustment), italic); not automatic below TextView
system-ui, ui-rounded, ui-monospace, ui-serif CTFontCreateUIFontForLanguage(.system) plus UIFontDescriptor.SystemDesign sans-serif, monospace, serif families
web fonts (bytes) CTFontManagerCreateFontDescriptorFromData; Bold Text untested for these Font.Builder(ByteBuffer); no automatic bold
colors, prefers-contrast semantic colors re-resolve under Increase Contrast outline comes from drawing with drawText*/Layout, not from CSS

The iOS Bold Text mapping is not a fixed +N: Regular (400) went to Semibold (600) and Bold (700) to Black (900). Android’s is a flat +300, clamped at 1000.

Both lay out “Hello “ regular, a long bold colored span that wraps, a 40×20 inline box, and trailing text, then read back size, line boxes, per-span rects, the box rect, a hit test and intrinsic widths. Max-content is the layout at an unbounded width; min-content is the widest line-break segment (kCFStringTokenizerUnitLineBreak, BreakIterator.getLineInstance), which both platforms only offer through extra measurements.

Read from NSTextLayoutManager: line fragments, enumerateTextSegments for span rects, the NSTextAttachment frame, and textSelectionNavigation.textSelections(interactingAt:) for the hit test.

default Bold Text
used size 237.85 × 64.67 230.17 × 84.96
lines 3 (baselines 16.19, 36.47, 60.57) 4
fonts .SFUI-Regular, .SFUI-Bold .SFUI-Semibold, .SFUI-Black
bold span rects (43.16, 0, 187.42, 20.29), (0, 20.29, 237.85, 20.29), (0, 40.57, 49.81, 24.1) in results/bold.json
box rect (49.81, 40.57, 40, 24.1) in results/bold.json
hit test at (60, baseline 2 − 4) offset 37 offset 29
max / min content 663.81 / 64.62 698.33 / 68.24

At AX XL and with Increase Contrast the paragraph is identical to default (fixed 17 pt). Parley lays the same paragraph out at 235.30 × 63.67, also in 3 lines. The difference is line height: TextKit uses the font’s lineHeight (20.29) where CSS normal is 21.2 in Parley, and TextKit grows the third line to fit the attachment (24.1). A native engine has to set line height explicitly (NSParagraphStyle min/max line height plus a baseline offset for half-leading) and size attachments by CSS vertical-align rules to meet the geometry contract below.

Android, StaticLayout and TextView, width 300 dp (788 px), 16 sp

Section titled “Android, StaticLayout and TextView, width 300 dp (788 px), 16 sp”

65 chars: [0,6) regular, [6,58) StyleSpan(BOLD) red, U+FFFC at 59 a 40×20 px ReplacementSpan on the baseline. Read through getLineTop/Bottom/Baseline/Left/Right, Layout.fillCharacterBounds (API 34) grouped per line, getPrimaryHorizontal and getOffsetForHorizontal. Three layouts per state: TextView at 16 sp, a raw StaticLayout at 16 × density px, and a manual StaticLayout with applyDimension(SP, 16) and weight 400 + fontWeightAdjustment. The manual StaticLayout equals TextView field for field in every state; the raw one never changes.

px default bold font scale 2.0
text size 42 42, weight 700 73.5
used size 780.77 × 106 783.77 × 106 743.82 × 270
lines 2 (baselines 45, 94) 2 3 (baselines 78, 164, 250)
regular span (0, 0)–(106, 55) (0, 0)–(109, 55) (0, 0)–(187, 96)
bold span x, per line 106–790.77, 0–310.59 109–793.77, 0–310.59 187–761.82, 0–753.05, 0–398.29
box (l, t, r, b) 320.59, 74, 360.59, 94 320.59, 74, 360.59, 94 416.29, 230, 456.29, 250
hit at (157.5, mid line 1) offset 50 offset 50 offset 29
min / max content 139 (“second”) / 1241.36 139 / 1247.36 238 / 2128.15

A span rect that ends at a break includes the trailing space (790.77 > 788); getLineRight excludes it. A driver has to pick one rule: in CSS the hanging space does not count toward the line.

iOS (simulator on an M4 Pro, warm, mean of 1000, the 80-char paragraph):

µs
TextKit 2: new storage + layout manager + ensureLayout 71.3
TextKit 2: relayout at a new width 64.4
NSAttributedString.boundingRect 41.8
CTFramesetter + suggestFrameSize 33.2
CTLine, one line, no breaking 23.0
Parley build + break_all_lines (macOS, release, same text) 23.1–23.6

Android (x86_64 emulator on KVM, 200 chars, 42 px, 788 px, 5 lines, UI thread, three launches; only ratios mean much):

µs, runs 1 / 2 / 3
StaticLayout.Builder.build, HIGH_QUALITY 42.5 / 46.1 / 94.1
StaticLayout.Builder.build, SIMPLE 39.1 / 56.6 / 87.4
StaticLayout, size nudged each time (misses the word cache) 49.9 / 66.2 / 88.8
TextView.setText + measure 66.9 / 73.2 / 67.3
TextShaper.shapeText (shaping only) 113.5 / 134.3 / 116.6
record layout.draw into a RenderNode 28.2 / 32.9 / 28.3
record 6 drawGlyphs runs 6.3 / 10.2 / 10.4

Parley was not benchmarked on the emulator. On iOS a native layout per IFC costs about 3× Parley, relayout at a new width saves only about 10%, and intrinsic widths cost one more layout (max-content) plus segment measurements (min-content). Every width Taffy asks about is one native layout, so caching (§7) decides whether native is affordable.

The driver is the boundary between niso-core’s inline layout and a text engine for one IFC. niso keeps owning everything around a paragraph: which nodes form an IFC, white-space processing, inline box layout, span backgrounds and borders, selection, ranges and accessibility. The engine owns line breaking, shaping and drawing of that one paragraph.

/// One IFC's content, in the order blitz builds it today.
pub struct Paragraph<'a> {
pub text: &'a str, // after white-space processing
pub runs: &'a [Run], // byte ranges covering `text`
pub boxes: &'a [Placeholder], // inline boxes, at U+FFFC offsets
pub style: ParagraphStyle, // align, direction, wrap mode, indent
}
pub struct Run {
pub range: Range<usize>,
pub font: FontRequest, // families, weight, style, stretch,
// size (CSS px, already scaled),
// features, variations
pub line_height: LineHeight, // CSS `normal`, px or factor
pub letter_spacing: f32,
pub word_spacing: f32,
pub decoration: Decoration,
pub brush: NodeId, // the node whose color and paint apply
}
pub struct Placeholder {
pub offset: usize,
pub node: NodeId,
pub size: Size<f32>, // margin box, laid out by niso first
pub baseline: f32, // from its top, per vertical-align
}
pub trait TextEngine {
type Layout: TextLayout;
fn layout(&mut self, p: &Paragraph, width: Option<f32>) -> Self::Layout;
fn content_widths(&mut self, p: &Paragraph) -> (f32, f32); // min, max
}
pub trait TextLayout {
fn size(&self) -> Size<f32>;
fn lines(&self) -> &[Line]; // range, top, bottom, baseline, left, right
fn range_rects(&self, range: Range<usize>, out: &mut Vec<Rect>);
fn placeholder_rect(&self, index: usize) -> Rect;
fn hit(&self, point: Point<f32>) -> (usize, Affinity);
fn caret(&self, offset: usize, affinity: Affinity) -> Rect;
fn draw(&self) -> Draw; // Parley: glyph runs; native: a handle
}

The C ABI mirrors this as a host callback table (niso_text_engine in niso_runtime.h), called synchronously on the main thread, where niso’s layout already runs. Runs and placeholders cross as one flat buffer per paragraph, with offsets converted to UTF-16 at the boundary. The native layout stays in the host behind an opaque handle that the IFC’s native view draws; niso keeps only the geometry.

  • Units are CSS px; the origin is the IFC’s content box top-left.
  • Line boxes follow CSS: each line’s height comes from line-height (normal meaning the font’s ascent + descent + line gap, as Parley computes it), half-leading is split above and below, and placeholders are aligned by their baseline. A native engine sets min/max line height and baseline offsets explicitly instead of using its own default (TextKit’s 20.29 vs CSS 21.2 above).
  • A hanging trailing space is not in a line’s width or in range rects.
  • Ranges and hit tests use niso’s text offsets (UTF-8), with affinity at line ends.
  • Intrinsic widths: max-content is the unbroken width; min-content is the widest unbreakable segment under the paragraph’s word-break, overflow-wrap and hyphens.
  • The same input under the same settings gives the same output, so it can be cached.

The Parley engine is today’s code moved behind the trait with its current behaviour unchanged, so every existing test keeps checking it.

blitz-dom’s compute_inline_layout (packages/blitz-dom/src/layout/inline/layout.rs:193 at be60597) builds a parley::Layout<TextBrush>, lays out each inline box first, then calls calculate_content_widths (:616) for intrinsic sizes and breaks lines (around :797). With the floats feature niso enables, it breaks one line at a time and gives each line its own slot beside floats. The driver slots in where the Layout is built and broken: inline boxes are still laid out by compute_child_layout first, their sizes become placeholders, and the engine returns the sizes Taffy expects. inline_layout_data.layout becomes an enum of the two engines’ layouts, and its readers in niso-core (geometry.rs, render.rs, template.rs) and in blitz-dom (hit testing, accessibility) go through TextLayout. niso uses blitz-dom unforked at a pinned upstream rev, so this needs a fork or an upstream hook; the hook is small (build, content widths, break, read back) and could be offered upstream.

Per-line slots are the one input the native engines don’t take in the same shape. StaticLayout.Builder.setIndents and TextKit exclusion paths can express a known set of line widths, but not the line-by-line negotiation blitz does now, so an IFC with floats beside it uses Parley (§9).

  • Key a paragraph by a hash of its text, runs, placeholder sizes and paragraph style, plus a settings generation (text size, bold, contrast).
  • Keep one native layout per IFC per width, so Taffy’s repeated measure calls at the same width (common in flex and grid) hit the cache. Relayout at a new width is barely cheaper than a new layout.
  • Cache intrinsic widths per paragraph separately. blitz recomputes them today (its own TODO near :610), and on native they are the most expensive part.
  • Placeholder sizes can differ under min- and max-content constraints, so the intrinsic cache is keyed by both.
  • A settings change bumps the generation and drops every native layout; the Parley path re-resolves fonts and rebuilds as it does today.

The engine is chosen per IFC, from the IFC root’s computed value of an inherited property, with the host’s default as fallback:

:root { --niso-text-engine: auto; } /* auto | native | parley */
.editor { --niso-text-engine: parley; }

A custom property is enough. Custom properties inherit by default, and stylo exposes them on ComputedValues::custom_properties() (stylo style/properties/properties.mako.rs:1690), so niso reads the value at the IFC root with no stylo change. A real prefixed property (-niso-text-engine, in the spirit of -apple-glass-effect in DECISIONS) needs a new longhand in stylo’s mako templates, which means forking stylo underneath blitz; it buys only parse-time validation. Start with the custom property and promote it only if stylo gets forked for something else.

auto means the host’s default (§10). native is a request, not a guarantee: an IFC that uses something the native engine can’t express (§9) falls back to Parley, and debug builds log why. Phase 1 exposes only an app-wide host switch (a text_engine field in the host’s niso configuration); the per-IFC fallback already applies there, so the per-block plumbing exists from the start.

  • Baselines: both engines report first and last baselines in the same coordinates, so align-items: baseline and inline-block baselines work across engines.
  • Intrinsic sizes: both answer content_widths under the same contract, so flex and grid size mixed blocks consistently.
  • Selection across blocks: selection is DOM ranges, owned by niso. Each IFC answers range→rects, point→offset and caret rects for its part, so a selection spanning a native and a Parley block is the union per block. Handles and carets are drawn by niso’s selection layer for both.
  • Find-in-page: search runs over the DOM text and highlights come from range_rects. A platform find UI (UIFindInteraction, Android’s search action mode) queries niso, never the native text objects directly.
  • Accessibility: text frames per range come from the same calls.
  • Look: both engines draw the same font files, but Minikin/CoreText and Parley can differ in kerning across runs, optical sizing and rounding. Blocks side by side in different engines may show it, which is another reason to keep one engine per app by default.

This list comes from the platform APIs as documented; only the paragraph features in §4 were prototyped.

  • Floats beside the IFC (per-line slots, §6).
  • Paint effects that need the glyphs: text-shadow lists, -webkit-text-stroke, background-clip: text, gradients or images in text, wavy or dotted decorations with text-decoration-thickness or -offset, text-emphasis. The native engine draws itself, and niso can’t reach its glyphs.
  • Line breaking CSS has and the platform lacks: line-break: anywhere, word-break: keep-all on iOS (Android has LineBreakConfig, API 33), overflow-wrap: anywhere (it changes min-content), white-space: break-spaces, text-wrap: balance and pretty on iOS.
  • word-spacing on iOS (no attributed-string attribute for it).
  • ::first-line, ::first-letter, initial-letter, ruby.
  • vertical-align values for inline boxes that depend on the line (top, bottom), beyond what a placeholder’s baseline encodes.
  • unicode-bidi: isolate or plaintext across nested spans where the native string attributes can’t mark isolates.
  • Editing hosts (contenteditable and fields niso draws itself): they relayout on every keystroke and depend on niso’s caret model, so they stay on Parley until the native engine is proven there.
Platform Default Why
iOS, iPadOS, macOS Parley Bold Text is captured by resolving fonts through CoreText (§2); Dynamic Type is a size mapping niso already does; Increase Contrast is colors. Native layout costs about 3× and brings line-height mismatches.
Android Parley Outline text comes from drawing runs with drawTextRun; bold and font scale need niso to feed fontWeightAdjustment and the sp mapping even with native layout below TextView. Only the Android 16 rect is Layout-only, and niso can paint it from line rects.
Windows Parley DirectWrite could be a native engine later; not probed.
Linux, headless, wasm, tests Parley, always No native engine, and headless tests stay deterministic.
Widget, Live Activity and notification templates native, at publish The target draws with native text and nothing else (§11).

Native stays available per app and per block for apps that want system text behaviour exactly, for example a reader that wants TextKit’s hyphenation and justification.

Templates (crates/niso-core/src/template.rs, format in niso_runtime.h) send each IFC as one string with the first run’s style, inside a frame that Parley measured. The target (WidgetKit, Glance/RemoteViews) draws it with native text and no niso at runtime. So the widget is wrong whenever the target’s text system disagrees with Parley’s frame, and whenever size or settings change while the app isn’t running. The immediate fix ships separately (wrap-content text with max lines and ellipsis instead of fixed boxes). The driver makes templates a required consumer of the native engine:

  1. Text nodes carry runs, not one string: the same Run list as the driver input, so spans keep their weight and color (AttributedString or concatenated Text on Apple; a SpannableString with parcelable spans through RemoteViews on Android. Glance’s Text takes one style, so spanned text goes through AndroidRemoteViews).
  2. Sizes carry their unit intent so the target scales them itself. On Apple that is .system(size: s, relativeTo: .body), which follows Dynamic Type the way UIFontMetrics did in the probe (17→37 pt at AX XL), never a bare .system(size:), which never scales. On Android it is setTextViewTextSize(COMPLEX_UNIT_SP, s), which the widget host rescales on a configuration change. Weight goes as a system weight so Bold Text and fontWeightAdjustment apply in the target.
  3. Boxes are flexible: a template keeps the page’s stacking intent (direction, gap, alignment, grow) and gives fixed frames only to boxes with explicit CSS sizes. Text boxes are wrap-content with maxLines and ellipsis, so when the widget is resized or the text size changes, the target reflows without the app.
  4. At publish, niso measures every template text node with the native engine (the fonts and current settings the target uses) at each size the surface can take: the WidgetKit families named by the surface, and on Android the RemoteViews(Map<SizeF, RemoteViews>) size buckets. That measurement decides maxLines and whether a node fits, and the frames it produces are hints, not constraints.
  5. When the app runs again, a settings change invalidates templates through the same generation counter as §7 and republishes them.

Native measurement at publish makes the widget correct at the settings it was published under; flexible boxes and unit-carrying sizes keep it correct after they change.

  • Two engines mean two sets of bugs, and every text feature needs tests on both. Headless tests only exercise Parley; the native engines need simulator or device tests.
  • Line height and baseline mapping is where native and CSS disagree first (20.29 vs 21.2 per line here). Getting it wrong shifts every block below.
  • Native layout is about 3× Parley per layout on iOS, and intrinsic sizes need extra layouts; without the cache a flex-heavy page gets slower.
  • UTF-8 ↔ UTF-16 offset conversion at every boundary call.
  • Mixed engines on one page may look slightly different side by side.
  • Every number here comes from the iOS 27 simulator and an API 36 emulator. The user’s phone showed the outline in RemoteViews, which matches the emulator, but nothing here ran on hardware.
  • The driver needs a hook in blitz-dom’s inline layout, which niso uses unforked today.
  1. Settings through Parley. No new engine. Android draws runs with drawTextRun at Parley’s origins (outline) and feeds fontWeightAdjustment and the sp mapping into the computed style; Apple resolves system fonts through CoreText so Bold Text reaches Parley. This is what DECISIONS already asks for; the spike shows which level supplies each setting. Status (branch text-settings): done, checked by tests/screens/settings.mjs against stock text views (now tests/screens/text.mjs, against the platform’s text styles). Android’s paint lays text out unhinted (isLinearText): hinted advances were 1–1.6% narrower than Parley’s, so no run matched and every run fell back to drawGlyphs. Stock views still lay out hinted, so a line near the edge can break one word earlier in niso. Apple matches the system font at the wght UIKit resolves each weight to (fontique per-family weight maps), and Android adds fontWeightAdjustment to every font’s weight. Not done: an element a running animation or transition restyles is drawn at its CSS size, because each resolve restyles it after the text scale applies; fixing that needs a step between blitz’s styling and layout, the same kind of hook as phase 2’s.
  2. Driver with Parley behind it, and templates on native measurement. Move Parley behind TextEngine/TextLayout with no behaviour change, add the blitz-dom hook, and make templates carry runs, unit-carrying sizes and flexible boxes, measured at publish by a native engine on each host.
  3. Native live engine behind an app-wide switch. TextKit 2 and StaticLayout engines under the same contract, the per-IFC Parley fallback, the geometry cache, and the host’s text_engine setting. Tests run the same pages through both engines and compare line boxes within 1 px.
  4. Per block. --niso-text-engine read at the IFC root, plus coexistence tests (baselines, selection across blocks, find-in-page).
  5. Close gaps as apps need them: per-line widths for floats and more CSS features in the native engines.