Files
ghostgram/submodules/TelegramUI/Components/RichTextEditor/Sources/RichTextEngineEditor/EngineDocumentBridge.swift
T
ichmagmaus111andClaude Opus 5.5 534cabe35b bug fixes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:55:08 +01:00

1910 lines
124 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#if canImport(UIKit)
import Foundation
import UIKit
import RichTextEditorCore
import RichTextEditorUIKit
/// Maps between Telegram's value model and the engine's `attributedBlocks`.
///
/// It owns NO mapping logic of its own: `AttributedStringMapper` already goes both ways
/// (`attributedString(for:)` and `runs(from:)`) and carries the InstantPage render metrics.
/// This type is the block-level shell around it, plus the paragraph-structure reuse policy.
@available(iOS 18.4, *)
public struct EngineDocumentBridge {
/// INTERNAL rather than private, for one consumer: `+ButtonRows.swift` hands it to
/// `RichTextButtonRowView`, whose packing needs the metrics, the theme, the writing direction and
/// the mapper's own two measuring closures — that is the whole mapper, so a narrower accessor
/// would buy nothing. Still narrower than the `styleSheet` below it, which is public.
internal private(set) var mapper: AttributedStringMapper
/// The stylesheet this bridge renders with. Read by the host for the quote geometry it has to
/// reserve in the LAYOUT rather than in the text (`quoteTopInset`/`quoteBottomInset`).
public var styleSheet: StyleSheet { mapper.styleSheet }
/// The palette the mapper renders with — read by the host-positioned overlays that draw text of
/// their own (a list marker), so their colour comes from the same place the text's does.
public var theme: RichTextEditorTheme { mapper.theme }
/// Quoted content one step below body, per V2's `quoteScale` — the same 15/17 the renderer
/// applies, derived from the host's mapper so its theme, metrics and writing direction carry over.
private let quoteMapper: AttributedStringMapper
/// The mapper a code block's text is rendered AND read back through.
///
/// A code block is flattened onto the engine's axis as an ordinary `.body` paragraph — there is
/// no code paragraph style to select — so the way to give it the code FONT is to hand that pass a
/// mapper whose body spec IS `metrics.codeBlock` (V2's absolute 15pt monospace). Substituting the
/// face afterwards cannot work: the paragraph style's pinned line height is computed from
/// whichever font the mapper laid out, so the text would be spaced for a font it is not set in.
/// Same shape as `quoteMapper`, and for the same reason.
private let codeMapper: AttributedStringMapper
/// Cells read at 15pt in both editors — the legacy canvas builds every cell box from this
/// variant (`TableBlockBox` derives it once per table).
private let tableCellMapper: AttributedStringMapper
/// Pull-quote geometry, which the stylesheet does not carry (it holds only the italic + centred
/// TEXT attributes for `.pullQuote`; the pill's own numbers live in `PullQuoteStyle`).
private let pullQuoteStyle: PullQuoteStyle
/// Media geometry the stylesheet does not carry: the bleed the attachment's height is computed
/// at (`MediaTextAttachment`). Travels with the bridge like `pullQuoteStyle`, and for the same
/// reason — a document set renders it, so a change rebuilds the bridge (`applyMediaBlockStyle`).
internal let mediaBlockStyle: MediaBlockStyle
public init(mapper: AttributedStringMapper, pullQuoteStyle: PullQuoteStyle = .default,
mediaBlockStyle: MediaBlockStyle = .default) {
self.mapper = mapper
self.pullQuoteStyle = pullQuoteStyle
self.mediaBlockStyle = mediaBlockStyle
let bodySize = mapper.styleSheet.metrics.body.size
quoteMapper = mapper.withBodyFontSize(floor(bodySize * RichTextRenderMetrics.quoteScale))
var codeMetrics = mapper.styleSheet.metrics
codeMetrics.body = codeMetrics.codeBlock
codeMapper = mapper.withRenderMetrics(codeMetrics)
tableCellMapper = mapper.tableCellVariant()
}
/// Paragraphs, the two LEAF containers, and now media. Table, block quote, details and button row
/// still TRAP naming rung R7. This is deliberate, not an oversight: a silent drop here would read
/// as data loss the first time a user pastes a table. The trap itself is exercised at R7 in the
/// Host app, not by an XCTest here — a `preconditionFailure` cannot be caught in-process, so a test
/// that "expects" it would only be asserting something weaker than its name claims.
///
/// **`IDBlock` has no kind.** The engine's document is a flat list of `(NSUUID, attributedText)`;
/// there is no field in which "this block is a code block" could be stored. So a container is
/// FLATTENED to its text here and its kind travels host-side, in the identity table the reverse
/// function reads — the same mechanism paragraph style and list membership already use, for the
/// same reason.
///
/// `CodeBlock` and `PullQuote` are LEAF containers: one `runs` list each, so each maps to exactly
/// one engine block and the flattening loses only the kind. `BlockQuote` has `children: [Block]`
/// and so is SEVERAL engine blocks, which this one-string-per-block API cannot express — it
/// traps HERE for that reason alone, not for lack of a recursive pass: `engineRepresentation(for:)`
/// is the live path a document set actually uses, and it flattens a quote (any child kind, any
/// depth) via `flatten(_:within:into:roles:)`.
public func attributedBlocks(for document: Document) -> [NSAttributedString] {
document.blocks.map { block in
switch block {
case let .paragraph(paragraph):
return Self.engineReadable(mapper.attributedString(for: Self.engineParagraph(paragraph)))
case let .code(code):
return Self.engineReadable(Self.padded(Self.stampingMonospaceBaseFont(codeMapper.attributedString(
for: ParagraphBlock(id: code.id, style: .body, runs: Self.engineRuns(Self.plainCodeRuns(code.runs))))),
by: mapper.styleSheet.codeHorizontalInset))
case let .pullQuote(quote):
// `.pullQuote` IS a paragraph style the mapper renders, so the quote's own look comes
// for free. Its `author` line is a SECOND region with no engine counterpart — it is
// preserved through the identity table rather than flattened, and is not editable on
// this path yet.
return Self.engineReadable(Self.padded(mapper.attributedString(
for: ParagraphBlock(id: quote.id, style: .pullQuote, runs: Self.pullQuoteRuns(quote.runs))),
by: pullQuoteStyle.horizontalPadding))
case let .media(media):
return mediaAtom(for: MediaShape(media))
case .table, .blockQuote, .details, .buttonRow:
richTextEngineUnimplemented("Block.\(block.caseNameForDiagnostics)", rung: "R7")
}
}
}
/// The attributes the bridge renders `role`'s first character with — what an empty block of
/// that role should be measured, typed into and restyled with. One probe render of THAT block.
internal func lineAttributes(for role: EngineBlockRole) -> [NSAttributedString.Key: Any] {
// A media atom is one attachment character, never an empty LINE of its own to measure. If it
// is ever empty, integrity settle (Task 8) makes it a paragraph — so it measures as one
// meanwhile.
if case .media = role.shape, role.secondaryKind == nil {
return lineAttributes(for: .standalone(.paragraph(ParagraphShape(id: role.blockID)), within: role.ancestry))
}
let (probeBlocks, probeRoles) = engineRepresentation(for: Document(blocks: [probeBlock(for: role)]))
let match = zip(probeBlocks, probeRoles).first { candidate, candidateRole in
candidate.length > 0 && candidateRole.secondaryKind == role.secondaryKind
}
return match?.0.attributes(at: 0, effectiveRange: nil) ?? [:]
}
/// How far along its line an EMPTY block's caret sits: at the start of a centred prompt for a
/// pull quote's author line or a media caption, else 0. A centred prompt is centred on the line,
/// so its leading edge is half its own width left of the insertion point the line's alignment
/// produces.
internal func emptyLineCaretOffset(for role: EngineBlockRole) -> CGFloat {
switch role.secondaryKind {
case .pullQuoteAuthor:
let font = Self.authorPromptFont(styleSheet: mapper.styleSheet)
return -(quoteAuthorPlaceholderText as NSString).size(withAttributes: [.font: font]).width / 2.0
case .mediaCaption:
let font = mapper.styleSheet.font(for: .caption, attributes: .plain)
return -(mediaCaptionPlaceholderText as NSString).size(withAttributes: [.font: font]).width / 2.0
case .codeLanguage, .blockQuoteAuthor, .detailsTitle, nil:
// A title is LEADING-aligned like a language line, so its caret sits at the line start.
return 0.0
}
}
/// The face the prompt is drawn in — bold + italic caption, matching the author line itself.
internal static func authorPromptFont(styleSheet: StyleSheet) -> UIFont {
let base = UIFont.systemFont(ofSize: styleSheet.metrics.caption.size)
let traits = base.fontDescriptor.symbolicTraits.union([.traitBold, .traitItalic])
guard let descriptor = base.fontDescriptor.withSymbolicTraits(traits) else { return base }
return UIFont(descriptor: descriptor, size: base.pointSize)
}
/// What makes two blocks render their empty line the same way: the role, the block's kind, and
/// the paragraph fields the stylesheet reads. Never the text.
internal static func probeShapeKey(for role: EngineBlockRole) -> String {
var key = role.secondaryKind.map { "secondary-\($0)" } ?? "primary"
// The ancestry's KIND SEQUENCE, not its length. Depth alone pins neither the face nor the
// indent: `quote > quote` and `quote > details` are both depth 2, render at the same size and
// at DIFFERENT indents (32 against 16), so keyed on depth they shared one cached measurement
// and whichever was measured first gave the other its empty-line indent and typing attributes.
key += "|ancestry\(role.ancestry.map { $0.details == nil ? "q" : "d" }.joined())"
if let table = role.owningTable, let (row, column) = role.tableCellCoordinates {
let cell = table.cell(row: row, column: column)
key += "|tableCell|\(cell?.isHeader ?? false)|\(String(describing: cell?.horizontalAlignment))"
}
switch role.shape {
case let .paragraph(paragraph):
key += "|paragraph|\(paragraph.style)|\(String(describing: paragraph.list?.marker))"
key += "|\(String(describing: paragraph.list?.level))"
key += "|\(paragraph.paragraph.alignment)"
case .code: key += "|code"
case .pullQuote: key += "|pullQuote"
case let .media(media): key += "|media|\(media.isCaptionless)"
// The pill COUNT and alignment change the packed height, which is what an empty-line
// measurement caches — two rows differing only in pill count must not share one.
case let .buttonRow(row): key += "|buttonRow|\(row.buttons.count)|\(row.alignment)"
case .collapsedQuote: key += "|collapsedQuote"
}
return key
}
/// The same block with one character in it, so its rendering can be measured.
private func probeBlock(for role: EngineBlockRole) -> Block {
let probe = [TextRun(text: "x")]
let leaf: Block
switch role.position {
case let .tableCell(table, row, column, cell):
// One cell with the probe in it, rendered with the cell mapper. The REAL cell's shape is
// used so its header flag and alignment shape the probe: a fresh `Cell` here gave a
// header cell non-bold typing attributes until the next re-render (seen on device).
var probeCell = table.cell(row: row, column: column)?.cell(blocks: []) ?? Cell(id: cell)
probeCell.id = cell
probeCell.blocks = [.paragraph(ParagraphBlock(id: BlockID("probe-p"), runs: probe))]
leaf = .table(TableBlock(id: table.id, columns: table.columns,
rows: [Row(id: BlockID("probe-row"), cells: [probeCell])],
compact: table.compact, bordered: table.bordered))
case let .secondary(owner, _, kind):
switch (kind, owner) {
case let (.codeLanguage, .block(.code(id, _))):
leaf = .code(CodeBlock(id: id, language: "x", runs: []))
case let (.blockQuoteAuthor, .blockQuote(quote)):
leaf = .blockQuote(BlockQuote(id: quote.id, children: [], collapsed: quote.collapsed, author: probe))
case let (.pullQuoteAuthor, .block(.pullQuote(id))):
leaf = .pullQuote(PullQuote(id: id, runs: [], author: probe))
case let (.mediaCaption, .block(.media(shape))):
leaf = .media(shape.block(caption: probe))
case let (.detailsTitle, .details(details)):
// Built EXPANDED and childless: the probe measures the title LINE, and a folded one
// would be fine too, but `expanded` also decides nothing about the title's own face.
// Without this arm the probe fell to the paragraph default, `lineAttributes` found
// no role with a matching `secondaryKind`, and returned NOTHING — so a freshly
// inserted details' empty title had no font and no paragraph style, putting its
// caret under the chevron while the placeholder drew 26pt in.
leaf = .details(DetailsBlock(id: details.id, title: probe, children: [], expanded: true))
default:
leaf = .paragraph(ParagraphBlock(id: Self.probeBlockID, runs: probe))
}
case let .standalone(shape):
leaf = shape.block(runs: probe)
}
// A quote's own author line already IS its quote (the arm above builds one), so it is
// enclosed in the quote's parents only; everything else in its whole ancestry.
let enclosing = role.ancestry.last?.id == role.containerID ? Array(role.ancestry.dropLast()) : role.ancestry
return Self.enclosed(leaf, in: enclosing)
}
/// `block` wrapped in each ancestor, innermost first — the document a set would render it in, so
/// an empty nested line is measured at its own indent and face.
private static func enclosed(_ block: Block, in ancestry: [EngineAncestor]) -> Block {
ancestry.reversed().reduce(block) { inner, ancestor in
switch ancestor {
case let .blockQuote(quote):
return .blockQuote(BlockQuote(id: quote.id, children: [inner], collapsed: quote.collapsed, author: []))
case let .details(details):
// The probe measures a LINE at its own indent, so the enclosing details is rebuilt
// EXPANDED whatever its real state: a folded one would drop the very child being
// measured, and the probe would come back at the wrong indent.
return .details(DetailsBlock(id: details.id, title: [], children: [inner], expanded: true))
}
}
}
/// A detail block's title line: Body style, and the ONLY thing a details indents — its glyphs
/// clear the leading chevron by `DetailsShape.titleGlyphIndent`, on top of whatever the enclosing
/// containers indent. The details itself contributes nothing to that enclosing indent (§3.4), so
/// this is an indent the title carries and its children do not.
///
/// A display-only paragraph indent, exactly as legacy does it, so the line's FRAME stays full
/// width and only the text moves.
static func detailsTitleLine(runs: [TextRun], using mapper: AttributedStringMapper,
contentIndent: CGFloat,
contentTrailingInset: CGFloat = 0) -> NSAttributedString {
let line = mapper.attributedString(for: ParagraphBlock(id: probeBlockID, style: .body,
runs: oneLineRuns(runs)))
return quoteIndented(engineReadable(line), by: contentIndent + DetailsShape.titleGlyphIndent,
trailing: contentTrailingInset)
}
/// Identity for a throwaway probe block; never reaches the document.
private static let probeBlockID = BlockID("empty-line-probe")
public func engineRepresentation(for document: Document) -> ([NSAttributedString], [EngineBlockRole]) {
var blocks: [NSAttributedString] = []
var roles: [EngineBlockRole] = []
flatten(Self.withoutInlineCodeOverLinks(document.blocks), within: [], into: &blocks, roles: &roles)
let rendered = Self.withoutBlockSeparators(blocks, roles: roles)
if let hook = Self.testHookRenderedBlocks {
return (hook(rendered, roles), roles)
}
return (rendered, roles)
}
/// TEST ONLY: rewrites a document set's rendering after the bridge's own contract has run — how a test
/// puts a block separator into a rendered block (the state no shipping path reaches since p23) to drive the
/// role-count recovery (`HarnessRoleRecoveryByIdentityTests`). Always nil in the app.
internal static var testHookRenderedBlocks: (([NSAttributedString], [EngineBlockRole]) -> [NSAttributedString])?
/// The bridge's output contract, enforced where every rendering leaves it: no engine block holds a
/// `U+000A`, which IS the engine's block separator. One that did became two engine blocks against
/// one role, every role after it shifted, and the layout pass trapped (debug) or the message lost
/// its last block (release) — a code language or an author rendered with its newline raw (composer
/// harness p23, `HarnessNewlineInOneLineFieldCrashTests`). Each kind maps its own newlines; this is
/// the backstop for a kind that does not: a one-line field's newline becomes a space (its rule,
/// `oneLineText`), any other block's a line separator (`engineText`).
private static func withoutBlockSeparators(_ blocks: [NSAttributedString],
roles: [EngineBlockRole]) -> [NSAttributedString] {
// LITERAL, as every structural "\n" search is (p20): CRLF is one composed character.
func holdsSeparator(_ block: NSAttributedString) -> Bool {
(block.string as NSString).range(of: "\n", options: .literal).location != NSNotFound
}
guard blocks.contains(where: holdsSeparator) else { return blocks }
return blocks.enumerated().map { index, block in
guard holdsSeparator(block) else { return block }
NSLog("RichTextEngineEditor: a rendered block held U+000A — mapped (bridge backstop)")
let oneLine = index < roles.count && roles[index].isOneLineField
let result = NSMutableAttributedString(attributedString: block)
var location = (result.string as NSString).range(of: "\n", options: .literal).location
while location != NSNotFound {
result.replaceCharacters(in: NSRange(location: location, length: 1), with: oneLine ? " " : lineSeparator)
let next = location + 1
location = (result.string as NSString).range(of: "\n", options: .literal,
range: NSRange(location: next, length: result.length - next)).location
}
return result
}
}
/// The engine representation of `blocks` as if they sat inside `ancestry`.
///
/// The entry point above hard-codes `within: []`, which is right for a whole document and WRONG
/// for a FRAGMENT that lives inside a container: a quoted table's cells render through the quote
/// mapper and are indented, so building them at top level and re-parenting the roles afterwards
/// gives correct structure wrapped around wrong TEXT. Passing the ancestry down makes the render
/// and the roles come from one call, which also removes the R7-nesting trap about factory-built
/// roles returning top-level rather than leaving it to be remembered.
internal func engineRepresentation(for blocks: [Block],
within ancestry: [EngineAncestor]) -> ([NSAttributedString], [EngineBlockRole]) {
var rendered: [NSAttributedString] = []
var roles: [EngineBlockRole] = []
flatten(Self.withoutInlineCodeOverLinks(blocks), within: ancestry, into: &rendered, roles: &roles)
return (Self.withoutBlockSeparators(rendered, roles: roles), roles)
}
/// One block after another onto the engine's axis, each role stamped `ancestry` (nesting design
/// §4.1). A quote pushes itself and recurses; every other kind emits exactly what it emits at top
/// level. Text inside a quote is indented `quoteIndent` per level in its paragraph style; a cell is
/// PLACED and is not (§4.2).
private func flatten(_ source: [Block], within ancestry: [EngineAncestor],
into blocks: inout [NSAttributedString], roles: inout [EngineBlockRole]) {
let depth = ancestry.count
// Whether a QUOTE encloses this level — the 15pt question, which is not the depth question:
// a details contributes a level and no size change.
let insideAQuote = ancestry.contains { $0.quote != nil }
// The SUM of what each container contributes, not the count times a constant: a details
// contributes nothing (R7-details spec §3.4).
let contentIndent = ancestry.contentIndent(styleSheet: mapper.styleSheet)
let indent = contentIndent
// …and how far short of the column each one stops. A quote keeps its content back on BOTH
// sides (legacy's `BlockQuoteBox`: `inner = width - leadingInset - trailingInset`), so the
// same sum is taken over the trailing insets and travels as a negative `tailIndent`.
let trailing = ancestry.contentTrailingInset(styleSheet: mapper.styleSheet)
func emit(_ text: NSAttributedString, _ role: EngineBlockRole) {
blocks.append(depth > 0 ? Self.quoteIndented(text, by: indent, trailing: trailing) : text)
roles.append(role)
}
for block in source {
switch block {
case let .table(table):
// The shape rescues a table with no rows to one empty cell.
let shape = TableShape(table)
for (r, row) in shape.rows.enumerated() {
for (c, cellShape) in row.cells.enumerated() {
let cell = table.rows.indices.contains(r) && table.rows[r].cells.indices.contains(c)
? table.rows[r].cells[c] : cellShape.cell(blocks: [])
blocks.append(cellLine(for: cell, paragraph: Self.cellParagraph(for: cell)))
roles.append(.tableCell(table: shape, row: r, column: c, cell: cell.id, within: ancestry))
}
}
case let .code(code):
// The LANGUAGE is a second editable line ABOVE the code, emitted even when empty: it is
// the affordance for adding one. Padded like the code so the label sits at its x.
emit(Self.padded(Self.languageLine(for: code, using: mapper), by: mapper.styleSheet.codeHorizontalInset),
.secondary(owner: .block(.code(id: code.id, language: code.language)),
line: BlockID("\(code.id.rawValue)#language"), kind: .codeLanguage, within: ancestry))
emit(attributedBlock(for: block), .standalone(.code(id: code.id, language: code.language), within: ancestry))
case let .pullQuote(pull):
emit(attributedBlock(for: block, using: insideAQuote ? quoteMapper : nil, within: ancestry),
.standalone(.pullQuote(id: pull.id), within: ancestry))
// Conditional where a language line is unconditional (`PullQuoteBox.shouldShowAuthor`).
if !pull.author.isEmpty || !pull.text.isEmpty {
emit(Self.padded(Self.authorLine(runs: pull.author, centred: true, using: mapper), by: pullQuoteStyle.horizontalPadding),
.secondary(owner: .block(.pullQuote(id: pull.id)), line: BlockID("\(pull.id.rawValue)#author"),
kind: .pullQuoteAuthor, within: ancestry))
}
case let .media(media):
let shape = MediaShape(media)
emit(mediaAtom(for: shape, within: ancestry), .standalone(.media(shape), within: ancestry))
// The caption BELOW the atom, unconditional for a captioned kind, absent for audio/document.
if !shape.isCaptionless {
emit(Self.captionLine(runs: media.caption, using: mapper),
.secondary(owner: .block(.media(shape)), line: shape.captionLineID, kind: .mediaCaption, within: ancestry))
}
case let .paragraph(paragraph):
// A QUOTE makes text 15pt, at any depth. `depth` counts CONTAINERS, so keyed on it a
// paragraph inside a top-level details rendered quote-sized although no quote
// enclosed it — while its title, always built with the plain mapper, stayed 17pt.
// Legacy's `DetailsBox` builds its children with the mapper it was handed, so a
// details is body-sized outside a quote and quote-sized inside one, throughout.
emit(attributedBlock(for: block, using: insideAQuote ? quoteMapper : nil, within: ancestry),
.standalone(.paragraph(ParagraphShape(paragraph)), within: ancestry))
case let .blockQuote(quote):
// FOLDED: one atom, no children on the axis, no author line. The quote does NOT
// join the ancestry — it is a leaf here, and the shape is the only copy of what it
// holds (R7-collapse §3.1). `emit` still gives the atom the ENCLOSING containers'
// indent, which is what a folded quote inside an open one needs.
if quote.collapsed {
let shape = QuoteShape(quote)
emit(collapsedQuoteAtom(for: shape, within: ancestry),
.standalone(.collapsedQuote(shape), within: ancestry))
continue
}
let inner = ancestry + [.blockQuote(QuoteShape(quote))]
// An empty quote still needs one engine block, or it would vanish on the round trip.
let children: [Block] = quote.children.isEmpty
? [.paragraph(ParagraphBlock(id: BlockID(UUID().uuidString)))] : quote.children
flatten(children, within: inner, into: &blocks, roles: &roles)
// The quote's own AUTHOR line, below everything it holds — shown once anything is
// quoted. A quote holds CHILDREN, so the question is whether it holds one, not
// whether any engine block it produced carries text: an empty TABLE, an empty code
// block, a captionless medium and an empty nested quote are all structure the user
// put there, and a quote holding one has something to attribute. Asked as "any
// primary engine block non-empty" — which it was — a quote whose only child was a
// fresh table never got its author line (K3, 2026-09-11). Settle's twin is
// `authorLinePresenceThatIsWrong`, which asks `ContainerContents.isEmpty`; the two
// must agree or a document SET and the next keystroke disagree about one document.
let quoted = children.contains { !Self.isEmptyBodyParagraph($0) }
if !quote.author.isEmpty || quoted {
blocks.append(Self.quoteIndented(
Self.authorLine(runs: quote.author, centred: false, using: mapper),
by: inner.contentIndent(styleSheet: mapper.styleSheet),
trailing: inner.contentTrailingInset(styleSheet: mapper.styleSheet)))
roles.append(.secondary(owner: .blockQuote(QuoteShape(quote)), line: BlockID("\(quote.id.rawValue)#author"),
kind: .blockQuoteAuthor, within: inner))
}
case let .details(details):
// The TITLE first — always, and always exactly one, which is what makes a folded
// details' span a single engine block. Then the children, ONLY when expanded: a
// folded body is not in the engine at all, it rides in the shape (§7.1).
let shape = DetailsShape(details)
let inner = ancestry + [.details(shape)]
// `detailsTitleLine` is the ONE spelling of a title, shared with `render(role:)`
// below, so a title emitted by a document set and one re-rendered after an edit
// cannot drift. Every other secondary kind is built inline in its own arm.
blocks.append(Self.detailsTitleLine(
runs: details.title, using: insideAQuote ? quoteMapper : mapper,
contentIndent: inner.contentIndent(styleSheet: mapper.styleSheet),
contentTrailingInset: inner.contentTrailingInset(styleSheet: mapper.styleSheet)))
roles.append(.secondary(owner: .details(shape), line: shape.titleLineID,
kind: .detailsTitle, within: inner))
if details.expanded {
let children: [Block] = details.children.isEmpty
? [.paragraph(ParagraphBlock(id: BlockID(UUID().uuidString)))] : details.children
flatten(children, within: inner, into: &blocks, roles: &roles)
}
case let .buttonRow(row):
let shape = ButtonRowShape(row)
emit(buttonRowAtom(for: shape, within: ancestry),
.standalone(.buttonRow(shape), within: ancestry))
}
}
}
/// One block's engine text. Shared by both paths so a quote's child is rendered exactly as the
/// same paragraph would be at top level.
private func attributedBlock(for block: Block,
using mapper: AttributedStringMapper? = nil,
within ancestry: [EngineAncestor] = []) -> NSAttributedString {
let mapper = mapper ?? self.mapper
switch block {
case let .paragraph(paragraph):
return Self.engineReadable(mapper.attributedString(for: Self.engineParagraph(paragraph)))
case let .code(code):
return Self.engineReadable(Self.padded(Self.stampingMonospaceBaseFont(codeMapper.attributedString(
for: ParagraphBlock(id: code.id, style: .body, runs: Self.engineRuns(Self.plainCodeRuns(code.runs))))),
by: mapper.styleSheet.codeHorizontalInset))
case let .pullQuote(quote):
return Self.engineReadable(Self.padded(Self.stampingAmbientItalicBaseFont(mapper.attributedString(
for: ParagraphBlock(id: quote.id, style: .pullQuote, runs: Self.pullQuoteRuns(quote.runs)))),
by: pullQuoteStyle.horizontalPadding))
case let .media(media):
return mediaAtom(for: MediaShape(media), within: ancestry)
case let .buttonRow(row):
return buttonRowAtom(for: ButtonRowShape(row), within: ancestry)
// A FOLDED quote renders as its atom. An EXPANDED one still traps: `flatten` recurses into
// it, so nothing else should ever ask this for one.
case let .blockQuote(quote) where quote.collapsed:
return collapsedQuoteAtom(for: QuoteShape(quote), within: ancestry)
case .table, .blockQuote, .details:
richTextEngineUnimplemented("Block.\(block.caseNameForDiagnostics)", rung: "R7")
}
}
/// The roles after an edit, by the engine's own identities: an identity in the table keeps its
/// role, one the table has never seen is genuinely new — the engine minted it inside this edit —
/// and arrives as a body paragraph with a fresh `BlockID`. No text is read here: a role is
/// structure only, and what the engine holds is read when it is used.
public func roles(for identifiers: [UUID],
reusingStructureOf previous: [UUID: EngineBlockRole]) -> [EngineBlockRole] {
identifiers.map { identifier in
// `within: []` is PROVISIONAL, not a claim that the block is top level: a fresh
// identity has no structure yet, and `declareFreshBlocks` (settle) gives it its
// predecessor's ancestry before anything reads it.
previous[identifier] ?? .standalone(.paragraph(ParagraphShape(id: BlockID(UUID().uuidString))),
within: [])
}
}
/// The model block an engine block holds: the role's shape with its runs read back from the
/// engine's text through the mapper the role was RENDERED with — the only one it can be read
/// back through, since `runs(from:)` strips a font size that equals its mapper's default and
/// pins one that does not. A secondary line is a plain paragraph; a cell's newlines come back
/// in model form.
internal func block(for role: EngineBlockRole, from attributed: NSAttributedString) -> Block {
let mapper = readBackMapper(for: role)
switch role.position {
case let .secondary(_, line, kind):
// A caption was rendered at `.caption`; read back at `.body` it would pin 15pt as an
// explicit size on every run. An AUTHOR line is rendered at `.caption` too
// (`authorLine(runs:centred:using:)`) and now keeps its runs, so it reads back there as
// well — at `.body` its runs would carry the caption's size and colour as explicit
// overrides. The other lines are body-sized fields.
let style: ParagraphStyleName
switch kind {
case .mediaCaption, .pullQuoteAuthor, .blockQuoteAuthor: style = .caption
case .codeLanguage, .detailsTitle: style = .body
}
let runs = modelRuns(readingBack: attributed, style: style, through: mapper)
// A one-line field reads back on one line too, so no model ever holds — or sends — a line
// break there, whatever the engine holds for a moment (an open composition) (p23).
return .paragraph(ParagraphBlock(id: line, runs: role.isOneLineField ? Self.oneLineRuns(runs) : runs))
case let .tableCell(table, row, column, cell):
var runs = modelRuns(readingBack: attributed, style: .body, through: mapper)
if table.cell(row: row, column: column)?.isHeader == true {
// A header cell renders bold as a font trait, which is what the engine path reads bold
// from: in a header cell bold is ambient and never the model's.
for index in runs.indices { runs[index].attributes.bold = false }
}
return .paragraph(ParagraphBlock(id: cell, runs: runs))
case let .standalone(shape):
switch shape {
case let .paragraph(paragraph):
return .paragraph(paragraph.block(runs: modelRuns(readingBack: attributed, style: paragraph.style, through: mapper)))
case .code:
return shape.block(runs: Self.plainCodeRuns(modelRuns(readingBack: attributed, style: .body, through: mapper)))
case .pullQuote:
return shape.block(runs: modelRuns(readingBack: attributed, style: .pullQuote, through: mapper))
case let .media(media):
return .media(media.block(caption: []))
case let .buttonRow(row):
// The atom's text is one attachment character and carries nothing of the model, so
// the shape IS the block — nothing to read back.
return .buttonRow(row.block)
case .collapsedQuote:
// Like a row: the atom's text is one attachment character and carries nothing of
// the model, so the shape IS the block.
return shape.block(runs: [])
}
}
}
/// The engine's text as MODEL runs, read through `mapper` at `style`: every line separator a
/// newline again; a formula run's text its source, which the renderer replaced with one
/// attachment character; and the size the mapper pins on every read-back dropped when it is the
/// size the style gives the run anyway. `nil` in the model means "the style's size", and the
/// render size of a quote child, a cell or code must not persist as an explicit override the
/// text would keep when moved out of its container.
private func modelRuns(readingBack attributed: NSAttributedString, style: ParagraphStyleName,
through mapper: AttributedStringMapper) -> [TextRun] {
let source = Self.withCarriedTraitsOnFallbackFaces(attributed)
return Self.withLinkUnderlines(mapper.runs(from: source, style: style), from: source).map { run in
var run = run
run.text = Self.modelText(run.text)
// An entity is never inline code too (`withoutInlineCodeOverLinks`).
if run.attributes.link != nil { run.attributes.inlineCode = false }
if let formula = run.attributes.formula, !formula.isEmpty, run.text == "\u{FFFC}" {
run.text = formula
}
if let size = run.attributes.fontSize {
var ambient = run.attributes
ambient.fontSize = nil
if abs(mapper.styleSheet.font(for: style, attributes: ambient).pointSize - CGFloat(size)) < 0.01 {
run.attributes.fontSize = nil
}
}
return run
}
}
/// The runs with an UNDERLINE on a linked run read back from the storage.
///
/// The shared mapper's read-back drops underline on any run carrying a link — a rule from when a
/// link's underline was render-derived. Nothing draws one now ("No underline: reference design
/// shows links as accent-colored text only"), so an underline on a link is the USER's, and the
/// engine drew it while the model — and the sent message — dropped it: Format ▸ Underline and
/// Format ▸ Link on one word, in either order (composer harness, 2026-09-23,
/// `HarnessLinkUnderlineTests`). Restored here rather than in the mapper, which the legacy canvas
/// shares and whose link removal still strips `.underlineStyle`.
///
/// `runs(from:)` emits exactly one run per attribute run, so the same enumeration lines up with
/// it run for run; a count mismatch leaves the runs as they are.
private static func withLinkUnderlines(_ runs: [TextRun], from source: NSAttributedString) -> [TextRun] {
var ranges: [[NSAttributedString.Key: Any]] = []
source.enumerateAttributes(in: NSRange(location: 0, length: source.length), options: []) { attributes, _, _ in
ranges.append(attributes)
}
guard ranges.count == runs.count else { return runs }
var result = runs
for (index, attributes) in ranges.enumerated()
where attributes[.link] != nil && ((attributes[.underlineStyle] as? Int) ?? 0) != 0 {
result[index].attributes.underline = true
}
return result
}
/// Engine-parallel: one block per role. A role the engine has no block for yet (a transient
/// inside a set) materialises empty, so the two arrays stay the same length.
internal func blocks(for roles: [EngineBlockRole], from attributed: [NSAttributedString]) -> [Block] {
roles.enumerated().map { index, role in
index < attributed.count ? block(for: role, from: attributed[index]) : role.shape.block(runs: [])
}
}
/// What the block `role` holds carries that the COMPOSER's model cannot: a text colour other than
/// the theme's, a highlight, a font family. `ChatInputContent` has no field for any of them, so a run
/// carrying one is drawn in the composer and not sent. The derived-presentation check cannot see it —
/// the read-back keeps the colour, so the re-render draws it too — which is how a pasted foreign
/// colour survived a whole fuzz sweep (composer harness p20, `HarnessPastedForeignColourTests`).
/// Answers each offending field with its value and the start of its run; empty when clean. A check, never a trap: the article editor
/// shares this bridge and its own sink decides what it can carry.
internal func modelUnrepresentableFields(role: EngineBlockRole, from attributed: NSAttributedString) -> [String] {
let block = Self.withoutCarriedPresentation(self.block(for: role, from: Self.withoutAmbientTraits(attributed)),
theme: mapper.theme)
let runs: [TextRun]
switch block {
case let .paragraph(paragraph): runs = paragraph.runs
case let .code(code): runs = code.runs
case let .pullQuote(quote): runs = quote.runs
case .media, .table, .blockQuote, .details, .buttonRow: runs = []
}
var fields: Set<String> = []
for run in runs {
if let c = run.attributes.foreground { fields.insert("foreground=\(c) \(run.text.prefix(12))") }
if let c = run.attributes.highlight { fields.insert("highlight=\(c) \(run.text.prefix(12))") }
if run.attributes.fontFamily != nil { fields.insert("fontFamily") }
}
return fields.sorted()
}
/// The engine text `role` should hold for the block it holds now — the rendering
/// `engineRepresentation(for:)` gives that block in a document set, its text read back first.
/// Settle applies the presentation of this to the blocks an edit touched (structure-only roles
/// design §9): presentation is derived from the role and the model, never carried by the text.
internal func render(role: EngineBlockRole, from attributed: NSAttributedString) -> NSAttributedString {
let block = Self.withoutCarriedPresentation(self.block(for: role, from: Self.withoutAmbientTraits(attributed)),
theme: mapper.theme)
switch role.position {
case let .tableCell(table, row, column, cell):
let source = table.cell(row: row, column: column)?.cell(blocks: []) ?? Cell(id: cell)
return cellLine(for: source, paragraph: block.asParagraph ?? ParagraphBlock(id: cell))
case let .secondary(_, _, kind):
let runs = block.asParagraph?.runs ?? []
// Nested at any depth >= 1, indented the same `quoteIndent * depth` as a standalone
// child below — `flatten`'s `emit` applies this to every secondary line it produces
// (`.codeLanguage`, `.pullQuoteAuthor`, `.mediaCaption`, padded first if padded at
// all), and this must match or a touched nested line's indent is overwritten by
// `normalisePresentation` and an untouched one drifts under `verifyPresentationIsDerived`.
func nested(_ text: NSAttributedString) -> NSAttributedString {
role.containerDepth > 0
? Self.quoteIndented(text, by: role.contentIndent(styleSheet: mapper.styleSheet),
trailing: role.contentTrailingInset(styleSheet: mapper.styleSheet))
: text
}
switch kind {
case .codeLanguage:
return nested(Self.padded(Self.languageLine(text: block.flatText, using: mapper),
by: mapper.styleSheet.codeHorizontalInset))
case .pullQuoteAuthor:
return nested(Self.padded(Self.authorLine(runs: runs, centred: true, using: mapper),
by: pullQuoteStyle.horizontalPadding))
case .blockQuoteAuthor:
return Self.quoteIndented(Self.authorLine(runs: runs, centred: false, using: mapper),
by: role.contentIndent(styleSheet: mapper.styleSheet),
trailing: role.contentTrailingInset(styleSheet: mapper.styleSheet))
case .mediaCaption:
return nested(Self.captionLine(runs: runs, using: mapper))
case .detailsTitle:
// The quote's size, when a quote encloses it: a title is the details' own line, and
// a line inside a quote is 15pt whatever it is. Built with the plain mapper, as it
// was, a quoted details wore a 17pt title above 15pt siblings.
return Self.detailsTitleLine(runs: runs, using: role.isInsideAQuote ? quoteMapper : mapper,
contentIndent: role.contentIndent(styleSheet: mapper.styleSheet),
contentTrailingInset: role.contentTrailingInset(styleSheet: mapper.styleSheet))
}
case .standalone:
// Text at any depth >= 1 is the quote mapper's (15pt, never rescaled) and indented one
// quoteIndent per level (§4.2); a code child keeps its face — the code arm below ignores
// the mapper it is handed.
let rendered = attributedBlock(for: block, using: role.isInsideAQuote ? quoteMapper : nil,
within: role.ancestry)
return role.containerDepth > 0
? Self.quoteIndented(rendered, by: role.contentIndent(styleSheet: mapper.styleSheet),
trailing: role.contentTrailingInset(styleSheet: mapper.styleSheet))
: rendered
}
}
/// The block with the presentation its text carried from another role dropped, so the role's
/// own wins: the mapper pins a run's rendered size on read-back by design ("a run keeps its
/// rendered size when moved across contexts"), which is exactly the carrying this pass undoes,
/// and it records a foreground that is not the style's default — the other style's default,
/// for text that came from an author line. Neither editor lets the user set a size, and a
/// colour the theme itself paints is not the user's either.
private static func withoutCarriedPresentation(_ block: Block, theme: RichTextEditorTheme) -> Block {
func strip(_ runs: [TextRun]) -> [TextRun] {
runs.map { run in
var run = run
run.attributes.fontSize = nil
if let foreground = run.attributes.foreground,
isThemeText(foreground, theme: theme) {
run.attributes.foreground = nil
}
// The inline-code pill's fill on text that is NOT inline code is that pill's
// presentation left behind, never a user highlight: typing or composing over a
// selection Monospace was just applied to took the controller's typing attributes,
// which carry the background and not the marker, so the composer drew a grey
// highlight the model could not send (composer harness p20 fix wave, found by the
// fuzz's model-unrepresentable check; `HarnessPastedForeignColourTests`).
if let highlight = run.attributes.highlight, !run.attributes.inlineCode,
matches(highlight, [theme.inlineCodeBackground]) {
run.attributes.highlight = nil
}
return run
}
}
switch block {
case let .paragraph(paragraph):
var stripped = paragraph
stripped.runs = strip(paragraph.runs)
return .paragraph(stripped)
case let .code(code): return .code(CodeBlock(id: code.id, language: code.language, runs: strip(code.runs)))
case let .pullQuote(quote): return .pullQuote(PullQuote(id: quote.id, runs: strip(quote.runs), author: quote.author))
case .media, .table, .blockQuote, .details, .buttonRow: return block
}
}
/// The text with each run's rendered face given back the user's bold and italic that TextKit's
/// font fixing took from it, so the mapper's read-back (which derives italic, and here bold,
/// from the FACE) sees them.
///
/// Font fixing replaces `.font` in the engine's storage with a fallback face for text the style
/// face cannot draw — PingFang for Chinese, an Arabic face, Apple Color Emoji — and those faces
/// often have no italic (emoji: no bold either). The engine keeps the user's intent in its own
/// markers (`IDInlineTextItalic` / `IDInlineTextBold`, which its trait pass and toolbar read),
/// so the storage said italic while the model — what is sent — said plain (composer harness,
/// 2026-09-23, `HarnessFallbackFontFormattingTests`). Only a trait a marker carries AND the
/// rendered face lacks is restored, on the run's base face (`IDInlineTextBaseFontAttributeName`,
/// what the engine derives the face from); a trait the base face itself carries is ambient
/// (a header cell, an author line) and is not the marker's to restore. Runs whose face kept its
/// traits — every non-substituted run — are left exactly as they are. The presentation verifier
/// compares the stored face through the same restoration, or the model's (correct) italic reads
/// as drift against a fallback face that cannot carry it.
internal static func withCarriedTraitsOnFallbackFaces(_ source: NSAttributedString) -> NSAttributedString {
var faces: [(NSRange, UIFont)] = []
source.enumerateAttributes(in: NSRange(location: 0, length: source.length), options: []) { attributes, range, _ in
guard let font = attributes[.font] as? UIFont,
let base = attributes[NSAttributedString.Key.IDInlineTextBaseFontAttributeName] as? UIFont else { return }
var carried: UIFontDescriptor.SymbolicTraits = []
if (attributes[NSAttributedString.Key.IDInlineTextBoldAttributeName] as? Bool) == true { carried.insert(.traitBold) }
if (attributes[NSAttributedString.Key.IDInlineTextItalicAttributeName] as? Bool) == true { carried.insert(.traitItalic) }
let emphasis: UIFontDescriptor.SymbolicTraits = [.traitBold, .traitItalic]
let baseTraits = base.fontDescriptor.symbolicTraits
let rendered = font.fontDescriptor.symbolicTraits.intersection(emphasis)
let traits: UIFontDescriptor.SymbolicTraits
if font.familyName != base.familyName {
// A SUBSTITUTED face: its traits are the substitute's, not the run's. It is read as
// what the engine derives the run from — the base face (whose traits are ambient: an
// author line's emphasis, a header cell's bold, a pull quote's slant) plus the user's
// markers — which is also what the run would wear had nothing been substituted. Kept
// as the substitute's own, a face could ADD a trait nobody set: a heading's New York
// Medium falls back to a BOLD face for Hebrew and Thai, and once that text left the
// heading (a cross-block edit merged it into a body paragraph) the read-back took the
// bold for the user's, the model gained it, and normalisation drew it bold from then
// on (composer harness, 2026-09-23, fuzz seed 12, `HarnessSubstitutedFaceTraitTests`);
// or LACK one it should carry: an emoji or CJK in an author line cannot be bold or
// italic (`HarnessAuthorLineFallbackFaceTests`).
//
// ALWAYS replaced, even when the substitute already carries exactly the intended
// traits: the mapper does not trust a bold SUBSTITUTED face (`boldIsAmbient` re-runs
// the substitution on the non-bold base and calls the bold ambient when that is bold
// too), so left as it was, Format ▸ Bold over a heading's Thai or Hebrew — whose
// substitute is bold before and after — never reached the model, and the verifier
// saw the bold face as drift against the unbolded model (composer harness,
// 2026-09-23, fuzz seed 35, `HarnessHeadingFallbackBoldTests`). On the base face's
// family the face reads as exactly what it is meant to carry.
let intended = baseTraits.intersection(emphasis).union(carried)
traits = baseTraits.subtracting(emphasis).union(intended)
} else {
let missing = carried.subtracting(baseTraits).subtracting(rendered)
guard !missing.isEmpty else { return }
traits = baseTraits.subtracting(emphasis).union(rendered).union(missing)
}
guard let descriptor = base.fontDescriptor.withSymbolicTraits(traits) else { return }
faces.append((range, UIFont(descriptor: descriptor, size: font.pointSize)))
}
guard !faces.isEmpty else { return source }
let result = NSMutableAttributedString(attributedString: source)
for (range, face) in faces { result.addAttribute(.font, value: face, range: range) }
return result
}
/// The text with the bold and italic its BASE face carries removed from its rendered face, so the
/// read-back does not take an ambient trait for the user's. The base face is the engine's own
/// notion of ambient (`IDInlineTextBaseFontAttributeName`, what its trait pass derives from);
/// the bridge stamps it bold on a header cell and emphasised on an author line, and code's is
/// plain. Text without a base face reads as it is.
private static func withoutAmbientTraits(_ source: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
let whole = NSRange(location: 0, length: result.length)
source.enumerateAttributes(in: whole, options: []) { attributes, range, _ in
guard let font = attributes[.font] as? UIFont,
let base = attributes[NSAttributedString.Key.IDInlineTextBaseFontAttributeName] as? UIFont else { return }
let ambient = base.fontDescriptor.symbolicTraits.intersection([.traitBold, .traitItalic])
guard !ambient.isEmpty else { return }
let traits = font.fontDescriptor.symbolicTraits.subtracting(ambient)
guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits) else { return }
result.addAttribute(.font, value: UIFont(descriptor: descriptor, size: font.pointSize), range: range)
}
return result
}
private static func isThemeText(_ colour: RGBAColor, theme: RichTextEditorTheme) -> Bool {
matches(colour, [theme.primaryText, theme.secondaryText])
}
private static func matches(_ colour: RGBAColor, _ candidates: [UIColor]) -> Bool {
candidates.contains { candidate in
var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
guard candidate.getRed(&red, green: &green, blue: &blue, alpha: &alpha) else { return false }
return abs(Double(red) - colour.red) < 0.005 && abs(Double(green) - colour.green) < 0.005
&& abs(Double(blue) - colour.blue) < 0.005 && abs(Double(alpha) - colour.alpha) < 0.005
}
}
/// The model document: every role materialised, then regrouped.
public func document(for roles: [EngineBlockRole], from attributed: [NSAttributedString]) -> Document {
EngineBlockRole.document(roles: roles, blocks: blocks(for: roles, from: attributed))
}
/// The mapper a role is RENDERED with — which is also the one it reads back through, so there
/// is one answer rather than two. Internal because the list-marker pass needs it: a marker is
/// drawn by the host beside text the bridge rendered, and a marker resolving its font from the
/// plain mapper is 17pt beside a quote's 15pt text.
internal func renderMapper(for role: EngineBlockRole) -> AttributedStringMapper {
readBackMapper(for: role)
}
private func readBackMapper(for role: EngineBlockRole) -> AttributedStringMapper {
// A details' title is authored text and is rendered at the quote's size inside one, so it
// reads back through the same mapper. Every other secondary line is a field the bridge
// re-derives, so the plain mapper stays right for those.
if role.secondaryKind == .detailsTitle { return role.isInsideAQuote ? quoteMapper : mapper }
if role.secondaryKind != nil { return mapper }
if case .code = role.shape { return codeMapper }
if role.owningTable != nil { return tableCellMapper }
// Must track the FORWARD choice exactly, or a read-back pins a size the render never used.
if role.isInsideAQuote { return quoteMapper }
return mapper
}
/// Records each run's unstyled face in `IDInlineTextBaseFontAttributeName`, the key the engine's
/// trait pass DERIVES bold and italic from. Writing only `.font` would be undone by the next
/// trait toggle, exactly as it would be for inline code.
///
/// This used to SUBSTITUTE the face too — `monospacedSystemFont` at whatever size the body mapper
/// had already produced — which is what left code at 17pt system-monospace instead of 15pt Menlo:
/// a face swap cannot reach the pinned line height, so the text was also spaced for a font it was
/// no longer set in. The face now comes from `codeMapper`, and only the base-font stamp is left.
/// Stripping the traits matters for the same reason the stamp does: a bold run inside code would
/// otherwise derive its base from the BOLD face and never toggle back.
///
/// It deliberately does NOT set `.rtInlineCode`: that marker is what `runs(from:)` reads back into
/// `CharacterAttributes.inlineCode`, and a code block's runs must not come back as inline-code
/// runs nested inside it. The monospacing here is presentation only.
/// A pull quote's italic is AMBIENT — the `.pullQuote` style forces it at render time and the
/// mapper's read-back strips it (`ca.italic = style == .pullQuote ? false : …`), so the model can
/// never carry it. The engine derives the face from `IDInlineTextBaseFontAttributeName` plus its
/// trait markers, so the italic belongs in the BASE face, as an author line's emphasis does.
/// Stamped from the upright base with the italic kept, every trait the engine recomputes (Italic
/// toggled off, Bold toggled on) keeps the slant the role derives. Left unstamped, the default
/// stamp stripped the italic into an `IDInlineTextItalic` marker and Format ▸ Italic took it
/// away — the word drawn upright while the model, and the sent message, stayed italic (composer
/// harness, 2026-09-23, `HarnessPullQuoteInlineFormatDriftTests`).
///
/// An inline-code run is left to the default stamp: the mapper renders code in a pull quote
/// UPRIGHT (the italic is not the user's, so `ca.italic` is false there), and that is the face
/// the legacy editor draws.
private static func stampingAmbientItalicBaseFont(_ source: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
var stamps: [(NSRange, UIFont)] = []
source.enumerateAttributes(in: NSRange(location: 0, length: source.length), options: []) { attributes, range, _ in
guard (attributes[.rtInlineCode] as? Bool) != true, let font = attributes[.font] as? UIFont else { return }
var traits = font.fontDescriptor.symbolicTraits
guard traits.contains(.traitItalic) else { return }
traits.remove(.traitBold)
let base = font.fontDescriptor.withSymbolicTraits(traits)
.map { UIFont(descriptor: $0, size: font.pointSize) } ?? font
stamps.append((range, base))
}
for (range, base) in stamps {
result.addAttribute(NSAttributedString.Key.IDInlineTextBaseFontAttributeName, value: base, range: range)
}
return result
}
private static func stampingMonospaceBaseFont(_ source: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
let whole = NSRange(location: 0, length: result.length)
result.enumerateAttribute(.font, in: whole, options: []) { value, range, _ in
guard let font = value as? UIFont else { return }
var traits = font.fontDescriptor.symbolicTraits
traits.subtract([.traitBold, .traitItalic])
let base = font.fontDescriptor.withSymbolicTraits(traits)
.map { UIFont(descriptor: $0, size: font.pointSize) } ?? font
result.addAttribute(NSAttributedString.Key.IDInlineTextBaseFontAttributeName,
value: base, range: range)
}
return result
}
/// A quote child's leading inset AND the trailing inset it stops short of, applied to a COPIED
/// paragraph style so V2's pinned line geometry survives — the same rule the code block's
/// padding follows.
///
/// `tailIndent` is NEGATIVE by convention: a negative value is the distance from the container's
/// trailing edge, which is what "stop 22pt short of the column" means, and it composes for
/// nesting because each level subtracts its own. Legacy narrows the same way and leaves the fill
/// full width (`BlockQuoteBox`: `inner = width - leadingInset - trailingInset`).
///
/// Presentation only: the mapper never reads an indent back, so this cannot reach the model.
private static func quoteIndented(_ source: NSAttributedString, by indent: CGFloat,
trailing: CGFloat = 0) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
let whole = NSRange(location: 0, length: result.length)
result.enumerateAttribute(.paragraphStyle, in: whole, options: []) { value, range, _ in
let base = (value as? NSParagraphStyle) ?? NSParagraphStyle.default
guard let style = base.mutableCopy() as? NSMutableParagraphStyle else { return }
style.firstLineHeadIndent += indent
style.headIndent += indent
style.tailIndent -= trailing
result.addAttribute(.paragraphStyle, value: style, range: range)
}
return result
}
/// Restores bold that the shared mapper drops on the way to the engine.
///
/// `attributes(from:)` renders an inline-code run with
/// `monospacedSystemFont(ofSize:weight: .regular)`, flattening the user's bold. That is fine on
/// the LEGACY path, where `.rtBold` carries the intent separately and read-back prefers it. The
/// engine has no such marker — it writes `IDInlineTextBold` and otherwise derives bold from the
/// RENDERED FONT — so a bold code run arrived plain, and the next read-back would then report
/// `bold: false` and lose it from the model too.
///
/// Fixed HERE rather than in the mapper on purpose: the mapper is shared, and changing what it
/// draws would change legacy rendering for every existing user. This is the engine's own import.
///
/// Italic has no equivalent marker, and needs none: since 2026-09-23 the mapper renders an italic
/// inline-code run in the ITALIC monospace face, so the face carries it both ways
/// (`HarnessMonospaceItalicTests` — italic on monospace text was dropped by the next edit anywhere).
/// How the engine's rendered strings express V2's line geometry.
///
/// `StyleSheet` pins `minimumLineHeight == maximumLineHeight == pitch`, and TextKit BOTTOM-ALIGNS
/// a line inside a pinned box — so a pitch taller than the font's own line puts the difference
/// ABOVE the line as dead space. The legacy canvas absorbs that with
/// `BlockLayoutEngine.baselineDelta`, a render-time correction the engine has no counterpart to:
/// it draws this string as-is. Re-expressing the same pitch as a smaller box plus a `lineSpacing`
/// remainder moves the baseline without moving baseline-to-baseline.
///
/// `.off` is today's behaviour and the rollback. See
/// the RichTextEditor `CLAUDE.md`, "A block's FRAME is a line box; the rhythm below it is the renderer's".
internal enum V2BaselinePlacement: Hashable {
/// Today: the box IS the pitch; the first baseline sits `pitch - |descender|` from the top.
case off
/// Box = `ceil(ascender + |descender|)`. The block's height stays exactly V2's, so the
/// rhythm is untouched; the baseline lands `ceil(fontBox) - fontBox` low.
case safe
/// Box = `ascender + |descender|`. The baseline is exactly V2's; the block measures
/// `ceil(fontBox) - fontBox` short unless that is restored elsewhere.
case exact
/// `.exact` plus that shortfall as `paragraphSpacing`, which IS counted in the used height —
/// exact on both axes if TextKit 2 agrees. An engine block is one paragraph by construction
/// (`engineText` maps "\n" to U+2028), so it is applied once.
case exactWithTrailingSpacing
}
internal static var v2BaselinePlacement: V2BaselinePlacement = .safe
private static func engineReadable(_ source: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
let whole = NSRange(location: 0, length: result.length)
// Collected then applied, never written inside the walk: adding a NEW key splits runs, and a
// walk that mutates its own subject re-enumerates what it just changed.
var boldFaces: [(NSRange, UIFont)] = []
result.enumerateAttributes(in: whole, options: []) { attributes, range, _ in
guard (attributes[.rtBold] as? Bool) == true,
let font = attributes[.font] as? UIFont,
!font.fontDescriptor.symbolicTraits.contains(.traitBold) else { return }
let wanted = font.fontDescriptor.symbolicTraits.union(.traitBold)
guard let descriptor = font.fontDescriptor.withSymbolicTraits(wanted) else { return }
boldFaces.append((range, UIFont(descriptor: descriptor, size: font.pointSize)))
}
for (range, font) in boldFaces { result.addAttribute(.font, value: font, range: range) }
stampInlineTraitMarkers(result)
applyV2BaselinePlacement(result)
return result
}
/// Re-expresses V2's pinned line box so TextKit lands the first baseline where V2 puts it.
/// See `V2BaselinePlacement`. Engine-only: it runs in the bridge, not in the shared `StyleSheet`,
/// so legacy rendering (which corrects this its own way and would then double-correct) is
/// untouched.
///
/// **The guard is `pitch > box`, and it is NOT about attachments.** A paragraph whose first run
/// carries an explicit `CharacterAttributes.fontSize` larger than its style's
/// (`StyleSheet.swift:84`; `RTFImport` produces these) has a font box TALLER than the pitch, which
/// `StyleSheet.paragraphStyle` derives from the style's BASE font. `NSParagraphStyle.lineSpacing`
/// is non-negative, so such a paragraph is skipped entirely. Do not clamp the remainder to 0 —
/// that would grow the pin, and the pitch with it.
private static func applyV2BaselinePlacement(_ result: NSMutableAttributedString) {
guard v2BaselinePlacement != .off else { return }
let whole = NSRange(location: 0, length: result.length)
// Collected then applied, for the same reason the bold pass above collects: writing inside
// the walk re-enumerates what it just changed.
var restyled: [(NSRange, NSParagraphStyle)] = []
result.enumerateAttribute(.paragraphStyle, in: whole, options: []) { value, range, _ in
guard let pinned = value as? NSParagraphStyle,
// The font of the style run's FIRST character, mirroring `BlockLayoutEngine
// .primaryFont` — an engine block is one paragraph, so this is its font.
let font = result.attribute(.font, at: range.location, effectiveRange: nil) as? UIFont,
let style = v2BaselinePlacedStyle(pinned, font: font)
else { return }
restyled.append((range, style))
}
for (range, style) in restyled { result.addAttribute(.paragraphStyle, value: style, range: range) }
}
/// The re-expression for ONE pinned paragraph style, or nil when the style is not pinned, the mode
/// is `.off`, or the guard refuses it.
///
/// **Shared by two callers, and the second one is not obvious.** `applyV2BaselinePlacement` walks a
/// rendered block's string; `collapsedQuotePreviewAttributes` builds ONE style directly for a
/// folded quote's preview, which is laid out by the LEGACY `BlockLayoutEngine` and so never passes
/// through `engineReadable` at all. That preview draws at
/// `rawFirstBaselineFromTop - firstLineBaselineFromTop` (`CollapsedQuotePreviewView`), and `raw`
/// is measured from THIS box — so a preview left on the old pitch draws `pitch - box` below the
/// expanded text it stands for, and a quote's text visibly jumps when you fold it. Measured at
/// 1.0pt for a 15pt quote before this was shared, by
/// `EngineQuoteCollapseFidelityTests.test_theFoldedTextSitsWhereTheExpandedTextSits`.
///
/// **The guard is `pitch > box`, and it is NOT about attachments.** A paragraph whose first run
/// carries an explicit `CharacterAttributes.fontSize` larger than its style's
/// (`StyleSheet.swift:84`; `RTFImport` produces these) has a font box TALLER than the pitch, which
/// `StyleSheet.paragraphStyle` derives from the style's BASE font. `NSParagraphStyle.lineSpacing`
/// is non-negative, so such a paragraph is skipped entirely. Do not clamp the remainder to 0 —
/// that would grow the pin, and the pitch with it.
internal static func v2BaselinePlacedStyle(_ pinned: NSParagraphStyle,
font: UIFont) -> NSParagraphStyle? {
let mode = v2BaselinePlacement
guard mode != .off,
pinned.maximumLineHeight > 0,
pinned.maximumLineHeight == pinned.minimumLineHeight else { return nil }
let fontBox = font.ascender - font.descender // `descender` is negative
let pitch = pinned.maximumLineHeight
let box = (mode == .safe) ? ceil(fontBox) : fontBox
guard pitch > box, let style = pinned.mutableCopy() as? NSMutableParagraphStyle else { return nil }
style.minimumLineHeight = box
style.maximumLineHeight = box
style.lineSpacing = pitch - box
if mode == .exactWithTrailingSpacing { style.paragraphSpacing += ceil(fontBox) - fontBox }
return style
}
/// Writes the engine's OWN trait markers for what the shared mapper rendered.
///
/// `IDInlineTextStyleResolver.traitsInAttributes:` reads `IDInlineTextBold` / `Italic` /
/// `Underline` / `Strikethrough` **and nothing else** — it does not derive state from the rendered
/// font (`baseFontForAttributes:` picks the base FACE, which is a different question, and is what
/// this file's older comment was describing). The mapper emits the DERIVED form and none of those
/// markers, so before this every trait reported `off` for text that arrived through a document set
/// — a reopened draft, a paste, the composer seeding content.
///
/// The measured consequences, worst first: the model and the render DIVERGED (un-bolding left
/// `bold: true` in the document while the face went regular, so the message would send bold while
/// the editor showed plain); un-bolding took two presses, the first writing the marker and
/// recomputing the identical face; and the toolbar showed Bold unlit over visibly bold text.
///
/// It ADDS keys and moves nothing rendered: `.font`, `.underlineStyle` and `.strikethroughStyle`
/// already equal what the engine derives from these markers, so they are left as the mapper wrote
/// them. And it runs INSIDE the render path, so `render(role:from:)` and the stored block produce
/// the same string and `verifyPresentationIsDerived` cannot drift.
private static func stampInlineTraitMarkers(_ result: NSMutableAttributedString) {
var stamps: [(NSRange, [NSAttributedString.Key: Any])] = []
result.enumerateAttributes(in: NSRange(location: 0, length: result.length),
options: []) { attributes, range, _ in
guard let font = attributes[.font] as? UIFont else { return }
let traits = font.fontDescriptor.symbolicTraits
var added: [NSAttributedString.Key: Any] = [:]
// From `.rtBold` ALONE, never from the rendered face. The face is bold for reasons that
// are not the user's: a pull quote's AUTHOR line and a code block's LANGUAGE line are
// style-bold, and the system "Bold Text" setting forces the trait onto substituted
// scripts. That is the whole reason `.rtBold` exists. Deriving from the face instead
// stamped a marker the re-render could not reproduce, and `verifyPresentationIsDerived`
// trapped on every pull quote with `RTBold` as the drifting key.
if (attributes[.rtBold] as? Bool) == true {
added[.IDInlineTextBoldAttributeName] = true
}
// Italic from the face, EXCEPT an italic the base face already carries: that one is
// ambient (a pull quote's body, its author line) and the marker is the user's. Stamped
// anyway, the engine reported Italic lit over a pull quote and its toggle took the
// user's marker away from text whose slant was never theirs.
let baseIsItalic = (attributes[.IDInlineTextBaseFontAttributeName] as? UIFont)?
.fontDescriptor.symbolicTraits.contains(.traitItalic) ?? false
if traits.contains(.traitItalic), !baseIsItalic { added[.IDInlineTextItalicAttributeName] = true }
// Nothing renders a link underlined, so an underline on a linked run is the user's and
// carries the marker like any other (`withLinkUnderlines` reads it back).
if ((attributes[.underlineStyle] as? Int) ?? 0) != 0 {
added[.IDInlineTextUnderlineAttributeName] = true
}
if ((attributes[.strikethroughStyle] as? Int) ?? 0) != 0 {
added[.IDInlineTextStrikethroughAttributeName] = true
}
// The base face is what the engine derives from, and its traits MUST be stripped or a bold
// run derives its base from the bold face and can never toggle back — the rule
// `stampingMonospaceBaseFont` states for code. That one runs FIRST on the code path, so an
// already-stamped base is left alone rather than recomputed from a face it deliberately
// replaced.
if attributes[.IDInlineTextBaseFontAttributeName] == nil {
let stripped = traits.subtracting([.traitBold, .traitItalic])
added[.IDInlineTextBaseFontAttributeName] = font.fontDescriptor
.withSymbolicTraits(stripped)
.map { UIFont(descriptor: $0, size: font.pointSize) } ?? font
}
if !added.isEmpty { stamps.append((range, added)) }
}
for (range, added) in stamps {
for (key, value) in added { result.addAttribute(key, value: value, range: range) }
}
// `.rtBold` is REMOVED once its fact is carried by the engine's own marker, so engine storage
// holds exactly ONE bold representation.
//
// Keeping both meant they could disagree, and they did: the engine's toggle rewrites its
// marker and recomputes the face but never touches `.rtBold`, so un-bolding loaded text left
// the document reading `bold: true` while the face went regular. Reconciling on the way back
// instead was tried and rejected — `setAllowedTypingAttributeNames:` admits the engine's four
// traits and not a host marker, so a typed character carries one and not the other, and the
// re-render then disagreed with storage on `RTBold` for every edited author line.
//
// Safe because this is precisely the "marker-less storage" case the mapper's read-back is
// written for: it falls back to the ambient-stripped `.traitBold`, whose `boldIsAmbient` check
// is what keeps the system "Bold Text" setting from leaking bold into the model. The engine
// keeps that face in lockstep with its marker, so the fallback reads the engine's answer.
result.removeAttribute(.rtBold, range: NSRange(location: 0, length: result.length))
}
/// A code block's language line: bold sans at the BODY size, lowercased — the same derivation
/// `CodeBlockBox.languageLine` uses, so the two editors render it identically.
///
/// Lowercased HERE rather than at draw time, which means the model keeps whatever case the user
/// typed and only the display is normalised — until the line is read back, at which point the
/// lowercased text IS what was authored. That matches the legacy editor, whose language line is
/// likewise the authored text.
// MARK: - Tables
/// A cell's newline. The engine raises on an interior `U+000A` — that IS its block separator —
/// so inside the engine a cell's newline is LINE SEPARATOR, which TextKit wraps at and the
/// engine never splits on. The model keeps `\n`; these two are the only crossing points.
internal static let lineSeparator = "\u{2028}"
/// A body's text as the engine holds it: every paragraph break a line separator. CR, CRLF, `U+2029` and NEL
/// are paragraph breaks first (Core `normalisedLineBreaks`, the one normaliser of every door): the render is
/// the SET's door, and a CR kept here stayed in the model as `U+000D`, was sent, and made the tokenizer's
/// paragraph boundaries disagree with the rest of `UITextInput` (composer harness p24,
/// `HarnessSetCarriageReturnTests`). The read-back gives "\n", so the set is reported to the chat.
internal static func engineText(_ text: String) -> String {
normalisedLineBreaks(text).replacingOccurrences(of: "\n", with: lineSeparator, options: .literal)
}
internal static func modelText(_ text: String) -> String {
text.replacingOccurrences(of: lineSeparator, with: "\n")
}
/// No run is inline code AND a link — a mention, a date and a URL are all links in this model. The
/// platform's nesting rule (Bot API "Formatting options": bold, italic, underline, strikethrough and
/// spoiler nest with anything except `pre` / `code`; no other entity contains another) does not let a
/// `Code` entity overlap a `MentionName`, `FormattedDate` or `TextUrl`, so a composer showing a monospaced
/// mention sent one the server normalises (composer harness p23, `HarnessInlineCodeOverEntityTests`).
/// The COMMANDS refuse to make one, as legacy does (Monospace over a range holding a link, Link over a
/// range holding inline code: `inlineTargetsHold`). Content that arrives with both — a set, a paste, an
/// external pasteboard's monospaced link — keeps the ENTITY and drops the inline code: the entity carries
/// data (a user, a date, a URL), the code is a face. Applied on render and on read-back, so settle's
/// presentation pass (`.rtInlineCode` is a presentation key) takes the marker off whatever an edit left.
internal static func withoutInlineCodeOverLinks(_ runs: [TextRun]) -> [TextRun] {
guard runs.contains(where: { $0.attributes.inlineCode && $0.attributes.link != nil }) else { return runs }
return runs.map { run in
guard run.attributes.inlineCode, run.attributes.link != nil else { return run }
var run = run
run.attributes.inlineCode = false
return run
}
}
/// `withoutInlineCodeOverLinks` over every run of `blocks`, containers, captions, authors and cells included.
internal static func withoutInlineCodeOverLinks(_ blocks: [Block]) -> [Block] {
return blocks.map { block in
switch block {
case var .paragraph(paragraph):
paragraph.runs = withoutInlineCodeOverLinks(paragraph.runs)
return .paragraph(paragraph)
case var .pullQuote(quote):
quote.runs = withoutInlineCodeOverLinks(quote.runs)
quote.author = withoutInlineCodeOverLinks(quote.author)
return .pullQuote(quote)
case var .blockQuote(quote):
quote.children = withoutInlineCodeOverLinks(quote.children)
quote.author = withoutInlineCodeOverLinks(quote.author)
return .blockQuote(quote)
case var .details(details):
details.title = withoutInlineCodeOverLinks(details.title)
details.children = withoutInlineCodeOverLinks(details.children)
return .details(details)
case var .media(media):
media.caption = withoutInlineCodeOverLinks(media.caption)
return .media(media)
case var .table(table):
for r in table.rows.indices {
for c in table.rows[r].cells.indices {
table.rows[r].cells[c].blocks = withoutInlineCodeOverLinks(table.rows[r].cells[c].blocks)
}
}
return .table(table)
case .code, .buttonRow:
return block
}
}
}
/// Text as a ONE-LINE field holds it (a code block's language, a details title, a quote's or pull
/// quote's author): every run of whitespace that contains a line break — `"\n"`, CR, CRLF, `U+2028`,
/// `U+2029`, NEL, every `Character.isNewline` — becomes one space, which is what a stock
/// `UITextField` does with a newline, so "123 Main St⏎City" keeps its word boundary (composer harness
/// p12, `HarnessSingleLineFieldNewlineTests`). A run collapses to ONE space because a blank line or an
/// indented continuation is layout of the text it came from, not two word boundaries.
///
/// The ONE rule for these fields, on every door: the render (`languageLine`, `authorLine`,
/// `detailsTitleLine` — a set, an edit load, a re-render), the surface gate (typed and replaced text,
/// the chat's inserts), a paste or a drop, and settle's repair of a composition's commit. Before p23 the
/// render kept a language's and an author's "\n" RAW — the engine's block separator — and the gate
/// flattened only text containing "\n", so a `U+2028` typed into one read back as "\n" and the next
/// render split the line (`HarnessNewlineInOneLineFieldCrashTests`).
internal static func oneLineText(_ text: String) -> String {
guard text.contains(where: { $0.isNewline }) else { return text }
var result = ""
var pendingWhitespace = ""
var runHasNewline = false
for character in text {
if character.isNewline {
runHasNewline = true
pendingWhitespace.append(character)
} else if character.isWhitespace {
pendingWhitespace.append(character)
} else {
result += runHasNewline ? " " : pendingWhitespace
pendingWhitespace = ""
runHasNewline = false
result.append(character)
}
}
result += runHasNewline ? " " : pendingWhitespace
return result
}
/// `oneLineText` over a one-line field's runs, run by run (a run's attributes stay its own).
internal static func oneLineRuns(_ runs: [TextRun]) -> [TextRun] {
runs.map { TextRun(text: oneLineText($0.text), attributes: $0.attributes) }
}
/// A body's runs as the engine holds them: every newline a line separator. A code block and a
/// pull quote are multi-line bodies in ONE engine block; a raw U+000A in their runs split them
/// into several engine blocks the roles knew nothing about (measured: a code block "a\nb"
/// rendered as two blocks while the document still read "a\nb").
internal static func engineRuns(_ runs: [TextRun]) -> [TextRun] {
runs.map { TextRun(text: engineText($0.text), attributes: $0.attributes) }
}
/// A pull quote's runs as the engine renders them: `engineRuns` with every run's ITALIC cleared.
/// A pull quote's slant is the role's and the read-back strips italic there
/// (`ca.italic = style == .pullQuote ? false : …`), so an italic a model run still carries — text
/// made a pull quote by Format ▸ Pull Quote keeps its runs' attributes — must not render either.
/// For plain text it made no visible difference (the style is italic anyway); for an INLINE-CODE
/// run, whose face the role composes upright, the mapper drew it monospaced italic, the read-back
/// dropped the italic, and the block drifted on NSFont against its own re-render from then on
/// (composer harness, 2026-09-23, fuzz seed 5 — the conversion twin of
/// `HarnessPullQuoteItalicOverCodeTests`).
internal static func pullQuoteRuns(_ runs: [TextRun]) -> [TextRun] {
engineRuns(runs).map { run in
var run = run
run.attributes.italic = false
return run
}
}
/// A code block's runs as this editor holds them: no inline format and no link — bold, italic,
/// underline, strikethrough, inline code, spoiler and `link` cleared, text and atoms kept.
///
/// A code block is plain text wherever it goes: on the text route it is a `Pre` entity, which
/// can contain no other entity, the shared decoder reads a code region back as one plain run,
/// and the legacy composer's model holds it plain. Formats the engine let the user apply inside
/// code were drawn and kept in the model, then dropped by the send — or sent after all when a
/// heading elsewhere took the message onto the rich route (composer harness p20,
/// `HarnessSendPathCodeFormattingTests`). Applied on render AND read-back, so a set of formatted
/// code, a paste into code and a move into code all come out plain on screen and in the model;
/// the inline commands refuse code too (`inlineFormatTargetRanges`).
///
/// An INLINE ATOM there is its text, as Core `runsPlainText` gives it (a custom emoji its alt text, a
/// formula its LaTeX, a pill its label): every user path already put the text there (p16 Format ▸ Code,
/// p21's emoji keyboard into code), but a SET kept the atom — drawn in the code block while the text route
/// sent the plain emoji (composer harness p24, `HarnessAtomInSetCodeBodyTests`). Render and read-back
/// agree because both come through here.
internal static func plainCodeRuns(_ runs: [TextRun]) -> [TextRun] {
runs.compactMap { run in
var attributes = run.attributes
attributes.bold = false
attributes.italic = false
attributes.underline = false
attributes.strikethrough = false
attributes.inlineCode = false
attributes.spoiler = false
attributes.link = nil
var text = run.text
if attributes.emoji != nil || attributes.formula != nil || attributes.button != nil {
text = runsPlainText([run])
attributes.emoji = nil
attributes.formula = nil
attributes.button = nil
if text.isEmpty { return nil }
}
return TextRun(text: text, attributes: attributes)
}
}
internal static func modelRuns(_ runs: [TextRun]) -> [TextRun] {
runs.map { TextRun(text: modelText($0.text), attributes: $0.attributes) }
}
internal static func engineParagraph(_ paragraph: ParagraphBlock) -> ParagraphBlock {
var engine = paragraph
engine.runs = engineRuns(paragraph.runs)
return engine
}
/// One paragraph for a cell, in MODEL form: paragraphs joined by `\n`, containers contributing
/// their inline runs. On the wire a cell is one inline text; nothing beyond that was ever sent.
internal static func cellParagraph(for cell: Cell) -> ParagraphBlock {
var runs: [TextRun] = []
for (index, block) in cell.blocks.enumerated() {
if index > 0 { runs.append(TextRun(text: "\n")) }
switch block {
case let .paragraph(paragraph): runs += paragraph.runs
case let .code(code): runs += code.runs
case let .pullQuote(quote): runs += quote.runs
case let .blockQuote(quote):
runs += quote.children.flatMap { $0.asParagraph?.runs ?? [] }
case .media, .table, .details, .buttonRow: break
}
}
return ParagraphBlock(id: cell.id, runs: runs)
}
private static func aligned(_ source: NSAttributedString, to alignment: TextAlignment) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
let whole = NSRange(location: 0, length: result.length)
let target: NSTextAlignment
switch alignment {
case .left: target = .left
case .center: target = .center
case .right: target = .right
case .justified: target = .justified
case .natural: target = .natural
}
result.enumerateAttribute(.paragraphStyle, in: whole) { value, range, _ in
let base = (value as? NSParagraphStyle) ?? NSParagraphStyle.default
guard let style = base.mutableCopy() as? NSMutableParagraphStyle else { return }
style.alignment = target
result.addAttribute(.paragraphStyle, value: style, range: range)
}
return result
}
/// Header bold is a FONT TRAIT only — not the mapper's `.rtBold` marker, which is what reads
/// back — so it renders bold and never reaches the model. Display-only by the mapper's own rule.
private static func emboldened(_ source: NSAttributedString) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
let whole = NSRange(location: 0, length: result.length)
result.enumerateAttribute(.font, in: whole) { value, range, _ in
guard let font = value as? UIFont else { return }
let traits = font.fontDescriptor.symbolicTraits.union(.traitBold)
guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits) else { return }
let bold = UIFont(descriptor: descriptor, size: font.pointSize)
result.addAttribute(.font, value: bold, range: range)
// AMBIENT: the base face the engine derives user traits from is itself bold, so neither
// the engine's trait state nor `render(role:from:)`'s read-back takes it for the user's.
// The base carries the bold and ONLY the bold — the author line's rule. The user's own
// italic stays out of it, as the engine keeps it (its trait pass puts a toggled italic in
// the marker and leaves the base alone); stamped from the rendered face, an italic header
// run rendered a base the engine never holds, and Format ▸ Italic over a header cell
// drifted on `IDInlineTextBaseFont` (composer harness, 2026-09-23, fuzz seeds 5 and 17,
// `HarnessTableHeaderItalicTests`).
let baseTraits = font.fontDescriptor.symbolicTraits.subtracting([.traitBold, .traitItalic]).union(.traitBold)
let base = font.fontDescriptor.withSymbolicTraits(baseTraits)
.map { UIFont(descriptor: $0, size: font.pointSize) } ?? bold
result.addAttribute(NSAttributedString.Key.IDInlineTextBaseFontAttributeName, value: base, range: range)
}
return result
}
private func cellLine(for cell: Cell, paragraph: ParagraphBlock) -> NSAttributedString {
let engineParagraph = ParagraphBlock(id: paragraph.id,
runs: paragraph.runs.map { run in
TextRun(text: Self.engineText(run.text), attributes: run.attributes) })
var rendered = Self.engineReadable(tableCellMapper.attributedString(for: engineParagraph))
rendered = Self.aligned(rendered, to: cell.horizontalAlignment)
if cell.isHeader { rendered = Self.emboldened(rendered) }
return rendered
}
/// The language AS STORED. It used to be rendered lowercased, and the line is also what the
/// language is read back from, so every render wrote the lowercased text back as the user's value:
/// opening an edited message turned `Swift` into `swift`, and undo / redo of typing in the line
/// lowercased what was typed (composer harness p13, `HarnessCodeLanguageRedoTests`). The editor's
/// rule is that the value is the user's; only highlighting normalises it
/// (`richTextNormalizedCodeLanguage`, which lowercases on its own).
private static func languageLine(for code: CodeBlock,
using mapper: AttributedStringMapper) -> NSAttributedString {
languageLine(text: code.language ?? "", using: mapper)
}
/// The line as typed: normalisation keeps the characters and only re-derives the face.
///
/// **It carries the body paragraph style**, which every other line in the editor gets from the
/// mapper and this one was built without. Without it the line has no pinned box at all, so its
/// height is TextKit's raw font box — measured 20.2871 where every other body line is 21 — which
/// is off the V2 pitch and off the pixel grid, and it pushed the whole code block and everything
/// below it onto fractional origins. Taken from the style sheet rather than pinned by hand so
/// there is one definition of the body box.
private static func languageLine(text: String, using mapper: AttributedStringMapper) -> NSAttributedString {
let size = mapper.styleSheet.metrics.body.size
return Self.engineReadable(NSAttributedString(string: oneLineText(text), attributes: [
.font: UIFont.systemFont(ofSize: size, weight: .semibold),
.foregroundColor: mapper.theme.primaryText,
.paragraphStyle: mapper.styleSheet.paragraphStyle(for: .body, attributes: ParagraphAttributes()),
NSAttributedString.Key.IDInlineTextBaseFontAttributeName:
UIFont.systemFont(ofSize: size, weight: .semibold),
]))
}
/// A pull quote's author line: the `.caption` style rendered bold + ITALIC and centred, matching
/// `PullQuoteBox`'s own author layout. The emphasis is render-only and never read back — see
/// `PullQuote.author`.
/// The ONE block that may be internally empty: a bare body paragraph. Everything else — a
/// table, a code block, a media atom, a button row, a container — is explicitly always
/// non-empty, because it is structure somebody added rather than an absence of text.
///
/// The role-side spelling of this is `ContainerContents.isEmpty`, which asks it of each child
/// UNIT (`shape.paragraph != nil && flatLength == 0`); this is the same question asked of a
/// model block, for the passes that run before there are any roles. A list item is a paragraph
/// here as it is there — an empty one is an empty line, whatever marker it wears.
internal static func isEmptyBodyParagraph(_ block: Block) -> Bool {
guard case let .paragraph(paragraph) = block else { return false }
return paragraph.runs.allSatisfy { $0.text.isEmpty }
}
private static func authorLine(runs: [TextRun], centred: Bool,
using mapper: AttributedStringMapper) -> NSAttributedString {
let paragraph = ParagraphBlock(id: BlockID(UUID().uuidString),
style: .caption,
paragraph: ParagraphAttributes(
alignment: centred ? .center : .natural),
runs: oneLineRuns(runs))
let rendered = NSMutableAttributedString(attributedString:
mapper.attributedString(for: paragraph))
let whole = NSRange(location: 0, length: rendered.length)
// A pull quote's author is bold + ITALIC, a block quote's just bold — `PullQuoteBox` and
// `BlockQuoteBox` differ, and the slant is what distinguishes them.
let emphasis: UIFontDescriptor.SymbolicTraits = centred ? [.traitBold, .traitItalic] : [.traitBold]
rendered.enumerateAttribute(.font, in: whole, options: []) { value, range, _ in
guard let font = value as? UIFont else { return }
let traits = font.fontDescriptor.symbolicTraits.union(emphasis)
guard let descriptor = font.fontDescriptor.withSymbolicTraits(traits) else { return }
let emphasised = UIFont(descriptor: descriptor, size: font.pointSize)
rendered.addAttribute(.font, value: emphasised, range: range)
// AMBIENT, as a header cell's bold is: the base face carries the emphasis — and ONLY
// the emphasis. The user's own italic on a run stays out of it, as the engine keeps it
// (its trait pass puts a toggled italic in the marker and leaves the base alone), or a
// block-quote author with an italic run rendered a base the engine never holds: Italic
// over a quote's author line drifted on `IDInlineTextBaseFont` (composer harness,
// 2026-09-23, fuzz seed 20, `HarnessAuthorLineItalicTests`).
let baseTraits = font.fontDescriptor.symbolicTraits.subtracting([.traitBold, .traitItalic]).union(emphasis)
let base = font.fontDescriptor.withSymbolicTraits(baseTraits)
.map { UIFont(descriptor: $0, size: font.pointSize) } ?? emphasised
rendered.addAttribute(NSAttributedString.Key.IDInlineTextBaseFontAttributeName, value: base, range: range)
}
return Self.engineReadable(rendered)
}
// MARK: - Media
/// The atom's face. One point, so the line box is the attachment's own height plus a descent
/// too small to see (P3): a real font would add its descender below every image.
internal static let mediaAtomFont = UIFont.systemFont(ofSize: 1)
/// One attachment character. The paragraph's alignment is the shape's, so TextKit places a
/// narrower single image the way `MediaBlockBox.mediaRect` does.
///
/// `ancestry` is the atom's own: the attachment carries the head indent its line is laid out
/// with, because TextKit 2 proposes the FULL container width regardless of it (probe P1, measured;
/// nesting design §4.4). An ANCESTRY rather than a depth, because the indent is the sum of what
/// each container contributes and a details contributes nothing (R7-details spec §3.4).
internal func mediaAtom(for shape: MediaShape, within ancestry: [EngineAncestor] = []) -> NSAttributedString {
let atom = NSMutableAttributedString(attachment: mediaAttachment(for: shape, within: ancestry))
atom.addAttributes(mediaAtomAttributes(for: shape, within: ancestry), range: NSRange(location: 0, length: atom.length))
return atom
}
/// Whether rows draw their "…" control — stamped from whether a host is listening. It is part of
/// the MEASUREMENT, not only the drawing: a row that shows no control reserves no width for one,
/// so composer pills match the sent message exactly rather than being needlessly narrower.
internal var showsButtonRowMenuAffordance: Bool = false
/// A button row's atom. The ONE place a `ButtonRowTextAttachment` is built, so the atom's string
/// and the attributes a menu verb merges over it can never disagree.
internal func buttonRowAtom(for shape: ButtonRowShape,
within ancestry: [EngineAncestor] = []) -> NSAttributedString {
let atom = NSMutableAttributedString(attachment: buttonRowAttachment(for: shape, within: ancestry))
atom.addAttributes(buttonRowAtomAttributes(for: shape, within: ancestry),
range: NSRange(location: 0, length: atom.length))
return atom
}
/// The atom's attributes alone — what a host command writes onto a freshly inserted `U+FFFC`.
///
/// The FONT and PARAGRAPH STYLE are pinned here, not merely the attachment, and that is
/// load-bearing for the same reason it is on the media atom: a rendered atom is built from
/// `NSAttributedString(attachment:)` and carries no typography, while an inserted one is a
/// `U+FFFC` typed into the engine and carries the ambient TYPING attributes. Pin only the
/// attachment and the two disagree on `.font` the moment a row is inserted rather than rendered
/// — which is exactly what `verifyPresentationIsDerived` trapped on, correctly, the first time
/// this shipped without them.
///
/// One point of face, like the media atom's: the line box is then the attachment's own height
/// plus a descent too small to see, where a real font would add its descender under every row.
/// A fresh paragraph style, because a row's ALIGNMENT is the packer's business inside the hosted
/// view and never the line's — unlike media, whose alignment does travel here.
internal func buttonRowAtomAttributes(for shape: ButtonRowShape,
within ancestry: [EngineAncestor]) -> [NSAttributedString.Key: Any] {
[
.attachment: buttonRowAttachment(for: shape, within: ancestry),
.paragraphStyle: NSMutableParagraphStyle(),
.font: Self.mediaAtomFont,
]
}
/// A folded quote's atom. The ONE place a `CollapsedQuoteTextAttachment` is built, so the atom's
/// string and any attributes merged over it can never disagree.
internal func collapsedQuoteAtom(for shape: QuoteShape,
within ancestry: [EngineAncestor] = []) -> NSAttributedString {
let atom = NSMutableAttributedString(attachment: collapsedQuoteAttachment(for: shape, within: ancestry))
atom.addAttributes(collapsedQuoteAtomAttributes(for: shape, within: ancestry),
range: NSRange(location: 0, length: atom.length))
return atom
}
/// The atom's attributes alone.
///
/// The FONT and PARAGRAPH STYLE are pinned here and not merely the attachment, R9-buttons'
/// recorded lesson: a RENDERED atom comes from `NSAttributedString(attachment:)` and carries no
/// typography, while an INSERTED one is a `U+FFFC` carrying the ambient typing attributes. Pin
/// only the attachment and `verifyPresentationIsDerived` traps the moment one is inserted.
///
/// One point of face, like the other two atoms': the line box is then the attachment's own
/// height plus a descent too small to see. A fresh paragraph style, because a folded quote's
/// content alignment is the preview view's business and never the line's — `emit` still adds the
/// ENCLOSING containers' indent to it, which is what a nested folded quote needs.
internal func collapsedQuoteAtomAttributes(for shape: QuoteShape,
within ancestry: [EngineAncestor]) -> [NSAttributedString.Key: Any] {
[
.attachment: collapsedQuoteAttachment(for: shape, within: ancestry),
.paragraphStyle: NSMutableParagraphStyle(),
.font: Self.mediaAtomFont,
]
}
/// The preview's face: the quote mapper's body (15pt at every depth — assign, never multiply),
/// the theme's primary text, WRAPPING. `BlockQuoteBox.previewAttributes(mapper:)`'s recipe, and
/// the reason it takes the style from the sheet rather than building one is that the sheet's
/// pinned line box IS the pitch the cap counts.
///
/// **The truncation is deliberately NOT here — it is the layout container's**
/// (`BlockLayoutEngine.capLines`). A truncating mode in the PARAGRAPH STYLE beats the container
/// in both text systems that read this string, and beats it by collapsing the text to a single
/// line however long it is: `boundingRect` measures one line (which is why
/// `CollapsedQuoteTextAttachment.measuring` exists), and TextKit lays out one line even with
/// `maximumNumberOfLines` set to three — measured 19.0pt against the 57.0pt the same string
/// gives when the style wraps and the container truncates. That second half is what a device
/// pass reported as "the height of a collapsed quote is correct, but it only displays one line,
/// ellipsized, instead of 3". Truncation is a property of the BOX the text is poured into, not
/// of the text; spelled on the text it silently becomes a line cap of one.
internal var collapsedQuotePreviewAttributes: [NSAttributedString.Key: Any] {
let base = quoteMapper.styleSheet.paragraphStyle(for: .body, attributes: .default)
let style = (base.mutableCopy() as? NSMutableParagraphStyle) ?? NSMutableParagraphStyle()
style.lineBreakMode = .byWordWrapping
let font = quoteMapper.styleSheet.font(for: .body, attributes: .plain)
// The SAME re-expression a rendered block gets. This string never passes through
// `engineReadable` — it is laid out by the legacy `BlockLayoutEngine` — but the preview's draw
// origin is `rawFirstBaselineFromTop - firstLineBaselineFromTop`, and `raw` is measured from
// this box. Left on the old pitch the folded text sits `pitch - box` below the expanded text
// it stands for, so a quote's text jumps when you fold it. See `v2BaselinePlacedStyle`.
let placed = Self.v2BaselinePlacedStyle(style, font: font) ?? style
return [.font: font,
.foregroundColor: quoteMapper.theme.primaryText,
.paragraphStyle: placed]
}
/// The folded body as ONE wrapped run, with its formatting intact.
///
/// Built through `quoteMapper`, so every run keeps bold, italic, underline, strikethrough,
/// inline code, link and colour — and so a custom emoji keeps the `EmojiTextAttachment` the
/// preview hosts a view over. `blockPlainText`, which this replaces, threw all of that away and
/// additionally leaked spoilered text as plain characters.
///
/// Blocks are joined by exactly one space AT the join rather than by replacing newlines in a
/// finished string: a run's own text is never rewritten, which is what keeps its attributes
/// aligned to its characters.
internal func collapsedQuotePreviewText(for children: [Block]) -> NSAttributedString {
let result = NSMutableAttributedString()
for child in children {
let piece = collapsedQuotePreviewPiece(for: child)
guard piece.length > 0 else { continue }
if result.length > 0 {
result.append(NSAttributedString(string: " ", attributes: collapsedQuotePreviewAttributes))
}
result.append(piece)
}
// The preview's paragraph style over the WHOLE run: the mapper gives each piece its own
// block's style, and a preview is one wrapped line whatever it came from.
if let style = collapsedQuotePreviewAttributes[.paragraphStyle] {
result.addAttributes([.paragraphStyle: style],
range: NSRange(location: 0, length: result.length))
}
return result
}
/// One block's contribution. Text blocks give their runs; a block with no text of its own gives
/// one marker attachment (`CollapsedQuoteMarkers`).
private func collapsedQuotePreviewPiece(for block: Block) -> NSAttributedString {
switch block {
case let .paragraph(paragraph):
return quoteMapper.attributedString(for: ParagraphBlock(
id: paragraph.id, style: .body, runs: paragraph.runs))
case let .code(code):
return quoteMapper.attributedString(for: ParagraphBlock(id: code.id, style: .body, runs: code.runs))
case let .pullQuote(pull):
return quoteMapper.attributedString(for: ParagraphBlock(id: pull.id, style: .body, runs: pull.runs))
case let .blockQuote(inner):
// A nested quote's text is a thing that was said, so it contributes it — as the plain
// walk did. Its own fold state is irrelevant here: this preview is already the fold.
return collapsedQuotePreviewText(for: inner.children)
case let .details(details):
return collapsedQuotePreviewText(for: details.children)
case .media, .table, .buttonRow:
// A block with no text of its own says it is THERE. Dropping it silently reads as
// content loss: the quote looks shorter than it is.
return CollapsedQuoteMarkers.marker(for: block, attributes: collapsedQuotePreviewAttributes)
}
}
private func collapsedQuoteAttachment(for shape: QuoteShape,
within ancestry: [EngineAncestor]) -> CollapsedQuoteTextAttachment {
CollapsedQuoteTextAttachment(
shape: shape,
horizontalIndent: Self.atomHorizontalReduction(within: ancestry, styleSheet: mapper.styleSheet),
leadingInset: mapper.styleSheet.quoteIndent,
trailingInset: mapper.styleSheet.quoteTrailingInset,
previewText: collapsedQuotePreviewText(for: shape.foldedChildren),
attributes: collapsedQuotePreviewAttributes)
}
private func buttonRowAttachment(for shape: ButtonRowShape,
within ancestry: [EngineAncestor]) -> ButtonRowTextAttachment {
ButtonRowTextAttachment(
shape: shape,
horizontalIndent: Self.atomHorizontalReduction(within: ancestry, styleSheet: mapper.styleSheet),
showsMenuAffordance: showsButtonRowMenuAffordance,
mapper: mapper)
}
/// The one place a `MediaTextAttachment` is built, so the atom's string and the attributes a ••• verb
/// merges over it can never disagree about either number.
///
/// **Inside a quote the bleed is 0, decided HERE rather than in the attachment's arithmetic** —
/// legacy makes the same call at the same place (`BlockQuoteBox` builds every nested child box with
/// `horizontalBleed: 0`), and it is simply the true value: the hosted view does not bleed at depth
/// ≥ 1 (`syncMediaViews`), so the medium's canvas IS its drawn width. Leaving the style's bleed on
/// would be invisible in the width — `MediaSlotGeometry` derives the HEIGHT from `line + bleed × 2`
/// and `slot` clamps the width back to `line` — and would reserve height the medium never fills
/// (163 rather than 147 for a 1000×500 photo drawn 294pt wide), growing the quote's band with it.
private func mediaAttachment(for shape: MediaShape, within ancestry: [EngineAncestor]) -> MediaTextAttachment {
// Inside ANY container the bleed is 0 — legacy makes the same call for both kinds
// (`BlockQuoteBox` and `DetailsBox` each build their children with `horizontalBleed: 0`).
MediaTextAttachment(shape: shape,
horizontalBleed: ancestry.isEmpty ? mediaBlockStyle.horizontalBleed : 0,
horizontalIndent: Self.atomHorizontalReduction(within: ancestry, styleSheet: mapper.styleSheet))
}
/// How much narrower than the PROPOSED fragment an atom's column is inside `ancestry`: the
/// containers' leading indent PLUS their trailing inset.
///
/// Probe P1 answered "full fragment" for a head indent, and the same holds for a tail one —
/// measured, not assumed: with the leading half alone a photo in a quote came back 294pt wide on
/// a 272pt line, centred alignment hanging it 11pt off each end of the line its own paragraph
/// stops at. So BOTH halves of the narrowing are the atom's to subtract.
internal static func atomHorizontalReduction(within ancestry: [EngineAncestor],
styleSheet: StyleSheet) -> CGFloat {
ancestry.contentIndent(styleSheet: styleSheet) + ancestry.contentTrailingInset(styleSheet: styleSheet)
}
/// The atom's attributes alone — what a host command writes onto a freshly inserted `U+FFFC`.
internal func mediaAtomAttributes(for shape: MediaShape, within ancestry: [EngineAncestor] = []) -> [NSAttributedString.Key: Any] {
let style = NSMutableParagraphStyle()
switch shape.alignment {
case .left: style.alignment = .left
case .center: style.alignment = .center
case .right: style.alignment = .right
}
return [
.attachment: mediaAttachment(for: shape, within: ancestry),
.paragraphStyle: style,
.font: Self.mediaAtomFont,
]
}
/// A media block's caption rendered with THIS bridge's mapper — what a host command that
/// inserts a unit with a caption applies over the caption line, so bold, a link or a custom
/// emoji in a passed caption survives instead of being flattened to plain text.
internal func captionLine(runs: [TextRun]) -> NSAttributedString {
Self.captionLine(runs: runs, using: mapper)
}
/// A media block's caption: `MediaBlockBox`'s caption paragraph exactly — `.caption`, centred.
internal static func captionLine(runs: [TextRun], using mapper: AttributedStringMapper) -> NSAttributedString {
let paragraph = ParagraphBlock(id: BlockID(UUID().uuidString), style: .caption,
paragraph: ParagraphAttributes(alignment: .center),
runs: Self.engineRuns(runs))
return Self.engineReadable(mapper.attributedString(for: paragraph))
}
/// A container's horizontal padding inside its own background — a code block's band, a pull
/// quote's pill.
///
/// The decoration view is a background BEHIND the text, so it cannot push the text inward — the
/// padding has to come from the text's own layout, which is why it is applied here and not in the
/// view. Without it the code sits flush against its own band and reads as broken; the legacy
/// composer insets it by `CodeStyle.horizontalInset`, and this reproduces that.
///
/// The paragraph style is COPIED and mutated, never replaced: the mapper has already put V2's
/// line geometry in it (`minimumLineHeight`/`maximumLineHeight` pinned to the pitch), and
/// substituting a fresh style would silently drop that and change every line's spacing.
///
/// Measured baseline worth knowing: every block already carries ~5pt of the text container's own
/// `lineFragmentPadding` horizontally, and ZERO vertically. So the horizontal inset here ADDS to
/// a non-zero baseline while the vertical one is the whole of it.
private static func padded(_ source: NSAttributedString,
by horizontalInset: CGFloat) -> NSAttributedString {
let result = NSMutableAttributedString(attributedString: source)
let whole = NSRange(location: 0, length: result.length)
result.enumerateAttribute(.paragraphStyle, in: whole, options: []) { value, range, _ in
let base = (value as? NSParagraphStyle) ?? NSParagraphStyle.default
guard let style = base.mutableCopy() as? NSMutableParagraphStyle else { return }
style.firstLineHeadIndent += horizontalInset
style.headIndent += horizontalInset
// Negative measures from the trailing edge, which is what keeps the inset symmetric
// regardless of how wide the container turns out to be.
style.tailIndent = -horizontalInset
result.addAttribute(.paragraphStyle, value: style, range: range)
}
return result
}
/// The host's `CodeStyle.horizontalInset`, resolved into the stylesheet.
///
/// **Horizontal only, and that is a measured limitation rather than a choice.** Vertical padding
/// was implemented here first, as `paragraphSpacingBefore`/`paragraphSpacing`, and measured to do
/// NOTHING: the attributes reach the render intact (verified through
/// `id_renderedTextForBlockAtIndex:`) but TextKit ignores spacing-before on the first paragraph of
/// a container, and trailing spacing does not extend the used height — so the band came out
/// exactly as tall as its text, 0pt of padding, top and bottom.
///
/// It is not re-added as a no-op knob: a value that changes nothing is not configuration, and it
/// would read as working. Real vertical padding needs the ENGINE to reserve space for a block
/// (a per-block content inset), which is an upstream addition, not a host-side attribute — and
/// that is where it now lives: `blockInsets` reserves it and `EngineBlockSpacing` sources it from
/// `StyleSheet.codeVerticalInset`, so the two halves of a code block's padding are applied by two
/// different mechanisms for this reason and not by oversight.
/// The inverse. Paragraph KIND (`style`, `paragraph`, `list`) is genuinely unrecoverable from an
/// attributed string alone: `attributedString(for:)` *consumes* those fields to build a rendering
/// artifact (an `NSParagraphStyle`), it does not encode them so they could be read back. So the
/// host must carry it — and it must carry it **keyed by the engine's own block identity**, never
/// by array position.
///
/// `engineIdentifiers` is `TGIDObservingBlockTextView.tgid_blockIdentifiers`: one `NSUUID` per
/// entry of `blocks`, straight off `IDBlockDocument.blocks`. `previous` maps those same `NSUUID`s
/// to the `Block` each of them last resolved to — the whole block, not just a paragraph, because
/// since containers flatten onto this axis the table also carries the block KIND and the fields
/// that kind owns (a code block's `language`, a pull quote's `author`). A block whose identifier is
/// IN the table keeps its kind, `id` and structural fields; only `runs` is rebuilt from
/// `attributed`. A block whose identifier the table has NEVER SEEN is genuinely new — the
/// engine minted that `NSUUID` inside this edit's affected span — so it mints a fresh `BlockID`
/// and gets DEFAULT structure (`.body`, `ParagraphAttributes.default`, no list); continuing e.g.
/// list membership into it is a host policy decision, not this bridge's (see
/// `RichTextEngineEditorView+Paragraph.swift`'s list-continuation section).
///
/// **Why identity rather than position, concretely.**
/// `-[IDBlockDocument replaceCanonicalRange:withAttributedString:]` (`IDBlockDocument.m`) builds
/// its result identifiers in three spans: every block BEFORE the affected range keeps its
/// `NSUUID`, every block AFTER keeps its own, and only the blocks produced INSIDE the affected
/// span get freshly minted ones. Positional reuse agrees with that only when new blocks appear
/// at the very END of the document. Take `[p0 body "A"][p1 heading1 "B"]` with the caret at the
/// end of "A" and press Return: the engine produces `["A"→u0, ""→new, "B"→u1]`. Positional reuse
/// hands index 1 (the blank new line) p1's heading structure and index 2 ("B", the real heading)
/// a default body — the heading migrates onto the blank line and the real one silently demotes.
/// The merge direction fails the same way: backspacing at the start of B in `[A][B][C]` gives C
/// block B's identity. Keying on the engine's own answer cannot express either mistake.
///
/// Also NOT a strict identity with `attributedBlocks(for:)` even for `runs`:
/// `AttributedStringMapper.characterAttributes(from:)` unconditionally pins `CharacterAttributes.
/// fontSize` to the rendered font's point size on every read-back (by design — see that
/// function's doc comment), so a run whose model `fontSize` was `nil` comes back with an explicit
/// size after the first round trip. That normalization is idempotent, though: once a run has been
/// through this bridge once, further round trips leave it unchanged
/// (`EngineDocumentBridgeTests.test_theRoundTripDoesNotDriftOnRepeatedApplication` pins this). So
/// the contract this function actually provides is "round-trips to a fixed point on the first
/// pass, then is exact thereafter," not "round-trips to the identity."
///
/// The REUSED style — not a default — must be threaded into `mapper.runs(from:style:)`: that
/// style feeds `AttributedStringMapper`'s `boldIsAmbient` comparison base and its per-style
/// default-foreground stripping, so reading a non-`.body` paragraph back at the wrong style
/// either misclassifies an ambient weight as an explicit user `bold` or fails to strip a
/// default colour (`EngineDocumentBridgeTests.test_aHeadingRoundTripsWithoutAcquiringExplicitBold`
/// and `…test_aCaptionsDefaultForegroundIsStrippedOnlyWhenReadAtItsOwnStyle` pin the two halves).
///
/// `blocks` and `engineIdentifiers` are parallel arrays. A shorter `engineIdentifiers` (which
/// cannot happen — both are projections of the same `IDBlockDocument.blocks`) would leave the
/// tail unidentified, so those blocks are treated as new rather than silently reusing an
/// unrelated neighbour's structure.
public func document(fromAttributedBlocks blocks: [NSAttributedString],
engineIdentifiers: [UUID],
reusingStructureOf previous: [UUID: Block]) -> Document {
Document(blocks: blocks.enumerated().map { index, attributed in
guard index < engineIdentifiers.count,
let prior = previous[engineIdentifiers[index]] else {
// A brand-new block is always a paragraph. Splitting a container is a container
// operation the commands own; the engine minting an identifier cannot tell us a kind.
return .paragraph(ParagraphBlock(id: BlockID(UUID().uuidString),
runs: mapper.runs(from: attributed)))
}
switch prior {
case let .paragraph(paragraph):
return .paragraph(ParagraphBlock(
id: paragraph.id,
style: paragraph.style,
paragraph: paragraph.paragraph,
list: paragraph.list,
runs: mapper.runs(from: attributed, style: paragraph.style)))
case let .code(code):
// Read at the DEFAULT style, matching how the forward path rendered it: the mono face
// is presentation the mapper never round-trips, so the runs come back clean.
return .code(CodeBlock(id: code.id, language: code.language,
runs: Self.plainCodeRuns(mapper.runs(from: attributed))))
case let .pullQuote(quote):
// `author` has no engine counterpart, so it survives only by being carried here.
return .pullQuote(PullQuote(id: quote.id,
runs: mapper.runs(from: attributed, style: .pullQuote),
author: quote.author))
case .media, .table, .blockQuote, .details, .buttonRow:
// Unreachable by construction: `attributedBlocks(for:)` traps on these, so one can
// never have reached the engine and so can never be in this table. Returned unchanged
// rather than rebuilt, because its text is not in `attributed` to rebuild FROM —
// fabricating a paragraph from someone else's text is the data loss the trap exists
// to prevent.
return prior
}
})
}
}
extension Block {
/// Diagnostic name only — never a behavioural switch.
var caseNameForDiagnostics: String {
switch self {
case .paragraph: return "paragraph"
case .media: return "media"
case .table: return "table"
case .code: return "code"
case .pullQuote: return "pullQuote"
case .blockQuote: return "blockQuote"
case .details: return "details"
case .buttonRow: return "buttonRow"
}
}
}
#endif