Skip to content

fix(editor): stop Monaco outlining fullwidth CJK punctuation - #462

Merged
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/cjk-punctuation-not-outlined
Aug 5, 2026
Merged

fix(editor): stop Monaco outlining fullwidth CJK punctuation#462
PathGao merged 1 commit into
sftwrdotdev:masterfrom
PathGao:fix/cjk-punctuation-not-outlined

Conversation

@PathGao

@PathGao PathGao commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Closes #186. Closes #94. First half of #393; the z-family half (#104) is left
alone, per your "can get to the other Monaco defaults at a later date" on #393.
You queued this one — "if we can fix the punctuation highlights we can queue it"
— the day before v2.7.0, and it did not make the release.

monaco.editor.create has never passed unicodeHighlight; there is no
occurrence anywhere in the repo, so Monaco's defaults have been in charge for
the editor's whole life. ambiguousCharacters defaults to true, and
are each a confusable of an ASCII counterpart, so each
gets a box. That default is right for source code, where a character that looks
like ASCII but is not is an attack vector, and wrong for a Markdown editor whose
users write prose.

Why it looks intermittent

The highlight is suppressed when the word around the character is entirely
non-ASCII — shouldHighlightNonBasicASCII in
common/services/unicodeTextModelHighlighter.js returns early on "no basic
ASCII, and something obviously non-ASCII present". So pure CJK is clean and the
boxes appear only once basic ASCII shares a word with the punctuation:

line boxed by default
你好,世界。(测试)!? nothing
使用 Monaco,然后保存。
安装依赖(npm ci),再运行测试。
打开 Settings:Editor,字体大小。
**粗体**,*斜体*,`代码`。

The last row is the one that makes this unavoidable: no Latin word is needed,
because a Markdown emphasis marker is itself basic ASCII. Mixing Latin technical
terms into CJK prose is the normal case, not an edge one.

Scope

One sub-option, ambiguousCharacters: false. The rest are deliberate:

invisibleCharacters stays at its default. A stray zero-width space or NBSP
is a real hazard in Markdown — an NBSP after - stops the list from parsing —
and unlike the punctuation it is never something the author typed on purpose. It
is the part of this feature that earns its place in a prose editor, and
unicodeHighlight: false would have taken it with the boxes.

nonBasicASCII needs no setting. It defaults to the inUntrustedWorkspace
sentinel, which resolves through !trusted, and standalone Monaco's
StandaloneWorkspaceTrustManagementService.isWorkspaceTrusted() returns true
unconditionally — so it is already off. Were it on, every ideograph would be
boxed rather than the punctuation, which is not what either reporter described.
Pinning it would be a no-op today and would freeze a default you are entitled to
change later.

allowedCharacters was the narrower alternative and is worse. It needs an
enumerated list of every fullwidth form across Chinese, Japanese and Korean
input, and boxes whatever the list forgets.

Unconditional, not gated on language

Monaco already ships the mechanism a language gate would use — allowedLocales,
seeded from the OS locale — and it does not help. 使用 Monaco,然后保存。 boxes
identically under en, zh-CN, zh-Hans, ja-JP and ko-KR, which is why the
reporters saw boxes on CJK systems in the first place. Beyond that, a content or
UI-language gate would make the same file render differently depending on state,
and would flip mid-typing as the first CJK character arrived.

The editor is the only Monaco instance in the app — the preview is a Rust
render_markdown HTML pipeline and there is no diff editor — so there is nowhere
else to apply this. editor.updateOptions merges and does not pass
unicodeHighlight, so setting it once at creation holds.

Tests

Four tests in editorOptionWiring.test.ts. They execute the option object
rather than matching the source for a string, which matters here: asserting that
"ambiguousCharacters" appears in Editor.svelte says nothing about which
characters Monaco ends up outlining, and that is the whole contract. The literal
is lifted out of the real monaco.editor.create call with the TypeScript parser,
evaluated, merged over Monaco's shipped defaults through
EditorOptions.unicodeHighlight.applyUpdate, and run through
UnicodeTextModelHighlighter — the function that decides what gets a box. A
regression has to survive Monaco's own code to reach the assertions.

One of the four asserts the fixtures still reproduce the bug once the option is
taken away, so a Monaco upgrade that changed the confusable set fails loudly
instead of leaving the other three quietly asserting nothing.

Reverting only the option, keeping the tests:

✖ the editor turns off ambiguous-character highlighting and nothing else
  AssertionError: exactly one sub-option is overridden
  + actual - expected
  + undefined
  - { ambiguousCharacters: false }

