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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 69 additions & 30 deletions Sources/PicoMarkdownView/Renderer/MarkdownAttributeBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ actor MarkdownAttributeBuilder {
case .listItem(let ordered, let index, let task):
return await renderListItem(snapshot: snapshot, ordered: ordered, index: index, task: task, previousBlockKind: previousBlockKind)
case .blockquote:
return await renderBlockquote(snapshot: snapshot)
return await renderBlockquote(snapshot: snapshot, previousBlockKind: previousBlockKind)
case .fencedCode:
if let mermaid = await renderMermaidFenceIfAvailable(snapshot: snapshot, previousBlockKind: previousBlockKind) {
return mermaid
Expand Down Expand Up @@ -399,6 +399,13 @@ actor MarkdownAttributeBuilder {
let inlineImages = collectImages(from: runs, blockID: snapshot.id, counter: &imageIndex)
let body = await renderInline(runs, font: bodyFont)
trimLeadingWhitespace(in: body)
// A finalized list line stores a trailing "\n" run. It normally
// coalesces into the plain text and is sanitized to a space, but when
// the item ends in a styled span (inline code, bold, link) the runs
// can't merge and the bare "\n" survives — combined with the "\n"
// terminator appended below it rendered as an empty paragraph
// (a full blank line between bullets). Trim it here.
trimTrailingNewlines(in: body)

let bulletPrefix = bulletText + " "
let rendered = NSMutableAttributedString(string: bulletPrefix, attributes: [.font: bodyFont])
Expand Down Expand Up @@ -702,49 +709,80 @@ actor MarkdownAttributeBuilder {
}
}

private func renderBlockquote(snapshot: BlockSnapshot) async -> RenderedContentResult {
private func trimTrailingNewlines(in attributedString: NSMutableAttributedString) {
let newline: unichar = 0x0A
while attributedString.length > 0 {
let lastIndex = attributedString.length - 1
guard attributedString.mutableString.character(at: lastIndex) == newline else { break }
attributedString.deleteCharacters(in: NSRange(location: lastIndex, length: 1))
}
}
Comment thread
ronaldmannak marked this conversation as resolved.


private func renderBlockquote(snapshot: BlockSnapshot, previousBlockKind: BlockKind? = nil) async -> RenderedContentResult {
var imageIndex = 0
let bodyRuns = sanitizeInlineRuns(snapshot.inlineRuns ?? [], kind: snapshot.kind)
// Container-only parents (e.g. the implicit level-1 block that
// `>> nested` opens) render nothing: their children draw the bars for
// every enclosing level themselves, so emitting a newline here would
// show up as a stray blank quote line above the nested content.
// Atomic payloads (images, math) count as content even when their
// text — e.g. an empty alt — is blank.
let hasOwnContent = bodyRuns.contains { run in
run.image != nil || run.math != nil ||
!run.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
if !hasOwnContent && !snapshot.childIDs.isEmpty {
return RenderedContentResult(attributed: AttributedString(),
Comment thread
ronaldmannak marked this conversation as resolved.
table: nil,
listItem: nil,
blockquote: nil,
math: nil,
images: [],
codeBlock: nil)
}
let inlineImages = collectImages(from: bodyRuns, blockID: snapshot.id, counter: &imageIndex)
let body = await renderInline(bodyRuns, font: bodyFont)
let paragraphStyle = makeBlockquoteParagraphStyle()
let lineColor = blockquoteColor.withAlphaComponent(0.6)
// Trailing newline runs that survive styled spans would otherwise
// combine with the terminator below into a blank quoted line. Only
// newlines: trailing spaces can be real content (e.g. a code span
// ending in a space).
trimTrailingNewlines(in: body)
// Nested quotes arrive as child blocks (depth 1, 2, …). The bars are
// NOT characters: the range is marked with the quote level + bar
// color and indented past a leading gutter; the text views draw one
// continuous vertical bar per level there (see
// BlockquoteBarDecoration.swift). Because the attribute also covers
// the trailing newline, adjacent quote blocks merge into one
// uninterrupted bar. Suppress the inter-paragraph gap between
// adjacent quote blocks so a nested quote reads as one quote body.
let level = snapshot.depth + 1
let followsBlockquote = previousBlockKind == .blockquote
let paragraphStyle = makeBlockquoteParagraphStyle(level: level,
spacingBefore: followsBlockquote ? 0 : 4)
Comment thread
ronaldmannak marked this conversation as resolved.
let textColor = PlatformColor.rendererLabel

let prefixAttributes: [NSAttributedString.Key: Any] = [
.font: bodyFont,
.foregroundColor: lineColor,
.paragraphStyle: paragraphStyle
]

let bodyAttributes: [NSAttributedString.Key: Any] = [
.font: bodyFont,
.foregroundColor: textColor,
.paragraphStyle: paragraphStyle
]

let result = NSMutableAttributedString(string: "│ ", attributes: prefixAttributes)
let styledBody = NSMutableAttributedString(attributedString: body)
if styledBody.length > 0 {
styledBody.addAttributes(bodyAttributes, range: NSRange(location: 0, length: styledBody.length))
}
result.append(styledBody)

let mutableString = result.mutableString
let prefixLength = ("│ " as NSString).length
var searchLocation = prefixLength
while searchLocation < mutableString.length {
let range = mutableString.range(of: "\n", options: [], range: NSRange(location: searchLocation, length: mutableString.length - searchLocation))
if range.location == NSNotFound { break }
let insertLocation = range.location + range.length
result.insert(NSAttributedString(string: "│ ", attributes: prefixAttributes), at: insertLocation)
searchLocation = insertLocation + prefixLength
}

result.append(NSAttributedString(string: "\n", attributes: prefixAttributes))
result.addAttribute(.paragraphStyle, value: paragraphStyle, range: NSRange(location: 0, length: result.length))
let result = NSMutableAttributedString(attributedString: styledBody)
result.append(NSAttributedString(string: "\n", attributes: bodyAttributes))
result.addAttributes([
.picoBlockquoteLevel: level,
.picoBlockquoteBarColor: blockquoteColor.withAlphaComponent(0.6)
], range: NSRange(location: 0, length: result.length))

return RenderedContentResult(attributed: AttributedString(result),
// The plain AttributedString initializer drops the custom keys —
// convert through the pico scope so the bar attributes survive.
return RenderedContentResult(attributed: AttributedString.picoConverted(from: result),
table: nil,
listItem: nil,
blockquote: RenderedBlockquote(content: AttributedString(styledBody)),
Expand All @@ -753,13 +791,14 @@ actor MarkdownAttributeBuilder {
codeBlock: nil)
}

private func makeBlockquoteParagraphStyle() -> NSMutableParagraphStyle {
private func makeBlockquoteParagraphStyle(level: Int, spacingBefore: CGFloat = 4) -> NSMutableParagraphStyle {
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineBreakMode = .byWordWrapping
paragraphStyle.firstLineHeadIndent = 0
paragraphStyle.headIndent = 0
let indent = BlockquoteBarMetrics.textIndent(level: level)
paragraphStyle.firstLineHeadIndent = indent
paragraphStyle.headIndent = indent
paragraphStyle.paragraphSpacing = 8
paragraphStyle.paragraphSpacingBefore = 4
paragraphStyle.paragraphSpacingBefore = spacingBefore
return paragraphStyle
}

Expand Down
7 changes: 7 additions & 0 deletions Sources/PicoMarkdownView/Renderer/MarkdownRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ actor MarkdownRenderer {
case .blockStarted(let id, _, let position):
await insertBlock(id: id, at: position)
mutated = true
// A new child changes its parent's snapshot (childIDs), and
// some renders depend on children — e.g. container-only quote
// parents render nothing. Refresh the parent so its cached
// render doesn't go stale mid-stream.
if let parentID = await snapshotProvider(id).parentID {
_ = await refreshBlock(id: parentID)
Comment thread
ronaldmannak marked this conversation as resolved.
}
case .runsAppended(let id, _),
.codeAppended(let id, _),
.tableHeaderConfirmed(let id),
Expand Down
77 changes: 77 additions & 0 deletions Sources/PicoMarkdownView/Renderer/PicoAttributeScope.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import Foundation

#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif

/// Typed keys for PicoMarkdownView's custom attributes.
///
/// `AttributedString`'s plain conversion initializers silently drop any
/// `NSAttributedString.Key` that isn't part of a registered attribute scope.
/// The rendering pipeline converts between `NSAttributedString` and
/// `AttributedString` at its seams, so every conversion that must keep these
/// attributes goes through the `picoConverted(from:)` helpers below.

/// 1-based blockquote nesting level (see `BlockquoteBarDecoration`).
enum PicoBlockquoteLevelAttribute: ObjectiveCConvertibleAttributedStringKey {
typealias Value = Int
typealias ObjectiveCValue = NSNumber
static let name = NSAttributedString.Key.picoBlockquoteLevel.rawValue

static func objectiveCValue(for value: Int) throws -> NSNumber {
NSNumber(value: value)
}

static func value(for object: NSNumber) throws -> Int {
object.intValue
}
}

/// Platform color for drawn blockquote bars.
enum PicoBlockquoteBarColorAttribute: ObjectiveCConvertibleAttributedStringKey {
typealias Value = MarkdownColor
typealias ObjectiveCValue = MarkdownColor
static let name = NSAttributedString.Key.picoBlockquoteBarColor.rawValue

static func objectiveCValue(for value: MarkdownColor) throws -> MarkdownColor {
value
}

static func value(for object: MarkdownColor) throws -> MarkdownColor {
object
}
}

extension AttributeScopes {
/// PicoMarkdownView's attribute scope: the custom keys plus the platform
/// and Foundation scopes, so scoped conversions keep standard attributes
/// (fonts, colors, paragraph styles, links, attachments) as well.
struct PicoMarkdownAttributes: AttributeScope {
let blockquoteLevel: PicoBlockquoteLevelAttribute
let blockquoteBarColor: PicoBlockquoteBarColorAttribute
#if canImport(UIKit)
let uiKit: UIKitAttributes
#elseif canImport(AppKit)
let appKit: AppKitAttributes
#endif
let foundation: FoundationAttributes
}

var picoMarkdown: PicoMarkdownAttributes.Type { PicoMarkdownAttributes.self }
}

extension AttributedString {
/// Conversion that preserves PicoMarkdownView's custom attributes.
static func picoConverted(from attributed: NSAttributedString) -> AttributedString {
(try? AttributedString(attributed, including: \.picoMarkdown)) ?? AttributedString(attributed)
}
}

extension NSAttributedString {
/// Conversion that preserves PicoMarkdownView's custom attributes.
static func picoConverted(from content: AttributedString) -> NSAttributedString {
(try? NSAttributedString(content, including: \.picoMarkdown)) ?? NSAttributedString(content)
}
}
Loading
Loading