fontisan 0.4.43 → 0.4.44

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 (124) hide show
  1. checksums.yaml +4 -4
  2. data/TODO.bug-fixes/12-type1-generators-duck-typing.md +80 -0
  3. data/TODO.bug-fixes/13-hint-extractor-duck-typing.md +30 -0
  4. data/TODO.bug-fixes/14-font-interface-type-checks.md +82 -0
  5. data/TODO.bug-fixes/15-glyph-type-checks-and-simple-glyph-points-bug.md +49 -0
  6. data/TODO.bug-fixes/16-variation-table-checks.md +31 -0
  7. data/TODO.bug-fixes/17-ufo-and-stitcher-cleanup.md +32 -0
  8. data/TODO.bug-fixes/18-generic-value-and-io-checks.md +56 -0
  9. data/TODO.bug-fixes/19-serialization-migration.md +52 -0
  10. data/TODO.bug-fixes/20-variation-validator-type-checks.md +29 -0
  11. data/TODO.bug-fixes/21-xml-builder-send.md +24 -0
  12. data/TODO.bug-fixes/22-type1-converter-font-info.md +25 -0
  13. data/TODO.bug-fixes/23-boundary-type-checks.md +31 -0
  14. data/TODO.bug-fixes/24-validator-dynamic-field-key.md +25 -0
  15. data/TODO.bug-fixes/25-serialization-migration.md +49 -0
  16. data/TODO.bug-fixes/26-variation-preserver-spec-migration.md +25 -0
  17. data/TODO.bug-fixes/27-table-class-registry.md +50 -0
  18. data/TODO.bug-fixes/28-spec-doubles-cleanup.md +40 -0
  19. data/TODO.bug-fixes/29-strategy-registries.md +30 -0
  20. data/TODO.bug-fixes/README.md +24 -0
  21. data/lib/fontisan/binary/base_record.rb +13 -12
  22. data/lib/fontisan/collection/builder.rb +3 -5
  23. data/lib/fontisan/collection/dfont_builder.rb +4 -11
  24. data/lib/fontisan/collection/writer.rb +1 -1
  25. data/lib/fontisan/commands/pack_command.rb +4 -4
  26. data/lib/fontisan/commands/unpack_command.rb +1 -1
  27. data/lib/fontisan/commands/validate_command.rb +1 -1
  28. data/lib/fontisan/converters/format_converter.rb +2 -2
  29. data/lib/fontisan/converters/glyf_table_builder.rb +1 -1
  30. data/lib/fontisan/converters/outline_converter.rb +2 -6
  31. data/lib/fontisan/converters/table_copier.rb +4 -12
  32. data/lib/fontisan/converters/type1_converter.rb +12 -37
  33. data/lib/fontisan/converters/woff2_encoder.rb +8 -10
  34. data/lib/fontisan/converters/woff_writer.rb +3 -3
  35. data/lib/fontisan/export/exporter.rb +8 -9
  36. data/lib/fontisan/export/table_serializer.rb +71 -186
  37. data/lib/fontisan/export/transformers/font_to_ttx.rb +18 -18
  38. data/lib/fontisan/export/transformers/head_transformer.rb +1 -1
  39. data/lib/fontisan/export/transformers/hhea_transformer.rb +1 -1
  40. data/lib/fontisan/export/transformers/maxp_transformer.rb +1 -1
  41. data/lib/fontisan/export/transformers/name_transformer.rb +1 -1
  42. data/lib/fontisan/export/transformers/os2_transformer.rb +1 -1
  43. data/lib/fontisan/export/transformers/post_transformer.rb +1 -1
  44. data/lib/fontisan/export/ttx_generator.rb +36 -37
  45. data/lib/fontisan/font_writer.rb +1 -1
  46. data/lib/fontisan/glyph_accessor.rb +7 -9
  47. data/lib/fontisan/hints/postscript_hint_extractor.rb +24 -64
  48. data/lib/fontisan/hints/truetype_hint_extractor.rb +1 -1
  49. data/lib/fontisan/models/ttx/tables/binary_table.rb +1 -1
  50. data/lib/fontisan/optimizers/charstring_rewriter.rb +1 -1
  51. data/lib/fontisan/sfnt_font.rb +2 -0
  52. data/lib/fontisan/sfnt_source.rb +28 -0
  53. data/lib/fontisan/stitcher/source.rb +8 -10
  54. data/lib/fontisan/subset/builder.rb +2 -2
  55. data/lib/fontisan/subset/table_strategy/cff2.rb +2 -3
  56. data/lib/fontisan/svg_to_glyf/assembler.rb +59 -20
  57. data/lib/fontisan/svg_to_glyf/document.rb +28 -9
  58. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +34 -17
  59. data/lib/fontisan/tables/cff/charstring.rb +3 -0
  60. data/lib/fontisan/tables/cff/dict.rb +6 -3
  61. data/lib/fontisan/tables/cff/index.rb +1 -1
  62. data/lib/fontisan/tables/cff/private_dict_writer.rb +2 -2
  63. data/lib/fontisan/tables/cff/table_builder.rb +1 -1
  64. data/lib/fontisan/tables/cff.rb +3 -0
  65. data/lib/fontisan/tables/cff2/table_builder.rb +2 -4
  66. data/lib/fontisan/tables/cff2.rb +3 -0
  67. data/lib/fontisan/tables/cmap.rb +3 -0
  68. data/lib/fontisan/tables/cvar.rb +3 -0
  69. data/lib/fontisan/tables/fvar.rb +3 -0
  70. data/lib/fontisan/tables/glyf/simple_glyph.rb +9 -0
  71. data/lib/fontisan/tables/glyf.rb +12 -8
  72. data/lib/fontisan/tables/glyf_table.rb +1 -1
  73. data/lib/fontisan/tables/gvar.rb +3 -0
  74. data/lib/fontisan/tables/head.rb +3 -0
  75. data/lib/fontisan/tables/hhea.rb +3 -0
  76. data/lib/fontisan/tables/hmtx.rb +3 -7
  77. data/lib/fontisan/tables/hvar.rb +4 -0
  78. data/lib/fontisan/tables/loca.rb +3 -0
  79. data/lib/fontisan/tables/maxp.rb +3 -0
  80. data/lib/fontisan/tables/mvar.rb +3 -0
  81. data/lib/fontisan/tables/name.rb +3 -0
  82. data/lib/fontisan/tables/os2.rb +3 -0
  83. data/lib/fontisan/tables/post.rb +3 -0
  84. data/lib/fontisan/tables/registry.rb +71 -0
  85. data/lib/fontisan/tables/vvar.rb +3 -0
  86. data/lib/fontisan/tables.rb +2 -0
  87. data/lib/fontisan/type1/afm_generator.rb +12 -65
  88. data/lib/fontisan/type1/charstring_converter.rb +2 -2
  89. data/lib/fontisan/type1/conversion_options.rb +3 -2
  90. data/lib/fontisan/type1/decryptor.rb +1 -1
  91. data/lib/fontisan/type1/generator.rb +14 -9
  92. data/lib/fontisan/type1/inf_generator.rb +10 -35
  93. data/lib/fontisan/type1/pfa_generator.rb +36 -26
  94. data/lib/fontisan/type1/pfb_generator.rb +24 -12
  95. data/lib/fontisan/type1/pfm_generator.rb +14 -31
  96. data/lib/fontisan/type1/ttf_to_type1_converter.rb +30 -24
  97. data/lib/fontisan/ufo/bounds.rb +91 -0
  98. data/lib/fontisan/ufo/compile/fvar.rb +2 -2
  99. data/lib/fontisan/ufo/contour.rb +5 -0
  100. data/lib/fontisan/ufo/convert/from_bin_data.rb +15 -29
  101. data/lib/fontisan/ufo/info.rb +4 -0
  102. data/lib/fontisan/ufo/point.rb +0 -7
  103. data/lib/fontisan/ufo.rb +1 -0
  104. data/lib/fontisan/utilities/brotli_wrapper.rb +3 -5
  105. data/lib/fontisan/utilities/padding.rb +9 -8
  106. data/lib/fontisan/validation/collection_validator.rb +2 -2
  107. data/lib/fontisan/validators/validator.rb +4 -4
  108. data/lib/fontisan/variable/delta_applicator.rb +4 -11
  109. data/lib/fontisan/variable/metric_delta_processor.rb +3 -3
  110. data/lib/fontisan/variable/static_font_builder.rb +19 -23
  111. data/lib/fontisan/variation/instance_font_wrapper.rb +41 -0
  112. data/lib/fontisan/variation/instance_writer.rb +19 -17
  113. data/lib/fontisan/variation/metrics_adjuster.rb +17 -20
  114. data/lib/fontisan/variation/validator.rb +26 -38
  115. data/lib/fontisan/variation/variable_svg_generator.rb +0 -48
  116. data/lib/fontisan/variation/variation_preserver.rb +2 -3
  117. data/lib/fontisan/variation.rb +1 -0
  118. data/lib/fontisan/version.rb +1 -1
  119. data/lib/fontisan/woff2/sfnt_checksum.rb +1 -1
  120. data/lib/fontisan/woff2/table_transformer.rb +22 -29
  121. data/lib/fontisan/woff2_font.rb +9 -8
  122. data/lib/fontisan/woff_font.rb +2 -0
  123. data/lib/fontisan.rb +1 -0
  124. metadata +24 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4e765f39b4bf4e07536caf5c50ef0b97de1194d29623836a90aef0a8aaba21a6
