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