Jason Harrop created FOP-3341:
---------------------------------

             Summary: A font whose GSUB or GPOS has no DFLT script table 
silently gets no substitution and no kerning under a default script, with no 
warning
                 Key: FOP-3341
                 URL: https://issues.apache.org/jira/browse/FOP-3341
             Project: FOP
          Issue Type: Bug
          Components: font/opentype
    Affects Versions: 2.11
            Reporter: Jason Harrop


GlyphTable.matchLookups falls back to the default script when a lookup match 
comes up empty:

{noformat}
if (lm.isEmpty() && !OTFScript.isDefault(script) && 
!OTFScript.isWildCard(script)) {
    return matchLookups(OTFScript.DEFAULT, OTFLanguage.DEFAULT, feature);
}
{noformat}

The fallback targets DFLT. So for a font that has no DFLT entry in its script 
list, a script of DFLT finds nothing, and the fallback looks for DFLT again and 
finds nothing again. Nothing is applied and nothing is reported. The font is 
loaded, its tables are read, performsSubstitution and performsPositioning both 
return true, and every lookup silently misses.

Fonts without a DFLT script table are not unusual. Carlito, the 
metric-compatible Calibri substitute packaged by most Linux distributions, 
declares cyrl, grek and latn and no DFLT. DejaVu declares DFLT alongside them, 
which is why the two behave differently on the same code path and why this is 
easy to mistake for a font-file or a loader problem.

Measured, on the same font objects with only the script argument changing:

{noformat}
script    Carlito 1.104      DejaVuLGCSerif
latn      'fi' -> U+FB01     'fi' -> U+FB01
*         'fi' -> U+FB01     'fi' -> U+FB01
DFLT      'fi' -> 'f','i'    'fi' -> U+FB01
zyyy      'fi' -> 'f','i'    'fi' -> U+FB01
auto      'fi' -> 'f','i'    'fi' -> U+FB01
{noformat}

The language argument opens the same hole. A language with no langsys entry 
under the script makes the (script, language) match come up empty, and the 
fallback then goes to (DFLT, dflt), skipping the script's own default langsys. 
Measured 2026-09-27 through the command line, one fo:block, only the language 
attribute changing, read back with pdftotext:

{noformat}
attributes                    Carlito                       DejaVu Sans
script=latn, no language      ffi ffl fi fl ft ti ligated    ffi ffl fi fl 
ligated
script=latn language="en"     nothing ligated               nothing ligated
{noformat}

And the language never matches in the first place. The FO language property 
carries an ISO 639 code ("en", "tr"); the font's language systems are OpenType 
tags ("ENG", "TRK"); FOP passes the code through untranslated. So a language 
the font does distinguish is treated like one it does not: DejaVuLGCSerif's TRK 
system omits liga, and performSubstitution("fi", "latn", "tr") ligates while 
("fi", "latn", "TRK") does not.

Neither font has an ENG langsys under latn. DejaVu Sans does have DFLT, but its 
DFLT langsys carries case, ccmp, dlig and kern and no liga, so the fallback 
lands on a script table that lacks the feature. DejaVu Serif's DFLT langsys 
does carry liga, and we measured that it ligates under the same attributes. So 
whether a run ligates today is font-accidental: it depends on which features 
the font's author happened to put in DFLT, not on what the font offers for the 
script.

This is not a corner case for a consumer. docx4j writes language="en" 
country="US" on every block and inline it produces, from w:lang and the 
document defaults, and it cannot drop the attribute because FOP selects 
hyphenation patterns by it. The first point of FOP-2092 (Thai shapes only under 
language="dflt") is this language half. The fix belongs in the fallback order, 
as OpenType layout engines do it: (script, language), then (script, dflt), then 
(DFLT, dflt). GlyphTable.matchLookups goes straight from the first to the third.

It is not only substitution. The same script matching governs GPOS, so kerning 
disappears too, and that is the wider harm because it reaches every letter pair 
rather than a handful of ligature sequences:

{noformat}
Carlito    legacy kern table: absent, 0 entries
           GPOS 'AV'  latn kerned   * kerned   DFLT null
DejaVu     legacy kern table: present, 101 entries
           GPOS 'AV'  latn kerned   * kerned   DFLT kerned
{noformat}

Carlito has no legacy kern table at all, so under a default script it gets no 
kerning from either mechanism. A consumer that sets kerning="true" on such a 
font gets nothing and is told nothing.

Note that the wildcard script "*" works, because the wildcard matches the 
font's real script tables. GlyphMapping maps "auto" and "zyyy" to "*", so the 
common FO path is safe; it is a caller that passes DFLT, or "auto"/"zyyy" 
unnormalised, that loses everything.

h3. Why it matters

The failure is silent and invisible in rendering: the text is drawn, just 
unshaped and unkerned. Nobody notices until they compare advance widths against 
another renderer. It also produces exactly the misleading symptom this was 
first mistaken for: "FOP reads this font's tables but not that one's".

h3. Proposed fix

Done on the fork, branch CR-003-script-fallback (GlyphTable, OTFLanguage, 
OTFScript; tests GlyphTableFallbackTestCase on synthetic tables shaped like 
Carlito and DejaVu Sans, and LanguageSystemTestCase on the DejaVuLGCSerif in 
the test tree, whose TRK system omits liga):

# Map the language to its OpenType tag first (a table of ISO 639 codes against 
the tags OTFLanguage already names: "tr" to TRK, "en-US" to ENG, "zh-TW" to 
ZHT; a tag is accepted as given; unknown codes match nothing and take the next 
step), then fall back in the order OpenType layout engines use: (script, 
language), then (script, dflt), then (DFLT, dflt). Each step is taken only when 
the one before found nothing.
# OTFScript.isWildCard compares with the wildcard rather than DEFAULT (harmless 
today, corrected alongside).

Measured after the change, same command line: Carlito under language="en" 
ligates (ffi fi fl ft ti, 26 glyphs to 21) and kerns (AVATAR 43.19 pt, was 
46.62); DejaVu Sans under "en" ligates; DejaVuLGCSerif 
performSubstitution("fi", "latn", "tr") no longer ligates, which is what its 
TRK system says; Noto Sans Arabic under "ar" unchanged.

This changes shaping and kerning for existing documents wherever a language was 
set and the font had no system for it, so it wants the release note. A safer 
first step, if preferred: an event when a font with GSUB or GPOS present yields 
no lookups for the requested script, turning the silent miss into a diagnosable 
one.

Note that the kerning half is also gated by the font configuration's kerning 
flag, which did not reach GPOS at all (a separate report, a companion issue 
filed alongside this one), and by a reader defect that drops a script's default 
language system when a named language shares its table (a companion issue filed 
alongside this one): DejaVu Sans needs all three before it kerns under "en".

h3. Provenance

Found while implementing the docx4j FO renderer's gsub-features hook, tracing 
why one font shaped and another did not through the same pipeline. All the 
numbers above are from loading each font directly with FontLoader and calling 
performSubstitution and performPositioning, so no renderer or consumer pipeline 
is involved.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to