diff --git a/CLAUDE.md b/CLAUDE.md index a402293def..dd02aef75f 100644 --- a/CLAUDE.md +++ b/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`. diff --git a/submodules/BrowserUI/Sources/BrowserMarkdown.swift b/submodules/BrowserUI/Sources/BrowserMarkdown.swift index 8096224beb..fb343116a1 100644 --- a/submodules/BrowserUI/Sources/BrowserMarkdown.swift +++ b/submodules/BrowserUI/Sources/BrowserMarkdown.swift @@ -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? - 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