fontisan 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 999173c7fe3caa0568c7bc29b87184b1f167c5885b5e66c9c7c7f0f884d2523e
4
- data.tar.gz: a9bf5e401b1b08672ced56f71b7ff5d6ab6b60e83b4ba1932ef43582c5d3a528
3
+ metadata.gz: 519952f147a10de9f78f3e30eb4456a077f35cb24099bb65781e7b1c2c4e59a3
4
+ data.tar.gz: c37e0f9d37bf0512cd7c0ed9be19e0eac5a9c74eb057c951d1a2dba4237c5f11
5
5
  SHA512:
6
- metadata.gz: 9b770784dbac538d4d3998b1e908eedd5cb010f72897a0dc94b28e32f1150c6fdb99d384703760360dd1b01855de725ef786c34b173854ca8fd869b23ad6a8d6
7
- data.tar.gz: 5b64092d77f6c19fc2e40fe40114722652000c49a36b22ae0dacccb86236d5529fe7d9c183bc4bd3141998d44a59b44ae2d5954bf675bb86fb489ff380a7b490
6
+ metadata.gz: c5702491e2326da5f357b5c89904896763c0f57dc807022b11b1d203ab1b4158372595c5059f0fff3143631323048a543e9b2f3046ded97e868d5cf105e518cc
7
+ data.tar.gz: 2f9f010ab0079fedfec7932313231bf7d002b2fed7af6476c459918e92e6d9c01300dac46594b64d16874b95924026bcd1a9779fe53ae1d2ea65240464256616
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ### Added
11
+
12
+ - `Fontisan::Ufo::Compile::Avar` — builds the OpenType `avar` (Axis
13
+ Variation) table with per-axis non-linear maps (defaults to identity
14
+ -1/0/1 mapping).
15
+ - `Fontisan::Ufo::Compile::Hvar` — builds the OpenType `HVAR`
16
+ (Horizontal Metrics Variation) table with advance-width deltas per
17
+ glyph.
18
+ - `Fontisan::Ufo::Compile::Mvar` — builds the OpenType `MVAR`
19
+ (Metrics Variation) table for font-wide metric deltas (ascender,
20
+ descender, etc.).
21
+ - `Fontisan::Ufo::Compile::Stat` — builds the OpenType `STAT` (Style
22
+ Attributes) table with design axes, axis value tables, and elided
23
+ fallback name ID.
24
+ - `Fontisan::Ufo::Compile::ItemVariationStore` — shared builder for
25
+ the ItemVariationStore structure used by HVAR and MVAR
26
+ (VariationRegionList + ItemVariationData with int8/int16 delta
27
+ packing).
28
+ - `Fontisan::Ufo::Compile::VariableTtf` — orchestrator that compiles
29
+ a default UFO master plus variation masters into a single variable
30
+ TTF (emits fvar, gvar, HVAR, MVAR, avar, STAT alongside the standard
31
+ TTF tables).
32
+ - `TtfCompiler#build_tables` — extracted public method returning the
33
+ TTF table hash without writing, so `VariableTtf` can reuse the
34
+ standard table pipeline.
35
+
36
+ ### Fixed
37
+
38
+ - `Stitcher` silently dropped compound (composite) TrueType glyphs
39
+ from TTF donors. The O(1) extraction path only handled `simple?`
40
+ glyphs and returned `nil` for compounds. Compound glyphs are now
41
+ recursively flattened (with affine transforms applied) into simple
42
+ contours, making them self-contained. Affected donors include
43
+ NotoSansCuneiform (U+12399), NotoSansTaiTham (594 glyphs),
44
+ NotoSerifDivesAkuru (414), NotoSerifTaiYo (1007), and others.
45
+
10
46
  ### Removed
11
47
 