✖ no box is drawn on fullwidth CJK punctuation
  AssertionError: nothing outlined in CJK prose, got
    [",","(",")",",",",",":",",",",",",","(",")"]
  11 !== 0

ℹ pass 9   ℹ fail 2

Replacing it with a blanket { ambiguousCharacters: false, invisibleCharacters: false, nonBasicASCII: false } instead reddens the other direction — "the
invisible-character warning survives the fix" and the narrowness assertion — so
the pair pins the fix from both sides.

scripts/monacoInternals.d.ts is the one judgement call I would flag. Monaco
ships declarations for its public API only, so driving the option registry and
the highlighter from a test needs types for two internal module paths.
Declaring them narrowly rather than as any means a shape change is a type
error and a moved module is a loud import failure; the alternative was to give
up on executing Monaco's real logic and go back to a source-text assertion.

Verification

npm audit, npm run check (0 errors, 644 files), npm test (681 pass), and
cargo test all green.

Visually: I rendered Monaco 0.55.1 with this exact option object against the
document above, with and without the fix. Default gives 11 unicode-highlight
decorations; with the fix, 1 — the zero-width space, still flagged, which is the
point of keeping invisibleCharacters. To be precise about what that was: real
Monaco in a browser with the same options, not the packaged Tauri app, which
does not run outside its webview (__TAURI_INTERNALS__ is undefined).

Co-authored-by: Claude Opus 5 noreply@anthropic.com

Monaco's Unicode highlighter is built for source code, where a character
that looks like ASCII but is not is an attack vector. Markpad is a Markdown
editor, and `monaco.editor.create` has never passed `unicodeHighlight` — no
occurrence anywhere in the repo — so Monaco's defaults have been in charge
for the editor's whole life. Two reports are the same defect: sftwrdotdev#186 ("there
are highlighted boxes in v2.6.11") and sftwrdotdev#94 ("Chinese punctuation marks are
displayed inside a box"), also raised as the first half of sftwrdotdev#393.

`ambiguousCharacters` defaults to true, and `,` `!` `?` `(` `)` `;` `:`
are each a confusable of an ASCII counterpart, so each gets a box. The
highlight is suppressed when the surrounding word is entirely non-ASCII
(`shouldHighlightNonBasicASCII`, common/services/unicodeTextModelHighlighter.js),
which is why pure CJK looks clean and the boxes appear only once basic ASCII
shares the word with the punctuation. That is the normal case in CJK
technical prose — `使用 Monaco,然后保存。`, `安装依赖(npm ci),再运行测试。` —
and `**粗体**,` triggers it with no Latin word at all, because a Markdown
emphasis marker is basic ASCII.

`invisibleCharacters` stays at its default. A stray zero-width space or NBSP
is a real hazard in Markdown — an NBSP after `-` stops the list from parsing
— and unlike the punctuation it is never something the author typed on
purpose, so it is the part of this feature that earns its place in a prose
editor. Disabling `unicodeHighlight` wholesale would have taken it too.

`nonBasicASCII` needs no setting. It defaults to the `inUntrustedWorkspace`
sentinel, which resolves through `!trusted`, and standalone Monaco's
`StandaloneWorkspaceTrustManagementService.isWorkspaceTrusted()` returns true
unconditionally — so it is already off. Were it on, every ideograph would be
boxed rather than the punctuation, which is not what either reporter
described. Pinning it would be a no-op today and would freeze a default the
embedder is entitled to change.

`allowedCharacters` was the narrower alternative and is worse: it needs an
enumerated list of every fullwidth form across Chinese, Japanese and Korean
input, and boxes whatever the list forgets.

Unconditional rather than gated on the document or the UI language. Monaco
already has the mechanism such a gate would use — `allowedLocales`, seeded
from the OS locale — and it does not help: `使用 Monaco,然后保存。` boxes
identically under en, zh-CN, zh-Hans, ja-JP and ko-KR. A gate would also make
the same file render differently depending on state, and would flip
mid-typing as the first CJK character arrived.

The editor is the only Monaco instance in the app — the preview is a Rust
`render_markdown` HTML pipeline and there is no diff editor — so there is
nowhere else to apply this. `editor.updateOptions` merges and does not pass
`unicodeHighlight`, so setting it once at creation holds.

