fontisan 0.2.23 → 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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fontisan/cli.rb +6 -0
  3. data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
  4. data/lib/fontisan/stitcher/selector/gid.rb +25 -0
  5. data/lib/fontisan/stitcher/selector/range.rb +30 -0
  6. data/lib/fontisan/stitcher/selector.rb +26 -0
  7. data/lib/fontisan/stitcher/source.rb +97 -0
  8. data/lib/fontisan/stitcher.rb +182 -0
  9. data/lib/fontisan/stitcher_cli.rb +69 -0
  10. data/lib/fontisan/ufo/anchor.rb +17 -0
  11. data/lib/fontisan/ufo/cli.rb +85 -0
  12. data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
  13. data/lib/fontisan/ufo/compile/cff.rb +224 -0
  14. data/lib/fontisan/ufo/compile/cmap.rb +129 -0
  15. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
  16. data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
  17. data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
  18. data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
  19. data/lib/fontisan/ufo/compile/filters.rb +57 -0
  20. data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
  21. data/lib/fontisan/ufo/compile/head.rb +98 -0
  22. data/lib/fontisan/ufo/compile/hhea.rb +36 -0
  23. data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
  24. data/lib/fontisan/ufo/compile/maxp.rb +57 -0
  25. data/lib/fontisan/ufo/compile/name.rb +79 -0
  26. data/lib/fontisan/ufo/compile/os2.rb +81 -0
  27. data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
  28. data/lib/fontisan/ufo/compile/post.rb +32 -0
  29. data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
  30. data/lib/fontisan/ufo/compile.rb +48 -0
  31. data/lib/fontisan/ufo/component.rb +18 -0
  32. data/lib/fontisan/ufo/contour.rb +29 -0
  33. data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
  34. data/lib/fontisan/ufo/convert.rb +18 -0
  35. data/lib/fontisan/ufo/data_set.rb +21 -0
  36. data/lib/fontisan/ufo/features.rb +17 -0
  37. data/lib/fontisan/ufo/font.rb +61 -0
  38. data/lib/fontisan/ufo/glyph.rb +421 -0
  39. data/lib/fontisan/ufo/guideline.rb +19 -0
  40. data/lib/fontisan/ufo/image.rb +16 -0
  41. data/lib/fontisan/ufo/image_set.rb +19 -0
  42. data/lib/fontisan/ufo/info.rb +79 -0
  43. data/lib/fontisan/ufo/kerning.rb +32 -0
  44. data/lib/fontisan/ufo/layer.rb +38 -0
  45. data/lib/fontisan/ufo/layer_set.rb +37 -0
  46. data/lib/fontisan/ufo/lib.rb +24 -0
  47. data/lib/fontisan/ufo/plist.rb +118 -0
  48. data/lib/fontisan/ufo/point.rb +38 -0
  49. data/lib/fontisan/ufo/reader.rb +144 -0
  50. data/lib/fontisan/ufo/transformation.rb +39 -0
  51. data/lib/fontisan/ufo/writer.rb +115 -0
  52. data/lib/fontisan/ufo.rb +44 -0
  53. data/lib/fontisan/version.rb +1 -1
  54. data/lib/fontisan.rb +3 -0
  55. metadata +51 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f84b59fae400f9db9dd05b2ddebce11bedb865138026f7a3a6d1b7ce0b3d8f2a
4
- data.tar.gz: 563b27ed9dc91412fb83d7769a72a0d618109e3a6394236c7648524a60ad4fb6
3
+ metadata.gz: 0fe760da43bddcfad046aef63c311613ea3767569245b21a2bda6a6d5f100a5f
4
+ data.tar.gz: 7571202d9bc900928ee4c3859e4081af6d561f53740587f0f94f7a159dbbbec3
5
5
  SHA512:
6
- metadata.gz: 7f2d94e40b9444414113ebf842a4a84e2188199829b4caf8b9e707bea6bca89c07b5e11fa04ba2f4aff1ce7ea658004385a5e2ae2ae4b4d4094d5bb90c4d5345
7
- data.tar.gz: becf5ccba115f938caeb23d29e3cc0685edc9e0cb8452f8b1ed8b7e76cc8e742adcf3f9d1f5c8b1f01c1631fcb28a4ff2305068ce6b9677ef339d8f5fb4765b9
6
+ metadata.gz: 66e94f6047f20385c27583a191d5c78ded6589ee8d2004386613062adb2071aba41644736c632be4087e24998ae29a7066fc17e42773a3627832c32f67d3c670
7
+ data.tar.gz: b9180051a16410731a112602c4ff5d18b679b6c6e521d31f092b5be2c550aceaae8992729c6bc71d7dd919b71b669217866febd26ce014528cd91eb392af1c96
data/lib/fontisan/cli.rb CHANGED
@@ -28,6 +28,12 @@ module Fontisan
28
28
  desc "cldr", "Manage local CLDR cache (subcommands)", hide: true
