Skip to content

FOP-2918: A surrogate pair is not split by word breaking, and both its units get one bidi level - #115

Open
plutext wants to merge 7 commits into
apache:mainfrom
plutext:FOP-2918
Open

plutext wants to merge 7 commits into
apache:mainfrom
plutext:FOP-2918

Conversation

@plutext

@plutext plutext commented Oct 2, 2026 •

Copy link
Copy Markdown

Addresses FOP-2918, open since 2020 with a patch from kwilkerson attached. This carries that patch's two changes, the issue's own layout test, a second case the issue did not have, and the root cause underneath both.

What goes wrong

A character outside the BMP is two UTF-16 units. TextLayoutManager ends a word where the bidi level changes or, with font-selection-strategy="character-by-character", where the selected font changes. Either could fall between the two units of a pair, and MultiByteFont.mapCharsToGlyphs then rejected the fragment:

java.lang.IllegalArgumentException: ill-formed UTF-16 sequence, contains isolated high surrogate at end of sequence

No PDF is produced.

The commits

  1. and 2. The word-splitting guard on the bidi-level branch and the CharUtilities.containsSurrogatePairAt bound (> to >=). These are the same two changes as 2918.patch; they reached this branch by way of the Metanorma fork, whose commits are kept with their author.
  2. containsSurrogatePairAt raises the documented IllegalArgumentException for a high surrogate at the end of a sequence, with a test.
  3. The same guard on the font-selection branch, which the issue did not cover: an emoji under character-by-character font selection (Aegean600 in the test tree) failed the same way. PDFEncodingTestCase renders it.
  4. The root cause. UnicodeBidiAlgorithm.resolveLevels(CharSequence, Direction) leaves a placeholder in the slot of each low surrogate, and its javadoc says both members of a pair come back with the same level. They did not: no rule resolves the placeholder's class, so it stayed at the embedding level while its character took the level of its script. U+10826 (Cypriot, right-to-left) came back as levels 1 and 0, which is why the word was split there in the first place. With the guards alone the word is kept whole and the line's reordering then asserts in InlineRun.split ("heterogeneous inlines not yet supported"). Each placeholder now takes the level of the character before it. SurrogatePairLevelsTestCase holds it.
  5. wordbreak_surrogates.xml, the layout test from 2918.patch, unchanged, credited to kwilkerson. It fails on main with the exception above, fails with the guards alone on the assertion, and passes with commit 5.
  6. The placeholder's class. Commit 5 copies the level after resolution, which cannot help where the resolution itself is wrong. The placeholder had a class of its own (SURROGATE) that is neither strong nor neutral, so rule N1's look-ahead stopped at it. A neutral outside the BMP inside right-to-left text (U+1F300 between two Hebrew words, in a left-to-right paragraph) therefore never saw the strong text after it, fell to the embedding direction under N2 and resolved to level 0, cutting the right-to-left run in two, where U+263A in the same place resolves to 1. The placeholder now takes the class of the character it belongs to, so the rules see the pair as one character. Outside the BMP Unicode assigns no ES, CS, WS, S, B or explicit-embedding class, so a repeated class resolves as a single one under every rule; commit 5's copy stays as the guarantee the javadoc makes. SurrogatePairLevelsTestCase gains two tests; the neutral one fails with commit 5 alone.

Not addressed here: BidiClass was generated from Unicode data older than 6.1, so U+1F600 and many later characters come back as L (U+1F300, from 6.0, is right). That is why the test uses U+1F300. It is a separate matter, regenerating the table.

Tests

SurrogatePairLevelsTestCase, CharUtilitiesTestCase, PDFEncodingTestCase, and the layout engine test wordbreak_surrogates.xml.

🤖 Generated with Claude Code

Intelligent2013 and others added 6 commits October 3, 2026 07:12
(cherry picked from commit 70a1e75)
(cherry picked from commit a1412e4)
(cherry picked from commit ba2ec2e)

