react-native-markdown-native 1.0.0

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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/MarkdownNative.podspec +19 -0
  3. package/README.md +109 -0
  4. package/android/.gradle/8.9/checksums/checksums.lock +0 -0
  5. package/android/.gradle/8.9/dependencies-accessors/gc.properties +0 -0
  6. package/android/.gradle/8.9/executionHistory/executionHistory.lock +0 -0
  7. package/android/.gradle/8.9/fileChanges/last-build.bin +0 -0
  8. package/android/.gradle/8.9/fileHashes/fileHashes.lock +0 -0
  9. package/android/.gradle/8.9/gc.properties +0 -0
  10. package/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock +0 -0
  11. package/android/.gradle/buildOutputCleanup/cache.properties +2 -0
  12. package/android/.gradle/vcs-1/gc.properties +0 -0
  13. package/android/build.gradle +36 -0
  14. package/android/src/main/AndroidManifest.xml +2 -0
  15. package/android/src/main/java/com/markdownnative/MarkdownPackage.java +25 -0
  16. package/android/src/main/java/com/markdownnative/MarkdownView.java +516 -0
  17. package/android/src/main/java/com/markdownnative/MarkdownViewManager.java +107 -0
  18. package/ios/MarkdownView.swift +771 -0
  19. package/ios/MarkdownViewManager.m +27 -0
  20. package/ios/MarkdownViewManager.swift +20 -0
  21. package/lib/commonjs/index.js +97 -0
  22. package/lib/commonjs/index.js.map +1 -0
  23. package/lib/commonjs/package.json +1 -0
  24. package/lib/commonjs/types.js +154 -0
  25. package/lib/commonjs/types.js.map +1 -0
  26. package/lib/index.d.ts +26 -0
  27. package/lib/index.js +97 -0
  28. package/lib/module/index.js +80 -0
  29. package/lib/module/index.js.map +1 -0
  30. package/lib/module/types.js +153 -0
  31. package/lib/module/types.js.map +1 -0
  32. package/lib/typescript/index.d.ts +30 -0
  33. package/lib/typescript/index.d.ts.map +1 -0
  34. package/lib/typescript/types.d.ts +80 -0
  35. package/lib/typescript/types.d.ts.map +1 -0
  36. package/package.json +92 -0
  37. package/react-native.config.js +11 -0
  38. package/src/index.tsx +117 -0
  39. package/src/types.ts +205 -0
