Skip to content

Commit 137f838

Browse files
clinteckerclaude
andcommitted
docs/notes: Android support plan (android.md) + doccard
Records the design for first-class Android: highest fidelity by drawing a COMPLETE render-scene natively with android.graphics.Canvas (Skia — real fonts, DPI-correct, Material-themeable) rather than shipping a foreign-font bitmap; highest utility via Compose/View wrappers, narration→contentDescription, and node tap callbacks; zero-hassle bridge via a one-line Maven Central .aar bundling prebuilt per-ABI .so. Linchpin is a complete RenderScene shared with SVG (#15) and the plugin contract (#14). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JoLDcosyaHg3tAKhU5SQaw
1 parent 92b9b6b commit 137f838

1 file changed

Lines changed: 146 additions & 0 deletions

File tree

docs/notes/android.md

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# MermaidKit on Android: fidelity, utility, and zero-hassle bridges
2+
3+
Design memo · the plan for first-class Android support. Not yet implemented;
4+
this records the options, the reasoning, and what we decided so we build the
5+
right thing.
6+
7+
## The goal
8+
9+
Three things, together — not a cheap port:
10+
11+
1. **Highest fidelity** on Android.
12+
2. **Highest utility** for Android developers.
13+
3. **Zero-hassle bridges** — they snap it into their app with no NDK, no Swift,
14+
no JNI, no friction.
15+
16+
## What "fidelity" means on Android
17+
18+
Crucially, it does **not** mean shipping macOS's exact pixels. It means drawing
19+
with **Android's own 2D stack (`android.graphics.Canvas`/`Paint`, i.e. Skia) and
20+
the device's real fonts** — so output is crisp at every DPI, matches the system,
21+
and themes with Material. A pre-rasterized bitmap produced from a foreign font
22+
stack (Cairo/FreeType with bundled fonts) is the *opposite* of that: fixed
23+
resolution, wrong fonts, no Material theming. So the naive "easy" paths are out.
24+
25+
## The architecture we chose
26+
27+
**Swift does the intellectual work and returns a complete scene; Kotlin draws it
28+
natively.**
29+
30+
Swift core (.so, built once via NDK) Kotlin/Compose (the .aar devs consume)
31+
───────────────────────────────────── ────────────────────────────────────────
32+
parse → IR → layout → lint → RenderScene ─JSON─▶ SceneRenderer draws with Canvas/Paint
33+
▲ measurement callback ◀─────────────── (Skia: native quality, real fonts, DPI-correct)
34+
35+
- The Swift side is the already-platform-free layer (parse, layout, lint). It
36+
never touches pixels on Android — it emits a **complete `RenderScene`**.
37+
- The Kotlin side draws that scene with `Canvas`/`Paint`. This is a "backend"
38+
exactly like the Apple (CoreGraphics), Linux (Silica/Cairo), and terminal
39+
backends — it just lives on the Kotlin side of the bridge.
40+
- **Layout measures against the fonts that actually draw** (see Measurement),
41+
so nothing clips and the geometry linter stays quiet.
42+
43+
## Options considered, and why
44+
45+
| Option | Fidelity | Utility / snap-in | Verdict |
46+
| --- | --- | --- | --- |
47+
| **SVG → WebView** | Medium (SVG renderer quirks; measure/render mismatch) | Poor — a WebView is not idiomatic, heavy, clunky | Rejected as the product path (SVG is still valuable as an *export* + reference, see below) |
48+
| **Cairo/Silica raster → Bitmap** | Medium — foreign font stack; **Android has no FontConfig** (fonts live in `/system/fonts`), so font discovery doesn't transfer; fixed-resolution bitmap | Simple bridge (bytes→Bitmap), single draw impl | Rejected — re-inherits the font problem, not DPI-crisp, not Material |
49+
| **Swift rasterizes → Bitmap** (pure-Swift or FreeType) | Medium — same font/DPI limits | Simple bridge | Rejected for the same reasons |
50+
| **Swift `RenderScene` → Kotlin draws with Canvas** | **Highest** — native Skia AA, real fonts, DPI-correct, Material-themeable | **Highest** — idiomatic Compose/View | **Chosen** |
51+
52+
The cost of the chosen path is a **second draw implementation** (Kotlin Canvas,
53+
alongside Swift's). We accept it because:
54+
- It's the only path that hits *both* fidelity and idiomatic utility.
55+
- The **draw-vs-scene conformance ratchet already exists** to keep the scene a
56+
faithful, complete description of the picture — so "draw the scene" in Kotlin
57+
and "draw the scene" in Swift stay in sync *by construction*, not by vigilance.
58+
59+
## The linchpin: a complete `RenderScene`
60+
61+
The one real blocker. Today's `DiagramScene` is **lossy** — it was built for the
62+
linter, so it carries frames, polylines, and label frames but **drops shape and
63+
color** (confirmed by the export-substrate research). A Kotlin renderer can't
64+
draw shapes it can't see.
65+
66+
So the enabling step is a **complete `RenderScene`**: frames + shape +
67+
fill/stroke/dash + arrowheads + text-with-font — a description that *fully
68+
determines the picture*. This is not Android-only work:
69+
70+
- **SVG export (issue #15) needs the exact same complete scene.**
71+
- It **is** the plugin/backend JSON contract (issue #14).
72+
73+
So this single piece unlocks Android, SVG, and the plugin ecosystem at once. Build
74+
it in Swift first — provable *now*, no NDK required — with a **reference SVG
75+
backend** as the proof that the scene fully determines the drawing (SVG is easy
76+
to diff and read, and it's a shippable export in its own right).
77+
78+
## The snap-in surface (zero friction for the Android dev)
79+
80+
- **Compose-first + classic View**, mirroring the Apple `MermaidView`:
81+
```kotlin
82+
MermaidDiagram(source = mmd, theme = MermaidTheme.fromMaterial(), modifier = Modifier.fillMaxWidth())
83+
```
84+
- **One Gradle line**, nothing else:
85+
```kotlin
86+
implementation("ai.2389:mermaidkit-android:1.x")
87+
```
88+
from Maven Central. The `.aar` bundles **prebuilt `.so` for every ABI**
89+
(arm64-v8a, armeabi-v7a, x86_64). The consumer never sees the NDK, Swift, or
90+
JNI — *we* eat the cross-compile so they don't. That is the whole "no
91+
frustration" requirement.
92+
93+
## Utility wins (an interactive diagram, not a static image)
94+
95+
- **Accessibility for free** — wire Android `contentDescription` straight from
96+
`MermaidAltText.narrate` (the step-by-step walkthrough already shipped). Instant
97+
differentiator.
98+
- **Tap callbacks** — the scene carries every node's frame, so hit-testing gives
99+
`onNodeClick(nodeId)` with no extra layout work.
100+
- **Material theming** (`DiagramTheme``MaterialTheme` colors), light/dark,
101+
DPI-aware, and export to PNG/SVG/PDF from Kotlin.
102+
103+
## Measurement (the subtle fidelity detail)
104+
105+
Layout is only correct if it measures with the fonts that draw. Two clean ways;
106+
pick during step 3:
107+
- **Bundled Roboto + FreeType (Swift-side):** ship Android's default font, measure
108+
it with FreeType, draw it with Canvas — fast (no JNI per label), high fidelity
109+
as long as we control the font. Simplest.
110+
- **JNI measurement callback:** Swift layout calls back into Kotlin
111+
`Paint.measureText`, **batched over one JNI hop** (all label strings+sizes in,
112+
all metrics out) and memoized (measurement is already memoized in the perf
113+
work). Highest fidelity when the app wants *its own* font.
114+
115+
## Obstacles, and how the decision handles each
116+
117+
- **No CoreGraphics on Android** → we don't need it; Kotlin draws with Canvas.
118+
- **No FontConfig on Android** → irrelevant; native `Paint` handles fonts.
119+
- **Swift↔Kotlin bridge** → tiny **C ABI** (`source + options + measure cb →
120+
scene bytes`, plus diagnostics), not the rich Swift API; hidden inside the AAR.
121+
- **Toolchain friction** → borne once, in *our* CI (NDK + Swift Android SDK);
122+
invisible to consumers.
123+
- **`MermaidView` (SwiftUI)** → doesn't port; Android gets the headless render +
124+
its own Compose/View wrappers.
125+
126+
## Staged plan
127+
128+
1. **Complete `RenderScene` + a reference SVG backend** (pure Swift, provable now;
129+
guarded by the draw-vs-scene ratchet). ← the linchpin; shared with #15/#14.
130+
2. **C ABI + NDK build** of the Swift core → per-ABI `.so`, with the measurement
131+
callback.
132+
3. **Kotlin `SceneRenderer`** (Canvas) + Compose/View wrappers + Material theming
133+
+ narration→`contentDescription` + tap callbacks.
134+
4. **Maven Central AAR** + a Gradle sample app; CI on an emulator.
135+
136+
## Open questions (decide as we go)
137+
138+
- Measurement: bundled-font vs JNI-callback (step 3) — likely start bundled, add
139+
callback as an opt-in.
140+
- Serialization: JSON (readable, `Codable` today) vs a tighter binary/FlatBuffer
141+
if per-frame scene transfer ever gets hot.
142+
- Swift-Java interop vs a hand-rolled C ABI as that ecosystem matures.
143+
144+
Related: `ir-compilation-targets.md`, `plugin-extensibility.md`, and issues #14
145+
(runtime plugins) and #15 (export backends — the SVG that doubles as the Android
146+
scene proof).

0 commit comments

Comments
 (0)