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,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
@@ -54,7 +54,8 @@ module Fontisan
54
54
  autoload :DictBuilder, "fontisan/tables/cff/dict_builder"
55
55
  autoload :Encoding, "fontisan/tables/cff/encoding"
56
56
  autoload :Header, "fontisan/tables/cff/header"
57
- autoload :HintOperationInjector, "fontisan/tables/cff/hint_operation_injector"
57
+ autoload :HintOperationInjector,
58
+ "fontisan/tables/cff/hint_operation_injector"
58
59
  autoload :Index, "fontisan/tables/cff/index"
59
60
  autoload :IndexBuilder, "fontisan/tables/cff/index_builder"
60
61
  autoload :OffsetRecalculator, "fontisan/tables/cff/offset_recalculator"
@@ -12,7 +12,8 @@ module Fontisan
12
12
  autoload :CmapTable, "fontisan/tables/cmap_table"
13
13
  autoload :Colr, "fontisan/tables/colr"
14
14
  autoload :CompoundGlyph, "fontisan/tables/glyf/compound_glyph"
15
- autoload :CompoundGlyphResolver, "fontisan/tables/glyf/compound_glyph_resolver"
15
+ autoload :CompoundGlyphResolver,
16
+ "fontisan/tables/glyf/compound_glyph_resolver"
16
17
  autoload :Cpal, "fontisan/tables/cpal"
17
18
  autoload :CurveConverter, "fontisan/tables/glyf/curve_converter"
18
19
  autoload :Cvar, "fontisan/tables/cvar"
