fontisan 0.4.5 → 0.4.7

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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/BUG-stitcher-drops-isolated-cps.md +58 -0
  3. data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
  4. data/BUG-stitcher-gid-cap-65535.md +110 -0
  5. data/CHANGELOG.md +142 -0
  6. data/README.adoc +121 -68
  7. data/benchmark/compile_benchmark.rb +70 -0
  8. data/docs/CFF2_SUPPORT.adoc +184 -0
  9. data/docs/STITCHER_GUIDE.adoc +151 -0
  10. data/docs/SVG_TO_GLYF.adoc +118 -0
  11. data/docs/UFO_COMPILATION.adoc +119 -0
  12. data/lib/fontisan/collection/writer.rb +5 -6
  13. data/lib/fontisan/error.rb +31 -0
  14. data/lib/fontisan/stitcher/deduplicator.rb +47 -0
  15. data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
  16. data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
  17. data/lib/fontisan/stitcher/source.rb +67 -4
  18. data/lib/fontisan/stitcher.rb +188 -167
  19. data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
  20. data/lib/fontisan/svg_to_glyf/document.rb +83 -0
  21. data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
  22. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
  23. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
  24. data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
  25. data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
  26. data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
  27. data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
  28. data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
  29. data/lib/fontisan/svg_to_glyf/path.rb +14 -0
  30. data/lib/fontisan/svg_to_glyf.rb +62 -0
  31. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
  32. data/lib/fontisan/tables/cff.rb +1 -0
  33. data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
  34. data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
  35. data/lib/fontisan/tables/cff2/header.rb +34 -0
  36. data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
  37. data/lib/fontisan/tables/cff2.rb +4 -0
  38. data/lib/fontisan/ufo/compile/avar.rb +46 -0
  39. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
  40. data/lib/fontisan/ufo/compile/cff2.rb +181 -0
  41. data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
  42. data/lib/fontisan/ufo/compile/colr.rb +80 -0
  43. data/lib/fontisan/ufo/compile/cpal.rb +61 -0
  44. data/lib/fontisan/ufo/compile/hvar.rb +42 -0
  45. data/lib/fontisan/ufo/compile/item_variation_store.rb +99 -0
  46. data/lib/fontisan/ufo/compile/math.rb +143 -0
  47. data/lib/fontisan/ufo/compile/meta.rb +51 -0
  48. data/lib/fontisan/ufo/compile/mvar.rb +59 -0
  49. data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
  50. data/lib/fontisan/ufo/compile/sbix.rb +99 -0
  51. data/lib/fontisan/ufo/compile/stat.rb +103 -0
  52. data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
  53. data/lib/fontisan/ufo/compile/ttf_compiler.rb +6 -3
  54. data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
  55. data/lib/fontisan/ufo/compile/variable_ttf.rb +161 -0
  56. data/lib/fontisan/ufo/compile.rb +18 -0
  57. data/lib/fontisan/version.rb +1 -1
  58. data/lib/fontisan.rb +3 -0
  59. metadata +47 -2
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `meta` table.
7
+ #
8
+ # The meta table stores font metadata as tagged data — longer-form
9
+ # strings than the `name` table supports. Common tags:
10
+ # "dlng" — design languages (BCP-47 tags)
11
+ # "slng" — supported languages
12
+ #
13
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/meta
14
+ module Meta
15
+ VERSION = 1
16
+ HEADER_SIZE = 16
17
+ DATA_MAP_SIZE = 12 # tag(4) + offset(4) + length(4)
18
+
19
+ # @param data [Hash<String,String>] tag → UTF-8 string value
20
+ # @return [String, nil] meta table bytes, or nil if no data
21
+ def self.build(data:)
22
+ return nil if data.nil? || data.empty?
23
+
24
+ count = data.size
25
+ data_offset = HEADER_SIZE + (count * DATA_MAP_SIZE)
26
+
27
+ io = +""
28
+ io << [VERSION, 0, count, data_offset].pack("NNNN")
29
+
30
+ # Two iterations over the same data: the meta table has two
31
+ # distinct output sections (data map entries then data values)
32
+ # that cannot be interleaved. The map-entry section records
33
+ # offsets that depend on the total size of the value section,
34
+ # so the value section must be produced last.
35
+ #
36
+ # rubocop:disable Style/CombinableLoops
37
+ offset = data_offset
38
+ data.each do |tag, value|
39
+ io << tag.ljust(4, " ")[0, 4]
40
+ io << [offset, value.bytesize].pack("NN")
41
+ offset += value.bytesize
42
+ end
43
+ data.each_value { |value| io << value.b }
44
+ # rubocop:enable Style/CombinableLoops
45
+
46
+ io
47
+ end
48
+ end
49
+ end
50
+ end
51
+ 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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # UFO → OTF (CFF2). Uses CFF2 outlines instead of CFF1.
7
+ #
8
+ # CFF2 uses the same OTTO sfnt signature as CFF1. The difference
9
+ # is the table tag: `CFF2` (vs `CFF ` for CFF1). CFF2 enables
10
+ # variable font support and improved subroutinization.
11
+ #
12
+ # Note: CFF2 does NOT bypass the 65,535 glyph cap. The maxp
13
+ # table's numGlyphs is uint16 in all OpenType versions, and the
14
+ # CFF2 CharStrings INDEX count must match it. For > 65,535
15
+ # glyphs, use TTC splitting.
16
+ class Otf2Compiler < BaseCompiler
17
+ SFNT_VERSION = SFNT_VERSION_OPEN_TYPE
18
+
19
+ def build_outline_tables
20
+ {
21
+ "CFF2" => Cff2.build(font, glyphs: font.glyphs.values),
22
+ }
23
+ end
24
+
25
+ def compile(output_path:)
26
+ glyphs = font.glyphs.values
27
+
28
+ tables = {
29
+ "head" => Head.build(font, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
30
+ "hhea" => Hhea.build(font, glyphs: glyphs),
31
+ "maxp" => Maxp.build(font, glyphs: glyphs, version: Maxp::VERSION_OPEN_TYPE),
32
+ "OS/2" => Os2.build(font, glyphs: glyphs),
33
+ "name" => Name.build(font),
34
+ "post" => Post.build(font),
35
+ "hmtx" => Hmtx.build(font, glyphs: glyphs),
36
+ "cmap" => Cmap.build(font, glyphs: glyphs),
37
+ "CFF2" => Cff2.build(font, glyphs: glyphs),
38
+ }
39
+
40
+ write(tables, output_path)
41
+ output_path
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `sbix` (Apple Bitmap) table.
7
+ #
8
+ # sbix stores bitmap strikes (PNG/JPEG) at multiple sizes. Each
9
+ # strike is a set of glyph-to-bitmap mappings at a specific ppem
10
+ # and resolution.
11
+ #
12
+ # Layout:
13
+ # Header (8 bytes):
14
+ # uint16 version (= 1)
15
+ # uint16 flags
16
+ # uint32 numStrikes
17
+ # Offset32 strikeOffsets[numStrikes]
18
+ #
19
+ # Strike (per ppem):
20
+ # uint16 ppem
21
+ # uint16 resolution (ppi)
22
+ # Offset32 glyphDataOffsets[numGlyphs + 1]
23
+ #
24
+ # Glyph Data (per glyph):
25
+ # int16 originOffsetX
26
+ # int16 originOffsetY
27
+ # uint16 graphicType ("png " or "jpeg")
28
+ # uint8 bitmapData[]
29
+ #
30
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/sbix
31
+ module Sbix
32
+ VERSION = 1
33
+ PNG_TYPE = "png "
34
+ JPEG_TYPE = "jpeg"
35
+
36
+ # @param strikes [Array<Hash>] each with :ppem, :resolution,
37
+ # and :glyphs (Array<Hash> with :origin_x, :origin_y,
38
+ # :graphic_type, :data per glyph)
39
+ # @param num_glyphs [Integer] total glyph count
40
+ # @return [String, nil] sbix bytes, or nil if no strikes
41
+ def self.build(strikes:, num_glyphs:)
42
+ return nil if strikes.nil? || strikes.empty?
43
+
44
+ num_strikes = strikes.size
45
+ header_size = 4 + 4 + (num_strikes * 4) # version + flags + offsets
46
+
47
+ strike_bytes = strikes.map { |s| build_strike(s, num_glyphs) }
48
+
49
+ io = +""
50
+ io << [VERSION, 0, num_strikes].pack("nnN")
51
+
52
+ offset = header_size
53
+ strike_bytes.each do |s|
54
+ io << [offset].pack("N")
55
+ offset += s.bytesize
56
+ io << s
57
+ end
58
+ io
59
+ end
60
+
61
+ def self.build_strike(strike, num_glyphs)
62
+ glyphs = strike[:glyphs] || []
63
+ ppem = strike[:ppem] || 0
64
+ resolution = strike[:resolution] || 72
65
+
66
+ # Offset table: ppem(2) + resolution(2) + (numGlyphs+1) × offset(4)
67
+ offset_table_size = 4 + ((num_glyphs + 1) * 4)
68
+
69
+ offsets = []
70
+ glyph_data = +""
71
+ current_offset = offset_table_size
72
+
73
+ num_glyphs.times do |gid|
74
+ glyph = glyphs[gid]
75
+ if glyph && glyph[:data]
76
+ offsets << current_offset
77
+ data = glyph[:data].b
78
+ glyph_data << [glyph[:origin_x] || 0, glyph[:origin_y] || 0].pack("nn")
79
+ glyph_data << (glyph[:graphic_type] || PNG_TYPE).ljust(4)[0, 4]
80
+ glyph_data << data
81
+ current_offset += 8 + data.bytesize
82
+ else
83
+ offsets << current_offset
84
+ end
85
+ end
86
+ offsets << current_offset # sentinel
87
+
88
+ io = +""
89
+ io << [ppem, resolution].pack("nn")
90
+ offsets.each { |o| io << [o].pack("N") }
91
+ io << glyph_data
92
+ io
93
+ end
94
+
95
+ private_class_method :build_strike
96
+ end
97
+ end
98
+ end
99
+ 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
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `SVG ` table for SVG-in-OpenType color glyphs.
7
+ #
8
+ # Layout:
9
+ # Header (10 bytes):
10
+ # uint16 version (= 0)
11
+ # Offset32 documentListOffset (= 10, immediately after header)
12
+ # uint32 reserved (= 0)
13
+ #
14
+ # Document List:
15
+ # uint16 numEntries
16
+ # DocumentRecord[numEntries]:
17
+ # uint16 startGlyphID
18
+ # uint16 endGlyphID
19
+ # Offset32 svgDocOffset (from start of SVG table)
20
+ # uint32 svgDocLength
21
+ #
22
+ # SVG Documents (raw XML, optionally gzipped)
23
+ #
24
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/svg
25
+ module SvgTable
26
+ VERSION = 0
27
+ HEADER_SIZE = 10
28
+ DOCUMENT_RECORD_SIZE = 12
29
+
30
+ # @param entries [Array<Hash>] each with :start_gid (Integer),
31
+ # :end_gid (Integer), :svg (String — raw SVG XML)
32
+ # @return [String, nil] SVG table bytes, or nil if no entries
33
+ def self.build(entries:)
34
+ return nil if entries.nil? || entries.empty?
35
+
36
+ num_entries = entries.size
37
+ doc_list_size = 2 + (num_entries * DOCUMENT_RECORD_SIZE)
38
+ doc_data_offset = HEADER_SIZE + doc_list_size
39
+
40
+ header = [VERSION, HEADER_SIZE, 0].pack("nNN")
41
+
42
+ records = +""
43
+ docs = +""
44
+ offset = doc_data_offset
45
+ entries.each do |entry|
46
+ svg_bytes = entry[:svg].b
47
+ records << [entry[:start_gid], entry[:end_gid],
48
+ offset, svg_bytes.bytesize].pack("nnNN")
49
+ docs << svg_bytes
50
+ offset += svg_bytes.bytesize
51
+ end
52
+
53
+ doc_list = [num_entries].pack("n") + records
54
+
55
+ header + doc_list + docs
56
+ end
57
+ end
58
+ end
59
+ end
60
+ 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,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ module Compile
8
+ # VariableOtf — produces a variable OTF with CFF2 outlines.
9
+ #
10
+ # The orchestrator assembles a default-master UFO plus variation
11
+ # masters into a variable OpenType font with:
12
+ # - CFF2 outlines (static — blend/vsindex operators are TODO 07/18)
13
+ # - fvar (axes + instances)
14
+ # - STAT (style attributes)
15
+ # - avar (axis remapping, when maps are provided)
16
+ #
17
+ # NOTE: the CFF2 table currently contains static outlines (no
18
+ # VariationStore, no blend operators). Full variable CFF2 requires
19
+ # TODO 07 (blend/vsindex) and TODO 18 (blend integration) to be
20
+ # wired into the charstring builder.
21
+ #
22
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
23
+ class VariableOtf
24
+ # @param default_font [Fontisan::Ufo::Font] the default master
25
+ # @param master_fonts [Array<Fontisan::Ufo::Font>] variation masters
26
+ # @param axes [Array<Hash>] fvar axes (tag, min, default, max, name_id)
27
+ # @param instances [Array<Hash>] named instances
28
+ def initialize(default_font, master_fonts: [], axes: [], instances: [])
29
+ @default = default_font
30
+ @masters = master_fonts
31
+ @axes = axes
32
+ @instances = instances
33
+ end
34
+
35
+ # Compile the variable OTF directly to the output path.
36
+ # @param output_path [String] output file path
37
+ # @return [String] the output path
38
+ def compile(output_path:)
39
+ tables = build_tables
40
+ Fontisan::FontWriter.write_to_file(
41
+ tables.transform_values { |t| t.is_a?(String) ? t : t.to_binary_s },
42
+ output_path,
43
+ sfnt_version: 0x4F54544F,
44
+ )
45
+ output_path
46
+ end
47
+
48
+ # Build all tables for the variable OTF.
49
+ # @return [Hash<String,String>] table tag → bytes
50
+ def build_tables
51
+ glyphs = @default.glyphs.values
52
+
53
+ tables = {
54
+ "head" => Head.build(@default, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
55
+ "hhea" => Hhea.build(@default, glyphs: glyphs),
56
+ "maxp" => Maxp.build(@default, glyphs: glyphs, version: Maxp::VERSION_OPEN_TYPE),
57
+ "OS/2" => Os2.build(@default, glyphs: glyphs),
58
+ "name" => Name.build(@default),
59
+ "post" => Post.build(@default),
60
+ "hmtx" => Hmtx.build(@default, glyphs: glyphs),
61
+ "cmap" => Cmap.build(@default, glyphs: glyphs),
62
+ "CFF2" => Cff2.build(@default, glyphs: glyphs),
63
+ "fvar" => Fvar.build(@default, axes: @axes, instances: @instances),
64
+ "STAT" => Stat.build(axes: @axes),
65
+ }
66
+
67
+ avar_bytes = Avar.build(axes: @axes)
68
+ tables["avar"] = avar_bytes if avar_bytes
69
+
70
+ tables
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -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