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:
- 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.
- What a native layout of one niso paragraph (two styled spans and an inline box) looks like, and what it costs.
- How a driver that lets each block choose native or Parley fits niso-core, blitz inline layout, Taffy and the widget templates.
Recommendation in short
Section titled “Recommendation in short”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.
1. Which level follows which setting
Section titled “1. Which level follows which setting”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
wghtmisses 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, throughUIFontMetricsor niso’s root font scale. - Increase Contrast changes colors, never text drawing: ink is identical at
every level,
secondaryLabelgoes from alpha 0.6 to 0.8 andisDarkerSystemColorsEnabledturns on. It reaches pages throughprefers-contrastand the semantic colors.
Android
Section titled “Android”| 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,drawTextRunandLayoutget it;drawGlyphsnever does, even with the platform’s ownFont. The Android 16 rect behind each line is Java,android.text.Layout.drawHighContrastBackgroundbehind thehighContrastTextSmallTextRectflag, so onlyLayout.draw(StaticLayout, TextView) paints it. Apps can detect the setting withAccessibilityManager.isHighContrastTextEnabled()and its listener. - Bold text is
Configuration.fontWeightAdjustment, applied only byTextView.setTypeface.Typeface.create,PaintandStaticLayoutignore 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.
4. Paragraph prototypes
Section titled “4. Paragraph prototypes”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.
iOS, TextKit 2, width 240 pt
Section titled “iOS, TextKit 2, width 240 pt”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.
5. Cost
Section titled “5. Cost”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.
6. The driver interface
Section titled “6. The driver interface”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.
The geometry contract both engines meet
Section titled “The geometry contract both engines meet”- 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(normalmeaning 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-wrapandhyphens. - 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.
Fit with blitz and Taffy
Section titled “Fit with blitz and Taffy”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).
7. Caching
Section titled “7. Caching”- 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.
8. Choosing per block
Section titled “8. Choosing per block”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.
Coexistence on one page
Section titled “Coexistence on one page”- Baselines: both engines report first and last baselines in the same
coordinates, so
align-items: baselineand inline-block baselines work across engines. - Intrinsic sizes: both answer
content_widthsunder 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.
9. What forces Parley for an IFC
Section titled “9. What forces Parley for an IFC”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-shadowlists,-webkit-text-stroke,background-clip: text, gradients or images in text, wavy or dotted decorations withtext-decoration-thicknessor-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-allon iOS (Android hasLineBreakConfig, API 33),overflow-wrap: anywhere(it changes min-content),white-space: break-spaces,text-wrap: balanceandprettyon iOS. word-spacingon iOS (no attributed-string attribute for it).::first-line,::first-letter,initial-letter, ruby.vertical-alignvalues for inline boxes that depend on the line (top,bottom), beyond what a placeholder’s baseline encodes.unicode-bidi: isolateorplaintextacross nested spans where the native string attributes can’t mark isolates.- Editing hosts (
contenteditableand 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.
10. Default engine per platform
Section titled “10. Default engine per platform”| 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.
11. Widgets and templates
Section titled “11. Widgets and templates”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:
- Text nodes carry runs, not one string: the same
Runlist as the driver input, so spans keep their weight and color (AttributedStringor concatenatedTexton Apple; aSpannableStringwith parcelable spans through RemoteViews on Android. Glance’sTexttakes one style, so spanned text goes throughAndroidRemoteViews). - 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 wayUIFontMetricsdid in the probe (17→37 pt at AX XL), never a bare.system(size:), which never scales. On Android it issetTextViewTextSize(COMPLEX_UNIT_SP, s), which the widget host rescales on a configuration change. Weight goes as a system weight so Bold Text andfontWeightAdjustmentapply in the target. - 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
maxLinesand ellipsis, so when the widget is resized or the text size changes, the target reflows without the app. - 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 decidesmaxLinesand whether a node fits, and the frames it produces are hints, not constraints. - 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.
12. Risks
Section titled “12. Risks”- 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.
13. Phased plan
Section titled “13. Phased plan”- Settings through Parley. No new engine. Android draws runs with
drawTextRunat Parley’s origins (outline) and feedsfontWeightAdjustmentand 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 (branchtext-settings): done, checked bytests/screens/settings.mjsagainst stock text views (nowtests/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 todrawGlyphs. 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 thewghtUIKit resolves each weight to (fontique per-family weight maps), and Android addsfontWeightAdjustmentto 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. - Driver with Parley behind it, and templates on native measurement.
Move Parley behind
TextEngine/TextLayoutwith 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. - 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_enginesetting. Tests run the same pages through both engines and compare line boxes within 1 px. - Per block.
--niso-text-engineread at the IFC root, plus coexistence tests (baselines, selection across blocks, find-in-page). - Close gaps as apps need them: per-line widths for floats and more CSS features in the native engines.