fontisan 0.4.23 → 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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +240 -29
  3. data/Gemfile +6 -0
  4. data/Rakefile +9 -6
  5. data/TODO.improvements/01-cbdt-cblc-gid-stable-propagation.md +73 -0
  6. data/TODO.improvements/02-collection-outline-priority.md +48 -0
  7. data/TODO.improvements/03-fontisan-audit-command.md +189 -0
  8. data/TODO.improvements/04-ufo-composite-glyph-encoding.md +46 -0
  9. data/TODO.improvements/05-otf-compiler-real-cff.md +63 -0
  10. data/TODO.improvements/06-cff2-blend-vsindex-operators.md +51 -0
  11. data/TODO.improvements/07-cpal-v1-header-fields.md +71 -0
  12. data/TODO.improvements/08-cff-standard-string-table.md +52 -0
  13. data/TODO.improvements/09-type1-seac-expansion.md +57 -0
  14. data/TODO.improvements/10-ufo-image-set-feature-writers.md +71 -0
  15. data/TODO.improvements/11-kern-groups-plist.md +49 -0
  16. data/TODO.improvements/12-cbdt-fixture-bindata-conversion.md +68 -0
  17. data/TODO.improvements/13-split-octokit-fetcher.md +45 -0
  18. data/TODO.improvements/14-rubocop-baseline-chip.md +62 -0
  19. data/TODO.improvements/README.md +57 -0
  20. data/benchmark/compile_benchmark.rb +13 -5
  21. data/docs/.vitepress/config.ts +1 -0
  22. data/docs/STITCHER_GUIDE.adoc +47 -3
  23. data/docs/api/layer.md +89 -0
  24. data/docs/cli/subset.md +25 -6
  25. data/docs/guide/cli/subset.md +31 -0
  26. data/docs/guide/color-fonts/bitmaps.md +17 -0
  27. data/lib/fontisan/cli.rb +2 -1
  28. data/lib/fontisan/collection/writer.rb +2 -1
  29. data/lib/fontisan/commands/validate_collection_command.rb +7 -1
  30. data/lib/fontisan/commands.rb +2 -1
  31. data/lib/fontisan/converters/collection_converter.rb +6 -1
  32. data/lib/fontisan/converters/svg_generator.rb +3 -1
  33. data/lib/fontisan/stitcher/collection_result.rb +2 -1
  34. data/lib/fontisan/stitcher/glyph_copier.rb +2 -1
  35. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +3 -1
  36. data/lib/fontisan/stitcher/partition_strategy/by_script.rb +6 -2
  37. data/lib/fontisan/stitcher/source.rb +2 -1
  38. data/lib/fontisan/stitcher.rb +2 -1
  39. data/lib/fontisan/subset/table_strategy/glyf_loca_builder.rb +2 -1
  40. data/lib/fontisan/svg/standalone_glyph.rb +4 -1
  41. data/lib/fontisan/svg_to_glyf/assembler.rb +6 -3
  42. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +11 -4
  43. data/lib/fontisan/svg_to_glyf/geometry.rb +4 -2
  44. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +11 -5
  45. data/lib/fontisan/tables/cff.rb +2 -1
  46. data/lib/fontisan/tables/cff2/index_builder.rb +2 -1
  47. data/lib/fontisan/tables/cpal.rb +112 -11
  48. data/lib/fontisan/tables.rb +2 -1
  49. data/lib/fontisan/ufo/cli.rb +4 -1
  50. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +2 -1
  51. data/lib/fontisan/ufo/compile/cff.rb +6 -2
  52. data/lib/fontisan/ufo/compile/cmap.rb +11 -5
  53. data/lib/fontisan/ufo/compile/cpal.rb +2 -1
  54. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +3 -1
  55. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +3 -1
  56. data/lib/fontisan/ufo/compile/feature_writers.rb +2 -1
  57. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +4 -2
  58. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +2 -1
  59. data/lib/fontisan/ufo/compile/fvar.rb +7 -3
  60. data/lib/fontisan/ufo/compile/glyf_loca.rb +4 -2
  61. data/lib/fontisan/ufo/compile/gvar.rb +5 -2
  62. data/lib/fontisan/ufo/compile/head.rb +2 -1
  63. data/lib/fontisan/ufo/compile/item_variation_store.rb +2 -1
  64. data/lib/fontisan/ufo/compile/name.rb +3 -1
  65. data/lib/fontisan/ufo/compile/sbix.rb +2 -1
  66. data/lib/fontisan/ufo/compile/stat.rb +6 -3
  67. data/lib/fontisan/ufo/convert/from_bin_data.rb +6 -2
  68. data/lib/fontisan/ufo/convert/to_dfont.rb +6 -2
  69. data/lib/fontisan/ufo/convert/to_otc.rb +4 -1
  70. data/lib/fontisan/ufo/convert/to_postscript.rb +8 -3
  71. data/lib/fontisan/ufo/convert/to_woff.rb +8 -3
  72. data/lib/fontisan/ufo/convert/to_woff2.rb +8 -3
  73. data/lib/fontisan/ufo/convert.rb +4 -1
  74. data/lib/fontisan/ufo/info.rb +4 -2
  75. data/lib/fontisan/ufo/plist.rb +7 -2
  76. data/lib/fontisan/ufo/reader.rb +3 -1
  77. data/lib/fontisan/ufo/writer.rb +13 -5
  78. data/lib/fontisan/version.rb +1 -1
  79. data/lib/fontisan/woff2/collection_decoder.rb +4 -1
  80. data/lib/fontisan/woff2/collection_encoder.rb +4 -1
  81. data/lib/fontisan/woff2/glyf_canonicalizer.rb +2 -1
  82. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +5 -2
  83. data/lib/fontisan/woff2/sfnt_checksum.rb +2 -1
  84. data/lib/fontisan.rb +0 -1
  85. metadata +18 -4
  86. data/lib/fontisan/tasks/fixture_downloader.rb +0 -162
  87. data/lib/fontisan/tasks.rb +0 -11
@@ -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.