Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, looks like some test also got fixed up

Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)];
}
Comment thread
eszlamczyk marked this conversation as resolved.

// 3. Zero out internal spacing for the last element (if not a code block)
if (!isCodeBlock) {
NSRange styleRange;
Expand Down Expand Up @@ -150,4 +195,4 @@ - (void)renderNodeRecursive:(MarkdownASTNode *)node
}
}

@end
@end
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Loading