Patrick Corless created PDFBOX-6250:
---------------------------------------
Summary: A CMap's own cid mappings are overridden by the ones it
inherits via usecmap, sovertical CJK draws horizontal glyph forms
Key: PDFBOX-6250
URL: https://issues.apache.org/jira/browse/PDFBOX-6250
Project: PDFBox
Issue Type: Bug
Components: FontBox
Affects Versions: 3.0.8 PDFBox
Reporter: Patrick Corless
*Description*
A CMap that starts with `usecmap` is meant to be able to redefine any code it
inherits, but in `org.apache.fontbox.cmap.CMap` the inherited
`cidchar`/`cidrange` mappings are merged into the same collections as the
importing CMap's own. `usecmap` is processed while the header is read, so the
parent's mappings land in `codeToCid` / `codeToCidRanges` _before_ the CMap's
own are parsed and appended. `toCID` consults the map and then scans
`codeToCidRanges` first-match, so an inherited range that covers a code beats
the mapping the importing CMap declared for it, and the override is silently
lost.
{code:java}
CMap cmap = new CMapParser().parsePredefined("ETenms-B5-H");
System.out.println(cmap.toCID(0x41, 1)); // prints 13681, expected 34{code}
`ETenms-B5-H` exists purely to do this override: it uses `ETen-B5-H` and then
remaps 0x20-0x7E to the proportional latin CIDs 1-95, where the parent maps
them to the fullwidth forms at 13648+. So 0x41 ('A') must be CID 34, not 13681
(the fullwidth 'A').
Dumping the parsed ranges of `ETenms-B5-H` shows both present, inherited first:
{code:java}
[0] 32..126 -> 13648 (inherited from ETen-B5-H, always matches first)
...
[226] 32..126 -> 1 (ETenms-B5-H's own, never reached){code}
*Scope*
31 of the 92 bundled predefined CMaps use `usecmap` *and* declare mappings of
their own, and all 31 are measurably affected — the sweep below reaches every
one of them and nothing else. Horizontal: `ETenms-B5-H`, `UniJIS-UCS2-HW-H`.
The remaining 29 are the `-V` variants, whose own mappings select the vertical
glyph forms — so vertical CJK is affected too. `UniJIS-UCS2-HW-H` is the same
shape as ETenms: its parent `UniJIS-UCS2-H` maps 0x0041 to the proportional CID
34, and the `-HW` CMap overrides it to the halfwidth CID 264.
*Downstream symptom*
Wrong CID means the wrong glyph is drawn, and because `/W` is indexed by CID,
the wrong CID usually falls outside the font's `/W` array, so the advance
silently falls back to `/DW` = 1000 — every latin glyph becomes a full em wide
(rendering as `J a v a S e r v e r`). Documents using `ETenms-B5-H` with a `/W`
array that starts at CID 1 are proof the producer expected the proportional
CIDs.
*Proposed fix*
Keep inherited CID mappings in their own map and range list
(`inheritedCodeToCid` `inheritedCodeToCidRanges`) and consult them only after
the CMap's own map and ranges: own map -> own ranges -> inherited map ->
inherited ranges.
A `usecmap` chain keeps nearest-wins ordering, which needs opposite insertion
orders for the two structures: the maps are looked up by key so `putAll` is
last-wins (deepest applied first, the
nearer CMap's own on top), while the range list is scanned first-match so the
nearer CMap's ranges are added ahead of the ones it inherited.
Three details worth calling out:
- `hasCIDMappings` has to account for the inherited mappings.* `Identity-V`
declares no cid mappings of its own at all, it only uses `Identity-H`, so once
the inherited ones live in separate collections `hasCIDMappings` would report
false and `toCID` would return 0 for every code. Covered by
`testUseCmapOnlyInheritedMappings`.
- The maps are copied, not shared.* This also removes a latent aliasing bug:
the previous
`codeToCid.putIfAbsent(length, mappings)` stored the used CMap's inner map
_by reference_ whenever the importing CMap had no mapping of that code length
yet, so a later `addCIDMapping` on the importing CMap wrote straight into the
cached predefined parent, corrupting it for every later user. Covered by
`testUseCmapDoesNotShareMappingsWithTheUsedCMap`.
- CID 0 is a valid mapping target, not a "not found" marker.* The private range
scan returned 0 for both, which with two range lists would make a CMap's own
`cidrange` to CID 0 fall through to the inherited mappings. It now returns -1
for "no range covers this code"; the public `toCID` still returns 0. Covered
by `testUseCmapOwnMappingToCidZeroIsNotAFallthrough`
*Testing*
Seven tests added to `TestCMapParser`:
||test||covers||
|testUseCmapOwnMappingsWin|the reproducer above, both `toCID` overloads|
|testUseCmapChainKeepsNearestMapping|a two level `usecmap` chain stays
nearest-wins |
|testUseCmapOwnMappingsBeatInheritedRanges| own cidchar and own cidrange both
beat an inherited range|
|testUseCmapOnlyInheritedMappings|`Identity-V`, where every mapping is
inherited |
|testUseCmapDoesNotShareMappingsWithTheUsedCMap|the used CMap is not mutated|
|testUseCmapOwnRangeBeatsInheritedChar|own cidrange beats an inherited cidchar|
|testUseCmapOwnMappingToCidZeroIsNotAFallthrough|CID 0 is a mapping, not a miss|
`mvn -pl fontbox -am test` is green (214 tests)
*toCID sweep over the bundled predefined CMaps*
All 92 bundled predefined CMaps were swept with both `toCID` overloads and the
length guessing one: the 1 and 2 byte code spaces exhaustively (0x00-0xFF and
0x0000-0xFFFF), the 3 byte boundaries, and for 4 byte codes every code covered
by a declared `cidrange`/`cidchar` plus a margin either side of each bound,
plus a strided sample of the whole 32 bit space (99,434 probes). That is
2,889,588 non-zero results per build.
Each result was checked against an oracle built independently of the patched
code, by reading the CMap resource files directly and resolving each `usecmap`
chain nearest-wins (a CMap's own `cidchar`/`cidrange` shadowing what it uses,
`cidchar` ahead of `cidrange`, earlier range first):
||Build||non-zero results||disagreeing with the oracle||
|trunk (before the fix)|2,889,588|2,714, across exactly the 31 CMaps named
under Scope|
| with the fix|2,889,588|0|
So the fix does not merely change behaviour, it makes `toCID` agree with what
the CMap files declare on every code swept, and it changes nothing on the other
61 CMaps. All 2,714 corrected lookups resolve to the nearest declaration in the
CMap's own `usecmap` chain.
The CID 0 sentinel change is behaviour preserving for the bundled set: the
sweeps taken with and without it are byte identical. It is there because a CMap
*may* legitimately map a code to CID 0, and the tests cover that case directly.
Patch / PR attached.
--
This message was sent by Atlassian Jira
(v8.20.10#820010)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]