fontisan 0.2.22 → 0.3.0

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 (138) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +6 -0
  3. data/.rubocop_todo.yml +93 -17
  4. data/CHANGELOG.md +12 -2
  5. data/README.adoc +6 -210
  6. data/fontisan.gemspec +48 -0
  7. data/lib/fontisan/cldr/unicode_set_parser.rb +23 -6
  8. data/lib/fontisan/cldr/version_resolver.rb +1 -1
  9. data/lib/fontisan/cli.rb +4 -168
  10. data/lib/fontisan/commands.rb +0 -3
  11. data/lib/fontisan/formatters/text_formatter.rb +0 -6
  12. data/lib/fontisan/formatters.rb +0 -3
  13. data/lib/fontisan/hints.rb +6 -3
  14. data/lib/fontisan/models.rb +4 -4
  15. data/lib/fontisan/pipeline/strategies.rb +4 -2
  16. data/lib/fontisan/pipeline.rb +2 -1
  17. data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
  18. data/lib/fontisan/stitcher/selector/gid.rb +25 -0
  19. data/lib/fontisan/stitcher/selector/range.rb +30 -0
  20. data/lib/fontisan/stitcher/selector.rb +26 -0
  21. data/lib/fontisan/stitcher/source.rb +97 -0
  22. data/lib/fontisan/stitcher.rb +182 -0
  23. data/lib/fontisan/stitcher_cli.rb +69 -0
  24. data/lib/fontisan/tables/cff.rb +2 -1
  25. data/lib/fontisan/tables.rb +2 -1
  26. data/lib/fontisan/ufo/anchor.rb +17 -0
  27. data/lib/fontisan/ufo/cli.rb +85 -0
  28. data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
  29. data/lib/fontisan/ufo/compile/cff.rb +224 -0
  30. data/lib/fontisan/ufo/compile/cmap.rb +129 -0
  31. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
  32. data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
  33. data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
  34. data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
  35. data/lib/fontisan/ufo/compile/filters.rb +57 -0
  36. data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
  37. data/lib/fontisan/ufo/compile/head.rb +98 -0
  38. data/lib/fontisan/ufo/compile/hhea.rb +36 -0
  39. data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
  40. data/lib/fontisan/ufo/compile/maxp.rb +57 -0
  41. data/lib/fontisan/ufo/compile/name.rb +79 -0
  42. data/lib/fontisan/ufo/compile/os2.rb +81 -0
  43. data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
  44. data/lib/fontisan/ufo/compile/post.rb +32 -0
  45. data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
  46. data/lib/fontisan/ufo/compile.rb +48 -0
  47. data/lib/fontisan/ufo/component.rb +18 -0
  48. data/lib/fontisan/ufo/contour.rb +29 -0
  49. data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
  50. data/lib/fontisan/ufo/convert.rb +18 -0
  51. data/lib/fontisan/ufo/data_set.rb +21 -0
  52. data/lib/fontisan/ufo/features.rb +17 -0
  53. data/lib/fontisan/ufo/font.rb +61 -0
  54. data/lib/fontisan/ufo/glyph.rb +421 -0
  55. data/lib/fontisan/ufo/guideline.rb +19 -0
  56. data/lib/fontisan/ufo/image.rb +16 -0
  57. data/lib/fontisan/ufo/image_set.rb +19 -0
  58. data/lib/fontisan/ufo/info.rb +79 -0
  59. data/lib/fontisan/ufo/kerning.rb +32 -0
  60. data/lib/fontisan/ufo/layer.rb +38 -0
  61. data/lib/fontisan/ufo/layer_set.rb +37 -0
  62. data/lib/fontisan/ufo/lib.rb +24 -0
  63. data/lib/fontisan/ufo/plist.rb +118 -0
  64. data/lib/fontisan/ufo/point.rb +38 -0
  65. data/lib/fontisan/ufo/reader.rb +144 -0
  66. data/lib/fontisan/ufo/transformation.rb +39 -0
  67. data/lib/fontisan/ufo/writer.rb +115 -0
  68. data/lib/fontisan/ufo.rb +44 -0
  69. data/lib/fontisan/version.rb +1 -1
  70. data/lib/fontisan.rb +3 -3
  71. metadata +57 -70
  72. data/lib/fontisan/audit/codepoint_range_coalescer.rb +0 -41
  73. data/lib/fontisan/audit/context.rb +0 -122
  74. data/lib/fontisan/audit/differ.rb +0 -124
  75. data/lib/fontisan/audit/extractors/aggregations.rb +0 -54
  76. data/lib/fontisan/audit/extractors/base.rb +0 -26
  77. data/lib/fontisan/audit/extractors/color_capabilities.rb +0 -141
  78. data/lib/fontisan/audit/extractors/coverage.rb +0 -48
  79. data/lib/fontisan/audit/extractors/hinting.rb +0 -197
  80. data/lib/fontisan/audit/extractors/identity.rb +0 -52
  81. data/lib/fontisan/audit/extractors/language_coverage.rb +0 -37
  82. data/lib/fontisan/audit/extractors/licensing.rb +0 -79
  83. data/lib/fontisan/audit/extractors/metrics.rb +0 -103
  84. data/lib/fontisan/audit/extractors/opentype_layout.rb +0 -69
  85. data/lib/fontisan/audit/extractors/provenance.rb +0 -29
  86. data/lib/fontisan/audit/extractors/style.rb +0 -32
  87. data/lib/fontisan/audit/extractors/variation_detail.rb +0 -99
  88. data/lib/fontisan/audit/extractors.rb +0 -27
  89. data/lib/fontisan/audit/library_aggregator.rb +0 -83
  90. data/lib/fontisan/audit/library_auditor.rb +0 -90
  91. data/lib/fontisan/audit/registry.rb +0 -60
  92. data/lib/fontisan/audit/style_extractor.rb +0 -80
  93. data/lib/fontisan/audit.rb +0 -20
  94. data/lib/fontisan/cli/ucd_cli.rb +0 -97
  95. data/lib/fontisan/commands/audit_command.rb +0 -123
  96. data/lib/fontisan/commands/audit_compare_command.rb +0 -66
  97. data/lib/fontisan/commands/audit_library_command.rb +0 -46
  98. data/lib/fontisan/config/ucd.yml +0 -23
  99. data/lib/fontisan/formatters/audit_diff_text_renderer.rb +0 -122
  100. data/lib/fontisan/formatters/audit_text_renderer.rb +0 -324
  101. data/lib/fontisan/formatters/library_summary_text_renderer.rb +0 -99
  102. data/lib/fontisan/models/audit/audit_axis.rb +0 -30
  103. data/lib/fontisan/models/audit/audit_block.rb +0 -32
  104. data/lib/fontisan/models/audit/audit_diff.rb +0 -77
  105. data/lib/fontisan/models/audit/audit_report.rb +0 -153
  106. data/lib/fontisan/models/audit/codepoint_range.rb +0 -40
  107. data/lib/fontisan/models/audit/codepoint_set_diff.rb +0 -34
  108. data/lib/fontisan/models/audit/color_capabilities.rb +0 -93
  109. data/lib/fontisan/models/audit/duplicate_group.rb +0 -23
  110. data/lib/fontisan/models/audit/embedding_type.rb +0 -76
  111. data/lib/fontisan/models/audit/field_change.rb +0 -28
  112. data/lib/fontisan/models/audit/fs_selection_flags.rb +0 -61
  113. data/lib/fontisan/models/audit/gasp_range.rb +0 -63
  114. data/lib/fontisan/models/audit/hinting.rb +0 -93
  115. data/lib/fontisan/models/audit/library_summary.rb +0 -40
  116. data/lib/fontisan/models/audit/licensing.rb +0 -48
  117. data/lib/fontisan/models/audit/metrics.rb +0 -111
  118. data/lib/fontisan/models/audit/named_instance.rb +0 -41
  119. data/lib/fontisan/models/audit/opentype_layout.rb +0 -40
  120. data/lib/fontisan/models/audit/script_coverage_row.rb +0 -26
  121. data/lib/fontisan/models/audit/script_features.rb +0 -28
  122. data/lib/fontisan/models/audit/variation_detail.rb +0 -44
  123. data/lib/fontisan/models/audit.rb +0 -33
  124. data/lib/fontisan/models/ucd/ucd.rb +0 -38
  125. data/lib/fontisan/models/ucd/ucd_char.rb +0 -67
  126. data/lib/fontisan/models/ucd.rb +0 -19
  127. data/lib/fontisan/ucd/aggregator.rb +0 -73
  128. data/lib/fontisan/ucd/cache_manager.rb +0 -111
  129. data/lib/fontisan/ucd/config.rb +0 -59
  130. data/lib/fontisan/ucd/download_error.rb +0 -9
  131. data/lib/fontisan/ucd/downloader.rb +0 -88
  132. data/lib/fontisan/ucd/error.rb +0 -8
  133. data/lib/fontisan/ucd/index.rb +0 -103
  134. data/lib/fontisan/ucd/index_builder.rb +0 -107
  135. data/lib/fontisan/ucd/range_entry.rb +0 -56
  136. data/lib/fontisan/ucd/unknown_version_error.rb +0 -9
  137. data/lib/fontisan/ucd/version_resolver.rb +0 -79
  138. data/lib/fontisan/ucd.rb +0 -23
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `cmap` (character-to-glyph mapping) table.
7
+ # Emits two subtables:
8
+ # - Format 4 (BMP), platform 3 encoding 1 (Windows Unicode BMP)
9
+ # - Format 12 (full Unicode), platform 3 encoding 10 (Windows Unicode full)
10
+ #
11
+ # Both subtables share the same segment list (capped to BMP for
12
+ # format 4); format 4 is required by Windows even though format
13
+ # 12 is more capable.
14
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cmap
15
+ module Cmap
16
+ PLATFORM_WINDOWS = 3
17
+ ENCODING_WINDOWS_BMP = 1
18
+ ENCODING_WINDOWS_FULL = 10
19
+
20
+ # @param _font [Fontisan::Ufo::Font]
21
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
22
+ # @return [String] cmap table bytes
23
+ def self.build(_font, glyphs:)
24
+ mappings = {}
25
+ glyphs.each_with_index do |glyph, gid|
26
+ glyph.unicodes.each do |cp|
27
+ mappings[cp] = gid unless mappings.key?(cp)
28
+ end
29
+ end
30
+
31
+ subtable_bmp = format4_subtable(mappings.reject { |cp, _| cp > 0xFFFF })
32
+ subtable_full = format12_subtable(mappings)
33
+
34
+ header_size = 4 + (2 * 8) # version + numTables + 2 records
35
+ offset_bmp = header_size
36
+ offset_full = header_size + subtable_bmp.bytesize
37
+
38
+ header = [0, 2].pack("nn")
39
+ header << subtable_record(PLATFORM_WINDOWS, ENCODING_WINDOWS_BMP, offset_bmp)
40
+ header << subtable_record(PLATFORM_WINDOWS, ENCODING_WINDOWS_FULL, offset_full)
41
+ header + subtable_bmp + subtable_full
42
+ end
43
+
44
+ def self.subtable_record(platform_id, encoding_id, offset)
45
+ [platform_id, encoding_id, offset].pack("nnN")
46
+ end
47
+
48
+ # Group adjacent (cp, gid) pairs into contiguous ranges where
49
+ # both cp and gid advance by 1 each step.
50
+ # @return [Array<Range>] Array of inclusive cp ranges
51
+ def self.build_segments(cp_to_gid)
52
+ sorted = cp_to_gid.sort
53
+ return [] if sorted.empty?
54
+
55
+ segments = []
56
+ seg_start_cp, prev_gid = sorted.first
57
+ prev_cp = seg_start_cp
58
+
59
+ sorted[1..].each do |cp, gid|
60
+ contiguous = cp == prev_cp + 1 && gid == prev_gid + 1
61
+ unless contiguous
62
+ segments << (seg_start_cp..prev_cp)
63
+ seg_start_cp = cp
64
+ end
65
+ prev_cp = cp
66
+ prev_gid = gid
67
+ end
68
+ segments << (seg_start_cp..prev_cp)
69
+ segments
70
+ end
71
+
72
+ # Format 4 segment-encoded cmap subtable for the BMP.
73
+ def self.format4_subtable(mappings)
74
+ segments = build_segments(mappings) + [0xFFFF..0xFFFF] # sentinel
75
+ seg_count = segments.size
76
+ search_range = largest_pow2_le(seg_count) * 2
77
+ entry_selector = (Math.log([1, search_range / 2].max) / Math.log(2)).to_i
78
+ range_shift = seg_count * 2 - search_range
79
+
80
+ end_codes = segments.map(&:end)
81
+ start_codes = segments.map(&:begin)
82
+ # id_delta[i] = (gid_at_start - start_code) mod 65536
83
+ id_deltas = segments.map do |range|
84
+ start_cp = range.begin
85
+ gid = mappings.fetch(start_cp, 0)
86
+ # For the sentinel segment (0xFFFF..0xFFFF) with no mapping,
87
+ # delta 1 maps 0xFFFF → gid 0 (.notdef).
88
+ gid_delta = start_cp == 0xFFFF && gid.zero? ? 1 : (gid - start_cp)
89
+ gid_delta & 0xFFFF
90
+ end
91
+
92
+ body = +""
93
+ body << [seg_count * 2, search_range, entry_selector, range_shift].pack("nnnn")
94
+ body << end_codes.pack("n*")
95
+ body << [0].pack("n") # reservedPad
96
+ body << start_codes.pack("n*")
97
+ body << id_deltas.pack("n*")
98
+ body << Array.new(seg_count, 0).pack("n*") # idRangeOffset (all 0)
99
+
100
+ length = 14 + body.bytesize # 14-byte header
101
+ [4, length, 0].pack("nnn") + body
102
+ end
103
+
104
+ # Format 12 sparse-coverage subtable for full Unicode.
105
+ def self.format12_subtable(mappings)
106
+ segments = build_segments(mappings)
107
+
108
+ body = +""
109
+ segments.each do |range|
110
+ start_gid = mappings.fetch(range.begin, 0)
111
+ body << [range.begin, range.end, start_gid].pack("NNN")
112
+ end
113
+
114
+ length = 16 + body.bytesize # 16-byte header
115
+ [12, 0].pack("nn") + [length, 0, segments.size].pack("NNN") + body
116
+ end
117
+
118
+ def self.largest_pow2_le(n)
119
+ return 0 if n <= 0
120
+
121
+ 1 << (n.bit_length - 1)
122
+ end
123
+ private_class_method :subtable_record, :build_segments,
124
+ :format4_subtable, :format12_subtable,
125
+ :largest_pow2_le
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module Filters
7
+ # Converts cubic Bezier curves to quadratic. TrueType outlines
8
+ # only support quadratic Beziers; UFO sources typically use
9
+ # cubic. This filter walks each contour, finds cubic segments
10
+ # (two consecutive off-curve points followed by an on-curve),
11
+ # and replaces them with one or more quadratic approximations.
12
+ #
13
+ # Algorithm (fontTools-compatible):
14
+ # For cubic (P0, C1, C2, P3):
15
+ # Q1 = (3*C1 - P0) / 2
16
+ # Q2 = (3*C2 - P3) / 2
17
+ # Q = midpoint(Q1, Q2)
18
+ # Error ≈ |Q1 - Q2| / 3
19
+ # If error ≤ tolerance: emit one quadratic (P0, Q, P3)
20
+ # Else: subdivide at t=0.5, recurse each half.
21
+ module CubicToQuadratic
22
+ DEFAULT_TOLERANCE = 1.0
23
+
24
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>]
25
+ # @param tolerance [Float] max deviation in font units
26
+ # @return [Array<Fontisan::Ufo::Glyph>] the same array, mutated
27
+ def self.run(glyphs, tolerance: DEFAULT_TOLERANCE, **_opts)
28
+ glyphs.each do |glyph|
29
+ glyph.contours.each_with_index do |contour, _ci|
30
+ contour.points = convert_contour(contour.points, tolerance)
31
+ end
32
+ end
33
+ glyphs
34
+ end
35
+
36
+ # Walk a contour's point list and replace cubic segments
37
+ # with quadratic approximations. Preserves on-curve points
38
+ # (lines, moves); only touches (off, off, on) triplets.
39
+ #
40
+ # @param points [Array<Fontisan::Ufo::Point>]
41
+ # @param tolerance [Float]
42
+ # @return [Array<Fontisan::Ufo::Point>] new point array
43
+ def self.convert_contour(points, tolerance)
44
+ return points if points.size < 4
45
+
46
+ result = []
47
+ i = 0
48
+
49
+ while i < points.size
50
+ # Check if we have a cubic segment: off, off, on-curve
51
+ if i + 2 < points.size &&
52
+ off_curve?(points[i]) &&
53
+ off_curve?(points[i + 1]) &&
54
+ on_curve?(points[i + 2])
55
+ # The previous on-curve point (P0) is the last on-curve
56
+ # before this segment. If result is empty, use the last
57
+ # point in the original contour (contour wraps around).
58
+ p0 = result.empty? ? last_on_curve(points) : result.last
59
+
60
+ if p0
61
+ c1 = points[i]
62
+ c2 = points[i + 1]
63
+ p3 = points[i + 2]
64
+
65
+ quads = subdivide_cubic(p0, c1, c2, p3, tolerance)
66
+ quads.each { |q| result << q }
67
+ i += 3
68
+ else
69
+ result << points[i]
70
+ i += 1
71
+ end
72
+ else
73
+ result << points[i]
74
+ i += 1
75
+ end
76
+ end
77
+
78
+ result
79
+ end
80
+
81
+ # Recursive cubic-to-quadratic subdivision.
82
+ # Returns a list of Points: alternating off-curve control
83
+ # points and on-curve endpoints.
84
+ def self.subdivide_cubic(p0, c1, c2, p3, tolerance)
85
+ q1x = (3.0 * c1.x - p0.x) / 2.0
86
+ q1y = (3.0 * c1.y - p0.y) / 2.0
87
+ q2x = (3.0 * c2.x - p3.x) / 2.0
88
+ q2y = (3.0 * c2.y - p3.y) / 2.0
89
+
90
+ # Error metric: half the distance between Q1 and Q2
91
+ dx = (q1x - q2x).abs
92
+ dy = (q1y - q2y).abs
93
+ error = [dx, dy].max / 3.0
94
+
95
+ if error <= tolerance
96
+ # Single quadratic approximation
97
+ mid_x = (q1x + q2x) / 2.0
98
+ mid_y = (q1y + q2y) / 2.0
99
+ [
100
+ Point.new(x: mid_x, y: mid_y, type: "offcurve"),
101
+ Point.new(x: p3.x, y: p3.y, type: p3.type, smooth: p3.smooth),
102
+ ]
103
+ else
104
+ # Subdivide at t=0.5
105
+ midpoint_on_cubic(p0, c1, c2, p3, 0.5)
106
+
107
+ # Left half: P0, L1, L2, M
108
+ Point.new(x: (p0.x + c1.x) / 2.0,
109
+ y: (p0.y + c1.y) / 2.0, type: "offcurve")
110
+ Point.new(x: (c1.x + c2.x) / 4.0 + (p0.x + c1.x) / 4.0,
111
+ y: (c1.y + c2.y) / 4.0 + (p0.y + c1.y) / 4.0,
112
+ type: "offcurve")
113
+ # Actually, proper De Casteljau subdivision:
114
+ l1x = (p0.x + c1.x) / 2.0
115
+ l1y = (p0.y + c1.y) / 2.0
116
+ mx = (c1.x + c2.x) / 2.0
117
+ my = (c1.y + c2.y) / 2.0
118
+ r2x = (c2.x + p3.x) / 2.0
119
+ r2y = (c2.y + p3.y) / 2.0
120
+ l2x = (l1x + mx) / 2.0
121
+ l2y = (l1y + my) / 2.0
122
+ r1x = (mx + r2x) / 2.0
123
+ r1y = (my + r2y) / 2.0
124
+ mid_x = (l2x + r1x) / 2.0
125
+ mid_y = (l2y + r1y) / 2.0
126
+
127
+ left_p0 = p0
128
+ left_c1 = Point.new(x: l1x, y: l1y, type: "offcurve")
129
+ left_c2 = Point.new(x: l2x, y: l2y, type: "offcurve")
130
+ left_p3 = Point.new(x: mid_x, y: mid_y, type: "qcurve")
131
+
132
+ right_p0 = left_p3
133
+ right_c1 = Point.new(x: r1x, y: r1y, type: "offcurve")
134
+ right_c2 = Point.new(x: r2x, y: r2y, type: "offcurve")
135
+ right_p3 = p3
136
+
137
+ left = subdivide_cubic(left_p0, left_c1, left_c2, left_p3, tolerance)
138
+ right = subdivide_cubic(right_p0, right_c1, right_c2, right_p3, tolerance)
139
+
140
+ # Drop the duplicated midpoint point between halves
141
+ left + right
142
+ end
143
+ end
144
+
145
+ # Evaluate a point on a cubic Bezier at parameter t.
146
+ def self.midpoint_on_cubic(p0, c1, c2, p3, t)
147
+ mt = 1.0 - t
148
+ x = mt * mt * mt * p0.x + 3 * mt * mt * t * c1.x +
149
+ 3 * mt * t * t * c2.x + t * t * t * p3.x
150
+ y = mt * mt * mt * p0.y + 3 * mt * mt * t * c1.y +
151
+ 3 * mt * t * t * c2.y + t * t * t * p3.y
152
+ Point.new(x: x, y: y, type: "qcurve")
153
+ end
154
+
155
+ def self.last_on_curve(points)
156
+ points.reverse_each.find { |p| on_curve?(p) }
157
+ end
158
+
159
+ def self.on_curve?(point)
160
+ ["line", "move", "curve", "qcurve"].include?(point.type)
161
+ end
162
+
163
+ def self.off_curve?(point)
164
+ point.type == "offcurve"
165
+ end
166
+
167
+ private_class_method :convert_contour, :subdivide_cubic,
168
+ :midpoint_on_cubic, :last_on_curve,
169
+ :on_curve?, :off_curve?
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module Filters
7
+ # Decomposes composite glyphs (those with Components) into
8
+ # simple glyphs by resolving each component reference to its
9
+ # base glyph's contours and applying the component's
10
+ # transformation matrix.
11
+ #
12
+ # For MVP: components are silently dropped (the glyph retains
13
+ # its own contours but loses component-derived outlines).
14
+ # Full resolution requires the full glyph-name → glyph lookup
15
+ # that the compiler provides. TODO.full/07b will wire this.
16
+ module DecomposeComponents
17
+ def self.run(glyphs, **_opts)
18
+ glyphs.each do |glyph|
19
+ # Drop components; keep contours only.
20
+ # Full implementation would:
21
+ # 1. Look up each component's base glyph by name
22
+ # 2. Clone its contours
23
+ # 3. Apply the component's Transformation matrix
24
+ # 4. Merge into this glyph's contours
25
+ glyph.components.clear
26
+ end
27
+ glyphs
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module Filters
7
+ # Flattens nested component references. If glyph A references
8
+ # glyph B which references glyph C, this filter makes A
9
+ # directly reference C (one level deep only).
10
+ #
11
+ # For MVP: same as DecomposeComponents — components are
12
+ # cleared. Full implementation lands with TODO.full/07b.
13
+ module FlattenComponents
14
+ def self.run(glyphs, **_opts)
15
+ glyphs.each { |g| g.components.clear }
16
+ glyphs
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ module Filters
7
+ # Reverses the point order in every contour. TrueType fonts
8
+ # use clockwise winding for outer contours (opposite of the
9
+ # PostScript/UFO convention). Applying this filter before
10
+ # TTF compilation ensures correct glyph rendering.
11
+ module ReverseContourDirection
12
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>]
13
+ # @return [Array<Fontisan::Ufo::Glyph>] the same array,
14
+ # mutated in place
15
+ def self.run(glyphs, **_opts)
16
+ glyphs.each do |glyph|
17
+ glyph.contours.each do |contour|
18
+ contour.points.reverse!
19
+ end
20
+ end
21
+ glyphs
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Glyph-processing filters applied between the UFO model and the
7
+ # binary table compilers. Each filter is a stateless module
8
+ # with `.run(glyphs, **opts)` that mutates the glyph list
9
+ # (or returns a new one).
10
+ #
11
+ # OCP: adding a filter = new module + one REGISTRY entry.
12
+ # The compiler picks which filters to run based on the output
13
+ # format — not a switch statement in compiler code.
14
+ module Filters
15
+ autoload :ReverseContourDirection,
16
+ "fontisan/ufo/compile/filters/reverse_contour_direction"
17
+ autoload :CubicToQuadratic,
18
+ "fontisan/ufo/compile/filters/cubic_to_quadratic"
19
+ autoload :DecomposeComponents,
20
+ "fontisan/ufo/compile/filters/decompose_components"
21
+ autoload :FlattenComponents,
22
+ "fontisan/ufo/compile/filters/flatten_components"
23
+
24
+ # Filters that MUST run for TTF output (TrueType only
25
+ # supports quadratic curves + clockwise outer winding).
26
+ TTF_REQUIRED = %i[
27
+ cubic_to_quadratic
28
+ reverse_contour_direction
29
+ ].freeze
30
+
31
+ # Filters that MUST run for OTF output (CFF handles cubic
32
+ # natively, so no curve conversion needed; winding is
33
+ # already correct for PostScript).
34
+ OTF_REQUIRED = [].freeze
35
+
36
+ REGISTRY = {
37
+ reverse_contour_direction: ReverseContourDirection,
38
+ cubic_to_quadratic: CubicToQuadratic,
39
+ decompose_components: DecomposeComponents,
40
+ flatten_components: FlattenComponents,
41
+ }.freeze
42
+
43
+ # @param names [Array<Symbol>] filter names from REGISTRY
44
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] glyphs to filter
45
+ # @param opts [Hash] per-filter options
46
+ # @return [Array<Fontisan::Ufo::Glyph>] the (possibly mutated) glyphs
47
+ def self.apply(names, glyphs, **)
48
+ Array(names).reduce(glyphs) do |current, name|
49
+ klass = REGISTRY[name.to_sym] or
50
+ raise ArgumentError, "unknown filter: #{name.inspect}"
51
+ klass.run(current, **)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the TrueType `glyf` + `loca` tables. Each glyph's
7
+ # outline is delta-encoded into the glyf table; loca is the
8
+ # offset index.
9
+ #
10
+ # For OTF output this module is NOT used — CFF takes its place.
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/glyf
12
+ module GlyfLoca
13
+ # Flag bits (simple glyph)
14
+ FLAG_ON_CURVE = 0x01
15
+ FLAG_X_SHORT = 0x02
16
+ FLAG_Y_SHORT = 0x04
17
+ FLAG_REPEAT = 0x08
18
+ FLAG_X_IS_POSITIVE = 0x10 # only meaningful if FLAG_X_SHORT
19
+ FLAG_Y_IS_POSITIVE = 0x20 # only meaningful if FLAG_Y_SHORT
20
+ FLAG_OVERLAP_SIMPLE = 0x40
21
+
22
+ # @param _font [Fontisan::Ufo::Font]
23
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
24
+ # @return [Hash<String, String>] {"glyf" => bytes, "loca" => bytes}
25
+ def self.build(_font, glyphs:)
26
+ glyf_bytes = +""
27
+ offsets = [0]
28
+
29
+ glyphs.each do |glyph|
30
+ glyf_bytes << encode_glyph(glyph)
31
+ glyf_bytes << "\x00" while glyf_bytes.bytesize.odd? # 2-byte align
32
+ offsets << glyf_bytes.bytesize
33
+ end
34
+
35
+ # Choose loca format based on the largest offset.
36
+ use_long = offsets.max > 0x1FFFE # 2 × uint16 max
37
+ loca_bytes =
38
+ if use_long
39
+ offsets.pack("N*")
40
+ else
41
+ offsets.map { |o| o / 2 }.pack("n*")
42
+ end
43
+
44
+ { "glyf" => glyf_bytes, "loca" => loca_bytes, :loca_format => use_long ? 1 : 0 }
45
+ end
46
+
47
+ # Encode a single glyph into glyf bytes. Empty glyphs (no
48
+ # contours, no components) produce zero bytes per spec.
49
+ def self.encode_glyph(glyph)
50
+ return "" if glyph.contours.empty? && glyph.components.empty?
51
+ return encode_composite(glyph) if glyph.composite?
52
+
53
+ encode_simple(glyph)
54
+ end
55
+
56
+ # Encode a simple (non-composite) glyph.
57
+ def self.encode_simple(glyph)
58
+ bbox = glyph.bbox
59
+ header = [
60
+ glyph.contours.size, # numberOfContours (int16)
61
+ bbox.x_min.to_i, bbox.y_min.to_i,
62
+ bbox.x_max.to_i, bbox.y_max.to_i
63
+ ].pack("nnnnn")
64
+ # NB: pack 'n' for int16 truncates negatives to unsigned 16-bit
65
+ # two's complement — which is what OpenType wants.
66
+
67
+ # endPtsOfContours (uint16[numContours])
68
+ end_points = +""
69
+ point_count = 0
70
+ glyph.contours.each do |contour|
71
+ point_count += contour.points.size
72
+ end_points << [point_count - 1].pack("n")
73
+ end
74
+
75
+ # instructions: empty for MVP
76
+ instructions = [0].pack("n")
77
+
78
+ flags, x_bytes, y_bytes = encode_points(glyph.contours)
79
+
80
+ header + end_points + instructions + flags + x_bytes + y_bytes
81
+ end
82
+
83
+ # Encode all points across contours into (flags, x_bytes, y_bytes).
84
+ # Per OpenType spec:
85
+ # - SHORT bit set + POSITIVE bit set → 1-byte positive value
86
+ # - SHORT bit set + POSITIVE bit clear → 1-byte negative value
87
+ # - SHORT bit clear + POSITIVE bit set → delta is 0 (no bytes)
88
+ # - SHORT bit clear + POSITIVE bit clear → 2-byte signed value
89
+ def self.encode_points(contours)
90
+ flags = +""
91
+ x_bytes = +""
92
+ y_bytes = +""
93
+
94
+ prev_x = 0
95
+ prev_y = 0
96
+ contours.flat_map(&:points).each do |point|
97
+ dx = point.x.to_i - prev_x
98
+ dy = point.y.to_i - prev_y
99
+
100
+ flag = point.on_curve? ? FLAG_ON_CURVE : 0
101
+
102
+ # X coordinate
103
+ if dx.zero?
104
+ flag |= FLAG_X_IS_POSITIVE # "same as previous" — no bytes
105
+ elsif dx.between?(-255, 255)
106
+ flag |= FLAG_X_SHORT
107
+ flag |= FLAG_X_IS_POSITIVE if dx.positive?
108
+ x_bytes << [dx.abs].pack("C")
109
+ else
110
+ # SHORT not set, POSITIVE not set → 2-byte signed
111
+ x_bytes << [dx & 0xFFFF].pack("n")
112
+ end
113
+
114
+ # Y coordinate
115
+ if dy.zero?
116
+ flag |= FLAG_Y_IS_POSITIVE # "same as previous" — no bytes
117
+ elsif dy.between?(-255, 255)
118
+ flag |= FLAG_Y_SHORT
119
+ flag |= FLAG_Y_IS_POSITIVE if dy.positive?
120
+ y_bytes << [dy.abs].pack("C")
121
+ else
122
+ y_bytes << [dy & 0xFFFF].pack("n")
123
+ end
124
+
125
+ flags << [flag].pack("C")
126
+ prev_x = point.x.to_i
127
+ prev_y = point.y.to_i
128
+ end
129
+
130
+ [flags, x_bytes, y_bytes]
131
+ end
132
+
133
+ # Composite glyph encoding (component references with optional
134
+ # transformations). Out of scope for MVP — emits empty bytes.
135
+ def self.encode_composite(_glyph)
136
+ # TODO.full/07: implement composite glyph encoding when the
137
+ # spec needs it. For now, return empty (the glyph renders as
138
+ # nothing).
139
+ ""
140
+ end
141
+ private_class_method :encode_glyph, :encode_simple, :encode_points, :encode_composite
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `head` table from a UFO Font.
7
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/head
8
+ module Head
9
+ MAGIC_NUMBER = 0x5F0F3CF5
10
+ DEFAULT_FLAGS = 0x000B
11
+ DEFAULT_LOWEST_REC_PPEM = 8
12
+ DEFAULT_FONT_DIRECTION_HINT = 2
13
+ DEFAULT_GLYPH_DATA_FORMAT = 0
14
+ LOCA_FORMAT_SHORT = 0
15
+ LOCA_FORMAT_LONG = 1
16
+ MAC_STYLE_REGULAR = 0
17
+ # Seconds between 1904-01-01 (Mac epoch) and 1970-01-01 (Unix epoch)
18
+ MAC_EPOCH_OFFSET = 2_082_844_800
19
+
20
+ # @param font [Fontisan::Ufo::Font]
21
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] (unused here;
22
+ # bbox is computed by the caller for performance)
23
+ # @return [Fontisan::Tables::Head]
24
+ def self.build(font, glyphs:, units_per_em: nil, loca_format: LOCA_FORMAT_LONG)
25
+ bbox = font_bbox(font, glyphs)
26
+ Fontisan::Tables::Head.new(
27
+ version_raw: 0x00010000,
28
+ font_revision_raw: font_revision_fixed(font),
29
+ checksum_adjustment: 0, # patched by FontWriter
30
+ magic_number: MAGIC_NUMBER,
31
+ flags: DEFAULT_FLAGS,
32
+ units_per_em: units_per_em || font.info.units_per_em || 1000,
33
+ created_raw: time_to_longdatetime(font.info.created),
34
+ modified_raw: time_to_longdatetime(font.info.modified),
35
+ x_min: bbox[:x_min].to_i,
36
+ y_min: bbox[:y_min].to_i,
37
+ x_max: bbox[:x_max].to_i,
38
+ y_max: bbox[:y_max].to_i,
39
+ mac_style: MAC_STYLE_REGULAR,
40
+ lowest_rec_ppem: DEFAULT_LOWEST_REC_PPEM,
41
+ font_direction_hint: DEFAULT_FONT_DIRECTION_HINT,
42
+ index_to_loc_format: loca_format,
43
+ glyph_data_format: DEFAULT_GLYPH_DATA_FORMAT,
44
+ )
45
+ end
46
+
47
+ # Union bbox of every glyph's outline.
48
+ def self.font_bbox(_font, glyphs)
49
+ return { x_min: 0, y_min: 0, x_max: 0, y_max: 0 } if glyphs.empty?
50
+
51
+ bboxes = glyphs.filter_map(&:bbox)
52
+ return { x_min: 0, y_min: 0, x_max: 0, y_max: 0 } if bboxes.empty?
53
+
54
+ {
55
+ x_min: bboxes.map(&:x_min).min,
56
+ y_min: bboxes.map(&:y_min).min,
57
+ x_max: bboxes.map(&:x_max).max,
58
+ y_max: bboxes.map(&:y_max).max,
59
+ }
60
+ end
61
+ private_class_method :font_bbox
62
+
63
+ # Parse the UFO version string ("1.0" or "Version 1.0") into
64
+ # a 16.16 fixed-point int. Defaults to 1.0 on parse failure.
65
+ def self.font_revision_fixed(font)
66
+ version = [font.info.version_major, font.info.version_minor].compact
67
+ return 0x00010000 if version.empty?
68
+
69
+ major = font.info.version_major || 1
70
+ minor = font.info.version_minor || 0
71
+ ((major & 0xFFFF) << 16) | (minor & 0xFFFF)
72
+ end
73
+ private_class_method :font_revision_fixed
74
+
75
+ # UFO 3 stores `openTypeHeadCreated` as "YYYY/MM/DD HH:MM:SS".
76
+ # OpenType head.created/modified are LONGDATETIME seconds
77
+ # since 1904-01-01. If the UFO field is absent, use now.
78
+ def self.time_to_longdatetime(value)
79
+ return Time.now.to_i + MAC_EPOCH_OFFSET if value.nil?
80
+
81
+ require "time"
82
+ parsed = nil
83
+ begin
84
+ parsed = Time.iso8601(value.to_s)
85
+ rescue ArgumentError
86
+ begin
87
+ parsed = Time.parse(value.to_s)
88
+ rescue ArgumentError
89
+ parsed = Time.now
90
+ end
91
+ end
92
+ parsed.to_i + MAC_EPOCH_OFFSET
93
+ end
94
+ private_class_method :time_to_longdatetime
95
+ end
96
+ end
97
+ end
98
+ end