Skip to content

Commit 86ab774

Browse files
committed
Enhance STMarkdown rendering and parsing capabilities
- Updated `STMarkdownRenderAdapter` to include `unorderedDepth` in block rendering for improved list handling. - Introduced `rowGroups` in `STMarkdownTableViewModel` to facilitate visual grouping of table rows based on empty first columns. - Added a new normalizer, `STMarkdownHeadingStandaloneStrongNormalizer`, to simplify headings containing only a single strong node. - Refined list item rendering to utilize `unorderedDepth` for bullet symbol selection in `STMarkdownAttributedStringRenderer`. - Improved handling of trailing emphasis and backticks in `STMarkdownStreamingPresenter` and `STMarkdownStreamingTransforms` to prevent unclosed markers during streaming.
1 parent eb16ab6 commit 86ab774

8 files changed

Lines changed: 93 additions & 61 deletions

‎Sources/STMarkdown/Core/STMarkdownRenderAdapter.swift‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,15 @@ public struct STMarkdownRenderAdapter: STMarkdownRenderAdapting, Sendable {
1818
public func adapt(_ document: STMarkdownDocument) -> STMarkdownRenderDocument {
1919
var slugger = STMarkdownAnchorSlugRegistry()
2020
let mainBlocks = document.blocks.enumerated().map {
21-
self.makeRenderBlock(from: $0.element, listLevel: 0, path: ["b:\($0.offset)"], slugger: &slugger)
21+
self.makeRenderBlock(from: $0.element, listLevel: 0, unorderedDepth: 0, path: ["b:\($0.offset)"], slugger: &slugger)
2222
}
2323
let merged = STMarkdownFootnoteSectionBuilder.appendingSectionIfNeeded(document: document, renderBlocks: mainBlocks)
2424
return STMarkdownRenderDocument(blocks: merged)
2525
}
2626
}
2727

