Skip to content

Fix lone surrogates in run text throwing during normalization and save - #414

Open
johnml1135 wants to merge 7 commits into
sillsdev:masterfrom
johnml1135:fix/lone-surrogate-run-text
Open

johnml1135 wants to merge 7 commits into
sillsdev:masterfrom
johnml1135:fix/lone-surrogate-run-text

Conversation

@johnml1135

@johnml1135 johnml1135 commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #412

On master a lone (unpaired) surrogate cannot enter the model: every TsString setter throws in TsStringUtils.NormalizeNfd. #412's repro reaches the serializer only for strings that bypass the model.

Design

  • Input: NormalizeNfd(ITsString), the choke point for the generated setters, custom String fields, MultiAccessor.set_String, DomainDataByFlid.SetString and SetMultiStringAlt, replaces each lone surrogate with U+FFFD, keeping length and run properties. The generated and custom plain Unicode setters and WfiWordformRepository.TryGetObject do the same. Stored text equals in-memory text, so segment offsets and analyses survive save and reload.
  • Normalization: TsString passes a lone surrogate through, as ICU does.
  • Serializer backstop: run text from TsStrings that skip the model gets U+FFFD, replaced before invalid characters are stripped, so lengths do not change and no run is written empty. A valid pair split across two runs becomes two U+FFFD. Invalid control characters are still deleted, as on master (tracked in Control characters in run text are deleted on save, so Segment.BeginOffset and run boundaries shift after reload #415).
  • IsCharWordForming steps back only over a real pair.

Behaviour change: text that used to throw now stores U+FFFD silently, including for direct callers of NormalizeNfd(ITsString) and plain Unicode setters such as LexEntry.LiftResidue.

Known gap: search-query text in StringSearcher and FindWordFormInString on raw non-model strings can still throw on a lone surrogate; neither stores data.

Merges

Tests: setters, plain Unicode and custom Unicode setters, wordform lookup, parser, fuzz, serializer order, and parse, save, reload of segment offsets. Green on net462 and net8.0.

🤖 Generated with Claude Code


This change is Reviewable

johnml1135 and others added 3 commits November 5, 2026 17:25
A TsString containing an unpaired UTF-16 surrogate threw from
get_NormalizedForm (EnumerateSegmentLimits -> char.ConvertToUtf32), so it
could not be saved. TsString normalization now passes a lone surrogate
through unchanged (as ICU does), and TsStringSerializer drops lone
surrogates when writing run text so the XML reloads. CharAt,
IsCharWordForming and WordMaker.IsWhite no longer throw either.

Adds Surrogates.CodePointAt, a ConvertToUtf32 that tolerates lone
surrogates.

Fixes sillsdev#412

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… on save

Review of sillsdev#414: stripping a lone surrogate on write let stored text differ
from in-memory text, so segment offsets and analyses were wrong after a
save and reload. On master a lone surrogate could not enter the model at
all (every TsString setter threw), so:

- TsStringUtils.NormalizeNfd(ITsString), which every model setter goes
  through, now replaces each lone surrogate with U+FFFD (same length).
- TsStringSerializer replaces with U+FFFD instead of deleting, as a
  backstop for TsStrings that never went through the model, and decides
  xml:space from the text it writes.
- IsCharWordForming steps back over a low surrogate only when a high
  surrogate precedes it.
- Restore the BOM in TsString.cs; clarify Surrogates.CodePointAt docs;
  use \u escapes in tests; add parser, fuzz and save/reload tests.

Fixes sillsdev#412

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
.NET Framework string.Normalize rejects unassigned code points such as the
D800+DFFF pair, so the net462 run of the fuzz failed in the test itself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
johnml1135 and others added 2 commits November 5, 2026 18:58
…r custom String fields

- GetXmlSafeText replaces lone surrogates first, so it keeps the length
  whether or not StripInvalidXmlChars also removes lone surrogates (as
  sillsdev#410's regex does), and removing a control character cannot join a lone
  high and low surrogate into a false pair. Test pins the order.
- CmObject.SetCustomPropertyForSDA runs NormalizeNfd on custom String
  fields, like model String properties. Test added.
- Tests use \u escapes for every non-ASCII character.
- CHANGELOG: say that callers relying on the exception now get U+FFFD.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
johnml1135 and others added 2 commits November 5, 2026 21:26
The generated setter for plain Unicode properties (propaccessors_simple
template) and WfiWordformRepository.TryGetObject called string.Normalize,
which throws on an unpaired surrogate. Replace it with U+FFFD first, as
the TsString paths do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… expectations

A custom Unicode field set to a lone surrogate stored it raw, and the next
Save() failed the whole project save. SetCustomPropertyForSDA now handles
CellarPropertyType.Unicode like the generated setter (replace with U+FFFD,
then NFD). Test first: file-backed cache, custom Unicode field, Save.

Also write expected U+FFFD values in tests as \u escapes, and update the
CHANGELOG wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

Saving run text that contains a lone (unpaired) surrogate throws in TsString normalization

1 participant