The four tests execute the option object rather than matching source text for
a string: the literal is lifted out of the real `monaco.editor.create` call
with the TypeScript parser, evaluated, merged over Monaco's shipped defaults
through `EditorOptions.unicodeHighlight.applyUpdate`, and run through
`UnicodeTextModelHighlighter` — the code that decides what gets a box. One of
them asserts the fixtures still reproduce the bug once the option is taken
away, so a Monaco upgrade that changed the confusable set fails loudly
instead of leaving the other three asserting nothing.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit b8199f9 into sftwrdotdev:master Aug 5, 2026
4 checks passed
@PathGao
PathGao deleted the fix/cjk-punctuation-not-outlined branch August 5, 2026 14:33
PathGao added a commit that referenced this pull request Aug 6, 2026
The review discipline here is real but undocumented: a mechanism section that
explains why the old behaviour happened rather than what was done about it, a
Scope section saying what was deliberately left alone, a falsification step on
fixes, and a Verification section with the exact commands, their counts and an
honest list of what was not checked. #468, #464, #462, #460 and #458 all have
that shape. Nobody arriving from outside can know it. #463 came close by
instinct, which is the argument for writing it down rather than hoping.

Five headings, prompts only, no checkboxes. A checkbox that feels mandatory is
a required field wearing a disguise, and friction is what makes a contributor
abandon a template rather than fill it in; the header says outright that every
section can be deleted. No licensing, conduct or "I read the guide" line --
there is no contributing guide to read.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
PathGao added a commit that referenced this pull request Aug 8, 2026
…scripts (#546)

Both come out of the audit in #393. Neither is a bug in Markpad — they are
Monaco defaults that are right for code in English and wrong for prose in a
script that behaves differently.

## The space bar under a CJK IME is outlined

#462 turned off `unicodeHighlight.ambiguousCharacters` and closed #186 and
#94 with it. The boxes came off the fullwidth punctuation and stayed on
U+3000 IDEOGRAPHIC SPACE, which is what the space bar produces under every
Chinese and Japanese IME — the same reporters, the same typing, the same
hover offering an **Adjust settings** button that leads nowhere in standalone
Monaco.

That character is caught by `invisibleCharacters`, a different flag, whose
465-entry set cannot be narrowed by `allowedLocales`: `strings.js` getData()
flattens every locale bucket into one set, so U+3000 is in it unconditionally.
Only `allowedCharacters` reaches it.

The flag itself stays on, and that is deliberate. An NBSP after `-` stops a
list from parsing, and nobody types one on purpose. Worth recording that the
comment justifying it was wrong in the other direction: it claimed the
highlighter "never fires on something typed on purpose", and U+3000 is exactly
that.

## Word-wise navigation treats a Chinese clause as one word

⌥←/→, double-click-to-select and ⌥⌫ split on `wordSeparators`. A language that
puts no spaces between its words supplies none, so the whole clause is one
word: ⌥→ crosses the sentence, double-click selects it, ⌥⌫ deletes it.

`wordSegmenterLocales` switches on `Intl.Segmenter`, which knows where the
words are.

It reads like a whitelist and is closer to a switch. ICU dispatches its
dictionary breaking by SCRIPT, not by this list: Thai, Khmer, Lao, Burmese and
Tibetan all segment without being named, and `['zh']`, `['ja']` and
`['zh','ja']` produce identical output — including on Han text, where the two
dictionaries might have been expected to differ. Pinned in a test, because the
obvious tidy-up is to trim the list to match the comment, which would change
nothing while making the code claim something false.

Space-delimited languages lose nothing: Korean, Vietnamese, Russian and
English segment word-for-word identically to splitting on whitespace. That is
why this can be on for everyone rather than keyed to the UI language — which
would be the wrong key anyway, since it is the document's script that decides,
not the language of the menus.

## Tests

Driven through Monaco's own code rather than by asserting the options are
spelled correctly: `UnicodeTextModelHighlighter.computeUnicodeHighlights` for
the first, `getMapForWordSeparators` — the function `wordOperations.js` and
`cursorWordOperations.js` both call — for the second. Each fixture is also
checked to still reproduce the original bug once the fix is taken away, so a
passing test cannot mean the fixture stopped exercising anything.

Verified in Node's ICU. The dictionary breaking is standard across ICU builds,
but it was not exercised in WKWebView, WebView2 or WebKitGTK.

Co-authored-by: PathGao <gaoyanbo@gaoyanbodeMacBook-Air.local>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

there are highlighted boxes in v2.6.11 Chinese punctuation marks are displayed inside a box,i want to hide the box.

1 participant