mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-05 19:28:46 +02:00
Make formulas trigger rich messages (strict math detection)
$…$/$$…$$ (and \(…\)/\[…\]) math now sends a rich message, gated by a strict boundary rule so casual $ usage stays plain: inline detection uses a 4-way boundary check (rejecting $5-$10, $FOO=$BAR) and block $$ requires an exact/bare opener. Math delimiters are added to the rich pre-filter. Documented in CLAUDE.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a9f8b0d067
commit
675252724f
2 changed files with 70 additions and 24 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -141,8 +141,8 @@ On message send, the app auto-decides: if the typed markdown maps onto the regul
|
|||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Boundary rule:** send rich iff the parse yields an `InstantPageBlock` with no entity equivalent. Entity-expressible whitelist (→ normal): `.paragraph`, `.preformatted`, `.blockQuote` (empty caption), `.anchor`, `.unsupported`, **and `.divider`/`.formula`** — the latter two are *deliberately excluded as triggers* (`---` and `$…$` are too common in casual text, e.g. `$5-$10`/`$FOO=$BAR`). Inline `.formula` is likewise treated as expressible. So effective triggers = headings, lists, tables.
|
||||
- **Approach A (parse-then-inspect):** the classifier reuses the real parser, so "what triggers rich" can't drift from "what the rich renderer shows." `markdownMightNeedRichLayout` is a cheap necessary-condition over-approximation — it may over-trigger a parse but must **never** false-negative. It detects `#`, list markers, dash-lines (`-{1,}`, which also catches setext-H2 underlines → heading blocks), `\n=` (setext H1), `|`, `![`. It deliberately does **not** detect `$`/`\(` (formulas don't trigger rich).
|
||||
- **Boundary rule:** send rich iff the parse yields an `InstantPageBlock` with no entity equivalent. Entity-expressible whitelist (→ normal): `.paragraph`, `.preformatted`, `.blockQuote` (empty caption), `.anchor`, `.unsupported`, **and `.divider`** (`---` is too common in casual text to trigger rich). **`.formula` (block and inline) DOES trigger rich**, gated by strict math detection (see "Formulas trigger rich messages" below) so casual `$` usage (`$5-$10`, `$FOO=$BAR`) stays plain. So effective triggers = headings, lists, tables, formulas.
|
||||
- **Approach A (parse-then-inspect):** the classifier reuses the real parser, so "what triggers rich" can't drift from "what the rich renderer shows." `markdownMightNeedRichLayout` is a cheap necessary-condition over-approximation — it may over-trigger a parse but must **never** false-negative. It detects `#`, list markers, dash-lines (`-{1,}`, which also catches setext-H2 underlines → heading blocks), `\n=` (setext H1), `|`, `![`, and math delimiters `$`/`\(`/`\[` (formulas now trigger rich; the strict detection step decides whether a `$` run is actually math).
|
||||
- **Chat vs. document path = `file == nil` / `context.documentURL == nil`.** `inputRichTextAttributeFromText` passes `file: nil`; the document-attachment path passes a real file. Two chat-only behaviors key off this: (a) generated heading anchors are **skipped** (`markdownBlocksWithGeneratedAnchors` runs only for documents — anchors exist for intra-document `#slug` links and otherwise prepend a spurious invisible `.anchor` block per heading); (b) a level-1 `#` heading maps to `.heading(text:, level: 1)`, not `.title` (the document/article-title treatment). H2–H6 → `.heading(level: 2…6)` for both paths. This converter only ever emits `.title` (H1-doc) or `.heading` — never `.header`/`.subheader`.
|
||||
- **The classifier is fed the RAW `effectiveInputText.string`**, not the post-`convertMarkdownToAttributes` `inputText`, so inline `**bold**` survives into the rich render. The entity branch still uses the converted `inputText`.
|
||||
- **Bypassed for `.customChatContents`** (business links / quick replies) via `isSpecialChatContents`. The compose/send gate lives here; **editing has its own symmetric re-classification** — see "Editing rich messages" below.
|
||||
|
|
@ -171,6 +171,18 @@ Rich messages (`RichTextMessageAttribute`, `text == ""`) are made editable by re
|
|||
- **Known limitation:** a rich→plain edit that leaves only inline-formatted text loses `*italic*` (the entity path recognizes only `__…__`). Rare edge; the rich round-trip contract holds.
|
||||
- **`previewText()` lives in TelegramStringFormatting, not TextFormat/TelegramCore.** It will gain a `strings: PresentationStrings` param (to localize the `"Photo"`/`"Video"`/`"Table"` placeholders), so it must sit in a UI-string module — `messageContentKind`/`descriptionStringForMessage` (same module) already take `strings:`. Teaching `messageContentKind` about rich cascades the preview to the edit accessory panel, reply/pinned panels, and forward preview in one place (those surfaces need no individual change).
|
||||
|
||||
## Formulas trigger rich messages (strict math detection)
|
||||
|
||||
`$…$`/`$$…$$` (and `\(…\)`/`\[…\]`) math triggers a rich message, gated by a
|
||||
strict boundary rule so casual `$` stays plain. Inverse companion of the
|
||||
markdown-send gate above.
|
||||
|
||||
### Non-obvious invariants
|
||||
|
||||
- **Inline `$…$`/`$$…$$` detection requires a 4-way boundary** (in `markdownReplacingInlineFormulas`, `BrowserMarkdown.swift`): outer side of each delimiter = line edge OR non-alphanumeric; inner side = non-whitespace; opener/closer `$`-counts must match (1 or 2). This is what rejects `$5-$10`/`$FOO=$BAR`/`cost$5$total` (alphanumeric outer) while keeping `$x$`, `($x$)`, `the answer is $x$.`. The outer check is the addition over a plain "no-space-inside" rule.
|
||||
- **Block `$$` detection** (`markdownBlockFormulaReplacement`): single-line `$$…$$` requires an exact `$$` opener (not `$$$`) and trailing whitespace only; multi-line requires a **bare** `$$` opener line. `$$x$$ trailing text` falls through to the inline rule. The `\[…\]` opener path is unchanged and exempt from these `$$`-only guards.
|
||||
- **Detection is shared with the document path; the gate is chat-only.** `markdownPreparedSource` (detection) runs for both chat and document attachments. The triggers (`richTextIsEntityExpressible`/`blockIsEntityExpressible` → `.formula` is non-expressible; `$`/`\(`/`\[` in `markdownMightNeedRichLayout`) are read only by the chat classifier `richMarkdownAttributeIfNeeded`.
|
||||
|
||||
## Postbox → TelegramEngine refactor (in progress)
|
||||
|
||||
A gradual migration is underway to eliminate direct `import Postbox` from consumer submodules in favor of `TelegramEngine`.
|
||||
|
|
|
|||
|
|
@ -748,6 +748,11 @@ private func markdownBlockFormulaReplacement(
|
|||
}
|
||||
|
||||
let openerContent = String(trimmedStart.dropFirst(opener.count))
|
||||
// Block opener must be exactly `$$` (not `$$$`); a `$$$…` line is not a block and
|
||||
// falls through to inline handling.
|
||||
if opener == "$$", openerContent.first == "$" {
|
||||
return nil
|
||||
}
|
||||
var latex = ""
|
||||
|
||||
if let closeRange = markdownFirstUnescapedRange(of: closer, in: openerContent, from: openerContent.startIndex) {
|
||||
|
|
@ -770,6 +775,11 @@ private func markdownBlockFormulaReplacement(
|
|||
return (indentation + descriptor.placeholder + ending, startLineIndex + 1, descriptor)
|
||||
}
|
||||
|
||||
// Reached only when there is no closer on the opener line (multi-line form).
|
||||
// A multi-line block opener must be a bare `$$` line; `$$ content` is not a block.
|
||||
if opener == "$$", !openerContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
return nil
|
||||
}
|
||||
let (_, openerEnding) = markdownLineContentAndEnding(lines[startLineIndex])
|
||||
latex.append(contentsOf: openerContent)
|
||||
latex.append(contentsOf: openerEnding)
|
||||
|
|
@ -862,29 +872,47 @@ private func markdownReplacingInlineFormulas(
|
|||
}
|
||||
|
||||
if content[index] == "$", !markdownIsEscaped(content, at: index) {
|
||||
guard let bodyStart = markdownIndex(content, offsetBy: 1, from: index), bodyStart < content.endIndex else {
|
||||
// Outer boundary before the opener: line start, or a non-alphanumeric char.
|
||||
// (Rejects `cost$5$total`, the `$` after `5` in `$5-$10`, etc.)
|
||||
if index > content.startIndex {
|
||||
let beforeOpener = content[content.index(before: index)]
|
||||
if beforeOpener.isLetter || beforeOpener.isNumber {
|
||||
result.append(content[index])
|
||||
index = content.index(after: index)
|
||||
continue
|
||||
}
|
||||
}
|
||||
// Opener: 1 or 2 leading `$` (a 3rd `$` becomes inner content).
|
||||
let openerRunEnd = markdownIndex(afterRepeating: "$", in: content, from: index)
|
||||
let openerCount = min(2, content.distance(from: index, to: openerRunEnd))
|
||||
let bodyStart = content.index(index, offsetBy: openerCount)
|
||||
// Inner boundary after the opener: must exist and be non-whitespace.
|
||||
guard bodyStart < content.endIndex, !content[bodyStart].isWhitespace 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
|
||||
}
|
||||
|
||||
let closerPattern = String(repeating: "$", count: openerCount)
|
||||
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
|
||||
while let closeRange = markdownFirstUnescapedRange(of: closerPattern, in: content, from: searchIndex) {
|
||||
// Inner boundary before the closer: non-whitespace.
|
||||
let beforeCloser = content[content.index(before: closeRange.lowerBound)]
|
||||
if beforeCloser.isWhitespace {
|
||||
searchIndex = closeRange.upperBound
|
||||
continue
|
||||
}
|
||||
searchIndex = closeRange.upperBound
|
||||
// Outer boundary after the closer: line end, or a non-alphanumeric char.
|
||||
if closeRange.upperBound < content.endIndex {
|
||||
let afterCloser = content[closeRange.upperBound]
|
||||
if afterCloser.isLetter || afterCloser.isNumber {
|
||||
searchIndex = closeRange.upperBound
|
||||
continue
|
||||
}
|
||||
}
|
||||
matchedRange = closeRange
|
||||
break
|
||||
}
|
||||
|
||||
if let matchedRange {
|
||||
let latex = String(content[bodyStart ..< matchedRange.lowerBound])
|
||||
if let descriptor = markdownAcceptedFormulaDescriptor(
|
||||
|
|
@ -1012,6 +1040,11 @@ private func markdownMightNeedRichLayout(_ text: String) -> Bool {
|
|||
if text.contains("|") || text.contains("![") {
|
||||
return true
|
||||
}
|
||||
// Math delimiters. Over-approximates (any `$` triggers a parse); the strict
|
||||
// detection + gate decide whether a formula block is actually produced.
|
||||
if text.contains("$") || text.contains("\\(") || text.contains("\\[") {
|
||||
return true
|
||||
}
|
||||
// Setext H1 heading: a line of '=' underlining the previous line.
|
||||
// (Setext H2 dash-underlines are caught by the dash-line branch in the regex.)
|
||||
if text.contains("\n=") {
|
||||
|
|
@ -1029,8 +1062,8 @@ private func markdownMightNeedRichLayout(_ text: String) -> Bool {
|
|||
|
||||
// True when this inline RichText maps onto Telegram message entities.
|
||||
// Returns false for inline content that forces the rich path (inline image,
|
||||
// sub/superscript, highlight). Inline formulas are intentionally treated as
|
||||
// expressible: '$'-delimited spans are too common in casual text ('$5-$10').
|
||||
// sub/superscript, highlight, formula). Formulas trigger the rich path; casual
|
||||
// '$' usage is excluded by the strict boundary rule in the detection step, not here.
|
||||
private func richTextIsEntityExpressible(_ text: RichText) -> Bool {
|
||||
switch text {
|
||||
case .empty, .plain:
|
||||
|
|
@ -1052,7 +1085,7 @@ private func richTextIsEntityExpressible(_ text: RichText) -> Bool {
|
|||
case .anchor(let inner, _):
|
||||
return richTextIsEntityExpressible(inner)
|
||||
case .formula:
|
||||
return true
|
||||
return false
|
||||
case .textCustomEmoji:
|
||||
return true
|
||||
case .textAutoEmail(let inner), .textAutoPhone(let inner), .textAutoUrl(let inner), .textBankCard(let inner), .textBotCommand(let inner), .textCashtag(let inner), .textHashtag(let inner), .textMention(let inner), .textSpoiler(let inner):
|
||||
|
|
@ -1075,9 +1108,10 @@ private func isEmptyRichText(_ text: RichText) -> Bool {
|
|||
|
||||
// Block types that do NOT trigger a rich-layout message. Besides the genuinely
|
||||
// entity-expressible blocks (paragraph/preformatted/blockQuote/anchor), dividers
|
||||
// and formulas are intentionally excluded as triggers too: '---' and '$'-delimited
|
||||
// spans are too common in casual text to justify a rich message. Effective rich
|
||||
// triggers are therefore headings, lists, and tables.
|
||||
// are intentionally excluded as triggers too ('---' is too common in casual text).
|
||||
// Formulas DO trigger the rich path; casual '$' usage is excluded by the strict
|
||||
// boundary rule in the detection step. Effective rich triggers are therefore
|
||||
// headings, lists, tables, and formulas.
|
||||
private func blockIsEntityExpressible(_ block: InstantPageBlock) -> Bool {
|
||||
switch block {
|
||||
case .paragraph(let text):
|
||||
|
|
@ -1088,7 +1122,7 @@ private func blockIsEntityExpressible(_ block: InstantPageBlock) -> Bool {
|
|||
return isEmptyRichText(caption) && richTextIsEntityExpressible(text)
|
||||
case .anchor, .unsupported:
|
||||
return true
|
||||
case .divider, .formula:
|
||||
case .divider:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue