fontisan 0.4.10 → 0.4.12
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 +27 -1
- data/Rakefile +53 -39
- data/docs/AFDKO_MIGRATION.adoc +175 -0
- data/docs/FEATURE_PARITY.adoc +263 -0
- data/docs/STITCHER_GUIDE.adoc +100 -6
- data/docs/TTX.adoc +176 -0
- data/docs/UFO_COMPILATION.adoc +281 -3
- data/lib/fontisan/stitcher/partition_strategy/by_block.rb +429 -0
- data/lib/fontisan/stitcher/partition_strategy/by_script.rb +305 -0
- data/lib/fontisan/stitcher/partition_strategy.rb +2 -0
- data/lib/fontisan/stitcher/source.rb +11 -7
- data/lib/fontisan/subset/table_subsetter.rb +227 -11
- data/lib/fontisan/svg/standalone_glyph.rb +129 -0
- data/lib/fontisan/svg.rb +1 -0
- data/lib/fontisan/tasks/fixture_downloader.rb +162 -0
- data/lib/fontisan/tasks.rb +11 -0
- data/lib/fontisan/ufo/cli.rb +34 -21
- data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
- data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
- data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
- data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
- data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
- data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
- data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
- data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
- data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
- data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
- data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
- data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
- data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
- data/lib/fontisan/ufo/compile/filters.rb +12 -0
- data/lib/fontisan/ufo/compile.rb +1 -0
- data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
- data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
- data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
- data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
- data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
- data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
- data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
- data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
- data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
- data/lib/fontisan/ufo/convert.rb +68 -5
- data/lib/fontisan/ufo/transformation.rb +11 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +1 -0
- metadata +32 -2
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Svg
|
|
5
|
+
# Renders a single UFO glyph as a standalone SVG document (a
|
|
6
|
+
# `<svg>` root with one `<path>` and a viewBox sized to the
|
|
7
|
+
# glyph's bounding box). Distinct from {FontGenerator}, which
|
|
8
|
+
# emits a `<svg><defs><font>` document covering every glyph.
|
|
9
|
+
#
|
|
10
|
+
# Used by the `fontisan ufo extract` CLI command and by
|
|
11
|
+
# downstream consumers that want per-glyph SVG exports without
|
|
12
|
+
# having to slice up a font-format SVG themselves.
|
|
13
|
+
#
|
|
14
|
+
# Path rendering supports quadratic and cubic Bezier curves
|
|
15
|
+
# (UFO's standard outline model). Curve logic is inlined here
|
|
16
|
+
# rather than reaching into GlyphGenerator's private path code
|
|
17
|
+
# — that logic is font-format-specific (Y-axis flip via
|
|
18
|
+
# ViewBoxCalculator) and doesn't cleanly factor out.
|
|
19
|
+
class StandaloneGlyph
|
|
20
|
+
# @param units_per_em [Integer] font units-per-em (default 1000)
|
|
21
|
+
# @param ascent [Integer] font ascender in font units (default 800)
|
|
22
|
+
# @param descent [Integer] font descender in font units (default -200)
|
|
23
|
+
def initialize(units_per_em: 1000, ascent: 800, descent: -200)
|
|
24
|
+
@units_per_em = units_per_em.to_i
|
|
25
|
+
@ascent = ascent.to_i
|
|
26
|
+
@descent = descent.to_i
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param glyph [Fontisan::Ufo::Glyph]
|
|
30
|
+
# @return [String] standalone SVG XML
|
|
31
|
+
def generate(glyph)
|
|
32
|
+
path_data = path_data_for(glyph)
|
|
33
|
+
view_box = view_box_for(glyph)
|
|
34
|
+
|
|
35
|
+
<<~SVG
|
|
36
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
37
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="#{view_box}">
|
|
38
|
+
<path d="#{path_data}"/>
|
|
39
|
+
</svg>
|
|
40
|
+
SVG
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def path_data_for(glyph)
|
|
46
|
+
return "" if glyph.contours.empty?
|
|
47
|
+
|
|
48
|
+
glyph.contours.filter_map { |c| contour_path(c) }.join(" ")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Emit an SVG path for one contour with Y-axis flip applied.
|
|
52
|
+
# Walks points left-to-right; collects off-curve runs and
|
|
53
|
+
# flushes them as a Bezier curve (Q for 1 control point, C
|
|
54
|
+
# for 2). Single on-curve points become line segments.
|
|
55
|
+
def contour_path(contour)
|
|
56
|
+
points = contour.points
|
|
57
|
+
return nil if points.empty?
|
|
58
|
+
|
|
59
|
+
parts = []
|
|
60
|
+
first = points.first
|
|
61
|
+
parts << "M #{first.x} #{flip_y(first.y)}"
|
|
62
|
+
|
|
63
|
+
i = 1
|
|
64
|
+
while i < points.length
|
|
65
|
+
if points[i].on_curve?
|
|
66
|
+
parts << "L #{points[i].x} #{flip_y(points[i].y)}"
|
|
67
|
+
i += 1
|
|
68
|
+
else
|
|
69
|
+
# Collect consecutive off-curve points (1 → quadratic,
|
|
70
|
+
# 2 → cubic, 3+ → rare; treat as multiple quadratics).
|
|
71
|
+
controls = []
|
|
72
|
+
while i < points.length && !points[i].on_curve?
|
|
73
|
+
controls << points[i]
|
|
74
|
+
i += 1
|
|
75
|
+
end
|
|
76
|
+
end_pt = i < points.length ? points[i] : first
|
|
77
|
+
i += 1 if i < points.length
|
|
78
|
+
|
|
79
|
+
parts << curve_segment(controls, end_pt)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
parts << "Z"
|
|
84
|
+
parts.join(" ")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def curve_segment(controls, end_pt)
|
|
88
|
+
end_x = end_pt.x
|
|
89
|
+
end_y = flip_y(end_pt.y)
|
|
90
|
+
|
|
91
|
+
case controls.size
|
|
92
|
+
when 1
|
|
93
|
+
"Q #{controls[0].x} #{flip_y(controls[0].y)} #{end_x} #{end_y}"
|
|
94
|
+
when 2
|
|
95
|
+
"C #{controls[0].x} #{flip_y(controls[0].y)} " \
|
|
96
|
+
"#{controls[1].x} #{flip_y(controls[1].y)} " \
|
|
97
|
+
"#{end_x} #{end_y}"
|
|
98
|
+
else
|
|
99
|
+
# 3+ controls: emit a Q to the midpoint of the first two,
|
|
100
|
+
# then continue. Rare in practice; degrades to Q chain.
|
|
101
|
+
mid_x = (controls[0].x + controls[1].x) / 2.0
|
|
102
|
+
mid_y = flip_y((controls[0].y + controls[1].y) / 2.0)
|
|
103
|
+
"Q #{controls[0].x} #{flip_y(controls[0].y)} #{mid_x} #{mid_y}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def flip_y(y)
|
|
108
|
+
@ascent - y.to_f
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def view_box_for(glyph)
|
|
112
|
+
bbox = bounding_box(glyph)
|
|
113
|
+
format("%<xmin>s %<ymin>s %<w>s %<h>s",
|
|
114
|
+
xmin: bbox[:x_min], ymin: bbox[:y_min],
|
|
115
|
+
w: (bbox[:x_max] - bbox[:x_min]),
|
|
116
|
+
h: (bbox[:y_max] - bbox[:y_min]))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def bounding_box(glyph)
|
|
120
|
+
points = glyph.contours.flat_map(&:points)
|
|
121
|
+
return { x_min: 0, y_min: 0, x_max: @units_per_em, y_max: @units_per_em } if points.empty?
|
|
122
|
+
|
|
123
|
+
xs = points.map(&:x)
|
|
124
|
+
ys = points.map(&:y)
|
|
125
|
+
{ x_min: xs.min, y_min: ys.min, x_max: xs.max, y_max: ys.max }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/fontisan/svg.rb
CHANGED
|
@@ -7,6 +7,7 @@ module Fontisan
|
|
|
7
7
|
autoload :FontFaceGenerator, "fontisan/svg/font_face_generator"
|
|
8
8
|
autoload :FontGenerator, "fontisan/svg/font_generator"
|
|
9
9
|
autoload :GlyphGenerator, "fontisan/svg/glyph_generator"
|
|
10
|
+
autoload :StandaloneGlyph, "fontisan/svg/standalone_glyph"
|
|
10
11
|
autoload :ViewBoxCalculator, "fontisan/svg/view_box_calculator"
|
|
11
12
|
end
|
|
12
13
|
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open-uri"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
|
|
7
|
+
module Fontisan
|
|
8
|
+
# Tasks supporting the developer workflow: fixture downloads, etc.
|
|
9
|
+
# Lives under its own namespace so Rakefiles and other tooling can
|
|
10
|
+
# load just the task plumbing without pulling in the full fontisan
|
|
11
|
+
# stack (BinData tables, UFO, etc.).
|
|
12
|
+
module Tasks
|
|
13
|
+
# Downloads a single fixture file with retry on transient network
|
|
14
|
+
# failures. Used by `rake fixtures:download` so a single CDN blip
|
|
15
|
+
# (5xx, connection reset, OpenTimeout) doesn't sink a fresh
|
|
16
|
+
# checkout. Permanent failures (404, malformed URL) surface
|
|
17
|
+
# immediately.
|
|
18
|
+
#
|
|
19
|
+
# The downloader is a focused class, not a procedural Rakefile
|
|
20
|
+
# patch, so the retry logic is unit-testable in isolation.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# Fontisan::Tasks::FixtureDownloader.new(
|
|
24
|
+
# url: "https://github.com/.../font.ttf",
|
|
25
|
+
# destination: "spec/fixtures/font.ttf",
|
|
26
|
+
# ).call
|
|
27
|
+
class FixtureDownloader
|
|
28
|
+
RETRIABLE_ERRORS = [
|
|
29
|
+
Net::OpenTimeout,
|
|
30
|
+
Net::ReadTimeout,
|
|
31
|
+
Errno::ECONNRESET,
|
|
32
|
+
Errno::ECONNREFUSED,
|
|
33
|
+
Errno::EHOSTUNREACH,
|
|
34
|
+
Errno::ETIMEDOUT,
|
|
35
|
+
EOFError,
|
|
36
|
+
IOError,
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# 5xx HTTP responses are transient server errors worth retrying.
|
|
40
|
+
# 4xx are permanent (404, 403) and must fail fast.
|
|
41
|
+
RETRIABLE_HTTP_STATUSES = (500..599)
|
|
42
|
+
|
|
43
|
+
DEFAULT_MAX_RETRIES = 3
|
|
44
|
+
DEFAULT_BASE_BACKOFF = 0.5 # seconds; doubles per attempt
|
|
45
|
+
|
|
46
|
+
# Error raised after exhausting all retries. Carries the last
|
|
47
|
+
# underlying exception so callers can log the root cause.
|
|
48
|
+
class Error < StandardError
|
|
49
|
+
attr_reader :last_error
|
|
50
|
+
|
|
51
|
+
def initialize(url:, attempts:, last_error:)
|
|
52
|
+
@last_error = last_error
|
|
53
|
+
super("Failed to download #{url} after #{attempts} attempts: " \
|
|
54
|
+
"#{last_error.class}: #{last_error.message}")
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
attr_reader :url, :destination, :max_retries, :base_backoff, :sleep_method
|
|
59
|
+
|
|
60
|
+
# @param url [String] source URL.
|
|
61
|
+
# @param destination [String] path to write bytes to. Parent dir
|
|
62
|
+
# is auto-created.
|
|
63
|
+
# @param max_retries [Integer] total attempts including the
|
|
64
|
+
# first. 3 means: try, retry, retry.
|
|
65
|
+
# @param base_backoff [Float] seconds to sleep before the first
|
|
66
|
+
# retry. Doubles per attempt.
|
|
67
|
+
# @param sleep_method [#call] injectable sleep (for tests).
|
|
68
|
+
# Defaults to Kernel.sleep.
|
|
69
|
+
def initialize(url:, destination:, max_retries: DEFAULT_MAX_RETRIES,
|
|
70
|
+
base_backoff: DEFAULT_BASE_BACKOFF, sleep_method: method(:sleep))
|
|
71
|
+
@url = url
|
|
72
|
+
@destination = destination
|
|
73
|
+
@max_retries = max_retries
|
|
74
|
+
@base_backoff = base_backoff
|
|
75
|
+
@sleep_method = sleep_method
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Performs the download. Returns the destination path on
|
|
79
|
+
# success. Raises {Error} after exhausting retries.
|
|
80
|
+
#
|
|
81
|
+
# @return [String] destination path
|
|
82
|
+
# @raise [Error]
|
|
83
|
+
def call
|
|
84
|
+
attempts = 0
|
|
85
|
+
nil
|
|
86
|
+
|
|
87
|
+
begin
|
|
88
|
+
attempts += 1
|
|
89
|
+
fetch_to_destination
|
|
90
|
+
destination
|
|
91
|
+
rescue StandardError => e
|
|
92
|
+
e
|
|
93
|
+
raise if permanent_failure?(e)
|
|
94
|
+
raise Error.new(url: url, attempts: attempts, last_error: e) if attempts >= max_retries
|
|
95
|
+
|
|
96
|
+
backoff = base_backoff * (2**(attempts - 1))
|
|
97
|
+
sleep_method.call(backoff)
|
|
98
|
+
retry
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def fetch_to_destination
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(destination))
|
|
106
|
+
|
|
107
|
+
# IO.copy_stream avoids loading the whole response into memory
|
|
108
|
+
# and is more Windows-compatible than remote.read + File.binwrite.
|
|
109
|
+
# URLs come from FixtureFonts config (version-controlled), not
|
|
110
|
+
# user input — same trust model as the previous inline URI.open
|
|
111
|
+
# call in the Rakefile.
|
|
112
|
+
#
|
|
113
|
+
# Parsing with URI.parse first satisfies CodeQL's "open with
|
|
114
|
+
# non-constant value" check: any string that isn't a valid URI
|
|
115
|
+
# raises URI::InvalidURIError before OpenURI can dispatch on
|
|
116
|
+
# it. The parsed URI's .open is OpenURI's standard entry.
|
|
117
|
+
# rubocop:disable Security/Open
|
|
118
|
+
URI.parse(url).open(open_uri_options) do |remote|
|
|
119
|
+
File.open(destination, "wb") do |file|
|
|
120
|
+
IO.copy_stream(remote, file)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
# rubocop:enable Security/Open
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `open-uri` follows redirects by default and surfaces HTTP
|
|
127
|
+
# errors as `OpenURI::HTTPError` whose `io.status` is the `[code,
|
|
128
|
+
# message]` array. We re-raise non-retriable 4xx as
|
|
129
|
+
# `permanent-failure`-tagged exceptions so the retry loop exits.
|
|
130
|
+
def open_uri_options
|
|
131
|
+
{
|
|
132
|
+
"User-Agent" => "fontisan-fixtures/1.0",
|
|
133
|
+
redirect: true,
|
|
134
|
+
open_timeout: 30,
|
|
135
|
+
read_timeout: 120,
|
|
136
|
+
}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def permanent_failure?(error)
|
|
140
|
+
case error
|
|
141
|
+
when OpenURI::HTTPError
|
|
142
|
+
status = parse_http_status(error)
|
|
143
|
+
status && !RETRIABLE_HTTP_STATUSES.cover?(status)
|
|
144
|
+
else
|
|
145
|
+
RETRIABLE_ERRORS.none? { |klass| error.is_a?(klass) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def parse_http_status(error)
|
|
150
|
+
io = error.io
|
|
151
|
+
return nil unless io
|
|
152
|
+
|
|
153
|
+
status = io.status
|
|
154
|
+
return nil unless status
|
|
155
|
+
|
|
156
|
+
status.first.to_i
|
|
157
|
+
rescue StandardError
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
# Tasks supporting the developer workflow: fixture downloads, etc.
|
|
5
|
+
# Lives under its own namespace so Rakefiles and other tooling can
|
|
6
|
+
# load just the task plumbing without pulling in the full fontisan
|
|
7
|
+
# stack (BinData tables, UFO, etc.).
|
|
8
|
+
module Tasks
|
|
9
|
+
autoload :FixtureDownloader, "fontisan/tasks/fixture_downloader"
|
|
10
|
+
end
|
|
11
|
+
end
|
data/lib/fontisan/ufo/cli.rb
CHANGED
|
@@ -14,20 +14,15 @@ module Fontisan
|
|
|
14
14
|
method_option :output, type: :string, required: true,
|
|
15
15
|
desc: "Output file path"
|
|
16
16
|
method_option :to, type: :string, default: "ttf",
|
|
17
|
-
desc: "Output format (ttf or
|
|
17
|
+
desc: "Output format (ttf, otf, or otf2)"
|
|
18
18
|
def build(ufo)
|
|
19
19
|
font = Font.open(ufo)
|
|
20
|
-
|
|
21
|
-
|
|
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])
|
|
20
|
+
format = (options[:to] || "ttf").to_s.downcase.to_sym
|
|
21
|
+
Convert.convert(font, to: format, output_path: options[:output])
|
|
30
22
|
puts "wrote #{options[:output]} (#{File.size(options[:output])} bytes)"
|
|
23
|
+
rescue ArgumentError => e
|
|
24
|
+
warn e.message
|
|
25
|
+
exit 1
|
|
31
26
|
rescue Errno::ENOENT
|
|
32
27
|
warn "UFO not found: #{ufo}"
|
|
33
28
|
exit 1
|
|
@@ -35,20 +30,12 @@ module Fontisan
|
|
|
35
30
|
|
|
36
31
|
desc "convert INPUT OUTPUT", "Convert between UFO and binary formats"
|
|
37
32
|
method_option :to, type: :string,
|
|
38
|
-
desc: "Override format detection (ttf, otf, ufo)"
|
|
33
|
+
desc: "Override format detection (ttf, otf, otf2, ufo)"
|
|
39
34
|
def convert(input, output)
|
|
40
35
|
if ufo?(input)
|
|
41
36
|
font = Font.open(input)
|
|
42
37
|
format = options[:to] || File.extname(output).delete(".").downcase
|
|
43
|
-
|
|
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)
|
|
38
|
+
Convert.convert(font, to: format.to_sym, output_path: output)
|
|
52
39
|
else
|
|
53
40
|
# Binary → UFO
|
|
54
41
|
loaded = Fontisan::FontLoader.load(input)
|
|
@@ -56,6 +43,9 @@ module Fontisan
|
|
|
56
43
|
Writer.new(ufo).write(output)
|
|
57
44
|
end
|
|
58
45
|
puts "wrote #{output}"
|
|
46
|
+
rescue ArgumentError => e
|
|
47
|
+
warn e.message
|
|
48
|
+
exit 1
|
|
59
49
|
end
|
|
60
50
|
|
|
61
51
|
desc "validate UFO", "Check a UFO source for structural issues"
|
|
@@ -75,6 +65,29 @@ module Fontisan
|
|
|
75
65
|
end
|
|
76
66
|
end
|
|
77
67
|
|
|
68
|
+
desc "extract UFO GLYPH_NAME OUTPUT.svg",
|
|
69
|
+
"Render a single glyph from a UFO as a standalone SVG"
|
|
70
|
+
def extract(ufo, glyph_name, output)
|
|
71
|
+
font = Font.open(ufo)
|
|
72
|
+
glyph = font.glyph(glyph_name)
|
|
73
|
+
raise ArgumentError, "glyph not found: #{glyph_name.inspect}" unless glyph
|
|
74
|
+
|
|
75
|
+
head = font.info
|
|
76
|
+
renderer = Fontisan::Svg::StandaloneGlyph.new(
|
|
77
|
+
units_per_em: head.units_per_em || 1000,
|
|
78
|
+
ascent: head.ascender || 800,
|
|
79
|
+
descent: head.descender || -200,
|
|
80
|
+
)
|
|
81
|
+
File.write(output, renderer.generate(glyph))
|
|
82
|
+
puts "wrote #{output} (#{File.size(output)} bytes)"
|
|
83
|
+
rescue ArgumentError => e
|
|
84
|
+
warn e.message
|
|
85
|
+
exit 1
|
|
86
|
+
rescue Errno::ENOENT
|
|
87
|
+
warn "UFO not found: #{ufo}"
|
|
88
|
+
exit 1
|
|
89
|
+
end
|
|
90
|
+
|
|
78
91
|
private
|
|
79
92
|
|
|
80
93
|
def ufo?(path)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# Value object returned by every feature writer's +#write+
|
|
8
|
+
# method. Carries the structured data the corresponding
|
|
9
|
+
# table builder needs to encode the binary subtable.
|
|
10
|
+
#
|
|
11
|
+
# Writers return +nil+ instead when the UFO has no data for
|
|
12
|
+
# the feature — the compiler skips +nil+ outputs.
|
|
13
|
+
FeatureOutput = Struct.new(
|
|
14
|
+
:table_tag, # "GPOS", "GSUB", "GDEF"
|
|
15
|
+
:feature_tag, # "kern", "mark", "curs", etc. (nil for GDEF)
|
|
16
|
+
:lookup_type, # Integer GSUB/GPOS lookup type
|
|
17
|
+
:data, # Hash — writer-specific structure
|
|
18
|
+
keyword_init: true,
|
|
19
|
+
) do
|
|
20
|
+
def table_tag
|
|
21
|
+
self[:table_tag]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def feature_tag
|
|
25
|
+
self[:feature_tag]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lookup_type
|
|
29
|
+
self[:lookup_type]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def data
|
|
33
|
+
self[:data]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Abstract base class for feature writers. Concrete writers
|
|
38
|
+
# (Kern, Gdef, Mark, etc.) extend this and implement +#write+.
|
|
39
|
+
class Base
|
|
40
|
+
attr_reader :font
|
|
41
|
+
|
|
42
|
+
# @param font [Fontisan::Ufo::Font] the UFO source
|
|
43
|
+
def initialize(font)
|
|
44
|
+
@font = font
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [FeatureOutput, nil] structured data for the
|
|
48
|
+
# feature, or +nil+ if the UFO has nothing for it
|
|
49
|
+
def write
|
|
50
|
+
raise NotImplementedError, "#{self.class} must implement #write"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# GPOS Cursive (lookup type 3) — cursive attachment.
|
|
8
|
+
#
|
|
9
|
+
# Pairs glyphs via their entry and exit anchors for cursive
|
|
10
|
+
# (joining) scripts. UFO convention: a glyph's +<name>entry+
|
|
11
|
+
# and +<name>exit+ anchors define where it connects to its
|
|
12
|
+
# neighbors.
|
|
13
|
+
#
|
|
14
|
+
# Pairs every glyph that has at least one +entry+ anchor with
|
|
15
|
+
# every glyph that has the corresponding +exit+ anchor.
|
|
16
|
+
# Multiple entry/exit class names are supported.
|
|
17
|
+
class Curs < Base
|
|
18
|
+
LOOKUP_TYPE = 3
|
|
19
|
+
FEATURE_TAG = "curs"
|
|
20
|
+
TABLE_TAG = "GPOS"
|
|
21
|
+
|
|
22
|
+
# @return [FeatureOutput, nil]
|
|
23
|
+
def write
|
|
24
|
+
attachments = {}
|
|
25
|
+
|
|
26
|
+
entry_glyphs = glyphs_with_anchor(/entry\z/)
|
|
27
|
+
exit_glyphs = glyphs_with_anchor(/exit\z/)
|
|
28
|
+
|
|
29
|
+
return nil if entry_glyphs.empty? && exit_glyphs.empty?
|
|
30
|
+
|
|
31
|
+
# Cursive attachment: each glyph can be both a "previous"
|
|
32
|
+
# (with exit anchor) and "next" (with entry anchor) in
|
|
33
|
+
# a cursive chain. The GPOS builder handles the
|
|
34
|
+
# cross-references; we emit every glyph with its entry
|
|
35
|
+
# and/or exit anchor positions.
|
|
36
|
+
font.glyphs.each_value do |glyph|
|
|
37
|
+
entry = find_anchor(glyph, /entry\z/)
|
|
38
|
+
exit = find_anchor(glyph, /exit\z/)
|
|
39
|
+
next unless entry || exit
|
|
40
|
+
|
|
41
|
+
attachments[glyph.name] = {
|
|
42
|
+
entry: entry && [entry.x, entry.y],
|
|
43
|
+
exit: exit && [exit.x, exit.y],
|
|
44
|
+
}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
return nil if attachments.empty?
|
|
48
|
+
|
|
49
|
+
FeatureOutput.new(
|
|
50
|
+
table_tag: TABLE_TAG,
|
|
51
|
+
feature_tag: FEATURE_TAG,
|
|
52
|
+
lookup_type: LOOKUP_TYPE,
|
|
53
|
+
data: { attachments: attachments },
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def glyphs_with_anchor(pattern)
|
|
60
|
+
font.glyphs.select do |_name, glyph|
|
|
61
|
+
glyph.anchors.any? { |a| pattern.match?(a.name.to_s) }
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def find_anchor(glyph, pattern)
|
|
66
|
+
glyph.anchors.find { |a| pattern.match?(a.name.to_s) }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# GDEF GlyphClassDef — classifies every glyph into one of
|
|
8
|
+
# the four OpenType glyph classes:
|
|
9
|
+
#
|
|
10
|
+
# 1 — Base glyph
|
|
11
|
+
# 2 — Ligature glyph
|
|
12
|
+
# 3 — Combining mark
|
|
13
|
+
# 4 — Glyph spacing variant (e.g. alternate space)
|
|
14
|
+
#
|
|
15
|
+
# The classification drives how GSUB/GPOS lookups apply. UFO
|
|
16
|
+
# sources carry this in the glyph's +lib+ plist under the
|
|
17
|
+
# +public.openTypeCategory+ key; this writer translates that
|
|
18
|
+
# into the structured form the GDEF builder expects.
|
|
19
|
+
class Gdef < Base
|
|
20
|
+
TABLE_TAG = "GDEF"
|
|
21
|
+
|
|
22
|
+
# Map UFO public.openTypeCategory values → OpenType glyph
|
|
23
|
+
# class integers. Glyphs whose category isn't listed get
|
|
24
|
+
# class 0 (unclassified — the GDEF ClassDef omits them).
|
|
25
|
+
CATEGORY_TO_CLASS = {
|
|
26
|
+
"base" => 1,
|
|
27
|
+
"ligature" => 2,
|
|
28
|
+
"mark" => 3,
|
|
29
|
+
"component" => 3, # UFO treats component as mark for GDEF
|
|
30
|
+
"spacing" => 4,
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
# @return [FeatureOutput]
|
|
34
|
+
def write
|
|
35
|
+
classifications = {}
|
|
36
|
+
|
|
37
|
+
font.glyphs.each_value do |glyph|
|
|
38
|
+
cls = classify(glyph)
|
|
39
|
+
classifications[glyph.name] = cls if cls
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# A GDEF without any classified glyphs is meaningless —
|
|
43
|
+
# return nil so the compiler skips the table.
|
|
44
|
+
return nil if classifications.empty?
|
|
45
|
+
|
|
46
|
+
FeatureOutput.new(
|
|
47
|
+
table_tag: TABLE_TAG,
|
|
48
|
+
feature_tag: nil,
|
|
49
|
+
lookup_type: nil,
|
|
50
|
+
data: { classes: classifications },
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
# Resolve a glyph's GDEF class. Prefer the explicit
|
|
57
|
+
# +public.openTypeCategory+ lib entry; fall back to a
|
|
58
|
+
# heuristic (glyphs with anchors named "top"/"bottom" are
|
|
59
|
+
# marks; glyphs with component references are ligatures
|
|
60
|
+
# only if their name has a +.liga+ suffix).
|
|
61
|
+
def classify(glyph)
|
|
62
|
+
category = glyph_lib_category(glyph)
|
|
63
|
+
return CATEGORY_TO_CLASS[category] if category && CATEGORY_TO_CLASS.key?(category)
|
|
64
|
+
|
|
65
|
+
heuristic_class(glyph)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def glyph_lib_category(glyph)
|
|
69
|
+
return nil unless glyph.lib
|
|
70
|
+
|
|
71
|
+
lib = glyph.lib
|
|
72
|
+
return lib["public.openTypeCategory"] if lib["public.openTypeCategory"]
|
|
73
|
+
|
|
74
|
+
categories = lib["public.openTypeCategories"]
|
|
75
|
+
categories[glyph.name] if categories.is_a?(Hash)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def heuristic_class(glyph)
|
|
79
|
+
anchor_names = glyph.anchors.map(&:name)
|
|
80
|
+
mark_anchor_pattern = /\A_[a-zA-Z]+\z/
|
|
81
|
+
return 3 if anchor_names.any? { |n| mark_anchor_pattern.match?(n.to_s) }
|
|
82
|
+
return 2 if glyph.name.to_s.end_with?(".liga")
|
|
83
|
+
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# GPOS PairPos (lookup type 2) — kerning pairs.
|
|
8
|
+
#
|
|
9
|
+
# Reads +font.kerning+ (parsed from kerning.plist) and emits
|
|
10
|
+
# a {FeatureOutput} whose +data+ is a structured list of
|
|
11
|
+
# pairs ready for the GPOS PairPos builder. Returns +nil+
|
|
12
|
+
# when the UFO has no kerning data — the compiler then
|
|
13
|
+
# omits the GPOS table entirely.
|
|
14
|
+
#
|
|
15
|
+
# Group-based kerning keys (e.g. +"@MMK_L_A @MMK_R_B"+) are
|
|
16
|
+
# emitted verbatim with +group: true+; the GPOS builder is
|
|
17
|
+
# responsible for resolving group references via the UFO's
|
|
18
|
+
# groups.plist data (TODO: groups.plist support is partial).
|
|
19
|
+
class Kern < Base
|
|
20
|
+
# GPOS lookup type 2 — PairPos (pair-positioning).
|
|
21
|
+
LOOKUP_TYPE = 2
|
|
22
|
+
FEATURE_TAG = "kern"
|
|
23
|
+
TABLE_TAG = "GPOS"
|
|
24
|
+
|
|
25
|
+
# @return [FeatureOutput, nil]
|
|
26
|
+
def write
|
|
27
|
+
return nil if font.kerning.nil? || font.kerning.empty?
|
|
28
|
+
|
|
29
|
+
pairs = font.kerning.pairs.map do |key, value|
|
|
30
|
+
left, right = key.to_s.split(" ", 2)
|
|
31
|
+
{
|
|
32
|
+
left: left,
|
|
33
|
+
right: right,
|
|
34
|
+
value: value.to_f,
|
|
35
|
+
group_left: left.to_s.start_with?("@"),
|
|
36
|
+
group_right: right.to_s.start_with?("@"),
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
FeatureOutput.new(
|
|
41
|
+
table_tag: TABLE_TAG,
|
|
42
|
+
feature_tag: FEATURE_TAG,
|
|
43
|
+
lookup_type: LOOKUP_TYPE,
|
|
44
|
+
data: { pairs: pairs },
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|