fontisan 0.4.24 → 0.4.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 556befd056a62c96c03bfaab023a7027b098df4145148415f4735db53da719ee
4
- data.tar.gz: 41ddfe5df546ee2b1855644d0eddb1caed472f61e88030ad653038e536393ee6
3
+ metadata.gz: 4f8c0752461168f5fa647b8a581455a1daa4ed17f7649b1b28cf3850b1453748
4
+ data.tar.gz: 99e14f77bfdb9cf1303b51d9f236a82c2cfbf284885d56fbe79be56fb338a237
5
5
  SHA512:
6
- metadata.gz: 057fc2598f2ad7a02b1847d502f6c0363f6f23382806de361a5dd87bb2b4c99d00993f2e272ab3f2c35697dc0b8843235df05301047b1c3cdbf0e0147498282d
7
- data.tar.gz: 1669c95792cc2a7731f4495e2d91e2e89b545412e213f8aff78e0dfc625a60c9f9403dc9960e4e487da1563e794fb2d3a6d5126f4ab6ef35e6ebe42e9950fd5d
6
+ metadata.gz: 15960f305c5c9f6cfef40e55adf0b5eb95a54f51aa902dae77d2db6c7735b821d7b3bb320582eebed3141a3a5d72cf396e51a3a7845a9222f283c6ca4e9a0e90
7
+ data.tar.gz: 70d21559793f7c48965c8bf2601788dccf6f63c7c66db70cd49a3822b280664d30a333db717b61ad7348fbb831a31ab158ef22dc117f8e683321da131b52dad5
@@ -0,0 +1,73 @@
1
+ # 01 — CBDT/CBLC GID-stable propagation
2
+
3
+ ## Priority
4
+ P0
5
+
6
+ ## Problem
7
+
8
+ `Stitcher::CbdtPropagator#propagate_tables_into` (`lib/fontisan/stitcher/cbdt_propagator.rb:147`) copies CBDT/CBLC bytes raw from the CBDT source into the compiled font. CBLC indexes glyphs by **source GID**. But the Stitcher's compile order is:
9
+
10
+ 1. `inject_notdef` → compiled GID 0
11
+ 2. `copy_outlines` → compiled GIDs 1..N (outline donor glyphs)
12
+ 3. `add_placeholder_glyphs` → compiled GIDs N+1..M (CBDT placeholders, often renamed to `"gid{N}.1"` to avoid name collisions per PR #108)
13
+
14
+ So the CBDT source's GID 110 may end up at compiled GID 4027+ — but the raw-bytes CBLC still says "GID 110 → bitmap for source GID 110". The bitmap data dangles, pointing at whatever outline glyph happens to be at compiled GID 110.
15
+
16
+ This is masked in the cited Essenfont use case because FSung-3 (Ext G outlines) and NotoColorEmoji (emoji bitmaps) cover disjoint codepoint ranges. But for any stitch where CBDT and outline donors cover overlapping codepoint ranges (e.g. assembling a combined Latin+emoji font from a single source that has both glyf and CBDT), the bitmap-to-glyph mapping is wrong.
17
+
18
+ Documented as a known limitation in `docs/STITCHER_GUIDE.adoc` (added in PR #110).
19
+
20
+ ## Goal
21
+
22
+ After compile, `propagate_tables_into` rebuilds the CBLC by:
23
+ 1. Walking the compiled font's cmap to find each compiled GID for every CBDT-covered codepoint.
24
+ 2. Mapping source GID → compiled GID via the Stitcher's `GlyphMapping`.
25
+ 3. Building a new CBLC whose `BitmapSize.startGlyphIndex`/`endGlyphIndex` and IndexSubTableArray entries reference compiled GIDs.
26
+ 4. Building a new CBDT that lays out bitmap blocks in compiled-GID order so each block matches its CBLC offset.
27
+
28
+ The result: any stitch — overlapping codepoint ranges or not — produces a font where CBLC's GID references line up with the compiled GIDs that actually have the right bitmaps.
29
+
30
+ ## Approach
31
+
32
+ Reuse the CBDT/CBLC BinData models from PR #106:
33
+ - `Tables::Cblc`, `Tables::CblcBitmapSize`, `Tables::CblcIndexSubTableArrayEntry`, `Tables::CblcIndexSubTable`
34
+ - `Subset::TableStrategy::ColorBitmapSubsetter` and its plan classes — already do the offset-remap math for the subsetter; same algorithm shape, different mapping source
35
+
36
+ New collaborator: `Stitcher::CbdtReindexer`. Sits between compile and table propagation:
37
+
38
+ ```
39
+ compile TTF
40
+
41
+ CbdtReindexer.reindex(source_cblc, source_cbdt, glyph_mapping) → new (CBLC, CBDT) bytes
42
+
43
+ FontWriter.write_to_file(tables.merge("CBLC" => new_cblc, "CBDT" => new_cbdt))
44
+ ```
45
+
46
+ The reindexer is stateless given inputs — testable in isolation.
47
+
48
+ Update `propagate_tables_into` to call the reindexer instead of raw-bytes copy.
49
+
50
+ Update `docs/STITCHER_GUIDE.adoc` to remove the "known limitation" note.
51
+
52
+ ## Out of scope
53
+
54
+ - Handling IndexSubTable format 4 (bit-packed offsets) — already unsupported in the subsetter; same gap.
55
+ - Multi-strike CBLC where each strike has a different glyph range — covered (reindexer iterates strikes).
56
+ - Subsetting the source CBDT before stitching — separate concern (the subsetter already does this for `Subset::Builder`).
57
+
58
+ ## Effort
59
+
60
+ ~1 day.
61
+
62
+ ## Dependencies
63
+
64
+ None. Unblocks TODO 02.
65
+
66
+ ## Acceptance criteria
67
+
68
+ - New spec `spec/fontisan/stitcher/cbdt_reindexer_spec.rb` covers:
69
+ - Disjoint-range stitch: CBLC's compiled GID references line up.
70
+ - Overlapping-range stitch: CBDT placeholder at compiled GID N gets the bitmap originally at source GID M (mapping via GlyphMapping).
71
+ - Multi-strike CBLC: every strike's bitmap data lands at the right compiled GID.
72
+ - Existing `spec/fontisan/stitcher/outline_priority_spec.rb` collection-mode test (currently skipped) is unblocked and passes.
73
+ - The "GID-stability limitation" section in `docs/STITCHER_GUIDE.adoc` is removed.
@@ -0,0 +1,48 @@
1
+ # 02 — Collection-mode outline-priority regression
2
+
3
+ ## Priority
4
+ P0
5
+
6
+ ## Problem
7
+
8
+ `spec/fontisan/stitcher/outline_priority_spec.rb:160` ("write_collection preserves outline-first cmap priority across faces") was unblocked for single-face mode in PR #108 but the **collection variant** is still skipped:
9
+
10
+ ```ruby
11
+ skip "Stitcher currently raises on a CBDT source without at least one outline codepoint in each face — " \
12
+ "collection-mode coverage needs CbdtPropagator hardening tracked separately."
13
+ ```
14
+
15
+ When the Stitcher builds a TTC/OTC, each face compiles independently. The CBDT source is global — its placeholders need to land in EVERY face that covers any of its codepoints. The current `CbdtPropagator#add_placeholder_glyphs` raises if a face has no outline glyph from any other donor (the CBDT source alone can't seed a face).
16
+
17
+ ## Goal
18
+
19
+ The skipped test passes. A CBDT source can be stitched into a collection where some faces cover only CBDT codepoints (no outline donor coverage). Each face gets the CBDT/CBLC tables propagated.
20
+
21
+ ## Approach
22
+
23
+ Two pieces:
24
+
25
+ 1. **`CbdtPropagator#add_placeholder_glyphs`** — relax the "must have an outline codepoint" precondition. Allow a face to be seeded with just CBDT placeholders (plus the `.notdef` that's always required).
26
+
27
+ 2. **`CbdtPropagator#propagate_tables_into`** — for collections, run per-face rather than once. The collection writer already iterates faces; this is a matter of plumbing.
28
+
29
+ This depends on TODO 01 (GID-stable propagation) because the collection case multiplies the GID-mapping complexity — each face has its own compiled GID space.
30
+
31
+ ## Out of scope
32
+
33
+ - Multi-CBDT-source support (the `MultipleCbdtSourcesError` guard remains).
34
+ - Cross-face glyph deduplication (already handled by the existing dedup pass).
35
+
36
+ ## Effort
37
+
38
+ ~2 hours once TODO 01 lands.
39
+
40
+ ## Dependencies
41
+
42
+ - TODO 01 (CBDT/CBLC GID-stable propagation).
43
+
44
+ ## Acceptance criteria
45
+
46
+ - The skipped test at `outline_priority_spec.rb:160` is unskipped and passes.
47
+ - A new test covers the "CBDT-only face" case (a face with no outline donor coverage, only CBDT placeholders).
48
+ - `MultipleCbdtSourcesError` is still raised when two CBDT sources are declared.
@@ -0,0 +1,189 @@
1
+ # 03 — `fontisan audit` command (identity+style+features lens)
2
+
3
+ ## Priority
4
+ P1
5
+
6
+ ## Problem
7
+
8
+ The `fontist-archive-private` pipeline currently reaches into Fontisan internals to extract font identity + metadata:
9
+
10
+ ```ruby
11
+ # Current (wrong) approach — manual cmap + table poking
12
+ font = Fontisan::FontLoader.load(font_path)
13
+ cmap = font.table("cmap")
14
+ codepoints = cmap ? cmap.unicode_mappings.keys : []
15
+ # ...plus ad-hoc reads of name, OS/2, head, fvar, GSUB, GPOS
16
+ ```
17
+
18
+ Fragile and bypasses Fontisan's API. Fontisan has `InfoCommand` (font metadata) and `UnicodeCommand` (codepoint/glyph mappings), but neither produces a complete, self-describing audit record suitable for archival use.
19
+
20
+ ## Goal
21
+
22
+ A `fontisan audit` command that produces a structured **font audit report** (YAML or JSON) covering what ucode's audit does NOT:
23
+
24
+ - **Identity** — name-table strings (family, full, PostScript, version)
25
+ - **Style** — OS/2 weight/width classes, head macStyle, fsSelection italic/bold, panose, fvar axes
26
+ - **Coverage facts** — total codepoints, total glyphs, cmap subtable provenance, optional codepoint list
27
+ - **OpenType layout** — GSUB/GPOS scripts (distinct from Unicode scripts), features list
28
+ - **Provenance** — `generated_at`, `fontisan_version`, `source_sha256`
29
+
30
+ ## MECE split with ucode
31
+
32
+ ucode owns the Unicode-coverage axis (UCD parsing, block/script aggregation, per-codepoint glyph extraction). fontisan owns the font-identity axis.
33
+
34
+ | Concern | Owner |
35
+ |---------|-------|
36
+ | UCD fetching + parsing | ucode |
37
+ | Block/script aggregation from codepoints | ucode |
38
+ | Per-codepoint SVG extraction | ucode |
39
+ | HTML browsers | ucode |
40
+ | Font identity (name table) | **fontisan audit** |
41
+ | Style metadata (OS/2, head, fvar) | **fontisan audit** |
42
+ | OpenType scripts + features | **fontisan audit** |
43
+ | Source provenance (sha256, fontisan_version) | **fontisan audit** |
44
+ | cmap subtable provenance | **fontisan audit** |
45
+
46
+ **The fontisan audit does NOT bundle UCDXML parsing.** Block aggregation is delegated: fontisan audit accepts an optional pre-built ucode index path, OR emits the codepoint list and lets the consumer (fontist-archive) run ucode separately.
47
+
48
+ ## Beyond ucode: revised priority after self-debate
49
+
50
+ A critical self-debate (see commit history) culled several "diagnostic" features that overlap with existing tools. The surviving axes:
51
+
52
+ ### Keep — high value, fontisan-unique
53
+ - **Identity + Style + Features** — justified for archival format (one YAML per face, with provenance). NOT a general font inspector.
54
+ - **Subset report** — when fontisan subsets, emit a structured report (glyph/codepoint/table breakdown). Web font engineers hit this daily; pyftsubset emits minimal output. fontisan has the subset infrastructure.
55
+ - **Cross-format equivalence** — `fontisan equivalent font.ttf font.woff2` → true/false + diff. Catches CDN regeneration bugs from stale masters. fontTools diffs are too noisy.
56
+ - **License/rights extraction** — OS/2 fsType embedding bits, name ID 13 license, head macStyle commercial-use hints. Compliance teams need this before redistribution. Currently buried.
57
+ - **Collection integrity** — narrow (CJK foundries, Apple system fonts) but real for that audience. Cheap to add once identity extraction exists.
58
+
59
+ ### Drop — overlap with established tools
60
+ - ~~**OTS-rejection predictor**~~ — Chromium ships `ots-sanitize`, the actual tool Chrome uses. Reimplementing in Ruby means we're always behind Chrome's evolving rule set. Better: wrap ots-sanitize as subprocess.
61
+ - ~~**Format-round-trip report**~~ — WOFF2 is lossless by design; report would say "nothing changed" 99% of the time. TTF↔OTF outline-fidelity is a real check but it's one function, not an audit axis.
62
+ - ~~**Variable-font readiness**~~ — **fontbakery** owns this space. Hundreds of mature checks, Google Fonts uses it for every submission. Don't compete; wrap if needed.
63
+ - ~~**Hinting audit**~~ — audience is ~50 people worldwide. Modern web fonts ship largely unhinted. VF fonts don't use TT hints. Specialized tools (FontLab, ttfautohint) serve this niche better.
64
+
65
+ ### Maybe later — niche but real
66
+ - **Performance profile** — glyph count, table-level bytes, predicted parse time. Mobile/embedded niche. Apple has internal tools; nothing public.
67
+ - **Identity hash** — canonical hash of (name + post + head + OS/2 identity) surviving lossless conversion. CDN/asset-pipeline use case.
68
+
69
+ The headline correction: the original proposal positioned fontisan audit as a "font inspector" competing with commodity tools. The actually-valuable features are **workflow-specific reports** (subset, conversion equivalence, license) that leverage fontisan's unique subsetting + conversion infrastructure. ucode can't do any of these.
70
+
71
+ ## Approach
72
+
73
+ ### Models (`lib/fontisan/models/audit/`)
74
+
75
+ Use `lutaml-model` like all other fontisan models:
76
+
77
+ - `AuditReport` — top-level report, attributes per the schema below
78
+ - `AuditAxis` — variable-font axis descriptor
79
+ - `AuditFeature` — feature tag + scripts list
80
+
81
+ ### Command (`lib/fontisan/commands/audit_command.rb`)
82
+
83
+ `Commands::AuditCommand` orchestrates — does NOT re-parse tables. Delegates to existing commands:
84
+
85
+ - `InfoCommand` — identity + name-table strings
86
+ - `UnicodeCommand` — codepoint list + cmap subtable provenance
87
+ - `ScriptsCommand` — `opentype_scripts`
88
+ - `FeaturesCommand` — `features`
89
+ - `FormatDetector` — `source_format`
90
+
91
+ This makes `AuditCommand` a thin orchestration layer.
92
+
93
+ ### CLI (`lib/fontisan/cli.rb`)
94
+
95
+ ```bash
96
+ # Standalone
97
+ fontisan audit Inter-Regular.ttf # → stdout (YAML)
98
+ fontisan audit Inter-Regular.ttf -o report.yaml # → file
99
+ fontisan audit Inter-Regular.ttf --format json # → stdout (JSON)
100
+
101
+ # Collection
102
+ fontisan audit Inter.ttc -o reports/ # → reports/Inter.ttc/{00..08}-*.yaml
103
+
104
+ # Single face from collection
105
+ fontisan audit Inter.ttc --font-index 6 -o Inter-Bold.yaml
106
+ ```
107
+
108
+ ### Output schema (one face per file)
109
+
110
+ ```yaml
111
+ generated_at: 2026-07-10T12:30:00Z
112
+ fontisan_version: 0.4.24
113
+ source_file: Inter-Regular.ttf
114
+ source_sha256: 3b1a...
115
+ source_format: ttf
116
+
117
+ font_index: null
118
+ num_fonts_in_source: 1
119
+
120
+ # Identity
121
+ family_name: Inter
122
+ subfamily_name: Regular
123
+ full_name: Inter Regular
124
+ postscript_name: Inter-Regular
125
+ version: Version 4.000;git-a52131595
126
+ font_revision: 4.0
127
+
128
+ # Style
129
+ weight_class: 400
130
+ width_class: 5
131
+ italic: false
132
+ bold: false
133
+ panose: "2 0 5 3 0 0 0 0 0 0"
134
+ is_variable: false
135
+ axes: []
136
+
137
+ # Coverage
138
+ total_codepoints: 2857
139
+ total_glyphs: 1486
140
+ cmap_subtables: [4, 12, 14]
141
+ codepoints: [U+0000, U+0020, ...] # omitted with --no-codepoints
142
+
143
+ # OpenType layout
144
+ opentype_scripts: [latn, cyrl]
145
+ features: [kern, liga, calt]
146
+ ```
147
+
148
+ ### Collection layout
149
+
150
+ ```
151
+ reports/
152
+ Inter.ttc/
153
+ 00-Inter-Regular.yaml
154
+ 01-Inter-Italic.yaml
155
+ ...
156
+ 08-Inter-Black.yaml
157
+ ```
158
+
159
+ Filename pattern: `{font_index:02d}-{postscript_name}.yaml`. Index prefix guarantees face-order sort and disambiguates broken fonts where two faces share a PostScript name.
160
+
161
+ ## Out of scope
162
+
163
+ - UCDXML parsing (ucode's domain)
164
+ - Block/script aggregation (consumer runs ucode separately, OR a future `--with-ucode` flag calls ucode as a subprocess)
165
+ - HTML browsers (ucode's domain)
166
+ - Per-codepoint glyph extraction (ucode's 4-tier resolver)
167
+ - Reading formula YAML — this command reports what the FONT FILE declares
168
+ - Generating WOFF specimens (ConvertCommand's domain)
169
+
170
+ ## Effort
171
+
172
+ ~1-2 days.
173
+
174
+ ## Dependencies
175
+
176
+ None directly. Existing commands (`InfoCommand`, `UnicodeCommand`, `ScriptsCommand`, `FeaturesCommand`) provide the data; `AuditCommand` just orchestrates.
177
+
178
+ ## Acceptance criteria
179
+
180
+ - New spec `spec/fontisan/commands/audit_command_spec.rb` covers:
181
+ - Standalone TTF → YAML + JSON output
182
+ - Collection TTC → directory of N face reports
183
+ - `--font-index N` → single-face report
184
+ - `--no-codepoints` omits the codepoint list
185
+ - Source sha256 matches `Digest::SHA256.file(path)`
186
+ - Variable font with fvar → axes array populated
187
+ - New spec `spec/fontisan/models/audit_report_spec.rb` covers the model's serialization.
188
+ - CLI smoke test verifies `fontisan audit path/to/font.ttf` produces valid YAML on stdout.
189
+ - README/docs updated.
@@ -0,0 +1,46 @@
1
+ # 04 — UFO composite glyph encoding
2
+
3
+ ## Priority
4
+ P1
5
+
6
+ ## Problem
7
+
8
+ `Ufo::Compile::GlyfLoca` (`lib/fontisan/ufo/compile/glyf_loca.rb:137`) emits simple glyphs only. UFO sources with composite glyphs (e.g., é defined as a component reference to e + acute) lose their component structure on compile — the compiled glyf either drops them or depends on `flatten_components` filter pre-processing.
9
+
10
+ ## Goal
11
+
12
+ `GlyfLoca` encodes composite glyphs natively per OpenType spec section 5.6. A UFO `<component>` element maps to a glyf component record referencing the target glyph by index, preserving the original transform.
13
+
14
+ ## Approach
15
+
16
+ Add a `CompositeEncoder` collaborator in `lib/fontisan/ufo/compile/glyf_loca/composite_encoder.rb` that takes a UFO `<component>` plus a GID-resolver (component name → compiled GID) and emits the binary component record per OT spec:
17
+
18
+ ```
19
+ uint16 flags
20
+ uint16 glyphIndex
21
+ (uint8|uint8|uint8|uint8 | int8|int8|int8|int8 | uint8) args # depends on flags bits 0/1
22
+ (F2Dot14 × 4 | F2Dot14 × 2 | F2Dot14) transformation # depends on flags bits 7/6/3
23
+ ```
24
+
25
+ `GlyfLoca` invokes the encoder when a UFO glyph has components (no contours, OR contours + components — latter is rare but spec-legal).
26
+
27
+ The existing `flatten_components` filter remains for callers who want pre-flattening (e.g., for fonts that target renderers without component support).
28
+
29
+ ## Out of scope
30
+
31
+ - Variable-font composite variation deltas (`gvar`) — separate concern, TODO 06's blast radius.
32
+ - Cycling component references (A → B → A) — these need pre-validation, tracked separately.
33
+
34
+ ## Effort
35
+
36
+ ~1 day.
37
+
38
+ ## Dependencies
39
+
40
+ None.
41
+
42
+ ## Acceptance criteria
43
+
44
+ - New spec covers encoding of all 8 component arg/transform combinations (flags bit patterns).
45
+ - Round-trip spec: UFO with composite → compile → re-read via `FontLoader` → components match original.
46
+ - The `flatten_components` filter still works as a pre-pass for callers that want flattened output.
@@ -0,0 +1,63 @@
1
+ # 05 — OTF compiler real CFF charstrings
2
+
3
+ ## Priority
4
+ P1
5
+
6
+ ## Problem
7
+
8
+ `Ufo::Compile::OtfCompiler` (`lib/fontisan/ufo/compile/otf_compiler.rb:9`) currently emits a placeholder CFF table — no real charstrings. Any OTF output is incomplete:
9
+
10
+ ```
11
+ # TODO.full/10: this currently emits a placeholder CFF table
12
+ # real charstrings. Full CFF construction lands when TODO 10
13
+ ```
14
+
15
+ The font loads but renders nothing useful. Production OTF output is blocked.
16
+
17
+ ## Goal
18
+
19
+ `OtfCompiler` emits a complete CFF table with:
20
+ - Real Type 2 charstrings for every glyph (converted from UFO contours)
21
+ - Valid Top DICT (font matrix, charset, encoding, CharStrings INDEX offset)
22
+ - Valid Private DICT (default width, nominal width, SubrZERO subroutines if any)
23
+ - Correct name INDEX, charset, FDSelect/FDArray (for CID-keyed fonts)
24
+
25
+ Round-trip: UFO → compile → re-read via `FontLoader` → contours match.
26
+
27
+ ## Approach
28
+
29
+ Reuse the Type 2 charstring conversion logic from `lib/fontisan/type1/charstring_converter.rb` — UFO contours → moveto/lineto/curveto closepath is structurally identical to Type 1 → Type 2 charstring conversion.
30
+
31
+ Add a new `CffBuilder` collaborator under `lib/fontisan/ufo/compile/otf_compiler/cff_builder.rb` that owns:
32
+
33
+ 1. **Top DICT construction** — font matrix, ROS (if CID), charstrings offset.
34
+ 2. **CharString INDEX** — per-glyph Type 2 charstring bytes.
35
+ 3. **Charset** — glyph name → GID map (uses post.names if available; else `gid{N}`).
36
+ 4. **Private DICT** — default width, nominal width, hint-mask operators (no-op when source has no hints).
37
+ 5. **Global / local subroutines** — empty INDEXs initially; TODO 06 wires hint subroutines.
38
+
39
+ The existing `Tables::Cff` BinData record already declares the on-disk structure; the builder produces the values to populate it.
40
+
41
+ ## Out of scope
42
+
43
+ - CFF2 (variable fonts) — TODO 06.
44
+ - Subroutine packing / optimization — `Optimizers::SubroutineOptimizer` already exists for Type 1; CFF equivalent is a separate task.
45
+ - CID-keyed fonts with multi-FD — single FD only initially.
46
+
47
+ ## Effort
48
+
49
+ ~2-3 days.
50
+
51
+ ## Dependencies
52
+
53
+ None (the placeholder CFF was the only thing blocking, and we're replacing it).
54
+
55
+ ## Acceptance criteria
56
+
57
+ - New spec `spec/fontisan/ufo/compile/otf_compiler/cff_builder_spec.rb` covers:
58
+ - Single-glyph font (`.notdef` + one outline) → valid CFF
59
+ - Multi-glyph font with quadratic curves → charstrings use rcurveto correctly
60
+ - Multi-glyph font with TrueType cubic curves → curve conversion to Type 2 cubic
61
+ - Charset ordering matches maxp.numGlyphs
62
+ - Round-trip: UFO → OTF → re-read → contours match (within Type 2 quantization tolerance)
63
+ - `fontisan convert font.ufo --to otf` produces a font that loads in fontTools and Chrome.
@@ -0,0 +1,51 @@
1
+ # 06 — CFF2 blend/vsindex operators
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+
8
+ `Ufo::Compile::VariableOtf` (`lib/fontisan/ufo/compile/variable_otf.rb:12`) emits static CFF2 only:
9
+
10
+ ```
11
+ - CFF2 outlines (static — blend/vsindex operators are TODO 07/18)
12
+ TODO 07 (blend/vsindex) and TODO 18 (blend integration) to be
13
+ ```
14
+
15
+ Variable CFF2 fonts (e.g., variable OTF output from a UFO with `fvar` + `gvar`-equivalent masters) can't round-trip. The compiled font is static — variation deltas are dropped.
16
+
17
+ ## Goal
18
+
19
+ `VariableOtf` emits CFF2 with `blend` and `vsindex` operators per CFF2 spec section 3.A.4. A variable UFO source compiles to a variable OTF that renders correctly at any chosen instance along each axis.
20
+
21
+ ## Approach
22
+
23
+ Two pieces:
24
+
25
+ 1. **CharString encoder** — extend the Type 2 charstring builder (from TODO 05) to emit `blend` after each variation-aware value. The master value is the default; deltas for each region are pushed on the stack then summed via `blend`.
26
+
27
+ 2. **ItemVariationData emission** — translate the UFO master model (`gvar`-equivalent: per-glyph deltas keyed by region) into CFF2's `ItemVariationData` and `ItemVariationStore` structures. Reuse `Ufo::Compile::ItemVariationStore` which already handles this for the variable-TTF path.
28
+
29
+ 3. **Region detection** — pre-pass over `fvar` axes + master locations to compute the set of variation regions. Same algorithm as `Ufo::Compile::Fvar`.
30
+
31
+ ## Out of scope
32
+
33
+ - HVAR/VVAR/MVAR tables for variable OTF — those are outside CFF2 but already partially supported; track separately if needed.
34
+ - Backwards-compat with non-variable CFF fonts — separate concern.
35
+ - Subroutine packing of variation-aware charstrings — TODO follow-up.
36
+
37
+ ## Effort
38
+
39
+ ~3-5 days. CFF2 charstring semantics are intricate.
40
+
41
+ ## Dependencies
42
+
43
+ - TODO 05 (OTF compiler real CFF) — must land first; the CFF2 builder extends the CFF builder.
44
+
45
+ ## Acceptance criteria
46
+
47
+ - New spec covers:
48
+ - Single-axis variable font → CFF2 with correct `blend` operators + `ItemVariationStore`
49
+ - Multi-axis variable font → region table correct, no cross-axis contamination
50
+ - Instance generation via `Variation::InstanceGenerator` produces output identical to direct master compile (within tolerance)
51
+ - Round-trip: UFO with fvar + masters → variable OTF → re-read → `fvar` axes match, instances render correctly.
@@ -0,0 +1,71 @@
1
+ # 07 — CPAL v1 header fields
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+
8
+ `Tables::Cpal` (`lib/fontisan/tables/cpal.rb:106,184`) only parses CPAL v0 fields. CPAL v1 adds:
9
+
10
+ - `numPaletteEntryLabels` (uint16) — count of palette entry labels
11
+ - `numPaletteLabels` (uint16) — count of palette labels
12
+ - `paletteEntryLabelsOffset` (uint32) — offset to label records
13
+ - `paletteLabelsOffset` (uint32) — offset to label records
14
+
15
+ These attach human-readable names (via the `name` table) to color palettes and entries. Fonts with CPAL v1 lose this metadata on parse.
16
+
17
+ ## Goal
18
+
19
+ `Tables::Cpal` parses v1 header fields and exposes:
20
+
21
+ - `palette_labels` — array of name-record IDs, one per palette
22
+ - `palette_entry_labels` — array of name-record IDs, one per palette entry index
23
+
24
+ Round-trip preserves the labels.
25
+
26
+ ## Approach
27
+
28
+ Extend the BinData record in `lib/fontisan/tables/cpal.rb`:
29
+
30
+ ```ruby
31
+ class Cpal < Binary::BaseRecord
32
+ uint16 :version
33
+ uint16 :num_palette_entries
34
+ uint16 :num_palettes
35
+ uint16 :num_color_records
36
+ uint32 :color_records_offset
37
+ array :color_record_indices, type: :uint16, initial_length: :num_palettes
38
+ # v0 ends here
39
+
40
+ # v1 extension — only present when version >= 1
41
+ choice :v1_fields, onlyif: -> { version >= 1 } do
42
+ uint16 :num_palette_entry_labels
43
+ uint16 :num_palette_labels
44
+ uint32 :palette_entry_labels_offset
45
+ uint32 :palette_labels_offset
46
+ end
47
+ end
48
+ ```
49
+
50
+ After BinData parse, lazily walk the label offsets to populate `palette_labels` and `palette_entry_labels` arrays.
51
+
52
+ For emission: if v1 fields are set, include them; else emit v0.
53
+
54
+ ## Out of scope
55
+
56
+ - CPAL v2 changes (none — v1 is the latest defined).
57
+ - Building new palettes from scratch — just parse + emit.
58
+
59
+ ## Effort
60
+
61
+ ~4 hours.
62
+
63
+ ## Dependencies
64
+
65
+ None.
66
+
67
+ ## Acceptance criteria
68
+
69
+ - New spec covers parse of a synthetic CPAL v1 table with both label arrays.
70
+ - Round-trip: v1 table → read → `to_binary_s` → identical bytes.
71
+ - v0 tables still parse and emit unchanged.
@@ -0,0 +1,52 @@
1
+ # 08 — CFF standard string table
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+
8
+ `Tables::Cff` (`lib/fontisan/tables/cff.rb:419`) ships only a partial standard string table (the 391 predefined Type 1 / CFF strings per spec appendix A). Glyph names that should be in the standard set are duplicated in the custom strings index, wasting space and breaking round-trip fidelity with fontTools.
9
+
10
+ ## Goal
11
+
12
+ Complete the 391-string standard table per CFF spec. `Cff` parses glyph names against the standard table; only non-standard names go in the custom strings index.
13
+
14
+ ## Approach
15
+
16
+ Replace the partial list with the canonical 391 strings (spec appendix A, also in fontTools' `fontTools.cffLib.strings`).
17
+
18
+ Add a class method:
19
+
20
+ ```ruby
21
+ class Cff < Binary::BaseRecord
22
+ STANDARD_STRINGS = [...].freeze # 391 entries
23
+
24
+ def self.standard_string?(name)
25
+ STANDARD_STRINGS.include?(name)
26
+ end
27
+
28
+ def self.standard_string_index(name)
29
+ STANDARD_STRINGS.index(name)
30
+ end
31
+ end
32
+ ```
33
+
34
+ Update the CFF builder (TODO 05's `CffBuilder`) to skip custom-string encoding when a name is standard.
35
+
36
+ ## Out of scope
37
+
38
+ - CID-keyed fonts (no name strings) — unaffected.
39
+ - Custom charstring-internal names (operator names) — those are operators, not string IDs.
40
+
41
+ ## Effort
42
+
43
+ ~2 hours.
44
+
45
+ ## Dependencies
46
+
47
+ - TODO 05 (OTF compiler real CFF) benefits; doesn't strictly block.
48
+
49
+ ## Acceptance criteria
50
+
51
+ - Spec covers: every standard name → correct index, every non-standard name → custom index.
52
+ - Round-trip: CFF with mixed standard/non-standard names → read → emit → identical bytes.
@@ -0,0 +1,57 @@
1
+ # 09 — Type 1 seac expansion
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+
8
+ `Type1::CharStringConverter` (`lib/fontisan/type1/charstring_converter.rb:161`) and `Type1::TtfToType1Converter` (`lib/fontisan/type1/ttf_to_type1_converter.rb:256`) emit seac operators as-is when converting Type 1 → TrueType. seac (Standard Encoding Accented Character) composites two glyphs into one — e.g., é = e + acute at a fixed offset. Modern renderers deprecated seac; Type 1 → TTF conversion must expand it into actual outlines.
9
+
10
+ ## Goal
11
+
12
+ `TtfToType1Converter` (and the reverse path) expands seac into merged outlines:
13
+
14
+ 1. Resolve the two referenced glyphs (base + accent) via the source font.
15
+ 2. Translate them to the seac offset.
16
+ 3. Concatenate their contours into the parent glyph.
17
+
18
+ ## Approach
19
+
20
+ Add a `SeacExpander` collaborator under `lib/fontisan/type1/seac_expander.rb` that takes:
21
+
22
+ - A charstring with a seac operator
23
+ - A resolver: glyph_name → charstring (for the base + accent)
24
+
25
+ Returns a new charstring with the seac replaced by the merged outline operators.
26
+
27
+ Walk the source's standard-encoding lookup (Adobe Standard Encoding) to map seac's two uint8 operands to glyph names. Decode the operands:
28
+
29
+ ```
30
+ seac: asb base accent
31
+ asb — sidebearing (unused in expansion)
32
+ base — Adobe Standard Encoding index of the base glyph
33
+ accent — Adobe Standard Encoding index of the accent glyph
34
+ ```
35
+
36
+ The expander recursively expands seac in base/accent glyphs (seac can nest).
37
+
38
+ ## Out of scope
39
+
40
+ - seac in CFF (not Type 1) — CFF deprecated seac entirely; should be a warning, not an expansion.
41
+ - Replacing the deprecated `seac` operator in TTF → Type 1 conversion — seac emission is already removed; we only need to handle source seac on input.
42
+
43
+ ## Effort
44
+
45
+ ~6 hours.
46
+
47
+ ## Dependencies
48
+
49
+ None.
50
+
51
+ ## Acceptance criteria
52
+
53
+ - Spec covers:
54
+ - Single-level seac (e.g., é) → expanded to merged outline.
55
+ - Nested seac (e.g., a glyph that references another seac glyph as its base) → fully expanded.
56
+ - seac referencing a missing glyph → raise `SeacReferenceError` with the missing name.
57
+ - Round-trip: Type 1 with seac → TTF → re-read → outline matches the merged shape.
@@ -0,0 +1,71 @@
1
+ # 10 — UFO image set + feature writers
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+
8
+ Two UFO subsystems ship as stubs:
9
+
10
+ - `Ufo::ImageSet` (`lib/fontisan/ufo/image_set.rb:6`) — placeholder, "real implementation lands with TODO 02 (glyph model + images)"
11
+ - `Ufo::Features` (`lib/fontisan/ufo/features.rb:8`) — placeholder, "TODO 08 (feature writers)"
12
+
13
+ UFO sources with `features.fea` (OpenType layout compilation from text) or images (UFO 3 image data) lose both on round-trip.
14
+
15
+ ## Goal
16
+
17
+ ### ImageSet
18
+ - `Ufo::ImageSet` parses UFO 3 image data: reads `images/` directory, exposes `images` array of `Ufo::Image` records with filename + sha + PNG bytes.
19
+ - `Ufo::Glyph#image` (already modeled) reads image references; round-trips.
20
+ - Compile: glyphs with image references emit `CBDT`/`CBLC` color-bitmap entries (reusing the BinData models from PR #106).
21
+
22
+ ### Features (feature writers)
23
+ - `Ufo::Features` parses `features.fea` via a new `FeatureCompiler` collaborator.
24
+ - Compiles OpenType layout rules (kern, liga, calt, etc.) into `GSUB`/`GPOS` tables.
25
+ - Output tables are mergeable into the compiled font.
26
+
27
+ ## Approach
28
+
29
+ ### ImageSet
30
+ Reuse existing `Tables::Cbdt`/`Cblc` BinData records. Convert each UFO image to a small-metrics-format PNG block. Build CBLC IndexSubTableArray pointing at each block.
31
+
32
+ For test fixtures: the existing `CbdtFixture` (PR #107) already builds valid CBDT/CBLC pairs — use as reference for the emission shape.
33
+
34
+ ### Features
35
+ Use a fea-syntax parser. Options:
36
+ - **Inline parser** — non-trivial; ~5 days work, ~500 LOC.
37
+ - **Adapter for `fontTools.feaLib`** — only available with Python; introduces cross-language dep (violates "pure Ruby" rule).
38
+ - **Limited fea subset** — handle only kern/liga (most common); error on unsupported constructs.
39
+
40
+ Recommendation: **limited fea subset** initially, expanding as real-world fonts demand.
41
+
42
+ Add `FeatureCompiler` under `lib/fontisan/ufo/compile/feature_compiler.rb`. Parses `features.fea` into a tree, then emits GSUB/GPOS records. Starts with:
43
+ - `feature kern { pair ... } kern;` → GPOS PairPos (format 1)
44
+ - `feature liga { sub ... by ...; } liga;` → GSUB LigatureSubst
45
+
46
+ ## Out of scope
47
+
48
+ - Adobe FEA parser completeness — full spec is 100+ pages; cover what real fonts use.
49
+ - AFDKO integration — out of scope.
50
+
51
+ ## Effort
52
+
53
+ - ImageSet: ~1 day (BinData + IO).
54
+ - Features (limited subset): ~3 days (kern + liga paths).
55
+ - Features (full fea): weeks — explicitly out of scope.
56
+
57
+ ## Dependencies
58
+
59
+ - TODO 05 (OTF compiler real CFF) — image glyphs end up in CBDT, unrelated to CFF. No dep.
60
+ - None blocking.
61
+
62
+ ## Acceptance criteria
63
+
64
+ ### ImageSet
65
+ - Spec covers read of UFO with one image → `glyph.image` populated, `imageset.images.first.bytes` matches PNG bytes.
66
+ - Compile round-trip: UFO with image glyph → TTF → re-read → CBDT/CBLC tables present, bitmap decodable.
67
+
68
+ ### Features
69
+ - Spec covers a UFO with kern feature → GPOS table present with correct PairPos.
70
+ - Spec covers a UFO with liga feature → GSUB table present with correct LigatureSubst.
71
+ - Unsupported fea constructs raise `UnsupportedFeaError` with a clear message.
@@ -0,0 +1,49 @@
1
+ # 11 — kern groups.plist support
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+
8
+ `Ufo::Compile::FeatureWriters::Kern` (`lib/fontisan/ufo/compile/feature_writers/kern.rb:18`) reads kerning pairs from `kerning.plist` but only partially supports `groups.plist`:
9
+
10
+ ```
11
+ # groups.plist data (TODO: groups.plist support is partial).
12
+ ```
13
+
14
+ UFO sources that use class-based kerning (kerning classes defined in `groups.plist` instead of per-glyph) lose the class data. Compiled fonts have incomplete kern tables.
15
+
16
+ ## Goal
17
+
18
+ `Kern` feature writer reads `groups.plist` class definitions, resolves them in `kerning.plist` lookups, and emits GPOS class-based PairPos records (format 2).
19
+
20
+ ## Approach
21
+
22
+ Two pieces:
23
+
24
+ 1. **Group resolution** — when `kerning.plist` references a group name (rather than a glyph name), expand to all group members. Track group → glyph-set mapping.
25
+
26
+ 2. **Format-2 emission** — emit PairPos format 2 (class-based) when > N pairs share a class. Threshold: ~5 pairs (configurable). Below threshold, fall back to format 1 (pair-based) for size.
27
+
28
+ Add a `KerningClassResolver` collaborator under `lib/fontisan/ufo/compile/feature_writers/kern/class_resolver.rb`. Stateless given (kerning.plist, groups.plist); returns a list of resolved pairs (glyph_a, glyph_b, value).
29
+
30
+ ## Out of scope
31
+
32
+ - Variable-font kerning (HVAR table) — separate concern.
33
+ - Contextual kerning (`kern` feature with context) — separate feature.
34
+
35
+ ## Effort
36
+
37
+ ~4 hours.
38
+
39
+ ## Dependencies
40
+
41
+ None.
42
+
43
+ ## Acceptance criteria
44
+
45
+ - Spec covers:
46
+ - Class-based kerning (groups.plist with two classes, kerning.plist with one pair between them).
47
+ - Mixed class + glyph kerning (one glyph + one class).
48
+ - Threshold: < 5 pairs → format 1; >= 5 pairs → format 2.
49
+ - Round-trip: UFO with class kerning → TTF → re-read → GPOS contains the right PairPos records.
@@ -0,0 +1,68 @@
1
+ # 12 — `cbdt_fixture.rb` full BinData conversion
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+
8
+ `spec/support/cbdt_fixture.rb` was partially refactored in PR #107 (CBDT/CBLC use the new BinData models), but the remaining tables still hand-roll binary packing:
9
+
10
+ - `head_table` — 19-element `[...].pack("NNNNnnNNnnnnnnnnnn")`
11
+ - `hhea_table` — 16-element pack
12
+ - `os2_table` — 43-element pack
13
+ - `name_table` — manual record + offset arithmetic
14
+ - `post_table` — manual pack
15
+ - `hmtx_table` — `Array.new(num_glyphs) { [1000, 0] }.flatten.pack("n*")`
16
+ - `cmap_table` / `format4_subtable` / `format12_subtable` — full cmap builder, ~80 lines
17
+
18
+ ~200 lines of pack/unpack that duplicate the layout knowledge in `Tables::*`. If any table layout changes, the fixture breaks silently — the same anti-pattern PR #107 already fixed for CBDT/CBLC.
19
+
20
+ ## Goal
21
+
22
+ Every table builder in `cbdt_fixture.rb` constructs via BinData record (`Tables::Head`, `Tables::Hhea`, `Tables::Maxp`, `Tables::Os2`, `Tables::Name`, `Tables::Post`, `Tables::Hmtx`, `Tables::Cmap`). No more `[...].pack(...)` calls.
23
+
24
+ The fixture's self-test (`spec/support/cbdt_fixture_spec.rb`) catches any drift between the fixture's output and the BinData models.
25
+
26
+ ## Approach
27
+
28
+ For each table, replace the hand-rolled pack with a `Tables::*` BinData construction:
29
+
30
+ ```ruby
31
+ # Before
32
+ def head_table
33
+ [0x00010000, 0x00005000, ...].pack("NNNNnnNNnnnnnnnnnn")
34
+ end
35
+
36
+ # After
37
+ def head_table
38
+ Tables::Head.new(
39
+ version_raw: 0x00010000,
40
+ font_revision: 0x00005000,
41
+ ...
42
+ ).to_binary_s
43
+ end
44
+ ```
45
+
46
+ For tables with field shapes that don't match the BinData record cleanly (e.g., `name_table`'s variable-length string storage), use the BinData record's nested array attributes.
47
+
48
+ For `cmap_table`: extract the format-4 / format-12 subtable builders to a reusable `Tables::Cmap.from_mappings(mappings)` class method. The same logic is duplicated in `Subset::TableStrategy::Cmap::Builder` and `Subset::TableStrategy::ColorBitmapSubsetter` — DRY win across three call sites.
49
+
50
+ ## Out of scope
51
+
52
+ - Refactoring the BinData models themselves — only changes the fixture's consumers.
53
+ - Adding `Tables::Cmap.from_mappings` if the DRY extraction scope proves larger than the fixture alone (split into separate PR).
54
+
55
+ ## Effort
56
+
57
+ ~4 hours.
58
+
59
+ ## Dependencies
60
+
61
+ None.
62
+
63
+ ## Acceptance criteria
64
+
65
+ - `spec/support/cbdt_fixture.rb` contains zero `[...].pack` calls.
66
+ - `spec/support/cbdt_fixture_spec.rb` self-test still passes (it round-trips via the BinData models; the assertions don't change).
67
+ - All downstream specs that use `CbdtFixture` still pass.
68
+ - Bundle size of the fixture file drops from ~300 lines to ~150.
@@ -0,0 +1,45 @@
1
+ # 13 — Split `OctokitFetcher` out of `fixture_downloader.rb`
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+
8
+ `spec/support/fixture_downloader.rb` (post-PR #111) is ~350 lines with two distinct concerns:
9
+
10
+ 1. **Retry / routing loop** (the `Downloader` class) — backoff, Retry-After, permanent-vs-transient classification.
11
+ 2. **Octokit URL dispatch** (the `OctokitFetcher` module) — release-asset, raw-file, contents-API routing.
12
+
13
+ These are MECE-distinct. Bundling them in one file muddies the boundary; future changes to Octokit routing shouldn't require reading the retry loop.
14
+
15
+ ## Goal
16
+
17
+ `OctokitFetcher` moves to its own file `spec/support/fixture_downloader/octokit_fetcher.rb`. The `Downloader` class becomes the only public surface in `spec/support/fixture_downloader.rb` and delegates to `OctokitFetcher` when needed.
18
+
19
+ ## Approach
20
+
21
+ 1. Create `spec/support/fixture_downloader/` directory.
22
+ 2. Move `OctokitFetcher` module to `spec/support/fixture_downloader/octokit_fetcher.rb`.
23
+ 3. Move `HttpStatusIo` and `Downloader` constants stay in `spec/support/fixture_downloader.rb`.
24
+ 4. Add a `module FixtureFonts::Downloader` autoload or simple `require_relative "fixture_downloader/octokit_fetcher"` from `fixture_downloader.rb`.
25
+
26
+ Actually — for spec/support files, `require_relative` is acceptable (CLAUDE.md rule on autoload is for `lib/`). No autoload needed; the Rakefile's `require_relative` chain handles loading.
27
+
28
+ ## Out of scope
29
+
30
+ - Splitting the Downloader further — it's cohesive.
31
+ - Moving `fixture_downloader_spec.rb` — already sibling, fine where it is.
32
+
33
+ ## Effort
34
+
35
+ ~1 hour.
36
+
37
+ ## Dependencies
38
+
39
+ None.
40
+
41
+ ## Acceptance criteria
42
+
43
+ - `spec/support/fixture_downloader.rb` < 200 lines.
44
+ - `spec/support/fixture_downloader/octokit_fetcher.rb` exists and contains all Octokit-specific code.
45
+ - All existing specs pass unchanged.
@@ -0,0 +1,62 @@
1
+ # 14 — Rubocop baseline chip (per-namespace)
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+
8
+ PR #111's `rubocop -A --auto-gen-config` baseline refresh tracked ~9600 remaining offenses. The `.rubocop_todo.yml` file is now ~800 lines of `Exclude:` lists. Layout/Style cops dominate (most are auto-correctable; the rest need manual fixes).
9
+
10
+ Leaving the baseline in this state makes future rubocop improvements invisible — new offenses are buried in baseline noise, and contributors can't see whether their PR is clean vs. baseline-burdened.
11
+
12
+ ## Goal
13
+
14
+ Reduce baseline count to < 1000 offenses via per-namespace PRs:
15
+
16
+ | Namespace | Approximate count | Priority |
17
+ |-----------|-------------------|----------|
18
+ | `lib/fontisan/tables/` | ~3000 | High (well-understood) |
19
+ | `lib/fontisan/ufo/compile/` | ~2500 | High |
20
+ | `lib/fontisan/stitcher/` | ~800 | Medium |
21
+ | `lib/fontisan/converters/` | ~500 | Medium |
22
+ | `lib/fontisan/type1/` | ~400 | Medium |
23
+ | `lib/fontisan/collection/` | ~300 | Medium |
24
+ | `lib/fontisan/subset/` | ~300 | Medium (recent PR #106 work) |
25
+ | `lib/fontisan/woff2/` | ~250 | Medium |
26
+ | `lib/fontisan/optimizers/` | ~200 | Low |
27
+ | `lib/fontisan/pipeline/` | ~200 | Low |
28
+ | `lib/fontisan/commands/` | ~150 | Low |
29
+ | `lib/fontisan/validators/` | ~100 | Low |
30
+ | `lib/fontisan/binary/` | ~100 | Low |
31
+ | `spec/` | ~1500 | Low |
32
+
33
+ ## Approach
34
+
35
+ One PR per namespace. Each PR:
36
+
37
+ 1. Runs `rubocop -A lib/fontisan/<namespace>/` to auto-correct everything possible.
38
+ 2. Manually fixes what auto-correct couldn't (typically: complex Layout cops, naming).
39
+ 3. Updates `.rubocop_todo.yml` to drop the now-cleaned `Exclude:` entries.
40
+ 4. Verifies the full test suite still passes.
41
+
42
+ Order: tables first (highest count, lowest risk — table parsing is purely structural).
43
+
44
+ ## Out of scope
45
+
46
+ - Disabling cops project-wide — these are real issues, not noise.
47
+ - Refactoring architectural patterns rubocop flags (e.g., long methods) — separate refactors.
48
+
49
+ ## Effort
50
+
51
+ ~30 minutes per namespace on average. ~7 hours total.
52
+
53
+ ## Dependencies
54
+
55
+ None.
56
+
57
+ ## Acceptance criteria
58
+
59
+ - `.rubocop_todo.yml` < 100 lines (from ~800).
60
+ - `bundle exec rubocop` reports 0 offenses.
61
+ - All existing specs pass after each namespace PR.
62
+ - No behavior change (pure style/layout).
@@ -0,0 +1,57 @@
1
+ # Fontisan Improvements Backlog
2
+
3
+ This directory tracks all known improvement work, ordered by priority.
4
+ Each file is `NN-short-name.md` where `NN` is the priority order.
5
+
6
+ ## Priorities
7
+
8
+ ### P0 — Correctness gaps (open bugs / silent failures)
9
+ - [01 — CBDT/CBLC GID-stable propagation](01-cbdt-cblc-gid-stable-propagation.md)
10
+ - [02 — Collection-mode outline-priority regression](02-collection-outline-priority.md)
11
+
12
+ ### P1 — High-value features
13
+ - [03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)
14
+ - [04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)
15
+ - [05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)
16
+
17
+ ### P2 — Specialist feature parity
18
+ - [x] ~~[07 — CPAL v1 header fields](07-cpal-v1-header-fields.md)~~ ✓ Done
19
+ - [06 — CFF2 blend/vsindex operators](06-cff2-blend-vsindex-operators.md)
20
+ - [08 — CFF standard string table](08-cff-standard-string-table.md)
21
+ - [09 — Type 1 seac expansion](09-type1-seac-expansion.md)
22
+ - [10 — UFO image set + feature writers](10-ufo-image-set-feature-writers.md)
23
+ - [11 — kern groups.plist support](11-kern-groups-plist.md)
24
+
25
+ ### P3 — Code-quality cleanup
26
+ - [x] ~~[13 — Split `OctokitFetcher` out of `fixture_downloader.rb`](13-split-octokit-fetcher.md)~~ ✓ Done (commit `4c68f2a`)
27
+ - [12 — `cbdt_fixture.rb` full BinData conversion](12-cbdt-fixture-bindata-conversion.md)
28
+ - [14 — Rubocop baseline chip (per-namespace)](14-rubocop-baseline-chip.md)
29
+
30
+ ## Convention
31
+
32
+ Each TODO file follows this template:
33
+
34
+ ```markdown
35
+ # NN — Title
36
+
37
+ ## Priority
38
+ P0 / P1 / P2 / P3
39
+
40
+ ## Problem
41
+ What's broken or missing. Concrete examples.
42
+
43
+ ## Goal
44
+ What good looks like. Acceptance criteria.
45
+
46
+ ## Approach
47
+ Architecture-level sketch. Files to add/change. Constraints.
48
+
49
+ ## Out of scope
50
+ Explicit non-goals.
51
+
52
+ ## Effort
53
+ Rough estimate (hours/days).
54
+
55
+ ## Dependencies
56
+ Other TODOs that must land first.
57
+ ```
@@ -27,7 +27,10 @@ module Fontisan
27
27
  # - numColorRecords (uint16): Total number of color records
28
28
  # - colorRecordsArrayOffset (uint32): Offset to color records array
29
29
  #
30
- # Version 1 adds optional metadata for palette types and labels.
30
+ # Version 1 Header (24 bytes) adds:
31
+ # - paletteTypesArrayOffset (uint32): Offset to palette types array
32
+ # - paletteLabelsArrayOffset (uint32): Offset to palette labels array
33
+ # - paletteEntryLabelsArrayOffset (uint32): Offset to palette entry labels
31
34
  #
32
35
  # Color Record Structure (4 bytes, BGRA format):
33
36
  # - blue (uint8)
@@ -47,6 +50,13 @@ module Fontisan
47
50
  # OpenType table tag for CPAL
48
51
  TAG = "CPAL"
49
52
 
53
+ # Palette type bit flags (CPAL v1, paletteTypesArray entries).
54
+ PALETTE_TYPE_LIGHT_ON_DARK = 0x01
55
+ PALETTE_TYPE_DARK_ON_LIGHT = 0x02
56
+
57
+ # Offset that means "no table present" in CPAL v1.
58
+ NULL_OFFSET = 0
59
+
50
60
  # @return [Integer] CPAL version (0 or 1)
51
61
  attr_reader :version
52
62
 
@@ -71,6 +81,19 @@ module Fontisan
71
81
  # @return [Array<Hash>] Parsed color records (RGBA hashes)
72
82
  attr_reader :color_records
73
83
 
84
+ # @return [Array<Integer>, nil] Palette type bit flags, one per
85
+ # palette. nil for CPAL v0 or when v1 has no palette types array.
86
+ attr_reader :palette_types
87
+
88
+ # @return [Array<Integer>, nil] Name-record IDs (name table) for
89
+ # each palette. nil for CPAL v0 or when v1 has no labels array.
90
+ attr_reader :palette_labels
91
+
92
+ # @return [Array<Integer>, nil] Name-record IDs (name table) for
93
+ # each palette entry index (shared across palettes). nil for
94
+ # CPAL v0 or when v1 has no entry-labels array.
95
+ attr_reader :palette_entry_labels
96
+
74
97
  # Override read to parse CPAL structure
75
98
  #
76
99
  # @param io [IO, String] Binary data to read
@@ -102,8 +125,8 @@ module Fontisan
102
125
  # Parse color records
103
126
  parse_color_records(io)
104
127
 
105
- # Version 1 features (palette types, labels) not implemented yet
106
- # TODO: Add version 1 features in follow-up task
128
+ # Version 1: palette types + labels (lazily walked via offsets)
129
+ parse_v1_metadata if version == 1
107
130
  rescue StandardError => e
108
131
  raise CorruptedTableError, "Failed to parse CPAL table: #{e.message}"
109
132
  end
@@ -152,6 +175,44 @@ module Fontisan
152
175
  color_record ? color_to_hex(color_record) : nil
153
176
  end
154
177
 
178
+ # Get the name-record ID for a palette's human-readable label.
179
+ #
180
+ # @param palette_index [Integer]
181
+ # @return [Integer, nil] nameID for the palette, or nil if v0 / no labels
182
+ def palette_label(palette_index)
183
+ return nil unless palette_labels
184
+ return nil if palette_index.negative? || palette_index >= palette_labels.length
185
+
186
+ palette_labels[palette_index]
187
+ end
188
+
189
+ # Get the name-record ID for a palette entry's human-readable label.
190
+ #
191
+ # @param entry_index [Integer]
192
+ # @return [Integer, nil] nameID for the entry, or nil if v0 / no labels
193
+ def palette_entry_label(entry_index)
194
+ return nil unless palette_entry_labels
195
+ return nil if entry_index.negative? || entry_index >= palette_entry_labels.length
196
+
197
+ palette_entry_labels[entry_index]
198
+ end
199
+
200
+ # Whether a palette is intended for light-on-dark use (CPAL v1).
201
+ #
202
+ # @param palette_index [Integer]
203
+ # @return [Boolean] true if the palette type bit is set; false otherwise
204
+ def light_on_dark?(palette_index)
205
+ palette_type_set?(palette_index, PALETTE_TYPE_LIGHT_ON_DARK)
206
+ end
207
+
208
+ # Whether a palette is intended for dark-on-light use (CPAL v1).
209
+ #
210
+ # @param palette_index [Integer]
211
+ # @return [Boolean] true if the palette type bit is set; false otherwise
212
+ def dark_on_light?(palette_index)
213
+ palette_type_set?(palette_index, PALETTE_TYPE_DARK_ON_LIGHT)
214
+ end
215
+
155
216
  # Validate the CPAL table structure
156
217
  #
157
218
  # @return [Boolean] True if valid
@@ -169,7 +230,7 @@ module Fontisan
169
230
 
170
231
  private
171
232
 
172
- # Parse CPAL header (12 bytes for version 0, 16 bytes for version 1)
233
+ # Parse CPAL header (12 bytes for v0, 24 bytes for v1)
173
234
  #
174
235
  # @param io [StringIO] Input stream
175
236
  def parse_header(io)
@@ -179,12 +240,14 @@ module Fontisan
179
240
  @num_color_records = io.read(2).unpack1("n")
180
241
  @color_records_array_offset = io.read(4).unpack1("N")
181
242
 
182
- # Version 1 has additional header fields
183
- if version == 1
184
- # TODO: Parse version 1 header fields
185
- # - paletteTypesArrayOffset (uint32)
186
- # - paletteLabelsArrayOffset (uint32)
187
- # - paletteEntryLabelsArrayOffset (uint32)
243
+ # Version 1 has three additional uint32 offset fields. Offsets
244
+ # are read here but the arrays themselves are walked lazily
245
+ # from parse_v1_metadata so the v0 path (most common) doesn't
246
+ # pay for the seeks.
247
+ if @version == 1
248
+ @palette_types_array_offset = io.read(4).unpack1("N")
249
+ @palette_labels_array_offset = io.read(4).unpack1("N")
250
+ @palette_entry_labels_array_offset = io.read(4).unpack1("N")
188
251
  end
189
252
  end
190
253
 
@@ -228,7 +291,7 @@ module Fontisan
228
291
  @palette_indices = []
229
292
  return if num_palettes.zero?
230
293
 
231
- # Palette indices immediately follow header (at offset 12 for v0, 16 for v1)
294
+ # Palette indices immediately follow header (at offset 12 for v0, 24 for v1)
232
295
  # Each index is uint16 (2 bytes)
233
296
  num_palettes.times do
234
297
  index = io.read(2).unpack1("n")
@@ -262,6 +325,44 @@ module Fontisan
262
325
  end
263
326
  end
264
327
 
328
+ # CPAL v1 metadata: palette types + labels + entry labels.
329
+ # Walked lazily via the offsets parsed in parse_header. Any
330
+ # offset of 0 means "no table present" — leave the accessor nil
331
+ # rather than raise.
332
+ def parse_v1_metadata
333
+ @palette_types = walk_uint32_array(@palette_types_array_offset, num_palettes)
334
+ @palette_labels = walk_uint16_array(@palette_labels_array_offset, num_palettes)
335
+ @palette_entry_labels = walk_uint16_array(@palette_entry_labels_array_offset,
336
+ num_palette_entries)
337
+ end
338
+
339
+ def walk_uint32_array(offset, count)
340
+ return nil if offset.nil? || offset == NULL_OFFSET
341
+ return [] if count.zero?
342
+
343
+ bytes = raw_data[offset, count * 4]
344
+ return nil unless bytes && bytes.bytesize == count * 4
345
+
346
+ bytes.unpack("N*")
347
+ end
348
+
349
+ def walk_uint16_array(offset, count)
350
+ return nil if offset.nil? || offset == NULL_OFFSET
351
+ return [] if count.zero?
352
+
353
+ bytes = raw_data[offset, count * 2]
354
+ return nil unless bytes && bytes.bytesize == count * 2
355
+
356
+ bytes.unpack("n*")
357
+ end
358
+
359
+ def palette_type_set?(palette_index, bit)
360
+ return false unless palette_types
361
+ return false if palette_index.negative? || palette_index >= palette_types.length
362
+
363
+ (palette_types[palette_index] & bit).nonzero? ? true : false
364
+ end
365
+
265
366
  # Convert color record to hex string
266
367
  #
267
368
  # @param color [Hash] Color hash with :red, :green, :blue, :alpha keys
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.24"
4
+ VERSION = "0.4.25"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.24
4
+ version: 0.4.25
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-07-09 00:00:00.000000000 Z
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -133,6 +133,21 @@ files:
133
133
  - LICENSE
134
134
  - README.adoc
135
135
  - Rakefile
136
+ - TODO.improvements/01-cbdt-cblc-gid-stable-propagation.md
137
+ - TODO.improvements/02-collection-outline-priority.md
138
+ - TODO.improvements/03-fontisan-audit-command.md
139
+ - TODO.improvements/04-ufo-composite-glyph-encoding.md
140
+ - TODO.improvements/05-otf-compiler-real-cff.md
141
+ - TODO.improvements/06-cff2-blend-vsindex-operators.md
142
+ - TODO.improvements/07-cpal-v1-header-fields.md
143
+ - TODO.improvements/08-cff-standard-string-table.md
144
+ - TODO.improvements/09-type1-seac-expansion.md
145
+ - TODO.improvements/10-ufo-image-set-feature-writers.md
146
+ - TODO.improvements/11-kern-groups-plist.md
147
+ - TODO.improvements/12-cbdt-fixture-bindata-conversion.md
148
+ - TODO.improvements/13-split-octokit-fetcher.md
149
+ - TODO.improvements/14-rubocop-baseline-chip.md
150
+ - TODO.improvements/README.md
136
151
  - benchmark/compile_benchmark.rb
137
152
  - benchmark/variation_quick_bench.rb
138
153
  - docs/.gitignore