Skip to content

fix: Text indices are character-based; add Doc(offset_kind=...) - #379

Open
xrl wants to merge 22 commits into
y-crdt:mainfrom
xrl:308-fix-utf16-offset-encoding
Open

xrl wants to merge 22 commits into
y-crdt:mainfrom
xrl:308-fix-utf16-offset-encoding

Conversation

@xrl

@xrl xrl commented Apr 10, 2026 •

Copy link
Copy Markdown

Summary

  • Add Doc(offset_kind="utf8" | "utf16") (default "utf8", the yrs default) controlling how yrs counts text positions internally
  • Convert Python character (code point) indices to the doc's offset units in all Text mutation methods — insert, insert_embed, __setitem__, __delitem__, format, __iadd__
  • len(text) now returns the Python character count
  • Export get_utf8_index / get_utf16_index conversion helpers
  • 30+ new Unicode tests (emoji, CJK, combining marks, ZWJ, surrogate pairs), parametrized over both offset kinds

The bug

pycrdt passed Python character indices straight to yrs, which interprets them in the doc's offset units (UTF-8 bytes by default). Any index past a multi-byte character landed in the wrong place — or mid-character:

doc = Doc()
doc['t'] = t = Text()
t += 'A📊B'
t.insert(2, 'X')   # before: 'A📊BX' (or a yrs panic) — after: 'A📊XB'