2828
private extension STMarkdownRenderAdapter {
29-
func makeRenderBlock(from block: STMarkdownBlockNode, listLevel: Int, path: [String], slugger: inout STMarkdownAnchorSlugRegistry) -> STMarkdownRenderBlock {
29+
func makeRenderBlock(from block: STMarkdownBlockNode, listLevel: Int, unorderedDepth: Int, path: [String], slugger: inout STMarkdownAnchorSlugRegistry) -> STMarkdownRenderBlock {
3030
switch block {
3131
case .paragraph(let inlines):
3232
return .paragraph(self.makeMetadata(kind: .paragraph, path: path), inlines)
@@ -46,6 +46,7 @@ private extension STMarkdownRenderAdapter {
4646
self.makeRenderBlock(
4747
from: $0.element,
4848
listLevel: listLevel,
49+
unorderedDepth: unorderedDepth,
4950
path: path + ["q:\($0.offset)"],
5051
slugger: &slugger
5152
)
@@ -58,6 +59,7 @@ private extension STMarkdownRenderAdapter {
5859
kind: kind,
5960
items: items,
6061
level: listLevel,
62+
unorderedDepth: unorderedDepth,
6163
path: path,
6264
slugger: &slugger
6365
)
@@ -80,6 +82,7 @@ private extension STMarkdownRenderAdapter {
8082
self.makeRenderBlock(
8183
from: $0.element,
8284
listLevel: listLevel,
85+
unorderedDepth: unorderedDepth,
8386
path: path + ["d:\($0.offset)"],
8487
slugger: &slugger
8588
)
@@ -94,6 +97,7 @@ private extension STMarkdownRenderAdapter {
9497
kind: STMarkdownListKind,
9598
items: [STMarkdownListItemNode],
9699
level: Int,
100+
unorderedDepth: Int,
97101
path: [String],
98102
slugger: inout STMarkdownAnchorSlugRegistry
99103
) -> [STMarkdownRenderListItem] {
@@ -110,13 +114,16 @@ private extension STMarkdownRenderAdapter {
110114
startIndex = 1
111115
}
112116

117+
let subUnorderedDepth = isOrdered ? unorderedDepth : unorderedDepth + 1
118+
113119
for (index, item) in items.enumerated() {
114120
let orderedIndex = isOrdered ? startIndex + index : nil
115121
let itemPath = path + ["li:\(index)"]
116122
let renderBlocks = item.blocks.enumerated().map {
117123
self.makeRenderBlock(
118124
from: $0.element,
119125
listLevel: level + 1,
126+
unorderedDepth: subUnorderedDepth,
120127
path: itemPath + ["b:\($0.offset)"],
121128
slugger: &slugger
122129
)
@@ -127,7 +134,8 @@ private extension STMarkdownRenderAdapter {
127134
ordered: isOrdered,
128135
level: level,
129136
orderedIndex: orderedIndex,
130-
checkbox: item.checkbox
137+
checkbox: item.checkbox,
138+
unorderedDepth: unorderedDepth
131139
)
132140
)
133141
}

‎Sources/STMarkdown/Parsing/STMarkdownAST.swift‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,39 @@ public struct STMarkdownTableModel: Hashable, Sendable {
6262
self.rows = rows
6363
self.columnAlignments = columnAlignments
6464
}
65+
66+
/// 将数据行按"首列为空→延续同组"规则分组,返回基于 `rows` 0-based 下标的分组数组。
67+
/// 首列有内容时开启新分组;首列为空且当前组非空时延续当前组。
68+
/// 用于渲染层实现跨行视觉分组效果(对应 LLM 输出的空首列=合并单元格模式)。
69+
public var rowGroups: [[Int]] {
70+
guard !rows.isEmpty else { return [] }
71+
var groups: [[Int]] = []
72+
var current: [Int] = []
73+
var currentGroupHeadEmpty = false
74+
for (index, row) in rows.enumerated() {
75+
let isEmpty = Self.isCellContentEmpty(row.first ?? [])
76+
if isEmpty && !current.isEmpty && !currentGroupHeadEmpty {
77+
current.append(index)
78+
} else {
79+
if !current.isEmpty { groups.append(current) }
80+
current = [index]
81+
currentGroupHeadEmpty = isEmpty
82+
}
83+
}
84+
if !current.isEmpty { groups.append(current) }
85+
return groups
86+
}
87+
88+
private static func isCellContentEmpty(_ cell: [STMarkdownInlineNode]) -> Bool {
89+
if cell.isEmpty { return true }
90+
return cell.allSatisfy {
91+
switch $0 {
92+
case .text(let t): return t.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
93+
case .softBreak: return true
94+
default: return false
95+
}
96+
}
97+
}
6598
}
6699

67100
public enum STMarkdownBlockNode: Hashable, Sendable {

‎Sources/STMarkdown/Parsing/STMarkdownRenderAST.swift‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,8 @@ public struct STMarkdownRenderListItem: Hashable, Sendable {
180180
public let blocks: [STMarkdownRenderBlock]
181181
public let ordered: Bool
182182
public let level: Int
183+
/// 仅计无序列表的嵌套深度,忽略有序列表祖先。用于选择 ●/○/▪ 符号。
184+
public let unorderedDepth: Int
183185
public let orderedIndex: Int?
184186
public let checkbox: STMarkdownCheckbox?
185187

@@ -188,11 +190,13 @@ public struct STMarkdownRenderListItem: Hashable, Sendable {
188190
ordered: Bool,
189191
level: Int,
190192
orderedIndex: Int?,
191-
checkbox: STMarkdownCheckbox? = nil
193+
checkbox: STMarkdownCheckbox? = nil,
194+
unorderedDepth: Int = 0
192195
) {
193196
self.blocks = blocks
194197
self.ordered = ordered
195198
self.level = level
199+
self.unorderedDepth = unorderedDepth
196200
self.orderedIndex = orderedIndex
197201
self.checkbox = checkbox
198202
}
@@ -203,7 +207,8 @@ public struct STMarkdownRenderListItem: Hashable, Sendable {
203207
level: Int,
204208
orderedIndex: Int?,
205209
childBlocks: [STMarkdownRenderBlock],
206-
checkbox: STMarkdownCheckbox? = nil
210+
checkbox: STMarkdownCheckbox? = nil,
211+
unorderedDepth: Int = 0
207212
) {
208213
var blocks: [STMarkdownRenderBlock] = []
209214
if content.isEmpty == false {
@@ -222,7 +227,8 @@ public struct STMarkdownRenderListItem: Hashable, Sendable {
222227
ordered: ordered,
223228
level: level,
224229
orderedIndex: orderedIndex,
225-
checkbox: checkbox
230+
checkbox: checkbox,
231+
unorderedDepth: unorderedDepth
226232
)
227233
}
228234

‎Sources/STMarkdown/Parsing/STMarkdownSemanticNormalizer.swift‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,25 @@ private extension STMarkdownSoftBreakCollapsingNormalizer {
9797
return result
9898
}
9999
}
100+
101+
/// heading 内容恰好是单一 `strong` 节点时剥除外层包裹(`### **text**` → `### text`)。
102+
/// 仅在全部内联恰好在同一个 strong 内时触发,不影响局部加粗的 heading(`### **A** B`)。
103+
/// 用途:避免渲染层对 heading font 和 strong bold trait 的双重叠加。
104+
public struct STMarkdownHeadingStandaloneStrongNormalizer: STMarkdownSemanticNormalizing {
105+
public init() {}
106+
107+
public func normalize(_ document: STMarkdownDocument) -> STMarkdownDocument {
108+
STMarkdownDocument(
109+
blocks: document.blocks.map(Self.normalizeBlock),
110+
footnoteDefinitions: document.footnoteDefinitions
111+
)
112+
}
113+
114+
private static func normalizeBlock(_ block: STMarkdownBlockNode) -> STMarkdownBlockNode {
115+
guard case .heading(let level, let content) = block,
116+
content.count == 1,
117+
case .strong(let children) = content[0]
118+
else { return block }
119+
return .heading(level: level, content: children)
120+
}
121+
}

‎Sources/STMarkdown/Parsing/STMarkdownStreamingPresenter.swift‎

Lines changed: 9 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -599,57 +599,9 @@ public enum STMarkdownStreamingPresenter {
599599
}
600600

601601
private static func autoCloseDanglingEmphasisInTrailingListItem(in text: String) -> String {
602-
guard !text.isEmpty else { return text }
603-
var lines = text.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
604-
guard let lastNonEmptyIndex = lines.lastIndex(where: {
605-
!$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
606-
}) else {
607-
return text
608-
}
609-
610-
var itemStartIndex: Int?
611-
var index = lastNonEmptyIndex
612-
while index >= 0 {
613-
let line = lines[index]
614-
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
615-
if trimmed.isEmpty { break }
616-
if STMarkdownStreamingTransforms.isStreamingListLine(line) {
617-
itemStartIndex = index
618-
break
619-
}
620-
if index == 0 { break }
621-
index -= 1
622-
}
623-
624-
guard let itemStartIndex else { return text }
625-
guard itemStartIndex < lastNonEmptyIndex else { return text }
626-
627-
let continuationLines = lines[(itemStartIndex + 1)...lastNonEmptyIndex]
628-
let hasContinuationBody = continuationLines.contains {
629-
let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines)
630-
return !trimmed.isEmpty && !STMarkdownStreamingTransforms.isStreamingListLine($0)
631-
}
632-
guard hasContinuationBody else { return text }
633-
634-
let itemLine = lines[itemStartIndex]
635-
let lineRange = NSRange(location: 0, length: itemLine.utf16.count)
636-
guard let match = Self.listLineContentCaptureRegex.firstMatch(in: itemLine, options: [], range: lineRange),
637-
match.numberOfRanges == 3,
638-
let contentRange = Range(match.range(at: 2), in: itemLine) else {
639-
return text
640-
}
641-
642-
var content = String(itemLine[contentRange])
643-
let markers = ["~~", "**", "__", "*", "_"]
644-
for marker in markers {
645-
if Self.countUnescapedOccurrences(of: marker, in: content) % 2 != 0 {
646-
content += marker
647-
}
648-
}
649-
650-
let prefixEnd = contentRange.lowerBound
651-
lines[itemStartIndex] = String(itemLine[..<prefixEnd]) + content
652-
return lines.joined(separator: "\n")
602+
// SSE 流式阶段列表项 marker 行尾部 emphasis 未闭合,后续 token 未知,不补闭合。
603+
// trimIncompleteTrailingEmphasis 会在后续步骤将孤立 marker trim 掉。
604+
return text
653605
}
654606

655607
private static func autoCloseTrailingStandaloneStrongLine(in text: String) -> String {
@@ -670,8 +622,12 @@ public enum STMarkdownStreamingPresenter {
670622
guard isStandaloneStrongLine || isHeadingStrongLine else { return text }
671623
guard !trimmed.hasSuffix("**") else { return text }
672624
guard Self.countUnescapedOccurrences(of: "**", in: trimmed) == 1 else { return text }
673-
let closingSuffix = line.hasSuffix("*") ? "*" : "**"
674-
lines[lastNonEmptyIndex] = line + closingSuffix
625+
// SSE 流式阶段尾部 ** 未闭合,后续 token 未知,不能补闭合。
626+
// 将包含孤立 ** 的尾行移除,待闭合符到达后再渲染。
627+
lines.removeSubrange(lastNonEmptyIndex...)
628+
while let last = lines.last, last.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
629+
lines.removeLast()
630+
}
675631
return lines.joined(separator: "\n")
676632
}
677633

‎Sources/STMarkdown/Parsing/STMarkdownStreamingTransforms.swift‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -518,7 +518,10 @@ public enum STMarkdownStreamingTransforms {
518518
prevChar = ch
519519
}
520520
guard backtickCount % 2 != 0 else { return text }
521-
return text + "`"
521+
// SSE 流式阶段尾部反引号未闭合,后续 token 未知,不补闭合。
522+
// 将尾部孤立的反引号 trim 掉,待闭合符到达后再渲染整个 inline code。
523+
guard let lastBacktickRange = text.range(of: "`", options: .backwards) else { return text }
524+
return String(text[..<lastBacktickRange.lowerBound])
522525
}
523526

524527
public static func trimTrailingSetextHeadingAmbiguity(in text: String) -> String {

‎Sources/STMarkdown/Rendering/Advanced/STMarkdownAttributedStringRenderer.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,7 @@ private extension STMarkdownAttributedStringRenderer {
658658
baselineOffset = 0
659659
} else {
660660
let bulletSymbol: String
661-
switch item.level {
661+
switch item.unorderedDepth {
662662
case 0: bulletSymbol = "●"
663663
case 1: bulletSymbol = "○"
664664
default: bulletSymbol = "▪"

‎Sources/STMarkdown/Table/STMarkdownTableViewModel.swift‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ public final class STMarkdownTableViewModel {
3636
public let hasHeader: Bool
3737
public let columnAlignments: [STMarkdownColumnAlignment]
3838
public let cells: [[STMarkdownTableCellData]]
39+
/// `STMarkdownTableModel.rowGroups` 的直传,基于 `table.rows` 的 0-based 下标。
40+
/// `hasHeader == true` 时 `cells` 的第 0 行是表头,使用时对行号 +1 对齐。
41+
public let rowGroups: [[Int]]
3942

4043
public init(
4144
from table: STMarkdownTableModel,
@@ -44,6 +47,7 @@ public final class STMarkdownTableViewModel {
4447
) {
4548
self.hasHeader = table.header != nil
4649
self.columnAlignments = table.columnAlignments
50+
self.rowGroups = table.rowGroups
4751

4852
let renderer = STMarkdownAttributedStringRenderer(
4953
style: style,

0 commit comments

Comments
 (0)