fontisan 0.3.0 → 0.4.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/README.adoc +100 -0
- data/lib/fontisan/error.rb +7 -0
- data/lib/fontisan/stitcher/source.rb +41 -0
- data/lib/fontisan/stitcher.rb +117 -12
- data/lib/fontisan/ufo/compile/cff.rb +2 -1
- data/lib/fontisan/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '020862878eef5569b82edb88989afdc3b7c21d3f01d19b605132af70cabe524f'
|
|
4
|
+
data.tar.gz: d8d685a687c94f987835e440619aca344a615368ea78d6caf6a882c34c2900d3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a98055b753907dedfd44fd89c61fd3d5d797d405d702e65730bae1e3299665354f385b135529ca6b48708672e37cfa0e10a7b518ce0b624ccb46fae9459cfb22
|
|
7
|
+
data.tar.gz: 46af4e780f4a1786501e2b874d4b439614c212b839b0b398316806a2baa9348b67c8e4db3e63b85142d79ae015df3f093db957f055ddbd3f4955268caa2c1dfa
|
data/README.adoc
CHANGED
|
@@ -2376,6 +2376,106 @@ Fontisan can:
|
|
|
2376
2376
|
* Importing and generation PNG block ruler layers
|
|
2377
2377
|
|
|
2378
2378
|
|
|
2379
|
+
== UFO source operations
|
|
2380
|
+
|
|
2381
|
+
fontisan reads, writes, and compiles UFO (Unified Font Object) v2/v3
|
|
2382
|
+
font sources into binary font formats (TTF, OTF). No AFDKO or Python
|
|
2383
|
+
fonttools needed.
|
|
2384
|
+
|
|
2385
|
+
=== Build a UFO to binary
|
|
2386
|
+
|
|
2387
|
+
[source,bash]
|
|
2388
|
+
----
|
|
2389
|
+
# TTF (TrueType outlines)
|
|
2390
|
+
fontisan ufo build font.ufo --output out.ttf --to ttf
|
|
2391
|
+
|
|
2392
|
+
# OTF (CFF outlines)
|
|
2393
|
+
fontisan ufo build font.ufo --output out.otf --to otf
|
|
2394
|
+
----
|
|
2395
|
+
|
|
2396
|
+
The TTF compiler automatically applies:
|
|
2397
|
+
- `cubic_to_quadratic` filter (UFO cubic Beziers -> TTF quadratic)
|
|
2398
|
+
- `reverse_contour_direction` filter (TTF winding convention)
|
|
2399
|
+
|
|
2400
|
+
=== Convert between UFO and binary
|
|
2401
|
+
|
|
2402
|
+
[source,bash]
|
|
2403
|
+
----
|
|
2404
|
+
# UFO -> binary
|
|
2405
|
+
fontisan ufo convert font.ufo out.ttf --to ttf
|
|
2406
|
+
|
|
2407
|
+
# Binary -> UFO (reverse direction)
|
|
2408
|
+
fontisan ufo convert font.ttf font.ufo/
|
|
2409
|
+
----
|
|
2410
|
+
|
|
2411
|
+
=== Validate a UFO source
|
|
2412
|
+
|
|
2413
|
+
[source,bash]
|
|
2414
|
+
----
|
|
2415
|
+
fontisan ufo validate font.ufo
|
|
2416
|
+
----
|
|
2417
|
+
|
|
2418
|
+
Checks: glyphs present, family name set, unitsPerEm set, .notdef exists.
|
|
2419
|
+
|
|
2420
|
+
=== Ruby API
|
|
2421
|
+
|
|
2422
|
+
[source,ruby]
|
|
2423
|
+
----
|
|
2424
|
+
require "fontisan"
|
|
2425
|
+
|
|
2426
|
+
# Read a UFO
|
|
2427
|
+
font = Fontisan::Ufo::Font.open("font.ufo")
|
|
2428
|
+
puts font.family_name
|
|
2429
|
+
puts font.glyphs.size
|
|
2430
|
+
|
|
2431
|
+
# Compile to TTF
|
|
2432
|
+
Fontisan::Ufo::Compile::TtfCompiler.new(font).compile(output_path: "out.ttf")
|
|
2433
|
+
|
|
2434
|
+
# Compile to OTF
|
|
2435
|
+
Fontisan::Ufo::Compile::OtfCompiler.new(font).compile(output_path: "out.otf")
|
|
2436
|
+
|
|
2437
|
+
# Write back to UFO (round-trip)
|
|
2438
|
+
Fontisan::Ufo::Writer.new(font).write("out.ufo")
|
|
2439
|
+
|
|
2440
|
+
# Convert a binary font to UFO
|
|
2441
|
+
ttf = Fontisan::FontLoader.load("font.ttf")
|
|
2442
|
+
ufo = Fontisan::Ufo::Convert::FromBinData.convert(ttf)
|
|
2443
|
+
----
|
|
2444
|
+
|
|
2445
|
+
== Font stitching
|
|
2446
|
+
|
|
2447
|
+
The Stitcher combines glyphs from multiple source fonts into one new
|
|
2448
|
+
font. Sources can be UFO directories or loaded TTF/OTF files.
|
|
2449
|
+
|
|
2450
|
+
=== Ruby API
|
|
2451
|
+
|
|
2452
|
+
[source,ruby]
|
|
2453
|
+
----
|
|
2454
|
+
stitcher = Fontisan::Stitcher.new
|
|
2455
|
+
stitcher.add_source(:latin, Fontisan::FontLoader.load("NotoSans.ttf"))
|
|
2456
|
+
stitcher.add_source(:cjk, Fontisan::FontLoader.load("FSung-m.ttf"))
|
|
2457
|
+
|
|
2458
|
+
stitcher.include_range(0x41..0x5A, from: :latin) # A-Z
|
|
2459
|
+
stitcher.include_range(0x4E00..0x9FFF, from: :cjk) # CJK Unified
|
|
2460
|
+
stitcher.include_notdef(from: :latin)
|
|
2461
|
+
|
|
2462
|
+
stitcher.write_to("stitched.ttf", format: :ttf)
|
|
2463
|
+
----
|
|
2464
|
+
|
|
2465
|
+
=== CLI
|
|
2466
|
+
|
|
2467
|
+
[source,bash]
|
|
2468
|
+
----
|
|
2469
|
+
fontisan stitch \
|
|
2470
|
+
--source latin=NotoSans.ttf \
|
|
2471
|
+
--source cjk=FSung-m.ttf \
|
|
2472
|
+
--include-range latin=0x41-0x5A \
|
|
2473
|
+
--include-range cjk=0x4E00-0x9FFF \
|
|
2474
|
+
--notdef-from latin \
|
|
2475
|
+
--output stitched.ttf
|
|
2476
|
+
----
|
|
2477
|
+
|
|
2478
|
+
|
|
2379
2479
|
== Testing
|
|
2380
2480
|
|
|
2381
2481
|
=== General
|
data/lib/fontisan/error.rb
CHANGED
|
@@ -199,6 +199,13 @@ module Fontisan
|
|
|
199
199
|
end
|
|
200
200
|
end
|
|
201
201
|
|
|
202
|
+
# Multiple CBDT sources detected in Stitcher
|
|
203
|
+
#
|
|
204
|
+
# Raised when more than one CBDT/CBLC source is registered with the
|
|
205
|
+
# Stitcher. Only single-source CBDT passthrough is supported; merging
|
|
206
|
+
# CBDT/CBLC across multiple sources requires a dedicated rebuild.
|
|
207
|
+
class MultipleCbdtSourcesError < Error; end
|
|
208
|
+
|
|
202
209
|
# Variation data corrupted (for use in data_extractor)
|
|
203
210
|
#
|
|
204
211
|
# Raised when extracted variation data appears corrupted.
|
|
@@ -10,6 +10,11 @@ module Fontisan
|
|
|
10
10
|
# via Ufo::Convert::FromBinData on first glyph access, then cached.
|
|
11
11
|
# This is O(n) in donor glyph count but amortized across all
|
|
12
12
|
# codepoint extractions from that donor.
|
|
13
|
+
#
|
|
14
|
+
# CBDT/CBLC sources (e.g. NotoColorEmoji) are detected via
|
|
15
|
+
# #bitmap_mode. When a source is :cbdt, the Stitcher propagates
|
|
16
|
+
# the raw CBDT/CBLC tables into the output instead of extracting
|
|
17
|
+
# outlines. The glyph data lives in the bitmap tables, not in glyf.
|
|
13
18
|
class Source
|
|
14
19
|
attr_reader :font
|
|
15
20
|
|
|
@@ -28,6 +33,27 @@ module Fontisan
|
|
|
28
33
|
end
|
|
29
34
|
end
|
|
30
35
|
|
|
36
|
+
# Detect how this source stores glyph data.
|
|
37
|
+
#
|
|
38
|
+
# - :glyf — TrueType outlines (glyf table present)
|
|
39
|
+
# - :cbdt — Color bitmaps (CBDT + CBLC tables, no glyf)
|
|
40
|
+
# - :mixed — Both glyf and CBDT
|
|
41
|
+
# - :none — UFO source or neither table present
|
|
42
|
+
#
|
|
43
|
+
# @return [Symbol]
|
|
44
|
+
def bitmap_mode
|
|
45
|
+
return :none if @font.is_a?(Fontisan::Ufo::Font)
|
|
46
|
+
return :none unless @font.respond_to?(:has_table?)
|
|
47
|
+
|
|
48
|
+
has_cbdt = @font.has_table?("CBDT") && @font.has_table?("CBLC")
|
|
49
|
+
has_glyf = @font.has_table?("glyf") || @font.has_table?("CFF ")
|
|
50
|
+
return :mixed if has_cbdt && has_glyf
|
|
51
|
+
return :cbdt if has_cbdt
|
|
52
|
+
return :glyf if has_glyf
|
|
53
|
+
|
|
54
|
+
:none
|
|
55
|
+
end
|
|
56
|
+
|
|
31
57
|
# Find the gid for a Unicode codepoint in this source.
|
|
32
58
|
# @param codepoint [Integer]
|
|
33
59
|
# @return [Integer, nil]
|
|
@@ -39,6 +65,9 @@ module Fontisan
|
|
|
39
65
|
end
|
|
40
66
|
|
|
41
67
|
# Extract a glyph by gid.
|
|
68
|
+
#
|
|
69
|
+
# For CBDT sources, returns a placeholder glyph (no contours)
|
|
70
|
+
# since the glyph data lives in the bitmap tables, not outlines.
|
|
42
71
|
# @param gid [Integer]
|
|
43
72
|
# @return [Fontisan::Ufo::Glyph, nil]
|
|
44
73
|
def glyph_for_gid(gid)
|
|
@@ -48,6 +77,18 @@ module Fontisan
|
|
|
48
77
|
end
|
|
49
78
|
end
|
|
50
79
|
|
|
80
|
+
# Raw table bytes from the loaded font (for passthrough).
|
|
81
|
+
# @param tag [String] 4-byte table tag (e.g. "CBDT", "CBLC")
|
|
82
|
+
# @return [String, nil] raw bytes or nil if table not present
|
|
83
|
+
def raw_table_bytes(tag)
|
|
84
|
+
sfnt_table = @font.table(tag)
|
|
85
|
+
return nil unless sfnt_table
|
|
86
|
+
|
|
87
|
+
sfnt_table.raw_data
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
51
92
|
private
|
|
52
93
|
|
|
53
94
|
# ---------- UFO source ----------
|
data/lib/fontisan/stitcher.rb
CHANGED
|
@@ -70,12 +70,23 @@ module Fontisan
|
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
# Write the stitched font to disk.
|
|
73
|
+
#
|
|
74
|
+
# For CBDT/CBLC sources (e.g. NotoColorEmoji), the raw CBDT and
|
|
75
|
+
# CBLC tables are copied byte-for-byte into the output. This works
|
|
76
|
+
# because CBDT-mode glyphs are processed first (GIDs 0..N-1),
|
|
77
|
+
# matching the source's GID layout. Only one CBDT source is
|
|
78
|
+
# supported; multiple CBDT sources raise MultipleCbdtSourcesError.
|
|
79
|
+
#
|
|
73
80
|
# @param path [String] output file path
|
|
74
81
|
# @param format [Symbol] :ttf or :otf
|
|
75
82
|
def write_to(path, format: :ttf)
|
|
76
83
|
build_target_font
|
|
77
84
|
compiler = compiler_for(format)
|
|
78
|
-
compiler.new(@target)
|
|
85
|
+
compiler_instance = compiler.new(@target)
|
|
86
|
+
compiler_instance.compile(output_path: path)
|
|
87
|
+
|
|
88
|
+
propagate_cbdt_tables(path) if cbdt_source
|
|
89
|
+
|
|
79
90
|
path
|
|
80
91
|
end
|
|
81
92
|
|
|
@@ -96,6 +107,53 @@ module Fontisan
|
|
|
96
107
|
end
|
|
97
108
|
end
|
|
98
109
|
|
|
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
|
+
def cbdt_source
|
|
114
|
+
cbdts = @sources.values.select { |s| s.bitmap_mode == :cbdt }
|
|
115
|
+
if cbdts.size > 1
|
|
116
|
+
raise MultipleCbdtSourcesError,
|
|
117
|
+
"multiple CBDT sources not supported (found #{cbdts.size})"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
cbdts.first
|
|
121
|
+
end
|
|
122
|
+
|
|
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
|
+
|
|
99
157
|
def compiler_for(format)
|
|
100
158
|
case format.to_sym
|
|
101
159
|
when :ttf then Ufo::Compile::TtfCompiler
|
|
@@ -107,26 +165,28 @@ module Fontisan
|
|
|
107
165
|
|
|
108
166
|
# Walk bindings in codepoint order, assign sequential new gids,
|
|
109
167
|
# copy each glyph into the target font's default layer.
|
|
168
|
+
#
|
|
169
|
+
# When a CBDT source is present, its glyphs are added FIRST (in
|
|
170
|
+
# source GID order) so that the CBLC's GID references remain valid.
|
|
171
|
+
# Glyf-source bindings are processed AFTER, appending new glyphs.
|
|
110
172
|
def assign_gids_and_copy_glyphs
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if
|
|
114
|
-
|
|
115
|
-
source: notdef_binding[:source],
|
|
116
|
-
donor_gid: 0)
|
|
173
|
+
cbdt = cbdt_source
|
|
174
|
+
|
|
175
|
+
if cbdt
|
|
176
|
+
add_all_cbdt_glyphs(cbdt)
|
|
117
177
|
else
|
|
118
|
-
|
|
119
|
-
@target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
|
|
178
|
+
add_notdef_from_bindings
|
|
120
179
|
end
|
|
121
180
|
|
|
122
181
|
sorted_bindings.each do |binding|
|
|
123
|
-
next if binding[:donor_gid].zero?
|
|
182
|
+
next if binding[:donor_gid].zero?
|
|
183
|
+
|
|
184
|
+
# Skip bindings from the CBDT source — its glyphs are already added.
|
|
185
|
+
next if cbdt && binding[:source].equal?(cbdt)
|
|
124
186
|
|
|
125
187
|
glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
|
|
126
188
|
next unless glyph
|
|
127
189
|
|
|
128
|
-
# If multiple codepoints map to the same glyph, only the first
|
|
129
|
-
# binding creates the glyph; subsequent ones add unicode entries.
|
|
130
190
|
if @target.glyphs.key?(glyph.name)
|
|
131
191
|
add_extra_unicode(glyph.name, binding[:codepoint])
|
|
132
192
|
else
|
|
@@ -159,6 +219,51 @@ module Fontisan
|
|
|
159
219
|
glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
|
|
160
220
|
end
|
|
161
221
|
|
|
222
|
+
# Add .notdef at GID 0 from the first binding that references gid 0.
|
|
223
|
+
# Falls back to a synthesized empty .notdef if none found.
|
|
224
|
+
def add_notdef_from_bindings
|
|
225
|
+
notdef_binding = @bindings.find { |b| b[:donor_gid].zero? }
|
|
226
|
+
if notdef_binding
|
|
227
|
+
copy_glyph_into(@target, name: ".notdef",
|
|
228
|
+
source: notdef_binding[:source],
|
|
229
|
+
donor_gid: 0)
|
|
230
|
+
else
|
|
231
|
+
@target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Add ALL glyphs from a CBDT source in source GID order. This
|
|
236
|
+
# ensures the CBLC's GID references remain valid in the output
|
|
237
|
+
# without rewriting the table. Each glyph gets a placeholder
|
|
238
|
+
# (no contours) since the bitmap data is in CBDT, not glyf.
|
|
239
|
+
def add_all_cbdt_glyphs(source)
|
|
240
|
+
ufo = source.font.is_a?(Ufo::Font) ? source.font : nil
|
|
241
|
+
if ufo
|
|
242
|
+
ufo.glyphs.each_value { |g| @target.layers.default_layer.add(clone_glyph(g, name: g.name)) }
|
|
243
|
+
return
|
|
244
|
+
end
|
|
245
|
+
|
|
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
|
+
maxp = source.font.table("maxp")
|
|
250
|
+
num_glyphs = maxp&.num_glyphs || 0
|
|
251
|
+
cmap = source.font.table("cmap")
|
|
252
|
+
mappings = cmap&.unicode_mappings || {}
|
|
253
|
+
|
|
254
|
+
# Build gid → [codepoints] from cmap
|
|
255
|
+
gid_cps = Hash.new { |h, k| h[k] = [] }
|
|
256
|
+
mappings.each { |cp, gid| gid_cps[gid] << cp }
|
|
257
|
+
|
|
258
|
+
num_glyphs.times do |gid|
|
|
259
|
+
name = gid.zero? ? ".notdef" : "gid#{gid}"
|
|
260
|
+
glyph = Ufo::Glyph.new(name: name)
|
|
261
|
+
glyph.width = 0
|
|
262
|
+
gid_cps[gid].each { |cp| glyph.add_unicode(cp) }
|
|
263
|
+
@target.layers.default_layer.add(glyph)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
162
267
|
# Deep-copy a glyph with a new name. Used so multiple target
|
|
163
268
|
# glyphs can share the same source outline without aliasing.
|
|
164
269
|
def clone_glyph(original, name:)
|
|
@@ -53,7 +53,8 @@ module Fontisan
|
|
|
53
53
|
end
|
|
54
54
|
|
|
55
55
|
def self.empty_charstring(width)
|
|
56
|
-
Fontisan::Tables::Cff::CharStringBuilder.
|
|
56
|
+
builder = Fontisan::Tables::Cff::CharStringBuilder.new
|
|
57
|
+
builder.build_empty(width: width.zero? ? nil : width)
|
|
57
58
|
end
|
|
58
59
|
|
|
59
60
|
# ---------- layout ----------
|
data/lib/fontisan/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fontisan
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-06-
|
|
11
|
+
date: 2026-06-30 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|