fontisan 0.4.11 → 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/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/source.rb +11 -7
- data/lib/fontisan/subset/table_subsetter.rb +104 -6
- data/lib/fontisan/svg/standalone_glyph.rb +129 -0
- data/lib/fontisan/svg.rb +1 -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
- metadata +27 -1
|
@@ -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
|
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
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# Extended kerning (format 2 / Device-table variant of PairPos).
|
|
8
|
+
#
|
|
9
|
+
# Same data shape as the basic {Kern} writer, but emits the
|
|
10
|
+
# extended format that supports:
|
|
11
|
+
# - device tables (sub-pixel kerning)
|
|
12
|
+
# - cross-stream kerning (y-direction adjustments)
|
|
13
|
+
# - per-pair value-format overrides
|
|
14
|
+
#
|
|
15
|
+
# The current implementation extracts the same kerning.pairs
|
|
16
|
+
# data as Kern; the differentiation is in how the GPOS table
|
|
17
|
+
# builder encodes the value format (Format1 vs Format2, plus
|
|
18
|
+
# device tables). Writers do not need to know about that —
|
|
19
|
+
# they just emit the structured data; the table builder
|
|
20
|
+
# picks the encoding.
|
|
21
|
+
#
|
|
22
|
+
# Downstream consumers (compilers) choose between Kern and
|
|
23
|
+
# Kern2 based on whether they need device tables. Most don't;
|
|
24
|
+
# basic Kern is sufficient for the common case.
|
|
25
|
+
class Kern2 < Base
|
|
26
|
+
LOOKUP_TYPE = 2
|
|
27
|
+
FEATURE_TAG = "kern"
|
|
28
|
+
TABLE_TAG = "GPOS"
|
|
29
|
+
FORMAT = 2
|
|
30
|
+
|
|
31
|
+
# @return [FeatureOutput, nil]
|
|
32
|
+
def write
|
|
33
|
+
return nil if font.kerning.nil? || font.kerning.empty?
|
|
34
|
+
|
|
35
|
+
pairs = font.kerning.pairs.map do |key, value|
|
|
36
|
+
left, right = key.to_s.split(" ", 2)
|
|
37
|
+
{
|
|
38
|
+
left: left,
|
|
39
|
+
right: right,
|
|
40
|
+
value: value.to_f,
|
|
41
|
+
group_left: left.to_s.start_with?("@"),
|
|
42
|
+
group_right: right.to_s.start_with?("@"),
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
FeatureOutput.new(
|
|
47
|
+
table_tag: TABLE_TAG,
|
|
48
|
+
feature_tag: FEATURE_TAG,
|
|
49
|
+
lookup_type: LOOKUP_TYPE,
|
|
50
|
+
data: { format: FORMAT, pairs: pairs },
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# GPOS MarkToBase (lookup type 4) + MarkToLigature (type 5).
|
|
8
|
+
#
|
|
9
|
+
# Builds mark attachments from the UFO's per-glyph anchors.
|
|
10
|
+
# Pair every mark glyph (one whose anchors include a
|
|
11
|
+
# +_<name>+ entry, e.g. +_top+) with every base glyph that
|
|
12
|
+
# has the matching non-underscore anchor (e.g. +top+).
|
|
13
|
+
#
|
|
14
|
+
# Prerequisite: {Filters::PropagateAnchors} should have run
|
|
15
|
+
# first so composite glyphs carry their inherited anchors.
|
|
16
|
+
class Mark < MarkFamilyBase
|
|
17
|
+
LOOKUP_TYPE = 4
|
|
18
|
+
FEATURE_TAG = "mark"
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def feature_tag
|
|
23
|
+
FEATURE_TAG
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def lookup_type
|
|
27
|
+
LOOKUP_TYPE
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Mark convention: _<name> → class_name = name (drop underscore).
|
|
31
|
+
def mark_class_from_anchor(anchor_name)
|
|
32
|
+
return nil unless anchor_name.start_with?("_")
|
|
33
|
+
|
|
34
|
+
anchor_name[1..]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Base convention: class_name is the anchor name verbatim.
|
|
38
|
+
def base_anchor_name_for(class_name)
|
|
39
|
+
class_name
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|