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.
- checksums.yaml +4 -4
- data/.rubocop.yml +6 -0
- data/.rubocop_todo.yml +93 -17
- data/CHANGELOG.md +12 -2
- data/README.adoc +6 -210
- data/fontisan.gemspec +48 -0
- data/lib/fontisan/cldr/unicode_set_parser.rb +23 -6
- data/lib/fontisan/cldr/version_resolver.rb +1 -1
- data/lib/fontisan/cli.rb +4 -168
- data/lib/fontisan/commands.rb +0 -3
- data/lib/fontisan/formatters/text_formatter.rb +0 -6
- data/lib/fontisan/formatters.rb +0 -3
- data/lib/fontisan/hints.rb +6 -3
- data/lib/fontisan/models.rb +4 -4
- data/lib/fontisan/pipeline/strategies.rb +4 -2
- data/lib/fontisan/pipeline.rb +2 -1
- data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
- data/lib/fontisan/stitcher/selector/gid.rb +25 -0
- data/lib/fontisan/stitcher/selector/range.rb +30 -0
- data/lib/fontisan/stitcher/selector.rb +26 -0
- data/lib/fontisan/stitcher/source.rb +97 -0
- data/lib/fontisan/stitcher.rb +182 -0
- data/lib/fontisan/stitcher_cli.rb +69 -0
- data/lib/fontisan/tables/cff.rb +2 -1
- data/lib/fontisan/tables.rb +2 -1
- data/lib/fontisan/ufo/anchor.rb +17 -0
- data/lib/fontisan/ufo/cli.rb +85 -0
- data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
- data/lib/fontisan/ufo/compile/cff.rb +224 -0
- data/lib/fontisan/ufo/compile/cmap.rb +129 -0
- data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
- data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
- data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
- data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
- data/lib/fontisan/ufo/compile/filters.rb +57 -0
- data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
- data/lib/fontisan/ufo/compile/head.rb +98 -0
- data/lib/fontisan/ufo/compile/hhea.rb +36 -0
- data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
- data/lib/fontisan/ufo/compile/maxp.rb +57 -0
- data/lib/fontisan/ufo/compile/name.rb +79 -0
- data/lib/fontisan/ufo/compile/os2.rb +81 -0
- data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
- data/lib/fontisan/ufo/compile/post.rb +32 -0
- data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
- data/lib/fontisan/ufo/compile.rb +48 -0
- data/lib/fontisan/ufo/component.rb +18 -0
- data/lib/fontisan/ufo/contour.rb +29 -0
- data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
- data/lib/fontisan/ufo/convert.rb +18 -0
- data/lib/fontisan/ufo/data_set.rb +21 -0
- data/lib/fontisan/ufo/features.rb +17 -0
- data/lib/fontisan/ufo/font.rb +61 -0
- data/lib/fontisan/ufo/glyph.rb +421 -0
- data/lib/fontisan/ufo/guideline.rb +19 -0
- data/lib/fontisan/ufo/image.rb +16 -0
- data/lib/fontisan/ufo/image_set.rb +19 -0
- data/lib/fontisan/ufo/info.rb +79 -0
- data/lib/fontisan/ufo/kerning.rb +32 -0
- data/lib/fontisan/ufo/layer.rb +38 -0
- data/lib/fontisan/ufo/layer_set.rb +37 -0
- data/lib/fontisan/ufo/lib.rb +24 -0
- data/lib/fontisan/ufo/plist.rb +118 -0
- data/lib/fontisan/ufo/point.rb +38 -0
- data/lib/fontisan/ufo/reader.rb +144 -0
- data/lib/fontisan/ufo/transformation.rb +39 -0
- data/lib/fontisan/ufo/writer.rb +115 -0
- data/lib/fontisan/ufo.rb +44 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +3 -3
- metadata +57 -70
- data/lib/fontisan/audit/codepoint_range_coalescer.rb +0 -41
- data/lib/fontisan/audit/context.rb +0 -122
- data/lib/fontisan/audit/differ.rb +0 -124
- data/lib/fontisan/audit/extractors/aggregations.rb +0 -54
- data/lib/fontisan/audit/extractors/base.rb +0 -26
- data/lib/fontisan/audit/extractors/color_capabilities.rb +0 -141
- data/lib/fontisan/audit/extractors/coverage.rb +0 -48
- data/lib/fontisan/audit/extractors/hinting.rb +0 -197
- data/lib/fontisan/audit/extractors/identity.rb +0 -52
- data/lib/fontisan/audit/extractors/language_coverage.rb +0 -37
- data/lib/fontisan/audit/extractors/licensing.rb +0 -79
- data/lib/fontisan/audit/extractors/metrics.rb +0 -103
- data/lib/fontisan/audit/extractors/opentype_layout.rb +0 -69
- data/lib/fontisan/audit/extractors/provenance.rb +0 -29
- data/lib/fontisan/audit/extractors/style.rb +0 -32
- data/lib/fontisan/audit/extractors/variation_detail.rb +0 -99
- data/lib/fontisan/audit/extractors.rb +0 -27
- data/lib/fontisan/audit/library_aggregator.rb +0 -83
- data/lib/fontisan/audit/library_auditor.rb +0 -90
- data/lib/fontisan/audit/registry.rb +0 -60
- data/lib/fontisan/audit/style_extractor.rb +0 -80
- data/lib/fontisan/audit.rb +0 -20
- data/lib/fontisan/cli/ucd_cli.rb +0 -97
- data/lib/fontisan/commands/audit_command.rb +0 -123
- data/lib/fontisan/commands/audit_compare_command.rb +0 -66
- data/lib/fontisan/commands/audit_library_command.rb +0 -46
- data/lib/fontisan/config/ucd.yml +0 -23
- data/lib/fontisan/formatters/audit_diff_text_renderer.rb +0 -122
- data/lib/fontisan/formatters/audit_text_renderer.rb +0 -324
- data/lib/fontisan/formatters/library_summary_text_renderer.rb +0 -99
- data/lib/fontisan/models/audit/audit_axis.rb +0 -30
- data/lib/fontisan/models/audit/audit_block.rb +0 -32
- data/lib/fontisan/models/audit/audit_diff.rb +0 -77
- data/lib/fontisan/models/audit/audit_report.rb +0 -153
- data/lib/fontisan/models/audit/codepoint_range.rb +0 -40
- data/lib/fontisan/models/audit/codepoint_set_diff.rb +0 -34
- data/lib/fontisan/models/audit/color_capabilities.rb +0 -93
- data/lib/fontisan/models/audit/duplicate_group.rb +0 -23
- data/lib/fontisan/models/audit/embedding_type.rb +0 -76
- data/lib/fontisan/models/audit/field_change.rb +0 -28
- data/lib/fontisan/models/audit/fs_selection_flags.rb +0 -61
- data/lib/fontisan/models/audit/gasp_range.rb +0 -63
- data/lib/fontisan/models/audit/hinting.rb +0 -93
- data/lib/fontisan/models/audit/library_summary.rb +0 -40
- data/lib/fontisan/models/audit/licensing.rb +0 -48
- data/lib/fontisan/models/audit/metrics.rb +0 -111
- data/lib/fontisan/models/audit/named_instance.rb +0 -41
- data/lib/fontisan/models/audit/opentype_layout.rb +0 -40
- data/lib/fontisan/models/audit/script_coverage_row.rb +0 -26
- data/lib/fontisan/models/audit/script_features.rb +0 -28
- data/lib/fontisan/models/audit/variation_detail.rb +0 -44
- data/lib/fontisan/models/audit.rb +0 -33
- data/lib/fontisan/models/ucd/ucd.rb +0 -38
- data/lib/fontisan/models/ucd/ucd_char.rb +0 -67
- data/lib/fontisan/models/ucd.rb +0 -19
- data/lib/fontisan/ucd/aggregator.rb +0 -73
- data/lib/fontisan/ucd/cache_manager.rb +0 -111
- data/lib/fontisan/ucd/config.rb +0 -59
- data/lib/fontisan/ucd/download_error.rb +0 -9
- data/lib/fontisan/ucd/downloader.rb +0 -88
- data/lib/fontisan/ucd/error.rb +0 -8
- data/lib/fontisan/ucd/index.rb +0 -103
- data/lib/fontisan/ucd/index_builder.rb +0 -107
- data/lib/fontisan/ucd/range_entry.rb +0 -56
- data/lib/fontisan/ucd/unknown_version_error.rb +0 -9
- data/lib/fontisan/ucd/version_resolver.rb +0 -79
- 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
|
data/lib/fontisan/tables/cff.rb
CHANGED
|
@@ -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,
|
|
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"
|
data/lib/fontisan/tables.rb
CHANGED
|
@@ -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,
|
|
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
|