Skip to content

Commit f638e7c

Browse files
committed
- Options now supports trimming whitespace on the formatted attr string
- Better handling of newlines - Better handling of custom MarkdownElementAttributes - Unordered list fixes
1 parent 0c87ad1 commit f638e7c

6 files changed

Lines changed: 346 additions & 79 deletions

File tree

Sources/MarkdownToAttributedString/AttributedStringFormatter.swift

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import Foundation
1414
///
1515
public class AttributedStringFormatter {
1616

17-
public var options: FormattingOptions?
17+
public var options: FormattingOptions
1818

1919
private var attributes: MarkdownAttributes?
2020

@@ -25,7 +25,7 @@ public class AttributedStringFormatter {
2525
/// - attributes: An optional `MarkdownAttributes` object defining styles for the formatted output.
2626
public init(
2727
attributes: MarkdownAttributes? = nil,
28-
options: FormattingOptions? = nil)
28+
options: FormattingOptions = FormattingOptions.default)
2929
{
3030
self.attributes = attributes
3131
self.options = options
@@ -40,7 +40,7 @@ public class AttributedStringFormatter {
4040
public static func format(
4141
markdown: String,
4242
attributes: MarkdownAttributes? = nil,
43-
options: FormattingOptions? = nil) -> NSAttributedString
43+
options: FormattingOptions = FormattingOptions.default) -> NSAttributedString
4444
{
4545
let asf = AttributedStringFormatter(
4646
attributes: attributes,
@@ -60,6 +60,34 @@ public class AttributedStringFormatter {
6060
attributes: attributes,
6161
options: options)
6262

63-
return asv.convert()
63+
var result = asv.convert()
64+
65+
if options.trimWhitespace {
66+
// Taking care not to butcher a trailing emoji 😅
67+
let nsString = result.string as NSString
68+
let nonWhitespace = CharacterSet.whitespacesAndNewlines.inverted
69+
70+
let startRange = nsString.rangeOfCharacter(from: nonWhitespace)
71+
let endRange = nsString.rangeOfCharacter(from: nonWhitespace, options: .backwards)
72+
73+
// If we actually found some non‐whitespace, trim
74+
if startRange.location != NSNotFound, endRange.location != NSNotFound {
75+
// Expand the start and end to cover full grapheme clusters
76+
let startCluster = nsString.rangeOfComposedCharacterSequence(at: startRange.location)
77+
let endCluster = nsString.rangeOfComposedCharacterSequence(at: endRange.location)
78+
79+
let newStart = startCluster.location
80+
// endCluster.location + endCluster.length gives the first character AFTER the cluster, so subtract 1 to get inclusive end
81+
let newEnd = endCluster.location + endCluster.length - 1
82+
83+
let trimmedRange = NSRange(location: newStart, length: newEnd - newStart + 1)
84+
result = result.attributedSubstring(from: trimmedRange)
85+
} else {
86+
// The entire string is whitespace
87+
return NSAttributedString(string: "", attributes: attributes?.baseAttributes)
88+
}
89+
}
90+
91+
return result
6492
}
6593
}

Sources/MarkdownToAttributedString/AttributedStringVisitor.swift

Lines changed: 120 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ struct AttributedStringVisitor: MarkupVisitor {
2929
options: FormattingOptions? = nil) {
3030
self.markdown = markdown
3131
self.markdownAttributes = attributes ?? MarkdownAttributes.default
32-
self.currentAttributes = self.markdownAttributes.baseAttributes
32+
self.currentAttributes = self.markdownAttributes.baseAttributes.deepCopy()
3333
self.formattingOptions = options
3434
}
3535

@@ -58,27 +58,27 @@ struct AttributedStringVisitor: MarkupVisitor {
5858

5959
mutating func visitText(_ text: Text) {
6060
debugLog("<open>", file: "")
61-
debugLog("appending: \(text.string)")
6261
appendToAttrStr(string: text.string)
6362
debugLog("<close>", file: "")
6463
}
6564

65+
// We deviate from spec here by inserting newlines for soft breaks. This makes dealing with external input, which may not have been crafted for markdown specifically, better. In the future this could be gated by a `strict` options flag.
6666
mutating func visitSoftBreak(_ softBreak: SoftBreak) {
6767
debugLog("<open>", file: "")
68-
appendToAttrStr(string: "\n")
68+
appendNewline()
6969
debugLog("<close>", file: "")
7070
}
7171

72-
// NB: I've never seen this called!
72+
// This is for hard line breaks, but SwiftMarkdown only seems to support the "\\\n" variety, not " \n".
7373
mutating func visitLineBreak(_ lineBreak: LineBreak) {
7474
debugLog("<open>", file: "")
75-
appendToAttrStr(string: "\n")
75+
appendNewline()
7676
debugLog("<close>", file: "")
7777
}
7878

7979
mutating func visitInlineHTML(_ inlineHTML: InlineHTML) {
8080
if inlineHTML.rawHTML == "<br>" {
81-
appendToAttrStr(string: "\n")
81+
appendNewline()
8282
}
8383
}
8484

@@ -172,7 +172,7 @@ struct AttributedStringVisitor: MarkupVisitor {
172172
}
173173
debugLog("<open>", file: "")
174174

175-
let previousAttributes = currentAttributes
175+
let previousAttributes = currentAttributes.deepCopy()
176176
var styleAttrs = markdownAttributes.attributesForType(.codeBlock)
177177

178178
if shouldAddCustomAttr {
@@ -183,7 +183,7 @@ struct AttributedStringVisitor: MarkupVisitor {
183183

184184
appendToAttrStr(string: codeBlock.code, attrs: styleAttrs)
185185

186-
currentAttributes = previousAttributes
186+
currentAttributes = previousAttributes.deepCopy()
187187

188188
if codeBlock.hasSuccessor {
189189
appendNewline()
@@ -195,13 +195,13 @@ struct AttributedStringVisitor: MarkupVisitor {
195195
/// NB about lists and SwiftMarkdown: SM considers *each* top level list item a separate list, so you can expect this to be called recursively once for each top level item. (Which yes, makes handling newlines a challenge.)
196196
mutating func visitUnorderedList(_ unorderedList: UnorderedList) {
197197
guard optionsSupportEl(.unorderedList) else {
198-
appendPlainText("\n")
198+
appendNewline()
199199
debugLog("Skipping unsupported: unorderedList"); return
200200
}
201201
debugLog("<open>", file: "")
202202

203203
var styleAttrs = markdownAttributes.attributesForType(.unorderedList)
204-
let previousAttributes = currentAttributes
204+
let previousAttributes = currentAttributes.deepCopy()
205205

206206
if shouldAddCustomAttr {
207207
styleAttrs.addMarkdownElementAttr(
@@ -223,19 +223,20 @@ struct AttributedStringVisitor: MarkupVisitor {
223223
appendNewline()
224224
}
225225

226-
currentAttributes = previousAttributes
226+
currentAttributes = previousAttributes.deepCopy()
227227

228228
debugLog("<close>", file: "")
229229
}
230230

231231
mutating func visitOrderedList(_ orderedList: OrderedList) {
232232
guard optionsSupportEl(.orderedList) else {
233-
appendPlainText("\n")
234-
debugLog("Skipping unsupported: orderedList"); return
233+
debugLog("Skipping unsupported: orderedList")
234+
appendNewline()
235+
return
235236
}
236237
debugLog("<open>", file: "")
237238
var styleAttrs = markdownAttributes.attributesForType(.orderedList)
238-
let previousAttributes = currentAttributes
239+
let previousAttributes = currentAttributes.deepCopy()
239240

240241
if shouldAddCustomAttr {
241242
styleAttrs.addMarkdownElementAttr(
@@ -255,38 +256,51 @@ struct AttributedStringVisitor: MarkupVisitor {
255256
}
256257
}
257258

258-
currentAttributes = previousAttributes
259+
currentAttributes = previousAttributes.deepCopy()
259260

260261
debugLog("<close>", file: "")
261262
}
262263

263264

264265
mutating func visitListItem(_ listItem: ListItem, index: Int? = nil) {
265266
guard optionsSupportEl(.listItem) else {
266-
appendPlainText("\n")
267-
debugLog("Skipping unsupported: listItem"); return
267+
debugLog("Skipping unsupported: listItem")
268+
appendNewline()
269+
return
268270
}
269271
debugLog("<open>", file: "")
270272
var styleAttrs = markdownAttributes.attributesForType(.listItem)
271-
let previousAttributes = currentAttributes
273+
let previousAttributes = currentAttributes.deepCopy()
272274

273275
currentAttributes.mergeAttributes(styleAttrs)
274-
276+
275277
let prefix: String
278+
let renderedDelimiter: String
276279
if let index = index {
277280
prefix = "\(index). "
281+
renderedDelimiter = ""
278282
} else {
279283
let bullets = ["", "", "", ""]
284+
renderedDelimiter = bullets[listItem.listDepth % bullets.count]
280285
let tabs = String(repeating: "\t", count: listItem.listDepth + 1)
281-
prefix = tabs + bullets[listItem.listDepth % bullets.count] + " "
286+
prefix = tabs + renderedDelimiter + " "
282287
}
283288

284289
if shouldAddCustomAttr {
290+
var typedDelimiter = "-"
291+
if let lowerBound = listItem.range?.lowerBound,
292+
let char = markdown.characterAt(line: lowerBound.line, col: lowerBound.column)
293+
{
294+
typedDelimiter = String(char)
295+
}
296+
285297
styleAttrs.addMarkdownElementAttr(
286298
ListItemMarkdownElementAttribute(
287299
listDepth: listItem.listDepth,
288300
indexInParent: listItem.indexInParent,
289-
prefix: prefix)
301+
prefix: prefix,
302+
typedDelimiter: typedDelimiter,
303+
renderedDelimiter: renderedDelimiter)
290304
)
291305
}
292306
currentAttributes.mergeAttributes(styleAttrs)
@@ -299,7 +313,7 @@ struct AttributedStringVisitor: MarkupVisitor {
299313
appendNewline()
300314
}
301315

302-
currentAttributes = previousAttributes
316+
currentAttributes = previousAttributes.deepCopy()
303317
debugLog("<close>", file: "")
304318
}
305319

@@ -327,7 +341,7 @@ struct AttributedStringVisitor: MarkupVisitor {
327341
}
328342
debugLog("<open>", file: "")
329343

330-
let previousAttributes = currentAttributes
344+
let previousAttributes = currentAttributes.deepCopy()
331345

332346
let level = max(1, min(heading.level, 6))
333347

@@ -358,7 +372,7 @@ struct AttributedStringVisitor: MarkupVisitor {
358372
appendNewline()
359373
}
360374

361-
currentAttributes = previousAttributes
375+
currentAttributes = previousAttributes.deepCopy()
362376
debugLog("<close>", file: "")
363377
}
364378

@@ -383,12 +397,12 @@ struct AttributedStringVisitor: MarkupVisitor {
383397
styleAttrs[.link] = url
384398
}
385399

386-
let previousAttributes = currentAttributes
400+
let previousAttributes = currentAttributes.deepCopy()
387401
currentAttributes.mergeAttributes(styleAttrs)
388402

389403
visitChildren(of: link)
390404

391-
currentAttributes = previousAttributes
405+
currentAttributes = previousAttributes.deepCopy()
392406

393407
debugLog("<close>", file: "")
394408
}
@@ -423,19 +437,19 @@ struct AttributedStringVisitor: MarkupVisitor {
423437
_ attributes: StringAttrs,
424438
_ markup: Markup
425439
) {
426-
let previousAttributes = currentAttributes
440+
let previousAttributes = currentAttributes.deepCopy()
427441
currentAttributes.mergeAttributes(attributes)
428442
visitChildren(of: markup)
429-
currentAttributes = previousAttributes
443+
currentAttributes = previousAttributes.deepCopy()
430444
}
431445

432446
private mutating func visitWithMergedAttributes(
433447
_ newAttributes: StringAttrs,
434448
_ markup: Markup,
435449
markupType: MarkupType
436450
) {
437-
let previousAttributes = currentAttributes
438-
var mergedAttributes = currentAttributes
451+
let previousAttributes = currentAttributes.deepCopy()
452+
var mergedAttributes = currentAttributes.deepCopy()
439453
mergedAttributes.mergeAttributes(newAttributes) // Merge general attributes
440454

441455
if shouldAddCustomAttr {
@@ -450,9 +464,9 @@ struct AttributedStringVisitor: MarkupVisitor {
450464
mergedAttributes[.font] = CocoaFont(descriptor: newDescriptor, size: expectedFont.pointSize)
451465
}
452466

453-
currentAttributes = mergedAttributes
467+
currentAttributes = mergedAttributes.deepCopy()
454468
visitChildren(of: markup)
455-
currentAttributes = previousAttributes
469+
currentAttributes = previousAttributes.deepCopy()
456470
}
457471

458472
private func mergeFontDescriptors(base: FontDescriptor, expected: FontDescriptor) -> FontDescriptor {
@@ -570,3 +584,77 @@ extension StringAttrs {
570584
}
571585
}
572586
}
587+
588+
extension Dictionary where Key == NSAttributedString.Key, Value == Any {
589+
func deepCopy() -> StringAttrs {
590+
var copy: StringAttrs = [:]
591+
592+
for (key, value) in self {
593+
if let copyable = value as? NSCopying {
594+
copy[key] = copyable.copy()
595+
} else if let array = value as? [Any] {
596+
copy[key] = array.deepCopyArray()
597+
} else if let dict = value as? [AnyHashable: Any] {
598+
copy[key] = dict.deepCopyDict()
599+
} else {
600+
copy[key] = value // Assume it's a value type (Int, String, etc.)
601+
}
602+
}
603+
604+
return copy
605+
}
606+
}
607+
608+
private extension Array where Element == Any {
609+
func deepCopyArray() -> [Any] {
610+
return self.map { element in
611+
if let copyable = element as? NSCopying {
612+
return copyable.copy()
613+
} else if let dict = element as? [AnyHashable: Any] {
614+
return dict.deepCopyDict()
615+
} else {
616+
return element
617+
}
618+
}
619+
}
620+
}
621+
622+
private extension Dictionary where Key == AnyHashable, Value == Any {
623+
func deepCopyDict() -> [AnyHashable: Any] {
624+
var copy: [AnyHashable: Any] = [:]
625+
for (key, value) in self {
626+
if let copyable = value as? NSCopying {
627+
copy[key] = copyable.copy()
628+
} else if let array = value as? [Any] {
629+
copy[key] = array.deepCopyArray()
630+
} else if let dict = value as? [AnyHashable: Any] {
631+
copy[key] = dict.deepCopyDict()
632+
} else {
633+
copy[key] = value
634+
}
635+
}
636+
return copy
637+
}
638+
}
639+
640+
extension String {
641+
func characterAt(line: Int, col: Int) -> Character? {
642+
let lines = self.split(separator: "\n", omittingEmptySubsequences: false)
643+
644+
let zeroIndexedLine = line - 1
645+
let zeroIndexedCol = col - 1
646+
647+
guard zeroIndexedLine >= 0 && zeroIndexedLine < lines.count else {
648+
return nil
649+
}
650+
651+
let line = lines[zeroIndexedLine]
652+
653+
guard zeroIndexedCol >= 0 && zeroIndexedCol < line.count else {
654+
return nil
655+
}
656+
657+
let index = line.index(line.startIndex, offsetBy: zeroIndexedCol)
658+
return line[index]
659+
}
660+
}

0 commit comments

Comments
 (0)