556 lines
31 KiB
Swift
556 lines
31 KiB
Swift
import AppKit
|
|
import CryptoKit
|
|
import Markdown
|
|
|
|
public enum NoteAttributes {
|
|
public static let block = NSAttributedString.Key("ConjureNote.block")
|
|
public static let headingLevel = NSAttributedString.Key("ConjureNote.headingLevel")
|
|
public static let depth = NSAttributedString.Key("ConjureNote.depth")
|
|
public static let number = NSAttributedString.Key("ConjureNote.number")
|
|
public static let checked = NSAttributedString.Key("ConjureNote.checked")
|
|
public static let language = NSAttributedString.Key("ConjureNote.language")
|
|
public static let inlineCode = NSAttributedString.Key("ConjureNote.inlineCode")
|
|
public static let bold = NSAttributedString.Key("ConjureNote.bold")
|
|
public static let italic = NSAttributedString.Key("ConjureNote.italic")
|
|
}
|
|
|
|
/// The attributed string is the document; Markdown is only an import/export format.
|
|
@MainActor
|
|
public enum NoteDocument {
|
|
static let metadataFilename = "ConjureNote.json"
|
|
|
|
public static func importMarkdown(_ source: NSAttributedString) -> NSAttributedString {
|
|
MarkdownImporter(source).contents()
|
|
}
|
|
|
|
public static func markdown(_ contents: NSAttributedString) -> NSAttributedString {
|
|
let result = NSMutableAttributedString(string: "")
|
|
let text = contents.string as NSString
|
|
var cursor = 0
|
|
while cursor < contents.length {
|
|
let range = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
|
let attributes = contents.attributes(at: cursor, effectiveRange: nil)
|
|
let block = attributes[NoteAttributes.block] as? String
|
|
if block == "code" {
|
|
let language = attributes[NoteAttributes.language] as? String ?? ""
|
|
var end = NSMaxRange(range)
|
|
while end < contents.length,
|
|
contents.attribute(NoteAttributes.block, at: end, effectiveRange: nil) as? String == "code",
|
|
(contents.attribute(NoteAttributes.language, at: end, effectiveRange: nil) as? String ?? "") == language {
|
|
end = NSMaxRange(text.paragraphRange(for: NSRange(location: end, length: 0)))
|
|
}
|
|
let code = contents.attributedSubstring(from: NSRange(location: cursor, length: end - cursor))
|
|
let fence = String(repeating: "`", count: max(3, longestRun(of: "`", in: code.string) + 1))
|
|
result.append(NSAttributedString(string: fence + language.replacingOccurrences(of: "\n", with: " ") + "\n"))
|
|
result.append(code)
|
|
if !code.string.hasSuffix("\n") { result.append(NSAttributedString(string: "\n")) }
|
|
result.append(NSAttributedString(string: fence))
|
|
if code.string.hasSuffix("\n") || end < contents.length { result.append(NSAttributedString(string: "\n")) }
|
|
cursor = end
|
|
continue
|
|
}
|
|
let depth = max(0, attributes[NoteAttributes.depth] as? Int ?? 0)
|
|
var prefix = ""
|
|
switch block {
|
|
case "heading": prefix = String(repeating: "#", count: min(6, max(1, attributes[NoteAttributes.headingLevel] as? Int ?? 1))) + " "
|
|
case "quote": prefix = String(repeating: "> ", count: depth + 1)
|
|
case "bullet": prefix = String(repeating: " ", count: depth) + "- "
|
|
case "ordered": prefix = String(repeating: " ", count: depth) + "\(attributes[NoteAttributes.number] as? Int ?? 1). "
|
|
case "task": prefix = String(repeating: " ", count: depth) + ((attributes[NoteAttributes.checked] as? Bool ?? false) ? "- [x] " : "- [ ] ")
|
|
default: break
|
|
}
|
|
result.append(NSAttributedString(string: prefix))
|
|
var bodyEnd = NSMaxRange(range)
|
|
while bodyEnd > range.location, [10, 13, 0x2028, 0x2029].contains(text.character(at: bodyEnd - 1)) { bodyEnd -= 1 }
|
|
appendInline(contents, range: NSRange(location: cursor, length: bodyEnd - cursor), to: result)
|
|
if bodyEnd < NSMaxRange(range) {
|
|
result.append(contents.attributedSubstring(from: NSRange(location: bodyEnd, length: NSMaxRange(range) - bodyEnd)))
|
|
}
|
|
cursor = NSMaxRange(range)
|
|
}
|
|
return NSAttributedString(attributedString: result)
|
|
}
|
|
|
|
public static func fileWrapper(_ contents: NSAttributedString) throws -> FileWrapper {
|
|
let wrapper = try contents.fileWrapper(
|
|
from: NSRange(location: 0, length: contents.length),
|
|
documentAttributes: [.documentType: NSAttributedString.DocumentType.rtfd]
|
|
)
|
|
guard wrapper.fileWrappers?[metadataFilename] == nil else { throw DocumentError.reservedMetadataFilename }
|
|
// Bind metadata to the text AppKit actually stores (RTF normalizes line endings).
|
|
guard let stored = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil) else {
|
|
throw DocumentError.corruptMetadata
|
|
}
|
|
guard stored.length == contents.length, stored.string == contents.string else {
|
|
throw DocumentError.textChangedDuringEncoding
|
|
}
|
|
let metadata = Metadata(version: 1, length: stored.length, digest: digest(stored.string), runs: runs(in: contents))
|
|
let json = FileWrapper(regularFileWithContents: try JSONEncoder().encode(metadata))
|
|
json.preferredFilename = metadataFilename
|
|
wrapper.addFileWrapper(json)
|
|
return wrapper
|
|
}
|
|
|
|
public static func contents(from wrapper: FileWrapper) throws -> NSAttributedString {
|
|
guard wrapper.isDirectory else { throw DocumentError.corruptMetadata }
|
|
try validate(wrapper)
|
|
guard let file = wrapper.fileWrappers?[metadataFilename], file.isRegularFile,
|
|
let data = file.regularFileContents else { throw DocumentError.corruptMetadata }
|
|
let metadata: Metadata
|
|
do { metadata = try JSONDecoder().decode(Metadata.self, from: data) }
|
|
catch { throw DocumentError.corruptMetadata }
|
|
guard metadata.version == 1 else { throw DocumentError.unsupportedVersion(metadata.version) }
|
|
guard let decoded = NSAttributedString(rtfdFileWrapper: wrapper, documentAttributes: nil),
|
|
metadata.length == decoded.length, metadata.digest == digest(decoded.string) else {
|
|
throw DocumentError.corruptMetadata
|
|
}
|
|
let result = NSMutableAttributedString(attributedString: decoded)
|
|
var end = 0
|
|
for run in metadata.runs {
|
|
guard run.location == end, run.length > 0, run.location >= 0,
|
|
run.location <= result.length, run.length <= result.length - run.location,
|
|
run.values.isValid else { throw DocumentError.corruptMetadata }
|
|
result.addAttributes(run.values.attributes, range: NSRange(location: run.location, length: run.length))
|
|
end += run.length
|
|
}
|
|
guard end == result.length else { throw DocumentError.corruptMetadata }
|
|
return NSAttributedString(attributedString: result)
|
|
}
|
|
|
|
private static func validate(_ wrapper: FileWrapper) throws {
|
|
guard !wrapper.isSymbolicLink else { throw DocumentError.corruptMetadata }
|
|
if wrapper.isDirectory {
|
|
guard let children = wrapper.fileWrappers else { throw DocumentError.corruptMetadata }
|
|
for (name, child) in children {
|
|
guard !name.isEmpty, name != ".", name != "..",
|
|
!name.contains("/"), !name.contains("\\"),
|
|
child.preferredFilename.map({ !$0.isEmpty && $0 != "." && $0 != ".." && !$0.contains("/") && !$0.contains("\\") }) ?? true else { throw DocumentError.corruptMetadata }
|
|
try validate(child)
|
|
}
|
|
} else if !wrapper.isRegularFile { throw DocumentError.corruptMetadata }
|
|
}
|
|
|
|
public enum DocumentError: LocalizedError {
|
|
case corruptMetadata
|
|
case unsupportedVersion(Int)
|
|
case textChangedDuringEncoding
|
|
case reservedMetadataFilename
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .corruptMetadata: "This note's structured metadata is missing, corrupt, or does not match its text. The original note was not changed."
|
|
case .unsupportedVersion(let version): "This note uses unsupported structured metadata version \(version). The original note was not changed."
|
|
case .textChangedDuringEncoding: "The note could not be saved without changing its text. The original note was not changed."
|
|
case .reservedMetadataFilename: "An attachment uses the reserved filename ConjureNote.json. Rename the attachment before saving. The original note was not changed."
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func digest(_ string: String) -> String {
|
|
SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined()
|
|
}
|
|
|
|
private struct Metadata: Codable {
|
|
let version: Int
|
|
let length: Int
|
|
let digest: String
|
|
let runs: [Run]
|
|
}
|
|
|
|
private struct Run: Codable {
|
|
let location: Int
|
|
let length: Int
|
|
let values: Values
|
|
}
|
|
|
|
private struct Values: Codable {
|
|
var block: String?
|
|
var headingLevel: Int?
|
|
var depth: Int?
|
|
var number: Int?
|
|
var checked: Bool?
|
|
var language: String?
|
|
var inlineCode: Bool?
|
|
var bold: Bool?
|
|
var italic: Bool?
|
|
var underline: Int?
|
|
var strike: Int?
|
|
var link: String?
|
|
|
|
init(_ attributes: [NSAttributedString.Key: Any]) {
|
|
block = attributes[NoteAttributes.block] as? String
|
|
headingLevel = attributes[NoteAttributes.headingLevel] as? Int
|
|
depth = attributes[NoteAttributes.depth] as? Int
|
|
number = attributes[NoteAttributes.number] as? Int
|
|
checked = attributes[NoteAttributes.checked] as? Bool
|
|
language = attributes[NoteAttributes.language] as? String
|
|
inlineCode = attributes[NoteAttributes.inlineCode] as? Bool
|
|
bold = attributes[NoteAttributes.bold] as? Bool
|
|
italic = attributes[NoteAttributes.italic] as? Bool
|
|
underline = attributes[.underlineStyle] as? Int
|
|
strike = attributes[.strikethroughStyle] as? Int
|
|
link = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
|
}
|
|
|
|
var isValid: Bool {
|
|
(block == nil || ["heading", "quote", "bullet", "ordered", "task", "code"].contains(block!))
|
|
&& (headingLevel == nil || (1...6).contains(headingLevel!))
|
|
&& (depth == nil || depth! >= 0)
|
|
&& (number == nil || number! >= 0)
|
|
}
|
|
|
|
var attributes: [NSAttributedString.Key: Any] {
|
|
var result: [NSAttributedString.Key: Any] = [:]
|
|
result[NoteAttributes.block] = block
|
|
result[NoteAttributes.headingLevel] = headingLevel
|
|
result[NoteAttributes.depth] = depth
|
|
result[NoteAttributes.number] = number
|
|
result[NoteAttributes.checked] = checked
|
|
result[NoteAttributes.language] = language
|
|
result[NoteAttributes.inlineCode] = inlineCode
|
|
result[NoteAttributes.bold] = bold
|
|
result[NoteAttributes.italic] = italic
|
|
result[.underlineStyle] = underline
|
|
result[.strikethroughStyle] = strike
|
|
result[.link] = link
|
|
return result
|
|
}
|
|
}
|
|
|
|
private static func runs(in contents: NSAttributedString) -> [Run] {
|
|
var result: [Run] = []
|
|
contents.enumerateAttributes(in: NSRange(location: 0, length: contents.length)) { attributes, range, _ in
|
|
result.append(Run(location: range.location, length: range.length, values: Values(attributes)))
|
|
}
|
|
return result
|
|
}
|
|
|
|
private static func longestRun(of character: Character, in text: String) -> Int {
|
|
var longest = 0
|
|
var count = 0
|
|
for value in text {
|
|
count = value == character ? count + 1 : 0
|
|
longest = max(longest, count)
|
|
}
|
|
return longest
|
|
}
|
|
|
|
private static func appendInline(_ contents: NSAttributedString, range: NSRange, to output: NSMutableAttributedString) {
|
|
let semantic = NSMutableAttributedString(string: (contents.string as NSString).substring(with: range))
|
|
contents.enumerateAttributes(in: range) { attributes, run, _ in
|
|
let traits = (attributes[.font] as? NSFont).map { NSFontManager.shared.traits(of: $0) } ?? []
|
|
var marks: [NSAttributedString.Key: Any] = [:]
|
|
if attributes[NoteAttributes.bold] as? Bool ?? traits.contains(.boldFontMask) { marks[NoteAttributes.bold] = true }
|
|
if attributes[NoteAttributes.italic] as? Bool ?? traits.contains(.italicFontMask) { marks[NoteAttributes.italic] = true }
|
|
if attributes[NoteAttributes.inlineCode] as? Bool ?? false { marks[NoteAttributes.inlineCode] = true }
|
|
if (attributes[.underlineStyle] as? Int ?? 0) != 0 { marks[.underlineStyle] = 1 }
|
|
if (attributes[.strikethroughStyle] as? Int ?? 0) != 0 { marks[.strikethroughStyle] = 1 }
|
|
marks[.link] = (attributes[.link] as? URL)?.absoluteString ?? attributes[.link] as? String
|
|
semantic.addAttributes(marks, range: NSRange(location: run.location - range.location, length: run.length))
|
|
}
|
|
// Markdown emphasis cannot open/close against whitespace. Trim each semantic
|
|
// span, not each display-font/color run; underline and links retain spaces.
|
|
let string = semantic.string as NSString
|
|
for key in [NoteAttributes.bold, NoteAttributes.italic, .strikethroughStyle] {
|
|
var edges: [NSRange] = []
|
|
semantic.enumerateAttribute(key, in: NSRange(location: 0, length: semantic.length)) { value, span, _ in
|
|
guard value != nil else { return }
|
|
var start = span.location
|
|
var end = NSMaxRange(span)
|
|
while start < end, string.character(at: start) == 32 || string.character(at: start) == 9 { start += 1 }
|
|
while end > start, string.character(at: end - 1) == 32 || string.character(at: end - 1) == 9 { end -= 1 }
|
|
if start > span.location { edges.append(NSRange(location: span.location, length: start - span.location)) }
|
|
if end < NSMaxRange(span) { edges.append(NSRange(location: end, length: NSMaxRange(span) - end)) }
|
|
}
|
|
for edge in edges { semantic.removeAttribute(key, range: edge) }
|
|
}
|
|
var active: [(open: String, close: String)] = []
|
|
semantic.enumerateAttributes(in: NSRange(location: 0, length: semantic.length)) { attributes, run, _ in
|
|
let value = contents.attributedSubstring(from: NSRange(location: range.location + run.location, length: run.length))
|
|
let text = value.string
|
|
let code = attributes[NoteAttributes.inlineCode] as? Bool ?? false
|
|
var desired: [(open: String, close: String)] = []
|
|
if let link = attributes[.link] as? String {
|
|
let destination = link.replacingOccurrences(of: "\\", with: "%5C").replacingOccurrences(of: " ", with: "%20").replacingOccurrences(of: "<", with: "%3C").replacingOccurrences(of: ">", with: "%3E").replacingOccurrences(of: "\n", with: "%0A")
|
|
desired.append(("[", "](<\(destination)>)"))
|
|
}
|
|
for (enabled, delimiter) in [(attributes[NoteAttributes.bold] as? Bool ?? false, "**"), (attributes[NoteAttributes.italic] as? Bool ?? false, "*"), ((attributes[.underlineStyle] as? Int ?? 0) != 0, "<u>"), ((attributes[.strikethroughStyle] as? Int ?? 0) != 0, "~~")] where enabled {
|
|
desired.append((delimiter, delimiter == "<u>" ? "</u>" : delimiter))
|
|
}
|
|
var common = 0
|
|
while common < min(active.count, desired.count),
|
|
active[common].open == desired[common].open, active[common].close == desired[common].close { common += 1 }
|
|
for mark in active.dropFirst(common).reversed() { output.append(NSAttributedString(string: mark.close)) }
|
|
for mark in desired.dropFirst(common) { output.append(NSAttributedString(string: mark.open)) }
|
|
active = desired
|
|
if code {
|
|
let fence = String(repeating: "`", count: max(1, longestRun(of: "`", in: text) + 1))
|
|
let pad = text.hasPrefix("`") || text.hasSuffix("`") || (text.hasPrefix(" ") && text.hasSuffix(" ") && !text.allSatisfy({ $0 == " " }))
|
|
output.append(NSAttributedString(string: fence + (pad ? " " : "")))
|
|
output.append(value)
|
|
output.append(NSAttributedString(string: (pad ? " " : "") + fence))
|
|
} else {
|
|
var index = 0
|
|
for character in text {
|
|
let length = String(character).utf16.count
|
|
if length == 1, "\\`*_{}[]<>()#+-.!|>~&".contains(character) { output.append(NSAttributedString(string: "\\")) }
|
|
output.append(value.attributedSubstring(from: NSRange(location: index, length: length)))
|
|
index += length
|
|
}
|
|
}
|
|
}
|
|
for mark in active.reversed() { output.append(NSAttributedString(string: mark.close)) }
|
|
}
|
|
}
|
|
|
|
|
|
@MainActor
|
|
private final class MarkdownImporter {
|
|
private let source: NSAttributedString
|
|
private let text: NSString
|
|
private let bytes: [UInt8]
|
|
private let lineStarts: [Int]
|
|
private let utf16Offsets: [Int]
|
|
private let result: NSMutableAttributedString
|
|
private var deletions: [NSRange] = []
|
|
private var replacements: [(NSRange, NSAttributedString)] = []
|
|
|
|
init(_ source: NSAttributedString) {
|
|
self.source = source
|
|
text = source.string as NSString
|
|
bytes = Array(source.string.utf8)
|
|
var starts = [0]
|
|
for (index, byte) in bytes.enumerated() where byte == 10 { starts.append(index + 1) }
|
|
lineStarts = starts
|
|
var offsets = Array(repeating: 0, count: bytes.count + 1)
|
|
var byteOffset = 0
|
|
var utf16Offset = 0
|
|
for scalar in source.string.unicodeScalars {
|
|
for index in 0..<scalar.utf8.count { offsets[byteOffset + index] = utf16Offset }
|
|
byteOffset += scalar.utf8.count
|
|
utf16Offset += scalar.utf16.count
|
|
}
|
|
offsets[bytes.count] = utf16Offset
|
|
utf16Offsets = offsets
|
|
result = NSMutableAttributedString(attributedString: source)
|
|
}
|
|
|
|
func contents() -> NSAttributedString {
|
|
let document = Document(parsing: source.string)
|
|
visit(document)
|
|
var merged: [NSRange] = []
|
|
for range in deletions.filter({ $0.length > 0 }).sorted(by: { $0.location < $1.location }) {
|
|
if let last = merged.last, NSMaxRange(last) >= range.location {
|
|
merged[merged.count - 1] = NSUnionRange(last, range)
|
|
} else { merged.append(range) }
|
|
}
|
|
var edits = replacements
|
|
for range in merged where !replacements.contains(where: { NSIntersectionRange($0.0, range).length > 0 }) {
|
|
edits.append((range, NSAttributedString(string: "")))
|
|
}
|
|
for (range, value) in edits.sorted(by: { $0.0.location > $1.0.location }) {
|
|
result.replaceCharacters(in: range, with: value)
|
|
}
|
|
return NSAttributedString(attributedString: result)
|
|
}
|
|
|
|
private func offset(_ location: SourceLocation) -> Int {
|
|
guard location.line > 0, location.line <= lineStarts.count else { return text.length }
|
|
let byte = min(bytes.count, lineStarts[location.line - 1] + max(0, location.column - 1))
|
|
return utf16Offsets[byte]
|
|
}
|
|
|
|
private func range(_ markup: any Markup) -> NSRange? {
|
|
guard let range = markup.range else { return nil }
|
|
let start = offset(range.lowerBound)
|
|
let end = offset(range.upperBound)
|
|
guard end >= start else { return nil }
|
|
return NSRange(location: start, length: end - start)
|
|
}
|
|
|
|
private func mark(_ range: NSRange, _ attributes: [NSAttributedString.Key: Any], paragraph: Bool = false) {
|
|
let target = paragraph ? text.paragraphRange(for: range) : range
|
|
result.addAttributes(attributes, range: target)
|
|
}
|
|
|
|
private func inlineContents(_ node: any Markup, sourceRange: NSRange) -> NSAttributedString {
|
|
var cursor = sourceRange.location
|
|
var attachments: [NSTextAttachment] = []
|
|
source.enumerateAttribute(.attachment, in: sourceRange) { value, _, _ in
|
|
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
|
}
|
|
var attachmentIndex = 0
|
|
func literal(_ string: String) -> NSMutableAttributedString {
|
|
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
|
let match = text.range(of: string, options: [], range: remaining)
|
|
let value: NSMutableAttributedString
|
|
if !string.isEmpty, match.location != NSNotFound {
|
|
value = NSMutableAttributedString(attributedString: result.attributedSubstring(from: match))
|
|
cursor = NSMaxRange(match)
|
|
} else {
|
|
let attributes = cursor < result.length ? result.attributes(at: cursor, effectiveRange: nil) : [:]
|
|
value = NSMutableAttributedString(string: string, attributes: attributes)
|
|
}
|
|
let characters = string as NSString
|
|
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
|
if attachmentIndex < attachments.count {
|
|
value.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1))
|
|
}
|
|
attachmentIndex += 1
|
|
}
|
|
return value
|
|
}
|
|
func children(_ container: any Markup) -> NSMutableAttributedString {
|
|
let output = NSMutableAttributedString(string: "")
|
|
var underline: [(offset: Int, literal: String)] = []
|
|
for child in container.children {
|
|
if let html = child as? InlineHTML {
|
|
if html.rawHTML.lowercased() == "<u>" { underline.append((output.length, html.rawHTML)); continue }
|
|
if html.rawHTML.lowercased() == "</u>", let start = underline.popLast() {
|
|
output.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: NSRange(location: start.offset, length: output.length - start.offset))
|
|
continue
|
|
}
|
|
}
|
|
output.append(render(child))
|
|
}
|
|
// Unmatched HTML is unsupported literal content, not a disappearing delimiter.
|
|
for start in underline.reversed() { output.insert(NSAttributedString(string: start.literal), at: start.offset) }
|
|
return output
|
|
}
|
|
func render(_ child: any Markup) -> NSMutableAttributedString {
|
|
switch child {
|
|
case let value as Text: return literal(value.string)
|
|
case let value as InlineCode:
|
|
let output = literal(value.code)
|
|
output.addAttribute(NoteAttributes.inlineCode, value: true, range: NSRange(location: 0, length: output.length))
|
|
return output
|
|
case is SoftBreak, is LineBreak:
|
|
let remaining = NSRange(location: cursor, length: max(0, NSMaxRange(sourceRange) - cursor))
|
|
let newline = text.rangeOfCharacter(from: .newlines, options: [], range: remaining)
|
|
let output = NSMutableAttributedString(string: "")
|
|
if newline.location != NSNotFound {
|
|
var start = newline.location
|
|
while start > cursor, text.character(at: start - 1) == 32 || text.character(at: start - 1) == 9 { start -= 1 }
|
|
if start < newline.location { output.append(result.attributedSubstring(from: NSRange(location: start, length: newline.location - start))) }
|
|
cursor = newline.location
|
|
}
|
|
output.append(literal("\n"))
|
|
return output
|
|
case let html as InlineHTML: return literal(html.rawHTML)
|
|
case is Strong, is Emphasis, is Strikethrough, is Link:
|
|
let output = children(child)
|
|
let range = NSRange(location: 0, length: output.length)
|
|
if child is Strong { output.addAttribute(NoteAttributes.bold, value: true, range: range) }
|
|
if child is Emphasis { output.addAttribute(NoteAttributes.italic, value: true, range: range) }
|
|
if child is Strikethrough { output.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) }
|
|
if let link = child as? Link, let destination = link.destination { output.addAttribute(.link, value: destination, range: range) }
|
|
return output
|
|
default: return literal(child.format())
|
|
}
|
|
}
|
|
let output = children(node)
|
|
// The parser deliberately omits terminal Markdown hard-break whitespace.
|
|
// It is still native note text and must survive migration/import.
|
|
var suffix = NSMaxRange(sourceRange)
|
|
while suffix > sourceRange.location, text.character(at: suffix - 1) == 32 || text.character(at: suffix - 1) == 9 { suffix -= 1 }
|
|
let rendered = output.string as NSString
|
|
var renderedSuffix = rendered.length
|
|
while renderedSuffix > 0, rendered.character(at: renderedSuffix - 1) == 32 || rendered.character(at: renderedSuffix - 1) == 9 { renderedSuffix -= 1 }
|
|
let missing = NSMaxRange(sourceRange) - suffix - (rendered.length - renderedSuffix)
|
|
if missing > 0 { output.append(result.attributedSubstring(from: NSRange(location: NSMaxRange(sourceRange) - missing, length: missing))) }
|
|
return output
|
|
}
|
|
|
|
private func visit(_ node: any Markup, listDepth: Int = 0) {
|
|
guard let whole = range(node) else {
|
|
for child in node.children { visit(child, listDepth: listDepth) }
|
|
return
|
|
}
|
|
switch node {
|
|
case let heading as Heading:
|
|
mark(whole, [NoteAttributes.block: "heading", NoteAttributes.headingLevel: heading.level], paragraph: true)
|
|
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
|
return
|
|
case let code as CodeBlock:
|
|
let full = text.paragraphRange(for: whole)
|
|
var value = code.code
|
|
if NSMaxRange(full) == text.length, !source.string.hasSuffix("\n"), value.hasSuffix("\n") { value.removeLast() }
|
|
// An empty code paragraph still needs a real character on which semantics can live.
|
|
if value.isEmpty { value = "\n" }
|
|
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: "code"]
|
|
if let language = code.language { attributes[NoteAttributes.language] = language }
|
|
let valueContents = NSMutableAttributedString(string: value, attributes: whole.location < result.length ? result.attributes(at: whole.location, effectiveRange: nil) : [:])
|
|
valueContents.addAttributes(attributes, range: NSRange(location: 0, length: valueContents.length))
|
|
var attachmentIndex = 0
|
|
var attachments: [NSTextAttachment] = []
|
|
source.enumerateAttribute(.attachment, in: full) { value, _, _ in
|
|
if let attachment = value as? NSTextAttachment { attachments.append(attachment) }
|
|
}
|
|
let characters = value as NSString
|
|
for index in 0..<characters.length where characters.character(at: index) == 0xfffc {
|
|
if attachmentIndex < attachments.count { valueContents.addAttribute(.attachment, value: attachments[attachmentIndex], range: NSRange(location: index, length: 1)) }
|
|
attachmentIndex += 1
|
|
}
|
|
replacements.append((full, valueContents))
|
|
return
|
|
case is BlockQuote:
|
|
var quoteDepth = 0
|
|
var ancestor = node.parent
|
|
while let current = ancestor {
|
|
if current is BlockQuote { quoteDepth += 1 }
|
|
ancestor = current.parent
|
|
}
|
|
mark(whole, [NoteAttributes.block: "quote", NoteAttributes.depth: quoteDepth], paragraph: true)
|
|
var cursor = text.paragraphRange(for: NSRange(location: whole.location, length: 0)).location
|
|
while cursor < NSMaxRange(whole) {
|
|
let line = text.paragraphRange(for: NSRange(location: cursor, length: 0))
|
|
var end = cursor
|
|
while end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
|
while end < NSMaxRange(line), text.character(at: end) == 62 {
|
|
end += 1
|
|
if end < NSMaxRange(line), text.character(at: end) == 32 { end += 1 }
|
|
}
|
|
if end > cursor { deletions.append(NSRange(location: cursor, length: end - cursor)) }
|
|
cursor = NSMaxRange(line)
|
|
}
|
|
case is OrderedList, is UnorderedList:
|
|
let ordered = node as? OrderedList
|
|
var number = Int(ordered?.startIndex ?? 1)
|
|
for child in node.children {
|
|
guard let item = child as? ListItem else { continue }
|
|
for block in item.children {
|
|
if block is OrderedList || block is UnorderedList {
|
|
visit(block, listDepth: listDepth + 1)
|
|
} else {
|
|
if let itemRange = range(item), let body = range(block) {
|
|
var attributes: [NSAttributedString.Key: Any] = [NoteAttributes.block: ordered == nil ? "bullet" : "ordered", NoteAttributes.depth: listDepth]
|
|
if ordered != nil { attributes[NoteAttributes.number] = number }
|
|
if let checked = item.checkbox {
|
|
attributes[NoteAttributes.block] = "task"
|
|
switch checked { case .checked: attributes[NoteAttributes.checked] = true; case .unchecked: attributes[NoteAttributes.checked] = false }
|
|
}
|
|
mark(body, attributes, paragraph: true)
|
|
if body.location > itemRange.location, block.indexInParent == 0 {
|
|
let start = text.paragraphRange(for: NSRange(location: itemRange.location, length: 0)).location
|
|
deletions.append(NSRange(location: start, length: body.location - start))
|
|
}
|
|
}
|
|
visit(block, listDepth: listDepth)
|
|
}
|
|
}
|
|
number += 1
|
|
}
|
|
return
|
|
case is Paragraph:
|
|
replacements.append((whole, inlineContents(node, sourceRange: whole)))
|
|
return
|
|
case is Document, is ListItem:
|
|
break
|
|
default:
|
|
// Tables, HTML, remote image syntax and other unsupported constructs stay literal.
|
|
return
|
|
}
|
|
for child in node.children { visit(child, listDepth: listDepth) }
|
|
}
|
|
}
|