@@ -0,0 +1,771 @@
1
+ import UIKit
2
+ import React
3
+
4
+ @available(iOS 15.0, *)
5
+ class MarkdownView: UITextView {
6
+
7
+ @objc var onNativeSizeChange: RCTDirectEventBlock?
8
+
9
+ @objc var markdownText: String = "" {
10
+ didSet { renderContent() }
11
+ }
12
+
13
+ @objc var markdownFontSize: CGFloat = 16.0 {
14
+ didSet { renderContent() }
15
+ }
16
+
17
+ @objc var markdownColor: String = "" {
18
+ didSet { renderContent() }
19
+ }
20
+
21
+ @objc var markdownFontFamily: String = "" {
22
+ didSet { renderContent() }
23
+ }
24
+
25
+ @objc var markdownSelectionColor: String = "" {
26
+ didSet { updateSelectionColor() }
27
+ }
28
+
29
+ @objc var markdownScrollEnabled: Bool = false {
30
+ didSet { self.isScrollEnabled = markdownScrollEnabled }
31
+ }
32
+
33
+ @objc var markdownSelectable: Bool = true {
34
+ didSet { self.isSelectable = markdownSelectable }
35
+ }
36
+
37
+ // Customization props
38
+ @objc var markdownLinkColor: String = "" {
39
+ didSet {
40
+ renderContent()
41
+ updateLinkAttributes()
42
+ }
43
+ }
44
+
45
+ @objc var markdownLineSpacing: CGFloat = 0.0 {
46
+ didSet { renderContent() }
47
+ }
48
+
49
+ @objc var markdownParagraphSpacing: CGFloat = 0.0 {
50
+ didSet { renderContent() }
51
+ }
52
+
53
+ @objc var markdownBulletIndent: CGFloat = 0.0 {
54
+ didSet { renderContent() }
55
+ }
56
+
57
+ // List styling props
58
+ @objc var markdownListLeftInset: CGFloat = 0.0 {
59
+ didSet { renderContent() }
60
+ }
61
+
62
+ @objc var markdownListSpacingBefore: CGFloat = 0.0 {
63
+ didSet { renderContent() }
64
+ }
65
+
66
+ @objc var markdownListSpacingAfter: CGFloat = 0.0 {
67
+ didSet { renderContent() }
68
+ }
69
+
70
+ // New rules-based styling system
71
+ @objc var markdownRulesJson: String = "" {
72
+ didSet { parseAndApplyRules() }
73
+ }
74
+
75
+ // Parsed rules storage
76
+ private var parsedRules: [String: [String: Any]] = [:]
77
+
78
+ // MARK: - Initialization
79
+
80
+ override init(frame: CGRect, textContainer: NSTextContainer?) {
81
+ super.init(frame: frame, textContainer: textContainer)
82
+ setupView()
83
+ }
84
+
85
+ required init?(coder: NSCoder) {
86
+ super.init(coder: coder)
87
+ setupView()
88
+ }
89
+
90
+ private func setupView() {
91
+ self.backgroundColor = .clear
92
+ self.isEditable = false
93
+ self.isScrollEnabled = false
94
+ self.isSelectable = true
95
+ self.textContainerInset = .zero
96
+ self.textContainer.lineFragmentPadding = 0
97
+ self.clipsToBounds = false
98
+
99
+ self.clipsToBounds = false
100
+
101
+ self.dataDetectorTypes = [.link]
102
+ self.isUserInteractionEnabled = true
103
+ updateLinkAttributes()
104
+ }
105
+
106
+ private func updateLinkAttributes() {
107
+ let linkColor = resolveColor(markdownLinkColor) ?? UIColor.systemBlue
108
+ self.linkTextAttributes = [
109
+ .foregroundColor: linkColor,
110
+ .underlineStyle: NSUnderlineStyle.single.rawValue
111
+ ]
112
+ }
113
+
114
+ // MARK: - Rendering
115
+
116
+ private func renderContent() {
117
+ guard !markdownText.isEmpty else {
118
+ self.attributedText = nil
119
+ notifySizeChange()
120
+ return
121
+ }
122
+
123
+
124
+
125
+ let textColor = resolveColor(markdownColor) ?? UIColor.black
126
+ let fontSize = markdownFontSize > 0 ? markdownFontSize : 16.0
127
+ let font = resolveFont(markdownFontFamily, size: fontSize)
128
+ let boldFont = UIFont.boldSystemFont(ofSize: fontSize)
129
+ let italicFont = UIFont.italicSystemFont(ofSize: fontSize)
130
+
131
+
132
+
133
+ let attributedString = parseMarkdownManually(
134
+ markdownText,
135
+ font: font,
136
+ boldFont: boldFont,
137
+ italicFont: italicFont,
138
+ color: textColor,
139
+ fontSize: fontSize
140
+ )
141
+
142
+ self.attributedText = attributedString
143
+ self.textColor = textColor
144
+
145
+ updateSelectionColor()
146
+
147
+ updateSelectionColor()
148
+
149
+ self.setNeedsLayout()
150
+ self.layoutIfNeeded()
151
+ notifySizeChange()
152
+ }
153
+
154
+ private func parseMarkdownManually(_ text: String, font: UIFont, boldFont: UIFont, italicFont: UIFont, color: UIColor, fontSize: CGFloat) -> NSAttributedString {
155
+ let result = NSMutableAttributedString()
156
+
157
+
158
+
159
+ let lines = text.components(separatedBy: "\n")
160
+
161
+ for (index, line) in lines.enumerated() {
162
+ let isEmpty = line.trimmingCharacters(in: .whitespaces).isEmpty
163
+ let isListItem = isListLine(line)
164
+
165
+ let isNextNonEmptyLineList = isNextNonEmptyList(lines: lines, currentIndex: index)
166
+
167
+ if isEmpty {
168
+ if index < lines.count - 1 {
169
+ result.append(NSAttributedString(string: "\n"))
170
+ }
171
+ continue
172
+ }
173
+
174
+
175
+
176
+ var spacingAfter: CGFloat = markdownParagraphSpacing
177
+
178
+ if !isListItem && isNextNonEmptyLineList {
179
+ spacingAfter = markdownListSpacingBefore
180
+ } else if isListItem && !isNextNonEmptyLineList {
181
+ spacingAfter = markdownListSpacingAfter
182
+ } else if isListItem && isNextNonEmptyLineList {
183
+ spacingAfter = markdownParagraphSpacing
184
+ }
185
+
186
+ let processedLine = processLine(
187
+ line,
188
+ font: font,
189
+ boldFont: boldFont,
190
+ italicFont: italicFont,
191
+ color: color,
192
+ fontSize: fontSize,
193
+ spacingBefore: 0,
194
+ spacingAfter: spacingAfter
195
+ )
196
+ result.append(processedLine)
197
+
198
+
199
+
200
+ if index < lines.count - 1 {
201
+ result.append(NSAttributedString(string: "\n"))
202
+ }
203
+ }
204
+
205
+ return result
206
+ }
207
+
208
+ private func isNextNonEmptyList(lines: [String], currentIndex: Int) -> Bool {
209
+ for i in (currentIndex + 1)..<lines.count {
210
+ let line = lines[i].trimmingCharacters(in: .whitespaces)
211
+ if !line.isEmpty {
212
+ return isListLine(lines[i])
213
+ }
214
+ }
215
+ return false
216
+ }
217
+
218
+ private func isPrevNonEmptyList(lines: [String], currentIndex: Int) -> Bool {
219
+ for i in stride(from: currentIndex - 1, through: 0, by: -1) {
220
+ let line = lines[i].trimmingCharacters(in: .whitespaces)
221
+ if !line.isEmpty {
222
+ return isListLine(lines[i])
223
+ }
224
+ }
225
+ return false
226
+ }
227
+
228
+ private func isListLine(_ line: String) -> Bool {
229
+
230
+ if line.range(of: "^\\d+\\.\\s+", options: .regularExpression) != nil {
231
+ return true
232
+ }
233
+ if line.range(of: "^[-*+]\\s+", options: .regularExpression) != nil {
234
+ return true
235
+ }
236
+ return false
237
+ }
238
+
239
+ private func processLine(_ line: String, font: UIFont, boldFont: UIFont, italicFont: UIFont, color: UIColor, fontSize: CGFloat, spacingBefore: CGFloat, spacingAfter: CGFloat) -> NSAttributedString {
240
+ var text = line
241
+
242
+ let baseParagraphStyle = NSMutableParagraphStyle()
243
+ baseParagraphStyle.lineSpacing = markdownLineSpacing
244
+ baseParagraphStyle.paragraphSpacing = spacingAfter
245
+ baseParagraphStyle.paragraphSpacingBefore = spacingBefore
246
+
247
+ var attributes: [NSAttributedString.Key: Any] = [
248
+ .font: font,
249
+ .foregroundColor: color,
250
+ .paragraphStyle: baseParagraphStyle
251
+ ]
252
+
253
+
254
+ if let headerMatch = text.range(of: "^#{1,6}\\s+", options: .regularExpression) {
255
+ let headerLevel = text[headerMatch].filter { $0 == "#" }.count
256
+ text = String(text[headerMatch.upperBound...])
257
+
258
+ // Determine element type based on header level
259
+ let headerType: String
260
+ switch headerLevel {
261
+ case 1: headerType = "heading1"
262
+ case 2: headerType = "heading2"
263
+ case 3: headerType = "heading3"
264
+ case 4: headerType = "heading4"
265
+ case 5: headerType = "heading5"
266
+ case 6: headerType = "heading6"
267
+ default: headerType = "heading6"
268
+ }
269
+
270
+ // Apply rules if available, otherwise use defaults
271
+ let headerFont = applyRulesToFont(headerType, baseSize: fontSize * (headerLevel == 1 ? 2.0 : headerLevel == 2 ? 1.5 : headerLevel == 3 ? 1.25 : headerLevel == 4 ? 1.1 : 1.0))
272
+ let headerColor = applyRulesToColor(headerType, defaultColor: color)
273
+
274
+ let headerParagraph = NSMutableParagraphStyle()
275
+ headerParagraph.paragraphSpacingBefore = getRuleValue(headerType, "marginTop", defaultValue: 8)
276
+ headerParagraph.paragraphSpacing = getRuleValue(headerType, "marginBottom", defaultValue: 4)
277
+ headerParagraph.lineSpacing = markdownLineSpacing
278
+
279
+ attributes[.font] = headerFont
280
+ attributes[.foregroundColor] = headerColor
281
+ attributes[.paragraphStyle] = headerParagraph
282
+
283
+ return applyInlineFormatting(to: text, baseAttributes: attributes, boldFont: headerFont, italicFont: UIFont.italicSystemFont(ofSize: headerFont.pointSize))
284
+ }
285
+
286
+ // Blockquote handling (> text)
287
+ if text.hasPrefix("> ") || text.hasPrefix(">") {
288
+ let content = text.hasPrefix("> ") ? String(text.dropFirst(2)) : String(text.dropFirst(1))
289
+
290
+ let quoteFont = applyRulesToFont("blockquote", baseSize: fontSize)
291
+ let quoteColor = applyRulesToColor("blockquote", defaultColor: color)
292
+
293
+ let quoteParagraph = NSMutableParagraphStyle()
294
+ quoteParagraph.paragraphSpacingBefore = getRuleValue("blockquote", "marginTop", defaultValue: 8)
295
+ quoteParagraph.paragraphSpacing = getRuleValue("blockquote", "marginBottom", defaultValue: 8)
296
+ quoteParagraph.lineSpacing = markdownLineSpacing
297
+
298
+ // Apply left border effect with padding
299
+ let leftInset: CGFloat = getRuleValue("blockquote", "paddingLeft", defaultValue: 12)
300
+ let marginLeft: CGFloat = getRuleValue("blockquote", "marginLeft", defaultValue: 16)
301
+ quoteParagraph.firstLineHeadIndent = marginLeft + leftInset
302
+ quoteParagraph.headIndent = marginLeft + leftInset
303
+
304
+ attributes[.font] = quoteFont
305
+ attributes[.foregroundColor] = quoteColor
306
+ attributes[.paragraphStyle] = quoteParagraph
307
+
308
+ // Background color for blockquote
309
+ if let bgColorStr: String = getRuleValue("blockquote", "backgroundColor", defaultValue: nil),
310
+ let bgColor = resolveColor(bgColorStr) {
311
+ attributes[.backgroundColor] = bgColor
312
+ }
313
+
314
+ return applyInlineFormatting(to: content, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont)
315
+ }
316
+
317
+ // Horizontal rule handling (---, ***, ___)
318
+ if text.range(of: "^[-*_]{3,}$", options: .regularExpression) != nil {
319
+ let hrParagraph = NSMutableParagraphStyle()
320
+ hrParagraph.paragraphSpacingBefore = getRuleValue("hr", "marginTop", defaultValue: 16)
321
+ hrParagraph.paragraphSpacing = getRuleValue("hr", "marginBottom", defaultValue: 16)
322
+
323
+ attributes[.paragraphStyle] = hrParagraph
324
+
325
+ // Create a visual line
326
+ let lineText = "─────────────────────────────────────"
327
+ let lineColor = getRuleValue("hr", "borderBottomColor", defaultValue: "#CCCCCC")
328
+ if let color = resolveColor(lineColor) {
329
+ attributes[.foregroundColor] = color
330
+ }
331
+
332
+ return NSAttributedString(string: lineText, attributes: attributes)
333
+ }
334
+
335
+
336
+ if let listMatch = text.range(of: "^\\d+\\.\\s+", options: .regularExpression) {
337
+ let prefix = String(text[listMatch])
338
+ let content = String(text[listMatch.upperBound...])
339
+
340
+ // Apply rules for ordered list items
341
+ let itemFont = applyRulesToFont("orderedListItem", baseSize: fontSize)
342
+ let itemColor = applyRulesToColor("orderedListItem", defaultColor: color)
343
+
344
+ let prefixSize = (prefix as NSString).size(withAttributes: [.font: itemFont])
345
+ let bulletIndent = getRuleValue("orderedListItem", "bulletIndent", defaultValue: markdownBulletIndent > 0 ? markdownBulletIndent : prefixSize.width)
346
+ let leftInset = getRuleValue("orderedListItem", "listLeftInset", defaultValue: markdownListLeftInset)
347
+
348
+ let listParagraph = NSMutableParagraphStyle()
349
+ listParagraph.headIndent = leftInset + bulletIndent
350
+ listParagraph.firstLineHeadIndent = leftInset
351
+ listParagraph.paragraphSpacing = getRuleValue("orderedListItem", "marginBottom", defaultValue: spacingAfter)
352
+ listParagraph.paragraphSpacingBefore = spacingBefore
353
+ listParagraph.lineSpacing = getRuleValue("orderedListItem", "lineHeight", defaultValue: markdownLineSpacing)
354
+
355
+ attributes[.font] = itemFont
356
+ attributes[.foregroundColor] = itemColor
357
+ attributes[.paragraphStyle] = listParagraph
358
+
359
+ let result = NSMutableAttributedString(string: prefix, attributes: attributes)
360
+ result.append(applyInlineFormatting(to: content, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont))
361
+ return result
362
+ }
363
+
364
+
365
+
366
+ if let bulletMatch = text.range(of: "^[-*+]\\s+", options: .regularExpression) {
367
+ let content = String(text[bulletMatch.upperBound...])
368
+
369
+ // Apply rules for list items
370
+ let itemFont = applyRulesToFont("listItem", baseSize: fontSize)
371
+ let itemColor = applyRulesToColor("listItem", defaultColor: color)
372
+
373
+ let bulletPrefix = "• "
374
+
375
+ let bulletSize = (bulletPrefix as NSString).size(withAttributes: [.font: itemFont])
376
+ let bulletIndent = getRuleValue("listItem", "bulletIndent", defaultValue: markdownBulletIndent > 0 ? markdownBulletIndent : bulletSize.width)
377
+ let leftInset = getRuleValue("listItem", "listLeftInset", defaultValue: markdownListLeftInset)
378
+
379
+ let bulletParagraph = NSMutableParagraphStyle()
380
+ bulletParagraph.headIndent = leftInset + bulletIndent
381
+ bulletParagraph.firstLineHeadIndent = leftInset
382
+ bulletParagraph.paragraphSpacing = getRuleValue("listItem", "marginBottom", defaultValue: spacingAfter)
383
+ bulletParagraph.paragraphSpacingBefore = spacingBefore
384
+ bulletParagraph.lineSpacing = getRuleValue("listItem", "lineHeight", defaultValue: markdownLineSpacing)
385
+
386
+ attributes[.font] = itemFont
387
+ attributes[.foregroundColor] = itemColor
388
+ attributes[.paragraphStyle] = bulletParagraph
389
+
390
+ let result = NSMutableAttributedString(string: bulletPrefix, attributes: attributes)
391
+ result.append(applyInlineFormatting(to: content, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont))
392
+ return result
393
+ }
394
+
395
+ return applyInlineFormatting(to: text, baseAttributes: attributes, boldFont: boldFont, italicFont: italicFont)
396
+ }
397
+
398
+ private func applyInlineFormatting(to text: String, baseAttributes: [NSAttributedString.Key: Any], boldFont: UIFont, italicFont: UIFont) -> NSAttributedString {
399
+ let result = NSMutableAttributedString(string: text, attributes: baseAttributes)
400
+
401
+ // Apply link patterns first (they need special handling)
402
+ applyLinkPattern(to: result, baseAttributes: baseAttributes)
403
+
404
+ // Apply bold formatting using rules
405
+ let strongFont = applyRulesToFont("strong", baseSize: boldFont.pointSize)
406
+ let strongColor = applyRulesToColor("strong", defaultColor: baseAttributes[.foregroundColor] as? UIColor ?? UIColor.black)
407
+ applyPatternWithColor("\\*\\*(.+?)\\*\\*|__(.+?)__", to: result, font: strongFont, color: strongColor)
408
+
409
+ // Apply italic formatting using rules
410
+ let emFont = applyRulesToFont("em", baseSize: italicFont.pointSize)
411
+ let emColor = applyRulesToColor("em", defaultColor: baseAttributes[.foregroundColor] as? UIColor ?? UIColor.black)
412
+ applyPatternWithColor("(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)|(?<!_)_(?!_)(.+?)(?<!_)_(?!_)", to: result, font: emFont, color: emColor)
413
+
414
+ // Apply strikethrough formatting using rules (~~text~~)
415
+ let strikeColor = applyRulesToColor("strikethrough", defaultColor: baseAttributes[.foregroundColor] as? UIColor ?? UIColor.black)
416
+ applyStrikethroughPattern("~~(.+?)~~", to: result, color: strikeColor)
417
+
418
+ // Apply inline code formatting using rules
419
+ let codeFont = applyRulesToFont("codeInline", baseSize: (baseAttributes[.font] as? UIFont)?.pointSize ?? 16)
420
+ let codeColor = applyRulesToColor("codeInline", defaultColor: UIColor.black)
421
+ let codeBgColor = getRuleValue("codeInline", "backgroundColor", defaultValue: "#F5F5F5")
422
+ applyCodePattern("`(.+?)`", to: result, font: codeFont, color: codeColor, backgroundColor: codeBgColor)
423
+
424
+ return result
425
+ }
426
+
427
+ private func applyLinkPattern(to attributedString: NSMutableAttributedString, baseAttributes: [NSAttributedString.Key: Any]) {
428
+
429
+ let linkPattern = "\\[([^\\]]+)\\]\\(([^)]+)\\)"
430
+ guard let regex = try? NSRegularExpression(pattern: linkPattern, options: []) else { return }
431
+
432
+ let fullRange = NSRange(location: 0, length: attributedString.length)
433
+ let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
434
+
435
+
436
+
437
+ let linkColor = resolveColor(markdownLinkColor) ?? UIColor.systemBlue
438
+
439
+ for match in matches.reversed() {
440
+ guard match.numberOfRanges >= 3 else { continue }
441
+
442
+ let textRange = match.range(at: 1)
443
+ let urlRange = match.range(at: 2)
444
+
445
+ guard textRange.location != NSNotFound, urlRange.location != NSNotFound else { continue }
446
+
447
+ let linkText = (attributedString.string as NSString).substring(with: textRange)
448
+ let urlString = (attributedString.string as NSString).substring(with: urlRange)
449
+
450
+
451
+
452
+ var linkAttributes = baseAttributes
453
+ linkAttributes[.foregroundColor] = linkColor
454
+ linkAttributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
455
+
456
+ if let url = URL(string: urlString) {
457
+ linkAttributes[.link] = url
458
+ }
459
+
460
+ let formattedLink = NSAttributedString(string: linkText, attributes: linkAttributes)
461
+
462
+
463
+
464
+ attributedString.replaceCharacters(in: match.range, with: formattedLink)
465
+ }
466
+ }
467
+
468
+ private func applyPattern(_ pattern: String, to attributedString: NSMutableAttributedString, attribute: NSAttributedString.Key, value: Any) {
469
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
470
+
471
+ let fullRange = NSRange(location: 0, length: attributedString.length)
472
+ let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
473
+
474
+ // Process in reverse to preserve ranges
475
+ for match in matches.reversed() {
476
+ // Find the actual content (first non-nil group)
477
+ var contentRange: NSRange?
478
+ for i in 1..<match.numberOfRanges {
479
+ let range = match.range(at: i)
480
+ if range.location != NSNotFound {
481
+ contentRange = range
482
+ break
483
+ }
484
+ }
485
+
486
+ guard let actualContentRange = contentRange else { continue }
487
+
488
+ // Get the content without markers
489
+ let content = (attributedString.string as NSString).substring(with: actualContentRange)
490
+
491
+ // Create new attributed string with the formatting
492
+ var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
493
+ newAttributes[attribute] = value
494
+
495
+ let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
496
+
497
+ // Replace the entire match (including markers) with just the formatted content
498
+ attributedString.replaceCharacters(in: match.range, with: formattedContent)
499
+ }
500
+ }
501
+
502
+ private func applyPatternWithColor(_ pattern: String, to attributedString: NSMutableAttributedString, font: UIFont, color: UIColor) {
503
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
504
+
505
+ let fullRange = NSRange(location: 0, length: attributedString.length)
506
+ let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
507
+
508
+ for match in matches.reversed() {
509
+ var contentRange: NSRange?
510
+ for i in 1..<match.numberOfRanges {
511
+ let range = match.range(at: i)
512
+ if range.location != NSNotFound {
513
+ contentRange = range
514
+ break
515
+ }
516
+ }
517
+
518
+ guard let actualContentRange = contentRange else { continue }
519
+
520
+ let content = (attributedString.string as NSString).substring(with: actualContentRange)
521
+
522
+ var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
523
+ newAttributes[.font] = font
524
+ newAttributes[.foregroundColor] = color
525
+
526
+ let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
527
+ attributedString.replaceCharacters(in: match.range, with: formattedContent)
528
+ }
529
+ }
530
+
531
+ private func applyCodePattern(_ pattern: String, to attributedString: NSMutableAttributedString, font: UIFont, color: UIColor, backgroundColor: String?) {
532
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
533
+
534
+ let fullRange = NSRange(location: 0, length: attributedString.length)
535
+ let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
536
+
537
+ for match in matches.reversed() {
538
+ var contentRange: NSRange?
539
+ for i in 1..<match.numberOfRanges {
540
+ let range = match.range(at: i)
541
+ if range.location != NSNotFound {
542
+ contentRange = range
543
+ break
544
+ }
545
+ }
546
+
547
+ guard let actualContentRange = contentRange else { continue }
548
+
549
+ let content = (attributedString.string as NSString).substring(with: actualContentRange)
550
+
551
+ var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
552
+ newAttributes[.font] = font
553
+ newAttributes[.foregroundColor] = color
554
+
555
+ if let bgColorStr = backgroundColor, let bgColor = resolveColor(bgColorStr) {
556
+ newAttributes[.backgroundColor] = bgColor
557
+ }
558
+
559
+ let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
560
+ attributedString.replaceCharacters(in: match.range, with: formattedContent)
561
+ }
562
+ }
563
+
564
+ private func applyStrikethroughPattern(_ pattern: String, to attributedString: NSMutableAttributedString, color: UIColor) {
565
+ guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
566
+
567
+ let fullRange = NSRange(location: 0, length: attributedString.length)
568
+ let matches = regex.matches(in: attributedString.string, options: [], range: fullRange)
569
+
570
+ for match in matches.reversed() {
571
+ var contentRange: NSRange?
572
+ for i in 1..<match.numberOfRanges {
573
+ let range = match.range(at: i)
574
+ if range.location != NSNotFound {
575
+ contentRange = range
576
+ break
577
+ }
578
+ }
579
+
580
+ guard let actualContentRange = contentRange else { continue }
581
+
582
+ let content = (attributedString.string as NSString).substring(with: actualContentRange)
583
+
584
+ var newAttributes = attributedString.attributes(at: actualContentRange.location, effectiveRange: nil)
585
+ newAttributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
586
+ newAttributes[.foregroundColor] = color
587
+
588
+ let formattedContent = NSAttributedString(string: content, attributes: newAttributes)
589
+ attributedString.replaceCharacters(in: match.range, with: formattedContent)
590
+ }
591
+ }
592
+
593
+ private func resolveColor(_ hex: String) -> UIColor? {
594
+ guard !hex.isEmpty else { return nil }
595
+
596
+ let start = hex.hasPrefix("#") ? hex.index(hex.startIndex, offsetBy: 1) : hex.startIndex
597
+ let hexColor = String(hex[start...])
598
+
599
+ guard hexColor.count == 6 || hexColor.count == 8 else { return nil }
600
+
601
+ var hexNumber: UInt64 = 0
602
+ guard Scanner(string: hexColor).scanHexInt64(&hexNumber) else { return nil }
603
+
604
+ if hexColor.count == 6 {
605
+ return UIColor(
606
+ red: CGFloat((hexNumber & 0xff0000) >> 16) / 255,
607
+ green: CGFloat((hexNumber & 0x00ff00) >> 8) / 255,
608
+ blue: CGFloat(hexNumber & 0x0000ff) / 255,
609
+ alpha: 1.0
610
+ )
611
+ } else {
612
+ return UIColor(
613
+ red: CGFloat((hexNumber & 0xff000000) >> 24) / 255,
614
+ green: CGFloat((hexNumber & 0x00ff0000) >> 16) / 255,
615
+ blue: CGFloat((hexNumber & 0x0000ff00) >> 8) / 255,
616
+ alpha: CGFloat(hexNumber & 0x000000ff) / 255
617
+ )
618
+ }
619
+ }
620
+
621
+ private func resolveFont(_ familyName: String, size: CGFloat) -> UIFont {
622
+ if !familyName.isEmpty, let customFont = UIFont(name: familyName, size: size) {
623
+ return customFont
624
+ }
625
+ return UIFont.systemFont(ofSize: size)
626
+ }
627
+
628
+ private func updateSelectionColor() {
629
+ if let color = resolveColor(markdownSelectionColor) {
630
+ self.tintColor = color
631
+ }
632
+ }
633
+
634
+ private var lastSize: CGSize = .zero
635
+
636
+ override func layoutSubviews() {
637
+ super.layoutSubviews()
638
+ // Force size notification after layout
639
+ DispatchQueue.main.async { [weak self] in
640
+ self?.notifySizeChange()
641
+ }
642
+ }
643
+
644
+ override func didMoveToWindow() {
645
+ super.didMoveToWindow()
646
+ // Trigger size calculation when view is added to window
647
+ if window != nil {
648
+ DispatchQueue.main.async { [weak self] in
649
+ self?.notifySizeChange()
650
+ }
651
+ }
652
+ }
653
+
654
+ private func notifySizeChange() {
655
+ var boundingWidth = self.bounds.width
656
+ if boundingWidth <= 0 {
657
+ boundingWidth = self.superview?.bounds.width ?? UIScreen.main.bounds.width
658
+ }
659
+
660
+ let size = self.sizeThatFits(CGSize(width: boundingWidth, height: .greatestFiniteMagnitude))
661
+
662
+ let minHeight = markdownFontSize * 1.5
663
+ let finalHeight = max(size.height, minHeight)
664
+
665
+ if abs(finalHeight - lastSize.height) > 0.5 || abs(size.width - lastSize.width) > 0.5 {
666
+ lastSize = CGSize(width: size.width, height: finalHeight)
667
+ onNativeSizeChange?(["width": size.width, "height": finalHeight])
668
+ self.invalidateIntrinsicContentSize()
669
+ }
670
+ }
671
+
672
+ override var intrinsicContentSize: CGSize {
673
+ var width = self.bounds.width
674
+ if width <= 0 {
675
+ width = self.superview?.bounds.width ?? UIScreen.main.bounds.width
676
+ }
677
+ let size = self.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude))
678
+ let minHeight = markdownFontSize * 1.5
679
+ return CGSize(width: size.width, height: max(size.height, minHeight))
680
+ }
681
+
682
+ // MARK: - Rules Parsing and Application
683
+
684
+ private func parseAndApplyRules() {
685
+ guard !markdownRulesJson.isEmpty else {
686
+ parsedRules = [:]
687
+ return
688
+ }
689
+
690
+ guard let data = markdownRulesJson.data(using: .utf8),
691
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: [String: Any]] else {
692
+ parsedRules = [:]
693
+ return
694
+ }
695
+
696
+ parsedRules = json
697
+ renderContent()
698
+ }
699
+
700
+ private func getRuleValue<T>(_ elementType: String, _ key: String, defaultValue: T) -> T {
701
+ guard let rule = parsedRules[elementType],
702
+ let value = rule[key] as? T else {
703
+ return defaultValue
704
+ }
705
+ return value
706
+ }
707
+
708
+ private func applyRulesToFont(_ elementType: String, baseSize: CGFloat) -> UIFont {
709
+ let fontSize: CGFloat = getRuleValue(elementType, "fontSize", defaultValue: baseSize)
710
+ let fontFamily: String? = getRuleValue(elementType, "fontFamily", defaultValue: nil as String?)
711
+ let fontWeight: String? = getRuleValue(elementType, "fontWeight", defaultValue: nil as String?)
712
+ let fontStyle: String? = getRuleValue(elementType, "fontStyle", defaultValue: nil as String?)
713
+
714
+ if let family = fontFamily, !family.isEmpty {
715
+ if let customFont = UIFont(name: family, size: fontSize) {
716
+ return applyFontTraits(customFont, weight: fontWeight, style: fontStyle)
717
+ }
718
+ }
719
+
720
+ // Apply weight and style to system font
721
+ var font = UIFont.systemFont(ofSize: fontSize)
722
+ if let weight = fontWeight {
723
+ if weight == "bold" || weight == "700" || weight == "800" || weight == "900" {
724
+ font = UIFont.boldSystemFont(ofSize: fontSize)
725
+ }
726
+ }
727
+ if let style = fontStyle, style == "italic" {
728
+ font = UIFont.italicSystemFont(ofSize: fontSize)
729
+ }
730
+
731
+ return font
732
+ }
733
+
734
+ private func applyFontTraits(_ font: UIFont, weight: String?, style: String?) -> UIFont {
735
+ var traits: UIFontDescriptor.SymbolicTraits = []
736
+
737
+ if let w = weight, (w == "bold" || w == "700" || w == "800" || w == "900") {
738
+ traits.insert(.traitBold)
739
+ }
740
+ if let s = style, s == "italic" {
741
+ traits.insert(.traitItalic)
742
+ }
743
+
744
+ if let descriptor = font.fontDescriptor.withSymbolicTraits(traits) {
745
+ return UIFont(descriptor: descriptor, size: font.pointSize)
746
+ }
747
+
748
+ return font
749
+ }
750
+
751
+ private func applyRulesToParagraphStyle(_ elementType: String, base: NSMutableParagraphStyle) -> NSMutableParagraphStyle {
752
+ let style = NSMutableParagraphStyle()
753
+ style.lineSpacing = base.lineSpacing
754
+ style.paragraphSpacing = getRuleValue(elementType, "marginBottom", defaultValue: base.paragraphSpacing)
755
+ style.paragraphSpacingBefore = getRuleValue(elementType, "marginTop", defaultValue: base.paragraphSpacingBefore)
756
+ style.firstLineHeadIndent = base.firstLineHeadIndent
757
+ style.headIndent = base.headIndent
758
+
759
+ return style
760
+ }
761
+
762
+ private func applyRulesToColor(_ elementType: String, defaultColor: UIColor) -> UIColor {
763
+ let colorStr: String? = getRuleValue(elementType, "color", defaultValue: nil)
764
+ if let str = colorStr, let color = resolveColor(str) {
765
+ return color
766
+ }
767
+ return defaultColor
768
+ }
769
+ }
770
+
771
+