Reflowed on cherry-pick: the guarded condition ran to 157 columns, over
checkstyle's 120. The surrogate test is now first, ahead of the font
selection strategy lookup. Same result, both operands being pure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 3612799)
…umentException

containsSurrogatePairAt documents IllegalArgumentException for ill-formed
UTF-16, but its end-of-sequence guard read (index + 1) > length, which is
never true at the last index. The isolated-surrogate branch was therefore
unreachable there and charAt(index + 1) raised
StringIndexOutOfBoundsException instead.

The existing malformed-sequence test puts the high surrogate mid-string, so
it exercised the other branch and passed either way. This one puts it last,
and fails with StringIndexOutOfBoundsException before the guard is fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit f62f18b)
Covers the reported failure end to end, which the CharUtilities test does not:
that one exercises a latent bug on a different path.

With font-selection-strategy="character-by-character", TextLayoutManager ended
the word wherever the per-character selection changed font. When the font
changed at a surrogate pair, the word was cut between the high surrogate and
its low surrogate, and MultiByteFont.mapCharsToGlyphs rejected the fragment
with "ill-formed UTF-16 sequence, contains isolated high surrogate at end of
sequence". No PDF was produced. MultiByteFont's guard is right; the word
splitting was wrong.

The case uses Helvetica with Aegean600 as fallback and U+10300, so the font
changes exactly at the pair. Aegean600 is already in FOP's test resources, so
no new binary ships. Verified to fail before the fix with that exact
exception, at MultiByteFont.java:695 by way of GlyphMapping.processWordMapping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit b64dfe4)
UnicodeBidiAlgorithm.resolveLevels(CharSequence, Direction) converts the text to scalar
values, leaving a placeholder in the slot of each low surrogate, and its javadoc says the
two members of a pair come back with the same level. They did not: no rule resolves the
placeholder's class, so it stayed at the embedding level while its character took the
level of its script. A supplementary-plane character of a right-to-left script, U+10826
(Cypriot) for one, came back as levels 1 and 0.

That is the root of this issue. TextLayoutManager ends a word where the bidi level
changes, so it ended one between the two surrogates, and MultiByteFont rejected the
fragment with "ill-formed UTF-16 sequence, contains isolated high surrogate at end of
sequence". With the guard added earlier on this branch the word is kept whole, and then
the line's reordering asserts in InlineRun.split ("heterogeneous inlines not yet
supported") because the one word still carries two levels.

After resolution, each placeholder now takes the level of the character before it, which
is what the javadoc promises. SurrogatePairLevelsTestCase holds it for a pair alone,
between Latin letters, and two pairs together.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rogate pair is laid out

wordbreak_surrogates.xml is taken unchanged from 2918.patch, attached to FOP-2918 by
kwilkerson on 2020-07-17. It sets U+10826 (Cypriot syllabary, a right-to-left script
outside the BMP). Before this branch it fails with "ill-formed UTF-16 sequence, contains
isolated high surrogate at end of sequence"; with the word-splitting guards alone it gets
further and fails on the assertion in InlineRun.split; with the bidi levels of the pair
made equal it passes. The TextLayoutManager and CharUtilities changes in that patch are
the same as the two earliest commits here, which came by way of the Metanorma fork.

Test-by: kwilkerson (FOP-2918)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
plutext added a commit to plutext/xmlgraphics-fop that referenced this pull request Oct 2, 2026
…R-008)

getClasses gave the placeholder in a low surrogate's slot a class of its own, SURROGATE,
which is neither strong, nor neutral, nor retained formatting. Rule N1's look-ahead stops
at it, so a neutral outside the BMP inside right-to-left text never saw the strong text
after it and fell to the embedding direction. U+1F300 between two Hebrew words in a
left-to-right paragraph resolved to level 0, cutting the right-to-left run in two, where
U+263A in the same place resolves to 1. Copying the level after resolution, as the
previous commit does, cannot mend that: the level it copies is already wrong.

