Repository navigation
Conversation
(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>
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 ( |
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
TextLayoutManagerends a word where the bidi level changes or, withfont-selection-strategy="character-by-character", where the selected font changes. Either could fall between the two units of a pair, andMultiByteFont.mapCharsToGlyphsthen rejected the fragment:No PDF is produced.
The commits
CharUtilities.containsSurrogatePairAtbound (>to>=). These are the same two changes as2918.patch; they reached this branch by way of the Metanorma fork, whose commits are kept with their author.containsSurrogatePairAtraises the documentedIllegalArgumentExceptionfor a high surrogate at the end of a sequence, with a test.PDFEncodingTestCaserenders it.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 inInlineRun.split("heterogeneous inlines not yet supported"). Each placeholder now takes the level of the character before it.SurrogatePairLevelsTestCaseholds it.wordbreak_surrogates.xml, the layout test from2918.patch, unchanged, credited to kwilkerson. It fails onmainwith the exception above, fails with the guards alone on the assertion, and passes with commit 5.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.SurrogatePairLevelsTestCasegains two tests; the neutral one fails with commit 5 alone.Not addressed here:
BidiClasswas 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 testwordbreak_surrogates.xml.🤖 Generated with Claude Code