29
29
  subcommand "cldr", CldrCli
30
30
 
31
+ desc "ufo", "UFO source operations (build, convert, validate)"
32
+ subcommand "ufo", Fontisan::Ufo::Cli
33
+
34
+ desc "stitch", "Stitch glyphs from multiple source fonts into one output"
35
+ subcommand "stitch", StitcherCli
36
+
31
37
  desc "info PATH", "Display font information"
32
38
  option :brief, type: :boolean, default: false,
33
39
  desc: "Brief mode - only essential info (5x faster, uses metadata loading)",
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module Selector
6
+ # Include an explicit list of codepoints.
7
+ class Codepoints
8
+ attr_reader :codepoints
9
+
10
+ def initialize(codepoints)
11
+ @codepoints = codepoints
12
+ end
13
+
14
+ def apply(source, bindings)
15
+ @codepoints.each do |cp|
16
+ gid = source.gid_for_codepoint(cp)
17
+ next unless gid
18
+
19
+ bindings << {
20
+ codepoint: cp,
21
+ source: source,
22
+ donor_gid: gid,
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module Selector
6
+ # Include a single glyph by its donor gid. Used for unencoded
7
+ # glyphs (.notdef, spaces, format-specific specials).
8
+ class Gid
9
+ attr_reader :gid
10
+
11
+ def initialize(gid)
12
+ @gid = gid
13
+ end
14
+
15
+ def apply(source, bindings)
16
+ bindings << {
17
+ codepoint: nil,
18
+ source: source,
19
+ donor_gid: @gid,
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module Selector
6
+ # Include every codepoint in a Range (e.g. 0x41..0x5A = A-Z).
7
+ # Glyphs missing from the source are silently skipped.
8
+ class Range
9
+ attr_reader :range
10
+
11
+ def initialize(range)
12
+ @range = range
13
+ end
14
+
15
+ def apply(source, bindings)
16
+ @range.each do |cp|
17
+ gid = source.gid_for_codepoint(cp)
18
+ next unless gid
19
+
20
+ bindings << {
21
+ codepoint: cp,
22
+ source: source,
23
+ donor_gid: gid,
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Selectors decide which glyphs from a source to include in the
6
+ # stitched font. Each selector appends to the Stitcher's bindings
7
+ # list. OCP: adding a new way to select = adding a new Selector
8
+ # class + a registry entry.
9
+ module Selector
10
+ autoload :Range, "fontisan/stitcher/selector/range"
11
+ autoload :Codepoints, "fontisan/stitcher/selector/codepoints"
12
+ autoload :Gid, "fontisan/stitcher/selector/gid"
13
+
14
+ REGISTRY = {
15
+ range: Range,
16
+ codepoints: Codepoints,
17
+ gid: Gid,
18
+ }.freeze
19
+
20
+ def self.resolve(name)
21
+ REGISTRY[name.to_sym] or
22
+ raise ArgumentError, "unknown selector: #{name.inspect}"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Wraps a source font (UFO or loaded TTF/OTF) behind a single
6
+ # extraction API used by the selectors.
7
+ #
8
+ # For UFO sources, glyphs are accessed by name directly. For TTF
9
+ # or OTF sources, the source is lazily converted to a UFO::Font
10
+ # via Ufo::Convert::FromBinData on first glyph access, then cached.
11
+ # This is O(n) in donor glyph count but amortized across all
12
+ # codepoint extractions from that donor.
13
+ class Source
14
+ attr_reader :font
15
+
16
+ def initialize(font)
17
+ @font = font
18
+ @ufo_cache = nil
19
+ end
20
+
21
+ # @return [Symbol] :ufo, :ttf, :otf
22
+ def format
23
+ case @font
24
+ when Fontisan::Ufo::Font then :ufo
25
+ when Fontisan::TrueTypeFont then :ttf
26
+ when Fontisan::OpenTypeFont then :otf
27
+ else :unknown
28
+ end
29
+ end
30
+
31
+ # Find the gid for a Unicode codepoint in this source.
32
+ # @param codepoint [Integer]
33
+ # @return [Integer, nil]
34
+ def gid_for_codepoint(codepoint)
35
+ case @font
36
+ when Fontisan::Ufo::Font then ufo_gid_for(codepoint)
37
+ else bin_data_gid_for(codepoint)
38
+ end
39
+ end
40
+
41
+ # Extract a glyph by gid.
42
+ # @param gid [Integer]
43
+ # @return [Fontisan::Ufo::Glyph, nil]
44
+ def glyph_for_gid(gid)
45
+ case @font
46
+ when Fontisan::Ufo::Font then ufo_glyph_at(gid)
47
+ else converted_ufo_glyph_at(gid)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # ---------- UFO source ----------
54
+
55
+ def ufo_gid_for(codepoint)
56
+ @font.glyphs.each_with_index do |(_name, glyph), index|
57
+ return index if glyph.unicodes.include?(codepoint)
58
+ end
59
+ nil
60
+ end
61
+
62
+ def ufo_glyph_at(gid)
63
+ names = @font.glyphs.keys
64
+ name = names[gid]
65
+ return nil unless name
66
+
67
+ @font.glyph(name)
68
+ end
69
+
70
+ # ---------- TTF/OTF source ----------
71
+
72
+ def bin_data_gid_for(codepoint)
73
+ cmap = @font.table("cmap")
74
+ return nil unless cmap
75
+
76
+ cmap.unicode_mappings[codepoint]
77
+ end
78
+
79
+ # Lazily convert the loaded TTF/OTF to a UFO::Font, then
80
+ # extract glyphs from the cached UFO model.
81
+ def converted_ufo
82
+ return @ufo_cache if @ufo_cache
83
+
84
+ @ufo_cache = Fontisan::Ufo::Convert::FromBinData.convert(@font)
85
+ end
86
+
87
+ def converted_ufo_glyph_at(gid)
88
+ ufo = converted_ufo
89
+ names = ufo.glyphs.keys
90
+ name = names[gid]
91
+ return nil unless name
92
+
93
+ ufo.glyph(name)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Multi-source font stitcher. Combines glyphs from one or more
5
+ # source fonts (UFO or loaded TTF/OTF) into a single new font.
6
+ #
7
+ # The Stitcher builds a Fontisan::Ufo::Font from selected glyphs,
8
+ # then delegates compilation to the existing TtfCompiler or
9
+ # OtfCompiler. Single source of truth: one compiler pipeline,
10
+ # whether the input is one UFO or many sources.
11
+ #
12
+ # @example Stitch ASCII from one UFO, Hiragana from another
13
+ # stitcher = Fontisan::Stitcher.new
14
+ # stitcher.add_source(:latin, Fontisan::Ufo::Font.open("latin.ufo"))
15
+ # stitcher.add_source(:jp, Fontisan::Ufo::Font.open("jp.ufo"))
16
+ # stitcher.include_range(0x41..0x5A, from: :latin)
17
+ # stitcher.include_range(0x3040..0x309F, from: :jp)
18
+ # stitcher.write_to("stitched.ttf", format: :ttf)
19
+ class Stitcher
20
+ autoload :Source, "fontisan/stitcher/source"
21
+ autoload :Selector, "fontisan/stitcher/selector"
22
+
23
+ attr_reader :sources, :bindings
24
+
25
+ def initialize
26
+ @sources = {}
27
+ @bindings = []
28
+ @target = Ufo::Font.new
29
+ end
30
+
31
+ # Register a named source font.
32
+ # @param label [Symbol, String] name to reference this source by
33
+ # @param font [Fontisan::Ufo::Font, Fontisan::SfntFont] the source
34
+ def add_source(label, font)
35
+ @sources[label.to_sym] = Source.new(font)
36
+ end
37
+
38
+ # Include all codepoints in a range from a named source.
39
+ # @param range [Range<Integer>] codepoint range
40
+ # @param from [Symbol, String] source label
41
+ def include_range(range, from:)
42
+ Selector::Range.new(range).apply(source(from), @bindings)
43
+ end
44
+
45
+ # Include an explicit list of codepoints.
46
+ # @param codepoints [Array<Integer>]
47
+ # @param from [Symbol, String] source label
48
+ def include_codepoints(codepoints, from:)
49
+ Selector::Codepoints.new(codepoints).apply(source(from), @bindings)
50
+ end
51
+
52
+ # Include a single glyph by donor gid (rare; for unencoded glyphs
53
+ # like .notdef).
54
+ # @param donor_gid [Integer]
55
+ # @param from [Symbol, String] source label
56
+ def include_gid(donor_gid, from:)
57
+ Selector::Gid.new(donor_gid).apply(source(from), @bindings)
58
+ end
59
+
60
+ # Always include .notdef from a named source.
61
+ # @param from [Symbol, String] source label
62
+ def include_notdef(from:)
63
+ include_gid(0, from: from)
64
+ end
65
+
66
+ # Set font-wide metadata on the stitched font.
67
+ # @param info_hash [Hash] any subset of Fontisan::Ufo::Info fields
68
+ def set_info(info_hash)
69
+ @target.info = Ufo::Info.new(info_hash)
70
+ end
71
+
72
+ # Write the stitched font to disk.
73
+ # @param path [String] output file path
74
+ # @param format [Symbol] :ttf or :otf
75
+ def write_to(path, format: :ttf)
76
+ build_target_font
77
+ compiler = compiler_for(format)
78
+ compiler.new(@target).compile(output_path: path)
79
+ path
80
+ end
81
+
82
+ # Build the internal UFO::Font from the current bindings. Useful
83
+ # for testing or for further manipulation before writing.
84
+ # @return [Fontisan::Ufo::Font]
85
+ def build_target_font
86
+ @target = Ufo::Font.new
87
+ assign_gids_and_copy_glyphs
88
+ @target
89
+ end
90
+
91
+ private
92
+
93
+ def source(label)
94
+ @sources.fetch(label.to_sym) do
95
+ raise ArgumentError, "unknown source: #{label.inspect}"
96
+ end
97
+ end
98
+
99
+ def compiler_for(format)
100
+ case format.to_sym
101
+ when :ttf then Ufo::Compile::TtfCompiler
102
+ when :otf then Ufo::Compile::OtfCompiler
103
+ else
104
+ raise ArgumentError, "unknown format: #{format.inspect}"
105
+ end
106
+ end
107
+
108
+ # Walk bindings in codepoint order, assign sequential new gids,
109
+ # copy each glyph into the target font's default layer.
110
+ def assign_gids_and_copy_glyphs
111
+ # Always put .notdef at gid 0 first.
112
+ notdef_binding = @bindings.find { |b| b[:donor_gid].zero? }
113
+ if notdef_binding
114
+ copy_glyph_into(@target, name: ".notdef",
115
+ source: notdef_binding[:source],
116
+ donor_gid: 0)
117
+ else
118
+ # Synthesize an empty .notdef
119
+ @target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
120
+ end
121
+
122
+ sorted_bindings.each do |binding|
123
+ next if binding[:donor_gid].zero? # already handled
124
+
125
+ glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
126
+ next unless glyph
127
+
128
+ # If multiple codepoints map to the same glyph, only the first
129
+ # binding creates the glyph; subsequent ones add unicode entries.
130
+ if @target.glyphs.key?(glyph.name)
131
+ add_extra_unicode(glyph.name, binding[:codepoint])
132
+ else
133
+ copy_glyph_into(@target, name: glyph.name,
134
+ source: binding[:source],
135
+ donor_gid: binding[:donor_gid],
136
+ codepoint: binding[:codepoint])
137
+ end
138
+ end
139
+ end
140
+
141
+ # Bindings sorted by codepoint (nil codepoints come last).
142
+ def sorted_bindings
143
+ @bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
144
+ end
145
+
146
+ def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
147
+ original = source.glyph_for_gid(donor_gid)
148
+ return unless original
149
+
150
+ copy = clone_glyph(original, name: name)
151
+ copy.add_unicode(codepoint) if codepoint
152
+ target_font.layers.default_layer.add(copy)
153
+ end
154
+
155
+ def add_extra_unicode(glyph_name, codepoint)
156
+ return unless codepoint
157
+
158
+ glyph = @target.glyph(glyph_name)
159
+ glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
160
+ end
161
+
162
+ # Deep-copy a glyph with a new name. Used so multiple target
163
+ # glyphs can share the same source outline without aliasing.
164
+ def clone_glyph(original, name:)
165
+ copy = Ufo::Glyph.new(name: name)
166
+ copy.width = original.width
167
+ copy.height = original.height
168
+ original.contours.each { |c| copy.add_contour(clone_contour(c)) }
169
+ original.components.each { |c| copy.add_component(c) }
170
+ original.anchors.each { |a| copy.add_anchor(a) }
171
+ original.guidelines.each { |g| copy.add_guideline(g) }
172
+ copy
173
+ end
174
+
175
+ def clone_contour(original)
176
+ points = original.points.map do |p|
177
+ Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
178
+ end
179
+ Ufo::Contour.new(points)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Fontisan
6
+ # CLI subcommand for multi-source font stitching.
7
+ #
8
+ # fontisan stitch --source latin=PATH --source jp=PATH \
9
+ # --output out.ttf \
10
+ # --include-range latin=0x41-0x5A \
11
+ # --include-range jp=0x3040-0x309F
12
+ class StitcherCli < Thor
13
+ desc "stitch", "Stitch glyphs from multiple sources into one font"
14
+ method_option :source, type: :array, required: true,
15
+ desc: "Named source (name=path); repeatable"
16
+ method_option :include_range, type: :array, default: [],
17
+ desc: "Range to include (name=hex-hex); repeatable"
18
+ method_option :include_codepoints, type: :array, default: [],
19
+ desc: "Codepoint list (name=hex,hex,...); repeatable"
20
+ method_option :notdef_from, type: :string,
21
+ desc: "Source label to take .notdef from"
22
+ method_option :output, type: :string, required: true,
23
+ desc: "Output file path"
24
+ method_option :to, type: :string, default: "ttf",
25
+ desc: "Output format (ttf or otf)"
26
+
27
+ def stitch
28
+ stitcher = Stitcher.new
29
+
30
+ options[:source].each do |spec|
31
+ label, path = spec.split("=", 2)
32
+ font = load_source(path)
33
+ stitcher.add_source(label, font)
34
+ end
35
+
36
+ options[:include_range]&.each do |spec|
37
+ label, range_str = spec.split("=", 2)
38
+ lo, hi = parse_range(range_str)
39
+ stitcher.include_range(lo..hi, from: label)
40
+ end
41
+
42
+ options[:include_codepoints]&.each do |spec|
43
+ label, cps_str = spec.split("=", 2)
44
+ cps = cps_str.split(",").map { |h| Integer(h) }
45
+ stitcher.include_codepoints(cps, from: label)
46
+ end
47
+
48
+ stitcher.include_notdef(from: options[:notdef_from]) if options[:notdef_from]
49
+
50
+ stitcher.write_to(options[:output], format: options[:to].to_sym)
51
+ puts "wrote #{options[:output]} (#{File.size(options[:output])} bytes)"
52
+ end
53
+
54
+ private
55
+
56
+ def load_source(path)
57
+ if File.directory?(path) && File.exist?(File.join(path, "fontinfo.plist"))
58
+ Ufo::Font.open(path)
59
+ else
60
+ FontLoader.load(path)
61
+ end
62
+ end
63
+
64
+ def parse_range(str)
65
+ lo, hi = str.split("-", 2)
66
+ [Integer(lo), Integer(hi)]
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # A mark-attachment anchor on a glyph (used in GSUB/GPOS).
6
+ class Anchor
7
+ attr_reader :x, :y, :name, :identifier
8
+
9
+ def initialize(x:, y:, name: nil, identifier: nil)
10
+ @x = x
11
+ @y = y
12
+ @name = name
13
+ @identifier = identifier
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ # CLI subcommand for UFO source operations.
8
+ #
9
+ # fontisan ufo build font.ufo --output out.ttf [--format otf]
10
+ # fontisan ufo convert font.ttf font.ufo
11
+ # fontisan ufo validate font.ufo
12
+ class Cli < Thor
13
+ desc "build UFO", "Compile a UFO source to a binary font"
14
+ method_option :output, type: :string, required: true,
15
+ desc: "Output file path"
16
+ method_option :to, type: :string, default: "ttf",
17
+ desc: "Output format (ttf or otf)"
18
+ def build(ufo)
19
+ font = Font.open(ufo)
20
+ format_sym = (options[:to] || "ttf").to_s.downcase.to_sym
21
+ compiler =
22
+ case format_sym
23
+ when :ttf then Compile::TtfCompiler
24
+ when :otf then Compile::OtfCompiler
25
+ else
26
+ warn "unknown format: #{options[:to].inspect}"
27
+ exit 1
28
+ end
29
+ compiler.new(font).compile(output_path: options[:output])
30
+ puts "wrote #{options[:output]} (#{File.size(options[:output])} bytes)"
31
+ rescue Errno::ENOENT
32
+ warn "UFO not found: #{ufo}"
33
+ exit 1
34
+ end
35
+
36
+ desc "convert INPUT OUTPUT", "Convert between UFO and binary formats"
37
+ method_option :to, type: :string,
38
+ desc: "Override format detection (ttf, otf, ufo)"
39
+ def convert(input, output)
40
+ if ufo?(input)
41
+ font = Font.open(input)
42
+ format = options[:to] || File.extname(output).delete(".").downcase
43
+ compiler =
44
+ case format.to_sym
45
+ when :ttf then Compile::TtfCompiler
46
+ when :otf then Compile::OtfCompiler
47
+ else
48
+ warn "unsupported output format: #{format.inspect}"
49
+ exit 1
50
+ end
51
+ compiler.new(font).compile(output_path: output)
52
+ else
53
+ # Binary → UFO
54
+ loaded = Fontisan::FontLoader.load(input)
55
+ ufo = Convert::FromBinData.convert(loaded)
56
+ Writer.new(ufo).write(output)
57
+ end
58
+ puts "wrote #{output}"
59
+ end
60
+
61
+ desc "validate UFO", "Check a UFO source for structural issues"
62
+ def validate(ufo)
63
+ font = Font.open(ufo)
64
+ issues = []
65
+ issues << "no glyphs in default layer" if font.glyphs.empty?
66
+ issues << "no family name" unless font.info.family_name
67
+ issues << "unitsPerEm not set" unless font.info.units_per_em
68
+ issues << "missing .notdef glyph" unless font.glyph(".notdef")
69
+
70
+ if issues.empty?
71
+ puts "OK #{ufo}"
72
+ else
73
+ issues.each { |i| warn "FAIL #{i}" }
74
+ exit 1
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def ufo?(path)
81
+ File.directory?(path) && File.exist?(File.join(path, "fontinfo.plist"))
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ module Compile
8
+ # Common orchestrator for TTF and OTF compilers.
9
+ # Subclasses implement `#build_outline_tables` (returning the
10
+ # format-specific table set: glyf+loca for TTF, CFF for OTF)
11
+ # and `sfnt_version` (0x00010000 for TTF, 0x4F54544F for OTF).
12
+ class BaseCompiler
13
+ SFNT_VERSION_TRUE_TYPE = 0x00010000
14
+ SFNT_VERSION_OPEN_TYPE = 0x4F54544F # "OTTO"
15
+
16
+ attr_reader :font
17
+
18
+ def initialize(font)
19
+ @font = font
20
+ end
21
+
22
+ # @param output_path [String] where to write the binary font
23
+ # @return [String] the path
24
+ def compile(output_path:)
25
+ tables = build_tables
26
+ write(tables, output_path)
27
+ output_path
28
+ end
29
+
30
+ # Format-specific extra tables. Override in subclasses.
31
+ # @return [Hash<String, #to_binary_s, String>]
32
+ def build_outline_tables
33
+ {}
34
+ end
35
+
36
+ # @return [Integer] 0x00010000 (TTF) or 0x4F54544F (OTF)
37
+ def sfnt_version
38
+ self.class::SFNT_VERSION
39
+ end
40
+
41
+ private
42
+
43
+ # All tables every OTF/TTF must have.
44
+ def build_tables
45
+ glyphs = font.glyphs.values
46
+ {
47
+ "head" => Head.build(font, glyphs: glyphs),
48
+ "hhea" => Hhea.build(font, glyphs: glyphs),
49
+ "maxp" => Maxp.build(font, glyphs: glyphs),
50
+ "OS/2" => Os2.build(font, glyphs: glyphs),
51
+ "name" => Name.build(font),
52
+ "post" => Post.build(font, glyphs: glyphs),
53
+ "hmtx" => Hmtx.build(font, glyphs: glyphs),
54
+ "cmap" => Cmap.build(font, glyphs: glyphs),
55
+ }.merge(build_outline_tables)
56
+ end
57
+
58
+ def write(tables_hash, output_path)
59
+ dir = File.dirname(output_path)
60
+ FileUtils.mkpath(dir) unless dir == "."
61
+
62
+ Fontisan::FontWriter.write_to_file(
63
+ tables_hash.transform_values { |t| serialize_table(t) },
64
+ output_path,
65
+ sfnt_version: sfnt_version,
66
+ )
67
+ end
68
+
69
+ # BinData records (Tables::*) respond to to_binary_s; raw
70
+ # String values pass through. We branch on class identity
71
+ # rather than `respond_to?` to keep the type system honest.
72
+ def serialize_table(table)
73
+ case table
74
+ when String then table
75
+ else table.to_binary_s
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end