InstantPage V2: inline-formula baseline alignment

Snapshot list marker subviews/sublayers before iteration, anchor
strike/underline drawing to item frame height, baseline-align inline
text, images, and formulas. Also: implementation plan and two spec
refreshes (clarify extraDescent nudge removal; refresh stale line
numbers).
This commit is contained in:
isaac 2026-05-20 00:33:55 +08:00
parent 8dbbf0f715
commit a53ef1f299
2 changed files with 71 additions and 65 deletions

View file

@ -522,7 +522,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
if !line.strikethroughItems.isEmpty {
for item in line.strikethroughItems {
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + floor((lineFrame.size.height / 2.0) + 1.0), width: itemFrame.size.width, height: 1.0))
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + floor((itemFrame.size.height / 2.0) + 1.0), width: itemFrame.size.width, height: 1.0))
}
}
@ -540,7 +540,7 @@ final class InstantPageV2TextView: UIView, InstantPageItemView {
context.setFillColor(color.cgColor)
}
let itemFrame = item.frame.offsetBy(dx: lineFrame.minX, dy: 0.0)
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + lineFrame.size.height + 2.0, width: itemFrame.size.width, height: 1.0))
context.fill(CGRect(x: itemFrame.minX, y: itemFrame.minY + itemFrame.size.height + 2.0, width: itemFrame.size.width, height: 1.0))
}
}
}
@ -616,14 +616,14 @@ final class InstantPageV2ListMarkerView: UIView, InstantPageItemView {
}
private func rebuildContents() {
for subview in Array(self.subviews) {
subview.removeFromSuperview()
}
if let sublayers = self.layer.sublayers {
for sublayer in sublayers {
for sublayer in Array(sublayers) {
sublayer.removeFromSuperlayer()
}
}
for subview in self.subviews {
subview.removeFromSuperview()
}
let item = self.item
switch item.kind {

View file

@ -2412,6 +2412,20 @@ private func v2LocalAttachmentBoundsForRange(_ range: NSRange, imageItems: [Inst
return result
}
private struct PendingV2ImageAttachment {
let xOffset: CGFloat
let range: NSRange
let id: Int64
let size: CGSize
}
private struct PendingV2FormulaAttachment {
let xOffset: CGFloat
let range: NSRange
let attachment: InstantPageMathAttachment
let baselineOffset: CGFloat
}
func layoutTextItem(
_ string: NSAttributedString,
boundingWidth: CGFloat,
@ -2458,6 +2472,8 @@ func layoutTextItem(
let fontLineHeight = floor(fontAscent + fontDescent)
let fontLineSpacing = floor(fontLineHeight * lineSpacingFactor)
let fontDescentBelowBaseline = max(0.0, -fontDescent)
let baselineToNextTopSlack = max(0.0, fontLineSpacing - 4.0)
var lastIndex: CFIndex = 0
var currentLineOrigin = CGPoint()
@ -2499,7 +2515,6 @@ func layoutTextItem(
var line = CTTypesetterCreateLineWithOffset(typesetter, CFRangeMake(lastIndex, lineCharacterCount), 100.0)
var lineWidth = CGFloat(CTLineGetTypographicBounds(line, nil, nil, nil))
let lineRange = NSMakeRange(lastIndex, lineCharacterCount)
let substring = string.attributedSubstring(from: lineRange).string
var stop = false
if maxNumberOfLines > 0 && lines.count == maxNumberOfLines - 1 && lastIndex + lineCharacterCount < string.length {
@ -2519,68 +2534,71 @@ func layoutTextItem(
extraDescent = 0.0
var lineImageItems: [InstantPageTextImageItem] = []
var lineFormulaItems: [InstantPageTextFormulaRun] = []
var lineMaxAttachmentHeight: CGFloat = 0.0
var pendingImages: [PendingV2ImageAttachment] = []
var pendingFormulas: [PendingV2FormulaAttachment] = []
var isRTL = false
if let glyphRuns = CTLineGetGlyphRuns(line) as? [CTRun], !glyphRuns.isEmpty {
if let run = glyphRuns.first, CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) {
isRTL = true
}
var appliedLineOffset: CGFloat = 0.0
for run in glyphRuns {
let cfRunRange = CTRunGetStringRange(run)
let runRange = NSMakeRange(cfRunRange.location == kCFNotFound ? NSNotFound : cfRunRange.location, cfRunRange.length)
string.enumerateAttributes(in: runRange, options: []) { attributes, range, _ in
if let id = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaIdAttribute)] as? Int64, let dimensions = attributes[NSAttributedString.Key.init(rawValue: InstantPageMediaDimensionsAttribute)] as? PixelDimensions {
var imageFrame = CGRect(origin: CGPoint(), size: dimensions.cgSize.fitted(CGSize(width: boundingWidth, height: boundingWidth)))
let imageSize = dimensions.cgSize.fitted(CGSize(width: boundingWidth, height: boundingWidth))
let xOffset = CTLineGetOffsetForStringIndex(line, CTRunGetStringRange(run).location, nil)
let yOffset = fontLineHeight.isZero ? 0.0 : floorToScreenPixels((fontLineHeight - imageFrame.size.height) / 2.0)
imageFrame.origin = imageFrame.origin.offsetBy(dx: workingLineOrigin.x + xOffset, dy: workingLineOrigin.y + yOffset)
let minSpacing = fontLineSpacing - 4.0
let delta = workingLineOrigin.y - minSpacing - imageFrame.minY - appliedLineOffset
if !fontAscent.isZero && delta > 0.0 {
workingLineOrigin.y += delta
appliedLineOffset += delta
imageFrame.origin = imageFrame.origin.offsetBy(dx: 0.0, dy: delta)
}
if !fontLineHeight.isZero {
extraDescent = max(extraDescent, imageFrame.maxY - (workingLineOrigin.y + fontLineHeight + minSpacing))
}
lineMaxAttachmentHeight = max(lineMaxAttachmentHeight, imageFrame.height)
lineImageItems.append(InstantPageTextImageItem(frame: imageFrame, range: range, id: EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: id)))
pendingImages.append(PendingV2ImageAttachment(xOffset: xOffset, range: range, id: id, size: imageSize))
} else if let attachment = attributes[NSAttributedString.Key(rawValue: InstantPageFormulaAttribute)] as? InstantPageMathAttachment {
let xOffset = CTLineGetOffsetForStringIndex(line, range.location, nil)
let baselineOffset = (attributes[NSAttributedString.Key.baselineOffset] as? CGFloat) ?? 0.0
var formulaFrame = CGRect(
origin: CGPoint(
x: workingLineOrigin.x + xOffset,
y: workingLineOrigin.y + fontLineHeight + baselineOffset - attachment.rendered.ascent
),
size: attachment.rendered.size
)
let minSpacing = fontLineSpacing - 4.0
let delta = workingLineOrigin.y - minSpacing - formulaFrame.minY - appliedLineOffset
if !fontAscent.isZero && delta > 0.0 {
workingLineOrigin.y += delta
appliedLineOffset += delta
formulaFrame.origin = formulaFrame.origin.offsetBy(dx: 0.0, dy: delta)
}
if !fontLineHeight.isZero {
extraDescent = max(extraDescent, formulaFrame.maxY - (workingLineOrigin.y + fontLineHeight + minSpacing))
}
lineMaxAttachmentHeight = max(lineMaxAttachmentHeight, formulaFrame.height)
lineFormulaItems.append(InstantPageTextFormulaRun(frame: formulaFrame, range: range, attachment: attachment))
pendingFormulas.append(PendingV2FormulaAttachment(xOffset: xOffset, range: range, attachment: attachment, baselineOffset: baselineOffset))
}
}
}
}
if substring.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && (!lineImageItems.isEmpty || !lineFormulaItems.isEmpty) {
extraDescent += max(6.0, fontLineSpacing / 2.0)
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 {
lineAscent = formulaAscent
}
if formula.attachment.rendered.descent > lineDescent {
lineDescent = formula.attachment.rendered.descent
}
}
let baselineY = workingLineOrigin.y + lineAscent
for image in pendingImages {
let imageFrame = CGRect(
x: workingLineOrigin.x + image.xOffset,
y: baselineY - image.size.height,
width: image.size.width,
height: image.size.height
)
lineImageItems.append(InstantPageTextImageItem(frame: imageFrame, range: image.range, id: EngineMedia.Id(namespace: Namespaces.Media.CloudFile, id: image.id)))
}
for formula in pendingFormulas {
let attachment = formula.attachment
let formulaAscent = attachment.rendered.size.height - attachment.rendered.descent
let formulaFrame = CGRect(
x: workingLineOrigin.x + formula.xOffset,
y: baselineY - formulaAscent + formula.baselineOffset,
width: attachment.rendered.size.width,
height: attachment.rendered.size.height
)
lineFormulaItems.append(InstantPageTextFormulaRun(frame: formulaFrame, range: formula.range, attachment: attachment))
}
extraDescent = max(0.0, lineDescent - baselineToNextTopSlack)
if !minimizeWidth && !hadIndexOffset && lineCharacterCount > 1 && lineWidth > currentMaxWidth + 5.0 {
if let imageItem = lineImageItems.last {
@ -2603,14 +2621,14 @@ func layoutTextItem(
let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
strikethroughItems.append(InstantPageTextStrikethroughItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y, width: abs(upperX - lowerX), height: fontLineHeight)))
strikethroughItems.append(InstantPageTextStrikethroughItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight), width: abs(upperX - lowerX), height: fontLineHeight)))
}
if let _ = attributes[NSAttributedString.Key.underlineStyle] {
let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
underlineItems.append(InstantPageTextUnderlineItem(
frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y, width: abs(upperX - lowerX), height: fontLineHeight),
frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight), width: abs(upperX - lowerX), height: fontLineHeight),
range: range,
color: attributes[NSAttributedString.Key.underlineColor] as? UIColor
))
@ -2626,7 +2644,7 @@ func layoutTextItem(
let lowerX = floor(CTLineGetOffsetForStringIndex(line, range.location, nil))
let upperX = ceil(CTLineGetOffsetForStringIndex(line, range.location + range.length, nil))
let x = lowerX < upperX ? lowerX : upperX
markedItems.append(InstantPageTextMarkedItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + delta, width: abs(upperX - lowerX), height: lineHeight), color: color, range: range))
markedItems.append(InstantPageTextMarkedItem(frame: CGRect(x: workingLineOrigin.x + x, y: workingLineOrigin.y + (lineAscent - fontLineHeight) + delta, width: abs(upperX - lowerX), height: lineHeight), color: color, range: range))
}
if let item = attributes[NSAttributedString.Key.init(rawValue: InstantPageAnchorAttribute)] as? Dictionary<String, Any>, let name = item["name"] as? String, let empty = item["empty"] as? Bool {
anchorItems.append(InstantPageTextAnchorItem(name: name, anchorText: item["text"] as? NSAttributedString, empty: empty))
@ -2641,19 +2659,7 @@ func layoutTextItem(
workingLineOrigin.y += fontLineSpacing
}
let height = !fontLineHeight.isZero ? max(fontLineHeight, lineMaxAttachmentHeight) : lineMaxAttachmentHeight
if !lineFormulaItems.isEmpty {
let baselineAdjustment = height - fontLineHeight
if !baselineAdjustment.isZero {
lineFormulaItems = lineFormulaItems.map { item in
InstantPageTextFormulaRun(
frame: item.frame.offsetBy(dx: 0.0, dy: baselineAdjustment),
range: item.range,
attachment: item.attachment
)
}
}
}
let height = lineAscent
if !markedItems.isEmpty {
markedItems = markedItems.map { item in
if let attachmentBounds = v2LocalAttachmentBoundsForRange(item.range, imageItems: lineImageItems, formulaItems: lineFormulaItems) {
@ -2676,7 +2682,7 @@ func layoutTextItem(
}
workingLineOrigin.x = 0.0
workingLineOrigin.y += fontLineHeight + fontLineSpacing + extraDescent
workingLineOrigin.y += lineAscent + fontLineSpacing + extraDescent
currentLineOrigin = workingLineOrigin
lastIndex += lineCharacterCount