Skip to content

FOP-2349: letter-spacing is in a word's width on the complex-script path too - #118

Open
plutext wants to merge 2 commits into
apache:mainfrom
plutext:FOP-2349
Open

plutext wants to merge 2 commits into
apache:mainfrom
plutext:FOP-2349

Conversation

@plutext

@plutext plutext commented Oct 3, 2026

Copy link
Copy Markdown

Addresses FOP-2349, reported by Matthias Reischenbacher in 2014. Andreas L. Delmelle traced it in 2015 to GlyphMapping.processWordMapping's [TBD] - handle letter spacing and sketched this approach. It follows FOP-2722, which gave processWordMapping the letter-space count but not the width.

Stacked on #113 (FOP-3344), which paints letter spacing on the position-adjustments path; the first commit here is #113's. Without #113, a GPOS-kerned font paints no letter spacing at all, so correctly measured lines would look short. Review #113 first; this PR's own change is its last commit.

What goes wrong

GlyphMapping.processWordNoMapping counts a word's letter spaces and adds letterSpaceIPD × count to its width. For a font with GSUB or GPOS tables processWordMapping is used instead. Since FOP-2722 it returns the same count, but it adds nothing to the width; it is not even passed letterSpaceIPD. The renderer spaces every glyph on either path, so letter-spaced text in any OpenType font is measured short and its lines overrun.

Measured on main with #113: Carlito 12pt, 3pt letter spacing, a 260pt line. Complex scripts on: 4 lines, ending up to 23pt past the margin. With -nocs: 5 lines, all inside it.

The change

