fix(mobile): keep unrestored escape stand-ins readable

gpt_markdown sometimes shows text unparsed, for example a strikethrough
that spans lines, and then a private-use stand-in showed as nothing.
Make each stand-in a WORD JOINER followed by the character's fullwidth
form, so that fallback still reads `#` rather than losing the character.

Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tom Brow <tomb@squareup.com>
This commit is contained in:
Tom Brow
2026-11-02 09:19:33 -08:00
parent 43f58e1889
commit ddc8b55592
2 changed files with 24 additions and 19 deletions

View File

@@ -1,18 +1,19 @@
import 'link_normalizer.dart';
/// Holds each backslash-escaped character as a private-use stand-in, so no
/// link, emphasis, mention, emoji, channel or math syntax matches it: `\*x\*`
/// stays `*x*` and `\@alice` stays text.
/// Holds each backslash-escaped character as a stand-in, so no link,
/// emphasis, mention, emoji, channel or math syntax matches it: `\*x\*` stays
/// `*x*` and `\@alice` stays text.
///
/// A stand-in is [_marker] followed by the character shifted into the
/// private-use area. An authored [_marker] becomes two markers, so authored
/// private-use characters come back from [unshieldMarkdownEscapes] unchanged.
/// A stand-in is an invisible [_marker] followed by the character's fullwidth
/// form, so text the renderer shows without restoring it still reads right.
/// An authored [_marker] becomes two markers, so authored text comes back from
/// [unshieldMarkdownEscapes] unchanged.
String shieldMarkdownEscapes(String text) => _needsShield(text)
? text.replaceAllMapped(
_escape,
(m) => m[1] == null
? '$_marker$_marker'
: '$_marker${String.fromCharCode(_escapeBase + m[1]!.codeUnitAt(0))}',
: '$_marker${String.fromCharCode(_fullwidthOffset + m[1]!.codeUnitAt(0))}',
)
: text;
@@ -34,18 +35,20 @@ String reescapeMarkdownEscapes(String text) => text.replaceAllMapped(
);
/// Matches one stand-in created by [shieldMarkdownEscapes].
final shieldedMarkdownEscape = RegExp('\uE000[\uE000\uE021-\uE07E]');
final shieldedMarkdownEscape = RegExp('\u2060[\u2060\uFF01-\uFF5E]');
bool _needsShield(String text) => text.contains(r'\') || text.contains(_marker);
String _held(String standIn) {
final unit = standIn.codeUnitAt(1);
return unit == _escapeBase
final held = standIn.substring(1);
return held == _marker
? _marker
: String.fromCharCode(unit - _escapeBase);
: String.fromCharCode(held.codeUnitAt(0) - _fullwidthOffset);
}
const _marker = '\uE000';
const _escapeBase = 0xE000;
// WORD JOINER: zero-width, not whitespace, and not Markdown syntax.
const _marker = '\u2060';
// ASCII 0x21-0x7E to FULLWIDTH 0xFF01-0xFF5E.
const _fullwidthOffset = 0xFEE0;
// CommonMark: a backslash escapes any ASCII punctuation character.
final _escape = RegExp(r'\\([!-/:-@\[-`{-~])|\uE000');
final _escape = RegExp(r'\\([!-/:-@\[-`{-~])|\u2060');

View File

@@ -512,8 +512,10 @@ void main() {
('```\nx \\# y\n```', r'x \# y'),
(r'code `a\#b`', r'a\#b'),
(r'\`literal\` then `a\#b`', '`literal` then '),
// Authored private-use characters are not mistaken for escapes.
('\uE02A \uE000\uE02A \\#', '\uE02A \uE000\uE02A #'),
// Authored stand-in characters are not mistaken for escapes.
('\uFF0A \u2060\uFF0A \\#', '\uFF0A \u2060\uFF0A #'),
// gpt_markdown shows this unparsed; the stand-in still reads as #.
('~~a\nb \\#c~~', '\uFF03c'),
// A bare link's trailing backslash stays outside the link.
(r'see https://example.com/a\', 'see '),
(r'in buzz://channel/9f18f3c6-b5e5-453d-b36d-65487913be0b.\', r'.\'),
@@ -538,17 +540,17 @@ void main() {
);
}
// No stand-in or leaked link syntax reaches text or semantics.
if (content.contains('\uE000')) continue;
if (content.contains('\u2060') || shown == '\uFF03c') continue;
expect(
find.textContaining(
RegExp('\uE000|\\]\\(http'),
RegExp('\u2060|\\]\\(http'),
findRichText: true,
),
findsNothing,
reason: reason,
);
expect(
find.bySemanticsLabel(RegExp('\uE000')),
find.bySemanticsLabel(RegExp('\u2060')),
findsNothing,
reason: reason,
);