Fix inline emoji/image line-height inflation in InstantPage V2

The V2 line-breaker used `lineAscent` as both the line height and the
baseline offset, then inflated it to each inline emoji/image's full visual
size and bottom-aligned the attachment on that inflated baseline. A 24pt
emoji on a ~17pt line therefore doubled the line height and shoved the
text baseline (and all text on the line) down.

Stop inflating the line for emoji/images (only formulas, which carry their
own metrics, still grow it) and center each attachment on the font line
box at `baselineY - fontLineHeight/2 - size/2`, matching V1
`layoutTextItemWithString` and the chat `InteractiveTextComponent`. The
attachment now bleeds symmetrically instead of moving the baseline.
`extraDescent` absorbs tall-attachment bottom overflow so the next line is
not overlapped, and the streaming-reveal `characterRect` is centered in
lockstep so the reveal mask tracks the cell (reveal cost stays width-only).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
isaac 2026-05-31 18:28:05 +02:00
parent a3492c0cb0
commit 9205fb2303
2 changed files with 37 additions and 23 deletions

View file

@ -2838,13 +2838,15 @@ func layoutTextItem(
}
}
// Inline emoji and images do NOT inflate the line: they are centered on the font
// line box and allowed to bleed above/below (mirroring V1 `layoutTextItemWithString`
// and the chat `InteractiveTextComponent`). Their run delegates already report the
// font's own ascent/descent, so CoreText lays the line out at the normal height the
// old `lineAscent = emoji.size` inflation both doubled the line height and (because the
// baseline sits at the bottom of the box) shoved the text baseline down. Only formulas,
// which carry their own typographic metrics, are allowed to grow the line.
var lineAscent: CGFloat = fontLineHeight
var lineDescent: CGFloat = fontDescentBelowBaseline
for image in pendingImages {
if image.size.height > lineAscent {
lineAscent = image.size.height
}
}
for formula in pendingFormulas {
let formulaAscent = formula.attachment.rendered.size.height - formula.attachment.rendered.descent
if formulaAscent > lineAscent {
@ -2854,17 +2856,15 @@ func layoutTextItem(
lineDescent = formula.attachment.rendered.descent
}
}
for emoji in pendingEmoji {
if emoji.size > lineAscent {
lineAscent = emoji.size
}
}
let baselineY = workingLineOrigin.y + lineAscent
for image in pendingImages {
// Center on the font line box (baseline fontLineHeight/2), matching V1's
// `(fontLineHeight - imageHeight) / 2` offset, instead of bottom-aligning on the
// baseline. Keeps the text baseline put and lets the image bleed symmetrically.
let imageFrame = CGRect(
x: workingLineOrigin.x + image.xOffset,
y: baselineY - image.size.height,
y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - image.size.height / 2.0),
width: image.size.width,
height: image.size.height
)
@ -2882,9 +2882,12 @@ func layoutTextItem(
lineFormulaItems.append(InstantPageTextFormulaRun(frame: formulaFrame, range: formula.range, attachment: attachment))
}
for emoji in pendingEmoji {
// Center on the font line box (baseline fontLineHeight/2) so a 24pt emoji on a
// ~17pt line bleeds symmetrically rather than forcing the line taller and pushing
// the text baseline down. Matches the chat `InteractiveTextComponent` placement.
let emojiFrame = CGRect(
x: workingLineOrigin.x + emoji.xOffset,
y: baselineY - emoji.size,
y: floorToScreenPixels(baselineY - fontLineHeight / 2.0 - emoji.size / 2.0),
width: emoji.size,
height: emoji.size
)
@ -2892,6 +2895,15 @@ func layoutTextItem(
}
extraDescent = max(0.0, lineDescent - baselineToNextTopSlack)
// A centered attachment taller than the line bleeds below the baseline; grow the
// descent so the following line isn't overlapped (mirrors V1's extraDescent handling).
// Emoji at the default 24/17 ratio stay within the line slack and contribute nothing.
for imageItem in lineImageItems {
extraDescent = max(extraDescent, imageItem.frame.maxY - (baselineY + baselineToNextTopSlack))
}
for emojiItem in lineEmojiItems {
extraDescent = max(extraDescent, emojiItem.frame.maxY - (baselineY + baselineToNextTopSlack))
}
if !minimizeWidth && !hadIndexOffset && lineCharacterCount > 1 && lineWidth > currentMaxWidth + 5.0 {
if let imageItem = lineImageItems.last {
@ -3025,22 +3037,23 @@ func layoutTextItem(
let localIndex = emoji.range.location - lineRange.location
if localIndex >= 0 && localIndex < rects.count {
let x = CTLineGetOffsetForStringIndex(line, emoji.range.location, nil)
// characterRects are baseline-relative (positive-up). The emoji cell sits
// bottom-on-baseline (see frame loop: y = baselineY - emoji.size), so its
// baseline-relative bottom is 0 and maxY = emoji.size the width feeds the
// reveal cost map; maxY feeds the reveal-mask y conversion in the renderer.
rects[localIndex] = CGRect(x: x, y: 0.0, width: emoji.size, height: emoji.size)
// characterRects are baseline-relative (positive-up). The emoji cell is now
// centered on the font line box (see frame loop), so in baseline-relative
// coords it spans [fontLineHeight/2 size/2, fontLineHeight/2 + size/2].
// Width feeds the reveal cost map; maxY feeds the reveal-mask y conversion in
// the renderer (lineAscent maxY), keeping the mask tracking the centered cell.
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - emoji.size / 2.0, width: emoji.size, height: emoji.size)
}
}
for image in pendingImages {
let localIndex = image.range.location - lineRange.location
if localIndex >= 0 && localIndex < rects.count {
let x = CTLineGetOffsetForStringIndex(line, image.range.location, nil)
// Image cell sits bottom-on-baseline (frame loop: y = baselineY - image.size.height).
// Baseline-relative cell: y = 0, height = image.size.height. The full width feeds
// the reveal cost map so the streaming cursor is charged the image's width when
// crossing it same as an emoji cell.
rects[localIndex] = CGRect(x: x, y: 0.0, width: image.size.width, height: image.size.height)
// Image cell is centered on the font line box (see frame loop). Baseline-relative
// cell spans [fontLineHeight/2 height/2, fontLineHeight/2 + height/2]; the full
// width feeds the reveal cost map so the streaming cursor is charged the image's
// width when crossing it same as an emoji cell.
rects[localIndex] = CGRect(x: x, y: fontLineHeight / 2.0 - image.size.height / 2.0, width: image.size.width, height: image.size.height)
}
}
lineCharacterRects = rects