processWordMapping takes letterSpaceIPD and adds the counted letter spaces to the width, as the plain path does. After it, the complex-script path breaks that paragraph into the same 5 lines as -nocs. The line ends match to within 0.4pt, the GPOS kerning main applies even with kerning="false" (FOP-3343, #112). With -nocs nothing moves: every glyph is at the same position as before.

Combining marks

On FOP-2349 Glenn Adams objected to a sketch that counted glyphs: this method exists for combining marks, where several glyphs make one character. This change does not count glyphs. It takes FOP-2722's character count, which FOP already uses, and makes the width agree with it. The painter still spaces each glyph, so the two disagree wherever substitution changes the number of glyphs (a ligature, a decomposition) or marks are present. That is unchanged by this PR and not measured here.

Tests

GlyphMappingTestCase.testLetterSpacesInTheWidthOfAWordInAFontWithLayoutTables: "word" in DejaVuLGCSerif with no letter spacing and with 3pt; the widths must differ by the three counted letter spaces. Before the change they differ by 0.

🤖 Generated with Claude Code

plutext and others added 2 commits October 3, 2026 07:17
…on adjustments

PDFPainter.drawText takes one of two paths. With no position adjustments, or DX-only ones,
drawTextWithDX writes the word as one TJ array and sets Tc to the letter spacing, which the
viewer adds after every glyph. With general adjustments (a GPOS kern is an x-advance
adjustment, so IFUtil.isDPOnlyDX is false) drawTextWithDP writes each glyph with its own Td
and Tj and advanced by the glyph width plus the adjustment. It set Tc too, but Tc acts only
between glyphs inside a TJ array; with one glyph per Tj and an explicit Td before each, the
letter spacing never reached the next glyph.

The layout is consistent with the first path: TextLayoutManager puts the letter spaces into
the word's elements and its area, and addMappingAreas computes the word-space adjustment on
the assumption that the renderer adds the character spacing "even to the last character of
a word and to space characters". So a letter-spaced word in a font that positions was
measured with its letter spaces and painted without them, and the gap to the next word
absorbed the difference: negative letter spacing opens the gap, positive closes it, to the
point of overprinting.

The letter spacing is now added to the advance on this path, for every glyph, spaces and
the last one included, which is what Tc does on the other. Java2DPainter and PCLPainter
already add it per glyph in their dp loops, PSPainter applies it through ATJ, AFPPainter
converts dp to dx; only the PDF painter was missing it.

Measured on this branch, Arimo 11pt, kerning on,
"During <fo:inline letter-spacing="-0.417pt">repair,</fo:inline> if however", per-glyph
steps read back with mutool draw -F stext:

  before   r>e 3.663 e>p 6.116 p>a 6.116 a>i 6.116 i>r 2.442 r>, 3.058   comma>space 3.047
  after    r>e 3.246 e>p 5.699 p>a 5.699 a>i 5.699 i>r 2.025 r>, 2.641   comma>space 5.549

After: each step is the advance less 0.417, and r>, carries the kern (-55/1000 em) too.
PDFPainterTestCase.testDrawDpTextKeepsLetterSpacing: two zero-width glyphs, a kern of -100
and a letter spacing of 500; the second Td must be 0.4, not -0.1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ath too

GlyphMapping.processWordNoMapping adds letterSpaceIPD x count to a word's width. For a font
with GSUB or GPOS tables processWordMapping is used instead. Since FOP-2722 it returns the
same letter-space count, but it adds nothing to the width (it was not passed letterSpaceIPD).
TextLayoutManager breaks lines on that width while the painter spaces every glyph, so
letter-spaced text in an OpenType font was measured short and overran the line.
processWordMapping now adds the counted spaces to the width, as the plain path does.

GlyphMappingTestCase lays out "word" in DejaVuLGCSerif with no letter spacing and with 3pt;
the widths must differ by the three counted letter spaces (they differed by 0).

Andreas L. Delmelle traced FOP-2349 to the same "[TBD] - handle letter spacing" in 2015.
This keeps FOP-2722's character count rather than counting glyphs.

Stacked on FOP-3344: without it, text on the position-adjustments path is not painted with
its letter spacing at all, so lines measured correctly would look short.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
plutext added a commit to plutext/docx4j that referenced this pull request Oct 3, 2026
…rule leaders on a FOP without setRuleStyle(int)

Apache main's FOP-2722 gives GlyphMapping.processWordMapping (the complex-script path) a
letter-space count without adding the spaces to the width. fixLetterSpaces trusted the count,
so on such a FOP it added one letter space where n were missing; on the fork's merge of
Apache main (fop/CR-009) 51 corpus documents regressed, documents without w:spacing included,
since docx4j's width fixups write letter-spacing of their own. The fork's CR-010 adds the
width and declares letter-space-width (upstream FOP-2349, apache/xmlgraphics-fop#118), but an
Apache release may carry FOP-2722 alone. So fixLetterSpaces now takes the spaces already in
the width from the path the word took (the font performs substitution or positioning, the
test doGlyphMapping makes) and the capability: none on the complex-script path without it,
the count otherwise (WordLineLayoutManager.lettersInWidth).

Apache's FOP-3325 replaces area.inline.Leader.setRuleStyle(int) with
setRuleStyle(BorderStyle); the fork keeps the int form (capability rule-style-int).
LBP.setRuleStyle calls it directly there and otherwise finds the setter once by reflection,
the int form first, so a rule leader is drawn on FOP 2.11, the fork, or a FOP with FOP-3325.

FopCapabilities gains RULE_STYLE_INT and LETTER_SPACE_WIDTH; FopHooks resolves them.

Tests: export-fo 235/0 on 2.11-docx4j.2, on Apache FOP 2.11 (-Papache-fop) and on
2.11-docx4j.4-SNAPSHOT (CR-010). Corpora and probes against the 2.11-docx4j.2 baseline:
0 changed with this change on 2.11-docx4j.2, and on 2.11-docx4j.4 with CR-010. On a
simulated FOP-2722-only renderer (2.11-docx4j.4 with the bare merge's GlyphMapping and
without letter-space-width), 17.3.0 changes 51 documents (real 20, real2 17, real3 9,
probes 5) and this change none; spacing-char 0.2857 -> 0.7857, kern-title 0.9250 -> 1.0000,
as on 2.11-docx4j.2. Not exercised: the BorderStyle branch (no FOP at hand has FOP-3325
without the int form).

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.

1 participant