txt-viewport-rendering — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited txt-viewport-rendering (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Authored against iOS 26.x / Swift 6.x / Xcode 26.x.
This skill covers how TextKit 2 actually renders text: the viewport-driven layout pass, the geometry of layout fragments and line fragments, and the rendering-attribute overlay system. The patterns here are a model — before applying them to a specific symptom (text clipped at descenders, scroll bar jiggling, custom drawing disappearing), open the actual fragment subclass or container code and confirm the geometry matches what's described. Before quoting any signature here, fetch the relevant page from Sosumi (sosumi.ai/documentation/uikit/<class>); the viewport controller and fragment APIs have grown each release.
TextKit 2 lays out only what's near the viewport. The document is divided into three regions:
Scrolling moves fragments between regions. Layout cost is O(viewport), not O(document) — that's the central performance promise of TextKit 2.
NSTextViewportLayoutController orchestrates the pass:
willLayout.configureRenderingSurfaceFor: once per visible fragment.didLayout.usageBoundsForTextContainer.A scroll, a text edit, a container resize, or a manual layoutViewport() call kicks the cycle.
// Before layout begins — remove old fragment views from the rendering surface
func textViewportLayoutControllerWillLayout(
_ controller: NSTextViewportLayoutController
) {
fragmentContainer.subviews.forEach { $0.removeFromSuperview() }
}
// For each visible layout fragment — position views/layers
func textViewportLayoutController(
_ controller: NSTextViewportLayoutController,
configureRenderingSurfaceFor fragment: NSTextLayoutFragment
) {
let frame = fragment.layoutFragmentFrame
let view = makeFragmentView(for: fragment)
view.frame = frame
fragmentContainer.addSubview(view)
}
// After layout completes — update content size for the scroll view
func textViewportLayoutControllerDidLayout(
_ controller: NSTextViewportLayoutController
) {
let height = textLayoutManager.usageBoundsForTextContainer.height
scrollView.contentSize = CGSize(width: bounds.width, height: height)
}The configureRenderingSurface callback fires once per visible fragment, every layout pass. It's the right place to position views or layers, but a wrong place to allocate them — fragment objects are stable across passes, so cache views by fragment identity.
usageBoundsForTextContainer.height is an estimate. It changes during scroll as fragments lay out and the estimate refines. This produces three user-visible artifacts:
scrollRangeToVisible for a far-off range arrives near-but-not-at the target until the surrounding content lays out.Apple's TextEdit shows all three on long documents. Code that needs exact metrics (line count, exact total height, jump-accurate navigation) either has to force layout for the relevant range first or live with TextKit 1.
Roughly: one NSTextLayoutFragment per paragraph, containing one or more NSTextLineFragment for each visual line that paragraph wraps into.
let fragment: NSTextLayoutFragment
fragment.layoutFragmentFrame // rect in document coordinates
fragment.renderingSurfaceBounds // drawing extent — may exceed layout frame
fragment.textLineFragments // [NSTextLineFragment]
fragment.rangeInElement // NSTextRange for the fragment
fragment.draw(at: origin, in: cgContext)renderingSurfaceBounds exists because drawing can extend past the layout frame: diacritics on top of the first line, descenders below the last line, custom backgrounds, glow effects. Custom NSTextLayoutFragment subclasses that draw outside the default frame must override renderingSurfaceBounds to expand the dirty rect, or the rendering is clipped at the frame edge.
class BubbleLayoutFragment: NSTextLayoutFragment {
override var renderingSurfaceBounds: CGRect {
layoutFragmentFrame.insetBy(dx: -8, dy: -8)
}
override func draw(at origin: CGPoint, in context: CGContext) {
// draw bubble background
super.draw(at: origin, in: context)
}
}let line: NSTextLineFragment
line.typographicBounds // rect — local to the parent layout fragment
line.glyphOrigin // point — where glyph drawing starts within the line
line.characterRange // range — local to line.attributedString, not the document
line.attributedString // a copy, not the original document substringcharacterRange is the most common bug source. It is local to the line's own attributed string, not document-relative. Code that uses it as a document range will hit the wrong characters as soon as the line isn't at document offset zero.
To convert a point inside a line fragment to document coordinates:
let docPoint = CGPoint(
x: layoutFragment.layoutFragmentFrame.origin.x
+ lineFragment.typographicBounds.origin.x
+ localPoint.x,
y: layoutFragment.layoutFragmentFrame.origin.y
+ lineFragment.typographicBounds.origin.y
+ localPoint.y
)Three coordinate spaces nest: document, layout fragment frame, line fragment typographic bounds. The glyph origin is inside the line.
When text ends with \n (or the document is empty), an extra empty line fragment exists for cursor placement at the trailing position.
extraLineFragmentRect, extraLineFragmentUsedRect on NSLayoutManager..ensuresExtraLineFragment option in enumerateTextLayoutFragments. Known bug FB15131180 makes the frame incorrect in some configurations.If a custom editor is missing its trailing-empty cursor position, the fix is usually to add .ensuresExtraLineFragment to the enumeration that builds the cursor rectangle.
When NSTextContainer.exclusionPaths is non-empty, a single visual line that crosses an exclusion path splits into multiple line fragments — one for the segment before the exclusion, one for the segment after.
The container's lineFragmentRect(forProposedRect:at:writingDirection:remainingRect:) returns:
Most code never calls this directly — the layout system uses it internally. But when wrap-around-image rendering is broken, the question is usually whether the exclusion path is in the right coordinate space and whether the line fragments produced by the system make sense.
textContainer.lineFragmentPadding = 5 // default 5 points
textView.textContainerInset = UIEdgeInsets(top: 8, left: 16, bottom: 8, right: 16)These are different things and frequently confused:
lineFragmentPadding insets text within each line fragment by the same amount on each end. Purely visual — the fragment rect itself is unchanged. Default 5 points.textContainerInset is the document-level margin around the entire text. Use this for visual padding around the text view's content.Neither is paragraph indentation — that's NSParagraphStyle.headIndent / firstLineHeadIndent.
A common wrong fix: setting lineFragmentPadding = 16 to add document margins. The padding is per-line, not document-level, and 16-point padding produces visibly inset wrap inside the fragment that isn't what was wanted.
Visual styling overlay that does not modify storage and does not invalidate layout. Replaces TextKit 1's temporary attributes.
textLayoutManager.setRenderingAttributes(
[.foregroundColor: UIColor.red],
forTextRange: range
)
textLayoutManager.addRenderingAttribute(
.backgroundColor, value: UIColor.yellow,
forTextRange: range
)
textLayoutManager.removeRenderingAttribute(.backgroundColor, forTextRange: range)Rendering attributes attach to the layout manager, not to character ranges in storage. Code that uses textStorage.addAttribute for what should be a rendering-only effect (find highlight, transient selection color) modifies the document and ends up in copy/paste, undo, and serialization — the rendering attribute is what was wanted.
Known bug FB9692714: certain rendering-attribute combinations have drawing artifacts. The workaround is a custom NSTextLayoutFragment subclass that draws the effect itself. This is one of the standing reasons to use TextKit 1 + temporary attributes for production syntax highlighting.
Font substitution happens during the editing lifecycle, in fixAttributes, before didProcessEditing runs. Characters with no glyph in the requested font are reassigned to a fallback font during the fix pass.
If a delegate sets a font in didProcessEditing, that change runs after fixAttributes and bypasses substitution. Characters that don't exist in the new font will not render — they'll show as .notdef boxes with no warning.
// WRONG — bypasses fixAttributes; missing glyphs show as boxes
func textStorage(_ ts: NSTextStorage,
didProcessEditing: NSTextStorage.EditActions,
range: NSRange, changeInLength: Int) {
ts.addAttribute(.font, value: customFont, range: range)
}
// CORRECT — set fonts in willProcessEditing; fixAttributes runs after
func textStorage(_ ts: NSTextStorage,
willProcessEditing: NSTextStorage.EditActions,
range: NSRange, changeInLength: Int) {
ts.addAttribute(.font, value: customFont, range: range)
}The same applies on TextKit 2 — the storage layer hasn't changed.
For context when porting code, TextKit 1's options:
boundingRect and lineFragmentRect can return slightly wrong coordinates for ranges in the multi-thousand-character region until those ranges are forced to lay out.UITextView.isScrollEnabled = false disables the scroll path entirely. The view expands to its full content size, which neutralizes TextKit 2's viewport optimization — all content gets laid out for Auto Layout intrinsic size. scrollRangeToVisible does not work in this configuration.
fixAttributes font substitution. Characters with missing glyphs render as .notdef boxes. Move font changes to willProcessEditing, or supply explicit fallback fonts in the attribute.textContainerInset of the text view; paragraph indentation goes on NSParagraphStyle.setRenderingAttributes) instead.txt-textkit2 — TextKit 2 API reference covering content manager, layout manager, and the editing transactiontxt-layout-invalidation — what triggers re-layout vs visual-only refreshtxt-textkit-debug — symptom-driven debugging for clipped text, scroll-bar bugs, and missing fragmentstxt-fallback-triggers — when viewport behavior is missing because the view fell back to TextKit 1txt-attachments — how inline views and attachments interact with fragment geometrytxt-exclusion-paths — multi-region containers and exclusion-path layout~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.