12
48
  - Audit subsystem (`Fontisan::Audit`, `Fontisan::Commands::Audit*`,
@@ -16,6 +16,8 @@ module Fontisan
16
16
  # the raw CBDT/CBLC tables into the output instead of extracting
17
17
  # outlines. The glyph data lives in the bitmap tables, not in glyf.
18
18
  class Source
19
+ MAX_COMPOUND_DEPTH = 32
20
+
19
21
  attr_reader :font
20
22
 
21
23
  def initialize(font)
@@ -219,14 +221,19 @@ module Fontisan
219
221
  end
220
222
 
221
223
  def extract_truetype_glyph(gid, cache)
222
- simple = cache[:glyf].glyph_for(gid, cache[:loca], cache[:head])
223
- return nil unless simple
224
- return nil unless simple.respond_to?(:simple?) && simple.simple?
224
+ raw = cache[:glyf].glyph_for(gid, cache[:loca], cache[:head])
225
+ return nil unless raw
225
226
 
226
227
  name = gid.zero? ? ".notdef" : "gid#{gid}"
227
228
  glyph = Fontisan::Ufo::Glyph.new(name: name)
228
229
  glyph.width = glyph_width(gid)
229
- copy_simple_contours(simple, glyph)
230
+
231
+ if raw.respond_to?(:simple?) && raw.simple?
232
+ copy_simple_contours(raw, glyph)
233
+ elsif raw.respond_to?(:compound?) && raw.compound?
234
+ flatten_compound_into(raw, glyph, cache, Set.new)
235
+ end
236
+
230
237
  add_cmap_unicodes(gid, glyph)
231
238
  glyph
232
239
  rescue StandardError
@@ -253,6 +260,62 @@ module Fontisan
253
260
  end
254
261
  end
255
262
 
263
+ # Recursively flatten a CompoundGlyph's components into the UFO
264
+ # glyph as contours (with transforms applied). This makes the
265
+ # extracted glyph self-contained — it doesn't depend on the
266
+ # component glyphs being present in the target font.
267
+ #
268
+ # Only components with ARGS_ARE_XY_VALUES are flattened by offset.
269
+ # Point-index alignment (rare) is skipped — those components
270
+ # contribute nothing, but the rest of the compound is preserved.
271
+ def flatten_compound_into(compound, ufo_glyph, cache, visited, depth = 0)
272
+ return if depth > MAX_COMPOUND_DEPTH
273
+ return if visited.include?(compound.glyph_id)
274
+
275
+ visited = visited.dup.add(compound.glyph_id)
276
+
277
+ compound.components.each do |component|
278
+ next unless component.args_are_xy?
279
+
280
+ raw = cache[:glyf].glyph_for(component.glyph_index, cache[:loca], cache[:head])
281
+ next unless raw
282
+
283
+ matrix = component.transformation_matrix
284
+
285
+ if raw.respond_to?(:simple?) && raw.simple?
286
+ flatten_simple_component(raw, ufo_glyph, matrix)
287
+ elsif raw.respond_to?(:compound?) && raw.compound?
288
+ flatten_compound_into(raw, ufo_glyph, cache, visited, depth + 1)
289
+ end
290
+ end
291
+ end
292
+
293
+ # Apply a 2×3 affine matrix [a, b, c, d, e, f] to each point of
294
+ # a simple component, appending the transformed contours.
295
+ # x' = a*x + c*y + e
296
+ # y' = b*x + d*y + f
297
+ def flatten_simple_component(simple, ufo_glyph, matrix)
298
+ a, b, c, d, e, f = matrix
299
+ num_contours = simple.end_pts_of_contours&.size || 0
300
+ return if num_contours.zero?
301
+
302
+ num_contours.times do |ci|
303
+ points = simple.points_for_contour(ci)
304
+ next unless points && !points.empty?
305
+
306
+ ufo_points = points.map do |pt|
307
+ x = (pt[:x] || pt["x"]).to_f
308
+ y = (pt[:y] || pt["y"]).to_f
309
+ tx = a * x + c * y + e
310
+ ty = b * x + d * y + f
311
+ on_curve = pt[:on_curve].nil? || pt[:on_curve]
312
+ type = on_curve ? "line" : "offcurve"
313
+ Fontisan::Ufo::Point.new(x: tx, y: ty, type: type)
314
+ end
315
+ ufo_glyph.add_contour(Fontisan::Ufo::Contour.new(ufo_points))
316
+ end
317
+ end
318
+
256
319
  # Add Unicode codepoints from the cmap that map to this gid.
257
320
  def add_cmap_unicodes(gid, glyph)
258
321
  cmap = @font.table("cmap")
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `avar` (Axis Variation) table.
7
+ #
8
+ # avar defines non-linear interpolation curves for each axis.
9
+ # For axes with linear interpolation (the common case), each
10
+ # axis gets 3 default maps: (-1→-1, 0→0, 1→1).
11
+ #
12
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/avar
13
+ module Avar
14
+ # @param axes [Array<Hash>] axis definitions (tag + optional maps)
15
+ # @return [String] avar table bytes
16
+ def self.build(axes:)
17
+ return nil if axes.nil? || axes.empty?
18
+
19
+ io = +""
20
+ io << [0x00010000].pack("N") # version 1.0
21
+ io << [0].pack("n") # reserved
22
+ io << [axes.size].pack("n") # axisCount
23
+
24
+ axes.each do |axis|
25
+ maps = axis[:maps] || default_maps
26
+ io << [maps.size].pack("n")
27
+ maps.each do |from, to|
28
+ io << [f2dot14(from), f2dot14(to)].pack("nn")
29
+ end
30
+ end
31
+
32
+ io
33
+ end
34
+
35
+ def self.default_maps
36
+ [[-1.0, -1.0], [0.0, 0.0], [1.0, 1.0]].freeze
37
+ end
38
+
39
+ def self.f2dot14(value)
40
+ (value.to_f * 16384).to_i.clamp(-16384, 16384)
41
+ end
42
+ private_class_method :default_maps, :f2dot14
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `HVAR` (Horizontal Metrics Variation) table.
7
+ #
8
+ # Stores advance-width variation deltas per glyph, enabling
9
+ # variable-font renderers to adjust spacing without loading gvar.
10
+ #
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/HVAR
12
+ module Hvar
13
+ # @param default_widths [Array<Integer>] advance widths for default master
14
+ # @param master_widths [Array<Array<Integer>>] advance widths per master
15
+ # @param axis_count [Integer]
16
+ # @return [String] HVAR table bytes
17
+ def self.build(default_widths:, master_widths:, axis_count:)
18
+ glyph_count = default_widths.size
19
+ master_count = master_widths.size
20
+
21
+ # Compute deltas: deltas[glyph][master] = master_width - default_width
22
+ deltas = Array.new(glyph_count) do |gid|
23
+ Array.new(master_count) do |mid|
24
+ master_widths.dig(mid, gid).to_i - default_widths[gid].to_i
25
+ end
26
+ end
27
+
28
+ store = ItemVariationStore.build(
29
+ axis_count: axis_count,
30
+ master_count: master_count,
31
+ item_count: glyph_count,
32
+ deltas: deltas,
33
+ )
34
+
35
+ # HVAR header: version(4) + itemVariationStoreOffset(4) +
36
+ # advanceWidthMappingOffset(4) + lsbMappingOffset(4) + rsbMappingOffset(4)
37
+ [0x00010000, 20].pack("NN") + [0, 0, 0].pack("NNN") + store
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Shared builder for the ItemVariationStore structure used by
7
+ # HVAR, MVAR, and other variation tables.
8
+ #
9
+ # The ItemVariationStore consists of:
10
+ # 1. A VariationRegionList (defines axis regions)
11
+ # 2. One or more ItemVariationData (delta sets per item)
12
+ #
13
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/otvarcommonformats
14
+ module ItemVariationStore
15
+ # @param axis_count [Integer]
16
+ # @param master_count [Integer] number of masters (one region per master)
17
+ # @param item_count [Integer] number of items (glyphs for HVAR, metrics for MVAR)
18
+ # @param deltas [Array<Array<Integer>>] deltas[item][master] = delta value
19
+ # @return [String] ItemVariationStore bytes
20
+ def self.build(axis_count:, master_count:, item_count:, deltas:)
21
+ # Build regions: one per master, each with peak=1.0 on its axis
22
+ regions = build_regions(axis_count, master_count)
23
+ region_list = serialize_region_list(regions, axis_count)
24
+
25
+ # Build ItemVariationData: one data block covering all items
26
+ var_data = serialize_variation_data(item_count, master_count, deltas)
27
+
28
+ # Assemble the store
29
+ # Header layout: format(uint16) + variationRegionListOffset(uint32)
30
+ # + itemVariationDataCount(uint16) + itemVariationDataOffsets[1](uint32)
31
+ # = 2 + 4 + 2 + 4 = 12 bytes total before region list
32
+ header_size = 8 # format(2) + regionListOffset(4) + itemVariationDataCount(2)
33
+ offsets_array_size = 4 # one data block → one offset
34
+ region_list_offset = header_size + offsets_array_size
35
+ var_data_offset = region_list_offset + region_list.bytesize
36
+
37
+ store = +""
38
+ store << [1].pack("n") # format = 1
39
+ store << [region_list_offset].pack("N") # variationRegionListOffset
40
+ store << [1].pack("n") # itemVariationDataCount
41
+ store << [var_data_offset].pack("N") # itemVariationDataOffsets[0]
42
+ store << region_list
43
+ store << var_data
44
+ store
45
+ end
46
+
47
+ def self.build_regions(axis_count, master_count)
48
+ Array.new(master_count) do |master_idx|
49
+ Array.new(axis_count) do |axis_idx|
50
+ if axis_idx == master_idx
51
+ { start: -1.0, peak: 1.0, end: 1.0 }
52
+ else
53
+ { start: -1.0, peak: 0.0, end: 1.0 }
54
+ end
55
+ end
56
+ end
57
+ end
58
+
59
+ def self.serialize_region_list(regions, axis_count)
60
+ io = +""
61
+ io << [axis_count].pack("n") # axisCount
62
+ io << [regions.size].pack("n") # regionCount
63
+ regions.each do |region|
64
+ region.each do |coords|
65
+ io << [f2dot14(coords[:start]), f2dot14(coords[:peak]), f2dot14(coords[:end])].pack("nnn")
66
+ end
67
+ end
68
+ io
69
+ end
70
+
71
+ def self.serialize_variation_data(item_count, region_count, deltas)
72
+ # Determine if all deltas fit in int8 (-128..127)
73
+ all_short = deltas.flatten.any? { |d| !d.between?(-127, 127) }
74
+ short_count = all_short ? region_count : 0
75
+
76
+ io = +""
77
+ io << [item_count].pack("n") # itemCount
78
+ io << [short_count].pack("n") # shortDeltaCount
79
+ io << [region_count].pack("n") # regionIndexCount (all regions)
80
+ region_count.times { |i| io << [i].pack("n") } # regionIndices
81
+
82
+ deltas.each do |item_deltas|
83
+ item_deltas.each_with_index do |delta, i|
84
+ io << (i < short_count ? [delta].pack("s>") : [delta].pack("c"))
85
+ end
86
+ end
87
+
88
+ io
89
+ end
90
+
91
+ def self.f2dot14(value)
92
+ (value.to_f * 16384).to_i.clamp(-16384, 16384)
93
+ end
94
+ private_class_method :build_regions, :serialize_region_list,
95
+ :serialize_variation_data, :f2dot14
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `MVAR` (Metrics Variation) table.
7
+ #
8
+ # Stores deltas for font-wide metrics (ascender, descender, etc.)
9
+ # so they can vary across the design space.
10
+ #
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/MVAR
12
+ module Mvar
13
+ HEADER_SIZE = 10 # majorVersion(2) + minorVersion(2) + valueRecordSize(2) +
14
+ # valueRecordCount(2) + itemVariationStoreOffset(2)
15
+ VALUE_RECORD_SIZE = 8 # tag(4) + outerIndex(2) + innerIndex(2)
16
+
17
+ # @param default_metrics [Hash<Symbol, Integer>] e.g. { hasc: 800, hdsc: -200 }
18
+ # @param master_metrics [Array<Hash<Symbol, Integer>>] per master
19
+ # @param axis_count [Integer]
20
+ # @return [String] MVAR table bytes
21
+ def self.build(default_metrics:, master_metrics:, axis_count:)
22
+ tags = default_metrics.keys
23
+ return nil if tags.empty?
24
+
25
+ master_count = master_metrics.size
26
+
27
+ deltas = []
28
+ records = +""
29
+ tags.each_with_index do |tag, idx|
30
+ tag_bytes = tag.to_s.ljust(4, " ")[0, 4]
31
+ delta = master_metrics.dig(0, tag).to_i - default_metrics[tag].to_i
32
+ records << tag_bytes
33
+ records << [0, idx].pack("nn") # outerIndex=0, innerIndex=idx
34
+ deltas << [delta]
35
+ end
36
+
37
+ store = ItemVariationStore.build(
38
+ axis_count: axis_count,
39
+ master_count: master_count,
40
+ item_count: tags.size,
41
+ deltas: deltas,
42
+ )
43
+
44
+ store_offset = HEADER_SIZE + records.bytesize
45
+
46
+ io = +""
47
+ io << [1].pack("n") # majorVersion
48
+ io << [0].pack("n") # minorVersion
49
+ io << [VALUE_RECORD_SIZE].pack("n")
50
+ io << [tags.size].pack("n") # valueRecordCount
51
+ io << [store_offset].pack("n") # itemVariationStoreOffset (Offset16)
52
+ io << records
53
+ io << store
54
+ io
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `STAT` (Style Attributes) table.
7
+ #
8
+ # Describes style attributes for each axis and named instances.
9
+ # Required for proper font matching in operating systems.
10
+ #
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/STAT
12
+ module Stat
13
+ HEADER_SIZE = 20
14
+ DESIGN_AXIS_RECORD_SIZE = 8 # tag(4) + nameID(2) + ordering(2)
15
+ AXIS_VALUE_OFFSET_SIZE = 4 # uint32 per axis value
16
+
17
+ # @param axes [Array<Hash>] axis definitions with tag + name_id + ordering
18
+ # @param axis_values [Array<Hash>] value records per axis
19
+ # @param elided_name_id [Integer, nil] fallback name ID
20
+ # @return [String] STAT table bytes
21
+ def self.build(axes:, axis_values: nil, elided_name_id: nil)
22
+ return nil if axes.nil? || axes.empty?
23
+
24
+ design_axis_count = axes.size
25
+ axis_value_list = axis_values || []
26
+ axis_value_count = axis_value_list.size
27
+
28
+ design_axes = serialize_design_axes(axes)
29
+ value_tables, value_offsets = serialize_axis_values(axis_value_list, design_axes.bytesize)
30
+
31
+ header = serialize_header(
32
+ design_axis_count: design_axis_count,
33
+ design_axes_size: design_axes.bytesize,
34
+ axis_value_count: axis_value_count,
35
+ elided_name_id: elided_name_id,
36
+ )
37
+
38
+ io = +""
39
+ io << header
40
+ io << design_axes
41
+ axis_value_list.each_index do |i|
42
+ io << [value_offsets[i]].pack("N")
43
+ end
44
+ io << value_tables
45
+ io
46
+ end
47
+
48
+ def self.serialize_header(design_axis_count:, design_axes_size:, axis_value_count:, elided_name_id:)
49
+ design_axes_offset = HEADER_SIZE
50
+ offset_to_axis_value_offsets = design_axes_offset + design_axes_size
51
+
52
+ [
53
+ 0x00010001, # version 1.1
54
+ DESIGN_AXIS_RECORD_SIZE,
55
+ design_axis_count,
56
+ design_axes_offset,
57
+ axis_value_count,
58
+ offset_to_axis_value_offsets,
59
+ elided_name_id || 0,
60
+ ].pack("NnnNnNn")
61
+ end
62
+
63
+ def self.serialize_design_axes(axes)
64
+ io = +""
65
+ axes.each_with_index do |axis, i|
66
+ tag = (axis[:tag] || axis["tag"] || " ").to_s.ljust(4, " ")[0, 4]
67
+ name_id = axis[:name_id] || axis["name_id"] || 0
68
+ ordering = axis[:ordering] || axis["ordering"] || i
69
+ io << tag
70
+ io << [name_id, ordering].pack("nn")
71
+ end
72
+ io
73
+ end
74
+
75
+ # Each AxisValueTable is Format 1 (nominal): format(2) + axisIndex(2)
76
+ # + flags(2) + valueNameID(2) + value(F2DOT14) = 10 bytes.
77
+ def self.serialize_axis_values(axis_values, design_axes_size)
78
+ value_tables = +""
79
+ value_offsets = []
80
+ base = HEADER_SIZE + design_axes_size + (axis_values.size * AXIS_VALUE_OFFSET_SIZE)
81
+
82
+ axis_values.each do |av|
83
+ value_offsets << (base + value_tables.bytesize)
84
+ format = 1
85
+ axis_idx = av[:axis_index] || av["axis_index"] || 0
86
+ flags = av[:flags] || av["flags"] || 0
87
+ name_id = av[:name_id] || av["name_id"] || 0
88
+ value = f2dot14(av[:value] || av["value"] || 0)
89
+ value_tables << [format, axis_idx, flags, name_id, value].pack("nnnnn")
90
+ end
91
+
92
+ [value_tables, value_offsets]
93
+ end
94
+
95
+ def self.f2dot14(value)
96
+ (value.to_f * 16384).to_i.clamp(-16384, 16384)
97
+ end
98
+ private_class_method :serialize_header, :serialize_design_axes,
99
+ :serialize_axis_values, :f2dot14
100
+ end
101
+ end
102
+ end
103
+ end
@@ -12,7 +12,8 @@ module Fontisan
12
12
  class TtfCompiler < BaseCompiler
13
13
  SFNT_VERSION = SFNT_VERSION_TRUE_TYPE
14
14
 
15
- def compile(output_path:)
15
+ # @return [Hash<String, #to_binary_s>] all TTF tables, not yet written
16
+ def build_tables
16
17
  glyphs = font.glyphs.values
17
18
 
18
19
  # Deep-clone glyphs so filters don't mutate the source UFO.
@@ -22,7 +23,7 @@ module Fontisan
22
23
  glyf_loca = GlyfLoca.build(font, glyphs: filtered)
23
24
  loca_format = glyf_loca.delete(:loca_format)
24
25
 
25
- tables = {
26
+ {
26
27
  "head" => Head.build(font, glyphs: filtered,
27
28
  loca_format: loca_format || Head::LOCA_FORMAT_LONG),
28
29
  "hhea" => Hhea.build(font, glyphs: filtered),
@@ -36,8 +37,10 @@ module Fontisan
36
37
  "glyf" => glyf_loca["glyf"],
37
38
  "loca" => glyf_loca["loca"],
38
39
  }
40
+ end
39
41
 
40
- write(tables, output_path)
42
+ def compile(output_path:)
43
+ write(build_tables, output_path)
41
44
  output_path
42
45
  end
43
46
 
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Orchestrates compilation of a UFO source plus its variation
7
+ # masters into a single variable TrueType font.
8
+ #
9
+ # Pipeline:
10
+ #
11
+ # default UFO font
12
+ # │
13
+ # ├─ TtfCompiler tables (head, hhea, maxp, OS/2, name,
14
+ # │ post, hmtx, cmap, glyf, loca)
15
+ # │
16
+ # ├─ fvar (axes + named instances)
17
+ # ├─ gvar (per-glyph point deltas across masters)
18
+ # ├─ HVAR (advance-width deltas across masters)
19
+ # ├─ MVAR (font-wide metric deltas across masters)
20
+ # ├─ avar (per-axis non-linear maps; defaults to identity)
21
+ # └─ STAT (style attributes for OS matching)
22
+ #
23
+ # Masters are supplied as an Array of Hashes, each with:
24
+ # - :font — the master UFO::Font
25
+ # - :axes — Hash<String, Float> mapping axis tag → region peak
26
+ #
27
+ class VariableTtf
28
+ SFNT_VERSION = BaseCompiler::SFNT_VERSION_TRUE_TYPE
29
+
30
+ # @param font [Fontisan::Ufo::Font] default master
31
+ # @param axes [Array<Hash>] axis definitions (tag, min/default/max,
32
+ # optional name_id, ordering, maps)
33
+ # @param masters [Array<Hash>] each master: { font:, axes: }
34
+ # @param instances [Array<Hash>] named instances for fvar
35
+ # @param stat_axis_values [Array<Hash>, nil] STAT axis value records
36
+ # @param stat_elided_name_id [Integer, nil] STAT elided fallback name
37
+ # @param default_metrics [Hash<Symbol, Integer>, nil] MVAR defaults
38
+ # @param master_metrics [Array<Hash<Symbol, Integer>>, nil] MVAR per-master
39
+ def initialize(font:, axes:, masters:, instances: nil,
40
+ stat_axis_values: nil, stat_elided_name_id: nil,
41
+ default_metrics: nil, master_metrics: nil)
42
+ @font = font
43
+ @axes = axes
44
+ @masters = masters
45
+ @instances = instances
46
+ @stat_axis_values = stat_axis_values
47
+ @stat_elided_name_id = stat_elided_name_id
48
+ @default_metrics = default_metrics
49
+ @master_metrics = master_metrics
50
+ end
51
+
52
+ # @param output_path [String]
53
+ # @return [String] the output path
54
+ def compile(output_path:)
55
+ tables = base_tables.merge(variation_tables)
56
+ write(tables, output_path)
57
+ output_path
58
+ end
59
+
60
+ private
61
+
62
+ def base_tables
63
+ @base_tables ||= TtfCompiler.new(@font).build_tables
64
+ end
65
+
66
+ def variation_tables
67
+ axis_count = @axes.size
68
+ tables = {}
69
+
70
+ fvar_bytes = Fvar.build(@font, axes: @axes, instances: @instances)
71
+ tables["fvar"] = fvar_bytes if fvar_bytes
72
+
73
+ avar_bytes = Avar.build(axes: @axes)
74
+ tables["avar"] = avar_bytes if avar_bytes
75
+
76
+ stat_bytes = Stat.build(
77
+ axes: stat_axes,
78
+ axis_values: @stat_axis_values,
79
+ elided_name_id: @stat_elided_name_id,
80
+ )
81
+ tables["STAT"] = stat_bytes if stat_bytes
82
+
83
+ default_glyphs = @font.glyphs.values
84
+ glyph_order = default_glyphs.map(&:name)
85
+
86
+ gvar_masters = @masters.map do |m|
87
+ {
88
+ axes: m[:axes],
89
+ glyphs: glyphs_for_master(m[:font], glyph_order),
90
+ }
91
+ end
92
+ tables["gvar"] = Gvar.build(
93
+ default_glyphs: default_glyphs,
94
+ masters: gvar_masters,
95
+ axis_count: axis_count,
96
+ )
97
+
98
+ default_widths = default_glyphs.map { |g| g.width.to_i }
99
+ master_widths = @masters.map do |m|
100
+ glyphs_for_master(m[:font], glyph_order).map { |g| g.width.to_i }
101
+ end
102
+ tables["HVAR"] = Hvar.build(
103
+ default_widths: default_widths,
104
+ master_widths: master_widths,
105
+ axis_count: axis_count,
106
+ )
107
+
108
+ if @default_metrics && @master_metrics
109
+ mvar = Mvar.build(
110
+ default_metrics: @default_metrics,
111
+ master_metrics: @master_metrics,
112
+ axis_count: axis_count,
113
+ )
114
+ tables["MVAR"] = mvar if mvar
115
+ end
116
+
117
+ tables
118
+ end
119
+
120
+ # Project a master's glyphs onto the default master's glyph order
121
+ # so deltas align by index. Missing glyphs default to the master's
122
+ # .notdef (index 0).
123
+ def glyphs_for_master(master_font, glyph_order)
124
+ by_name = master_font.glyphs
125
+ notdef = by_name.values.first
126
+ glyph_order.map { |name| by_name[name] || notdef }
127
+ end
128
+
129
+ # STAT design-axis records derived from @axes. Each axis may
130
+ # supply :name_id and :ordering; missing values default sensibly.
131
+ def stat_axes
132
+ @axes.each_with_index.map do |axis, i|
133
+ {
134
+ tag: axis[:tag] || axis["tag"],
135
+ name_id: axis[:name_id] || axis["name_id"] || 0,
136
+ ordering: axis[:ordering] || axis["ordering"] || i,
137
+ }
138
+ end
139
+ end
140
+
141
+ def write(tables_hash, output_path)
142
+ dir = File.dirname(output_path)
143
+ FileUtils.mkpath(dir) unless dir == "."
144
+ Fontisan::FontWriter.write_to_file(
145
+ tables_hash.transform_values { |t| serialize_table(t) },
146
+ output_path,
147
+ sfnt_version: SFNT_VERSION,
148
+ )
149
+ end
150
+
151
+ # BinData records respond to to_binary_s; raw String values pass through.
152
+ def serialize_table(table)
153
+ case table
154
+ when String then table
155
+ else table.to_binary_s
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end
@@ -46,6 +46,13 @@ module Fontisan
46
46
  autoload :Cmap, "fontisan/ufo/compile/cmap"
47
47
  autoload :GlyfLoca, "fontisan/ufo/compile/glyf_loca"
48
48
  autoload :Cff, "fontisan/ufo/compile/cff"
49
+ autoload :Avar, "fontisan/ufo/compile/avar"
50
+ autoload :Hvar, "fontisan/ufo/compile/hvar"
51
+ autoload :Mvar, "fontisan/ufo/compile/mvar"
52
+ autoload :Stat, "fontisan/ufo/compile/stat"
53
+ autoload :VariableTtf, "fontisan/ufo/compile/variable_ttf"
54
+ autoload :ItemVariationStore,
55
+ "fontisan/ufo/compile/item_variation_store"
49
56
  end
50
57
  end
51
58
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Fontisan
4
- VERSION = "0.4.5"
4
+ VERSION = "0.4.6"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fontisan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.5
4
+ version: 0.4.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-30 00:00:00.000000000 Z
11
+ date: 2026-07-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -555,6 +555,7 @@ files:
555
555
  - lib/fontisan/ufo/anchor.rb
556
556
  - lib/fontisan/ufo/cli.rb
557
557
  - lib/fontisan/ufo/compile.rb
558
+ - lib/fontisan/ufo/compile/avar.rb
558
559
  - lib/fontisan/ufo/compile/base_compiler.rb
559
560
  - lib/fontisan/ufo/compile/cff.rb
560
561
  - lib/fontisan/ufo/compile/cmap.rb
@@ -570,12 +571,17 @@ files:
570
571
  - lib/fontisan/ufo/compile/head.rb
571
572
  - lib/fontisan/ufo/compile/hhea.rb
572
573
  - lib/fontisan/ufo/compile/hmtx.rb
574
+ - lib/fontisan/ufo/compile/hvar.rb
575
+ - lib/fontisan/ufo/compile/item_variation_store.rb
573
576
  - lib/fontisan/ufo/compile/maxp.rb
577
+ - lib/fontisan/ufo/compile/mvar.rb
574
578
  - lib/fontisan/ufo/compile/name.rb
575
579
  - lib/fontisan/ufo/compile/os2.rb
576
580
  - lib/fontisan/ufo/compile/otf_compiler.rb
577
581
  - lib/fontisan/ufo/compile/post.rb
582
+ - lib/fontisan/ufo/compile/stat.rb
578
583
  - lib/fontisan/ufo/compile/ttf_compiler.rb
584
+ - lib/fontisan/ufo/compile/variable_ttf.rb
579
585
  - lib/fontisan/ufo/component.rb
580
586
  - lib/fontisan/ufo/contour.rb
581
587
  - lib/fontisan/ufo/convert.rb