react-native-jet-markdown 0.2.2 → 0.2.3-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
11
11
  s.authors = package["author"]
12
12
 
13
13
  s.platforms = { :ios => min_ios_version_supported }
14
- s.source = { :git => "https://github.com/alizahid/react-native-jet-markdown.git", :tag => "#{s.version}" }
14
+ s.source = { :git => "https://github.com/alizahid/react-native-jet-markdown.git", :tag => "v#{s.version}" }
15
15
 
16
16
  # Headers are deliberately NOT in source_files. Xcode's project-wide
17
17
  # header maps index every pod target's headers BY BASENAME, so adding
package/README.md CHANGED
@@ -33,6 +33,8 @@ cd ios && pod install
33
33
 
34
34
  Requires React Native 0.86+ with the New Architecture (Fabric).
35
35
 
36
+ > **Android support is experimental.** The Android renderer implements the same feature set as iOS, but it has had far less real-world use. Expect rough edges and please [report](https://github.com/alizahid/react-native-jet-markdown/issues) anything that renders differently from iOS.
37
+
36
38
  ## Usage
37
39
 
38
40
  ```tsx
@@ -71,20 +73,29 @@ const styles: MarkdownStyles = mergeStyles({
71
73
  | Prop | Type | Description |
72
74
  | --- | --- | --- |
73
75
  | `markdown` | `string` | The markdown source. |
74
- | `style` | `MarkdownContainerStyle` | Main container style: `backgroundColor`, `padding`/`padding{Left,Right,Top,Bottom}`, `gap` (spacing between blocks), plus base text styles (`fontSize`, `fontWeight`, `fontFamily`, `color`, `fontVariant`, `textDecoration*`) that cascade into every text element unless overridden per-element via `styles`. Element builtins survive the cascade: heading sizes/weight stay unless `headings.hN` overrides, and code blocks keep their monospace font unless `codeBlock` overrides. For outer layout (margin, width, flex), wrap the viewer in a `View`. |
75
- | `styles` | `MarkdownStyles` | Per-element styles (below). Omitted = fully plain output (no colors, boxes, heading sizes — only bold/italic runs, monospace code, list markers, and the spoiler cover). Pass the exported `defaultStyles` for the classic look, or `mergeStyles(overrides)` for defaults + your changes. Hoist to module scope or memoize. |
76
- | `images` | `{ url, width, height }[]` | Pre-sizing data. Listed images lay out at their final size immediately zero layout shift. Unlisted images render a styled full-width, 200pt-tall placeholder, then snap to their real aspect once loaded. Loading runs on SDWebImage (iOS) and Glide (Android) — the same cores expo-image uses — with memory + disk caches, request dedupe, and animated GIF playback (plus APNG on iOS). |
76
+ | `style` | `MarkdownContainerStyle` | Container box (`backgroundColor`, `padding*`, `gap`) plus base text styles that cascade into every element. See [Container style](#container-style). |
77
+ | `styles` | `MarkdownStyles` | Per-element styles. See [Styling](#styling). Hoist to module scope or memoize. |
78
+ | `images` | `{ url, width, height }[]` | Pre-sizing data. Listed images lay out at their final size immediately (zero layout shift); unlisted ones render a full-width, 200pt-tall placeholder and snap to their real aspect once loaded. |
79
+ | `allowFontScaling` | `boolean` | Scale all text, including `lineHeight`, with the system font size setting. Default `true`. |
77
80
  | `onLinkPress` | `({ url }) => void` | Link or mention tapped. Mentions arrive with their scheme (e.g. `users://ali`). |
78
81
  | `onLinkLongPress` | `({ url }) => void` | Link long-pressed. |
79
82
  | `onImagePress` | `({ url, x, y, width, height }) => void` | Image tapped. `width`/`height` are the rendered size and `x`/`y` the position relative to the screen (dp) — everything a lightbox needs for a zoom-from-thumbnail transition. |
80
83
 
81
84
  ## Styling
82
85
 
83
- The viewer ships **unstyled by default**. Two exports cover the common cases:
86
+ The viewer ships **unstyled by default**: with no `styles` prop the output is plain text (only bold/italic runs, monospace code, list markers, and the spoiler cover survive). Two exports cover the common cases:
84
87
 
85
88
  - `defaultStyles` — a plain `MarkdownStyles` object with the classic markdown look (heading scale, blue links, code boxes, quote bar, table separators). It's just data: spread it, fork it, or use it as a reference.
86
89
  - `mergeStyles(overrides)` — deep-merges your overrides into `defaultStyles` (element sections merge key-by-key, heading levels individually).
87
90
 
91
+ ### Container style
92
+
93
+ The `style` prop styles the container itself: `backgroundColor`, `padding` (+ `paddingHorizontal`/`paddingVertical` and per-side), and `gap` (spacing between blocks; wins over `styles.gap`). Text keys on it (`fontSize`, `fontWeight`, `fontFamily`, `color`, `lineHeight`, `fontVariant`, `textDecoration*`) are the base of the cascade: every element inherits them unless its own section overrides. Element builtins survive the cascade — heading sizes and weights stay unless `headings.hN` overrides, and code keeps its monospace font unless `codeBlock` overrides.
94
+
95
+ All of these are measured natively. For outer layout (margin, width, flex), wrap the viewer in a `View`.
96
+
97
+ ### Element styles
98
+
88
99
  Two shared shapes compose every element style:
89
100
 
90
101
  **Text** — `fontSize`, `fontWeight`, `fontFamily`, `color`, `backgroundColor` (run highlight), `fontVariant`, `lineHeight`, `textDecorationColor`, `textDecorationLine`, `textDecorationStyle`
@@ -125,6 +136,10 @@ Each column gets its natural (unwrapped) width, clamped to `[minColumnWidth, max
125
136
 
126
137
  Mentions are plain markdown links with custom schemes — `[@ali](users://ali)`, `[#general](channels://general)` — classified by the `mention.variants` regexes and styled accordingly. Presses arrive through `onLinkPress`; branch on the URL scheme.
127
138
 
139
+ ### Images
140
+
141
+ A paragraph containing only an image renders as a block image, aspect-fit to the container width. Pass `images` to pre-size known images and avoid layout shift. Loading runs on SDWebImage (iOS) and Glide (Android) — the same cores expo-image uses — with memory + disk caches, request dedupe, and animated GIF playback (plus APNG on iOS).
142
+
128
143
  ## Using in lists
129
144
 
130
145
  Rendering hundreds of viewers in FlatList / FlashList / LegendList is a first-class use case:
@@ -161,37 +176,23 @@ You may notice styles ship natively as one `stylesJson` string instead of a stru
161
176
  ## Platform notes & limitations
162
177
 
163
178
  - New Architecture (Fabric) only; React Native 0.86+.
179
+ - Android support is experimental; iOS is the reference implementation.
164
180
  - `textDecorationStyle`/`textDecorationColor` render fully on iOS; Android draws plain underline/strikethrough.
165
181
  - `borderCurve: 'continuous'` is iOS-only.
166
182
  - `fontVariant` supports `tabular-nums`, `proportional-nums`, `oldstyle-nums`, `lining-nums`, `small-caps` (font support required).
167
- - `allowFontScaling` (default `true`) scales all text — including `lineHeight` — with the system font size setting.
168
183
  - Spoilers and inline formatting inside link labels stay literal; spoilers inside table cells are unsupported (the `|` delimiter conflicts).
169
184
  - Code blocks render plain monospace (no syntax highlighting).
185
+ - The viewer fills the width it is given and sizes its own height. Under an unconstrained width (a horizontal `ScrollView`, `alignSelf: 'flex-start'`) it has nothing to wrap against and renders empty — give it a definite width.
170
186
 
171
187
  ## Sponsors
172
188
 
173
- Development of react-native-jet-markdown is supported by:
174
-
175
- <table>
176
- <tr>
177
- <td align="center" width="200">
178
- <a href="https://duet.so">
179
- <img alt="Duet" src="https://github.com/alizahid/react-native-jet-markdown/blob/main/.github/duet.png?raw=true" width="96"><br>
180
- <b>Duet</b>
181
- </a>
182
- <br>
183
- <sub>Your AI coworker that runs your business 24/7</sub>
184
- </td>
185
- <td align="center" width="200">
186
- <a href="https://acorn.blue">
187
- <img alt="Acorn" src="https://github.com/alizahid/react-native-jet-markdown/blob/main/.github/acorn.png?raw=true" width="96"><br>
188
- <b>Acorn</b>
189
- </a>
190
- <br>
191
- <sub>Reddit for mobile</sub>
192
- </td>
193
- </tr>
194
- </table>
189
+ <p align="center">
190
+ <a href="https://acorn.blue"><img alt="Acorn" src="https://acorn.blue/images/acorn.png" width="96"></a>
191
+ </p>
192
+
193
+ <p align="center">
194
+ Built for and sponsored by <a href="https://acorn.blue">Acorn</a>, a Reddit client for iOS.
195
+ </p>
195
196
 
196
197
  ## License
197
198
 
@@ -10,6 +10,7 @@ import com.facebook.react.uimanager.StateWrapper
10
10
  import com.facebook.react.uimanager.UIManagerHelper
11
11
  import com.facebook.react.uimanager.events.Event
12
12
  import com.jetmarkdown.render.ContentCache
13
+ import com.jetmarkdown.render.RenderedContent
13
14
  import com.jetmarkdown.style.StyleConfig
14
15
  import com.jetmarkdown.views.BlockStackView
15
16
  import com.jetmarkdown.views.MarkdownHost
@@ -27,12 +28,14 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
27
28
  set(value) {
28
29
  if (field != value) {
29
30
  field = value
30
- boundKey = null
31
+ boundLayout = null
31
32
  requestLayout()
32
33
  }
33
34
  }
34
- private var boundKey: List<Any>? = null
35
- private var boundWidth: Int = 0
35
+ // Layouts are cached per (content, width, image sizes), and content per
36
+ // (markdown, styles, font scale, appearance): identity says whether the
37
+ // bound view tree is still current.
38
+ private var boundLayout: RenderedContent.WidthLayout? = null
36
39
  private val stack = BlockStackView(context)
37
40
 
38
41
  /** url -> [w, h] dp: from the images prop (wins) and loaded bitmaps. */
@@ -53,8 +56,7 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
53
56
  stylesJson = ""
54
57
  allowFontScaling = true
55
58
  stateWrapper = null
56
- boundKey = null
57
- boundWidth = 0
59
+ boundLayout = null
58
60
  propImageSizes.clear()
59
61
  loadedImageSizes.clear()
60
62
  revealedSpoilers.clear()
@@ -200,9 +202,9 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
200
202
  val paddingRightPx = (styles.paddingRight * density).toInt()
201
203
  val paddingTopPx = (styles.paddingTop * density).toInt()
202
204
  val contentWidthPx = (r - l) - paddingLeftPx - paddingRightPx
203
- if (contentWidthPx <= 0 || markdown.isEmpty()) {
205
+ if (contentWidthPx <= 0) {
204
206
  stack.setBlocks(emptyList(), 0f)
205
- boundKey = null
207
+ boundLayout = null
206
208
  return
207
209
  }
208
210
 
@@ -210,10 +212,8 @@ class JetMarkdownView(context: Context) : ViewGroup(context), MarkdownHost {
210
212
  val imageSizes = mergedImageSizes()
211
213
  val layout = content.layoutFor(contentWidthPx, imageSizes)
212
214
 
213
- val key = listOf(markdown, stylesJson, imageSizes.hashCode(), fontScale)
214
- if (boundKey != key || boundWidth != contentWidthPx) {
215
- boundKey = key
216
- boundWidth = contentWidthPx
215
+ if (layout !== boundLayout) {
216
+ boundLayout = layout
217
217
  stack.setBlocks(layout.measured, content.gap)
218
218
  }
219
219
 
@@ -17,45 +17,63 @@ class BlockStackView(context: Context) : ViewGroup(context) {
17
17
 
18
18
  var host: MarkdownHost? = null
19
19
 
20
- private fun unbindImages(group: android.view.ViewGroup) {
21
- for (index in 0 until group.childCount) {
22
- when (val child = group.getChildAt(index)) {
23
- is MarkdownImageView -> child.unbind()
24
- is android.view.ViewGroup -> unbindImages(child)
25
- }
20
+ // Discard hook for views dropped for good: stop in-flight image requests
21
+ // from delivering into a recycled host.
22
+ private fun unbindImages(view: View) {
23
+ when (view) {
24
+ is MarkdownImageView -> view.unbind()
25
+ is ViewGroup -> for (index in 0 until view.childCount) unbindImages(view.getChildAt(index))
26
26
  }
27
27
  }
28
28
 
29
+ /**
30
+ * Rebinds in place: a child of the right kind at the same index is reused,
31
+ * anything else is replaced. List recycling rebinds whole trees, so the
32
+ * common case (same block kinds, different content) allocates nothing.
33
+ */
29
34
  fun setBlocks(blocks: List<MeasuredBlock>, gap: Float) {
30
- unbindImages(this)
31
35
  measured = blocks
32
36
  gapPx = gap
33
- removeAllViews()
34
- for (block in blocks) {
35
- addView(createView(block))
37
+ blocks.forEachIndexed { index, block ->
38
+ val old = getChildAt(index)
39
+ val view = bindView(old, block)
40
+ if (view !== old) {
41
+ if (old != null) {
42
+ unbindImages(old)
43
+ removeViewAt(index)
44
+ }
45
+ addView(view, index)
46
+ }
47
+ }
48
+ while (childCount > blocks.size) {
49
+ val extra = getChildAt(childCount - 1)
50
+ unbindImages(extra)
51
+ removeViewAt(childCount - 1)
36
52
  }
37
53
  requestLayout()
38
54
  }
39
55
 
40
- private fun createView(measuredBlock: MeasuredBlock): View {
56
+ private fun bindView(old: View?, measuredBlock: MeasuredBlock): View {
41
57
  return when (val block = measuredBlock.block) {
42
- is Block.Text -> BlockTextView(context).apply {
58
+ is Block.Text -> (old as? BlockTextView ?: BlockTextView(context)).apply {
43
59
  this.host = this@BlockStackView.host
44
60
  setBlock(block)
45
61
  measuredBlock.textLayout?.let(::setTextLayout)
46
62
  }
47
- is Block.Code -> CodeBlockView(context).apply { bind(measuredBlock, block) }
48
- is Block.Quote -> QuoteView(context).apply {
63
+ is Block.Code -> (old as? CodeBlockView ?: CodeBlockView(context)).apply {
64
+ bind(measuredBlock, block)
65
+ }
66
+ is Block.Quote -> (old as? QuoteView ?: QuoteView(context)).apply {
49
67
  bind(measuredBlock, block, gapPx, this@BlockStackView.host)
50
68
  }
51
- is Block.ListBlock -> ListBlockView(context).apply {
69
+ is Block.ListBlock -> (old as? ListBlockView ?: ListBlockView(context)).apply {
52
70
  bind(measuredBlock, block, gapPx, this@BlockStackView.host)
53
71
  }
54
- is Block.Divider -> DividerView(context).apply { color = block.color }
55
- is Block.Table -> TableBlockView(context).apply {
72
+ is Block.Divider -> (old as? DividerView ?: DividerView(context)).apply { color = block.color }
73
+ is Block.Table -> (old as? TableBlockView ?: TableBlockView(context)).apply {
56
74
  bind(measuredBlock, block, this@BlockStackView.host)
57
75
  }
58
- is Block.Image -> MarkdownImageView(context).apply {
76
+ is Block.Image -> (old as? MarkdownImageView ?: MarkdownImageView(context)).apply {
59
77
  this.host = this@BlockStackView.host
60
78
  bind(block)
61
79
  }
@@ -228,13 +246,22 @@ class ListBlockView(context: Context) : ViewGroup(context) {
228
246
  measured = measuredBlock
229
247
  block = list
230
248
  gapPx = gap
231
- removeAllViews()
249
+ // Children are (marker, content) pairs; grow/shrink the pair list, then
250
+ // rebind every pair in place.
251
+ val needed = measuredBlock.markerLayouts.size * 2
252
+ while (childCount > needed) {
253
+ removeViewAt(childCount - 1)
254
+ }
255
+ while (childCount < needed) {
256
+ addView(BlockTextView(context))
257
+ addView(BlockStackView(context))
258
+ }
232
259
  measuredBlock.markerLayouts.forEachIndexed { index, marker ->
233
- addView(BlockTextView(context).apply { setTextLayout(marker) })
234
- addView(BlockStackView(context).apply {
260
+ (getChildAt(index * 2) as BlockTextView).setTextLayout(marker)
261
+ (getChildAt(index * 2 + 1) as BlockStackView).apply {
235
262
  this.host = host
236
263
  setBlocks(measuredBlock.rowContents[index], gap)
237
- })
264
+ }
238
265
  }
239
266
  requestLayout()
240
267
  }
@@ -54,6 +54,8 @@ class BlockTextView(context: Context) : View(context) {
54
54
 
55
55
  fun setBlock(block: Block.Text) {
56
56
  textBlock = block
57
+ // Spoiler styling may change without the layout changing.
58
+ invalidate()
57
59
  }
58
60
 
59
61
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
@@ -74,13 +74,21 @@ class TableGridView(context: Context) : ViewGroup(context) {
74
74
  fun bind(measuredBlock: MeasuredBlock, table: Block.Table, host: MarkdownHost? = null) {
75
75
  measured = measuredBlock
76
76
  block = table
77
- removeAllViews()
77
+ // One text view per cell, row-major; grow/shrink then rebind in place.
78
+ val needed = measuredBlock.cellLayouts.sumOf { it.size }
79
+ while (childCount > needed) {
80
+ removeViewAt(childCount - 1)
81
+ }
82
+ while (childCount < needed) {
83
+ addView(BlockTextView(context))
84
+ }
85
+ var index = 0
78
86
  for (row in measuredBlock.cellLayouts) {
79
87
  for (cell in row) {
80
- addView(BlockTextView(context).apply {
88
+ (getChildAt(index++) as BlockTextView).apply {
81
89
  this.host = host
82
90
  setTextLayout(cell)
83
- })
91
+ }
84
92
  }
85
93
  }
86
94
  invalidate()
@@ -278,8 +278,8 @@ void superscriptPass(Node* parent, AstArena& arena) {
278
278
  }
279
279
 
280
280
  void process(Node* node, AstArena& arena) {
281
- if (node->type == NodeType::Link || node->type == NodeType::CodeBlock ||
282
- node->type == NodeType::InlineCode || node->type == NodeType::Image) {
281
+ if (node->type == NodeType::CodeBlock || node->type == NodeType::InlineCode ||
282
+ node->type == NodeType::Image) {
283
283
  return;
284
284
  }
285
285
  if (!node->children.empty()) {
@@ -319,6 +319,11 @@ int onEnterSpan(MD_SPANTYPE type, void* detail, void* userdata) {
319
319
  auto* d = static_cast<MD_SPAN_A_DETAIL*>(detail);
320
320
  Node* node = state->push(NodeType::Link);
321
321
  node->url = attributeToString(d->href);
322
+ // md4c (patched) lets Reddit-style spaces through in destinations;
323
+ // encode them so the URL is openable as-is.
324
+ for (size_t i = 0; (i = node->url.find(' ', i)) != std::string::npos; i += 3) {
325
+ node->url.replace(i, 1, "%20");
326
+ }
322
327
  break;
323
328
  }
324
329
  case MD_SPAN_IMG: {
@@ -443,7 +448,8 @@ std::unique_ptr<MarkdownDocument> parseMarkdown(const std::string& markdown) {
443
448
 
444
449
  MD_PARSER parser = {};
445
450
  parser.abi_version = 0;
446
- parser.flags = MD_FLAG_TABLES | MD_FLAG_PERMISSIVEAUTOLINKS | MD_FLAG_NOHTML;
451
+ parser.flags = MD_FLAG_TABLES | MD_FLAG_PERMISSIVEAUTOLINKS | MD_FLAG_NOHTML |
452
+ MD_FLAG_PERMISSIVEATXHEADERS; // Reddit accepts "###Heading"
447
453
  parser.enter_block = onEnterBlock;
448
454
  parser.leave_block = onLeaveBlock;
449
455
  parser.enter_span = onEnterSpan;
package/cpp/md4c/md4c.c CHANGED
@@ -2055,9 +2055,22 @@ md_is_link_destination_B(MD_CTX* ctx, OFF beg, OFF max_end, OFF* p_end,
2055
2055
  continue;
2056
2056
  }
2057
2057
 
2058
- if(ISWHITESPACE(off) || ISCNTRL(off))
2058
+ if(ISCNTRL(off))
2059
2059
  break;
2060
2060
 
2061
+ /* JETMARKDOWN PATCH: Reddit (snudown) allows spaces inside link
2062
+ * destinations. Whitespace ends the destination only when what
2063
+ * follows is a title opener, the closing ')' or the end. */
2064
+ if(ISWHITESPACE(off)) {
2065
+ OFF tmp = off;
2066
+ while(tmp < max_end && ISWHITESPACE(tmp))
2067
+ tmp++;
2068
+ if(tmp >= max_end || ISANYOF(tmp, _T("\"')")))
2069
+ break;
2070
+ off = tmp;
2071
+ continue;
2072
+ }
2073
+
2061
2074
  /* Link destination may include balanced pairs of unescaped '(' ')'.
2062
2075
  * Note we limit the maximal nesting level by 32 to protect us from
2063
2076
  * https://github.com/jgm/cmark/issues/214 */
@@ -41,8 +41,9 @@ static BOOL JMDIsScrollInProgress(UIView *view);
41
41
  NSString *_stylesJson;
42
42
  BOOL _allowFontScaling;
43
43
  JMDBlockStackView *_stack;
44
- NSString *_boundKey;
45
- CGFloat _boundWidth;
44
+ // Layout objects are cached per (content, width, image sizes, font
45
+ // scale), so identity says whether the bound view tree is still current.
46
+ JMDWidthLayout *_boundLayout;
46
47
  // url -> @[w, h] points: from the images prop (wins) and loaded bitmaps.
47
48
  NSMutableDictionary<NSString *, NSArray<NSNumber *> *> *_propImageSizes;
48
49
  NSMutableDictionary<NSString *, NSArray<NSNumber *> *> *_loadedImageSizes;
@@ -346,7 +347,7 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
346
347
  // Dynamic (platform) colors resolve at draw time for text, but border
347
348
  // and background colors snapshot into CGColors when blocks bind; rebind
348
349
  // so they re-resolve under the new appearance.
349
- _boundKey = nil;
350
+ _boundLayout = nil;
350
351
  [self setNeedsLayout];
351
352
  JMDSetNeedsDisplayDeep(self);
352
353
  }
@@ -449,7 +450,7 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
449
450
  [super prepareForRecycle];
450
451
  _markdown = @"";
451
452
  _stylesJson = @"";
452
- _boundKey = nil;
453
+ _boundLayout = nil;
453
454
  _state = nullptr;
454
455
  [_propImageSizes removeAllObjects];
455
456
  [_loadedImageSizes removeAllObjects];
@@ -463,9 +464,9 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
463
464
 
464
465
  const CGFloat contentWidth =
465
466
  self.bounds.size.width - styles.paddingLeft - styles.paddingRight;
466
- if (contentWidth <= 0 || _markdown.length == 0) {
467
+ if (contentWidth <= 0) {
467
468
  [_stack setBlocks:@[] gap:0];
468
- _boundKey = nil;
469
+ _boundLayout = nil;
469
470
  return;
470
471
  }
471
472
 
@@ -477,16 +478,8 @@ static void JMDSetNeedsDisplayDeep(UIView *view) {
477
478
  NSDictionary *imageSizes = [self mergedImageSizes];
478
479
  JMDWidthLayout *layout = [content layoutForWidth:contentWidth imageSizes:imageSizes];
479
480
 
480
- // Full contents, not hashes: CFString samples long strings and
481
- // NSDictionary.hash is the entry count — both collide.
482
- NSString *key = [NSString stringWithFormat:@"%@\x1f%@\x1f%@\x1f%.3f",
483
- _markdown,
484
- _stylesJson,
485
- [JMDRenderedContent keyForImageSizes:imageSizes],
486
- fontScale];
487
- if (![key isEqualToString:_boundKey] || _boundWidth != contentWidth) {
488
- _boundKey = key;
489
- _boundWidth = contentWidth;
481
+ if (layout != _boundLayout) {
482
+ _boundLayout = layout;
490
483
  [_stack setBlocks:layout.measured gap:content.gap];
491
484
  }
492
485
 
@@ -12,7 +12,7 @@
12
12
  fontScale:(CGFloat)fontScale {
13
13
  JMDStyleConfig *styles = [JMDStyleConfig configWithJson:stylesJson];
14
14
  const CGFloat contentWidth = maxWidth - styles.paddingLeft - styles.paddingRight;
15
- if (contentWidth <= 0 || markdown.length == 0) {
15
+ if (contentWidth <= 0) {
16
16
  return 0;
17
17
  }
18
18
 
@@ -92,6 +92,13 @@ FOUNDATION_EXPORT NSAttributedStringKey const JMDRunBackgroundAttributeName;
92
92
  /// Tables: resolved column widths and per-row heights.
93
93
  @property (nonatomic, strong) NSArray<NSNumber *> *columnWidths;
94
94
  @property (nonatomic, strong) NSArray<NSNumber *> *rowHeights;
95
+ /// TextKit stacks built at measure time (storage owns the layout manager
96
+ /// and container); the views draw with these, so measure and draw never
97
+ /// disagree and text is laid out once. Text/Code: textStorage. Lists:
98
+ /// one per row. Tables: row-major cells.
99
+ @property (nonatomic, strong, nullable) NSTextStorage *textStorage;
100
+ @property (nonatomic, strong) NSArray<NSTextStorage *> *markerStorages;
101
+ @property (nonatomic, strong) NSArray<NSArray<NSTextStorage *> *> *cellStorages;
95
102
  @end
96
103
 
97
104
  NS_ASSUME_NONNULL_END
@@ -21,6 +21,8 @@
21
21
  _rowContents = @[];
22
22
  _columnWidths = @[];
23
23
  _rowHeights = @[];
24
+ _markerStorages = @[];
25
+ _cellStorages = @[];
24
26
  }
25
27
  return self;
26
28
  }
@@ -186,7 +186,7 @@ NSArray<NSDictionary *> *featureSettings(NSArray<NSString *> *variants) {
186
186
  return features;
187
187
  }
188
188
 
189
- UIFont *buildFont(const ResolvedAttrs &attrs) {
189
+ UIFont *buildFontUncached(const ResolvedAttrs &attrs) {
190
190
  UIFont *base;
191
191
  if (attrs.family.length > 0) {
192
192
  UIFontDescriptor *descriptor = [UIFontDescriptor fontDescriptorWithFontAttributes:@{
@@ -228,6 +228,32 @@ UIFont *buildFont(const ResolvedAttrs &attrs) {
228
228
  return font ?: base;
229
229
  }
230
230
 
231
+ // Descriptor matching (family + weight trait, especially for custom
232
+ // families) costs real time and runs once per text run otherwise. UIFont is
233
+ // immutable, NSCache is thread-safe: the layout thread and main thread share
234
+ // one cache.
235
+ UIFont *buildFont(const ResolvedAttrs &attrs) {
236
+ static NSCache<NSString *, UIFont *> *cache;
237
+ static dispatch_once_t onceToken;
238
+ dispatch_once(&onceToken, ^{
239
+ cache = [NSCache new];
240
+ cache.countLimit = 256;
241
+ });
242
+ NSString *key = [NSString stringWithFormat:@"%@\x1f%.2f\x1f%ld\x1f%d\x1f%@",
243
+ attrs.family ?: @"",
244
+ attrs.fontSize,
245
+ (long)attrs.weight,
246
+ attrs.italic ? 1 : 0,
247
+ [attrs.variants componentsJoinedByString:@","] ?: @""];
248
+ UIFont *cached = [cache objectForKey:key];
249
+ if (cached != nil) {
250
+ return cached;
251
+ }
252
+ UIFont *font = buildFontUncached(attrs);
253
+ [cache setObject:font forKey:key];
254
+ return font;
255
+ }
256
+
231
257
  NSUnderlineStyle decorationMask(NSString *style) {
232
258
  if ([style isEqualToString:@"double"]) {
233
259
  return NSUnderlineStyleDouble;
@@ -84,12 +84,13 @@
84
84
  return layout;
85
85
  }
86
86
 
87
- - (CGSize)textSize:(NSAttributedString *)text width:(CGFloat)width {
88
- // Measured with TextKit, not NSStringDrawing: boundingRect stops at the
89
- // last line's drawn descent, dropping the bottom of the final line box
90
- // under a custom lineHeight. The full box keeps overlays (spoiler covers,
91
- // run background chips) from being clipped at the view's bottom edge and
92
- // matches how RN's <Text> and the Android renderer measure.
87
+ // Lays text out with TextKit, not NSStringDrawing: boundingRect stops at
88
+ // the last line's drawn descent, dropping the bottom of the final line box
89
+ // under a custom lineHeight. The full box keeps overlays (spoiler covers,
90
+ // run background chips) from being clipped at the view's bottom edge and
91
+ // matches how RN's <Text> and the Android renderer measure. The returned
92
+ // storage owns the layout manager; views draw with the same stack.
93
+ - (NSTextStorage *)layoutText:(NSAttributedString *)text width:(CGFloat)width {
93
94
  NSTextStorage *storage = [[NSTextStorage alloc] initWithAttributedString:text];
94
95
  NSLayoutManager *layoutManager = [NSLayoutManager new];
95
96
  NSTextContainer *container = [[NSTextContainer alloc]
@@ -98,10 +99,20 @@
98
99
  [layoutManager addTextContainer:container];
99
100
  [storage addLayoutManager:layoutManager];
100
101
  [layoutManager ensureLayoutForTextContainer:container];
101
- const CGRect used = [layoutManager usedRectForTextContainer:container];
102
+ return storage;
103
+ }
104
+
105
+ static CGSize JMDUsedSize(NSTextStorage *storage) {
106
+ NSLayoutManager *layoutManager = storage.layoutManagers.firstObject;
107
+ const CGRect used =
108
+ [layoutManager usedRectForTextContainer:layoutManager.textContainers.firstObject];
102
109
  return CGSizeMake(ceil(used.size.width), ceil(used.size.height));
103
110
  }
104
111
 
112
+ - (CGSize)textSize:(NSAttributedString *)text width:(CGFloat)width {
113
+ return JMDUsedSize([self layoutText:text width:width]);
114
+ }
115
+
105
116
  - (JMDMeasuredBlock *)measureBlock:(JMDBlock *)block
106
117
  width:(CGFloat)width
107
118
  imageSizes:(nullable NSDictionary<NSString *, NSArray<NSNumber *> *> *)imageSizes {
@@ -111,13 +122,17 @@
111
122
 
112
123
  switch (block.kind) {
113
124
  case JMDBlockKindText: {
114
- const CGSize size = [self textSize:block.attributedText width:width];
125
+ NSTextStorage *storage = [self layoutText:block.attributedText width:width];
126
+ const CGSize size = JMDUsedSize(storage);
127
+ measured.textStorage = storage;
115
128
  measured.height = size.height;
116
129
  measured.textHeight = size.height;
117
130
  break;
118
131
  }
119
132
  case JMDBlockKindCode: {
120
- const CGSize size = [self textSize:block.attributedText width:CGFLOAT_MAX];
133
+ NSTextStorage *storage = [self layoutText:block.attributedText width:CGFLOAT_MAX];
134
+ const CGSize size = JMDUsedSize(storage);
135
+ measured.textStorage = storage;
121
136
  measured.contentWidth = size.width;
122
137
  measured.textHeight = size.height;
123
138
  measured.height =
@@ -142,11 +157,14 @@
142
157
  const CGFloat contentWidth = MAX(width - contentX, 1);
143
158
  measured.contentWidth = contentWidth;
144
159
  NSMutableArray<NSNumber *> *markerHeights = [NSMutableArray new];
160
+ NSMutableArray<NSTextStorage *> *markerStorages = [NSMutableArray new];
145
161
  NSMutableArray<NSArray<JMDMeasuredBlock *> *> *rowContents = [NSMutableArray new];
146
162
  CGFloat height = 0;
147
163
  for (NSUInteger i = 0; i < block.rows.count; i++) {
148
164
  JMDListRow *row = block.rows[i];
149
- const CGSize markerSize = [self textSize:row.marker width:block.markerWidth];
165
+ NSTextStorage *markerStorage = [self layoutText:row.marker width:block.markerWidth];
166
+ [markerStorages addObject:markerStorage];
167
+ const CGSize markerSize = JMDUsedSize(markerStorage);
150
168
  NSMutableArray<JMDMeasuredBlock *> *content = [NSMutableArray new];
151
169
  for (JMDBlock *child in row.content) {
152
170
  [content addObject:[self measureBlock:child width:contentWidth imageSizes:imageSizes]];
@@ -159,6 +177,7 @@
159
177
  }
160
178
  }
161
179
  measured.markerHeights = markerHeights;
180
+ measured.markerStorages = markerStorages;
162
181
  measured.rowContents = rowContents;
163
182
  measured.height = height;
164
183
  break;
@@ -209,6 +228,7 @@
209
228
  }
210
229
 
211
230
  NSMutableArray<NSNumber *> *rowHeights = [NSMutableArray new];
231
+ NSMutableArray<NSArray<NSTextStorage *> *> *cellStorages = [NSMutableArray new];
212
232
  CGFloat contentHeight = 0;
213
233
  for (JMDTableRow *row in block.tableRows) {
214
234
  JMDLayoutStyle *rowStyle = row.isHeader ? block.headerRowStyle : block.bodyRowStyle;
@@ -217,10 +237,14 @@
217
237
  const CGFloat cellPadV = pad.top + pad.bottom;
218
238
  const CGFloat rowExtra = rowStyle.borderTopWidth + rowStyle.borderBottomWidth;
219
239
  CGFloat rowHeight = 0;
240
+ NSMutableArray<NSTextStorage *> *rowStorages = [NSMutableArray new];
220
241
  for (NSUInteger column = 0; column < row.cells.count; column++) {
221
242
  const CGFloat cellWidth = MAX(columnWidths[column] - cellPadH, 1);
222
- rowHeight = MAX(rowHeight, [self textSize:row.cells[column] width:cellWidth].height);
243
+ NSTextStorage *storage = [self layoutText:row.cells[column] width:cellWidth];
244
+ [rowStorages addObject:storage];
245
+ rowHeight = MAX(rowHeight, JMDUsedSize(storage).height);
223
246
  }
247
+ [cellStorages addObject:rowStorages];
224
248
  rowHeight += cellPadV + rowExtra;
225
249
  [rowHeights addObject:@(rowHeight)];
226
250
  contentHeight += rowHeight;
@@ -235,6 +259,7 @@
235
259
 
236
260
  measured.columnWidths = widths;
237
261
  measured.rowHeights = rowHeights;
262
+ measured.cellStorages = cellStorages;
238
263
  measured.contentWidth = contentWidth;
239
264
  measured.height = contentHeight + block.layoutStyle.verticalInset;
240
265
  break;