NFC 标准化边界是否也扩展了字素簇边界?

mic*_*yer 7 unicode unicode-normalization

这个问题与文本编辑有关。假设您有一段标准化形式的NFC文本,以及一个指向文本中扩展字素簇边界的光标。您想在光标位置插入另一段文本,并确保生成的文本也在 NFC 中。您还希望将光标移动到紧跟在插入文本之后的第一个字素边界上。

现在,由于连接两个都在 NFC 中的字符串不一定会生成也在 NFC 中的字符串,因此您可能需要修改插入点周围的文本。例如,如果您有一个包含 4 个代码点的字符串,如下所示:

[0] LATIN SMALL LETTER B
[1] LATIN SMALL LETTER E
[2] COMBINING MACRON BELOW
--- Cursor location
[3] LATIN SMALL LETTER A
Run Code Online (Sandbox Code Playgroud)

并且您想{COMBINING ACUTE ACCENT, COMBINING DOT ABOVE}在光标位置插入一个 2-codepoints 字符串。那么结果将是:

[0] LATIN SMALL LETTER B
[1] LATIN SMALL LETTER E WITH ACUTE
[2] COMBINING MACRON BELOW
[3] COMBINING DOT ABOVE
--- Cursor location
[4] LATIN SMALL LETTER A
Run Code Online (Sandbox Code Playgroud)

现在我的问题是:在插入字符串后,您如何确定应该将光标放置在哪个偏移量处,从而使光标在插入的字符串之后结束位于字素边界上?在这种特殊情况下,在标准化期间,光标位置后面的文本不可能与前面的文本交互。因此,以下示例 Python 代码将起作用:

import unicodedata

def insert(text, cursor_pos, text_to_insert):
    new_text = text[:cursor_pos] + text_to_insert
    new_text = unicodedata.normalize("NFC", new_text)
    new_cursor_pos = len(new_text)
    new_text += text[cursor_pos:]
    if new_cursor_pos == 0:
        # grapheme_break_after is a function that
        # returns the offset of the first grapheme
        # boundary after the given index
        new_cursor_pos = grapheme_break_after(new_text, 0)
    return new_text, new_cursor_pos

Run Code Online (Sandbox Code Playgroud)

但是这种方法一定有效吗?更明确地说:在规范化过程中,遵循字形边界的文本是否一定不会与它之前的文本交互,这NFC(text[:grapheme_break]) + NFC(text[grapheme_break:]) == NFC(text)总是正确的吗?

更新

@nwellnhof 下面的出色分析促使我进一步调查。所以我遵循“有疑问时,使用蛮力”的口头禅并编写了一个小脚本来解析字素中断属性并检查可能出现在字素开头的每个代码点,以测试它是否可能与前面的代码点交互在正常化过程中。这是脚本:

from urllib.request import urlopen
import icu, unicodedata

URL = "http://www.unicode.org/Public/UCD/latest/ucd/auxiliary/GraphemeBreakProperty.txt"

break_props = {}

with urlopen(URL) as f:
    for line in f:
        line = line.decode()
        p = line.find("#")
        if p >= 0:
            line = line[:p]
        line = line.strip()
        if not line:
            continue
        fields = [x.strip() for x in line.split(";")]
        codes = [int(x, 16) for x in fields[0].split("..")]
        if len(codes) == 2:
            start, end = codes
        else:
            assert(len(codes) == 1)
            start, end = codes[0], codes[0]
        category = fields[1]
        break_props.setdefault(category, []).extend(range(start, end + 1))

# The only code points that can't appear at the beginning of a grapheme boundary
# are those that appear in the following categories. See the regexps in
# UAX #29 Tables 1b and 1c.
to_ignore = set(c for name in ("Extend", "ZWJ", "SpacingMark") for c in break_props[name])

nfc = icu.Normalizer2.getNFCInstance()
for c in range(0x10FFFF + 1):
    if c in to_ignore:
        continue
    if not nfc.hasBoundaryBefore(chr(c)):
        print("U+%04X %s" % (c, unicodedata.name(chr(c))))
Run Code Online (Sandbox Code Playgroud)

查看输出,似乎有大约 40 个代码点是字素起始符,但仍与 NFC 中的前面代码点组成。基本上,它们是V (U+1161..U+1175) 和T(U+11A8..U+11C2)类型的非预组合韩文音节。当您检查UAX #29,表 1c 中的正则表达式以及标准关于 Jamo 组合的内容(第 3.12 节,标准版本 13 的第 147 页)时,事情就变得有意义。其要点是,该形式的韩文序列{L, V}可以组成一个类型的韩文音节LV,类似地,该形式的序列{LV, T}可以组成一个类型的音节LVT

综上所述,假设我没有弄错,上面的 Python 代码可以更正如下:

import unicodedata
import icu # pip3 install icu

def insert(text, cursor_pos, text_to_insert):
    new_text = text[:cursor_pos] + text_to_insert
    new_text = unicodedata.normalize("NFC", new_text)
    new_cursor_pos = len(new_text)
    new_text += text[cursor_pos:]
    new_text = unicodedata.normalize("NFC", new_text)
    break_iter = icu.BreakIterator.createCharacterInstance(icu.Locale())
    break_iter.setText(new_text)
    if new_cursor_pos == 0:
        # Move the cursor to the first grapheme boundary > 0.
        new_cursor_pos = breakIter.nextBoundary()
    elif new_cursor_pos > len(new_text):
        new_cursor_pos = len(new_text)
    elif not break_iter.isBoundary(new_cursor_pos):
        # isBoundary() moves the cursor on the first boundary >= the given
        # position.
        new_cursor_pos = break_iter.current()
    return new_text, new_cursor_pos
Run Code Online (Sandbox Code Playgroud)

(可能)毫无意义的测试new_cursor_pos > len(new_text)是为了抓住这个案例len(NFC(x)) > len(NFC(x + y))。我不确定当前的 Unicode 数据库是否真的会发生这种情况(需要更多的测试来证明),但理论上很有可能。比如说,如果你有一组三个点ABC和两个预组成形式A+BA+B+C(但没有 A+C),那么你很可能有NFC({A, C} + {B}) = {A+B+C}

如果这种情况在实践中没有发生(这很有可能,尤其是对于“真实”文本),那么上面的 Python 代码必然会定位到插入文本结束后的第一个字素边界。否则,它只会在插入的文本之后定位一些字素边界,但不一定是第一个。我还没有看到如何改进第二个案例(假设它不仅仅是理论上的),所以我想我现在将调查留在那里。

nwe*_*hof 5

正如我在评论中提到的,实际边界可能略有不同。但 AFAICS 不应该有任何有意义的互动。UAX #29指出:

6.1 标准化

[...] 字素簇边界规范具有以下特征:

  • 非间距标记序列中永远不会出现中断。
  • 基本字符和后续的非空格标记之间永远不会有中断。

这仅提到非空格标记。但是对于扩展的字素簇(而不是传统的),我很确定这些陈述也适用于“非启动器”间距标记[1]。这将涵盖所有标准化非起始标记(必须是非间距 (Mn) 或间距 (Mc) 标记)。因此,在非启动器[2]之前永远不会有扩展的字素簇边界,这应该为您提供所需的保证。

请注意,在单个字素簇内可以多次运行启动器和非启动器(“标准化边界”),例如使用 U+034F COMBINING GRAPHEME JOINER。

[1]排除了一些间距标记,但这些都应该是起始的。

[2] 文本开头除外。