The placeholder now takes the class of the character it belongs to, so the rules see the
pair as one character. Outside the BMP Unicode assigns no ES, CS, WS, S, B or explicit
embedding class, so a repeated class resolves as a single one under every rule; the level
copy stays as the guarantee the javadoc makes.

SurrogatePairLevelsTestCase gains the neutral case (with and without a space before it,
beside the same text with U+263A) and N2 between the two directions. The neutral case
fails on the level copy alone (element 4, expected 1, was 0). Full build: 3611 tests,
0 failures; checkstyle and spotbugs clean. Not yet in apache#115.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plutext added a commit to plutext/xmlgraphics-fop that referenced this pull request Oct 2, 2026
… notes draft and Start here

The CR records the two halves of the fix, the control runs, the command-line measurement
with assertions on and off, and what it does not fix: FOP's bidi class table predates
Unicode 6.1 (U+1F600 is L to it), and apache#115 carries the first half only. CLAUDE.md's
layout-test path was wrong (fop/test, not fop-core/test); corrected, with the
single-test property.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getClasses gave the placeholder in a low surrogate's slot a class of its own, SURROGATE,
which is neither strong, nor neutral, nor retained formatting. Rule N1's look-ahead stops
at it, so a neutral outside the BMP inside right-to-left text never saw the strong text
after it and fell to the embedding direction. U+1F300 between two Hebrew words in a
left-to-right paragraph resolved to level 0, cutting the right-to-left run in two, where
U+263A in the same place resolves to 1. Copying the level after resolution, as the
earlier commit on this branch does, cannot mend that: the level it copies is already
wrong.

The placeholder now takes the class of the character it belongs to, so the rules see the
pair as one character. Outside the BMP Unicode assigns no ES, CS, WS, S, B or explicit
embedding class, so a repeated class resolves as a single one under every rule; the level
copy stays as the guarantee the javadoc makes.

SurrogatePairLevelsTestCase gains the neutral case (with and without a space before it,
beside the same text with U+263A) and N2 between the two directions. The neutral case
fails on the level copy alone (element 4, expected 1, was 0). U+1F300 rather than U+1F600
because BidiClass was generated from Unicode data older than 6.1 and reads U+1F600 as L,
a separate matter.

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

plutext commented Oct 2, 2026

Copy link
Copy Markdown
Author

Added a seventh commit. Commit 5's level copy fixes a right-to-left character outside the BMP, but not a neutral one: the low surrogate's placeholder had a class of its own, which ended rule N1's run of neutrals, so an emoji (U+1F300 here) between two Hebrew words resolved to the embedding direction and cut the right-to-left run in two, where U+263A resolves with the text. The placeholder now takes its character's class. Two tests added; the neutral one fails on commit 5 alone. Full build (mvn -B package checkstyle:check spotbugs:check) green on main.

plutext added a commit to plutext/docx4j that referenced this pull request Oct 3, 2026
…ecord corrected; a surrogate-pairs-bidi probe

The P2-1 record said the bidi-level half of the surrogate fix was undemonstrated. It is not:
a right-to-left letter outside the BMP (Cypriot U+10826) gets two levels, an InlineRun.split
assertion under -ea and a reversed word without, and docx4j documents reach it from plain
runs. Upstream it is FOP-2918 (apache/xmlgraphics-fop#115); the fork's fix is fop/CR-008.

The new probe surrogate-pairs-bidi holds the gate's cases: the Cypriot letter between Latin
and inside Hebrew, U+1F44D, U+263A and U+1F600 between two Hebrew words, and the U+1F44D line
again in a w:bidi paragraph. The gate passed on 2.11-docx4j.3-SNAPSHOT: no exception under
-ea, the two broken lines become one right-to-left run, everything else identical to the
glyph position; corpora identical by construction.

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.

2 participants