Telegram-iOS/submodules/BrowserUI/Sources/InstantPageToMarkdown.swift
isaac 471d11df16 Copy rich messages as markdown (whole message + partial selection)
Rich messages (RichTextMessageAttribute, text == "") are copyable as
markdown two ways: the context-menu Copy action copies the whole
message, and a text selection inside the rich-data bubble copies just
the selected range. Both reconstruct markdown mirroring the edit
round-trip (markdownStringFromInstantPage).

Implements the full RichText entity case set
(mention/hashtag/cashtag/bot-command/bank-card/auto url/email/phone) with
tap interaction, the InstantPage -> markdown inverse converter and edit
round-trip, markdown-context stamping during V2 layout
(InstantPageMarkdownBlockContext: heading level, list/code/table/quote
depth), partial-selection markdown emission
(InstantPageMultiTextAdapter.markdownForRange), and numerous converter
edge-case fixes (tables, links, fenced code, blockquote line coalescing,
compact nested >> markers).

CLAUDE.md documents the feature; the spec/plan scratch docs generated
during development are not committed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 13:52:47 +02:00

275 lines
10 KiB
Swift

import Foundation
import TelegramCore
/// Reconstructs a markdown source string from an `InstantPage`.
///
/// This is the inverse of the markdown`InstantPage` conversion used when
/// sending rich messages. It is best-effort and never fails: media blocks
/// (images/videos/audio/embeds/maps) are skipped, and any block or inline node
/// without a CommonMark representation falls back to its plain text.
///
/// Inline formatting is emitted as CommonMark (`**bold**`, `*italic*`,
/// `` `code` ``, `~~strike~~`, `[text](url)`) because the send-time classifier
/// re-parses the text through the rich (Apple CommonMark) path, not the entity
/// regex.
public func markdownStringFromInstantPage(_ instantPage: InstantPage) -> String {
return markdownString(from: instantPage.blocks)
}
private func markdownString(from blocks: [InstantPageBlock]) -> String {
var pieces: [String] = []
for block in blocks {
if let piece = markdownString(from: block) {
pieces.append(piece)
}
}
return pieces.joined(separator: "\n\n")
}
private func markdownString(from block: InstantPageBlock) -> String? {
switch block {
case let .heading(text, level):
let hashes = String(repeating: "#", count: max(1, min(6, Int(level))))
return "\(hashes) \(markdownInline(from: text))"
case let .title(text):
return "# \(markdownInline(from: text))"
case let .paragraph(text):
let rendered = markdownInline(from: text)
return rendered.isEmpty ? nil : escapeLeadingBlockMarker(rendered)
case let .preformatted(text, language):
let language = language ?? ""
return "```\(language)\n\(markdownInline(from: text))\n```"
case let .blockQuote(blocks, _):
return markdownBlockQuoteBlocks(blocks)
case let .pullQuote(text, _):
return markdownBlockQuote(text)
case let .list(items, ordered):
return markdownList(items: items, ordered: ordered, indent: 0)
case let .table(_, rows, _, _):
return markdownTable(rows: rows)
case .divider:
return "---"
case let .formula(latex):
return "$\(latex)$"
case .anchor:
// Chat send already strips generated heading anchors; drop them here too.
return nil
case let .subtitle(text), let .header(text), let .subheader(text), let .footer(text), let .kicker(text):
let rendered = markdownInline(from: text)
return rendered.isEmpty ? nil : escapeLeadingBlockMarker(rendered)
default:
// Media and other structural blocks are skipped (out of scope).
return nil
}
}
private func markdownBlockQuote(_ text: RichText) -> String {
let body = markdownInline(from: text)
let lines = body.split(separator: "\n", omittingEmptySubsequences: false)
return lines.map { "> \($0)" }.joined(separator: "\n")
}
private func markdownBlockQuoteBlocks(_ blocks: [InstantPageBlock]) -> String {
var lines: [String] = []
for block in blocks {
guard let body = markdownString(from: block) else {
continue
}
for line in body.split(separator: "\n", omittingEmptySubsequences: false) {
// Stack nested-quote markers without internal spaces (`>>` not `> >`):
// a line already starting with `>` (a nested quote) gets a bare `>`.
let text = String(line)
lines.append(text.hasPrefix(">") ? ">\(text)" : "> \(text)")
}
}
return lines.joined(separator: "\n")
}
private func markdownInline(from richText: RichText) -> String {
switch richText {
case .empty:
return ""
case let .plain(string):
return escapeMarkdown(string)
case let .bold(text):
return "**\(markdownInline(from: text))**"
case let .italic(text):
return "*\(markdownInline(from: text))*"
case let .fixed(text):
return "`\(markdownInline(from: text))`"
case let .strikethrough(text):
return "~~\(markdownInline(from: text))~~"
case let .url(text, url, _):
return "[\(markdownInline(from: text))](\(url))"
case let .email(text, email):
return "[\(markdownInline(from: text))](mailto:\(email))"
case let .phone(text, phone):
return "[\(markdownInline(from: text))](tel:\(phone))"
case let .concat(parts):
return parts.map { markdownInline(from: $0) }.joined()
case let .anchor(text, _):
return markdownInline(from: text)
case let .formula(latex):
return "$\(latex)$"
// No CommonMark equivalent; emit inner text. These cannot arise from a
// markdown-composed message (Apple's markdown parser never produces them),
// so this is purely defensive.
case let .underline(text):
return markdownInline(from: text)
case let .marked(text):
return markdownInline(from: text)
case let .superscript(text):
return markdownInline(from: text)
case let .`subscript`(text):
return markdownInline(from: text)
default:
// .image, .textCustomEmoji, and the entity cases (.textMention,
// .textHashtag, ): fall back to plain text.
return escapeMarkdown(richText.plainText)
}
}
/// Backslash-escapes inline markdown-significant characters so literal text
/// does not re-parse as formatting. Pragmatic set, not full CommonMark.
private func escapeMarkdown(_ string: String) -> String {
var result = ""
result.reserveCapacity(string.count)
for character in string {
switch character {
case "\\", "*", "_", "`", "[", "]", "~", "|":
result.append("\\")
result.append(character)
default:
result.append(character)
}
}
return result
}
/// Escapes a line-leading character that would otherwise be read as a block
/// marker (ATX heading, blockquote, or list item) when the text starts a line.
private func escapeLeadingBlockMarker(_ string: String) -> String {
guard let first = string.first else {
return string
}
if first == "#" || first == ">" || first == "-" || first == "+" {
return "\\" + string
}
// Ordered-list marker: one or more digits followed by '.' or ')'.
if first.isNumber {
var index = string.startIndex
while index < string.endIndex, string[index].isNumber {
index = string.index(after: index)
}
if index < string.endIndex, string[index] == "." || string[index] == ")" {
let prefix = string[string.startIndex ..< index]
let delimiter = string[index]
let rest = string[string.index(after: index)...]
return "\(prefix)\\\(delimiter)\(rest)"
}
}
return string
}
/// Collapses newlines for a single-line GFM table cell. The `|` character is
/// already escaped by `escapeMarkdown` for `.plain` runs.
private func escapeTableCell(_ string: String) -> String {
return string.replacingOccurrences(of: "\n", with: " ")
}
private func markdownList(items: [InstantPageListItem], ordered: Bool, indent: Int) -> String {
let indentString = String(repeating: " ", count: indent * 2)
var lines: [String] = []
var index = 1
for item in items {
// Ordered markers are regenerated from the running index (CommonMark renumbers
// anyway); the unordered marker is fixed. A task-list `checked` state is emitted
// as a GitHub task marker so re-classification on save re-parses it as a checkbox.
let listMarker = ordered ? "\(index). " : "- "
let taskMarker: String
switch item.checked {
case .some(false):
taskMarker = "[ ] "
case .some(true):
taskMarker = "[x] "
case .none:
taskMarker = ""
}
let marker = "\(listMarker)\(taskMarker)"
switch item {
case let .text(text, _, _):
lines.append("\(indentString)\(marker)\(markdownInline(from: text))")
case let .blocks(blocks, _, _):
var remainder = blocks
var markerLineText = ""
if case let .paragraph(text)? = remainder.first {
markerLineText = markdownInline(from: text)
remainder = Array(remainder.dropFirst())
}
lines.append("\(indentString)\(marker)\(markerLineText)")
let childIndentString = String(repeating: " ", count: (indent + 1) * 2)
for block in remainder {
if case let .list(nestedItems, nestedOrdered) = block {
lines.append(markdownList(items: nestedItems, ordered: nestedOrdered, indent: indent + 1))
} else if let rendered = markdownString(from: block) {
for line in rendered.split(separator: "\n", omittingEmptySubsequences: false) {
lines.append("\(childIndentString)\(line)")
}
}
}
case .unknown:
break
}
index += 1
}
return lines.joined(separator: "\n")
}
private func markdownTable(rows: [InstantPageTableRow]) -> String? {
guard !rows.isEmpty else {
return nil
}
let columnCount = rows.map { $0.cells.count }.max() ?? 0
guard columnCount > 0 else {
return nil
}
func renderRow(_ row: InstantPageTableRow) -> String {
var cellStrings: [String] = []
for columnIndex in 0 ..< columnCount {
if columnIndex < row.cells.count, let text = row.cells[columnIndex].text {
cellStrings.append(escapeTableCell(markdownInline(from: text)))
} else {
cellStrings.append("")
}
}
return "| " + cellStrings.joined(separator: " | ") + " |"
}
var lines: [String] = []
lines.append(renderRow(rows[0]))
var separators: [String] = []
for columnIndex in 0 ..< columnCount {
let alignment: TableHorizontalAlignment
if columnIndex < rows[0].cells.count {
alignment = rows[0].cells[columnIndex].alignment
} else {
alignment = .left
}
switch alignment {
case .left:
separators.append("---")
case .center:
separators.append(":---:")
case .right:
separators.append("---:")
}
}
lines.append("| " + separators.joined(separator: " | ") + " |")
for row in rows.dropFirst() {
lines.append(renderRow(row))
}
return lines.joined(separator: "\n")
}