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:
isaac 2026-05-26 23:11:17 +02:00
parent a9f8b0d067
commit 675252724f
2 changed files with 70 additions and 24 deletions

View file

@ -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). H2H6 → `.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`.

View file

@ -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