mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
2280 lines
75 KiB
Swift
2280 lines
75 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import Postbox
|
|
import TelegramCore
|
|
import AccountContext
|
|
import InstantPageUI
|
|
|
|
private let markdownPresentationIntentAttribute = NSAttributedString.Key("NSPresentationIntent")
|
|
private let markdownInlinePresentationIntentAttribute = NSAttributedString.Key("NSInlinePresentationIntent")
|
|
private let markdownLinkAttribute = NSAttributedString.Key("NSLink")
|
|
private let markdownImageURLAttribute = NSAttributedString.Key("NSImageURL")
|
|
private let markdownAlternateDescriptionAttribute = NSAttributedString.Key("NSAlternateDescription")
|
|
|
|
@available(iOS 15.0, *)
|
|
private let markdownSoftBreakInlineIntent = InlinePresentationIntent(rawValue: 1 << 6)
|
|
@available(iOS 15.0, *)
|
|
private let markdownHardBreakInlineIntent = InlinePresentationIntent(rawValue: 1 << 7)
|
|
@available(iOS 15.0, *)
|
|
private let markdownInlineHTMLInlineIntent = InlinePresentationIntent(rawValue: 1 << 8)
|
|
|
|
private let markdownDefaultBlockImageDimensions = PixelDimensions(width: 1200, height: 900)
|
|
private let markdownDefaultInlineImageDimensions = PixelDimensions(width: 18, height: 18)
|
|
private let markdownImageParsingEnabled = false
|
|
private let markdownTaskListUncheckedNumber = "\u{001f}tg-md-task:unchecked"
|
|
private let markdownTaskListCheckedNumber = "\u{001f}tg-md-task:checked"
|
|
private let markdownRawHTMLTagRegex = try! NSRegularExpression(pattern: #"</?([A-Za-z][A-Za-z0-9:-]*)\b[^>]*?>"#)
|
|
private let markdownFormulaPlaceholderRegex = try! NSRegularExpression(pattern: #"TGMDMATH\d+TGMD"#)
|
|
private let markdownVoidHTMLTags: Set<String> = [
|
|
"area",
|
|
"base",
|
|
"br",
|
|
"col",
|
|
"embed",
|
|
"hr",
|
|
"img",
|
|
"input",
|
|
"link",
|
|
"meta",
|
|
"param",
|
|
"source",
|
|
"track",
|
|
"wbr"
|
|
]
|
|
|
|
private struct MarkdownSafetyLimits {
|
|
let maxFileSize = 524_288
|
|
let maxLineLength = 32_768
|
|
let maxBlockquoteDepth = 64
|
|
let maxListIndent = 96
|
|
let maxRawHTMLTagCount = 8_000
|
|
let maxRawHTMLNestingDepth = 64
|
|
let maxAttributedStringLength = 400_000
|
|
let maxAttributeRuns = 20_000
|
|
let maxPresentationIntentDepth = 128
|
|
let maxIntentNodes = 10_000
|
|
let maxEmittedBlocks = 5_000
|
|
let maxTableColumns = 32
|
|
let maxTableCells = 2_000
|
|
let maxMediaItems = 100
|
|
let maxInlineHTMLStyleDepth = 32
|
|
let maxDataImageBytes = 2_097_152
|
|
let maxDataImagePixelCount = 12_000_000
|
|
let maxFormulas = 200
|
|
let maxFormulaSourceCharacters = 20_000
|
|
let maxInlineFormulaLength = 256
|
|
let maxBlockFormulaLength = 4_096
|
|
}
|
|
|
|
private let markdownSafetyLimits = MarkdownSafetyLimits()
|
|
|
|
private final class MarkdownConversionBudget {
|
|
let limits: MarkdownSafetyLimits
|
|
|
|
private(set) var isExceeded = false
|
|
|
|
private var attributeRunCount = 0
|
|
private var intentNodeCount = 0
|
|
private var tableCellCount = 0
|
|
private var mediaItemCount = 0
|
|
|
|
init(limits: MarkdownSafetyLimits) {
|
|
self.limits = limits
|
|
}
|
|
|
|
@discardableResult
|
|
func fail() -> Bool {
|
|
self.isExceeded = true
|
|
return false
|
|
}
|
|
|
|
@discardableResult
|
|
func registerAttributedStringLength(_ length: Int) -> Bool {
|
|
guard length <= self.limits.maxAttributedStringLength else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerAttributeRun() -> Bool {
|
|
self.attributeRunCount += 1
|
|
guard self.attributeRunCount <= self.limits.maxAttributeRuns else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerPresentationIntentDepth(_ depth: Int) -> Bool {
|
|
guard depth <= self.limits.maxPresentationIntentDepth else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerIntentNode() -> Bool {
|
|
self.intentNodeCount += 1
|
|
guard self.intentNodeCount <= self.limits.maxIntentNodes else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerBlockDepth(_ depth: Int) -> Bool {
|
|
guard depth <= self.limits.maxPresentationIntentDepth else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerTableColumns(_ count: Int) -> Bool {
|
|
guard count <= self.limits.maxTableColumns else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerTableCells(_ count: Int) -> Bool {
|
|
self.tableCellCount += count
|
|
guard self.tableCellCount <= self.limits.maxTableCells else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerMediaItem() -> Bool {
|
|
self.mediaItemCount += 1
|
|
guard self.mediaItemCount <= self.limits.maxMediaItems else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func registerInlineHTMLStyleDepth(_ depth: Int) -> Bool {
|
|
guard depth <= self.limits.maxInlineHTMLStyleDepth else {
|
|
return self.fail()
|
|
}
|
|
return true
|
|
}
|
|
|
|
@discardableResult
|
|
func validateFinalBlocks(_ blocks: [InstantPageBlock]) -> Bool {
|
|
var count = 0
|
|
return self.validate(blocks: blocks, depth: 0, count: &count)
|
|
}
|
|
|
|
private func validate(blocks: [InstantPageBlock], depth: Int, count: inout Int) -> Bool {
|
|
guard self.registerBlockDepth(depth) else {
|
|
return false
|
|
}
|
|
for block in blocks {
|
|
count += 1
|
|
guard count <= self.limits.maxEmittedBlocks else {
|
|
return self.fail()
|
|
}
|
|
switch block {
|
|
case let .list(items, _):
|
|
for item in items {
|
|
if !self.validate(listItem: item, depth: depth + 1, count: &count) {
|
|
return false
|
|
}
|
|
}
|
|
case let .details(_, nestedBlocks, _):
|
|
if !self.validate(blocks: nestedBlocks, depth: depth + 1, count: &count) {
|
|
return false
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
private func validate(listItem: InstantPageListItem, depth: Int, count: inout Int) -> Bool {
|
|
guard self.registerBlockDepth(depth) else {
|
|
return false
|
|
}
|
|
if case let .blocks(blocks, _) = listItem {
|
|
return self.validate(blocks: blocks, depth: depth + 1, count: &count)
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct MarkdownPageResult {
|
|
let blocks: [InstantPageBlock]
|
|
let media: [MediaId: Media]
|
|
}
|
|
|
|
private enum MarkdownFormulaMode {
|
|
case inline
|
|
case block
|
|
}
|
|
|
|
private struct MarkdownFormulaDescriptor {
|
|
let placeholder: String
|
|
let latex: String
|
|
let mode: MarkdownFormulaMode
|
|
}
|
|
|
|
private struct MarkdownPreparedSource {
|
|
let text: String
|
|
let formulasByPlaceholder: [String: MarkdownFormulaDescriptor]
|
|
}
|
|
|
|
private enum MarkdownInlineTextSegment {
|
|
case plain(String)
|
|
case formula(MarkdownFormulaDescriptor)
|
|
}
|
|
|
|
private enum MarkdownInlineFragment {
|
|
case richText(RichText)
|
|
case formula(MarkdownFormulaDescriptor, RichText)
|
|
case image(MarkdownResolvedImage)
|
|
}
|
|
|
|
private struct MarkdownInlineContent {
|
|
let fragments: [MarkdownInlineFragment]
|
|
|
|
var richText: RichText {
|
|
var result: [RichText] = []
|
|
result.reserveCapacity(self.fragments.count)
|
|
|
|
for fragment in self.fragments {
|
|
switch fragment {
|
|
case let .richText(text):
|
|
result.append(text)
|
|
case let .formula(_, text):
|
|
result.append(text)
|
|
case let .image(image):
|
|
var text: RichText = .image(id: image.mediaId, dimensions: image.inlineDimensions)
|
|
if let linkUrl = image.linkUrl {
|
|
text = .url(text: text, url: linkUrl, webpageId: nil)
|
|
}
|
|
result.append(text)
|
|
}
|
|
}
|
|
|
|
return markdownCompact(result)
|
|
}
|
|
|
|
var standaloneBlockFormula: MarkdownFormulaDescriptor? {
|
|
var result: MarkdownFormulaDescriptor?
|
|
|
|
for fragment in self.fragments {
|
|
switch fragment {
|
|
case let .richText(text):
|
|
if !markdownIsWhitespaceOnly(text) {
|
|
return nil
|
|
}
|
|
case let .formula(descriptor, _):
|
|
guard descriptor.mode == .block else {
|
|
return nil
|
|
}
|
|
if result != nil {
|
|
return nil
|
|
}
|
|
result = descriptor
|
|
case .image:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
var standaloneImage: MarkdownResolvedImage? {
|
|
var result: MarkdownResolvedImage?
|
|
|
|
for fragment in self.fragments {
|
|
switch fragment {
|
|
case let .richText(text):
|
|
if !markdownIsWhitespaceOnly(text) {
|
|
return nil
|
|
}
|
|
case .formula:
|
|
return nil
|
|
case let .image(image):
|
|
if result != nil {
|
|
return nil
|
|
}
|
|
result = image
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|
|
|
|
private struct MarkdownResolvedImage {
|
|
let mediaId: MediaId
|
|
let inlineDimensions: PixelDimensions
|
|
let caption: InstantPageCaption
|
|
let linkUrl: String?
|
|
}
|
|
|
|
private enum MarkdownResolvedImageSource {
|
|
case remote(String)
|
|
case data(Data, PixelDimensions)
|
|
case unsupported
|
|
}
|
|
|
|
private enum MarkdownTaskListState {
|
|
case unchecked
|
|
case checked
|
|
}
|
|
|
|
private final class MarkdownConversionContext {
|
|
private let context: AccountContext
|
|
fileprivate let documentURL: URL
|
|
fileprivate let formulasByPlaceholder: [String: MarkdownFormulaDescriptor]
|
|
fileprivate let budget: MarkdownConversionBudget
|
|
private var nextRemoteMediaId: Int64 = 0
|
|
private var nextLocalMediaId: Int64 = 0
|
|
|
|
private(set) var media: [MediaId: Media] = [:]
|
|
|
|
init(context: AccountContext, documentURL: URL, formulasByPlaceholder: [String: MarkdownFormulaDescriptor], budget: MarkdownConversionBudget) {
|
|
self.context = context
|
|
self.documentURL = documentURL
|
|
self.formulasByPlaceholder = formulasByPlaceholder
|
|
self.budget = budget
|
|
}
|
|
|
|
func makePageResult(blocks: [InstantPageBlock]) -> MarkdownPageResult {
|
|
return MarkdownPageResult(blocks: blocks, media: self.media)
|
|
}
|
|
|
|
func resolveImage(attributes: [NSAttributedString.Key: Any]) -> MarkdownResolvedImage? {
|
|
guard markdownImageParsingEnabled else {
|
|
return nil
|
|
}
|
|
guard let imageUrl = markdownImageURL(attributes: attributes) else {
|
|
return nil
|
|
}
|
|
|
|
let inlineDimensions = markdownInlineImageDimensions(attributes: attributes)
|
|
let caption = markdownImageCaption(markdownAlternateDescription(attributes: attributes))
|
|
let linkUrl = markdownLink(attributes: attributes, documentURL: self.documentURL)
|
|
|
|
switch markdownResolveImageSource(imageUrl, limits: self.budget.limits) {
|
|
case let .remote(url):
|
|
guard self.budget.registerMediaItem() else {
|
|
return nil
|
|
}
|
|
let mediaId = self.nextMediaId(namespace: Namespaces.Media.CloudImage)
|
|
self.media[mediaId] = TelegramMediaImage(
|
|
imageId: mediaId,
|
|
representations: [
|
|
TelegramMediaImageRepresentation(
|
|
dimensions: markdownDefaultBlockImageDimensions,
|
|
resource: InstantPageExternalMediaResource(url: url),
|
|
progressiveSizes: [],
|
|
immediateThumbnailData: nil
|
|
)
|
|
],
|
|
immediateThumbnailData: nil,
|
|
reference: nil,
|
|
partialReference: nil,
|
|
flags: []
|
|
)
|
|
return MarkdownResolvedImage(
|
|
mediaId: mediaId,
|
|
inlineDimensions: inlineDimensions,
|
|
caption: caption,
|
|
linkUrl: linkUrl
|
|
)
|
|
case let .data(data, dimensions):
|
|
guard self.budget.registerMediaItem() else {
|
|
return nil
|
|
}
|
|
let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max), size: Int64(data.count), isSecretRelated: false)
|
|
self.context.engine.resources.storeResourceData(id: EngineMediaResource.Id(resource.id), data: data)
|
|
|
|
let mediaId = self.nextMediaId(namespace: Namespaces.Media.LocalImage)
|
|
self.media[mediaId] = TelegramMediaImage(
|
|
imageId: mediaId,
|
|
representations: [
|
|
TelegramMediaImageRepresentation(
|
|
dimensions: dimensions,
|
|
resource: resource,
|
|
progressiveSizes: [],
|
|
immediateThumbnailData: nil
|
|
)
|
|
],
|
|
immediateThumbnailData: nil,
|
|
reference: nil,
|
|
partialReference: nil,
|
|
flags: []
|
|
)
|
|
return MarkdownResolvedImage(
|
|
mediaId: mediaId,
|
|
inlineDimensions: inlineDimensions,
|
|
caption: caption,
|
|
linkUrl: linkUrl
|
|
)
|
|
case .unsupported:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func nextMediaId(namespace: Int32) -> MediaId {
|
|
switch namespace {
|
|
case Namespaces.Media.LocalImage:
|
|
self.nextLocalMediaId += 1
|
|
return MediaId(namespace: namespace, id: self.nextLocalMediaId)
|
|
default:
|
|
self.nextRemoteMediaId += 1
|
|
return MediaId(namespace: namespace, id: self.nextRemoteMediaId)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func markdownPassesPreflight(data: Data, limits: MarkdownSafetyLimits) -> Bool {
|
|
guard data.count <= limits.maxFileSize else {
|
|
return false
|
|
}
|
|
guard let text = markdownDecodedSourceText(data) else {
|
|
return false
|
|
}
|
|
return markdownPassesPreflight(text: text, limits: limits)
|
|
}
|
|
|
|
private func markdownDecodedSourceText(_ data: Data) -> String? {
|
|
if let text = String(data: data, encoding: .utf8) {
|
|
return text
|
|
}
|
|
if data.starts(with: [0xff, 0xfe]) {
|
|
return String(data: data, encoding: .utf16LittleEndian)
|
|
}
|
|
if data.starts(with: [0xfe, 0xff]) {
|
|
return String(data: data, encoding: .utf16BigEndian)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func markdownPassesPreflight(text: String, limits: MarkdownSafetyLimits) -> Bool {
|
|
var activeFence: Character?
|
|
var htmlTagCount = 0
|
|
var htmlDepth = 0
|
|
|
|
for rawLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
|
|
var line = String(rawLine)
|
|
if line.hasSuffix("\r") {
|
|
line.removeLast()
|
|
}
|
|
|
|
if (line as NSString).length > limits.maxLineLength {
|
|
return false
|
|
}
|
|
|
|
if let fenceMarker = markdownFenceMarker(in: line) {
|
|
if activeFence == fenceMarker {
|
|
activeFence = nil
|
|
} else {
|
|
activeFence = fenceMarker
|
|
}
|
|
continue
|
|
}
|
|
|
|
if activeFence != nil {
|
|
continue
|
|
}
|
|
|
|
if markdownBlockquoteDepth(in: line) > limits.maxBlockquoteDepth {
|
|
return false
|
|
}
|
|
if markdownIndentWidth(in: line) > limits.maxListIndent {
|
|
return false
|
|
}
|
|
if !markdownScanRawHTMLTags(in: line, limits: limits, tagCount: &htmlTagCount, depth: &htmlDepth) {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func markdownFenceMarker(in line: String) -> Character? {
|
|
let trimmed = line.drop(while: { $0 == " " || $0 == "\t" })
|
|
guard let marker = trimmed.first, marker == "`" || marker == "~" else {
|
|
return nil
|
|
}
|
|
var count = 0
|
|
var index = trimmed.startIndex
|
|
while index < trimmed.endIndex, trimmed[index] == marker {
|
|
count += 1
|
|
index = trimmed.index(after: index)
|
|
}
|
|
return count >= 3 ? marker : nil
|
|
}
|
|
|
|
private func markdownBlockquoteDepth(in line: String) -> Int {
|
|
var depth = 0
|
|
var index = line.startIndex
|
|
|
|
while index < line.endIndex {
|
|
switch line[index] {
|
|
case " ", "\t":
|
|
index = line.index(after: index)
|
|
case ">":
|
|
depth += 1
|
|
index = line.index(after: index)
|
|
if index < line.endIndex, (line[index] == " " || line[index] == "\t") {
|
|
index = line.index(after: index)
|
|
}
|
|
default:
|
|
return depth
|
|
}
|
|
}
|
|
|
|
return depth
|
|
}
|
|
|
|
private func markdownIndentWidth(in line: String) -> Int {
|
|
var width = 0
|
|
for character in line {
|
|
switch character {
|
|
case " ":
|
|
width += 1
|
|
case "\t":
|
|
width += 4
|
|
default:
|
|
return width
|
|
}
|
|
}
|
|
return width
|
|
}
|
|
|
|
private func markdownScanRawHTMLTags(in line: String, limits: MarkdownSafetyLimits, tagCount: inout Int, depth: inout Int) -> Bool {
|
|
let nsLine = line as NSString
|
|
let matches = markdownRawHTMLTagRegex.matches(in: line, range: NSRange(location: 0, length: nsLine.length))
|
|
|
|
for match in matches {
|
|
tagCount += 1
|
|
guard tagCount <= limits.maxRawHTMLTagCount else {
|
|
return false
|
|
}
|
|
|
|
let tagText = nsLine.substring(with: match.range)
|
|
let tagName = nsLine.substring(with: match.range(at: 1)).lowercased()
|
|
let lowercasedTag = tagText.lowercased()
|
|
|
|
if lowercasedTag.hasPrefix("</") {
|
|
depth = max(0, depth - 1)
|
|
} else if !lowercasedTag.hasSuffix("/>") && !markdownVoidHTMLTags.contains(tagName) {
|
|
depth += 1
|
|
guard depth <= limits.maxRawHTMLNestingDepth else {
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func markdownPreparedSource(text: String, limits: MarkdownSafetyLimits) -> MarkdownPreparedSource {
|
|
let lines = markdownLinesPreservingEndings(text)
|
|
|
|
var activeFence: Character?
|
|
var formulasByPlaceholder: [String: MarkdownFormulaDescriptor] = [:]
|
|
var acceptedFormulaCount = 0
|
|
var totalFormulaCharacters = 0
|
|
var nextPlaceholderIndex = 0
|
|
var result = ""
|
|
|
|
var lineIndex = 0
|
|
while lineIndex < lines.count {
|
|
let line = lines[lineIndex]
|
|
let (content, _) = markdownLineContentAndEnding(line)
|
|
|
|
if let fenceMarker = markdownFenceMarker(in: content) {
|
|
if activeFence == fenceMarker {
|
|
activeFence = nil
|
|
} else {
|
|
activeFence = fenceMarker
|
|
}
|
|
result.append(contentsOf: line)
|
|
lineIndex += 1
|
|
continue
|
|
}
|
|
|
|
if activeFence != nil {
|
|
result.append(contentsOf: line)
|
|
lineIndex += 1
|
|
continue
|
|
}
|
|
|
|
if let replacement = markdownBlockFormulaReplacement(
|
|
in: lines,
|
|
startLineIndex: lineIndex,
|
|
limits: limits,
|
|
acceptedFormulaCount: &acceptedFormulaCount,
|
|
totalFormulaCharacters: &totalFormulaCharacters,
|
|
nextPlaceholderIndex: &nextPlaceholderIndex
|
|
) {
|
|
result.append(contentsOf: replacement.replacement)
|
|
formulasByPlaceholder[replacement.descriptor.placeholder] = replacement.descriptor
|
|
lineIndex = replacement.nextLineIndex
|
|
continue
|
|
}
|
|
|
|
let processedLine = markdownReplacingInlineFormulas(
|
|
in: line,
|
|
limits: limits,
|
|
acceptedFormulaCount: &acceptedFormulaCount,
|
|
totalFormulaCharacters: &totalFormulaCharacters,
|
|
nextPlaceholderIndex: &nextPlaceholderIndex,
|
|
formulasByPlaceholder: &formulasByPlaceholder
|
|
)
|
|
result.append(contentsOf: processedLine)
|
|
lineIndex += 1
|
|
}
|
|
|
|
return MarkdownPreparedSource(text: result, formulasByPlaceholder: formulasByPlaceholder)
|
|
}
|
|
|
|
private func markdownLinesPreservingEndings(_ text: String) -> [String] {
|
|
guard !text.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
var result: [String] = []
|
|
var lineStart = text.startIndex
|
|
var index = text.startIndex
|
|
|
|
while index < text.endIndex {
|
|
if text[index] == "\n" {
|
|
let nextIndex = text.index(after: index)
|
|
result.append(String(text[lineStart ..< nextIndex]))
|
|
lineStart = nextIndex
|
|
}
|
|
index = text.index(after: index)
|
|
}
|
|
|
|
if lineStart < text.endIndex {
|
|
result.append(String(text[lineStart...]))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func markdownLineContentAndEnding(_ line: String) -> (content: String, ending: String) {
|
|
if line.hasSuffix("\r\n") {
|
|
return (String(line.dropLast(2)), "\r\n")
|
|
} else if line.hasSuffix("\n") {
|
|
return (String(line.dropLast()), "\n")
|
|
} else {
|
|
return (line, "")
|
|
}
|
|
}
|
|
|
|
private func markdownFormulaPlaceholder(_ index: Int) -> String {
|
|
return "TGMDMATH\(index)TGMD"
|
|
}
|
|
|
|
private func markdownAcceptedFormulaDescriptor(
|
|
latex: String,
|
|
mode: MarkdownFormulaMode,
|
|
limits: MarkdownSafetyLimits,
|
|
acceptedFormulaCount: inout Int,
|
|
totalFormulaCharacters: inout Int,
|
|
nextPlaceholderIndex: inout Int
|
|
) -> MarkdownFormulaDescriptor? {
|
|
let normalizedLatex: String
|
|
switch mode {
|
|
case .inline:
|
|
normalizedLatex = latex
|
|
case .block:
|
|
normalizedLatex = latex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
guard !normalizedLatex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let latexLength = (normalizedLatex as NSString).length
|
|
let maxLength = mode == .inline ? limits.maxInlineFormulaLength : limits.maxBlockFormulaLength
|
|
guard latexLength <= maxLength else {
|
|
return nil
|
|
}
|
|
guard acceptedFormulaCount < limits.maxFormulas else {
|
|
return nil
|
|
}
|
|
guard totalFormulaCharacters + latexLength <= limits.maxFormulaSourceCharacters else {
|
|
return nil
|
|
}
|
|
|
|
let placeholder = markdownFormulaPlaceholder(nextPlaceholderIndex)
|
|
nextPlaceholderIndex += 1
|
|
acceptedFormulaCount += 1
|
|
totalFormulaCharacters += latexLength
|
|
return MarkdownFormulaDescriptor(placeholder: placeholder, latex: normalizedLatex, mode: mode)
|
|
}
|
|
|
|
private func markdownBlockFormulaReplacement(
|
|
in lines: [String],
|
|
startLineIndex: Int,
|
|
limits: MarkdownSafetyLimits,
|
|
acceptedFormulaCount: inout Int,
|
|
totalFormulaCharacters: inout Int,
|
|
nextPlaceholderIndex: inout Int
|
|
) -> (replacement: String, nextLineIndex: Int, descriptor: MarkdownFormulaDescriptor)? {
|
|
guard startLineIndex >= 0, startLineIndex < lines.count else {
|
|
return nil
|
|
}
|
|
|
|
let (content, _) = markdownLineContentAndEnding(lines[startLineIndex])
|
|
let indentation = String(content.prefix { $0 == " " || $0 == "\t" })
|
|
let trimmedStart = content.drop(while: { $0 == " " || $0 == "\t" })
|
|
|
|
let opener: String
|
|
let closer: String
|
|
if trimmedStart.hasPrefix("$$") {
|
|
opener = "$$"
|
|
closer = "$$"
|
|
} else if trimmedStart.hasPrefix("\\[") {
|
|
opener = "\\["
|
|
closer = "\\]"
|
|
} else {
|
|
return nil
|
|
}
|
|
|
|
let openerContent = String(trimmedStart.dropFirst(opener.count))
|
|
var latex = ""
|
|
|
|
if let closeRange = markdownFirstUnescapedRange(of: closer, in: openerContent, from: openerContent.startIndex) {
|
|
let trailing = String(openerContent[closeRange.upperBound...])
|
|
guard trailing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return nil
|
|
}
|
|
latex = String(openerContent[..<closeRange.lowerBound])
|
|
guard let descriptor = markdownAcceptedFormulaDescriptor(
|
|
latex: latex,
|
|
mode: .block,
|
|
limits: limits,
|
|
acceptedFormulaCount: &acceptedFormulaCount,
|
|
totalFormulaCharacters: &totalFormulaCharacters,
|
|
nextPlaceholderIndex: &nextPlaceholderIndex
|
|
) else {
|
|
return nil
|
|
}
|
|
let (_, ending) = markdownLineContentAndEnding(lines[startLineIndex])
|
|
return (indentation + descriptor.placeholder + ending, startLineIndex + 1, descriptor)
|
|
}
|
|
|
|
let (_, openerEnding) = markdownLineContentAndEnding(lines[startLineIndex])
|
|
latex.append(contentsOf: openerContent)
|
|
latex.append(contentsOf: openerEnding)
|
|
|
|
var currentIndex = startLineIndex + 1
|
|
while currentIndex < lines.count {
|
|
let (nextContent, nextEnding) = markdownLineContentAndEnding(lines[currentIndex])
|
|
if let closeRange = markdownFirstUnescapedRange(of: closer, in: nextContent, from: nextContent.startIndex) {
|
|
let trailing = String(nextContent[closeRange.upperBound...])
|
|
guard trailing.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
|
return nil
|
|
}
|
|
latex.append(contentsOf: String(nextContent[..<closeRange.lowerBound]))
|
|
guard let descriptor = markdownAcceptedFormulaDescriptor(
|
|
latex: latex,
|
|
mode: .block,
|
|
limits: limits,
|
|
acceptedFormulaCount: &acceptedFormulaCount,
|
|
totalFormulaCharacters: &totalFormulaCharacters,
|
|
nextPlaceholderIndex: &nextPlaceholderIndex
|
|
) else {
|
|
return nil
|
|
}
|
|
return (indentation + descriptor.placeholder + nextEnding, currentIndex + 1, descriptor)
|
|
} else {
|
|
latex.append(contentsOf: nextContent)
|
|
latex.append(contentsOf: nextEnding)
|
|
}
|
|
currentIndex += 1
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private func markdownReplacingInlineFormulas(
|
|
in line: String,
|
|
limits: MarkdownSafetyLimits,
|
|
acceptedFormulaCount: inout Int,
|
|
totalFormulaCharacters: inout Int,
|
|
nextPlaceholderIndex: inout Int,
|
|
formulasByPlaceholder: inout [String: MarkdownFormulaDescriptor]
|
|
) -> String {
|
|
let (content, ending) = markdownLineContentAndEnding(line)
|
|
guard !content.isEmpty else {
|
|
return line
|
|
}
|
|
|
|
var result = ""
|
|
var index = content.startIndex
|
|
var activeCodeDelimiterLength: Int?
|
|
|
|
while index < content.endIndex {
|
|
if content[index] == "`" {
|
|
let delimiterEnd = markdownIndex(afterRepeating: "`", in: content, from: index)
|
|
let delimiterLength = content.distance(from: index, to: delimiterEnd)
|
|
result.append(contentsOf: content[index ..< delimiterEnd])
|
|
if activeCodeDelimiterLength == delimiterLength {
|
|
activeCodeDelimiterLength = nil
|
|
} else {
|
|
activeCodeDelimiterLength = delimiterLength
|
|
}
|
|
index = delimiterEnd
|
|
continue
|
|
}
|
|
|
|
if activeCodeDelimiterLength != nil {
|
|
result.append(content[index])
|
|
index = content.index(after: index)
|
|
continue
|
|
}
|
|
|
|
if content[index] == "\\", !markdownIsEscaped(content, at: index), let nextIndex = markdownIndex(content, offsetBy: 1, from: index), nextIndex < content.endIndex, content[nextIndex] == "(" {
|
|
let bodyStart = content.index(after: nextIndex)
|
|
if let closeRange = markdownFirstUnescapedRange(of: "\\)", in: content, from: bodyStart) {
|
|
let latex = String(content[bodyStart ..< closeRange.lowerBound])
|
|
if let descriptor = markdownAcceptedFormulaDescriptor(
|
|
latex: latex,
|
|
mode: .inline,
|
|
limits: limits,
|
|
acceptedFormulaCount: &acceptedFormulaCount,
|
|
totalFormulaCharacters: &totalFormulaCharacters,
|
|
nextPlaceholderIndex: &nextPlaceholderIndex
|
|
) {
|
|
result.append(contentsOf: descriptor.placeholder)
|
|
formulasByPlaceholder[descriptor.placeholder] = descriptor
|
|
index = closeRange.upperBound
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
if content[index] == "$", !markdownIsEscaped(content, at: index) {
|
|
guard let bodyStart = markdownIndex(content, offsetBy: 1, from: index), bodyStart < content.endIndex else {
|
|
result.append(content[index])
|
|
index = content.index(after: index)
|
|
continue
|
|
}
|
|
if content[bodyStart] == "$" || content[bodyStart].isWhitespace {
|
|
result.append(content[index])
|
|
index = content.index(after: index)
|
|
continue
|
|
}
|
|
|
|
var searchIndex = bodyStart
|
|
var matchedRange: Range<String.Index>?
|
|
while let closeRange = markdownFirstUnescapedRange(of: "$", in: content, from: searchIndex) {
|
|
let closerIndex = closeRange.lowerBound
|
|
let previousIndex = content.index(before: closerIndex)
|
|
if !content[previousIndex].isWhitespace {
|
|
matchedRange = closeRange
|
|
break
|
|
}
|
|
searchIndex = closeRange.upperBound
|
|
}
|
|
|
|
if let matchedRange {
|
|
let latex = String(content[bodyStart ..< matchedRange.lowerBound])
|
|
if let descriptor = markdownAcceptedFormulaDescriptor(
|
|
latex: latex,
|
|
mode: .inline,
|
|
limits: limits,
|
|
acceptedFormulaCount: &acceptedFormulaCount,
|
|
totalFormulaCharacters: &totalFormulaCharacters,
|
|
nextPlaceholderIndex: &nextPlaceholderIndex
|
|
) {
|
|
result.append(contentsOf: descriptor.placeholder)
|
|
formulasByPlaceholder[descriptor.placeholder] = descriptor
|
|
index = matchedRange.upperBound
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
result.append(content[index])
|
|
index = content.index(after: index)
|
|
}
|
|
|
|
result.append(contentsOf: ending)
|
|
return result
|
|
}
|
|
|
|
private func markdownFirstUnescapedRange(of pattern: String, in text: String, from startIndex: String.Index) -> Range<String.Index>? {
|
|
guard !pattern.isEmpty, startIndex <= text.endIndex else {
|
|
return nil
|
|
}
|
|
|
|
var searchIndex = startIndex
|
|
while searchIndex < text.endIndex {
|
|
guard let range = text.range(of: pattern, range: searchIndex ..< text.endIndex) else {
|
|
return nil
|
|
}
|
|
if !markdownIsEscaped(text, at: range.lowerBound) {
|
|
return range
|
|
}
|
|
searchIndex = range.upperBound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func markdownIsEscaped(_ text: String, at index: String.Index) -> Bool {
|
|
guard index > text.startIndex else {
|
|
return false
|
|
}
|
|
|
|
var slashCount = 0
|
|
var currentIndex = text.index(before: index)
|
|
while true {
|
|
if text[currentIndex] == "\\" {
|
|
slashCount += 1
|
|
} else {
|
|
break
|
|
}
|
|
guard currentIndex > text.startIndex else {
|
|
break
|
|
}
|
|
currentIndex = text.index(before: currentIndex)
|
|
}
|
|
|
|
return slashCount % 2 == 1
|
|
}
|
|
|
|
private func markdownIndex(_ text: String, offsetBy distance: Int, from index: String.Index) -> String.Index? {
|
|
guard distance >= 0 else {
|
|
return nil
|
|
}
|
|
return text.index(index, offsetBy: distance, limitedBy: text.endIndex)
|
|
}
|
|
|
|
private func markdownIndex(afterRepeating character: Character, in text: String, from startIndex: String.Index) -> String.Index {
|
|
var index = startIndex
|
|
while index < text.endIndex, text[index] == character {
|
|
index = text.index(after: index)
|
|
}
|
|
return index
|
|
}
|
|
|
|
func markdownWebpage(context: AccountContext, file: FileMediaReference) -> (webPage: TelegramMediaWebpage, fileURL: URL)? {
|
|
guard #available(iOS 15.0, *) else {
|
|
return nil
|
|
}
|
|
guard let path = context.engine.resources.completedResourcePath(id: EngineMediaResource.Id(file.media.resource.id)) else {
|
|
return nil
|
|
}
|
|
let fileURL = URL(fileURLWithPath: path)
|
|
guard let data = try? Data(contentsOf: fileURL) else {
|
|
return nil
|
|
}
|
|
guard let webPage = markdownWebpage(context: context, file: file, fileURL: fileURL, data: data) else {
|
|
return nil
|
|
}
|
|
return (webPage, fileURL)
|
|
}
|
|
|
|
@available(iOS 15.0, *)
|
|
private func markdownWebpage(context: AccountContext, file: FileMediaReference, fileURL: URL, data: Data) -> TelegramMediaWebpage? {
|
|
let limits = markdownSafetyLimits
|
|
guard markdownPassesPreflight(data: data, limits: limits) else {
|
|
return nil
|
|
}
|
|
guard let sourceText = markdownDecodedSourceText(data) else {
|
|
return nil
|
|
}
|
|
let preparedSource = markdownPreparedSource(text: sourceText, limits: limits)
|
|
|
|
let attributedString: NSAttributedString
|
|
do {
|
|
attributedString = try NSAttributedString(
|
|
markdown: Data(preparedSource.text.utf8),
|
|
options: .init(),
|
|
baseURL: fileURL.deletingLastPathComponent()
|
|
)
|
|
} catch {
|
|
return nil
|
|
}
|
|
|
|
let budget = MarkdownConversionBudget(limits: limits)
|
|
let conversionContext = MarkdownConversionContext(context: context, documentURL: fileURL, formulasByPlaceholder: preparedSource.formulasByPlaceholder, budget: budget)
|
|
guard let pageResult = markdownPageResult(from: attributedString, context: conversionContext) else {
|
|
return nil
|
|
}
|
|
let blocks = markdownBlocksWithGeneratedAnchors(pageResult.blocks)
|
|
guard !blocks.isEmpty, budget.validateFinalBlocks(blocks) else {
|
|
return nil
|
|
}
|
|
|
|
let title = markdownTitle(from: blocks, file: file, fileURL: fileURL)
|
|
let text = markdownFirstParagraphText(from: blocks)
|
|
let instantPage = InstantPage(
|
|
blocks: blocks,
|
|
media: pageResult.media,
|
|
isComplete: true,
|
|
rtl: false,
|
|
url: fileURL.absoluteString,
|
|
views: nil
|
|
)
|
|
|
|
return TelegramMediaWebpage(
|
|
webpageId: MediaId(namespace: 0, id: 0),
|
|
content: .Loaded(
|
|
TelegramMediaWebpageLoadedContent(
|
|
url: fileURL.absoluteString,
|
|
displayUrl: fileURL.absoluteString,
|
|
hash: 0,
|
|
type: "article",
|
|
websiteName: nil,
|
|
title: title,
|
|
text: text,
|
|
embedUrl: nil,
|
|
embedType: nil,
|
|
embedSize: nil,
|
|
duration: nil,
|
|
author: nil,
|
|
isMediaLargeByDefault: nil,
|
|
imageIsVideoCover: false,
|
|
image: nil,
|
|
file: nil,
|
|
story: nil,
|
|
attributes: [],
|
|
instantPage: instantPage
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
@available(iOS 15.0, *)
|
|
private func markdownPageResult(from attributedString: NSAttributedString, context: MarkdownConversionContext) -> MarkdownPageResult? {
|
|
guard context.budget.registerAttributedStringLength(attributedString.length) else {
|
|
return nil
|
|
}
|
|
|
|
var nodesByIdentity: [Int: MarkdownIntentNode] = [:]
|
|
var rootNodes: [MarkdownIntentNode] = []
|
|
var rootIdentities: Set<Int> = []
|
|
var didAbort = false
|
|
|
|
attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: []) { attributes, range, stop in
|
|
guard range.length > 0 else {
|
|
return
|
|
}
|
|
guard context.budget.registerAttributeRun() else {
|
|
didAbort = true
|
|
stop.pointee = true
|
|
return
|
|
}
|
|
guard let presentationIntent = attributes[markdownPresentationIntentAttribute] as? PresentationIntent else {
|
|
return
|
|
}
|
|
let components = presentationIntent.components
|
|
guard !components.isEmpty else {
|
|
return
|
|
}
|
|
guard context.budget.registerPresentationIntentDepth(components.count) else {
|
|
didAbort = true
|
|
stop.pointee = true
|
|
return
|
|
}
|
|
|
|
var orderedNodes: [MarkdownIntentNode] = []
|
|
for component in components.reversed() {
|
|
let node: MarkdownIntentNode
|
|
if let current = nodesByIdentity[component.identity] {
|
|
node = current
|
|
} else {
|
|
guard context.budget.registerIntentNode() else {
|
|
didAbort = true
|
|
stop.pointee = true
|
|
return
|
|
}
|
|
let created = MarkdownIntentNode(component: component)
|
|
nodesByIdentity[component.identity] = created
|
|
node = created
|
|
}
|
|
orderedNodes.append(node)
|
|
}
|
|
|
|
if let rootNode = orderedNodes.first, rootIdentities.insert(rootNode.identity).inserted {
|
|
rootNodes.append(rootNode)
|
|
}
|
|
if orderedNodes.count >= 2 {
|
|
for index in 0 ..< (orderedNodes.count - 1) {
|
|
orderedNodes[index].append(child: orderedNodes[index + 1])
|
|
}
|
|
}
|
|
if let leafNode = orderedNodes.last {
|
|
leafNode.append(text: attributedString.attributedSubstring(from: range))
|
|
}
|
|
}
|
|
|
|
guard !didAbort, !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
guard let blocks = markdownBlocks(from: rootNodes, context: context, depth: 0), !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
return context.makePageResult(blocks: blocks)
|
|
}
|
|
|
|
private func markdownBlocks(from nodes: [MarkdownIntentNode], context: MarkdownConversionContext, depth: Int) -> [InstantPageBlock]? {
|
|
guard context.budget.registerBlockDepth(depth), !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
|
|
var result: [InstantPageBlock] = []
|
|
for node in nodes {
|
|
guard let blocks = markdownBlocks(from: node, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
result.append(contentsOf: blocks)
|
|
if context.budget.isExceeded {
|
|
return nil
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func markdownBlocks(from node: MarkdownIntentNode, context: MarkdownConversionContext, depth: Int) -> [InstantPageBlock]? {
|
|
guard context.budget.registerBlockDepth(depth), !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
|
|
switch node.kind {
|
|
case let .table(alignments):
|
|
guard let rows = markdownTableRows(from: node.children, alignments: alignments, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
guard !rows.isEmpty else {
|
|
return []
|
|
}
|
|
return [.table(title: .empty, rows: rows, bordered: true, striped: false)]
|
|
case let .header(level):
|
|
guard let text = markdownRichText(from: node.attributedText, context: context) else {
|
|
return nil
|
|
}
|
|
guard markdownHasDisplayableContent(text) else {
|
|
return []
|
|
}
|
|
switch level {
|
|
case Int.min ... 1:
|
|
return [.title(text)]
|
|
default:
|
|
return [.heading(text: text, level: Int32(max(2, min(level, 6))))]
|
|
}
|
|
case .paragraph:
|
|
guard let inlineContent = markdownInlineContent(from: node.attributedText, context: context) else {
|
|
return nil
|
|
}
|
|
if let formula = inlineContent.standaloneBlockFormula {
|
|
return [.formula(latex: formula.latex)]
|
|
}
|
|
if let image = inlineContent.standaloneImage {
|
|
return [
|
|
.image(
|
|
id: image.mediaId,
|
|
caption: image.caption,
|
|
url: image.linkUrl,
|
|
webpageId: nil
|
|
)
|
|
]
|
|
}
|
|
let text = inlineContent.richText
|
|
guard markdownHasDisplayableContent(text) else {
|
|
return []
|
|
}
|
|
return [.paragraph(text)]
|
|
case let .codeBlock(languageHint):
|
|
guard let text = markdownRichText(from: markdownTrimTrailingCodeBlockNewline(node.attributedText), context: context) else {
|
|
return nil
|
|
}
|
|
guard markdownHasDisplayableContent(text) else {
|
|
return []
|
|
}
|
|
return [.preformatted(text: text, language: markdownNormalizedCodeBlockLanguage(languageHint))]
|
|
case .thematicBreak:
|
|
return [.divider]
|
|
case .blockQuote:
|
|
var result: [InstantPageBlock] = []
|
|
for child in node.children {
|
|
guard let childBlocks = markdownBlocks(from: child, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
for childBlock in childBlocks {
|
|
switch childBlock {
|
|
case let .paragraph(text):
|
|
result.append(.blockQuote(text: text, caption: .empty))
|
|
default:
|
|
let plainText = markdownPlainText(from: childBlock, depth: depth + 1)
|
|
if !plainText.isEmpty {
|
|
result.append(.blockQuote(text: .plain(plainText), caption: .empty))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
case .orderedList:
|
|
guard let items = markdownListItems(from: node.children, ordered: true, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
guard !items.isEmpty else {
|
|
return []
|
|
}
|
|
return [.list(items: items, ordered: true)]
|
|
case .unorderedList:
|
|
guard let items = markdownListItems(from: node.children, ordered: false, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
guard !items.isEmpty else {
|
|
return []
|
|
}
|
|
return [.list(items: items, ordered: false)]
|
|
case .listItem(_), .tableHeaderRow, .tableRow, .tableCell(_), .unknown:
|
|
return markdownBlocks(from: node.children, context: context, depth: depth + 1)
|
|
}
|
|
}
|
|
|
|
private func markdownListItems(from nodes: [MarkdownIntentNode], ordered: Bool, context: MarkdownConversionContext, depth: Int) -> [InstantPageListItem]? {
|
|
guard context.budget.registerBlockDepth(depth), !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
|
|
var result: [InstantPageListItem] = []
|
|
for node in nodes {
|
|
guard case let .listItem(ordinal) = node.kind else {
|
|
continue
|
|
}
|
|
guard var blocks = markdownBlocks(from: node.children, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
let taskListState = markdownApplyTaskListMarker(to: &blocks)
|
|
let number: String?
|
|
if let taskListState {
|
|
number = markdownTaskListNumber(for: taskListState)
|
|
} else if ordered {
|
|
number = "\(ordinal)"
|
|
} else {
|
|
number = nil
|
|
}
|
|
if blocks.isEmpty {
|
|
if let number {
|
|
result.append(.text(.plain(" "), number))
|
|
}
|
|
continue
|
|
}
|
|
if blocks.count == 1, case let .paragraph(text) = blocks[0] {
|
|
if number != nil && markdownIsWhitespaceOnly(text) {
|
|
result.append(.text(.plain(" "), number))
|
|
} else {
|
|
result.append(.text(text, number))
|
|
}
|
|
} else {
|
|
result.append(.blocks(blocks, number))
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func markdownTaskListNumber(for state: MarkdownTaskListState) -> String {
|
|
switch state {
|
|
case .unchecked:
|
|
return markdownTaskListUncheckedNumber
|
|
case .checked:
|
|
return markdownTaskListCheckedNumber
|
|
}
|
|
}
|
|
|
|
private func markdownApplyTaskListMarker(to blocks: inout [InstantPageBlock]) -> MarkdownTaskListState? {
|
|
guard !blocks.isEmpty, case let .paragraph(text) = blocks[0] else {
|
|
return nil
|
|
}
|
|
guard let (state, strippedText) = markdownStrippingTaskListMarker(from: text) else {
|
|
return nil
|
|
}
|
|
if blocks.count > 1 && markdownIsWhitespaceOnly(strippedText) {
|
|
blocks.removeFirst()
|
|
} else {
|
|
blocks[0] = .paragraph(strippedText)
|
|
}
|
|
return state
|
|
}
|
|
|
|
private func markdownStrippingTaskListMarker(from text: RichText) -> (MarkdownTaskListState, RichText)? {
|
|
guard let (state, markerLength) = markdownTaskListMarker(in: text.plainText) else {
|
|
return nil
|
|
}
|
|
return (state, markdownDroppingPrefixLength(markerLength, from: text))
|
|
}
|
|
|
|
private func markdownTaskListMarker(in plainText: String) -> (MarkdownTaskListState, Int)? {
|
|
switch plainText {
|
|
case _ where plainText.hasPrefix("[ ] "):
|
|
return (.unchecked, 4)
|
|
case "[ ]":
|
|
return (.unchecked, 3)
|
|
case _ where plainText.hasPrefix("[x] "), _ where plainText.hasPrefix("[X] "):
|
|
return (.checked, 4)
|
|
case "[x]", "[X]":
|
|
return (.checked, 3)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func markdownTableRows(from nodes: [MarkdownIntentNode], alignments: [TableHorizontalAlignment], context: MarkdownConversionContext, depth: Int) -> [InstantPageTableRow]? {
|
|
guard context.budget.registerBlockDepth(depth), !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
|
|
var result: [InstantPageTableRow] = []
|
|
for node in nodes {
|
|
switch node.kind {
|
|
case .tableHeaderRow:
|
|
guard let cells = markdownTableCells(from: node.children, alignments: alignments, header: true, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
if !cells.isEmpty {
|
|
result.append(InstantPageTableRow(cells: cells))
|
|
}
|
|
case .tableRow:
|
|
guard let cells = markdownTableCells(from: node.children, alignments: alignments, header: false, context: context, depth: depth + 1) else {
|
|
return nil
|
|
}
|
|
if !cells.isEmpty {
|
|
result.append(InstantPageTableRow(cells: cells))
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
}
|
|
return context.budget.isExceeded ? nil : result
|
|
}
|
|
|
|
private func markdownTableCells(from nodes: [MarkdownIntentNode], alignments: [TableHorizontalAlignment], header: Bool, context: MarkdownConversionContext, depth: Int) -> [InstantPageTableCell]? {
|
|
guard context.budget.registerBlockDepth(depth), !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
|
|
let maxColumnIndex = nodes.reduce(-1) { partialResult, node in
|
|
if case let .tableCell(column) = node.kind {
|
|
return max(partialResult, column)
|
|
} else {
|
|
return partialResult
|
|
}
|
|
}
|
|
let columnCount = max(alignments.count, maxColumnIndex + 1)
|
|
guard columnCount > 0 else {
|
|
return []
|
|
}
|
|
guard context.budget.registerTableColumns(columnCount) else {
|
|
return nil
|
|
}
|
|
|
|
var result: [InstantPageTableCell] = []
|
|
var nextColumn = 0
|
|
|
|
for node in nodes {
|
|
guard case let .tableCell(column) = node.kind else {
|
|
continue
|
|
}
|
|
|
|
while nextColumn < column {
|
|
result.append(markdownEmptyTableCell(header: header, alignment: markdownTableAlignment(at: nextColumn, from: alignments)))
|
|
nextColumn += 1
|
|
}
|
|
|
|
guard let text = markdownRichText(from: node.attributedText, context: context) else {
|
|
return nil
|
|
}
|
|
result.append(
|
|
InstantPageTableCell(
|
|
text: text,
|
|
header: header,
|
|
alignment: markdownTableAlignment(at: column, from: alignments),
|
|
verticalAlignment: .top,
|
|
colspan: 1,
|
|
rowspan: 1
|
|
)
|
|
)
|
|
nextColumn = max(nextColumn, column + 1)
|
|
}
|
|
|
|
while nextColumn < columnCount {
|
|
result.append(markdownEmptyTableCell(header: header, alignment: markdownTableAlignment(at: nextColumn, from: alignments)))
|
|
nextColumn += 1
|
|
}
|
|
|
|
guard context.budget.registerTableCells(result.count) else {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func markdownEmptyTableCell(header: Bool, alignment: TableHorizontalAlignment) -> InstantPageTableCell {
|
|
return InstantPageTableCell(
|
|
text: .empty,
|
|
header: header,
|
|
alignment: alignment,
|
|
verticalAlignment: .top,
|
|
colspan: 1,
|
|
rowspan: 1
|
|
)
|
|
}
|
|
|
|
private func markdownTableAlignment(at index: Int, from alignments: [TableHorizontalAlignment]) -> TableHorizontalAlignment {
|
|
guard index >= 0, index < alignments.count else {
|
|
return .left
|
|
}
|
|
return alignments[index]
|
|
}
|
|
|
|
private func markdownRichText(from attributedString: NSAttributedString, context: MarkdownConversionContext) -> RichText? {
|
|
return markdownInlineContent(from: attributedString, context: context)?.richText
|
|
}
|
|
|
|
private func markdownInlineContent(from attributedString: NSAttributedString, context: MarkdownConversionContext) -> MarkdownInlineContent? {
|
|
guard attributedString.length > 0, #available(iOS 15.0, *) else {
|
|
return MarkdownInlineContent(fragments: [])
|
|
}
|
|
|
|
var fragments: [MarkdownInlineFragment] = []
|
|
var htmlStyles: [MarkdownHTMLInlineStyle] = []
|
|
var consumeNextSoftBreak = false
|
|
var didAbort = false
|
|
|
|
attributedString.enumerateAttributes(in: NSRange(location: 0, length: attributedString.length), options: []) { attributes, range, stop in
|
|
guard range.length > 0 else {
|
|
return
|
|
}
|
|
if context.budget.isExceeded {
|
|
didAbort = true
|
|
stop.pointee = true
|
|
return
|
|
}
|
|
|
|
let text = attributedString.attributedSubstring(from: range).string
|
|
guard !text.isEmpty else {
|
|
return
|
|
}
|
|
|
|
let inlineIntent: InlinePresentationIntent?
|
|
if let inlineIntentValue = attributes[markdownInlinePresentationIntentAttribute] as? InlinePresentationIntent {
|
|
inlineIntent = inlineIntentValue
|
|
} else if let inlineIntentValue = attributes[markdownInlinePresentationIntentAttribute] as? NSNumber {
|
|
inlineIntent = InlinePresentationIntent(rawValue: inlineIntentValue.uintValue)
|
|
} else {
|
|
inlineIntent = nil
|
|
}
|
|
|
|
if let inlineIntent, inlineIntent.contains(markdownInlineHTMLInlineIntent), let directive = markdownHTMLDirective(for: text) {
|
|
switch directive {
|
|
case let .open(style):
|
|
htmlStyles.append(style)
|
|
guard context.budget.registerInlineHTMLStyleDepth(htmlStyles.count) else {
|
|
didAbort = true
|
|
stop.pointee = true
|
|
return
|
|
}
|
|
case let .close(style):
|
|
if let index = htmlStyles.lastIndex(of: style) {
|
|
htmlStyles.remove(at: index)
|
|
}
|
|
case .lineBreak:
|
|
fragments.append(.richText(markdownApplyHTMLStyles(htmlStyles, to: .plain("\n"))))
|
|
consumeNextSoftBreak = true
|
|
}
|
|
return
|
|
}
|
|
|
|
if consumeNextSoftBreak {
|
|
if let inlineIntent, inlineIntent.contains(markdownSoftBreakInlineIntent), text == " " {
|
|
consumeNextSoftBreak = false
|
|
return
|
|
}
|
|
consumeNextSoftBreak = false
|
|
}
|
|
|
|
if let image = context.resolveImage(attributes: attributes) {
|
|
fragments.append(.image(image))
|
|
return
|
|
}
|
|
|
|
let segments = markdownInlineTextSegments(from: text, formulasByPlaceholder: context.formulasByPlaceholder)
|
|
for segment in segments {
|
|
let baseText: RichText
|
|
let descriptor: MarkdownFormulaDescriptor?
|
|
switch segment {
|
|
case let .plain(plainText):
|
|
baseText = .plain(plainText)
|
|
descriptor = nil
|
|
case let .formula(formulaDescriptor):
|
|
baseText = .formula(latex: formulaDescriptor.latex)
|
|
descriptor = formulaDescriptor
|
|
}
|
|
|
|
var fragment = markdownApplyingInlineIntent(inlineIntent, to: baseText)
|
|
if let url = markdownLink(attributes: attributes, documentURL: context.documentURL) {
|
|
fragment = .url(text: fragment, url: url, webpageId: nil)
|
|
}
|
|
fragment = markdownApplyHTMLStyles(htmlStyles, to: fragment)
|
|
|
|
if let descriptor {
|
|
fragments.append(.formula(descriptor, fragment))
|
|
} else {
|
|
fragments.append(.richText(fragment))
|
|
}
|
|
}
|
|
}
|
|
|
|
guard !didAbort, !context.budget.isExceeded else {
|
|
return nil
|
|
}
|
|
return MarkdownInlineContent(fragments: fragments)
|
|
}
|
|
|
|
private func markdownInlineTextSegments(from text: String, formulasByPlaceholder: [String: MarkdownFormulaDescriptor]) -> [MarkdownInlineTextSegment] {
|
|
guard !text.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
let nsText = text as NSString
|
|
let matches = markdownFormulaPlaceholderRegex.matches(in: text, range: NSRange(location: 0, length: nsText.length))
|
|
guard !matches.isEmpty else {
|
|
return [.plain(text)]
|
|
}
|
|
|
|
var result: [MarkdownInlineTextSegment] = []
|
|
var currentLocation = 0
|
|
|
|
for match in matches {
|
|
if match.range.location > currentLocation {
|
|
result.append(.plain(nsText.substring(with: NSRange(location: currentLocation, length: match.range.location - currentLocation))))
|
|
}
|
|
|
|
let placeholder = nsText.substring(with: match.range)
|
|
if let descriptor = formulasByPlaceholder[placeholder] {
|
|
result.append(.formula(descriptor))
|
|
} else {
|
|
result.append(.plain(placeholder))
|
|
}
|
|
currentLocation = match.range.location + match.range.length
|
|
}
|
|
|
|
if currentLocation < nsText.length {
|
|
result.append(.plain(nsText.substring(from: currentLocation)))
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
@available(iOS 15.0, *)
|
|
private func markdownApplyingInlineIntent(_ inlineIntent: InlinePresentationIntent?, to text: RichText) -> RichText {
|
|
guard let inlineIntent else {
|
|
return text
|
|
}
|
|
|
|
var result = text
|
|
if inlineIntent.contains(.stronglyEmphasized) {
|
|
result = .bold(result)
|
|
}
|
|
if inlineIntent.contains(.emphasized) {
|
|
result = .italic(result)
|
|
}
|
|
if inlineIntent.contains(.strikethrough) {
|
|
result = .strikethrough(result)
|
|
}
|
|
if inlineIntent.contains(.code) {
|
|
result = .fixed(result)
|
|
}
|
|
if inlineIntent.contains(markdownHardBreakInlineIntent) {
|
|
result = .plain("\n")
|
|
}
|
|
return result
|
|
}
|
|
|
|
private enum MarkdownHTMLInlineStyle: Equatable {
|
|
case underline
|
|
case `subscript`
|
|
case superscript
|
|
case marked
|
|
}
|
|
|
|
private enum MarkdownHTMLDirective {
|
|
case open(MarkdownHTMLInlineStyle)
|
|
case close(MarkdownHTMLInlineStyle)
|
|
case lineBreak
|
|
}
|
|
|
|
private func markdownHTMLDirective(for text: String) -> MarkdownHTMLDirective? {
|
|
switch text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
|
|
case "<u>":
|
|
return .open(.underline)
|
|
case "</u>":
|
|
return .close(.underline)
|
|
case "<sub>":
|
|
return .open(.subscript)
|
|
case "</sub>":
|
|
return .close(.subscript)
|
|
case "<sup>":
|
|
return .open(.superscript)
|
|
case "</sup>":
|
|
return .close(.superscript)
|
|
case "<mark>":
|
|
return .open(.marked)
|
|
case "</mark>":
|
|
return .close(.marked)
|
|
case "<br>", "<br/>", "<br />":
|
|
return .lineBreak
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func markdownApplyHTMLStyles(_ styles: [MarkdownHTMLInlineStyle], to text: RichText) -> RichText {
|
|
var result = text
|
|
for style in styles {
|
|
switch style {
|
|
case .underline:
|
|
result = .underline(result)
|
|
case .subscript:
|
|
result = .subscript(result)
|
|
case .superscript:
|
|
result = .superscript(result)
|
|
case .marked:
|
|
result = .marked(result)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
private func markdownAlternateDescription(attributes: [NSAttributedString.Key: Any]) -> String? {
|
|
if let value = attributes[markdownAlternateDescriptionAttribute] as? String, !value.isEmpty {
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func markdownImageURL(attributes: [NSAttributedString.Key: Any]) -> String? {
|
|
if let value = attributes[markdownImageURLAttribute] as? URL {
|
|
return value.absoluteString
|
|
}
|
|
if let value = attributes[markdownImageURLAttribute] as? NSURL {
|
|
return (value as URL).absoluteString
|
|
}
|
|
if let value = attributes[markdownImageURLAttribute] as? String, !value.isEmpty {
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func markdownResolveImageSource(_ value: String, limits: MarkdownSafetyLimits) -> MarkdownResolvedImageSource {
|
|
if value.hasPrefix("//") {
|
|
return .remote("https:\(value)")
|
|
}
|
|
|
|
if value.lowercased().hasPrefix("data:") {
|
|
return markdownResolveDataImageSource(value, limits: limits)
|
|
}
|
|
|
|
guard let url = URL(string: value), let scheme = url.scheme?.lowercased() else {
|
|
return .unsupported
|
|
}
|
|
|
|
switch scheme {
|
|
case "http", "https":
|
|
return .remote(url.absoluteString)
|
|
case "data":
|
|
return markdownResolveDataImageSource(url.absoluteString, limits: limits)
|
|
default:
|
|
return .unsupported
|
|
}
|
|
}
|
|
|
|
private func markdownResolveDataImageSource(_ value: String, limits: MarkdownSafetyLimits) -> MarkdownResolvedImageSource {
|
|
guard value.lowercased().hasPrefix("data:"),
|
|
let commaIndex = value.firstIndex(of: ",") else {
|
|
return .unsupported
|
|
}
|
|
|
|
let header = String(value[value.index(value.startIndex, offsetBy: 5) ..< commaIndex])
|
|
let payloadStart = value.index(after: commaIndex)
|
|
let payload = String(value[payloadStart...])
|
|
let isBase64 = header.lowercased().contains(";base64")
|
|
|
|
let data: Data?
|
|
if isBase64 {
|
|
data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters])
|
|
} else if let decodedPayload = payload.removingPercentEncoding {
|
|
data = decodedPayload.data(using: .utf8)
|
|
} else {
|
|
data = nil
|
|
}
|
|
|
|
guard let data = data, data.count <= limits.maxDataImageBytes else {
|
|
return .unsupported
|
|
}
|
|
|
|
guard let image = UIImage(data: data),
|
|
let dimensions = markdownImagePixelDimensions(image) else {
|
|
return .unsupported
|
|
}
|
|
|
|
let pixelCount = Int64(dimensions.width) * Int64(dimensions.height)
|
|
guard pixelCount <= Int64(limits.maxDataImagePixelCount) else {
|
|
return .unsupported
|
|
}
|
|
|
|
return .data(data, dimensions)
|
|
}
|
|
|
|
private func markdownImagePixelDimensions(_ image: UIImage) -> PixelDimensions? {
|
|
if let cgImage = image.cgImage {
|
|
return PixelDimensions(width: Int32(cgImage.width), height: Int32(cgImage.height))
|
|
}
|
|
|
|
let width = max(1, Int32(ceil(image.size.width * image.scale)))
|
|
let height = max(1, Int32(ceil(image.size.height * image.scale)))
|
|
return PixelDimensions(width: width, height: height)
|
|
}
|
|
|
|
private func markdownImageCaption(_ title: String?) -> InstantPageCaption {
|
|
if let title, !title.isEmpty {
|
|
return InstantPageCaption(text: .plain(title), credit: .empty)
|
|
} else {
|
|
return InstantPageCaption(text: .empty, credit: .empty)
|
|
}
|
|
}
|
|
|
|
private func markdownInlineImageDimensions(attributes: [NSAttributedString.Key: Any]) -> PixelDimensions {
|
|
guard let font = attributes[.font] as? UIFont else {
|
|
return markdownDefaultInlineImageDimensions
|
|
}
|
|
|
|
let side = max(markdownDefaultInlineImageDimensions.width, Int32(ceil(font.lineHeight)))
|
|
return PixelDimensions(width: side, height: side)
|
|
}
|
|
|
|
private func markdownLink(attributes: [NSAttributedString.Key: Any], documentURL: URL) -> String? {
|
|
if let value = attributes[markdownLinkAttribute] as? URL {
|
|
return markdownNormalizedLink(value, documentURL: documentURL)
|
|
}
|
|
if let value = attributes[markdownLinkAttribute] as? NSURL {
|
|
return markdownNormalizedLink(value as URL, documentURL: documentURL)
|
|
}
|
|
if let value = attributes[markdownLinkAttribute] as? String, !value.isEmpty {
|
|
if value.hasPrefix("#") {
|
|
return value
|
|
}
|
|
if let url = URL(string: value) {
|
|
return markdownNormalizedLink(url, documentURL: documentURL)
|
|
}
|
|
return value
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func markdownNormalizedLink(_ url: URL, documentURL: URL) -> String {
|
|
if url.baseURL != nil {
|
|
let relative = url.relativeString
|
|
if relative.hasPrefix("#") {
|
|
return relative
|
|
}
|
|
}
|
|
if let fragment = url.fragment, markdownMatchesDocument(url, documentURL: documentURL) {
|
|
return "#\(fragment)"
|
|
}
|
|
return url.absoluteString
|
|
}
|
|
|
|
private func markdownMatchesDocument(_ url: URL, documentURL: URL) -> Bool {
|
|
let normalizedUrl = markdownURLWithoutFragment(url)
|
|
let normalizedDocumentURL = markdownURLWithoutFragment(documentURL)
|
|
|
|
if normalizedUrl.isFileURL && normalizedDocumentURL.isFileURL {
|
|
return normalizedUrl.standardizedFileURL == normalizedDocumentURL.standardizedFileURL
|
|
} else {
|
|
return normalizedUrl == normalizedDocumentURL
|
|
}
|
|
}
|
|
|
|
private func markdownURLWithoutFragment(_ url: URL) -> URL {
|
|
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: true) else {
|
|
return url
|
|
}
|
|
components.fragment = nil
|
|
return components.url ?? url
|
|
}
|
|
|
|
private func markdownCompact(_ fragments: [RichText]) -> RichText {
|
|
var compacted: [RichText] = []
|
|
for fragment in fragments {
|
|
switch fragment {
|
|
case .empty:
|
|
continue
|
|
case let .plain(text):
|
|
guard !text.isEmpty else {
|
|
continue
|
|
}
|
|
if let last = compacted.last, case let .plain(lastText) = last {
|
|
compacted[compacted.count - 1] = .plain(lastText + text)
|
|
} else {
|
|
compacted.append(fragment)
|
|
}
|
|
case let .concat(items):
|
|
let nested = markdownCompact(items)
|
|
switch nested {
|
|
case .empty:
|
|
continue
|
|
case let .plain(text):
|
|
if let last = compacted.last, case let .plain(lastText) = last {
|
|
compacted[compacted.count - 1] = .plain(lastText + text)
|
|
} else {
|
|
compacted.append(.plain(text))
|
|
}
|
|
default:
|
|
compacted.append(nested)
|
|
}
|
|
default:
|
|
compacted.append(fragment)
|
|
}
|
|
}
|
|
if compacted.isEmpty {
|
|
return .empty
|
|
} else if compacted.count == 1 {
|
|
return compacted[0]
|
|
} else {
|
|
return .concat(compacted)
|
|
}
|
|
}
|
|
|
|
private func markdownDroppingPrefixLength(_ length: Int, from text: RichText) -> RichText {
|
|
guard length > 0 else {
|
|
return text
|
|
}
|
|
switch text {
|
|
case .empty:
|
|
return .empty
|
|
case let .plain(string):
|
|
let nsString = string as NSString
|
|
if nsString.length <= length {
|
|
return .empty
|
|
} else {
|
|
return .plain(nsString.substring(from: length))
|
|
}
|
|
case let .bold(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .bold(dropped)
|
|
case let .italic(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .italic(dropped)
|
|
case let .underline(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .underline(dropped)
|
|
case let .strikethrough(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .strikethrough(dropped)
|
|
case let .fixed(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .fixed(dropped)
|
|
case let .url(inner, url, webpageId):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .url(text: dropped, url: url, webpageId: webpageId)
|
|
case let .email(inner, email):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .email(text: dropped, email: email)
|
|
case let .concat(items):
|
|
var remainingLength = length
|
|
var result: [RichText] = []
|
|
result.reserveCapacity(items.count)
|
|
for item in items {
|
|
if remainingLength > 0 {
|
|
let itemLength = (item.plainText as NSString).length
|
|
if itemLength <= remainingLength {
|
|
remainingLength -= itemLength
|
|
continue
|
|
}
|
|
result.append(markdownDroppingPrefixLength(remainingLength, from: item))
|
|
remainingLength = 0
|
|
} else {
|
|
result.append(item)
|
|
}
|
|
}
|
|
return markdownCompact(result)
|
|
case let .subscript(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .subscript(dropped)
|
|
case let .superscript(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .superscript(dropped)
|
|
case let .marked(inner):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .marked(dropped)
|
|
case let .phone(inner, phone):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .phone(text: dropped, phone: phone)
|
|
case .image:
|
|
return text
|
|
case let .formula(latex):
|
|
let nsLatex = latex as NSString
|
|
if nsLatex.length <= length {
|
|
return .empty
|
|
} else {
|
|
return .plain(nsLatex.substring(from: length))
|
|
}
|
|
case let .anchor(inner, name):
|
|
let dropped = markdownDroppingPrefixLength(length, from: inner)
|
|
return dropped == .empty ? .empty : .anchor(text: dropped, name: name)
|
|
}
|
|
}
|
|
|
|
private func markdownHasDisplayableContent(_ richText: RichText) -> Bool {
|
|
switch richText {
|
|
case .empty:
|
|
return false
|
|
case let .plain(text):
|
|
return !text.isEmpty
|
|
case let .bold(text),
|
|
let .italic(text),
|
|
let .underline(text),
|
|
let .strikethrough(text),
|
|
let .fixed(text),
|
|
let .subscript(text),
|
|
let .superscript(text),
|
|
let .marked(text),
|
|
let .anchor(text, _):
|
|
return markdownHasDisplayableContent(text)
|
|
case let .url(text, _, _),
|
|
let .email(text, _),
|
|
let .phone(text, _):
|
|
return markdownHasDisplayableContent(text)
|
|
case let .concat(items):
|
|
return items.contains(where: markdownHasDisplayableContent)
|
|
case .image:
|
|
return true
|
|
case let .formula(latex):
|
|
return !latex.isEmpty
|
|
}
|
|
}
|
|
|
|
private func markdownIsWhitespaceOnly(_ richText: RichText) -> Bool {
|
|
switch richText {
|
|
case .empty:
|
|
return true
|
|
case let .plain(text):
|
|
return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
case let .bold(text),
|
|
let .italic(text),
|
|
let .underline(text),
|
|
let .strikethrough(text),
|
|
let .fixed(text),
|
|
let .subscript(text),
|
|
let .superscript(text),
|
|
let .marked(text),
|
|
let .anchor(text, _):
|
|
return markdownIsWhitespaceOnly(text)
|
|
case let .url(text, _, _),
|
|
let .email(text, _),
|
|
let .phone(text, _):
|
|
return markdownIsWhitespaceOnly(text)
|
|
case let .concat(items):
|
|
return items.allSatisfy(markdownIsWhitespaceOnly)
|
|
case .image:
|
|
return false
|
|
case let .formula(latex):
|
|
return latex.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
|
}
|
|
}
|
|
|
|
private func markdownPlainText(from block: InstantPageBlock, depth: Int = 0) -> String {
|
|
guard depth <= markdownSafetyLimits.maxPresentationIntentDepth else {
|
|
return ""
|
|
}
|
|
|
|
switch block {
|
|
case let .title(text):
|
|
return text.plainText
|
|
case let .subtitle(text):
|
|
return text.plainText
|
|
case let .authorDate(author, _):
|
|
return author.plainText
|
|
case let .header(text):
|
|
return text.plainText
|
|
case let .subheader(text):
|
|
return text.plainText
|
|
case let .heading(text, _):
|
|
return text.plainText
|
|
case let .formula(latex):
|
|
return latex
|
|
case let .paragraph(text):
|
|
return text.plainText
|
|
case let .preformatted(text, _):
|
|
return text.plainText
|
|
case let .footer(text):
|
|
return text.plainText
|
|
case let .blockQuote(text, caption):
|
|
return text.plainText.isEmpty ? caption.plainText : text.plainText
|
|
case let .pullQuote(text, caption):
|
|
return text.plainText.isEmpty ? caption.plainText : text.plainText
|
|
case let .kicker(text):
|
|
return text.plainText
|
|
case let .table(title, _, _, _):
|
|
return title.plainText
|
|
case let .details(title, _, _):
|
|
return title.plainText
|
|
case let .relatedArticles(title, _):
|
|
return title.plainText
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
private func markdownTitle(from blocks: [InstantPageBlock], file: FileMediaReference, fileURL: URL) -> String {
|
|
for block in blocks {
|
|
if case let .title(text) = block, !text.plainText.isEmpty {
|
|
return text.plainText
|
|
}
|
|
}
|
|
if let fileName = file.media.fileName, !fileName.isEmpty {
|
|
let baseName = URL(fileURLWithPath: fileName).deletingPathExtension().lastPathComponent
|
|
if !baseName.isEmpty {
|
|
return baseName
|
|
}
|
|
return fileName
|
|
}
|
|
let baseName = fileURL.deletingPathExtension().lastPathComponent
|
|
if !baseName.isEmpty {
|
|
return baseName
|
|
}
|
|
return fileURL.lastPathComponent
|
|
}
|
|
|
|
private func markdownNormalizedCodeBlockLanguage(_ language: String?) -> String? {
|
|
guard let language else {
|
|
return nil
|
|
}
|
|
let normalized = language.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
|
return normalized.isEmpty ? nil : normalized
|
|
}
|
|
|
|
private func markdownFirstParagraphText(from blocks: [InstantPageBlock], depth: Int = 0) -> String? {
|
|
guard depth <= markdownSafetyLimits.maxPresentationIntentDepth else {
|
|
return nil
|
|
}
|
|
|
|
for block in blocks {
|
|
switch block {
|
|
case let .formula(latex):
|
|
if !latex.isEmpty {
|
|
return latex
|
|
}
|
|
case let .paragraph(text):
|
|
if !text.plainText.isEmpty {
|
|
return text.plainText
|
|
}
|
|
case let .list(items, _):
|
|
for item in items {
|
|
switch item {
|
|
case let .text(text, _):
|
|
if !text.plainText.isEmpty {
|
|
return text.plainText
|
|
}
|
|
case let .blocks(blocks, _):
|
|
if let text = markdownFirstParagraphText(from: blocks, depth: depth + 1) {
|
|
return text
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
case let .details(_, blocks, _):
|
|
if let text = markdownFirstParagraphText(from: blocks, depth: depth + 1) {
|
|
return text
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func markdownBlocksWithGeneratedAnchors(_ blocks: [InstantPageBlock]) -> [InstantPageBlock] {
|
|
var result: [InstantPageBlock] = []
|
|
var slugCounts: [String: Int] = [:]
|
|
|
|
for block in blocks {
|
|
if let headingText = markdownHeadingText(from: block), !headingText.isEmpty {
|
|
let baseSlug = markdownAnchorSlug(from: headingText)
|
|
if !baseSlug.isEmpty {
|
|
let count = slugCounts[baseSlug] ?? 0
|
|
slugCounts[baseSlug] = count + 1
|
|
|
|
let slug: String
|
|
if count == 0 {
|
|
slug = baseSlug
|
|
} else {
|
|
slug = "\(baseSlug)-\(count)"
|
|
}
|
|
result.append(.anchor(slug))
|
|
}
|
|
}
|
|
result.append(block)
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
private func markdownHeadingText(from block: InstantPageBlock) -> String? {
|
|
switch block {
|
|
case let .title(text):
|
|
return text.plainText
|
|
case let .header(text):
|
|
return text.plainText
|
|
case let .subheader(text):
|
|
return text.plainText
|
|
case let .heading(text, _):
|
|
return text.plainText
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func markdownAnchorSlug(from text: String) -> String {
|
|
let normalized = text
|
|
.folding(options: [.caseInsensitive, .diacriticInsensitive, .widthInsensitive], locale: nil)
|
|
.lowercased()
|
|
|
|
let dashScalar = "-".unicodeScalars.first!
|
|
let separatorSet = CharacterSet.whitespacesAndNewlines.union(CharacterSet(charactersIn: "-_"))
|
|
var scalars: [UnicodeScalar] = []
|
|
var previousWasDash = false
|
|
|
|
for scalar in normalized.unicodeScalars {
|
|
if CharacterSet.alphanumerics.contains(scalar) {
|
|
scalars.append(scalar)
|
|
previousWasDash = false
|
|
} else if separatorSet.contains(scalar) {
|
|
if !scalars.isEmpty && !previousWasDash {
|
|
scalars.append(dashScalar)
|
|
previousWasDash = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if scalars.last == dashScalar {
|
|
scalars.removeLast()
|
|
}
|
|
|
|
return String(String.UnicodeScalarView(scalars))
|
|
}
|
|
|
|
private func markdownTrimTrailingCodeBlockNewline(_ attributedString: NSAttributedString) -> NSAttributedString {
|
|
guard attributedString.length > 0 else {
|
|
return attributedString
|
|
}
|
|
let mutable = NSMutableAttributedString(attributedString: attributedString)
|
|
let string = mutable.string
|
|
if string.hasSuffix("\r\n"), mutable.length >= 2 {
|
|
mutable.deleteCharacters(in: NSRange(location: mutable.length - 2, length: 2))
|
|
} else if string.hasSuffix("\n") {
|
|
mutable.deleteCharacters(in: NSRange(location: mutable.length - 1, length: 1))
|
|
}
|
|
return mutable
|
|
}
|
|
|
|
private enum MarkdownIntentKind {
|
|
case table([TableHorizontalAlignment])
|
|
case tableHeaderRow
|
|
case tableRow
|
|
case tableCell(Int)
|
|
case paragraph
|
|
case header(Int)
|
|
case codeBlock(String?)
|
|
case thematicBreak
|
|
case blockQuote
|
|
case unorderedList
|
|
case orderedList
|
|
case listItem(Int)
|
|
case unknown
|
|
|
|
@available(iOS 15.0, *)
|
|
init(component: PresentationIntent.IntentType) {
|
|
switch component.kind {
|
|
case let .table(columns):
|
|
self = .table(columns.map(markdownTableColumnAlignment))
|
|
case .tableHeaderRow:
|
|
self = .tableHeaderRow
|
|
case .tableRow(_):
|
|
self = .tableRow
|
|
case let .tableCell(column):
|
|
self = .tableCell(column)
|
|
case .paragraph:
|
|
self = .paragraph
|
|
case let .header(level):
|
|
self = .header(level)
|
|
case let .codeBlock(languageHint):
|
|
self = .codeBlock(languageHint)
|
|
case .thematicBreak:
|
|
self = .thematicBreak
|
|
case .blockQuote:
|
|
self = .blockQuote
|
|
case .unorderedList:
|
|
self = .unorderedList
|
|
case .orderedList:
|
|
self = .orderedList
|
|
case let .listItem(ordinal):
|
|
self = .listItem(ordinal)
|
|
default:
|
|
self = .unknown
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 15.0, *)
|
|
private func markdownTableColumnAlignment(_ column: PresentationIntent.TableColumn) -> TableHorizontalAlignment {
|
|
switch column.alignment {
|
|
case .left:
|
|
return .left
|
|
case .center:
|
|
return .center
|
|
case .right:
|
|
return .right
|
|
@unknown default:
|
|
return .left
|
|
}
|
|
}
|
|
|
|
private final class MarkdownIntentNode {
|
|
let identity: Int
|
|
let kind: MarkdownIntentKind
|
|
|
|
private(set) var children: [MarkdownIntentNode] = []
|
|
private var childIdentities: Set<Int> = []
|
|
private(set) var attributedText = NSMutableAttributedString(string: "")
|
|
|
|
@available(iOS 15.0, *)
|
|
init(component: PresentationIntent.IntentType) {
|
|
self.identity = component.identity
|
|
self.kind = MarkdownIntentKind(component: component)
|
|
}
|
|
|
|
func append(child: MarkdownIntentNode) {
|
|
if self.childIdentities.insert(child.identity).inserted {
|
|
self.children.append(child)
|
|
}
|
|
}
|
|
|
|
func append(text: NSAttributedString) {
|
|
self.attributedText.append(text)
|
|
}
|
|
}
|