|
| 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