@@ -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
@@ -0,0 +1,224 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ module Compile
8
+ # Builds the OpenType `CFF ` table from UFO glyphs.
9
+ #
10
+ # Pipeline:
11
+ # 1. Convert each UFO glyph's contours to a Models::Outline
12
+ # (cubic Bezier commands).
13
+ # 2. Encode each outline as a Type 2 charstring via the
14
+ # existing `Tables::Cff::CharStringBuilder`.
15
+ # 3. Build the CFF structural INDEXes (Name, Top DICT, String,
16
+ # Global Subr, CharStrings) with correct offsets.
17
+ #
18
+ # The Top DICT references absolute offsets to the charset,
19
+ # charstrings, and private dict. Those offsets depend on the
20
+ # size of everything that comes before them — including the Top
21
+ # DICT itself. We resolve the circular dependency with a
22
+ # fixed-point iteration: build the Top DICT, compute offsets,
23
+ # rebuild the Top DICT with the new offsets, repeat until it
24
+ # converges (typically 2 iterations).
25
+ module Cff
26
+ # @param font [Fontisan::Ufo::Font]
27
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
28
+ # @return [String] CFF table bytes
29
+ def self.build(font, glyphs:)
30
+ name = font.info.postscript_font_name || font.info.family_name || "Untitled"
31
+ charstrings = glyphs.map { |g| charstring_for(g) }
32
+ private_dict = +""
33
+
34
+ layout = compute_layout(name: name, glyphs: glyphs,
35
+ charstrings: charstrings,
36
+ private_dict: private_dict)
37
+
38
+ assemble(layout: layout)
39
+ end
40
+
41
+ # ---------- charstring per-glyph ----------
42
+
43
+ def self.charstring_for(glyph)
44
+ return empty_charstring(glyph.width.to_i) if glyph.contours.empty?
45
+
46
+ builder = Fontisan::Tables::Cff::CharStringBuilder.new
47
+ builder.build(glyph.to_outline, width: glyph.width.to_i)
48
+ rescue StandardError
49
+ # Any failure (e.g., too-short contours, unsupported curve
50
+ # combination): fall back to an empty charstring so the
51
+ # INDEX stays valid.
52
+ empty_charstring(glyph.width.to_i)
53
+ end
54
+
55
+ def self.empty_charstring(width)
56
+ Fontisan::Tables::Cff::CharStringBuilder.build_empty(width: width.zero? ? nil : width)
57
+ end
58
+
59
+ # ---------- layout ----------
60
+
61
+ # Compute the byte offsets of every CFF structural section.
62
+ # Iterates until the Top DICT's encoded size stabilizes.
63
+ def self.compute_layout(name:, glyphs:, charstrings:, private_dict:)
64
+ name_index = index_bytes([name.b])
65
+ string_index = index_bytes([])
66
+ global_subr_index = index_bytes([])
67
+ charset = charset_bytes(glyphs)
68
+ charstrings_index = index_bytes(charstrings)
69
+
70
+ # First guess: Top DICT encoded with all-zero offsets.
71
+ top_dict = top_dict_bytes(0, 0, 0, 0)
72
+ top_dict_index = index_bytes([top_dict])
73
+
74
+ charset_offset = 0
75
+ charstrings_offset = 0
76
+ private_offset = 0
77
+
78
+ 10.times do
79
+ header_size = 4
80
+ post_top_dict = header_size + name_index.bytesize +
81
+ top_dict_index.bytesize + string_index.bytesize +
82
+ global_subr_index.bytesize
83
+
84
+ charset_offset = post_top_dict
85
+ charstrings_offset = charset_offset + charset.bytesize
86
+ private_offset = charstrings_offset + charstrings_index.bytesize
87
+
88
+ new_top_dict = top_dict_bytes(
89
+ charset_offset,
90
+ charstrings_offset,
91
+ private_dict.bytesize,
92
+ private_offset,
93
+ )
94
+ new_top_dict_index = index_bytes([new_top_dict])
95
+
96
+ if new_top_dict_index.bytesize == top_dict_index.bytesize
97
+ # Converged. Use the freshly-encoded Top DICT so the
98
+ # layout hash is self-consistent with the offsets.
99
+ top_dict_index = new_top_dict_index
100
+ break
101
+ end
102
+
103
+ top_dict_index = new_top_dict_index
104
+ end
105
+
106
+ {
107
+ name_index: name_index,
108
+ top_dict_index: top_dict_index,
109
+ string_index: string_index,
110
+ global_subr_index: global_subr_index,
111
+ charset_offset: charset_offset,
112
+ charstrings_offset: charstrings_offset,
113
+ private_offset: private_offset,
114
+ charset: charset,
115
+ charstrings_index: charstrings_index,
116
+ private_dict: private_dict,
117
+ }
118
+ end
119
+
120
+ # ---------- byte emission ----------
121
+
122
+ def self.assemble(layout:)
123
+ io = StringIO.new("".b)
124
+
125
+ io.write([1, 0, 4, 1].pack("CCCC")) # CFF header (v1.0, hdrSize=4, offSize=1)
126
+
127
+ io.write(layout[:name_index])
128
+ io.write(layout[:top_dict_index])
129
+ io.write(layout[:string_index])
130
+ io.write(layout[:global_subr_index])
131
+
132
+ io.write(layout[:charset])
133
+ io.write(layout[:charstrings_index])
134
+ io.write(layout[:private_dict])
135
+
136
+ io.string
137
+ end
138
+
139
+ # Top DICT bytes with the three standard offset operators.
140
+ # Operators: charset(15), CharStrings(17), Private(18).
141
+ # Encoding (operator 16) is implicit for OpenType fonts.
142
+ def self.top_dict_bytes(charset_offset, charstrings_offset,
143
+ private_size, private_offset)
144
+ bytes = +""
145
+ bytes << encode_int(charset_offset) << "\x0f" # charset
146
+ bytes << encode_int(charstrings_offset) << "\x11" # CharStrings
147
+ bytes << encode_int(private_size) << encode_int(private_offset) << "\x12" # Private
148
+ bytes
149
+ end
150
+
151
+ # Format 0 charset: 1 format byte + (n-1) SIDs.
152
+ # Gid 0 is implicit .notdef.
153
+ def self.charset_bytes(glyphs)
154
+ return +"" if glyphs.size <= 1
155
+
156
+ bytes = +"\x00"
157
+ (1...glyphs.size).each { |_| bytes << [0].pack("n") } # SID 0 placeholder
158
+ bytes
159
+ end
160
+
161
+ # ---------- INDEX helpers ----------
162
+
163
+ def self.index_bytes(items)
164
+ io = StringIO.new("".b)
165
+ if items.empty?
166
+ io.write([0].pack("n"))
167
+ return io.string
168
+ end
169
+
170
+ count = items.size
171
+ offsets = [1]
172
+ items.each { |item| offsets << offsets.last + item.bytesize }
173
+ max_offset = offsets.last
174
+ off_size = byte_size_for(max_offset)
175
+
176
+ io.write([count, off_size].pack("nC"))
177
+ offsets.each { |o| io.write(pack_offset(o, off_size)) }
178
+ items.each { |item| io.write(item) }
179
+ io.string
180
+ end
181
+
182
+ def self.byte_size_for(max_value)
183
+ return 1 if max_value <= 0xFF
184
+ return 2 if max_value <= 0xFFFF
185
+ return 3 if max_value <= 0xFFFFFF
186
+
187
+ 4
188
+ end
189
+
190
+ def self.pack_offset(value, size)
191
+ case size
192
+ when 1 then [value].pack("C")
193
+ when 2 then [value].pack("n")
194
+ when 3
195
+ [(value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF].pack("CCC")
196
+ when 4 then [value].pack("N")
197
+ end
198
+ end
199
+
200
+ # CFF DICT integer encoding (CFF spec section 3.1).
201
+ def self.encode_int(value)
202
+ if value.between?(-107, 107)
203
+ [value + 139].pack("C")
204
+ elsif value.between?(108, 1131)
205
+ v = value - 108
206
+ [(v / 256) + 247, v % 256].pack("CC")
207
+ elsif value.between?(-1131, -108)
208
+ v = -value - 108
209
+ [-(v / 256) - 247, v % 256].pack("CC")
210
+ elsif value.between?(-32_768, 32_767)
211
+ [28, value].pack("Cn")
212
+ else
213
+ [29, value].pack("CN")
214
+ end
215
+ end
216
+
217
+ private_class_method :charstring_for, :empty_charstring,
218
+ :compute_layout, :assemble, :top_dict_bytes,
219
+ :charset_bytes, :index_bytes, :byte_size_for,
220
+ :pack_offset, :encode_int
221
+ end
222
+ end
223
+ end
224
+ end