diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png index 35782b1d..0e302516 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/blockquote_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png index 1a3aa0a8..dfae1fd4 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png index 6ef14b8d..f8eafb8b 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/code_block_math_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png index 7cdf9df7..391fc96d 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/header_code_block_combo_display.png differ diff --git a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png index 534f77eb..3d62e6dd 100644 Binary files a/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png and b/.maestro/enrichedMarkdownText/screenshots/ios/paragraph_code_block_combo_display.png differ diff --git a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m index f6d78ea9..04c6ba8c 100644 --- a/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m +++ b/packages/react-native-enriched-markdown/ios/renderer/AttributedRenderer.m @@ -3,6 +3,7 @@ #import "LastElementUtils.h" #import "MarkdownASTNode.h" #import "NodeRenderer.h" +#import "ParagraphStyleUtils.h" #import "RenderContext.h" #import "RendererFactory.h" #import "StyleConfig.h" @@ -58,7 +59,32 @@ - (NSMutableAttributedString *)renderRoot:(MarkdownASTNode *)root context:(Rende return output; } -/// Removes trailing margin spacing while preserving code block padding +/** + * Trims trailing newlines after the last rendered block, with special handling for code blocks. + * + * For non-code-block tail elements: deletes all trailing newlines after the last non-newline + * character and zeroes the last element's paragraph spacing so it doesn't render a margin past + * the content. + * + * For code blocks (`isLastBlockACodeBlock` == YES): keeps the bottom padding spacer in place + * AND preserves a single trailing newline immediately after it (typically the + * codeBlockMarginBottom spacer that CodeBlockRenderer already appended; if none exists, one is + * synthesized here). That extra newline matters because of an iOS layout quirk: + * + * - iOS treats the trailing `\n` of the storage as a paragraph terminator and reports the + * line fragment for the paragraph after it via `extraLineFragmentRect`. + * - `boundingRectForGlyphRange:` excludes that extra line, and the graphics-context clip + * passed to `drawBackgroundForGlyphRange:` is tiled to the laid-out content area (rounded + * up to 128pt boundaries) which also excludes it. + * - If the bottom padding spacer were the trailing `\n`, its 16/19pt line fragment would + * fall into that excluded zone — the background rect would have to be manually extended + * past the clip and the bottom rounded corner would get sliced off at the tile boundary + * (the original visual bug in #354). + * + * By keeping a 1pt-tall tail newline beyond the bottom padding spacer, iOS lays the spacer + * out as a normal interior line fragment included in usedRect/boundingRect and inside the + * drawing tiles, so the corner renders intact and no rect extension is needed. + */ - (void)removeTrailingSpacing:(NSMutableAttributedString *)output { if (output.length == 0) @@ -83,17 +109,36 @@ - (void)removeTrailingSpacing:(NSMutableAttributedString *)output // 2. Trim trailing characters NSUInteger logicalEnd = NSMaxRange(lastContent); - BOOL isCodeBlock = isLastElementCodeBlock(output); + BOOL isCodeBlock = isLastBlockACodeBlock(output); + NSRange codeRange = NSMakeRange(0, 0); if (isCodeBlock) { - NSRange codeRange; - [output attribute:CodeBlockAttributeName atIndex:lastContent.location effectiveRange:&codeRange]; - logicalEnd = NSMaxRange(codeRange); + [output attribute:CodeBlockAttributeName + atIndex:lastContent.location + longestEffectiveRange:&codeRange + inRange:NSMakeRange(0, output.length)]; + NSUInteger codeEnd = NSMaxRange(codeRange); + if (codeEnd >= output.length) { + [output appendAttributedString:kNewlineAttributedString]; + } + logicalEnd = codeEnd + 1; } if (logicalEnd < output.length) { [output deleteCharactersInRange:NSMakeRange(logicalEnd, output.length - logicalEnd)]; } + if (isCodeBlock && NSMaxRange(codeRange) < output.length) { + NSUInteger tailIdx = NSMaxRange(codeRange); + [output removeAttribute:CodeBlockAttributeName range:NSMakeRange(tailIdx, 1)]; + NSParagraphStyle *style = [output attribute:NSParagraphStyleAttributeName atIndex:tailIdx effectiveRange:NULL]; + NSMutableParagraphStyle *mutableStyle = style ? [style mutableCopy] : [[NSMutableParagraphStyle alloc] init]; + mutableStyle.paragraphSpacing = 0; + mutableStyle.paragraphSpacingBefore = 0; + mutableStyle.minimumLineHeight = 1; + mutableStyle.maximumLineHeight = 1; + [output addAttribute:NSParagraphStyleAttributeName value:mutableStyle range:NSMakeRange(tailIdx, 1)]; + } + // 3. Zero out internal spacing for the last element (if not a code block) if (!isCodeBlock) { NSRange styleRange; @@ -150,4 +195,4 @@ - (void)renderNodeRecursive:(MarkdownASTNode *)node } } -@end \ No newline at end of file +@end diff --git a/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h b/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h index fa294e6c..a9fafbf0 100644 --- a/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h +++ b/packages/react-native-enriched-markdown/ios/utils/LastElementUtils.h @@ -6,8 +6,45 @@ NS_ASSUME_NONNULL_BEGIN static NSString *const CodeBlockAttributeName = @"CodeBlock"; /** - * Checks if the last element in the attributed string is a code block. - * Used to compensate for iOS text APIs not measuring/drawing trailing newlines with custom line heights. + * Returns YES when the last semantic block in the storage is a code block, ignoring any + * trailing newline spacers (e.g. codeBlockMarginBottom) that may follow it. + * + * Detection is based on the last non-newline character: if it sits inside a CodeBlock + * attribute run, the answer is YES regardless of what newlines come after. + * + * Use this in render-time cleanup paths (e.g. removeTrailingSpacing) that need to know + * "is the document ending with a code block?" before any trailing newlines have been trimmed. + * The stricter `isLastElementCodeBlock` would answer NO in that pre-trim state because + * the CodeBlock range hasn't yet been pushed to text.length. + */ +static inline BOOL isLastBlockACodeBlock(NSAttributedString *text) +{ + if (text.length == 0) + return NO; + + NSRange lastContent = [text.string rangeOfCharacterFromSet:[[NSCharacterSet newlineCharacterSet] invertedSet] + options:NSBackwardsSearch]; + if (lastContent.location == NSNotFound) + return NO; + + NSNumber *isCodeBlock = [text attribute:CodeBlockAttributeName atIndex:lastContent.location effectiveRange:nil]; + return isCodeBlock.boolValue; +} + +/** + * Stricter sibling of `isLastBlockACodeBlock`: requires both that the last non-newline + * character is inside a CodeBlock run AND that the CodeBlock range extends literally to + * text.length (i.e. no characters at all live after the code block). + * + * Used in the iOS measurement path (and the analogous drawing-compensation check) to + * decide whether to add `codeBlockPadding` to make up for iOS not measuring/drawing + * trailing newlines that carry custom minimum/maximum line heights. + * + * After `removeTrailingSpacing` runs, the bottom padding spacer is preserved together + * with a single trailing newline outside the code block range, so this returns NO and + * the compensation is skipped — the bottom padding spacer is no longer the trailing + * paragraph terminator, so iOS lays its line fragment out as part of usedRect normally. + * The check still exists as a safety net for unexpected storage shapes. */ static inline BOOL isLastElementCodeBlock(NSAttributedString *text) {