Telegram-iOS/submodules/BrowserUI/Sources/BrowserMarkdown.swift
isaac b48b96ecd9 RichText.textCustomEmoji: inline animated custom emoji in rich-data bubbles
Add the RichText.textCustomEmoji(fileId:alt:) case end-to-end: model +
Postbox coding, FlatBuffers schema/codec, and Api.RichText
parsing/serialization (lossless), plus display in the InstantPage V2
renderer. The emoji renders as an InlineStickerItemLayer that participates
in the streaming reveal (pops in as the reveal cursor crosses it) and is
gated by the bubble's visibility rect, propagated recursively through the
nested V2 view tree. Also frees the CTRunDelegate extent buffers for the
image/formula/custom-emoji attachment arms and documents the feature in
CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 22:12:47 +04:00

2307 lines
76 KiB
Swift

import Foundation
import UIKit
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: [EngineMedia.Id: EngineRawMedia]
}
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: EngineMedia.Id
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: [EngineMedia.Id: EngineRawMedia] = [:]
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) -> EngineMedia.Id {
switch namespace {
case Namespaces.Media.LocalImage:
self.nextLocalMediaId += 1
return EngineMedia.Id(namespace: namespace, id: self.nextLocalMediaId)
default:
self.nextRemoteMediaId += 1
return EngineMedia.Id(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), data: data) else {
return nil
}
return (webPage, fileURL)
}
public func inputRichTextAttributeFromText(context: AccountContext, text: String) -> RichTextMessageAttribute? {
guard #available(iOS 15.0, *) else {
return nil
}
guard let data = text.data(using: .utf8) else {
return nil
}
guard let webpage = markdownWebpage(context: context, file: nil, data: data), case let .Loaded(content) = webpage.content, let instantPage = content.instantPage else {
return nil
}
return RichTextMessageAttribute(instantPage: instantPage._parse())
}
@available(iOS 15.0, *)
private func markdownWebpage(context: AccountContext, file: (file: FileMediaReference, url: 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 {
let baseURL: URL?
if let file {
baseURL = file.url.deletingLastPathComponent()
} else {
baseURL = nil
}
attributedString = try NSAttributedString(
markdown: Data(preparedSource.text.utf8),
options: .init(),
baseURL: baseURL
)
} catch {
return nil
}
let budget = MarkdownConversionBudget(limits: limits)
let conversionContext = MarkdownConversionContext(context: context, documentURL: file?.url, 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
}
var title: String?
if let file {
title = markdownTitle(from: blocks, file: file.file, fileURL: file.url)
}
let text = markdownFirstParagraphText(from: blocks)
let instantPage = InstantPage(
blocks: blocks,
media: pageResult.media,
isComplete: true,
rtl: false,
url: file?.url.absoluteString ?? "",
views: nil
)
return TelegramMediaWebpage(
webpageId: EngineMedia.Id(namespace: 0, id: 0),
content: .Loaded(
TelegramMediaWebpageLoadedContent(
url: file?.url.absoluteString ?? "",
displayUrl: file?.url.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 documentURL, 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)
case .textCustomEmoji:
return text
}
}
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
case .textCustomEmoji:
return true
}
}
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
case .textCustomEmoji:
return false
}
}
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)
}
}