fontisan 0.4.6 → 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.
- checksums.yaml +4 -4
- data/BUG-stitcher-drops-isolated-cps.md +58 -0
- data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
- data/BUG-stitcher-gid-cap-65535.md +110 -0
- data/CHANGELOG.md +106 -0
- data/README.adoc +121 -68
- data/benchmark/compile_benchmark.rb +70 -0
- data/docs/CFF2_SUPPORT.adoc +184 -0
- data/docs/STITCHER_GUIDE.adoc +151 -0
- data/docs/SVG_TO_GLYF.adoc +118 -0
- data/docs/UFO_COMPILATION.adoc +119 -0
- data/lib/fontisan/collection/writer.rb +5 -6
- data/lib/fontisan/error.rb +31 -0
- data/lib/fontisan/stitcher/deduplicator.rb +47 -0
- data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
- data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
- data/lib/fontisan/stitcher.rb +188 -167
- data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
- data/lib/fontisan/svg_to_glyf/document.rb +83 -0
- data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
- data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
- data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
- data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
- data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
- data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
- data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
- data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
- data/lib/fontisan/svg_to_glyf/path.rb +14 -0
- data/lib/fontisan/svg_to_glyf.rb +62 -0
- data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
- data/lib/fontisan/tables/cff.rb +1 -0
- data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
- data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
- data/lib/fontisan/tables/cff2/header.rb +34 -0
- data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
- data/lib/fontisan/tables/cff2.rb +4 -0
- data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
- data/lib/fontisan/ufo/compile/cff2.rb +181 -0
- data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
- data/lib/fontisan/ufo/compile/colr.rb +80 -0
- data/lib/fontisan/ufo/compile/cpal.rb +61 -0
- data/lib/fontisan/ufo/compile/math.rb +143 -0
- data/lib/fontisan/ufo/compile/meta.rb +51 -0
- data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
- data/lib/fontisan/ufo/compile/sbix.rb +99 -0
- data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
- data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
- data/lib/fontisan/ufo/compile.rb +11 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +3 -0
- metadata +41 -2
data/lib/fontisan/stitcher.rb
CHANGED
|
@@ -1,102 +1,101 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Fontisan
|
|
4
|
-
# Multi-source font stitcher
|
|
5
|
-
# source fonts (UFO or loaded TTF/OTF) into a single new font.
|
|
4
|
+
# Multi-source font stitcher with explicit subfont declaration.
|
|
6
5
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
6
|
+
# Every set of codepoints is explicitly assigned to a named subfont
|
|
7
|
+
# via the required `into:` keyword. The user controls the collection
|
|
8
|
+
# structure upfront — there are no defaults and no after-the-fact
|
|
9
|
+
# splitting.
|
|
11
10
|
#
|
|
12
|
-
#
|
|
11
|
+
# Single-font output: `write_to` requires a `subfont:` name.
|
|
12
|
+
# Collection output: `write_collection` writes all declared subfonts.
|
|
13
|
+
#
|
|
14
|
+
# @example Single font
|
|
13
15
|
# stitcher = Fontisan::Stitcher.new
|
|
14
16
|
# stitcher.add_source(:latin, Fontisan::Ufo::Font.open("latin.ufo"))
|
|
15
|
-
# stitcher.
|
|
16
|
-
# stitcher.
|
|
17
|
-
#
|
|
18
|
-
#
|
|
17
|
+
# stitcher.include_range(0x41..0x5A, from: :latin, into: :main)
|
|
18
|
+
# stitcher.write_to("out.ttf", format: :ttf, subfont: :main)
|
|
19
|
+
#
|
|
20
|
+
# @example Collection
|
|
21
|
+
# stitcher = Fontisan::Stitcher.new
|
|
22
|
+
# stitcher.add_source(:noto_sans, noto_sans)
|
|
23
|
+
# stitcher.add_source(:noto_cjk, noto_cjk)
|
|
24
|
+
# stitcher.include_range(0x41..0x5A, from: :noto_sans, into: :latin)
|
|
25
|
+
# stitcher.include_range(0x4E00..0x9FFF, from: :noto_cjk, into: :cjk)
|
|
26
|
+
# stitcher.write_collection("out.otc", format: :otf2)
|
|
19
27
|
class Stitcher
|
|
20
|
-
autoload :Source,
|
|
21
|
-
autoload :Selector,
|
|
28
|
+
autoload :Source, "fontisan/stitcher/source"
|
|
29
|
+
autoload :Selector, "fontisan/stitcher/selector"
|
|
30
|
+
autoload :GlyphSignature, "fontisan/stitcher/glyph_signature"
|
|
31
|
+
autoload :Deduplicator, "fontisan/stitcher/deduplicator"
|
|
32
|
+
autoload :GlyphLimit, "fontisan/stitcher/glyph_limit"
|
|
22
33
|
|
|
23
|
-
|
|
34
|
+
DEFAULT_DEDUPLICATE = true
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
attr_reader :sources, :subfonts, :info
|
|
37
|
+
|
|
38
|
+
def initialize(deduplicate: DEFAULT_DEDUPLICATE)
|
|
26
39
|
@sources = {}
|
|
27
|
-
@
|
|
28
|
-
@
|
|
40
|
+
@subfonts = Hash.new { |h, k| h[k] = [] }
|
|
41
|
+
@info = nil
|
|
42
|
+
@deduplicate = deduplicate
|
|
29
43
|
end
|
|
30
44
|
|
|
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
45
|
def add_source(label, font)
|
|
35
46
|
@sources[label.to_sym] = Source.new(font)
|
|
36
47
|
end
|
|
37
48
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
# @param from [Symbol, String] source label
|
|
41
|
-
def include_range(range, from:)
|
|
42
|
-
Selector::Range.new(range).apply(source(from), @bindings)
|
|
49
|
+
def include_range(range, from:, into:)
|
|
50
|
+
Selector::Range.new(range).apply(source(from), @subfonts[into])
|
|
43
51
|
end
|
|
44
52
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
# @param from [Symbol, String] source label
|
|
48
|
-
def include_codepoints(codepoints, from:)
|
|
49
|
-
Selector::Codepoints.new(codepoints).apply(source(from), @bindings)
|
|
53
|
+
def include_codepoints(codepoints, from:, into:)
|
|
54
|
+
Selector::Codepoints.new(codepoints).apply(source(from), @subfonts[into])
|
|
50
55
|
end
|
|
51
56
|
|
|
52
|
-
|
|
53
|
-
|
|
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)
|
|
57
|
+
def include_gid(donor_gid, from:, into:)
|
|
58
|
+
Selector::Gid.new(donor_gid).apply(source(from), @subfonts[into])
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def include_notdef(from:)
|
|
63
|
-
include_gid(0, from: from)
|
|
61
|
+
def include_notdef(from:, into:)
|
|
62
|
+
include_gid(0, from: from, into: into)
|
|
64
63
|
end
|
|
65
64
|
|
|
66
|
-
# Set font-wide metadata on the stitched font.
|
|
67
|
-
# @param info_hash [Hash] any subset of Fontisan::Ufo::Info fields
|
|
68
65
|
def set_info(info_hash)
|
|
69
|
-
@
|
|
66
|
+
@info = Ufo::Info.new(info_hash)
|
|
70
67
|
end
|
|
71
68
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
69
|
+
def subfont_names
|
|
70
|
+
@subfonts.keys
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def build_target_font(subfont:)
|
|
74
|
+
build_target_for(subfont)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def write_to(path, format:, subfont:)
|
|
78
|
+
target = build_target_for(subfont)
|
|
79
|
+
GlyphLimit.check!(target.glyphs.size, format: format)
|
|
80
|
+
|
|
84
81
|
compiler = compiler_for(format)
|
|
85
|
-
|
|
86
|
-
compiler_instance.compile(output_path: path)
|
|
82
|
+
compiler.new(target).compile(output_path: path)
|
|
87
83
|
|
|
88
84
|
propagate_cbdt_tables(path) if cbdt_source
|
|
89
|
-
|
|
90
85
|
path
|
|
91
86
|
end
|
|
92
87
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
88
|
+
def write_collection(path, format:)
|
|
89
|
+
raise ArgumentError, "no subfonts declared" if @subfonts.empty?
|
|
90
|
+
|
|
91
|
+
compiled = @subfonts.keys.map do |name|
|
|
92
|
+
compile_subfont_to_loaded_font(name, format: format)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
collection_format = collection_format_for(format)
|
|
96
|
+
Collection::Builder.new(compiled, format: collection_format,
|
|
97
|
+
optimize: true).build_to_file(path)
|
|
98
|
+
path
|
|
100
99
|
end
|
|
101
100
|
|
|
102
101
|
private
|
|
@@ -107,9 +106,6 @@ module Fontisan
|
|
|
107
106
|
end
|
|
108
107
|
end
|
|
109
108
|
|
|
110
|
-
# Find the single CBDT source among registered sources, if any.
|
|
111
|
-
# Raises if more than one CBDT source is present (merge not supported).
|
|
112
|
-
# @return [Source, nil]
|
|
113
109
|
def cbdt_source
|
|
114
110
|
cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
|
|
115
111
|
if cbdts.size > 1
|
|
@@ -120,87 +116,93 @@ module Fontisan
|
|
|
120
116
|
cbdts.first
|
|
121
117
|
end
|
|
122
118
|
|
|
123
|
-
# Copy raw CBDT + CBLC table bytes from the CBDT source into the
|
|
124
|
-
# compiled output file. The GIDs must match (CBDT glyphs are at
|
|
125
|
-
# the same GIDs in both source and output because they were added
|
|
126
|
-
# first during build_target_font).
|
|
127
|
-
def propagate_cbdt_tables(path)
|
|
128
|
-
source = cbdt_source
|
|
129
|
-
return unless source
|
|
130
|
-
|
|
131
|
-
compiled = Fontisan::FontLoader.load(path)
|
|
132
|
-
|
|
133
|
-
tables = {}
|
|
134
|
-
compiled.table_names.each do |tag|
|
|
135
|
-
raw = extract_raw_table(compiled, tag)
|
|
136
|
-
tables[tag] = raw if raw
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
cbdt_bytes = source.raw_table_bytes("CBDT")
|
|
140
|
-
cblc_bytes = source.raw_table_bytes("CBLC")
|
|
141
|
-
tables["CBDT"] = cbdt_bytes if cbdt_bytes
|
|
142
|
-
tables["CBLC"] = cblc_bytes if cblc_bytes
|
|
143
|
-
|
|
144
|
-
sfnt = tables.key?("CFF ") ? 0x4F54544F : 0x00010000
|
|
145
|
-
Fontisan::FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def extract_raw_table(font, tag)
|
|
149
|
-
sfnt_table = font.table(tag)
|
|
150
|
-
return nil unless sfnt_table
|
|
151
|
-
|
|
152
|
-
sfnt_table.raw_data
|
|
153
|
-
rescue StandardError
|
|
154
|
-
nil
|
|
155
|
-
end
|
|
156
|
-
|
|
157
119
|
def compiler_for(format)
|
|
158
120
|
case format.to_sym
|
|
159
121
|
when :ttf then Ufo::Compile::TtfCompiler
|
|
160
122
|
when :otf then Ufo::Compile::OtfCompiler
|
|
123
|
+
when :otf2 then Ufo::Compile::Otf2Compiler
|
|
161
124
|
else
|
|
162
125
|
raise ArgumentError, "unknown format: #{format.inspect}"
|
|
163
126
|
end
|
|
164
127
|
end
|
|
165
128
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
129
|
+
def collection_format_for(subfont_format)
|
|
130
|
+
subfont_format == :ttf ? :ttc : :otc
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_target_for(subfont_name)
|
|
134
|
+
bindings = @subfonts[subfont_name] || []
|
|
135
|
+
target = Ufo::Font.new
|
|
136
|
+
target.info = @info ? @info.dup : Ufo::Info.new
|
|
137
|
+
dedup = @deduplicate ? Deduplicator.new : nil
|
|
138
|
+
assign_gids_and_copy_glyphs(bindings, target, dedup)
|
|
139
|
+
target
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def compile_subfont_to_loaded_font(subfont_name, format:)
|
|
143
|
+
target = build_target_for(subfont_name)
|
|
144
|
+
GlyphLimit.check!(target.glyphs.size, format: format)
|
|
145
|
+
|
|
146
|
+
ext = format == :ttf ? ".ttf" : ".otf"
|
|
147
|
+
Dir.mktmpdir do |dir|
|
|
148
|
+
sub_path = File.join(dir, "sub#{subfont_name}#{ext}")
|
|
149
|
+
compiler = compiler_for(format)
|
|
150
|
+
compiler.new(target).compile(output_path: sub_path)
|
|
151
|
+
return Fontisan::FontLoader.load(sub_path)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def assign_gids_and_copy_glyphs(bindings, target, deduplicator)
|
|
156
|
+
cbdt = safe_cbdt_source
|
|
174
157
|
|
|
175
158
|
if cbdt
|
|
176
|
-
add_all_cbdt_glyphs(cbdt)
|
|
159
|
+
add_all_cbdt_glyphs(cbdt, target)
|
|
177
160
|
else
|
|
178
|
-
|
|
161
|
+
add_notdef_from(bindings, target, deduplicator)
|
|
179
162
|
end
|
|
180
163
|
|
|
181
|
-
sorted_bindings.each do |binding|
|
|
164
|
+
sorted_bindings(bindings).each do |binding|
|
|
182
165
|
next if binding[:donor_gid].zero?
|
|
183
|
-
|
|
184
|
-
# Skip bindings from the CBDT source — its glyphs are already added.
|
|
185
166
|
next if cbdt && binding[:source].equal?(cbdt)
|
|
186
167
|
|
|
187
168
|
glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
|
|
188
169
|
next unless glyph
|
|
189
170
|
|
|
190
|
-
|
|
191
|
-
|
|
171
|
+
canonical = deduplicator&.find(glyph)
|
|
172
|
+
if canonical && target.glyphs.key?(canonical)
|
|
173
|
+
add_extra_unicode(target, canonical, binding[:codepoint])
|
|
192
174
|
else
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
175
|
+
name = unique_target_name(target, glyph.name)
|
|
176
|
+
copy_glyph_into(target, name: name, source: binding[:source],
|
|
177
|
+
donor_gid: binding[:donor_gid],
|
|
178
|
+
codepoint: binding[:codepoint])
|
|
179
|
+
deduplicator&.register(glyph, name)
|
|
197
180
|
end
|
|
198
181
|
end
|
|
199
182
|
end
|
|
200
183
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
184
|
+
def safe_cbdt_source
|
|
185
|
+
cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
|
|
186
|
+
cbdts.size == 1 ? cbdts.first : nil
|
|
187
|
+
rescue MultipleCbdtSourcesError
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def sorted_bindings(bindings)
|
|
192
|
+
bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def add_notdef_from(bindings, target, deduplicator)
|
|
196
|
+
notdef_binding = bindings.find { |b| b[:donor_gid].zero? }
|
|
197
|
+
if notdef_binding
|
|
198
|
+
copy_glyph_into(target, name: ".notdef",
|
|
199
|
+
source: notdef_binding[:source],
|
|
200
|
+
donor_gid: 0)
|
|
201
|
+
else
|
|
202
|
+
target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
|
|
203
|
+
end
|
|
204
|
+
dedup_target = target.glyphs[".notdef"]
|
|
205
|
+
deduplicator&.register(dedup_target, ".notdef") if dedup_target
|
|
204
206
|
end
|
|
205
207
|
|
|
206
208
|
def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
|
|
@@ -212,46 +214,85 @@ module Fontisan
|
|
|
212
214
|
target_font.layers.default_layer.add(copy)
|
|
213
215
|
end
|
|
214
216
|
|
|
215
|
-
def add_extra_unicode(glyph_name, codepoint)
|
|
217
|
+
def add_extra_unicode(target_font, glyph_name, codepoint)
|
|
216
218
|
return unless codepoint
|
|
217
219
|
|
|
218
|
-
glyph =
|
|
220
|
+
glyph = target_font.glyph(glyph_name)
|
|
219
221
|
glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
|
|
220
222
|
end
|
|
221
223
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
@target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
|
|
224
|
+
def unique_target_name(target_font, base_name)
|
|
225
|
+
return base_name unless target_font.glyphs.key?(base_name)
|
|
226
|
+
|
|
227
|
+
suffix = 1
|
|
228
|
+
loop do
|
|
229
|
+
candidate = "#{base_name}.#{suffix}"
|
|
230
|
+
return candidate unless target_font.glyphs.key?(candidate)
|
|
231
|
+
|
|
232
|
+
suffix += 1
|
|
232
233
|
end
|
|
233
234
|
end
|
|
234
235
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
236
|
+
def clone_glyph(original, name:)
|
|
237
|
+
copy = Ufo::Glyph.new(name: name)
|
|
238
|
+
copy.width = original.width
|
|
239
|
+
copy.height = original.height
|
|
240
|
+
original.contours.each { |c| copy.add_contour(clone_contour(c)) }
|
|
241
|
+
original.components.each { |c| copy.add_component(c) }
|
|
242
|
+
original.anchors.each { |a| copy.add_anchor(a) }
|
|
243
|
+
original.guidelines.each { |g| copy.add_guideline(g) }
|
|
244
|
+
copy
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def clone_contour(original)
|
|
248
|
+
points = original.points.map do |p|
|
|
249
|
+
Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
|
|
250
|
+
end
|
|
251
|
+
Ufo::Contour.new(points)
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def propagate_cbdt_tables(path)
|
|
255
|
+
source = cbdt_source
|
|
256
|
+
return unless source
|
|
257
|
+
|
|
258
|
+
compiled = Fontisan::FontLoader.load(path)
|
|
259
|
+
|
|
260
|
+
tables = {}
|
|
261
|
+
compiled.table_names.each do |tag|
|
|
262
|
+
raw = extract_raw_table(compiled, tag)
|
|
263
|
+
tables[tag] = raw if raw
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
cbdt_bytes = source.raw_table_bytes("CBDT")
|
|
267
|
+
cblc_bytes = source.raw_table_bytes("CBLC")
|
|
268
|
+
tables["CBDT"] = cbdt_bytes if cbdt_bytes
|
|
269
|
+
tables["CBLC"] = cblc_bytes if cblc_bytes
|
|
270
|
+
|
|
271
|
+
sfnt = tables.key?("CFF ") || tables.key?("CFF2") ? 0x4F54544F : 0x00010000
|
|
272
|
+
Fontisan::FontWriter.write_to_file(tables, path, sfnt_version: sfnt)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def extract_raw_table(font, tag)
|
|
276
|
+
sfnt_table = font.table(tag)
|
|
277
|
+
return nil unless sfnt_table
|
|
278
|
+
|
|
279
|
+
sfnt_table.raw_data
|
|
280
|
+
rescue StandardError
|
|
281
|
+
nil
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def add_all_cbdt_glyphs(source, target)
|
|
240
285
|
ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
|
|
241
286
|
if ufo
|
|
242
|
-
ufo.glyphs.each_value { |g|
|
|
287
|
+
ufo.glyphs.each_value { |g| target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
|
|
243
288
|
return
|
|
244
289
|
end
|
|
245
290
|
|
|
246
|
-
# For loaded TTF/OTF sources, iterate via cmap to get glyph names.
|
|
247
|
-
# CBDT fonts (like NotoColorEmoji) may have thousands of glyphs;
|
|
248
|
-
# we add them all as placeholders.
|
|
249
291
|
maxp = source.font.table("maxp")
|
|
250
292
|
num_glyphs = maxp&.num_glyphs || 0
|
|
251
293
|
cmap = source.font.table("cmap")
|
|
252
294
|
mappings = cmap&.unicode_mappings || {}
|
|
253
295
|
|
|
254
|
-
# Build gid → [codepoints] from cmap
|
|
255
296
|
gid_cps = Hash.new { |h, k| h[k] = [] }
|
|
256
297
|
mappings.each { |cp, gid| gid_cps[gid] << cp }
|
|
257
298
|
|
|
@@ -260,28 +301,8 @@ module Fontisan
|
|
|
260
301
|
glyph = Ufo::Glyph.new(name: name)
|
|
261
302
|
glyph.width = 0
|
|
262
303
|
gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
|
|
263
|
-
|
|
264
|
-
end
|
|
265
|
-
end
|
|
266
|
-
|
|
267
|
-
# Deep-copy a glyph with a new name. Used so multiple target
|
|
268
|
-
# glyphs can share the same source outline without aliasing.
|
|
269
|
-
def clone_glyph(original, name:)
|
|
270
|
-
copy = Ufo::Glyph.new(name: name)
|
|
271
|
-
copy.width = original.width
|
|
272
|
-
copy.height = original.height
|
|
273
|
-
original.contours.each { |c| copy.add_contour(clone_contour(c)) }
|
|
274
|
-
original.components.each { |c| copy.add_component(c) }
|
|
275
|
-
original.anchors.each { |a| copy.add_anchor(a) }
|
|
276
|
-
original.guidelines.each { |g| copy.add_guideline(g) }
|
|
277
|
-
copy
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
def clone_contour(original)
|
|
281
|
-
points = original.points.map do |p|
|
|
282
|
-
Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
|
|
304
|
+
target.layers.default_layer.add(glyph)
|
|
283
305
|
end
|
|
284
|
-
Ufo::Contour.new(points)
|
|
285
306
|
end
|
|
286
307
|
end
|
|
287
308
|
end
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module SvgToGlyf
|
|
5
|
+
# Orchestrates the full SVG → Ufo::Glyph pipeline:
|
|
6
|
+
#
|
|
7
|
+
# path data + transforms
|
|
8
|
+
# → Path::Parser.parse → [Command]
|
|
9
|
+
# → Path::ContourBuilder.build → [Ufo::Contour]
|
|
10
|
+
# → apply final transform (normalization · group transform)
|
|
11
|
+
# → round to Integer
|
|
12
|
+
# → Ufo::Glyph
|
|
13
|
+
#
|
|
14
|
+
# For SVG files and directories, the Document class extracts the
|
|
15
|
+
# viewBox, accumulated transforms, and path data; the Assembler
|
|
16
|
+
# composes the normalizer with the group transform and runs the
|
|
17
|
+
# pipeline once per path.
|
|
18
|
+
class Assembler
|
|
19
|
+
attr_reader :upm
|
|
20
|
+
|
|
21
|
+
# @param upm [Integer] font units-per-em
|
|
22
|
+
def initialize(upm: SvgToGlyf::DEFAULT_UPM)
|
|
23
|
+
@upm = upm.to_i
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Build a glyph directly from a path data string.
|
|
27
|
+
#
|
|
28
|
+
# @param path_data [String] SVG path d= attribute
|
|
29
|
+
# @param codepoint [Integer, nil] Unicode codepoint
|
|
30
|
+
# @param name [String, nil] glyph name
|
|
31
|
+
# @param viewbox [Hash{Symbol=>Float}, nil] :width, :height
|
|
32
|
+
# @param transform [Geometry::AffineTransform, nil] group transform
|
|
33
|
+
# @return [Fontisan::Ufo::Glyph]
|
|
34
|
+
def build_from_path_data(path_data, codepoint: nil, name: nil,
|
|
35
|
+
viewbox: nil, transform: nil)
|
|
36
|
+
viewbox ||= { width: @upm, height: @upm }
|
|
37
|
+
final = normalizer_for(**viewbox).final_transform(transform || Geometry::AffineTransform.identity)
|
|
38
|
+
contours = build_contours(path_data, final)
|
|
39
|
+
assemble_glyph(name || glyph_name_for(codepoint), contours, codepoint)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Build a glyph from an SVG file.
|
|
43
|
+
#
|
|
44
|
+
# @param file_path [String]
|
|
45
|
+
# @param codepoint [Integer, nil] override; otherwise derived from filename
|
|
46
|
+
# @return [Fontisan::Ufo::Glyph]
|
|
47
|
+
def build_from_file(file_path, codepoint: nil)
|
|
48
|
+
doc = Document.from_file(file_path)
|
|
49
|
+
codepoint ||= codepoint_from_filename(File.basename(file_path))
|
|
50
|
+
|
|
51
|
+
doc.each_path.with_object(nil) do |(data, transform), _|
|
|
52
|
+
return build_from_doc_path(data, transform, doc, codepoint)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
empty_glyph(codepoint)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Build a font from a directory of SVG files.
|
|
59
|
+
#
|
|
60
|
+
# @param dir [String]
|
|
61
|
+
# @return [Fontisan::Ufo::Font]
|
|
62
|
+
def build_from_directory(dir)
|
|
63
|
+
font = Fontisan::Ufo::Font.new
|
|
64
|
+
font.info.units_per_em = @upm
|
|
65
|
+
|
|
66
|
+
Dir.glob(File.join(dir, "*.svg")).each do |path|
|
|
67
|
+
glyph = build_from_file(path)
|
|
68
|
+
font.glyphs[glyph.name] = glyph
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
font
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def build_from_doc_path(data, group_transform, doc, codepoint)
|
|
77
|
+
final = normalizer_for(width: doc.viewbox_width, height: doc.viewbox_height)
|
|
78
|
+
.final_transform(group_transform)
|
|
79
|
+
contours = build_contours(data, final)
|
|
80
|
+
assemble_glyph(glyph_name_for(codepoint), contours, codepoint)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def build_contours(path_data, final_transform)
|
|
84
|
+
commands = Path::Parser.parse(path_data)
|
|
85
|
+
contours = Path::ContourBuilder.new.build(commands)
|
|
86
|
+
contours.map { |c| transform_contour(c, final_transform) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def transform_contour(contour, transform)
|
|
90
|
+
points = contour.points.map do |pt|
|
|
91
|
+
x, y = transform.apply(pt.x, pt.y)
|
|
92
|
+
Fontisan::Ufo::Point.new(x: x.round, y: y.round, type: pt.type, smooth: pt.smooth)
|
|
93
|
+
end
|
|
94
|
+
Fontisan::Ufo::Contour.new(points)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalizer_for(width:, height:)
|
|
98
|
+
Geometry::Normalizer.new(viewbox_width: width, viewbox_height: height, upm: @upm)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def assemble_glyph(name, contours, codepoint)
|
|
102
|
+
glyph = Fontisan::Ufo::Glyph.new(name: name)
|
|
103
|
+
glyph.width = @upm
|
|
104
|
+
contours.each { |c| glyph.add_contour(c) }
|
|
105
|
+
glyph.add_unicode(codepoint) if codepoint
|
|
106
|
+
glyph
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def empty_glyph(codepoint)
|
|
110
|
+
glyph = Fontisan::Ufo::Glyph.new(name: glyph_name_for(codepoint))
|
|
111
|
+
glyph.width = @upm
|
|
112
|
+
glyph.add_unicode(codepoint) if codepoint
|
|
113
|
+
glyph
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# UFO convention: uniXXXX for BMP, uXXXXX for supplementary planes.
|
|
117
|
+
def glyph_name_for(codepoint)
|
|
118
|
+
return "glyph" unless codepoint
|
|
119
|
+
|
|
120
|
+
codepoint < 0x10000 ? "uni%04X" % codepoint : "u%05X" % codepoint
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Derive a codepoint from a filename like "U+10940.svg" or "10940.svg".
|
|
124
|
+
def codepoint_from_filename(basename)
|
|
125
|
+
match = basename.match(/(?:U\+)?([0-9A-Fa-f]{4,6})\.svg\z/)
|
|
126
|
+
return nil unless match
|
|
127
|
+
|
|
128
|
+
match[1].to_i(16)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "nokogiri"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module SvgToGlyf
|
|
7
|
+
# Walks an SVG XML document to extract path data and accumulated
|
|
8
|
+
# transforms. The Document is the single source of truth for which
|
|
9
|
+
# transforms apply to which paths and what coordinate space the
|
|
10
|
+
# SVG defines (via viewBox).
|
|
11
|
+
class Document
|
|
12
|
+
attr_reader :viewbox_width, :viewbox_height, :source
|
|
13
|
+
|
|
14
|
+
# @param xml [String] raw SVG XML
|
|
15
|
+
def self.from_xml(xml)
|
|
16
|
+
new(Nokogiri::XML(xml))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @param path [String] file path to an .svg file
|
|
20
|
+
def self.from_file(path)
|
|
21
|
+
from_xml(File.read(path))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @param doc [Nokogiri::XML::Document]
|
|
25
|
+
def initialize(doc)
|
|
26
|
+
@doc = doc
|
|
27
|
+
@source = doc
|
|
28
|
+
extract_viewbox
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Yield each <path> element's d= string along with the accumulated
|
|
32
|
+
# AffineTransform from all ancestor <g> transform= attributes.
|
|
33
|
+
#
|
|
34
|
+
# @yieldparam path_data [String] the d= attribute value
|
|
35
|
+
# @yieldparam transform [Geometry::AffineTransform] accumulated group transform
|
|
36
|
+
def each_path(&)
|
|
37
|
+
return enum_for(:each_path) unless block_given?
|
|
38
|
+
|
|
39
|
+
walk(@doc.root, Geometry::AffineTransform.identity, &)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def extract_viewbox
|
|
45
|
+
root = @doc.root
|
|
46
|
+
vb = root&.attribute("viewBox")&.value
|
|
47
|
+
if vb
|
|
48
|
+
_, _, w, h = vb.split(/\s+/).map(&:to_f)
|
|
49
|
+
@viewbox_width = w
|
|
50
|
+
@viewbox_height = h
|
|
51
|
+
else
|
|
52
|
+
@viewbox_width = (root&.attribute("width")&.value || DEFAULT_UPM).to_f
|
|
53
|
+
@viewbox_height = (root&.attribute("height")&.value || DEFAULT_UPM).to_f
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Recursively walk the XML tree. When a <g> has a transform=,
|
|
58
|
+
# compose it into the running accumulated transform. When a <path>
|
|
59
|
+
# is found, yield its d= with the current accumulated transform.
|
|
60
|
+
def walk(node, accumulated, &)
|
|
61
|
+
return unless node
|
|
62
|
+
|
|
63
|
+
node.children.each do |child|
|
|
64
|
+
next unless child.element?
|
|
65
|
+
|
|
66
|
+
case child.name
|
|
67
|
+
when "g"
|
|
68
|
+
child_transform = parse_transform(child)
|
|
69
|
+
walk(child, accumulated.compose(child_transform), &)
|
|
70
|
+
when "path"
|
|
71
|
+
data = child.attribute("d")&.value
|
|
72
|
+
yield(data, accumulated) if data
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def parse_transform(element)
|
|
78
|
+
raw = element.attribute("transform")&.value
|
|
79
|
+
Geometry::TransformParser.parse(raw)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|