4
- data.tar.gz: 4f83b4c02796fb6158f73d387e903f2aacab84d465a2704b525e07fbb506218c
3
+ metadata.gz: 7d195924806ae6e5a9ca3f0796493ec611303a30f39aeed2293829a6d3317a06
4
+ data.tar.gz: 96183528b1693ff11796622f2df05541a371606cfaddd8bc8ae5fccacf42b488
5
5
  SHA512:
6
- metadata.gz: 2b06582ee5b83fe73009fcafe496864619075fe4f240bb5b8a336ec67e6caa22d26de432147f690661751af40db4dbb2750fe2392dbf0c32752ae3b231983ea4
7
- data.tar.gz: cb4678080eb0a69c0e4c5ab698b0cdb012dcbc8e7929e57005f2dcc5df21b53708d549d3e099095cac51f44e11586965d8de2c99083e4e3dfba777b28ffac0d7
6
+ metadata.gz: 0c991caf6890e43dd5d95c47fe187421f536b4dc14c70f1a2c035ab254212356a4f85886e118832e3586721726fc69411b71739654fcf017ecaa9d09e5a2aa7a
7
+ data.tar.gz: dd7c245e25cc82d4024a49404938c624462a0b8d1593764c5c2c5bdb0555da052ce7bb436079d6959eaf17359f601034bf0c4c46b064b8be6cb419abc941829a
@@ -0,0 +1,80 @@
1
+ # 12 — Type 1 generators duck-typing cleanup
2
+
3
+ ## Priority
4
+ P0
5
+
6
+ ## Problem
7
+ 50+ `respond_to?` calls in `lib/fontisan/type1/` check for methods that
8
+ don't exist on the BinData Name/OS/2/Post tables. Every check returns
9
+ false and the generators silently fall through to empty/unknown values.
10
+
11
+ ## Sites (by file)
12
+
13
+ ### `pfm_generator.rb` (10)
14
+ - :506 `respond_to?(:copyright)` — Name table has no `#copyright`
15
+ - :521 `respond_to?(:full_font_name)` — Name table has no `#full_font_name`
16
+ - :523 `respond_to?(:font_family)` — Name table has no `#font_family`
17
+ - :525 `respond_to?(:postscript_name)` — Name table has no `#postscript_name`
18
+ - :541/:543 `respond_to?(:us_weight_class)` / `respond_to?(:weight_class)` — OS/2 has neither
19
+ - :372/:374 `respond_to?(:cap_height)` / `respond_to?(:s_typo_ascender)` — same
20
+ - :382 `respond_to?(:x_height)` — OS/2 has no `#x_height`
21
+ - :567 `respond_to?(:is_fixed_pitch)` — Post has no `#is_fixed_pitch`
22
+ - :148 `respond_to?(:unicode_mappings)` — Cmap DOES have this; check unnecessary
23
+
24
+ ### `pfa_generator.rb` (6)
25
+ - :137/:142 `respond_to?(:version_string)` / `:copyright` on Name
26
+ - :164 `respond_to?(:typo_ascender)` on OS/2
27
+ - :181/:189 `respond_to?(:weight_class)` on OS/2
28
+ - :307 `respond_to?(:points)` on glyph — same broken pattern as #20
29
+
30
+ ### `pfb_generator.rb` (3)
31
+ - :162/:170 `respond_to?(:weight_class)` on OS/2
32
+ - :276 `respond_to?(:points)` on glyph
33
+
34
+ ### `afm_generator.rb` (16)
35
+ - :170/:173 `respond_to?(:underline_position)` / `:underline_thickness` on Post
36
+ - :255/:257 `respond_to?(:us_weight_class)` / `:weight_class`
37
+ - :283 `respond_to?(:italic_angle)`
38
+ - :297 `respond_to?(:is_fixed_pitch)`
39
+ - :311/:323 `respond_to?(:version_string)` / `:copyright` on Name
40
+ - :339/:341/:343 `respond_to?(:unicode_mappings)` / `:unicode_bmp_mapping` / `:subtables` on Cmap
41
+ - :346/:348/:352 subtable field probes
42
+ - :368/:370/:371 `respond_to?(:font_bounding_box)` / `:x_min` / `:y_min` on Head
43
+ - :393 `respond_to?(:parse_with_context)` on Loca
44
+ - :401 `respond_to?(:glyph_for)` on Glyf
45
+ - :405/:407/:408 `respond_to?(:bounding_box)` / `:x_min` etc. on glyph
46
+
47
+ ### `inf_generator.rb` (10)
48
+ - :187/:202/:217/:269/:281/:293/:305 Name/Post field probes
49
+ - :229/:231 OS/2 weight class probes
50
+ - :257 `respond_to?(:italic_angle)`
51
+
52
+ ### `generator.rb` (1)
53
+ - :203 `respond_to?(:postscript_name)` on Name
54
+
55
+ ### `decryptor.rb` (1)
56
+ - :115 `respond_to?(:b)` on String — always true for Ruby strings
57
+
58
+ ## Approach
59
+ 1. Add a `NameIds` constants module if not present (NAME_COPYRIGHT=0,
60
+ NAME_FONT_FAMILY=1, NAME_FULL_FONT_NAME=4, NAME_VERSION=5,
61
+ NAME_POSTSCRIPT_NAME=6, NAME_MANUFACTURER=8, NAME_LICENSE=13).
62
+ 2. Replace `name_table.respond_to?(:X) ? name_table.X(1) : ""` with
63
+ `name_table&.english_name(NAME_ID_X) || ""`.
64
+ 3. For OS/2 fields, declare the BinData field accessor (most already
65
+ exist) and access directly: `os2&.us_weight_class`.
66
+ 4. For Post fields, same: `post&.italic_angle`.
67
+ 5. For Cmap, `cmap.unicode_mappings` is the documented API — drop the
68
+ `respond_to?` and just call it (nil guard at the boundary).
69
+ 6. For Loca parse_with_context, Loca responds to it always — drop the
70
+ check.
71
+ 7. For glyph bounding boxes, use the typed SimpleGlyph/CompoundGlyph
72
+ `bounding_box` method (always present).
73
+ 8. For `glyph.respond_to?(:points)`, see TODO 15 — the underlying bug
74
+ is fixed there.
75
+
76
+ ## Acceptance criteria
77
+ - 0 `respond_to?` in `lib/fontisan/type1/` (excluding `respond_to_missing?`)
78
+ - All Type 1 generation specs pass with real Libertinus fixture
79
+ - AFM/PFM/PFA/PFB/INF output now contains real Name table values
80
+ (previously was empty for most fields)
@@ -0,0 +1,30 @@
1
+ # 13 — Hint extractor duck-typing cleanup
2
+
3
+ ## Priority
4
+ P1
5
+
6
+ ## Problem
7
+ 15 `respond_to?` calls in `lib/fontisan/hints/postscript_hint_extractor.rb`
8
+ probe PrivateDict fields that are always declared on the BinData record.
9
+ Plus 2 sites in `postscript_hint_extractor.rb:60-62` that probe Charstring
10
+ shape (`:data` vs `:bytes`).
11
+
12
+ ## Sites
13
+ - :60/:62 `charstring.respond_to?(:data)` / `:bytes` — Charstring has one shape
14
+ - :292/:296/:300/:304 `respond_to?(:blue_values)` / `:other_blues` / `:family_blues` / `:family_other_blues`
15
+ - :308/:312/:316 `respond_to?(:blue_scale)` / `:blue_shift` / `:blue_fuzz`
16
+ - :320/:324 `respond_to?(:std_hw)` / `:std_vw`
17
+ - :328/:332 `respond_to?(:stem_snap_h)` / `:stem_snap_v`
18
+ - :336/:340 `respond_to?(:force_bold)` / `:language_group`
19
+
20
+ ## Approach
21
+ 1. Verify which fields are declared on `Tables::Cff::PrivateDict`.
22
+ 2. For declared fields, drop the `respond_to?` and read directly with
23
+ nil-coalescence: `private_dict.blue_values`.
24
+ 3. For undeclared fields (rare BinData optional), either declare them
25
+ with a default `nil` or remove the dead branch.
26
+ 4. For Charstring, standardize on one accessor (probably `#data`).
27
+
28
+ ## Acceptance criteria
29
+ - 0 `respond_to?` in `lib/fontisan/hints/`
30
+ - All hint extraction specs pass
@@ -0,0 +1,82 @@
1
+ # 14 — Font interface type checks
2
+
3
+ ## Priority
4
+ P1
5
+
6
+ ## Problem
7
+ ~30 `respond_to?` calls probe whether an object is font-like by
8
+ checking for `:table`, `:tables`, `:table_data`, `:has_table?`,
9
+ `:header`, `:cff?`. The codebase has a typed font hierarchy
10
+ (`SfntFont`, `TrueTypeFont`, `OpenTypeFont`, `WoffFont`, `Woff2Font`,
11
+ `Type1Font`) — these should be checked with `is_a?`.
12
+
13
+ ## Sites (by category)
14
+
15
+ ### Collection builders
16
+ - `collection/builder.rb:61` `respond_to?(:table_data)`
17
+ - `collection/dfont_builder.rb:47` `respond_to?(:table_data)`
18
+
19
+ ### Subset
20
+ - `subset/builder.rb:115` `respond_to?(:table)`
21
+ - `subset/table_strategy/cff2.rb` — already cleaned
22
+
23
+ ### Converters
24
+ - `converters/woff2_encoder.rb:221` `respond_to?(:table_names)`
25
+ - `converters/woff2_encoder.rb:250` `respond_to?(:has_table?)`
26
+ - `converters/woff2_encoder.rb:313/:315/:317` font shape probes
27
+ - `converters/outline_converter.rb:278/:282` `respond_to?(:tables)` / `:table`
28
+ - `converters/table_copier.rb:33/:37/:76/:80` font shape probes
29
+ - `converters/woff_writer.rb:116/:305` font shape / `:cff?`
30
+ - `converters/type1_converter.rb:115/:160/:222/:723-749` font shape + font_info probes
31
+ - `converters/format_converter.rb:415` `respond_to?(:table)`
32
+
33
+ ### Stitcher
34
+ - `stitcher/source.rb:83` `respond_to?(:has_table?)`
35
+ - `stitcher/source.rb:193/:220/:228` table parse probes
36
+ - `stitcher/source.rb:287/:289/:375/:377` glyph type probes (see TODO 15)
37
+
38
+ ### Variable
39
+ - `variable/delta_aplicator.rb:263` `respond_to?(:table_data)`
40
+ - `variable/static_font_builder.rb:80` `respond_to?(:tables)`
41
+ - `variation/variation_preserver.rb:151/:152` `respond_to?(:has_table?)` / `:table_data`
42
+
43
+ ### Commands
44
+ - `commands/validate_command.rb:216` `respond_to?(:table)`
45
+ - `commands/pack_command.rb:177/:188` `respond_to?(:header)`
46
+
47
+ ### Validation
48
+ - `validation/collection_validator.rb:249/:259` `respond_to?(:has_table?)`
49
+
50
+ ### Woff2 table transformer
51
+ - `woff2/table_transformer.rb:153` `respond_to?(:table_data)`
52
+
53
+ ### Other
54
+ - `commands/unpack_command.rb:210` `respond_to?(:table)`
55
+
56
+ ## Approach
57
+ 1. Define `SfntFont` as the type for any TrueType/OpenType/WOFF/WOFF2
58
+ font (already the base class).
59
+ 2. Define `Type1Font` similarly (already exists).
60
+ 3. Replace `respond_to?(:table)` / `:tables` / `:table_data` / `:has_table?`
61
+ with `is_a?(SfntFont)` (or `is_a?(Type1Font)` where applicable).
62
+ 4. Where the input legitimately can be either SfntFont or Type1Font,
63
+ accept a union: `font.is_a?(SfntFont) || font.is_a?(Type1Font)`.
64
+ 5. For `respond_to?(:header)` in pack_command, the SfntFont exposes
65
+ `sfnt_version` directly (already used elsewhere).
66
+ 6. For converters/type1_converter.rb font_info probes, the `font_info`
67
+ is always a `Type1::FontInfo` record — replace with `is_a?`.
68
+
69
+ ## Affected specs
70
+ - spec/fontisan/collection/builder_spec.rb (uses doubles)
71
+ - spec/fontisan/subset/builder_spec.rb (uses doubles)
72
+ - spec/fontisan/converters/* (most use real fonts; check each)
73
+ - spec/fontisan/stitcher/* (check)
74
+
75
+ The collection/builder_spec and subset/builder_spec were identified in
76
+ PR #135 as using doubles that block the is_a?(SfntFont) migration.
77
+ Migrate them to real SfntFont instances or SimpleDelegator-based fakes.
78
+
79
+ ## Acceptance criteria
80
+ - 0 `respond_to?(:table)` / `:tables` / `:table_data` / `:has_table?` /
81
+ `:header` / `:cff?` in `lib/fontisan/`
82
+ - All affected specs pass with real fonts or SimpleDelegator-based fakes
@@ -0,0 +1,49 @@
1
+ # 15 — Glyph type checks + SimpleGlyph#points bug
2
+
3
+ ## Priority
4
+ P0
5
+
6
+ ## Problem
7
+ ~15 `respond_to?` calls probe glyph shape (`:simple?`, `:compound?`,
8
+ `:points`, `:bounding_box`). Plus the latent bug discovered in PR #135:
9
+ `Type1::TTFToType1Converter#extract_points` calls `glyph.points.each`
10
+ but `Tables::SimpleGlyph` has no `points` method. The `respond_to?`
11
+ check silently masked this for years.
12
+
13
+ ## Sites
14
+ - `type1/ttf_to_type1_converter.rb:140` `respond_to?(:points)` — masks bug
15
+ - `type1/pfa_generator.rb:307` `respond_to?(:points)` — same bug
16
+ - `type1/pfb_generator.rb:276` `respond_to?(:points)` — same bug
17
+ - `ufo/convert/from_bin_data.rb:192/:312/:323/:325` `respond_to?(:simple?)` / `:compound?`
18
+ - `ufo/convert/from_bin_data.rb:361` `respond_to?(:glyph_name)`
19
+ - `stitcher/source.rb:287/:289/:375/:377` `respond_to?(:simple?)` / `:compound?`
20
+ - `hints/truetype_hint_extractor.rb:66` `respond_to?(:instructions)`
21
+ - `tables/glyf.rb:243` already fixed in PR #135
22
+
23
+ ## SimpleGlyph API surface (current)
24
+ - `num_contours`, `num_points`
25
+ - `points_for_contour(index)` → Array<Hash{x,y,on_curve}>
26
+ - `point_at(index)`, `on_curve?(index)`
27
+ - `bounding_box` → [x_min, y_min, x_max, y_max]
28
+ - `simple?`, `compound?`, `empty?`
29
+
30
+ ## Approach
31
+ 1. Add `Tables::SimpleGlyph#points` — returns a flat Array<Hash> across
32
+ all contours. Single source of truth, lazy-built.
33
+ 2. Add `Tables::CompoundGlyph#points` — returns the union of component
34
+ points (or empty if no components).
35
+ 3. Replace all `respond_to?(:points)` with `is_a?(Tables::SimpleGlyph)`.
36
+ 4. Replace `respond_to?(:simple?)` / `:compound?` with `is_a?` against
37
+ `Tables::SimpleGlyph`, `Tables::CompoundGlyph`, `Tables::Cff::CFFGlyph`.
38
+ 5. For `:bounding_box`, the typed glyph classes all have it — drop the
39
+ check.
40
+ 6. For `:glyph_name` on Post glyph records, declare it as a method (or
41
+ always-present BinData field).
42
+ 7. For `:instructions` on SimpleGlyph, declare it.
43
+
44
+ ## Acceptance criteria
45
+ - 0 `respond_to?(:simple?)` / `:compound?` / `:points` / `:bounding_box` /
46
+ `:glyph_name` / `:instructions` in `lib/fontisan/`
47
+ - Type 1 conversion now produces non-empty CharString data for simple
48
+ glyphs (the previously-broken `extract_points` path)
49
+ - All Type 1 specs pass
@@ -0,0 +1,31 @@
1
+ # 16 — Variation table checks
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+ ~15 `respond_to?` calls in `lib/fontisan/variation/` probe variation
8
+ table shape (`:item_variation_store`, `:region_list`, `:num_axes`,
9
+ `:advance_height_delta_set`, etc.). All variation tables are typed
10
+ BinData records — these checks should be `is_a?` or direct calls.
11
+
12
+ ## Sites
13
+ - `variation/validator.rb:106/:136/:142/:144/:199/:211/:275/:278/:281/:286`
14
+ - `variation/metrics_adjuster.rb:230/:233/:310`
15
+ - `variable/metric_delta_processor.rb:224/:229/:234`
16
+ - `variable/delta_applicator.rb:296`
17
+
18
+ ## Approach
19
+ 1. For each variation table (Fvar, Hvar, Vvar, Mvar, Cff2, ItemVariationStore),
20
+ verify the BinData-declared fields and methods.
21
+ 2. Replace `respond_to?(:item_variation_store)` with `is_a?(Tables::Hvar)`
22
+ or `is_a?(Tables::Vvar)` etc., then call `.item_variation_store`
23
+ directly.
24
+ 3. For optional fields (rare in variation tables), declare them with
25
+ defaults on the BinData record.
26
+ 4. For `metrics_adjuster.rb:310` `respond_to?(:number_of_h_metrics=)`,
27
+ Hhea is a BinData record with a declared writer — drop the check.
28
+
29
+ ## Acceptance criteria
30
+ - 0 `respond_to?` in `lib/fontisan/variation/` and `lib/fontisan/variable/`
31
+ - All variation specs pass
@@ -0,0 +1,32 @@
1
+ # 17 — UFO convert and Stitcher cleanup
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+ ~10 `respond_to?` calls in `ufo/convert/from_bin_data.rb` probe BinData
8
+ table shape. Plus 1 site in `ufo/compile/fvar.rb`.
9
+
10
+ ## Sites
11
+ - `ufo/convert/from_bin_data.rb:64/:82` Name records access
12
+ - `ufo/convert/from_bin_data.rb:103/:105` Post italic_angle
13
+ - `ufo/convert/from_bin_data.rb:131` Cmap unicode_mappings
14
+ - `ufo/convert/from_bin_data.rb:149/:155` Hmtx parse/metric
15
+ - `ufo/convert/from_bin_data.rb:172` Loca parse
16
+ - `ufo/convert/from_bin_data.rb:192/:312/:323/:325` Glyph type (see TODO 15)
17
+ - `ufo/convert/from_bin_data.rb:361` Post glyph_name
18
+ - `ufo/compile/fvar.rb:23/:24` Info axes/named_instances
19
+ - `stitcher/source.rb:83/:193/:220/:228/:287/:289/:375/:377` (see TODO 14/15)
20
+
21
+ ## Approach
22
+ 1. Replace `name_table.respond_to?(:name_records)` with `is_a?(Tables::Name)`.
23
+ 2. Replace `post.respond_to?(:italic_angle)` with `is_a?(Tables::Post)`.
24
+ 3. Replace `cmap_table.respond_to?(:unicode_mappings)` with `is_a?(Tables::Cmap)`.
25
+ 4. Replace `hmtx.respond_to?(:parse_with_context)` with `is_a?(Tables::Hmtx)`.
26
+ 5. Replace `loca.respond_to?(:parse_with_context)` with `is_a?(Tables::Loca)`.
27
+ 6. For `ufo/compile/fvar.rb`, the `font.info` is always a `Ufo::Info`.
28
+ Check `Ufo::Info#axes` exists; if not, add it.
29
+
30
+ ## Acceptance criteria
31
+ - 0 `respond_to?` in `lib/fontisan/ufo/convert/` and `lib/fontisan/ufo/compile/`
32
+ - 0 stitcher/source.rb respond_to? (split across TODO 14/15)
@@ -0,0 +1,56 @@
1
+ # 18 — Generic value and IO checks
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+ ~20 `respond_to?` calls probe generic value shape (`:to_i`, `:bytesize`,
8
+ `:to_binary_s`, `:empty?`, `:b`) or IO capabilities (`:seek`, `:rewind`).
9
+ Some are valid duck-typing at system boundaries; others are dead
10
+ defensive code.
11
+
12
+ ## Sites
13
+ ### Generic value conversion
14
+ - `export/ttx_generator.rb:67/:474` `respond_to?(:to_i)`
15
+ - `export/transformers/name_transformer.rb:47`
16
+ - `export/transformers/post_transformer.rb:36`
17
+ - `export/transformers/head_transformer.rb:45`
18
+ - `export/transformers/maxp_transformer.rb:48`
19
+ - `export/transformers/os2_transformer.rb:104`
20
+ - `export/transformers/hhea_transformer.rb:44`
21
+ - `export/transformers/font_to_ttx.rb:106`
22
+ - `export/exporter.rb:184/:204/:209` `respond_to?(:to_binary_s)` / `:checksum`
23
+ - `export/ttx_generator.rb:296/:435`
24
+
25
+ ### String/bytes shape
26
+ - `utilities/padding.rb:30/:55` `respond_to?(:bytesize)`
27
+ - `utilities/brotli_wrapper.rb:136` `respond_to?(:bytesize)`
28
+ - `type1/decryptor.rb:115` `respond_to?(:b)` — String always has `.b`
29
+
30
+ ### IO capability
31
+ - `binary/base_record.rb:27/:37` `respond_to?(:empty?)` / `:rewind`
32
+ - `tables/cff/index.rb:61` `respond_to?(:seek)`
33
+
34
+ ### Charstring shape
35
+ - `hints/postscript_hint_extractor.rb:60/:62` (see TODO 13)
36
+
37
+ ### Pattern matching
38
+ - `optimizers/charstring_rewriter.rb:130` `respond_to?(:positions)`
39
+
40
+ ## Approach
41
+ 1. For `respond_to?(:to_i)`, replace with `Integer(value) rescue value`
42
+ — accepts Integer-coercible values, falls back to original.
43
+ 2. For `respond_to?(:bytesize)`, narrow the type at the boundary: accept
44
+ `String` only, call `.bytesize` directly.
45
+ 3. For `respond_to?(:to_binary_s)`, all BinData records respond to it.
46
+ Drop the check; if a non-BinData object is passed, fix the caller.
47
+ 4. For `respond_to?(:b)` on String, just call `.b` (String always has it).
48
+ 5. For IO `respond_to?(:seek)` / `:rewind`, accept `IO` / `StringIO`
49
+ only. Convert String inputs to StringIO at the boundary.
50
+ 6. For `respond_to?(:empty?)`, the BinData array always responds — drop.
51
+
52
+ ## Acceptance criteria
53
+ - 0 unnecessary `respond_to?` in `lib/fontisan/export/`, `lib/fontisan/utilities/`,
54
+ `lib/fontisan/binary/`
55
+ - Where duck-typing is genuinely needed at a system boundary, document
56
+ it with a comment
@@ -0,0 +1,52 @@
1
+ # 19 — Hand-rolled serialization migration to lutaml-model
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+ 5 sites hand-roll `to_h` / `to_hash` on model classes, bypassing
8
+ lutaml-model's declarative serialization. Violates the absolute rule:
9
+ "NEVER write `def to_h`, `def to_hash`, `def from_h`, `def from_hash` on
10
+ a model class."
11
+
12
+ ## Sites
13
+ - `lib/fontisan/tables/hmtx.rb:54` `def to_h` — manual hash from instance vars
14
+ - `lib/fontisan/tables/cff/dict.rb:112` `def to_h` — manual hash
15
+ - `lib/fontisan/type1/generator.rb:167` `def to_hash` — manual result hash
16
+ - `lib/fontisan/type1/conversion_options.rb:105` `def to_hash` — manual hash
17
+ - `lib/fontisan/ufo/point.rb:32` `def to_h` — manual hash for GLIF output
18
+
19
+ ## Approach
20
+ For each model class:
21
+
22
+ 1. Make it inherit from `Lutaml::Model::Serializable`.
23
+ 2. Replace instance variables with `attribute :name, :type` declarations.
24
+ 3. Replace `to_h` / `to_hash` with a `key_value do ... end` mapping block.
25
+ 4. Callers use `model.to_hash` (framework-provided) instead of
26
+ hand-rolled `to_h`.
27
+
28
+ ### Special considerations
29
+
30
+ - **`Ufo::Point#to_h`**: produces a Hash for GLIF XML output. The
31
+ lutaml-model `key_value` mapping with `:x`, `:y`, `:type`, `:smooth`
32
+ fields handles this. The `smooth: true` only-if-set behavior is
33
+ modeled via lutaml-model's optional attribute pattern.
34
+
35
+ - **`Type1::Generator#to_hash`**: this is a *result* hash, not a model
36
+ serialization. Rename to `#to_result_hash` (or expose individual
37
+ accessors) to clarify it's not lutaml-model-shaped. OR convert the
38
+ result to a `Models::Type1GenerationResult` model.
39
+
40
+ - **`Type1::ConversionOptions#to_hash`**: this is a value object
41
+ representing conversion options. Migrate to lutaml-model with
42
+ `attribute` declarations for each option.
43
+
44
+ - **`Tables::Hmtx#to_h`**: BinData records already provide `snapshot`.
45
+ Remove the hand-rolled `to_h` and let callers use `snapshot`.
46
+
47
+ - **`Tables::Cff::Dict#to_h`**: same — use `snapshot`.
48
+
49
+ ## Acceptance criteria
50
+ - 0 `def to_h` / `def to_hash` in `lib/fontisan/` (excluding lutaml-model
51
+ framework methods)
52
+ - All serialization goes through `lutaml-model`
@@ -0,0 +1,29 @@
1
+ # 20 — Variation validator type checks
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+ 11 `respond_to?` calls in `lib/fontisan/variation/validator.rb` probe
8
+ typed BinData records (Cff2, Hvar, ItemVariationStore, RegionList).
9
+ The checks were left in place in PR #136 because `validator_spec.rb`
10
+ uses minimal doubles without all the methods stubbed.
11
+
12
+ ## Sites
13
+ - :106 `cff2.respond_to?(:num_axes)` — Cff2 always declares num_axes
14
+ - :136 `table.respond_to?(:item_variation_store)` — Hvar/Vvar/Mvar all do
15
+ - :142 `store.respond_to?(:region_list)` — ItemVariationStore has it
16
+ - :144 `region_list.respond_to?(:axis_count)` — RegionList has it
17
+ - :199 `hvar.respond_to?(:item_variation_store)` — Hvar has it
18
+ - :211 `store.respond_to?(:item_variation_data)` — ItemVariationStore has it
19
+ - :275/:278/:281/:286 region/region_list/region_axes checks
20
+
21
+ ## Approach
22
+ 1. Replace `respond_to?` with direct method calls.
23
+ 2. Wrap in begin/rescue NoMethodError OR pre-validate with is_a?.
24
+ 3. Update `validator_spec.rb` doubles to provide all methods the
25
+ validator now calls directly. Use Struct-based fakes if helpful.
26
+
27
+ ## Acceptance criteria
28
+ - 0 `respond_to?` in `lib/fontisan/variation/validator.rb`
29
+ - All variation specs pass
@@ -0,0 +1,24 @@
1
+ # 21 — XML builder send() → tag!
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+ 3 `xml.send(tag.to_sym)` calls in export/ use `.send` to create XML
8
+ elements with dynamic tag names. This violates the "no private send"
9
+ rule even though it's a common Ruby XML builder pattern.
10
+
11
+ ## Sites
12
+ - `lib/fontisan/models/ttx/tables/binary_table.rb:21` `xml.send(tag.to_sym)`
13
+ - `lib/fontisan/export/ttx_generator.rb:297` `xml.send(tag.to_sym)`
14
+ - `lib/fontisan/export/ttx_generator.rb:313` `xml.send(clean_tag.to_sym)`
15
+
16
+ ## Approach
17
+ Replace `xml.send(tag.to_sym) { ... }` with `xml.tag!(tag) { ... }`
18
+ (or the Builder-native equivalent public method). Builder::XmlMarkup
19
+ and Nokogiri::XML::Builder both expose `tag!` as the public dynamic
20
+ element creator.
21
+
22
+ ## Acceptance criteria
23
+ - 0 `send(` in `lib/fontisan/export/` and `lib/fontisan/models/ttx/`
24
+ - All export/ttx specs pass
@@ -0,0 +1,25 @@
1
+ # 22 — Type1Converter font_info fields
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+ 6 `respond_to?` calls in `lib/fontisan/converters/type1_converter.rb`
8
+ probe `font_info` for typed fields (family_name, full_name, version,
9
+ copyright, weight, notice). The font_info is always a `Type1::FontInfo`
10
+ model — these fields are declared.
11
+
12
+ ## Sites
13
+ - :723/:728/:733/:738/:744/:749 font_info field probes
14
+
15
+ Plus 1 site at :160: `font.font_dictionary.respond_to?(:reload)` —
16
+ FontDictionary is a typed class.
17
+
18
+ ## Approach
19
+ 1. Verify `Type1::FontInfo` declares all the probed fields.
20
+ 2. Replace `font_info.respond_to?(:family_name)` with direct call.
21
+ 3. For `font_dictionary.respond_to?(:reload)`, use `is_a?(Type1Font::FontDictionary)`.
22
+
23
+ ## Acceptance criteria
24
+ - 0 `respond_to?` in `lib/fontisan/converters/type1_converter.rb`
25
+ - All Type1 converter specs pass
@@ -0,0 +1,31 @@
1
+ # 23 — Boundary type checks (padding, base_record)
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+ 4 `respond_to?` calls in `utilities/padding.rb` and `binary/base_record.rb`
8
+ are at String/IO system boundaries. They're the last remaining
9
+ "legitimate" duck-typing sites, but the rule is absolute: no
10
+ respond_to?.
11
+
12
+ ## Sites
13
+ - `padding.rb:30/:55` `size.respond_to?(:bytesize) ? size.bytesize : size`
14
+ — accepts both String (uses bytesize) and Integer (uses self)
15
+ - `base_record.rb:27` `io.respond_to?(:empty?) && io.empty?` — String check
16
+ - `base_record.rb:37` `io.respond_to?(:rewind)` — IO check (String has no rewind)
17
+
18
+ ## Approach
19
+
20
+ ### padding.rb
21
+ - Change the signature: accept Integer only. Callers that have a
22
+ String compute `.bytesize` before calling.
23
+
24
+ ### base_record.rb
25
+ - Convert String input to StringIO at the boundary (in `self.read`).
26
+ After conversion, `io` is always a StringIO/IO and responds to
27
+ `rewind`/`empty?` natively. Drop the respond_to? guards.
28
+
29
+ ## Acceptance criteria
30
+ - 0 `respond_to?` in `lib/fontisan/utilities/` and `lib/fontisan/binary/`
31
+ - All padding and base_record specs pass
@@ -0,0 +1,25 @@
1
+ # 24 — Validator dynamic field_key
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+ `lib/fontisan/validators/validator.rb:294` uses `table.respond_to?(field_key)`
8
+ to dynamically check whether a table has a named field. This is a
9
+ meta-programming pattern for running field-by-field validation rules.
10
+
11
+ ## Site
12
+ - :294 `value = if table.respond_to?(field_key)`
13
+
14
+ ## Approach
15
+ Replace the dynamic field lookup with BinData's `snapshot` hash.
16
+ BinData records expose all declared fields via `snapshot`, which
17
+ returns a hash of field name → value. Use `snapshot.key?(field_key)`
18
+ instead of `respond_to?`.
19
+
20
+ Alternatively, declare a typed `FieldValueAccessor` on each table
21
+ model that exposes declared fields as a hash.
22
+
23
+ ## Acceptance criteria
24
+ - 0 `respond_to?` in `lib/fontisan/validators/validator.rb`
25
+ - All validator specs pass
@@ -0,0 +1,49 @@
1
+ # 25 — Serialization migration to lutaml-model
2
+
3
+ ## Priority
4
+ P2
5
+
6
+ ## Problem
7
+ 6 sites hand-roll `to_h` / `to_hash` / `to_json` on model classes,
8
+ violating the absolute rule against hand-rolled serialization.
9
+
10
+ ## Sites
11
+ - `lib/fontisan/tables/hmtx.rb:54` `MetricRecord#to_h` — BinData inner record
12
+ - `lib/fontisan/tables/cff/dict.rb:112` `Dict#to_h` — internal hash accessor
13
+ - `lib/fontisan/type1/generator.rb:167` `Generator#to_hash` — option snapshot
14
+ - `lib/fontisan/type1/conversion_options.rb:105` `ConversionOptions#to_hash` — option snapshot
15
+ - `lib/fontisan/ufo/point.rb:32` `Point#to_h` — GLIF XML output
16
+ - `lib/fontisan/export/exporter.rb:65` `Result#to_json` — export payload
17
+
18
+ ## Approach (per site)
19
+
20
+ ### Hmtx::MetricRecord#to_h
21
+ BinData records expose `snapshot` returning a hash. Remove `to_h`,
22
+ update callers (in stitcher/, type1/) to use `metric.snapshot` or
23
+ extract `[:advance_width]` / `[:lsb]` directly.
24
+
25
+ ### Cff::Dict#to_h
26
+ `Dict` wraps an internal `@dict` hash. `to_h` returns `@dict.dup`.
27
+ Rename to `#raw_hash` (or `#pairs`) to make clear it's an accessor,
28
+ not serialization. Update callers in `cff/table_builder.rb`,
29
+ `cff/private_dict_writer.rb`.
30
+
31
+ ### Type1::Generator#to_hash and Type1::ConversionOptions#to_hash
32
+ These are option snapshots, not wire serialization. Rename to
33
+ `#to_options_hash` to clarify intent. Migrate `ConversionOptions`
34
+ to a `Lutaml::Model::Serializable` for declarative attribute
35
+ management.
36
+
37
+ ### Ufo::Point#to_h
38
+ Used for GLIF XML output. Migrate `Ufo::Point` to inherit from
39
+ `Lutaml::Model::Serializable` with `:x`, `:y`, `:type`, `:smooth`
40
+ attributes and a `key_value` mapping.
41
+
42
+ ### Export::Result#to_json
43
+ Used for export payload. Migrate to a `Models::ExportResult`
44
+ lutaml-model with JSON mapping.
45
+
46
+ ## Acceptance criteria
47
+ - 0 `def to_h` / `def to_hash` / `def to_json` in `lib/fontisan/`
48
+ - All serialization goes through lutaml-model or BinData snapshot
49
+ - All affected specs pass
@@ -0,0 +1,25 @@
1
+ # 26 — Variation preserver spec migration
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+ `lib/fontisan/variation/variation_preserver.rb:153` still uses
8
+ `respond_to?(:has_table?) && respond_to?(:table_data)` because the
9
+ spec uses `instance_double(TrueTypeFont)` with extensive per-test
10
+ stubs. Replacing with `is_a?(SfntSource)` requires migrating the
11
+ spec to use `SpecHelpers::FakeFont`.
12
+
13
+ ## Site
14
+ - `variation_preserver.rb:151-154` (with TODO comment)
15
+ - `spec/fontisan/variation/variation_preserver_spec.rb` (entire file)
16
+
17
+ ## Approach
18
+ 1. Update spec to use `SpecHelpers::FakeFont` instead of `instance_double`.
19
+ 2. For per-test variations, mutate `font.tables_hash` instead of
20
+ stubbing `has_table?`/`table_data`.
21
+ 3. Replace `respond_to?` check in production with `is_a?(SfntSource)`.
22
+
23
+ ## Acceptance criteria
24
+ - 0 `respond_to?` in `lib/fontisan/variation/variation_preserver.rb`
25
+ - All variation_preserver specs pass