Skip to content

Commit f7d15cc

Browse files
LeonarddeRclaude
andcommitted
Add ICU sentence segmentation to OffsetsTextInfo
textUtils.icu gains calculateSentenceOffsets using the SENTENCE break iterator. OffsetsTextInfo._getSentenceOffsets segments the containing paragraph with it instead of raising NotImplementedError. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e39de83 commit f7d15cc

4 files changed

Lines changed: 226 additions & 6 deletions

File tree

source/textInfos/offsets.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@
1616
import locationHelper
1717
from treeInterceptorHandler import TreeInterceptor
1818
import textUtils
19+
from textUtils import icu
1920
from textUtils.segFlag import CharSegFlag, WordSegFlag
2021
from textUtils._wordSeg.wordSegmenter import WordSegmenter
22+
from winBindings.icu import ICU_AVAILABLE
2123
from dataclasses import dataclass
2224
from typing import (
2325
Any,
@@ -556,13 +558,30 @@ def _getLineOffsets(self, offset):
556558
return [start, end]
557559

558560
def _getSentenceOffsets(self, offset: int) -> tuple[int, int]:
559-
"""
560-
Gets the start and end offsets of the sentence containing the given offset.
561+
"""Gets the start and end offsets of the sentence containing the given offset.
562+
563+
Sentences are segmented over the containing paragraph (:meth:`_getParagraphOffsets`)
564+
using the Windows built-in ICU BreakIterator (UAX#29), so a sentence can span
565+
multiple lines.
566+
561567
:param offset: The offset of the character within the sentence.
562568
:return: A tuple of the start and end offsets of the sentence.
563-
:raise NotImplementedError: If the method is not implemented.
569+
:raises NotImplementedError: If ICU is unavailable (Windows older than version 1703)
570+
or the ICU call fails.
564571
"""
565-
raise NotImplementedError
572+
if not ICU_AVAILABLE:
573+
raise NotImplementedError
574+
paragraphStart, paragraphEnd = self._getParagraphOffsets(offset)
575+
paragraphText = self._getTextRange(paragraphStart, paragraphEnd)
576+
result = icu.calculateOffsetsForEncoding(
577+
icu.calculateSentenceOffsets,
578+
paragraphText,
579+
offset - paragraphStart,
580+
self.encoding,
581+
)
582+
if result is None:
583+
raise NotImplementedError
584+
return (result[0] + paragraphStart, result[1] + paragraphStart)
566585

567586
def _getParagraphOffsets(self, offset):
568587
return self._getLineOffsets(offset)

source/textUtils/icu.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
from logHandler import log
1818

1919
_ROOT_LOCALE: bytes = b""
20-
"""ICU root locale. Word and character segmentation are script-driven, not
21-
locale-driven (see calculateWordOffsets), so the root locale is always used.
20+
"""ICU root locale. Word, character and sentence segmentation are script-driven, not
21+
locale-driven (see calculateWordOffsets and calculateSentenceOffsets), so the root
22+
locale is always used.
2223
"""
2324

2425

@@ -147,6 +148,37 @@ def calculateWordOffsets(
147148
return None
148149

149150

151+
def calculateSentenceOffsets(
152+
text: str,
153+
offset: int,
154+
) -> tuple[int, int] | None:
155+
"""Calculate the UTF-16 start and end offsets of the sentence at the given offset.
156+
157+
Sentence boundaries follow Unicode Standard Annex #29 default rules, driven by the
158+
language-neutral Sentence_Break property (STerm/ATerm terminators such as ".", "!",
159+
"?" and the ideographic full stop "。"), so the root locale is used. Abbreviation
160+
tailoring, which would keep "Dr." from ending a sentence, is locale-specific and is
161+
therefore not applied. Trailing whitespace and punctuation are attached to the
162+
preceding sentence.
163+
164+
:param text: The paragraph text as a Python str.
165+
:param offset: UTF-16 code unit offset within text at which to find the boundary.
166+
:return: (startOffset, endOffset) as UTF-16 code unit indices (endOffset exclusive),
167+
or None if the ICU call failed.
168+
"""
169+
buf = ctypes.create_unicode_buffer(text)
170+
textLength = len(buf) - 1
171+
if offset >= textLength:
172+
return (offset, offset + 1)
173+
174+
try:
175+
with _breakIterator(icu.UBRK.SENTENCE, _ROOT_LOCALE, buf) as bi:
176+
return _containingSegment(bi, offset, textLength)
177+
except RuntimeError:
178+
log.debugWarning("ICU sentence break iterator failed", exc_info=True)
179+
return None
180+
181+
150182
def calculateOffsetsForEncoding(
151183
calculate: Callable[[str, int], tuple[int, int] | None],
152184
text: str,

source/winBindings/icu.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ class UBRK(IntEnum):
5252
WORD = 1
5353
"""Word breaks."""
5454

55+
SENTENCE = 3
56+
"""Sentence breaks (UAX#29)."""
57+
5558

5659
def U_FAILURE(code: int) -> bool:
5760
"""Return True if the given UErrorCode indicates an error."""
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
# A part of NonVisual Desktop Access (NVDA)
2+
# Copyright (C) 2026 NV Access Limited, Leonard de Ruijter
3+
# This file may be used under the terms of the GNU General Public License, version 2 or later, as modified by the NVDA license.
4+
# For full terms and any additional permissions, see the NVDA license file: https://github.com/nvaccess/nvda/blob/master/copying.txt
5+
6+
"""Unit tests for ICU sentence segmentation.
7+
8+
Covers the low-level ``textUtils.icu.calculateSentenceOffsets`` primitive and the
9+
``OffsetsTextInfo._getSentenceOffsets`` integration, including the iteration/tiling
10+
invariant that ``move``/``expand`` rely on. Tests that require ICU are skipped when
11+
the ICU library is not present on the system.
12+
"""
13+
14+
import unittest
15+
from unittest.mock import patch
16+
17+
import textInfos
18+
from textInfos import offsets as offsetsModule
19+
from textInfos.offsets import Offsets
20+
from textUtils import icu
21+
22+
from ..textProvider import BasicTextInfo, BasicTextProvider
23+
from . import skipIfNoICU
24+
25+
26+
class _BlockParagraphTextInfo(BasicTextInfo):
27+
"""A TextInfo whose paragraphs are blocks separated by a blank line ("\\n\\n").
28+
29+
Mimics the block-level bounds VirtualBufferTextInfo._getParagraphOffsets returns,
30+
rather than the line splitting BasicTextInfo does. Test text is ASCII, so str
31+
offsets equal UTF-16 offsets.
32+
"""
33+
34+
def _getParagraphOffsets(self, offset):
35+
text = self._getStoryText()
36+
start = text.rfind("\n\n", 0, offset)
37+
start = 0 if start < 0 else start + 2
38+
end = text.find("\n\n", offset)
39+
end = len(text) if end < 0 else end + 2
40+
return (start, end)
41+
42+
43+
class _BlockParagraphProvider(BasicTextProvider):
44+
TextInfo = _BlockParagraphTextInfo
45+
46+
47+
@skipIfNoICU
48+
class TestCalculateSentenceOffsets(unittest.TestCase):
49+
"""Low-level UAX#29 sentence boundary tests (UTF-16 code-unit offsets, root locale)."""
50+
51+
def test_english_two_sentences(self):
52+
"""Trailing space after the terminator is attached to the preceding sentence (UAX#29)."""
53+
text = "Hello world. Goodbye now."
54+
self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 13))
55+
# Mid-sentence offsets resolve to the same sentence.
56+
self.assertEqual(icu.calculateSentenceOffsets(text, 5), (0, 13))
57+
self.assertEqual(icu.calculateSentenceOffsets(text, 12), (0, 13))
58+
self.assertEqual(icu.calculateSentenceOffsets(text, 13), (13, 25))
59+
60+
def test_japanese_ideographic_full_stop(self):
61+
"""U+3002 (。) is Sentence_Break=STerm, so Japanese segments without a locale."""
62+
text = "これは日本語です。次の文です。"
63+
self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 9))
64+
self.assertEqual(icu.calculateSentenceOffsets(text, 9), (9, 15))
65+
66+
def test_abbreviation_splits_under_root_locale(self):
67+
"""Under the root locale, which has no abbreviation tailoring, "Dr." ends a sentence."""
68+
text = "Dr. Smith went home."
69+
self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 4))
70+
self.assertEqual(icu.calculateSentenceOffsets(text, 4), (4, 20))
71+
72+
def test_surrogate_pair_offsets(self):
73+
"""Offsets are UTF-16 code-unit indexed; a surrogate pair counts as two units."""
74+
# H i sp [🤦 = 2 units] sp t h e r e . sp B y e sp n o w .
75+
text = "Hi \U0001f926 there. Bye now."
76+
# First sentence spans the surrogate pair and ends after the space at UTF-16 offset 13.
77+
self.assertEqual(icu.calculateSentenceOffsets(text, 0), (0, 13))
78+
# An offset inside the surrogate pair still resolves to the containing sentence.
79+
self.assertEqual(icu.calculateSentenceOffsets(text, 4), (0, 13))
80+
self.assertEqual(icu.calculateSentenceOffsets(text, 13), (13, 21))
81+
82+
def test_offset_past_end_fast_path(self):
83+
"""An offset at/past the end returns a single-unit span (matches word behaviour)."""
84+
self.assertEqual(icu.calculateSentenceOffsets("abc", 5), (5, 6))
85+
86+
def test_offset_containment(self):
87+
"""For every in-range offset, start <= offset < end (the tiling precondition)."""
88+
text = "One. Two! Three? Four."
89+
length = len(text.encode("utf-16-le")) // 2
90+
for offset in range(length):
91+
start, end = icu.calculateSentenceOffsets(text, offset)
92+
self.assertTrue(
93+
start <= offset < end,
94+
f"offset {offset} not contained in ({start}, {end}) for {text!r}",
95+
)
96+
97+
98+
@skipIfNoICU
99+
class TestSentenceIterationTiling(unittest.TestCase):
100+
"""The iteration invariant: move(UNIT_SENTENCE) walks sentences gap-free without stalling."""
101+
102+
def _collectSentences(self, obj, length: int, direction: int) -> list[tuple[int, int]]:
103+
"""Enumerate sentence spans by expanding then moving in ``direction`` until the walk stops.
104+
105+
:param obj: A text provider to navigate.
106+
:param length: UTF-16 length of the story text (bounds the loop and seeds the reverse walk).
107+
:param direction: 1 to walk forward from the start, -1 to walk backward from the end.
108+
:return: Sentence spans in document order (the backward walk is reversed before returning).
109+
"""
110+
startPos = 0 if direction > 0 else max(0, length - 1)
111+
info = obj.makeTextInfo(Offsets(startPos, startPos))
112+
info.expand(textInfos.UNIT_SENTENCE)
113+
spans = []
114+
# The loop is bounded so that a stalled walk fails rather than hangs.
115+
for _ in range(length + 2):
116+
spans.append((info._startOffset, info._endOffset))
117+
if info.move(textInfos.UNIT_SENTENCE, direction) == 0:
118+
break
119+
info.expand(textInfos.UNIT_SENTENCE)
120+
if direction < 0:
121+
spans.reverse()
122+
return spans
123+
124+
def _assertTiles(self, spans: list[tuple[int, int]], length: int):
125+
"""Assert the spans tile [0, length) gap-free, in order, with no overlaps."""
126+
self.assertEqual(spans[0][0], 0, f"first sentence does not start at 0: {spans}")
127+
self.assertEqual(spans[-1][1], length, f"last sentence does not reach {length}: {spans}")
128+
for (_, prevEnd), (nextStart, _) in zip(spans, spans[1:]):
129+
self.assertEqual(prevEnd, nextStart, f"gap/overlap between sentences: {spans}")
130+
131+
def test_single_paragraph_tiles_both_directions(self):
132+
text = "Hello world. Goodbye now. The third one."
133+
length = len(text) # all-ASCII, so str length == UTF-16 length
134+
obj = BasicTextProvider(text=text)
135+
forward = self._collectSentences(obj, length, 1)
136+
self._assertTiles(forward, length)
137+
# Three terminators => three sentences.
138+
self.assertEqual(len(forward), 3)
139+
# Backward navigation (alt+upArrow) visits the same sentences in the same order.
140+
backward = self._collectSentences(obj, length, -1)
141+
self.assertEqual(backward, forward)
142+
143+
def test_crosses_block_paragraph_boundary_both_directions(self):
144+
"""With block-paragraph semantics, the walk crosses the blank-line boundary."""
145+
text = "One. Two.\n\nThree. Four."
146+
length = len(text)
147+
blockStart = text.index("\n\n") + 2 # start of the second block paragraph
148+
obj = _BlockParagraphProvider(text=text)
149+
forward = self._collectSentences(obj, length, 1)
150+
self._assertTiles(forward, length)
151+
# A sentence boundary lands exactly on the second paragraph's start (the walk crossed).
152+
self.assertIn(blockStart, [start for start, _ in forward])
153+
backward = self._collectSentences(obj, length, -1)
154+
self.assertEqual(backward, forward)
155+
self.assertIn(blockStart, [start for start, _ in backward])
156+
157+
158+
class TestSentenceOffsetsWithoutIcu(unittest.TestCase):
159+
"""When ICU is unavailable, _getSentenceOffsets degrades to NotImplementedError."""
160+
161+
def test_not_implemented_when_icu_unavailable(self):
162+
obj = BasicTextProvider(text="Hello world. Goodbye now.")
163+
info = obj.makeTextInfo(Offsets(0, 0))
164+
with patch.object(offsetsModule, "ICU_AVAILABLE", False):
165+
with self.assertRaises(NotImplementedError):
166+
info._getSentenceOffsets(0)

0 commit comments

Comments
 (0)