diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h index 3e13bf94..99ea444f 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.h @@ -13,6 +13,13 @@ NS_ASSUME_NONNULL_BEGIN - (void)renderMarkdownSynchronously:(NSString *)markdownString; - (BOOL)hasRenderedMarkdown:(NSString *)markdown; - (BOOL)hasRenderedWithStyleFingerprint:(size_t)fingerprint; + +/// Size last committed by Fabric via `updateLayoutMetrics:`, readable from +/// any thread without blocking (issue #550: the shadow-node measure path must +/// never wait on the main thread, and reading `bounds` off-main is unsafe). +/// Backed by a single lock-free atomic, so the pair can't tear. Returns +/// CGSizeZero until the first layout is committed. +- (CGSize)lastCommittedLayoutSize; @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm index 55eea259..7c5112b8 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdown.mm @@ -2,6 +2,7 @@ #import "ContextMenuUtils.h" #import "ENRMAccessibilityLabels.h" #import "ENRMAsyncRenderCoordinator.h" +#import "ENRMAtomicSize.h" #import "ENRMImageAttachment.h" #import "ENRMMarkdownParser.h" #import "ENRMTailFadeInAnimator.h" @@ -67,7 +68,6 @@ typedef NS_OPTIONS(NSUInteger, ENRMDirtyFlags) { static char kENRMSegmentFadeAnimatorKey; - @interface EnrichedMarkdown () + (ENRMMd4cFlags *)flagsFromProps:(const EnrichedMarkdownMd4cFlagsStruct &)props; - (void)emitLinkPress:(NSString *)url; @@ -119,6 +119,8 @@ @implementation EnrichedMarkdown { ENRMWritingDirectionMode _writingDirectionMode; NSWritingDirection _resolvedLayoutDirection; + + ENRMAtomicSize _lastCommittedSize; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -367,6 +369,11 @@ - (CGSize)measureSize:(CGFloat)maxWidth return CGSizeMake(measuredWidth, measuredHeight); } +- (CGSize)lastCommittedLayoutSize +{ + return _lastCommittedSize.load(); +} + - (BOOL)hasRenderedMarkdown:(NSString *)markdown { return _renderedMarkdown != nil && [_renderedMarkdown isEqualToString:markdown]; @@ -410,6 +417,8 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics { [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + _lastCommittedSize.store(CGSizeMake(layoutMetrics.frame.size.width, layoutMetrics.frame.size.height)); + NSWritingDirection resolved = _resolvedLayoutDirection; if (layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) { resolved = NSWritingDirectionRightToLeft; diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.h b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.h index 6fe547b4..7287ac2b 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.h +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.h @@ -13,6 +13,13 @@ NS_ASSUME_NONNULL_BEGIN - (void)renderMarkdownSynchronously:(NSString *)markdownString; - (BOOL)hasRenderedMarkdown:(NSString *)markdown; - (BOOL)hasRenderedWithStyleFingerprint:(size_t)fingerprint; + +/// Size last committed by Fabric via `updateLayoutMetrics:`, readable from +/// any thread without blocking (issue #550: the shadow-node measure path must +/// never wait on the main thread, and reading `bounds` off-main is unsafe). +/// Backed by a single lock-free atomic, so the pair can't tear. Returns +/// CGSizeZero until the first layout is committed. +- (CGSize)lastCommittedLayoutSize; @end NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm index 5cf1498d..0f1cb4ba 100644 --- a/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm +++ b/packages/react-native-enriched-markdown/ios/EnrichedMarkdownText.mm @@ -3,6 +3,7 @@ #import "ContextMenuUtils.h" #import "ENRMAccessibilityLabels.h" #import "ENRMAsyncRenderCoordinator.h" +#import "ENRMAtomicSize.h" #import "ENRMContextMenuTextView+macOS.h" #import "ENRMImageAttachment.h" #import "ENRMMarkdownParser.h" @@ -112,6 +113,8 @@ @implementation EnrichedMarkdownText { NSWritingDirection _resolvedLayoutDirection; ENRMDirtyFlags _dirtyFlags; + + ENRMAtomicSize _lastCommittedSize; } + (ComponentDescriptorProvider)componentDescriptorProvider @@ -142,6 +145,11 @@ - (CGSize)measureSize:(CGFloat)maxWidth return size; } +- (CGSize)lastCommittedLayoutSize +{ + return _lastCommittedSize.load(); +} + - (BOOL)hasRenderedMarkdown:(NSString *)markdown { return _renderedMarkdown != nil && [_renderedMarkdown isEqualToString:markdown]; @@ -170,6 +178,8 @@ - (void)updateLayoutMetrics:(const LayoutMetrics &)layoutMetrics { [super updateLayoutMetrics:layoutMetrics oldLayoutMetrics:oldLayoutMetrics]; + _lastCommittedSize.store(CGSizeMake(layoutMetrics.frame.size.width, layoutMetrics.frame.size.height)); + NSWritingDirection resolved = _resolvedLayoutDirection; if (layoutMetrics.layoutDirection == LayoutDirection::RightToLeft) { resolved = NSWritingDirectionRightToLeft; diff --git a/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h new file mode 100644 index 00000000..667d605f --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/internals/ENRMViewFreeMeasurement.h @@ -0,0 +1,307 @@ +#pragma once + +#import "ENRMFeatureFlags.h" +#import "ENRMMarkdownParser.h" +#import "ENRMTextRenderer.h" +#import "ENRMTextViewSetup.h" +#import "ImageRequestHeaderUtils.h" +#import "MarkdownASTNode.h" +#import "ParagraphStyleUtils.h" +#import "RenderedMarkdownSegment.h" +#import "SegmentRenderer.h" +#import "StreamingMarkdownFilter.h" +#import "StylePropsUtils.h" +#import "TableContainerView.h" +#include +#if ENRICHED_MARKDOWN_MATH +#import "ENRMMathContainerView.h" +#endif + +/** + * View-free markdown measurement (issue #550). + * + * The previous measurement path built throwaway component views on the main + * thread via `dispatch_sync` — a deadlock when `measureContent` runs under + * RN's locked commit fallback while a main-thread committer (Reanimated) is + * blocked on the same mutex, and a serialization bottleneck (streaming jank) + * even when it doesn't deadlock. + * + * These pipelines measure on the calling thread with no view and no + * main-thread dependency, mirroring RN core's `RCTTextLayoutManager` (which + * measures every `` this way in production) and this library's own + * Android `MeasurementStore`: + * + * - `ENRMMeasureMarkdownViewFree` (EnrichedMarkdownText): parse (md4c) → + * `ENRMRenderASTNodes` (the SAME renderer the visible view uses, so + * measured layout ≡ rendered layout) → a fresh per-call + * NSTextStorage/NSLayoutManager/NSTextContainer stack → shared finalize. + * - `ENRMMeasureSegmentedMarkdownViewFree` (EnrichedMarkdown): the iOS + * counterpart of Android's `MeasurementStore.measureAndCacheSplit` — split + * the AST into segments via the same `ENRMRenderSegmentsFromAST` the view + * uses, then walk them exactly like the view's + * `computeSegmentLayoutForWidth:`: text via the TextKit stack, tables via + * `+[TableContainerView measureHeightForTableNode:…]`, math via + * `+[ENRMMathContainerView measureHeightForLatex:…]`, with the same + * margin arithmetic. + * + * TextKit objects are thread-CONFINED, not main-thread-only: safe off main as + * long as one thread owns a given stack (Apple-sanctioned; the + * Texture/AsyncDisplayKit pattern). Allocating the stack per call — exactly + * what `RCTTextLayoutManager` does — makes confinement trivially true. + * `boundingRectWithSize:` (tables, math fallback) is documented thread-safe. + * + * Ambient-state rules for this path: + * - Font scale comes in as a parameter (from `LayoutContext::fontSizeMultiplier`), + * never from `RCTFontSizeMultiplier()` (UIKit read). + * - Pixel rounding uses `LayoutContext::pointScaleFactor`, never + * `RCTScreenScale()` (dispatch_syncs to main on first use). + * - The resolved layout direction comes from `LayoutConstraints::layoutDirection`, + * never from `RCTI18nUtil`. + * - The geometry config must match the visible view's + * (`ENRMConfigureMarkdownTextView`): zero insets, `lineFragmentPadding = 0`, + * `allowsNonContiguousLayout = NO`. The view's custom `TextViewLayoutManager` + * subclass only draws backgrounds and does not affect geometry. + * - Each entry point drains its own `@autoreleasepool`. The calling JS/layout + * thread has no runloop draining pools, and a full measure autoreleases + * thousands of objects (AST, attributed string, TextKit stack, fonts) — the + * old main-thread path silently leaned on the main runloop's per-cycle + * drain, and without a local pool sustained re-measurement accumulates + * until jetsam kills the app. + * + * RaTeX concurrency (math segments and lazily-measured inline math + * attachments now parse on the calling thread, possibly concurrently with the + * render thread): verified safe against RaTeX sources. The Swift engine is + * stateless per call, font registration is NSLock-guarded and idempotent + * (RaTeXFontLoader), the FFI's last-error slot is thread_local, the Rust + * core's only statics are immutable OnceLock/LazyLock tables, and renderer + * font caches are per-call locals. + */ + +template static inline ENRMMd4cFlags *ENRMMd4cFlagsFromProps(const Md4cFlagsT &props) +{ + ENRMMd4cFlags *flags = [ENRMMd4cFlags defaultFlags]; + flags.underline = props.underline; + flags.superscript = props.superscript; + flags.subscript = props.subscript; + flags.latexMath = props.latexMath; + flags.highlight = props.highlight; + return flags; +} + +/** + * Builds a StyleConfig from props alone, replicating the mock-view recipe + * field for field: a fresh config receives the props' style diffed against a + * default-constructed style struct (the mock view's `updateProps + * oldProps:nullptr` applied the same diff against default props), then the + * font scale, max multiplier, and image request headers. + */ +template +static inline StyleConfig *ENRMStyleConfigFromProps(const PropsT &typedProps, CGFloat fontScale) +{ + StyleConfig *config = [[StyleConfig alloc] init]; + using StyleT = std::remove_cv_t>; + static const StyleT kDefaultStyle{}; + applyMarkdownStyleToConfig(config, typedProps.markdownStyle, kDefaultStyle); + [config setFontScaleMultiplier:fontScale]; + [config setMaxFontSizeMultiplier:typedProps.maxFontSizeMultiplier]; + [config setImageRequestHeaders:ENRMImageRequestHeadersFromProps(typedProps.imageRequestHeaders)]; + return config; +} + +/** + * Lays out an already-rendered attributed string in a fresh, call-confined + * TextKit stack and finalizes with the shared algorithm + * (`ENRMFinalizeMeasuredTextSize`). Returns CGSizeZero for empty text — the + * same result the view path produces, and a guard against TextKit's + * empty-string layout freeze (see RCTTextLayoutManager). + */ +static inline CGSize ENRMMeasureAttributedTextViewFree(NSAttributedString *text, CGFloat maxWidth, StyleConfig *config, + BOOL allowTrailingMargin, CGFloat lastElementMarginBottom, + CGFloat pointScaleFactor) +{ + if (text.length == 0) { + return CGSizeZero; + } + + NSTextContainer *textContainer = [[NSTextContainer alloc] initWithSize:CGSizeMake(maxWidth, CGFLOAT_MAX)]; + textContainer.lineFragmentPadding = 0; + NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init]; + layoutManager.allowsNonContiguousLayout = NO; + [layoutManager addTextContainer:textContainer]; + NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:text]; + [textStorage addLayoutManager:layoutManager]; + + [layoutManager ensureLayoutForTextContainer:textContainer]; + ENRMTextLayoutResult layout; + layout.usedRect = [layoutManager usedRectForTextContainer:textContainer]; + layout.extraLineFragmentRect = layoutManager.extraLineFragmentRect; + + return ENRMFinalizeMeasuredTextSize(layout, layoutManager, text, maxWidth, config, allowTrailingMargin, + lastElementMarginBottom, pointScaleFactor); +} + +/** + * Measures EnrichedMarkdownText content from props alone, on the calling + * thread. The line-break strategy and writing-direction mode are resolved + * from the props exactly as `updateProps:` resolves them. Falls back to + * (maxWidth × system-16 line height) for empty markdown, parse failures, and + * empty render output — the same fallback `measureSize:` produced. + */ +template +static inline CGSize ENRMMeasureMarkdownViewFree(const PropsT &typedProps, CGFloat maxWidth, CGFloat fontScale, + CGFloat pointScaleFactor, NSWritingDirection resolvedLayoutDirection) +{ + @autoreleasepool { + CGSize fallback = CGSizeMake(maxWidth, UIFontLineHeight([UIFont systemFontOfSize:16.0])); + if (typedProps.markdown.empty()) { + return fallback; + } + + NSString *markdown = [[NSString alloc] initWithUTF8String:typedProps.markdown.c_str()]; + StyleConfig *config = ENRMStyleConfigFromProps(typedProps, fontScale); + + ENRMMd4cFlags *flags = ENRMMd4cFlagsFromProps(typedProps.md4cFlags); + ENRMMarkdownParser *parser = [[ENRMMarkdownParser alloc] init]; + MarkdownASTNode *ast = [parser parseMarkdown:markdown flags:flags]; + if (!ast) { + return fallback; + } + + NSLineBreakStrategy lineBreakStrategy = + ENRMResolveLineBreakStrategy([[NSString alloc] initWithUTF8String:typedProps.lineBreakStrategyIOS.c_str()]); + ENRMRenderResult *result = + ENRMRenderASTNodes(ast.children, config, typedProps.allowTrailingMargin, typedProps.allowFontScaling, + typedProps.maxFontSizeMultiplier, lineBreakStrategy); + NSMutableAttributedString *text = result.attributedText; + if (text.length == 0) { + return fallback; + } + + ENRMWritingDirectionMode writingDirectionMode = + ENRMResolveWritingDirectionMode([[NSString alloc] initWithUTF8String:typedProps.writingDirection.c_str()]); + ENRMApplyWritingDirectionMode(text, writingDirectionMode, resolvedLayoutDirection); + + CGSize size = ENRMMeasureAttributedTextViewFree(text, maxWidth, config, typedProps.allowTrailingMargin, + result.lastElementMarginBottom, pointScaleFactor); + if (size.height == 0) { + return fallback; + } + return size; + } +} + +/** + * Measures segmented EnrichedMarkdown content from props alone, on the + * calling thread, replicating the view path end to end: the streaming table + * filter (`ENRMRenderableMarkdownForStreaming` with the props' + * streamingConfig.tableMode), the segment split and per-segment rendering + * (`ENRMRenderSegmentsFromAST` + per-text-segment writing direction — the + * same calls `renderMarkdownContent:` makes), then the walk from + * `computeSegmentLayoutForWidth:`: text segments include their trailing + * margin unless last (`!isLast || allowTrailingMargin`), table and math + * segments add their config marginTop before and marginBottom after under the + * same condition, tables report full width. Finishes with `measureSize:`'s + * pixel rounding (width capped at maxWidth) and its + * (maxWidth × system-16 line height) fallback for empty/failed content. + */ +template +static inline CGSize ENRMMeasureSegmentedMarkdownViewFree(const PropsT &typedProps, CGFloat maxWidth, CGFloat fontScale, + CGFloat pointScaleFactor, + NSWritingDirection resolvedLayoutDirection) +{ + @autoreleasepool { + CGSize fallback = CGSizeMake(maxWidth, UIFontLineHeight([UIFont systemFontOfSize:16.0])); + if (typedProps.markdown.empty()) { + return fallback; + } + + NSString *markdown = [[NSString alloc] initWithUTF8String:typedProps.markdown.c_str()]; + + if (typedProps.streamingAnimation) { + NSString *tableModeStr = [[NSString alloc] initWithUTF8String:typedProps.streamingConfig.tableMode.c_str()]; + ENRMTableStreamingMode tableStreamingMode = + [tableModeStr isEqualToString:@"hidden"] ? ENRMTableStreamingModeHidden : ENRMTableStreamingModeProgressive; + markdown = ENRMRenderableMarkdownForStreaming(markdown, tableStreamingMode); + if (markdown.length == 0) { + return fallback; + } + } + + StyleConfig *config = ENRMStyleConfigFromProps(typedProps, fontScale); + + ENRMMd4cFlags *flags = ENRMMd4cFlagsFromProps(typedProps.md4cFlags); + ENRMMarkdownParser *parser = [[ENRMMarkdownParser alloc] init]; + MarkdownASTNode *ast = [parser parseMarkdown:markdown flags:flags]; + if (!ast) { + return fallback; + } + + NSLineBreakStrategy lineBreakStrategy = + ENRMResolveLineBreakStrategy([[NSString alloc] initWithUTF8String:typedProps.lineBreakStrategyIOS.c_str()]); + ENRMWritingDirectionMode writingDirectionMode = + ENRMResolveWritingDirectionMode([[NSString alloc] initWithUTF8String:typedProps.writingDirection.c_str()]); + + NSArray *segments = + ENRMRenderSegmentsFromAST(ast, config, typedProps.allowTrailingMargin, typedProps.allowFontScaling, + typedProps.maxFontSizeMultiplier, lineBreakStrategy); + for (ENRMRenderedSegment *segment in segments) { + if (segment.kind == ENRMSegmentKindText && segment.textResult) { + ENRMApplyWritingDirectionMode(segment.textResult.attributedText, writingDirectionMode, resolvedLayoutDirection); + } + } + + if (segments.count == 0) { + return fallback; + } + + CGFloat yOffset = 0.0; + CGFloat maxContentWidth = 0.0; + const NSUInteger lastIndex = segments.count - 1; + + for (NSUInteger i = 0; i < segments.count; i++) { + ENRMRenderedSegment *segment = segments[i]; + const BOOL isLast = (i == lastIndex); + const BOOL shouldAddBottomMargin = (!isLast || typedProps.allowTrailingMargin); + + if (segment.kind == ENRMSegmentKindText && segment.textResult) { + CGSize textSize = ENRMMeasureAttributedTextViewFree( + segment.textResult.attributedText, maxWidth, config, shouldAddBottomMargin, + segment.textResult.lastElementMarginBottom, pointScaleFactor); + yOffset += textSize.height; + maxContentWidth = MAX(maxContentWidth, textSize.width); + } else if (segment.kind == ENRMSegmentKindTable && segment.tableSegment) { + yOffset += config.tableMarginTop; + yOffset += [TableContainerView measureHeightForTableNode:segment.tableSegment.tableNode + config:config + allowFontScaling:typedProps.allowFontScaling + maxFontSizeMultiplier:typedProps.maxFontSizeMultiplier + writingDirectionMode:writingDirectionMode + resolvedLayoutDirection:resolvedLayoutDirection]; + maxContentWidth = maxWidth; + if (shouldAddBottomMargin) { + yOffset += config.tableMarginBottom; + } + } +#if ENRICHED_MARKDOWN_MATH + else if (segment.kind == ENRMSegmentKindMath && segment.mathSegment) { + yOffset += config.mathMarginTop; + yOffset += [ENRMMathContainerView measureHeightForLatex:segment.mathSegment.latex + config:config + maxWidth:maxWidth]; + maxContentWidth = maxWidth; + if (shouldAddBottomMargin) { + yOffset += config.mathMarginBottom; + } + } +#endif + } + + if (yOffset == 0) { + return fallback; + } + + CGFloat measuredWidth = MIN(ceil(maxContentWidth * pointScaleFactor) / pointScaleFactor, maxWidth); + CGFloat measuredHeight = ceil(yOffset * pointScaleFactor) / pointScaleFactor; + return CGSizeMake(measuredWidth, measuredHeight); + } +} diff --git a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.h b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.h index 0fcac16a..d2f7cb55 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.h +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownShadowNode.h @@ -24,8 +24,7 @@ class EnrichedMarkdownShadowNode : public ConcreteViewShadowNode @@ -42,32 +43,25 @@ } } -id EnrichedMarkdownShadowNode::setupMockEnrichedMarkdown_(CGFloat width) const -{ - EnrichedMarkdown *mockView = [[EnrichedMarkdown alloc] initWithFrame:CGRectMake(20000, 20000, width, 1000)]; - - const auto props = this->getProps(); - [mockView updateProps:props oldProps:nullptr]; - - const auto &typedProps = *std::static_pointer_cast(props); - if (!typedProps.markdown.empty()) { - NSString *markdown = [NSString stringWithUTF8String:typedProps.markdown.c_str()]; - [mockView renderMarkdownSynchronously:markdown]; - } - - return mockView; -} - Size EnrichedMarkdownShadowNode::measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const { - const auto &typedProps = *std::static_pointer_cast(this->getProps()); + const auto propsHandle = std::static_pointer_cast(this->getProps()); + const auto &typedProps = *propsHandle; const int receivedCounter = getStateData().getHeightRecalculationCounter(); + const EnrichedMarkdownProps *props = propsHandle.get(); + CGFloat pointScaleFactor = layoutContext.pointScaleFactor; + NSWritingDirection resolvedLayoutDirection = layoutConstraints.layoutDirection == LayoutDirection::RightToLeft + ? NSWritingDirectionRightToLeft + : NSWritingDirectionLeftToRight; return ENRMMeasureMarkdownContent( typedProps, getStateData().getComponentViewRef(), receivedCounter, lastExactMeasurementCounter_, - MarkdownFlavor::GitHub, layoutConstraints, - ^(CGFloat width) { return (EnrichedMarkdown *)setupMockEnrichedMarkdown_(width); }); + MarkdownFlavor::GitHub, layoutContext, layoutConstraints, + ^(EnrichedMarkdown *view, CGFloat maxWidth, CGFloat fontScale) { + return ENRMMeasureSegmentedMarkdownViewFree(*props, maxWidth, fontScale, pointScaleFactor, + resolvedLayoutDirection); + }); } } // namespace facebook::react diff --git a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.h b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.h index 24add5ed..6545010b 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.h +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.h @@ -26,8 +26,7 @@ class EnrichedMarkdownTextShadowNode Size measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const override; - static ShadowNodeTraits BaseTraits() - { + static ShadowNodeTraits BaseTraits() { auto traits = ConcreteViewShadowNode::BaseTraits(); traits.set(ShadowNodeTraits::Trait::LeafYogaNode); traits.set(ShadowNodeTraits::Trait::MeasurableYogaNode); @@ -37,9 +36,6 @@ class EnrichedMarkdownTextShadowNode private: int localHeightRecalculationCounter_{0}; mutable int lastExactMeasurementCounter_{0}; - - // Creates mock view off-screen for initial measurement when real view doesn't exist - id setupMockEnrichedMarkdownText_(CGFloat width) const; }; } // namespace facebook::react diff --git a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.mm b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.mm index 60f5f6c8..5b5c7f64 100644 --- a/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.mm +++ b/packages/react-native-enriched-markdown/ios/internals/EnrichedMarkdownTextShadowNode.mm @@ -1,4 +1,5 @@ #import "EnrichedMarkdownTextShadowNode.h" +#import "ENRMViewFreeMeasurement.h" #import "EnrichedMarkdownText.h" #import "ShadowMeasurementUtils.h" #import @@ -43,32 +44,24 @@ } } -id EnrichedMarkdownTextShadowNode::setupMockEnrichedMarkdownText_(CGFloat width) const -{ - EnrichedMarkdownText *mockView = [[EnrichedMarkdownText alloc] initWithFrame:CGRectMake(20000, 20000, width, 1000)]; - - const auto props = this->getProps(); - [mockView updateProps:props oldProps:nullptr]; - - const auto &typedProps = *std::static_pointer_cast(props); - if (!typedProps.markdown.empty()) { - NSString *markdown = [NSString stringWithUTF8String:typedProps.markdown.c_str()]; - [mockView renderMarkdownSynchronously:markdown]; - } - - return mockView; -} - Size EnrichedMarkdownTextShadowNode::measureContent(const LayoutContext &layoutContext, const LayoutConstraints &layoutConstraints) const { - const auto &typedProps = *std::static_pointer_cast(this->getProps()); + const auto propsHandle = std::static_pointer_cast(this->getProps()); + const auto &typedProps = *propsHandle; const int receivedCounter = getStateData().getHeightRecalculationCounter(); + const EnrichedMarkdownTextProps *props = propsHandle.get(); + CGFloat pointScaleFactor = layoutContext.pointScaleFactor; + NSWritingDirection resolvedLayoutDirection = layoutConstraints.layoutDirection == LayoutDirection::RightToLeft + ? NSWritingDirectionRightToLeft + : NSWritingDirectionLeftToRight; return ENRMMeasureMarkdownContent( typedProps, getStateData().getComponentViewRef(), receivedCounter, lastExactMeasurementCounter_, - MarkdownFlavor::CommonMark, layoutConstraints, - ^(CGFloat width) { return (EnrichedMarkdownText *)setupMockEnrichedMarkdownText_(width); }); + MarkdownFlavor::CommonMark, layoutContext, layoutConstraints, + ^(EnrichedMarkdownText *view, CGFloat maxWidth, CGFloat fontScale) { + return ENRMMeasureMarkdownViewFree(*props, maxWidth, fontScale, pointScaleFactor, resolvedLayoutDirection); + }); } } // namespace facebook::react diff --git a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h index 5f1e5973..7f0d5db1 100644 --- a/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h +++ b/packages/react-native-enriched-markdown/ios/internals/ShadowMeasurementUtils.h @@ -6,31 +6,43 @@ #include #include #include +#include #include namespace facebook::react { -// measureContent runs on Yoga's background layout thread and uses -// dispatch_sync to main for UIKit reads. Safe because RN never -// synchronously joins the layout queue from main. A synchronous -// layout flush from main would deadlock. - -static inline CGFloat ENRMFontScaleForMeasurement(bool allowFontScaling) +// Threading contract (issue #550): measureContent may run on any thread while +// RN holds commit locks that main-thread committers (e.g. Reanimated) also +// acquire — with `preventShadowTreeCommitExhaustion`, the JS thread re-runs +// layout under `revisionMutexRecursive_` after 3 failed optimistic commits. +// Any dispatch_sync to main from this path can therefore deadlock: JS waits +// for main, main waits for the mutex JS holds (watchdog kill 0x8BADF00D). +// Nothing in the measurement path may wait on the main thread. +// EnrichedMarkdownText and EnrichedMarkdown both measure view-free +// (ENRMViewFreeMeasurement.h) and comply fully. The one known remaining +// violation is the text input's own measureContent +// (EnrichedMarkdownTextInputShadowNode.mm), which measures live editable +// UITextView state and needs its own strategy. + +/** + * Font scale (Dynamic Type multiplier) for measurement, without touching the + * main thread. + * + * Reads `LayoutContext::fontSizeMultiplier` instead of calling + * `RCTFontSizeMultiplier()` (a UIKit read that previously forced a + * dispatch_sync to main on every measure — a deadlock window under the locked + * commit fallback, see the contract above). RN maintains the multiplier for + * us: `RCTFabricSurface::_updateLayoutContext` sets it on main from + * `RCTFontSizeMultiplier()` and re-runs `constraintLayout` whenever + * `UIContentSizeCategoryDidChangeNotification` fires, so every Dynamic Type + * change re-measures with the fresh value. This is the same source + * `ParagraphShadowNode` uses for core text. A measure racing a Dynamic Type + * change at worst caches under the outgoing scale, which is never read again + * once the re-layout lands. + */ +static inline CGFloat ENRMFontScaleForMeasurement(bool allowFontScaling, const LayoutContext &layoutContext) { - if (!allowFontScaling) { - return 1.0; - } - - __block CGFloat fontScale = 1.0; - void (^readFontScale)(void) = ^{ fontScale = RCTFontSizeMultiplier(); }; - - if ([NSThread isMainThread]) { - readFontScale(); - } else { - dispatch_sync(dispatch_get_main_queue(), readFontScale); - } - - return fontScale; + return allowFontScaling ? layoutContext.fontSizeMultiplier : 1.0; } static inline Size ENRMClampMeasuredSize(CGSize size, const LayoutConstraints &layoutConstraints) @@ -58,11 +70,25 @@ static inline bool ENRMPropsNeedExactStreamingMeasurement(const PropsT &oldProps computeStyleFingerprint(oldProps.markdownStyle) != computeStyleFingerprint(newProps.markdownStyle); } +/** + * Shared measureContent implementation for the markdown component views. + * + * Owns the thread-safe layers — the streaming fast path (lock-free + * `ENRMAtomicSize` mailbox published by `updateLayoutMetrics:`), the + * LRU measurement cache, and the `layoutContext`-sourced font scale — then + * delegates cache misses to `measureUncached`, through which both components + * plug their view-free pipeline (`ENRMMeasureMarkdownViewFree` / + * `ENRMMeasureSegmentedMarkdownViewFree`); the resolved view is still handed + * to the block for strategies that can use it. `fontScale` handed to the + * block is the real multiplier even when streaming skips the cache — + * rendering must always use it. + */ template -static inline Size ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr &componentViewRef, - int receivedCounter, int &lastExactMeasurementCounter, - MarkdownFlavor flavor, const LayoutConstraints &layoutConstraints, - ViewT * (^createMockView)(CGFloat width)) +static inline Size +ENRMMeasureMarkdownContent(const PropsT &typedProps, const std::shared_ptr &componentViewRef, int receivedCounter, + int &lastExactMeasurementCounter, MarkdownFlavor flavor, const LayoutContext &layoutContext, + const LayoutConstraints &layoutConstraints, + CGSize (^measureUncached)(ViewT *view, CGFloat maxWidth, CGFloat fontScale)) { CGFloat maxWidth = layoutConstraints.maximumSize.width; @@ -70,26 +96,14 @@ static inline Size ENRMMeasureMarkdownContent(const PropsT &typedProps, const st ViewT *view = weakWrapper ? (ViewT *)weakWrapper.object : nil; if (typedProps.streamingAnimation && view && receivedCounter <= lastExactMeasurementCounter) { - __block CGSize currentSize = CGSizeZero; - void (^readCurrentSize)(void) = ^{ - if (view.bounds.size.width > 0 && view.bounds.size.height > 0) { - currentSize = view.bounds.size; - } - }; - - if ([NSThread isMainThread]) { - readCurrentSize(); - } else { - dispatch_sync(dispatch_get_main_queue(), readCurrentSize); - } - - if (currentSize.height > 0) { + CGSize currentSize = [view lastCommittedLayoutSize]; + if (currentSize.width > 0 && currentSize.height > 0) { return ENRMClampMeasuredSize(currentSize, layoutConstraints); } } const bool shouldUseMeasurementCache = !typedProps.streamingAnimation; - CGFloat fontScale = shouldUseMeasurementCache ? ENRMFontScaleForMeasurement(typedProps.allowFontScaling) : 1.0; + CGFloat fontScale = ENRMFontScaleForMeasurement(typedProps.allowFontScaling, layoutContext); if (shouldUseMeasurementCache && !typedProps.markdown.empty()) { auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor); @@ -99,26 +113,7 @@ static inline Size ENRMMeasureMarkdownContent(const PropsT &typedProps, const st } } - __block CGSize size; - NSString *currentMarkdown = typedProps.markdown.empty() ? nil : @(typedProps.markdown.c_str()); - size_t styleFingerprint = - computeStyleFingerprint(typedProps.markdownStyle) ^ std::hash{}(typedProps.allowTrailingMargin); - - void (^measureBlock)(void) = ^{ - if (view && (typedProps.streamingAnimation || ([view hasRenderedMarkdown:currentMarkdown] && - [view hasRenderedWithStyleFingerprint:styleFingerprint]))) { - size = [view measureSize:maxWidth]; - } else { - ViewT *mockView = createMockView(maxWidth); - size = [mockView measureSize:maxWidth]; - } - }; - - if ([NSThread isMainThread]) { - measureBlock(); - } else { - dispatch_sync(dispatch_get_main_queue(), measureBlock); - } + CGSize size = measureUncached(view, maxWidth, fontScale); if (shouldUseMeasurementCache && !typedProps.markdown.empty()) { auto cacheKey = buildMeasurementCacheKey(typedProps, maxWidth, fontScale, flavor); diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMAtomicSize.h b/packages/react-native-enriched-markdown/ios/utils/ENRMAtomicSize.h new file mode 100644 index 00000000..8228aa49 --- /dev/null +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMAtomicSize.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include + +/** + * Lock-free size mailbox (issue #550). + * + * `measureContent` may run on the JS/layout thread while React Native holds + * the ShadowTree revision mutex (`preventShadowTreeCommitExhaustion` re-runs + * the entire commit — layout included — under `revisionMutexRecursive_` after + * three failed optimistic commits). A main-thread committer (e.g. Reanimated) + * can already be blocked on that same mutex, so any `dispatch_sync` to main + * from the measure path can deadlock: JS waits for main, main waits for the + * mutex JS holds. iOS kills the frozen app ~60s later (watchdog 0x8BADF00D). + * + * Reading `view.bounds` from a background thread is not safe either, so the + * component view *publishes* its last committed size here (from + * `updateLayoutMetrics:`, always on main) and the measure path *loads* it + * wait-free from any thread. + * + * Both dimensions are packed as IEEE-754 floats into one `uint64_t` so a + * single lock-free atomic covers the pair — a reader can never observe a new + * width with an old height (no torn reads). Relaxed memory ordering suffices: + * the value is self-contained and no other memory is published through it. + */ +class ENRMAtomicSize { +public: + void store(CGSize size) + { + bits_.store(pack(size), std::memory_order_relaxed); + } + + CGSize load() const + { + return unpack(bits_.load(std::memory_order_relaxed)); + } + +private: + static uint64_t pack(CGSize size) + { + float width = (float)size.width; + float height = (float)size.height; + uint32_t widthBits; + uint32_t heightBits; + memcpy(&widthBits, &width, sizeof(widthBits)); + memcpy(&heightBits, &height, sizeof(heightBits)); + return ((uint64_t)widthBits << 32) | heightBits; + } + + static CGSize unpack(uint64_t bits) + { + uint32_t widthBits = (uint32_t)(bits >> 32); + uint32_t heightBits = (uint32_t)bits; + float width; + float height; + memcpy(&width, &widthBits, sizeof(width)); + memcpy(&height, &heightBits, sizeof(height)); + return CGSizeMake(width, height); + } + + std::atomic bits_{0}; +}; diff --git a/packages/react-native-enriched-markdown/ios/utils/ENRMTextViewSetup.h b/packages/react-native-enriched-markdown/ios/utils/ENRMTextViewSetup.h index 28d143a6..0151d0fc 100644 --- a/packages/react-native-enriched-markdown/ios/utils/ENRMTextViewSetup.h +++ b/packages/react-native-enriched-markdown/ios/utils/ENRMTextViewSetup.h @@ -30,26 +30,32 @@ static inline void ENRMDetachLayoutManager(ENRMPlatformTextView *textView) } } -static inline CGSize ENRMMeasureMarkdownText(ENRMPlatformTextView *textView, CGFloat maxWidth, StyleConfig *config, - BOOL allowTrailingMargin, CGFloat lastElementMarginBottom) +/// Turns a raw TextKit layout pass into the final measured size. Shared by +/// the view-backed path (`ENRMMeasureMarkdownText`) and the view-free path +/// (`ENRMMeasureMarkdownViewFree`) so both measure with one algorithm. +/// +/// Steps, in order: +/// - Multiline pin: if the first line fragment doesn't span all glyphs the +/// text wrapped, and returning the tight usedRect width would let flexShrink +/// narrow the view below maxWidth, re-wrapping at the narrower width and +/// mismatching the measured height — so multiline content reports maxWidth, +/// while single-line content keeps its tight width for shrink-to-fit. +/// - Subtract the extra line fragment (iOS counts a trailing newline's empty +/// line fragment into usedRect). +/// - Add code-block padding when the last element is a code block, and the +/// trailing margin when enabled. +/// - Round up to the pixel grid of `scale` (pass `RCTScreenScale()` on main; +/// off-main callers must pass `LayoutContext::pointScaleFactor` instead — +/// `RCTScreenScale()` dispatch_syncs to main on first use, which the +/// measure path must never do, see issue #550). +static inline CGSize ENRMFinalizeMeasuredTextSize(ENRMTextLayoutResult layout, NSLayoutManager *layoutManager, + NSAttributedString *text, CGFloat maxWidth, StyleConfig *config, + BOOL allowTrailingMargin, CGFloat lastElementMarginBottom, + CGFloat scale) { - NSAttributedString *text = ENRMGetAttributedText(textView); - if (text.length == 0) { - return CGSizeZero; - } - - ENRMTextLayoutResult layout = ENRMMeasureTextLayout(textView, maxWidth); - CGFloat measuredWidth = layout.usedRect.size.width; CGFloat measuredHeight = layout.usedRect.size.height; - // Detect multiline content by checking if the layout produced more than one - // line fragment. When text wraps, returning the tight usedRect width lets - // flexShrink narrow the view below maxWidth, causing re-wrap at the narrower - // width and a height mismatch. Pin to maxWidth for multiline content so Yoga - // assigns the width the text was actually measured at. Single-line content - // keeps its tight width for shrink-to-fit behavior. - NSLayoutManager *layoutManager = textView.layoutManager; NSUInteger glyphCount = [layoutManager numberOfGlyphs]; if (glyphCount > 0) { NSRange firstLineRange; @@ -71,8 +77,20 @@ static inline CGSize ENRMMeasureMarkdownText(ENRMPlatformTextView *textView, CGF measuredHeight += lastElementMarginBottom; } - CGFloat scale = RCTScreenScale(); return CGSizeMake(ceil(measuredWidth * scale) / scale, ceil(measuredHeight * scale) / scale); } +static inline CGSize ENRMMeasureMarkdownText(ENRMPlatformTextView *textView, CGFloat maxWidth, StyleConfig *config, + BOOL allowTrailingMargin, CGFloat lastElementMarginBottom) +{ + NSAttributedString *text = ENRMGetAttributedText(textView); + if (text.length == 0) { + return CGSizeZero; + } + + ENRMTextLayoutResult layout = ENRMMeasureTextLayout(textView, maxWidth); + return ENRMFinalizeMeasuredTextSize(layout, textView.layoutManager, text, maxWidth, config, allowTrailingMargin, + lastElementMarginBottom, RCTScreenScale()); +} + NS_ASSUME_NONNULL_END diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h b/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h index 5aa14cef..6d27f309 100644 --- a/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h +++ b/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.h @@ -14,6 +14,14 @@ NS_ASSUME_NONNULL_BEGIN - (CGFloat)measureHeight:(CGFloat)maxWidth; +/// View-free math-block height for shadow-node measurement (issue #550): +/// parses the LaTeX through the same RaTeX bridge `applyLatex:` uses and +/// applies the same padding math as `measureHeight:` — including the wrapped +/// source-fallback path when RaTeX cannot parse — without creating any view. +/// Runs on the calling thread. Like every member of this class, only +/// implemented when ENRICHED_MARKDOWN_MATH is on — guard call sites. ++ (CGFloat)measureHeightForLatex:(NSString *)latex config:(StyleConfig *)config maxWidth:(CGFloat)maxWidth; + @property (nonatomic, strong) StyleConfig *config; @property (nonatomic, copy, readonly) NSString *cachedLatex; @property (nonatomic, strong, nullable) ENRMAccessibilityLabels *accessibilityLabels; diff --git a/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m b/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m index c7af6219..542e198c 100644 --- a/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m +++ b/packages/react-native-enriched-markdown/ios/views/ENRMMathContainerView.m @@ -183,6 +183,29 @@ - (CGFloat)measureHeight:(CGFloat)maxWidth return [self mathViewIntrinsicSize].height + padding * 2; } ++ (CGFloat)measureHeightForLatex:(NSString *)latex config:(StyleConfig *)config maxWidth:(CGFloat)maxWidth +{ + CGFloat padding = config.mathPadding; + + ENRMRaTeXRenderResult *result = [ENRMRaTeXBridge parse:latex + displayMode:YES + fontSize:config.mathFontSize + color:config.mathColor]; + if (result) { + return ceil(result.totalHeight) + padding * 2; + } + + NSAttributedString *fallbackSource = ENRMMathFallbackString(latex, @"$$", config.mathFontSize, config.mathColor); + if (!fallbackSource) { + return 0; + } + CGFloat available = MAX(maxWidth - padding * 2, 1); + CGRect bounds = [fallbackSource boundingRectWithSize:CGSizeMake(available, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin + context:nil]; + return ceil(bounds.size.height) + padding * 2; +} + - (CGFloat)alignedOriginXForWidth:(CGFloat)formulaWidth inBounds:(CGFloat)boundsWidth padding:(CGFloat)padding { CGFloat available = boundsWidth - padding * 2; diff --git a/packages/react-native-enriched-markdown/ios/views/TableContainerView.h b/packages/react-native-enriched-markdown/ios/views/TableContainerView.h index 4fe92ed2..5c21bfef 100644 --- a/packages/react-native-enriched-markdown/ios/views/TableContainerView.h +++ b/packages/react-native-enriched-markdown/ios/views/TableContainerView.h @@ -18,6 +18,20 @@ typedef void (^TableLinkPressBlock)(NSString *url); - (CGFloat)measureHeight:(CGFloat)maxWidth; +/// View-free table height for shadow-node measurement (issue #550): renders +/// the cells' attributed strings and runs the same column/row layout the view +/// runs in `applyTableNode:`/`computeLayout` — shared statics, so measured and +/// rendered heights cannot drift — without creating any view. Safe on any +/// thread; the iOS counterpart of Android's +/// `TableContainerView.measureTableNodeHeight`. Table height is intrinsic +/// (content-sized columns, horizontal scroll), so no maxWidth parameter. ++ (CGFloat)measureHeightForTableNode:(MarkdownASTNode *)tableNode + config:(StyleConfig *)config + allowFontScaling:(BOOL)allowFontScaling + maxFontSizeMultiplier:(CGFloat)maxFontSizeMultiplier + writingDirectionMode:(ENRMWritingDirectionMode)writingDirectionMode + resolvedLayoutDirection:(NSWritingDirection)resolvedLayoutDirection; + @property (nonatomic, strong) StyleConfig *config; @property (nonatomic, assign) BOOL allowFontScaling; diff --git a/packages/react-native-enriched-markdown/ios/views/TableContainerView.m b/packages/react-native-enriched-markdown/ios/views/TableContainerView.m index f1baaf43..72aca104 100644 --- a/packages/react-native-enriched-markdown/ios/views/TableContainerView.m +++ b/packages/react-native-enriched-markdown/ios/views/TableContainerView.m @@ -28,6 +28,184 @@ @interface TableCellData : NSObject @implementation TableCellData @end +// Cell rendering and layout are view-free statics so the instance path +// (applyTableNode:/computeLayout) and the shadow-measurement class method +// (+measureHeightForTableNode:...) share one implementation and cannot drift +// (issue #550). + +static NSTextAlignment ENRMTableTextAlignmentFromString(NSString *align) +{ + if ([align isEqualToString:@"center"]) + return NSTextAlignmentCenter; + if ([align isEqualToString:@"right"]) + return NSTextAlignmentRight; + return NSTextAlignmentLeft; +} + +static NSString *ENRMTablePlainTextFromNode(MarkdownASTNode *node) +{ + if (!node) + return @""; + NSMutableString *buffer = [node.content mutableCopy] ?: [NSMutableString string]; + for (MarkdownASTNode *child in node.children) { + [buffer appendString:ENRMTablePlainTextFromNode(child)]; + } + return [buffer copy]; +} + +static StyleConfig *ENRMTableCellConfig(StyleConfig *config, BOOL isHeader) +{ + StyleConfig *cellConfig = [config copy]; + + [cellConfig setParagraphFontSize:config.tableFontSize]; + NSString *headerFamily = + config.tableHeaderFontFamily.length > 0 ? config.tableHeaderFontFamily : config.tableFontFamily; + [cellConfig setParagraphFontFamily:isHeader ? headerFamily : config.tableFontFamily]; + [cellConfig setParagraphFontWeight:isHeader ? @"bold" : config.tableFontWeight]; + [cellConfig setParagraphColor:isHeader ? config.tableHeaderTextColor : config.tableColor]; + [cellConfig setParagraphLineHeight:config.tableLineHeight]; + + [cellConfig setParagraphMarginTop:0]; + [cellConfig setParagraphMarginBottom:0]; + + return cellConfig; +} + +static NSMutableAttributedString *ENRMTableRenderCellNode(MarkdownASTNode *cellNode, StyleConfig *cellConfig, + NSTextAlignment alignment, BOOL allowFontScaling, + CGFloat maxFontSizeMultiplier, + ENRMWritingDirectionMode writingDirectionMode, + NSWritingDirection resolvedLayoutDirection) +{ + MarkdownASTNode *temporaryRoot = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; + for (MarkdownASTNode *child in cellNode.children) { + [temporaryRoot addChild:child]; + } + + AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:cellConfig]; + RenderContext *context = [RenderContext new]; + context.allowFontScaling = allowFontScaling; + context.maxFontSizeMultiplier = maxFontSizeMultiplier; + + NSMutableAttributedString *attributedText = [renderer renderRoot:temporaryRoot context:context]; + + [context applyLinkAttributesToString:attributedText]; + + ENRMApplyWritingDirectionMode(attributedText, writingDirectionMode, resolvedLayoutDirection); + + if (alignment != NSTextAlignmentLeft && attributedText.length > 0) { + NSRange fullRange = NSMakeRange(0, attributedText.length); + [attributedText + enumerateAttribute:NSParagraphStyleAttributeName + inRange:fullRange + options:0 + usingBlock:^(NSParagraphStyle *paragraphStyle, NSRange range, BOOL *stop) { + NSMutableParagraphStyle *mutableStyle = + paragraphStyle ? [paragraphStyle mutableCopy] : [[NSMutableParagraphStyle alloc] init]; + mutableStyle.alignment = alignment; + [attributedText addAttribute:NSParagraphStyleAttributeName value:mutableStyle range:range]; + }]; + } + + return attributedText; +} + +static NSArray *> *ENRMTableBuildRows(MarkdownASTNode *tableNode, StyleConfig *config, + BOOL allowFontScaling, CGFloat maxFontSizeMultiplier, + ENRMWritingDirectionMode writingDirectionMode, + NSWritingDirection resolvedLayoutDirection, + NSUInteger *outColCount) +{ + StyleConfig *headerCellConfig = ENRMTableCellConfig(config, YES); + StyleConfig *bodyCellConfig = ENRMTableCellConfig(config, NO); + + NSMutableArray *allRows = [NSMutableArray array]; + NSUInteger colCount = 0; + + for (MarkdownASTNode *section in tableNode.children) { + BOOL isSectionHead = (section.type == MarkdownNodeTypeTableHead); + + for (MarkdownASTNode *rowNode in section.children) { + if (rowNode.type != MarkdownNodeTypeTableRow) + continue; + + NSMutableArray *rowCells = [NSMutableArray array]; + for (MarkdownASTNode *cellNode in rowNode.children) { + TableCellData *cell = [[TableCellData alloc] init]; + cell.isHeader = isSectionHead || (cellNode.type == MarkdownNodeTypeTableHeaderCell); + cell.alignment = ENRMTableTextAlignmentFromString(cellNode.attributes[@"align"]); + cell.plainText = ENRMTablePlainTextFromNode(cellNode); + cell.markdownText = markdownFromASTNodeChildren(cellNode); + + StyleConfig *cellConfig = cell.isHeader ? headerCellConfig : bodyCellConfig; + cell.attributedText = + ENRMTableRenderCellNode(cellNode, cellConfig, cell.alignment, allowFontScaling, maxFontSizeMultiplier, + writingDirectionMode, resolvedLayoutDirection); + [rowCells addObject:cell]; + } + colCount = MAX(colCount, rowCells.count); + [allRows addObject:rowCells]; + } + } + + if (outColCount != NULL) { + *outColCount = colCount; + } + return [allRows copy]; +} + +static void ENRMTableComputeLayout(NSArray *> *rows, NSUInteger colCount, StyleConfig *config, + CGFloat borderWidth, NSMutableArray *_Nullable *_Nullable outColWidths, + NSMutableArray *_Nullable *_Nullable outRowHeights, + CGFloat *_Nullable outTotalWidth, CGFloat *_Nullable outTotalHeight) +{ + // TODO: Consider making minColumnWidth / maxColumnWidth configurable via style props + const CGFloat minimumColumnWidth = 60.0; + const CGFloat maximumColumnWidth = 300.0; + const CGFloat horizontalPadding = config.tableCellPaddingHorizontal * 2; + const CGFloat verticalPadding = config.tableCellPaddingVertical * 2; + + NSMutableArray *colWidths = [NSMutableArray arrayWithCapacity:colCount]; + for (NSUInteger i = 0; i < colCount; i++) + [colWidths addObject:@0]; + + for (NSArray *row in rows) { + for (NSUInteger column = 0; column < row.count; column++) { + CGRect boundingRect = [row[column].attributedText + boundingRectWithSize:CGSizeMake(maximumColumnWidth, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + context:nil]; + CGFloat width = MIN(MAX(ceil(boundingRect.size.width) + horizontalPadding, minimumColumnWidth), + maximumColumnWidth + horizontalPadding); + if (width > [colWidths[column] doubleValue]) + colWidths[column] = @(width); + } + } + + NSMutableArray *rowHeights = [NSMutableArray arrayWithCapacity:rows.count]; + for (NSArray *row in rows) { + CGFloat maxHeight = 0; + for (NSUInteger column = 0; column < row.count; column++) { + CGFloat availableWidth = [colWidths[column] doubleValue] - horizontalPadding; + CGRect boundingRect = [row[column].attributedText + boundingRectWithSize:CGSizeMake(availableWidth, CGFLOAT_MAX) + options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading + context:nil]; + maxHeight = MAX(maxHeight, ceil(boundingRect.size.height) + verticalPadding); + } + [rowHeights addObject:@(maxHeight)]; + } + + if (outColWidths != NULL) + *outColWidths = colWidths; + if (outRowHeights != NULL) + *outRowHeights = rowHeights; + if (outTotalWidth != NULL) + *outTotalWidth = [[colWidths valueForKeyPath:@"@sum.self"] doubleValue] + borderWidth; + if (outTotalHeight != NULL) + *outTotalHeight = [[rowHeights valueForKeyPath:@"@sum.self"] doubleValue] + borderWidth; +} + #if !TARGET_OS_OSX @interface TableContainerView () @end @@ -46,7 +224,6 @@ @implementation TableContainerView { NSMutableArray *_rowHeights; CGFloat _totalTableWidth; CGFloat _totalTableHeight; - CGFloat _borderWidth; NSString *_cachedMarkdown; @@ -58,7 +235,6 @@ - (instancetype)initWithConfig:(StyleConfig *)config self = [super initWithFrame:CGRectZero]; if (self) { _config = config; - _borderWidth = config.tableBorderWidth; _allowFontScaling = YES; _maxFontSizeMultiplier = 0; _enableLinkPreview = YES; @@ -119,74 +295,6 @@ - (void)setupScrollView #endif } -- (StyleConfig *)cellConfigForHeader:(BOOL)isHeader -{ - StyleConfig *cellConfig = [self.config copy]; - - [cellConfig setParagraphFontSize:self.config.tableFontSize]; - NSString *headerFamily = - self.config.tableHeaderFontFamily.length > 0 ? self.config.tableHeaderFontFamily : self.config.tableFontFamily; - [cellConfig setParagraphFontFamily:isHeader ? headerFamily : self.config.tableFontFamily]; - [cellConfig setParagraphFontWeight:isHeader ? @"bold" : self.config.tableFontWeight]; - [cellConfig setParagraphColor:isHeader ? self.config.tableHeaderTextColor : self.config.tableColor]; - [cellConfig setParagraphLineHeight:self.config.tableLineHeight]; - - [cellConfig setParagraphMarginTop:0]; - [cellConfig setParagraphMarginBottom:0]; - - return cellConfig; -} - -- (NSMutableAttributedString *)renderCellNode:(MarkdownASTNode *)cellNode - isHeader:(BOOL)isHeader - cellConfig:(StyleConfig *)cellConfig - alignment:(NSTextAlignment)alignment -{ - - MarkdownASTNode *temporaryRoot = [[MarkdownASTNode alloc] initWithType:MarkdownNodeTypeDocument]; - for (MarkdownASTNode *child in cellNode.children) { - [temporaryRoot addChild:child]; - } - - AttributedRenderer *renderer = [[AttributedRenderer alloc] initWithConfig:cellConfig]; - RenderContext *context = [RenderContext new]; - context.allowFontScaling = self.allowFontScaling; - context.maxFontSizeMultiplier = self.maxFontSizeMultiplier; - - NSMutableAttributedString *attributedText = [renderer renderRoot:temporaryRoot context:context]; - - [context applyLinkAttributesToString:attributedText]; - - ENRMApplyWritingDirectionMode(attributedText, _writingDirectionMode, _resolvedLayoutDirection); - - if (alignment != NSTextAlignmentLeft && attributedText.length > 0) { - NSRange fullRange = NSMakeRange(0, attributedText.length); - [attributedText - enumerateAttribute:NSParagraphStyleAttributeName - inRange:fullRange - options:0 - usingBlock:^(NSParagraphStyle *paragraphStyle, NSRange range, BOOL *stop) { - NSMutableParagraphStyle *mutableStyle = - paragraphStyle ? [paragraphStyle mutableCopy] : [[NSMutableParagraphStyle alloc] init]; - mutableStyle.alignment = alignment; - [attributedText addAttribute:NSParagraphStyleAttributeName value:mutableStyle range:range]; - }]; - } - - return attributedText; -} - -- (NSString *)extractPlainTextFromNode:(MarkdownASTNode *)node -{ - if (!node) - return @""; - NSMutableString *buffer = [node.content mutableCopy] ?: [NSMutableString string]; - for (MarkdownASTNode *child in node.children) { - [buffer appendString:[self extractPlainTextFromNode:child]]; - } - return [buffer copy]; -} - - (NSUInteger)rowCount { return _rows.count; @@ -211,96 +319,47 @@ - (void)applyTableNode:(MarkdownASTNode *)tableNode { [[_gridContainer subviews] makeObjectsPerformSelector:@selector(removeFromSuperview)]; - StyleConfig *headerCellConfig = [self cellConfigForHeader:YES]; - StyleConfig *bodyCellConfig = [self cellConfigForHeader:NO]; - - NSMutableArray *allRows = [NSMutableArray array]; - _colCount = 0; - - for (MarkdownASTNode *section in tableNode.children) { - BOOL isSectionHead = (section.type == MarkdownNodeTypeTableHead); - - for (MarkdownASTNode *rowNode in section.children) { - if (rowNode.type != MarkdownNodeTypeTableRow) - continue; - - NSMutableArray *rowCells = [NSMutableArray array]; - for (MarkdownASTNode *cellNode in rowNode.children) { - TableCellData *cell = [[TableCellData alloc] init]; - cell.isHeader = isSectionHead || (cellNode.type == MarkdownNodeTypeTableHeaderCell); - cell.alignment = [self textAlignmentFromString:cellNode.attributes[@"align"]]; - cell.plainText = [self extractPlainTextFromNode:cellNode]; - cell.markdownText = markdownFromASTNodeChildren(cellNode); - - StyleConfig *cellConfig = cell.isHeader ? headerCellConfig : bodyCellConfig; - cell.attributedText = [self renderCellNode:cellNode - isHeader:cell.isHeader - cellConfig:cellConfig - alignment:cell.alignment]; - [rowCells addObject:cell]; - } - _colCount = MAX(_colCount, rowCells.count); - [allRows addObject:rowCells]; - } - } - - _rows = [allRows copy]; + NSUInteger colCount = 0; + _rows = ENRMTableBuildRows(tableNode, self.config, self.allowFontScaling, self.maxFontSizeMultiplier, + _writingDirectionMode, _resolvedLayoutDirection, &colCount); + _colCount = colCount; _cachedMarkdown = [self buildMarkdownFromRows]; _cachedAccessibilityElements = nil; [self computeLayout]; [self renderGrid]; } -- (NSTextAlignment)textAlignmentFromString:(NSString *)align +- (void)computeLayout { - if ([align isEqualToString:@"center"]) - return NSTextAlignmentCenter; - if ([align isEqualToString:@"right"]) - return NSTextAlignmentRight; - return NSTextAlignmentLeft; + NSMutableArray *colWidths = nil; + NSMutableArray *rowHeights = nil; + CGFloat totalWidth = 0; + CGFloat totalHeight = 0; + ENRMTableComputeLayout(_rows, _colCount, self.config, self.config.tableBorderWidth, &colWidths, &rowHeights, + &totalWidth, &totalHeight); + _colWidths = colWidths; + _rowHeights = rowHeights; + _totalTableWidth = totalWidth; + _totalTableHeight = totalHeight; } -- (void)computeLayout ++ (CGFloat)measureHeightForTableNode:(MarkdownASTNode *)tableNode + config:(StyleConfig *)config + allowFontScaling:(BOOL)allowFontScaling + maxFontSizeMultiplier:(CGFloat)maxFontSizeMultiplier + writingDirectionMode:(ENRMWritingDirectionMode)writingDirectionMode + resolvedLayoutDirection:(NSWritingDirection)resolvedLayoutDirection { - // TODO: Consider making minColumnWidth / maxColumnWidth configurable via style props - const CGFloat minimumColumnWidth = 60.0; - const CGFloat maximumColumnWidth = 300.0; - const CGFloat horizontalPadding = self.config.tableCellPaddingHorizontal * 2; - const CGFloat verticalPadding = self.config.tableCellPaddingVertical * 2; - - _colWidths = [NSMutableArray arrayWithCapacity:_colCount]; - for (NSUInteger i = 0; i < _colCount; i++) - [_colWidths addObject:@0]; - - for (NSArray *row in _rows) { - for (NSUInteger column = 0; column < row.count; column++) { - CGRect boundingRect = [row[column].attributedText - boundingRectWithSize:CGSizeMake(maximumColumnWidth, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - context:nil]; - CGFloat width = MIN(MAX(ceil(boundingRect.size.width) + horizontalPadding, minimumColumnWidth), - maximumColumnWidth + horizontalPadding); - if (width > [_colWidths[column] doubleValue]) - _colWidths[column] = @(width); - } - } - - _rowHeights = [NSMutableArray arrayWithCapacity:_rows.count]; - for (NSArray *row in _rows) { - CGFloat maxHeight = 0; - for (NSUInteger column = 0; column < row.count; column++) { - CGFloat availableWidth = [_colWidths[column] doubleValue] - horizontalPadding; - CGRect boundingRect = [row[column].attributedText - boundingRectWithSize:CGSizeMake(availableWidth, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading - context:nil]; - maxHeight = MAX(maxHeight, ceil(boundingRect.size.height) + verticalPadding); - } - [_rowHeights addObject:@(maxHeight)]; - } + NSUInteger colCount = 0; + NSArray *> *rows = + ENRMTableBuildRows(tableNode, config, allowFontScaling, maxFontSizeMultiplier, writingDirectionMode, + resolvedLayoutDirection, &colCount); + if (rows.count == 0) + return 0; - _totalTableWidth = [[_colWidths valueForKeyPath:@"@sum.self"] doubleValue] + _borderWidth; - _totalTableHeight = [[_rowHeights valueForKeyPath:@"@sum.self"] doubleValue] + _borderWidth; + CGFloat totalHeight = 0; + ENRMTableComputeLayout(rows, colCount, config, config.tableBorderWidth, NULL, NULL, NULL, &totalHeight); + return totalHeight; } - (void)renderGrid @@ -356,7 +415,7 @@ - (void)renderGridMacOS columnWidths:_colWidths rowHeights:_rowHeights borderColor:self.config.tableBorderColor - borderWidth:_borderWidth + borderWidth:self.config.tableBorderWidth horizontalCellPadding:self.config.tableCellPaddingHorizontal verticalCellPadding:self.config.tableCellPaddingVertical cornerRadius:self.config.tableBorderRadius]; @@ -391,7 +450,7 @@ - (void)renderGridIOS columnWidths:_colWidths rowHeights:_rowHeights borderColor:self.config.tableBorderColor - borderWidth:_borderWidth + borderWidth:self.config.tableBorderWidth horizontalCellPadding:self.config.tableCellPaddingHorizontal verticalCellPadding:self.config.tableCellPaddingVertical cornerRadius:self.config.tableBorderRadius];