From f2fd785e74184d93a70344a4c2f65daee6fb9cf4 Mon Sep 17 00:00:00 2001 From: Tim <6703801+timzifer@users.noreply.github.com> Date: Tue, 22 Sep 2026 07:05:55 +0200 Subject: [PATCH] sfnt: read the format 4 glyph id array from its real start MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The glyph id array of a format 4 cmap begins after the idRangeOffset array, at 16+4*segCountX2. The reader sliced it at 16+3*segCountX2 - the start of the offset array - while lookup measured from its end, so every code point in a range-offset segment read the id segCountX2 bytes early, a neighbour's glyph or none. Delta segments were unaffected, which is all the test font had. In Noto Sans that drew "•" as "," in every embedded PDF and left 753 BMP code points (Romanian Ș/ț, much of Cyrillic Extended, the combining marks) without a glyph. The PDF's ToUnicode map came from the rune, so copied text still read correctly and hid it. Against x/image/font/sfnt the lookup now agrees on every BMP code point of Noto Sans Regular, Bold and Italic. sfnttest.Cmap4Ranged is a format 4 map with two range-offset segments, so the offset arithmetic is checked at two different segment indices. Co-Authored-By: Claude Opus 5 (1M context) --- internal/sfnt/cmap.go | 5 ++- internal/sfnt/sfnt_test.go | 14 +++++++++ internal/sfnttest/font.go | 63 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/internal/sfnt/cmap.go b/internal/sfnt/cmap.go index 5286be7..04ce2ad 100644 --- a/internal/sfnt/cmap.go +++ b/internal/sfnt/cmap.go @@ -118,7 +118,10 @@ func (c *cmap) read4(t []byte) error { // Everything after the range-offset array is the glyph id array, and the // offsets are measured from inside that array rather than from the table // — which is why it is kept as bytes and indexed relative to its start. - c.glyphIDs = t[16+3*c.segX2:] + // After, not at: the range-offset array starts at 16+3*segX2 and is + // segX2 bytes long. Slicing at its start put every lookup through it + // segX2 bytes early, onto a neighbour's glyph. + c.glyphIDs = t[16+4*c.segX2:] return nil } diff --git a/internal/sfnt/sfnt_test.go b/internal/sfnt/sfnt_test.go index 1517c1e..bbdab14 100644 --- a/internal/sfnt/sfnt_test.go +++ b/internal/sfnt/sfnt_test.go @@ -76,6 +76,20 @@ func TestTheCmapFormats(t *testing.T) { check(t, f, 'Z', 0) check(t, f, '\U0001F600', 0) }) + // Segments that go through the glyph id array. The reader once took the + // start of the idRangeOffset array for the start of the glyph id array, + // so every such lookup landed segCountX2 bytes early - on a neighbour's + // id. In Noto Sans that drew "•" as "," in every embedded PDF. + t.Run("format 4 with range offsets", func(t *testing.T) { + f := parse(t, sfnttest.Font(sfnttest.Cmap4Ranged(), sfnttest.NumGlyphs)) + check(t, f, 'A', sfnttest.GlyphA) + check(t, f, 'B', sfnttest.GlyphB) + check(t, f, '·', sfnttest.GlyphA) + check(t, f, '•', sfnttest.GlyphComp) + check(t, f, '‣', 0) + check(t, f, '․', sfnttest.GlyphB) + check(t, f, '‥', 0) + }) t.Run("format 12", func(t *testing.T) { f := parse(t, sfnttest.Font(sfnttest.Cmap12(), sfnttest.NumGlyphs)) check(t, f, 'A', sfnttest.GlyphA) diff --git a/internal/sfnttest/font.go b/internal/sfnttest/font.go index 2bdffc6..38c81b4 100644 --- a/internal/sfnttest/font.go +++ b/internal/sfnttest/font.go @@ -126,6 +126,69 @@ func Cmap4() []byte { return wrapCmap(3, 1, b) } +// Cmap4Ranged is a format 4 map whose segments read the glyph id array +// through idRangeOffset rather than adding a delta — the shape a real font +// uses for scattered code points such as "·" and "•", and the one Cmap4 does +// not exercise. Two such segments, because the offset is measured from the +// segment's own entry and a reader that gets the base of the array wrong is +// off by a different amount in each: +// +// U+00B7 → GlyphA +// U+2022..2024 → GlyphComp, missing, GlyphB +// 'A'..'B' → a plain delta segment in front of them +func Cmap4Ranged() []byte { + type seg struct { + start, end uint16 + delta int16 + ids []uint16 // nil: a delta segment + } + segs := []seg{ + {'A', 'B', GlyphA - 'A', nil}, + {0xB7, 0xB7, 0, []uint16{GlyphA}}, + {0x2022, 0x2024, 0, []uint16{GlyphComp, 0, GlyphB}}, + {0xFFFF, 0xFFFF, 1, nil}, + } + n := len(segs) + var ids []uint16 + offsets := make([]uint16, n) + for i, s := range segs { + if s.ids == nil { + continue + } + // From this segment's idRangeOffset entry to its first id: the rest + // of the offset array, then the ids of the segments before it. + offsets[i] = uint16(2*(n-i) + 2*len(ids)) + ids = append(ids, s.ids...) + } + + var b []byte + u16 := func(v uint16) { b = binary.BigEndian.AppendUint16(b, v) } + u16(4) + u16(uint16(16 + 8*n + 2*len(ids))) // length + u16(0) // language + u16(uint16(2 * n)) + u16(2) // the search hints, which nothing reads + u16(1) + u16(0) + for _, s := range segs { + u16(s.end) + } + u16(0) // the reserved pad + for _, s := range segs { + u16(s.start) + } + for _, s := range segs { + u16(uint16(s.delta)) + } + for _, off := range offsets { + u16(off) + } + for _, id := range ids { + u16(id) + } + return wrapCmap(3, 1, b) +} + // Cmap12 is the same mapping in the flat format a font needs to reach past the // Basic Multilingual Plane, plus one code point that actually is past it. func Cmap12() []byte {