On main, the new test cases produce wrong results and pyo3_runtime.PanicException panics from yrs (see @dlqqq's run earlier in this thread). With the conversion in place, all operations are character-based in both offset kinds.

What offset_kind does (and doesn't do)

Earlier revisions of this description claimed UTF-16 was required for yjs compatibility — @jbdyn was right to push on that, and it's now corrected. The update wire format is identical for both kinds (struct clocks are UTF-16 code units either way); the tests now sync docs across mismatched kinds to pin that down, and I've verified content round-trips against yjs 13.6.31 in both modes. offset_kind controls how indices passed to yrs APIs are interpreted. "utf16" matches JS yjs index semantics, which matters when raw yrs offsets cross runtimes — sticky indices (#382) and event delta units (discussed above).

Heads-up for release planning

Making indices character-based is a behavior change for callers that pre-converted to byte offsets as a #308 workaround — jupyter_ydoc's YUnicode.set() (jupyter-server/jupyter_ydoc#370) is a released example and would corrupt against this change. Since jupyter_ydoc pins pycrdt >=0.14,<0.15, shipping this as 0.15.0 with a coordinated jupyter_ydoc update (drop the byte-diff workaround when pycrdt >= 0.15) avoids any window where both fixes are active. Happy to open that jupyter_ydoc PR.

Index semantics

Indices follow Python conventions in both offset kinds: out-of-range slice bounds clamp (del t[1:100] ≡ del t[1:]), negative indices count from the end. Note negative int keys previously raised OverflowError on main; they now resolve Python-style, matching __getitem__. If you'd rather keep rejecting them I can do that instead — the important property is that both offset kinds behave identically, which is now tested.

Follow-ups already split out: #381 (XmlText, stacked on this), #382 (StickyIndex), TextEvent.delta units (flagged in-thread for discussion).

Fixes #308
Related: jupyter-ai-contrib/jupyter-server-documents#197

Set OffsetKind::Utf16 on yrs Doc so the wire format uses UTF-16 code
unit offsets, matching JS yjs. Without this, pycrdt uses UTF-8 byte
offsets, causing findIndexSS "Unexpected case" crashes when JS yjs
clients apply incremental updates containing multi-byte characters.

In the Python wrapper, convert character (code point) indices to
UTF-16 code unit indices before passing to yrs. This ensures
Text.insert(), __setitem__, __delitem__, and format() all work
correctly with emoji and other non-BMP characters.

Fixes: y-crdt#308
Related: jupyter-ai-contrib/jupyter-server-documents#197
xrl added a commit to xrl/jupyter-server-documents that referenced this pull request Apr 10, 2026
Replace the individual queued message replay in handle_sync_step1 with
a single batched diff computed from the pre-sync state vector.

The queue+replay approach (Approach B) correctly fixed the handshake
race (infra#307) but exposed a pycrdt offset encoding bug
(jupyter-server-documents#197) where incremental Text updates after
multi-byte characters crash JS yjs with findIndexSS "Unexpected case".

The batched catchup avoids the crash because all CRDT struct references
are resolvable within a single update, while also covering any mutations
that occurred during the handshake gap.

Upstream pycrdt fix: y-crdt/pycrdt#379
@krassowski

Copy link
Copy Markdown
Member

Thanks for the PR. I think this should include comprehensive tests.

See jupyter-server/jupyter_ydoc#370 for some prior art (using a different implementation, but you could also pull test cases out of there, though they will need adopting)

xrl added 2 commits April 10, 2026 14:44
Cover insert, delete, setitem, slice, len, and cross-doc sync with:
- emoji (surrogate pairs: 📊 🎉)
- CJK (BMP: 价格 世界 特征工程)
- Cyrillic (мир)
- supplementary plane (𝒜 𠀀)
- mixed scripts in one text

These all fail on stock pycrdt 0.12.50 and pass with the OffsetKind::Utf16 fix.
11 parametrized test cases adapted from jupyter-server/jupyter_ydoc#370
covering emoji swaps, flags, ZWJ family sequences, combining marks,
keycap sequences, RTL/LTR text, Japanese, and math operators.

These exercise Text insert/delete/replace via SequenceMatcher-based
diffing (the same pattern jupyter_ydoc.YUnicode.set() uses).
xrl added a commit to xrl/jupyter-server-documents that referenced this pull request Apr 10, 2026
Replace the individual queued message replay in handle_sync_step1 with
a single batched diff computed from the pre-sync state vector.

The queue+replay approach (Approach B) correctly fixed the handshake
race (infra#307) but exposed a pycrdt offset encoding bug
(jupyter-server-documents#197) where incremental Text updates after
multi-byte characters crash JS yjs with findIndexSS "Unexpected case".

The batched catchup avoids the crash because all CRDT struct references
are resolvable within a single update, while also covering any mutations
that occurred during the handshake gap.

Upstream pycrdt fix: y-crdt/pycrdt#379
@xrl

xrl commented Apr 10, 2026

Copy link
Copy Markdown
Author

@krassowski please take a look now 👌

@davidbrochart

Copy link
Copy Markdown
Collaborator

@xrl It looks like _utf16_to_char is not tested.

Addresses review feedback from @davidbrochart. Tests cover ASCII
(identity), BMP characters, supplementary plane (emoji), multiple
emoji, and roundtrip with _char_to_utf16.
@xrl

xrl commented Apr 14, 2026

Copy link
Copy Markdown
Author

@davidbrochart take a look now, thanks!

@davidbrochart

Copy link
Copy Markdown
Collaborator

Hmm I'm a bit confused that _utf16_to_char is actually not used. Should we remove it and the corresponding tests?

@davidbrochart

Copy link
Copy Markdown
Collaborator

@jbdyn Since you looked at that in #129, what do you think about this PR?

@jbdyn

jbdyn commented Apr 16, 2026

Copy link
Copy Markdown
Contributor

@davidbrochart I am currently looking into this, but I need a bit of time.

@jbdyn

jbdyn commented Apr 16, 2026 •

Copy link
Copy Markdown
Contributor

The __iadd__ of Text does not behave:

from pycrdt import Doc, Text

d = Doc()
t = d["t"] = Text()
t += "A📊B"
t += "X"
print(str(t))

which prints

A📊XB  # contrary to expected 'A📊BX'

Internally, Text.integrated.insert(txn, index, chars) needs the UTF-16 index, but Text.__len__ returns the Python character counts.

xrl and others added 2 commits April 17, 2026 00:05
…_char

Text.__iadd__ passed len(self) (Python character count) to the yrs
insert, but yrs expects a UTF-16 code unit index — so `t += "X"` after
an emoji landed inside the surrogate pair. Convert the index through
_char_to_utf16, matching every other mutating method.

Also removes _utf16_to_char and its tests, which had no callers.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
xrl added a commit to xrl/pycrdt that referenced this pull request Apr 17, 2026
XmlText mirrored the same bug that y-crdt#379 fixed in Text: every mutating
method passed a raw Python character index to yrs, but yrs (with
OffsetKind::Utf16 set on the Doc) expects UTF-16 code unit offsets.
Non-BMP content (emoji, supplementary plane) landed at the wrong
position or split surrogate pairs.

Convert the index through _char_to_utf16 in insert, insert_embed,
format, and __delitem__ (handling surrogate-pair length for single
deletes). __iadd__ inherits the fix via self.insert.

Also change __len__ to return Python character count, matching Text.
Previously it returned yrs' UTF-16 length, which disagreed with the
string returned by str(self) for non-BMP characters.

Depends on y-crdt#379 for the OffsetKind::Utf16 doc option.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xrl

xrl commented Apr 17, 2026

Copy link
Copy Markdown
Author

Further review of the surface area turned up three more spots touched by the OffsetKind::Utf16 change. Rather than balloon this PR, I've split them out:

  • fix: use UTF-16 offsets for XmlText operations #381 — XmlText has the same bug as Text (unconverted offsets in insert/insert_embed/format/__delitem__/__iadd__, plus __len__ returning UTF-16 count). Opened as a draft PR stacked on this one; will mark ready once this lands.
  • StickyIndex: UTF-16 offsets not converted for Text sequences #382 — StickyIndex doesn't convert in either direction for Text/XmlText. Filed as an issue rather than a PR because the fix has design questions around serialized indices and their source sequence.
  • TextEvent.delta / XmlEvent.delta — retain/delete counts bubble out of yrs as UTF-16 code units. This might actually be the right contract for yjs wire compat — flagging here for discussion rather than changing unilaterally. Let me know if you'd prefer a separate issue for it.

@jbdyn

jbdyn commented Apr 17, 2026 •

Copy link
Copy Markdown
Contributor

I cannot reproduce the garbling example

doc['source'] += '# Analysis 📊\n'           # 15 chars, but yrs sees 18 bytes
doc['source'].insert(len(text), 'model()\n')  # Python says 15, yrs reads 15
                                               # → offset 15 in bytes lands INSIDE 📊

given in the PR description on the main branch.

When running

from pycrdt import Doc, Text

d = Doc()
t = d["t"] = Text()
text = "# Analysis 📊\n"
t += text
t.insert(len(text), 'model()\n')
print(str(t))

I get

# Analysis 📊\nmodel()\n

and not # Analysis 📊\nmod📊el()\n.

Is yjs still crashing then?

@dlqqq

dlqqq commented Apr 20, 2026 •

Copy link
Copy Markdown
Collaborator

@jbdyn I wasn't able to reproduce that specific error either, but I am definitely able to reproduce other issues with pycrdt's existing handling of multi-byte characters using the test cases added in this PR.

# fetch PR
git remote add xrl git@github.com:xrl/pycrdt.git
git fetch xrl
git switch 308-fix-utf16-offset-encoding

# check out new test suite on main branch
git checkout main
git checkout 308-fix-utf16-offset-encoding -- tests/test_text.py

# activate env and run new tests against main
uv venv # (if needed)
source .venv/bin/activate
uv pip install -e .
pytest tests/test_text.py

The test cases fail on the main branch with some runtime panics (click to show):

Details
----------------------------------------------------------------------------------------------------------------------------- Captured stderr call -----------------------------------------------------------------------------------------------------------------------------

thread '<unnamed>' (75872785) panicked at /Users/dlq/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/yrs-0.25.0/src/types/text.rs:845:9:
Couldn't remove 2 elements from an array. Only 0 of them were successfully removed.
=========================================================================================================================== short test summary info ============================================================================================================================
FAILED tests/test_text.py::test_unicode_emoji_insert - assert 6 == 3
FAILED tests/test_text.py::test_unicode_emoji_len - assert 6 == 3
FAILED tests/test_text.py::test_unicode_emoji_delete - pyo3_runtime.PanicException: Couldn't remove 1 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_emoji_delete_emoji - AssertionError: Got 'A'
FAILED tests/test_text.py::test_unicode_emoji_slice_delete - AssertionError: Got 'A'
FAILED tests/test_text.py::test_unicode_emoji_setitem - pyo3_runtime.PanicException: Couldn't remove 1 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_emoji_slice_setitem - AssertionError: Got 'AXYZ'
FAILED tests/test_text.py::test_unicode_cjk - assert 7 == 3
FAILED tests/test_text.py::test_unicode_mixed_scripts - AssertionError: Got 'Hello 世界 м!ир 📊'
FAILED tests/test_text.py::test_unicode_supplementary_plane - assert 11 == 5
FAILED tests/test_text.py::test_unicode_granular_diff[emoji_swap] - pyo3_runtime.PanicException: Couldn't remove 1 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_granular_diff[text_change_emoji_stay] - AssertionError: Got 'Here is a star: ⭐ and a rocketar: 🚀', expected 'Here is a star: ⭐ and a rocket: 🚀'
FAILED tests/test_text.py::test_unicode_granular_diff[combining_marks] - pyo3_runtime.PanicException: Couldn't remove 5 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_granular_diff[flags] - AssertionError: Got 'Flags: 🇨🇦🇬🇧🇸🇬🇧🇨🇦', expected 'Flags: 🇨🇦🇬🇧🇺🇸'
FAILED tests/test_text.py::test_unicode_granular_diff[zwj_family] - pyo3_runtime.PanicException: Couldn't remove 2 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_granular_diff[rtl_ltr] - pyo3_runtime.PanicException: Couldn't remove 1 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_granular_diff[keycap] - pyo3_runtime.PanicException: Couldn't remove 1 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_granular_diff[emoji_boundaries] - pyo3_runtime.PanicException: Couldn't remove 1 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_granular_diff[japanese] - pyo3_runtime.PanicException: Couldn't remove 2 elements from an array. Only 0 of them were successfully removed.
FAILED tests/test_text.py::test_unicode_granular_diff[math_operators] - pyo3_runtime.PanicException: Couldn't remove 2 elements from an array. Only 0 of them were successfully removed.
======================================================================================================================== 20 failed, 16 passed in 0.63s =========================================================================================================================

This exactly matches the behavior we are seeing in AI extensions when they attempt to add multi-byte characters like emojis into notebooks using RTC MCP tools.

@dlqqq dlqqq left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xrl Looks good, thank you so much for working on this! The approach is correct and the test coverage is thorough. Approving.

@davidbrochart I think that this PR is good to merge - what do you think?

Also, I think there are two edge cases that aren't covered by this PR. I don't think that these are worth blocking the PR over since they are not regressions, but would be good to address as a follow-up.

  1. sticky_index feature: StickyIndex.new() passes the Python code point index directly to yrs without converting to UTF-16 code units, and get_index() returns the raw yrs UTF-16 offset without converting back. This will be wrong for text containing characters above U+FFFF. Not a regression since this wouldn't have worked before anyways.

  2. TextEvent deltas from observe(): The retain/delete lengths in change events come from yrs in UTF-16 code units. We should convert the UTF-16 code unit offsets back to the code point offsets that Python consumers expect. This isn't a regression either, since we weren't converting UTF-8 byte offsets back to code point offsets before anyways.

Comment thread python/pycrdt/_text.py Outdated
self.integrated.remove_range(txn._txn, key, 1)
utf16_idx = _char_to_utf16(current, key)
char_at = current[key]
utf16_len = 2 if ord(char_at) > 0xFFFF else 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some digging into this. This is counting the number of UTF-16 code units per code point. Python counts length in code points.

Each code point is always either 1 or 2 UTF-16 code units in length; this is defined by the UTF-16 encoding itself. A surrogate pair (a Python code point comprised of 2 code units) is always composed of a high surrogate (>0xFFFF) and a low surrogate (<=0xFFFF). So this logic is correct.

Since Python indexes strings using code points, the _char_to_utf16() function can be implemented similarly by iterating over every code point and add 1 for each high surrogate to compute the UTF-16 byte offset. That is what sum(1 for ch in prefix if ord(ch) > 0xFFFF) does. So that function is correct as well.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Generated a small table for reference:

Visual character Grapheme clusters (humans) Code points (Python) UTF-16 code units (Yjs)
A 1 1 1
é (precomposed, U+00E9) 1 1 1
é (decomposed, U+0065 + U+0301) 1 2 2
★ (U+2605) 1 1 1
中 (U+4E2D) 1 1 1
📊 (U+1F4CA) 1 1 2
🇺🇸 (flag) 1 2 4
👨‍👩‍👧‍👦 (family) 1 7 11
Hello 5 5 5

Comment thread src/doc.rs Outdated
Comment on lines +88 to +92
// Use UTF-16 offsets for compatibility with JS yjs clients.
// Without this, pycrdt uses UTF-8 byte offsets which causes
// findIndexSS crashes when JS yjs applies incremental updates
// containing multi-byte characters.
options.offset_kind = OffsetKind::Utf16;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like the correct approach. Yjs is inherently locked into UTF-16 code unit offsets for indices and lengths because JavaScript strings are encoded in UTF-16. This line configures pycrdt documents to also use UTF-16 code unit offsets for indices and lengths returned by Yrs's methods. This is pretty much required for compatibility with Yjs for containing anything except plain ASCII, since UTF-8 byte offsets != UTF-16 code unit offsets for everything outside plain ASCII (even in the BMP).

Reference: https://github.com/y-crdt/y-crdt/blob/195f0f4627be6b3fbc223bd3bfbadddc874458ca/yrs/src/doc.rs#L948

@davidbrochart davidbrochart left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that pycrdt should be hard-coded to use an offset kind of UTF-16. Just like Yrs, it should be parameterizable.

Comment thread python/pycrdt/_text.py Outdated
"""
with self.doc.transaction() as txn:
return self.integrated.len(txn._txn)
# Return Python character count, not yrs UTF-16 code unit count

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Return Python character count, not yrs UTF-16 code unit count

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 855af5c — dropped the inline comment and tightened the __len__ docstring to match.

Comment thread python/pycrdt/_text.py Outdated
from ._doc import Doc


def _char_to_utf16(text: str, char_index: int) -> int:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
def _char_to_utf16(text: str, char_index: int) -> int:
def get_utf16_index(text: str, char_index: int) -> int:

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed in 855af5c. Added a get_utf8_index counterpart since offset_kind is now parameterizable per #379 (commit 41c8cc5) — both are re-exported from the top-level package. The dispatcher itself stays private (one-line conditional).

Comment thread python/pycrdt/_text.py

For pure-ASCII / BMP text this is a no-op (returns ``char_index``
unchanged).
"""

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please use Google style docstrings (Args, Returns).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 855af5c — both get_utf16_index and get_utf8_index now use Google-style Args / Returns docstrings.

@jbdyn

jbdyn commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

I don't think that pycrdt should be hard-coded to use an offset kind of UTF-16.

Agreed, as it decides about yjs compatibility (thanks @dlqqq for the reference) and thus also about compatibility with other clients using the UTF-8 offset. The user needs to choose one.
Also, yrs might become the core implementation for all language wrappers/bindings at some point:

Eventually Yrs might replace the Yjs module by generating a wasm module from this package.

An offset parameter would come in handy during migration.

@jbdyn

jbdyn commented Apr 21, 2026

Copy link
Copy Markdown
Contributor

@davidbrochart With a paramterizable offset, Text and other datatypes also need a get_utf8_index conversion, right?

@davidbrochart

Copy link
Copy Markdown
Collaborator

@davidbrochart With a paramterizable offset, Text and other datatypes also need a get_utf8_index conversion, right?

I think so, yes.

xrl and others added 4 commits April 28, 2026 10:27
Adds an `offset_kind` parameter to `Doc(...)` so callers can choose
between UTF-8 byte offsets (the yrs default) and UTF-16 code unit
offsets (required for cross-runtime interop with JS yjs). Default is
"utf8", matching yrs.

The Text wrapper previously assumed UTF-16 unconditionally and passed
Python char indices through a UTF-16 conversion. This commit replaces
that with a dispatcher that picks UTF-8 or UTF-16 conversion based on
the doc's offset_kind, so Text behaves correctly in either mode while
the public API still takes Python character indices.

src/doc.rs gains a third positional arg on `Doc::new` accepting "utf8"
/ "utf-8" / "utf16" / "utf-16" / None (None preserves the yrs default).
Invalid values raise PyValueError. _from_snapshot_impl now reads the
offset kind from the source doc instead of hardcoding it. A new
`#[getter] offset_kind` returns the canonical "utf8" or "utf16" string.

_base.py forwards the new kwarg, and raises ValueError if both `doc=`
(an existing _Doc) and `offset_kind=` are supplied with disagreeing
values. _doc.py adds the kwarg to Doc.__init__, documents it, and
exposes a read-only `Doc.offset_kind` property.

Addresses davidbrochart's review on y-crdt#379 asking for parameterization
with a UTF-8 default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Addresses davidbrochart's three inline reviews on y-crdt#379:

- _text.py:14 — rename `_char_to_utf16` → `get_utf16_index` (public,
  per the suggested rename). Add a counterpart `get_utf8_index` since
  with offset_kind parameterization the conversion is needed in both
  directions (jbdyn + davidbrochart agreed in the thread). The
  dispatcher and single-char-length helpers stay private — trivial
  conditionals best read at the call site.
- _text.py:23 — both public helpers now use Google-style docstrings
  (Args / Returns sections), matching the rest of the module.
- _text.py:112 — drop the inline comment that duplicated the
  docstring; tighten the docstring to match.

Re-export the two helpers from `pycrdt.__init__` so consumers can
reach them without importing from the underscore module. Drop a
now-dead `_char_to_utf16` import in tests/test_text.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds an `offset_kind` pytest fixture parametrizing all Unicode tests
over both ``"utf8"`` and ``"utf16"`` modes. The same test bodies now
exercise the dispatcher in `Text` for both yrs offset configurations,
without changing the assertions — public API contract is always Python
char indices.

`test_unicode_cross_doc_sync` constructs both peers with the same
offset_kind (mismatched offset kinds across peers is unsupported by
yrs/yjs). New explicit tests cover:
- default-is-utf8
- explicit "utf8" / "utf-8" / "utf16" / "utf-16"
- ValueError on unknown values
- snapshot round-trip preserves offset_kind for both modes
- new public `get_utf16_index` / `get_utf8_index` helpers
  (identity / BMP CJK / non-BMP emoji)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xrl

xrl commented Apr 28, 2026

Copy link
Copy Markdown
Author

@krassowski take a look now, please

xrl and others added 2 commits April 28, 2026 10:35
The Rust-extension stub didn't reflect the new constructor signature
or the offset_kind getter, so `mypy python` failed in CI on _base.py
and _doc.py with "Too many arguments" / "no attribute offset_kind".

Add the third positional `offset_kind: str | None` to `Doc.__init__`
and declare the read-only `offset_kind` property.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fixes the ubuntu python-3.14 coverage regression on y-crdt#379. The new
mismatch check at python/pycrdt/_base.py:74 (raise ValueError when
both doc= and offset_kind= are passed and they disagree) wasn't
exercised by any pytest test, dropping coverage to 99% and tripping
the suite's fail-under=100 gate.

Add a one-shot regression test that constructs an existing Doc with
offset_kind="utf8", then attempts to wrap it as a new Doc with
offset_kind="utf16" and asserts ValueError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces configurable text offset semantics so pycrdt can safely operate on Unicode text (including non-BMP emoji) and interoperate with JS yjs when using UTF-16 code unit offsets.

Changes:

  • Add offset_kind to Doc construction (Rust + Python) and expose Doc.offset_kind to Python.
  • Update Python Text operations to convert Python character indices into the doc’s internal offset units (UTF-8 bytes or UTF-16 code units).
  • Add extensive Unicode-focused tests and export get_utf8_index / get_utf16_index helpers.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/doc.rs Adds offset_kind parsing to yrs Options and exposes an offset_kind getter.
python/pycrdt/_base.py Wires offset_kind through to the Rust _Doc and validates consistency when reusing an existing doc.
python/pycrdt/_doc.py Exposes Doc.offset_kind and documents the new constructor option.
python/pycrdt/_text.py Adds UTF-8/UTF-16 index conversion helpers and updates Text mutation methods to use converted offsets.
python/pycrdt/__init__.py Re-exports get_utf8_index / get_utf16_index as public helpers.
python/pycrdt/_pycrdt.pyi Updates type stubs for the new _pycrdt.Doc constructor arg and property.
tests/test_text.py Adds parameterized tests for Unicode behavior across both offset kinds and validates offset_kind API behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread python/pycrdt/_base.py Outdated
Comment on lines +72 to +76
doc = _Doc(client_id, skip_gc, offset_kind)
elif offset_kind is not None and offset_kind != doc.offset_kind:
raise ValueError(
f"offset_kind={offset_kind!r} does not match doc.offset_kind={doc.offset_kind!r}"
)

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The offset_kind mismatch check compares the raw user string to doc.offset_kind, but the constructor accepts hyphenated aliases (e.g. utf-16). Passing an existing _Doc plus offset_kind='utf-16' will currently raise even though it's semantically the same as 'utf16'. Normalize offset_kind to the canonical form (e.g. strip hyphens / lowercase) before comparing, or accept both spellings in the comparison.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 5e1df54 by dropping the hyphenated aliases entirely — with only 'utf8'/'utf16' accepted, the comparison is correct by construction. Added the accept-path test.

Comment thread tests/test_text.py Outdated
Comment on lines +421 to +423
# initial content, then applies a granular edit (using SequenceMatcher on
# byte offsets, matching how jupyter_ydoc.YUnicode.set() works), and verifies
# the result is correct.

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The block comment says the granular edit uses "SequenceMatcher on byte offsets", but _apply_diff() uses SequenceMatcher(a=old_value, b=new_value) on Python strings and then applies edits using character indices. Update the comment to avoid implying byte-based offsets here (since that distinction is important in this PR).

Suggested change
# initial content, then applies a granular edit (using SequenceMatcher on
# byte offsets, matching how jupyter_ydoc.YUnicode.set() works), and verifies
# the result is correct.
# initial content, then applies a granular edit using SequenceMatcher on
# Python strings / character indices in this helper, and verifies the result
# is correct.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9a73db6.

Comment thread src/doc.rs
Comment on lines 77 to 104
let mut options = Options::default();
if !client_id.is_none() {
let _client_id: u64 = client_id.cast::<PyInt>()
.map_err(|_| PyValueError::new_err("client_id must be an integer"))?
.extract()
.map_err(|_| PyValueError::new_err("client_id must be a valid u64"))?;
options.client_id = _client_id;
}
if !skip_gc.is_none() {
let _skip_gc: bool = skip_gc.cast::<PyBool>()
.map_err(|_| PyValueError::new_err("skip_gc must be a boolean"))?
.extract()
.map_err(|_| PyValueError::new_err("skip_gc must be a valid bool"))?;
options.skip_gc = _skip_gc;
}
if !offset_kind.is_none() {
let _offset_kind: String = offset_kind
.extract()
.map_err(|_| PyValueError::new_err("offset_kind must be a string"))?;
options.offset_kind = match _offset_kind.as_str() {
"utf8" | "utf-8" => OffsetKind::Bytes,
"utf16" | "utf-16" => OffsetKind::Utf16,
_ => return Err(PyValueError::new_err(
"offset_kind must be 'utf8' or 'utf16'",
)),
};
}
let doc = _Doc::with_options(options);

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constructor only sets options.offset_kind when offset_kind is explicitly provided, which means Doc() will still use the yrs default (OffsetKind::Bytes). That conflicts with the PR description/title claiming the Doc is configured to use UTF-16 offsets by default. Either set options.offset_kind = OffsetKind::Utf16 unconditionally (and adjust tests/docs accordingly) or update the PR description to reflect that UTF-16 is opt-in via offset_kind.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update the PR description to reflect that UTF-16 is opt-in via offset_kind

I suppose we agreed on this one.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Description and title updated to reflect the opt-in design.

Comment thread python/pycrdt/_text.py Outdated
Comment on lines +179 to +180
current = str(self)
offset = _char_to_offset(current, len(current), self.doc.offset_kind)

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

__iadd__ now calls str(self) just to compute the append offset, which makes text += ... O(n) in the current text size and allocates the whole string. For appends you can avoid this by using the underlying integrated length in the doc’s offset units (e.g. self.integrated.len(txn._txn)) as the insert index, since that already matches the offset kind.

Suggested change
current = str(self)
offset = _char_to_offset(current, len(current), self.doc.offset_kind)
offset = self.integrated.len(txn._txn)

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 63af1e2 — integrated.len(txn) is already in offset units. 2.4x faster appends on a ~1MB doc.

Comment thread python/pycrdt/_text.py Outdated
prefix = text[:char_index]
# Count characters that need a surrogate pair (code point > 0xFFFF)
extra = sum(1 for ch in prefix if ord(ch) > 0xFFFF)
return char_index + extra

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_utf16_index() returns char_index + extra, which becomes incorrect when char_index is out of range or negative because Python slicing clamps but char_index is not clamped (e.g. get_utf16_index('A📊B', 10) returns 11 instead of the end offset 4). This can produce offsets beyond the actual text length and break insert/delete/format calls in UTF-16 mode. Compute the result from the sliced prefix length (or clamp char_index to [0, len(text)]) so the returned UTF-16 offset always stays within bounds.

Suggested change
return char_index + extra
return len(prefix) + extra

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 87bb6d1 — both helpers now use Python slice-bound semantics (clamp out-of-range, negative from the end), documented and tested.

@krassowski

Copy link
Copy Markdown
Member

@xrl did you have a chance to look at the Copilot feedback here? It looks like it picked up a conflict since.

xrl and others added 7 commits June 12, 2026 01:17
get_utf16_index computed char_index + surrogate_count, which is only
valid for in-range non-negative indices:

- negative indices: the utf16 doc silently edited the wrong position
  (del text[-1] on 'A📊B' removed 'A'), while utf8 docs resolved them
  per Python slicing — the two offset kinds visibly diverged.
- out-of-range slice stops: utf16 docs raised a pyo3 PanicException
  from yrs remove_range and left the document partially mutated
  (text[1:100] = 'Z' removed the tail but never inserted 'Z'), while
  utf8 docs clamped like Python.

Both helpers now share the same contract, documented in their
docstrings: indices are interpreted like Python slice bounds (clamped
when out of range, from the end when negative). Implementing the utf16
helper via encode('utf-16-le') also replaces a per-character Python
loop with a single C-level pass (~30x faster on large prefixes).

This resolves the Copilot review comment about the unclamped helper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Rust constructor also accepted hyphenated spellings ('utf-8',
'utf-16') that no reviewer asked for, that the error message and
docstrings never mentioned, and that broke the doc-reuse check in
BaseDoc.__init__: Doc(doc=existing, offset_kind='utf-16') raised a
spurious ValueError because the raw user string was compared against
the normalized doc.offset_kind. With only the two canonical spellings
accepted, that comparison is correct by construction.

Type the parameter as Literal['utf8', 'utf16'] | None (and the
property as Literal['utf8', 'utf16']) following the existing
convention, e.g. Awareness.observe's topic parameter.

This resolves the Copilot review comment about the alias mismatch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Text.__iadd__ built the whole document string and converted its length
just to find the append offset; integrated.len(txn) already returns
that offset in the doc's native units. Appending to a ~1MB document
drops from 2.3ms to 0.97ms per call, and += no longer scales with the
conversion cost of the existing text.

_check_slice now takes the length instead of computing len(self)
internally, which built the document string a second time in
__delitem__/__setitem__ (for stop=None slices, e.g. del text[5:] and
clear()) and in format(), on top of the current = str(self) those
methods already hold.

This resolves the Copilot review comment about the O(n) __iadd__.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- The 'combining_marks' granular-diff case contained only precomposed
  code points (U+00E1 etc.), so it never exercised combining marks.
  Use decomposed base + U+0301 sequences, written as explicit escapes,
  so SequenceMatcher opcodes can split a base character from its mark.
- Parametrize the cross-doc sync test over the offset kinds of both
  peers. The update wire format is the same for both kinds (offset_kind
  only affects how API indices are interpreted), so documents with
  different offset kinds stay in sync; the previous docstring claimed
  the opposite.
- Fix the stale comment saying granular diffs are applied 'using
  SequenceMatcher on byte offsets' — _apply_diff diffs on characters,
  as its own docstring states. This resolves the corresponding Copilot
  review comment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add a 'Text indices and Unicode' section to the usage docs: Text
  takes Python character indices; offset_kind selects the internal
  yrs counting units; utf16 matches JS yjs index semantics.
- Add get_utf8_index/get_utf16_index to the API reference members.
- Correct the Doc.__init__ docstring: utf16 is not 'required for
  cross-runtime compatibility with JS yjs' — the update wire format is
  the same for both offset kinds (verified: documents with different
  offset kinds, and yjs peers, converge on the same content). The
  offset kind matters when raw yrs offsets are shared with yjs peers
  (sticky indices, event deltas). Also note that subdocuments carry
  the offset kind of the peer that created them, and that character
  indices address the text content as returned by str() (embeds are
  not accounted for).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The two-character local read like a boolean flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@xrl xrl changed the title fix: use UTF-16 offsets for Text operations fix: Text indices are character-based; add Doc(offset_kind=...) Jun 12, 2026
@xrl

xrl commented Jun 12, 2026

Copy link
Copy Markdown
Author

Pushed a round of updates — this should be ready for another look:

  • Merged main (yrs 0.27 / v0.14.0) and resolved the src/doc.rs conflict.
  • Index edge cases (87bb6d1): the conversion helpers now have Python slice-bound semantics — out-of-range clamps, negative counts from the end — identically in both offset kinds. Before this, a utf16 doc would silently edit the wrong position for del t[-1] and panic (with partial mutation) on del t[1:100]. Both offset kinds now behave identically across every operation, and the new tests assert it.
  • Dropped the "utf-8"/"utf-16" aliases (5e1df54): only the canonical spellings are accepted, which also fixes the doc-reuse mismatch check Copilot flagged. offset_kind is typed Literal["utf8", "utf16"] following the existing convention.
  • Perf (63af1e2): += no longer materializes the document (integrated.len is already in offset units) — 2.4× faster appends on a 1MB doc; removed double string builds in del text[5:] / clear() / format().
  • Docs (5566062): usage section on character indices and offset_kind, helpers added to the API reference, and the Doc docstring no longer overclaims what offset_kind does.
  • Description rewritten per the earlier agreement — including a correction: @jbdyn was right that the wire format isn't the issue. Mixed offset-kind docs sync fine (now tested); offset_kind is about index semantics, not update compatibility.

One thing I want maintainer eyes on: released jupyter_ydoc works around #308 by passing UTF-8 byte offsets into Text (jupyter-server/jupyter_ydoc#370). Character-based indices break that workaround, so this likely wants to be 0.15.0 with a lockstep jupyter_ydoc change — happy to open that PR.

@xrl
xrl requested a review from krassowski June 16, 2026 02:01
@xrl

xrl commented Jun 30, 2026

Copy link
Copy Markdown
Author

@krassowski I reworked things a bit, it should be better now

@dergachoff

Copy link
Copy Markdown

Small edge case from 0.14.2 / #408: embeds take one position but disappear from str(text), while this PR derives offsets from that string.

text = Text("AB")
doc["t"] = text
text.insert_embed(1, Map())
text.insert(len(str(text)), "X")
assert str(text) == "ABX"  # gets "AXB"

Might be worth adding an embed test when rebasing onto main.

@xrl
xrl requested a review from davidbrochart September 19, 2026 19:39
@xrl

xrl commented Sep 19, 2026

Copy link
Copy Markdown
Author

@davidbrochart @krassowski I’ve merged current main and added embed-aware Text indexing with regression coverage in both offset modes. All 351 tests pass locally. Could you take another look?

@dergachoff thanks for catching the embed issue—would appreciate your eyes on the new indexing contract. Embeds now count as one position; nonempty string-valued embeds explicitly reject indexed operations because Yrs’ delta API cannot distinguish them from text runs.

@dergachoff

Copy link
Copy Markdown

@xrl Thanks for following up! I checked mixed Unicode and embeds in both offset modes, including rejected edits leaving the text unchanged. I didn't find any further issues with the new contract 👍

This branch has not been deployed

No deployments
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.

Unexpected text behavior for XmlText (possibly Text)

7 participants