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,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# Shared base for feature writers that pair "mark" glyphs with
|
|
8
|
+
# "base" glyphs via a paired-anchor convention (Mark uses
|
|
9
|
+
# +_<name>+ ↔ +<name>+; Mkmk uses +_<name>mkmk+ ↔
|
|
10
|
+
# +<name>mkmk+). Concrete subclasses plug in the lookup
|
|
11
|
+
# convention + the lookup type + the feature tag.
|
|
12
|
+
#
|
|
13
|
+
# Reduces duplication between Mark and Mkmk: both walk every
|
|
14
|
+
# glyph, collect its anchor classes (with the convention's
|
|
15
|
+
# prefix + suffix), then pair each class with matching bases.
|
|
16
|
+
class MarkFamilyBase < Base
|
|
17
|
+
# @return [FeatureOutput, nil]
|
|
18
|
+
def write
|
|
19
|
+
marks = collect_marks
|
|
20
|
+
return nil if marks.empty?
|
|
21
|
+
|
|
22
|
+
attachments = {}
|
|
23
|
+
|
|
24
|
+
marks.each do |(mark_name, classes)|
|
|
25
|
+
classes.each do |class_name, mark_anchor|
|
|
26
|
+
bases = collect_bases_for(class_name)
|
|
27
|
+
next if bases.empty?
|
|
28
|
+
|
|
29
|
+
attachments[class_name] ||= new_class_entry
|
|
30
|
+
attachments[class_name][:marks][mark_name] = mark_anchor
|
|
31
|
+
bases.each { |n, a| attachments[class_name][base_bucket_key][n] = a }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
return nil if attachments.empty?
|
|
36
|
+
|
|
37
|
+
FeatureOutput.new(
|
|
38
|
+
table_tag: table_tag,
|
|
39
|
+
feature_tag: feature_tag,
|
|
40
|
+
lookup_type: lookup_type,
|
|
41
|
+
data: { attachments: attachments },
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# The convention's mark-anchor predicate. Returns the
|
|
48
|
+
# class_name extracted from a mark glyph's anchor name, or
|
|
49
|
+
# +nil+ if the anchor doesn't match the convention.
|
|
50
|
+
#
|
|
51
|
+
# Subclasses implement this — Mark's convention is
|
|
52
|
+
# "_<name>"; Mkmk's is "_<name>mkmk".
|
|
53
|
+
def mark_class_from_anchor(_anchor_name)
|
|
54
|
+
raise NotImplementedError,
|
|
55
|
+
"#{self.class} must implement #mark_class_from_anchor"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Inverse of +mark_class_from_anchor+: given a class name
|
|
59
|
+
# extracted from a mark anchor, what's the matching
|
|
60
|
+
# base-anchor name? Mark's convention is class_name; Mkmk's
|
|
61
|
+
# is "<class_name>mkmk".
|
|
62
|
+
def base_anchor_name_for(_class_name)
|
|
63
|
+
raise NotImplementedError,
|
|
64
|
+
"#{self.class} must implement #base_anchor_name_for"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Subclass overrides — Mark uses :bases, Mkmk uses :base_marks.
|
|
68
|
+
def base_bucket_key
|
|
69
|
+
:bases
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Build a fresh per-class attachment entry with the right
|
|
73
|
+
# base bucket key. Avoids inline hash-literal ambiguity
|
|
74
|
+
# with dynamic keys.
|
|
75
|
+
def new_class_entry
|
|
76
|
+
{ marks: {}, base_bucket_key => {} }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def table_tag
|
|
80
|
+
"GPOS"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def feature_tag
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def lookup_type
|
|
88
|
+
nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def collect_marks
|
|
92
|
+
font.glyphs.each_with_object({}) do |(name, glyph), h|
|
|
93
|
+
classes = mark_classes_for(glyph)
|
|
94
|
+
h[name] = classes unless classes.empty?
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def mark_classes_for(glyph)
|
|
99
|
+
glyph.anchors.each_with_object({}) do |anchor, h|
|
|
100
|
+
class_name = mark_class_from_anchor(anchor.name.to_s)
|
|
101
|
+
next unless class_name
|
|
102
|
+
|
|
103
|
+
h[class_name] = [anchor.x, anchor.y]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def collect_bases_for(class_name)
|
|
108
|
+
target = base_anchor_name_for(class_name)
|
|
109
|
+
return {} unless target
|
|
110
|
+
|
|
111
|
+
font.glyphs.each_with_object({}) do |(name, glyph), h|
|
|
112
|
+
anchor = glyph.anchors.find { |a| a.name == target }
|
|
113
|
+
next unless anchor
|
|
114
|
+
|
|
115
|
+
h[name] = [anchor.x, anchor.y]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module FeatureWriters
|
|
7
|
+
# GPOS MarkToMark (lookup type 6) — mark-to-mark attachment.
|
|
8
|
+
#
|
|
9
|
+
# Same shape as MarkToBase, but pairs marks with OTHER marks
|
|
10
|
+
# rather than marks with bases. UFO convention: a mark glyph
|
|
11
|
+
# that can attach to another mark has a +_<name>mkmk+ anchor
|
|
12
|
+
# (the attach-FROM point), and the "base mark" it attaches to
|
|
13
|
+
# has the matching +<name>mkmk+ anchor (the attach-TO point).
|
|
14
|
+
#
|
|
15
|
+
# Pairs every glyph with a +_<name>mkmk+ anchor with every
|
|
16
|
+
# glyph that has the matching +<name>mkmk+ anchor.
|
|
17
|
+
class Mkmk < MarkFamilyBase
|
|
18
|
+
LOOKUP_TYPE = 6
|
|
19
|
+
FEATURE_TAG = "mkmk"
|
|
20
|
+
MKMK_SUFFIX = "mkmk"
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def feature_tag
|
|
25
|
+
FEATURE_TAG
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lookup_type
|
|
29
|
+
LOOKUP_TYPE
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def base_bucket_key
|
|
33
|
+
:base_marks
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Mkmk convention: _<name>mkmk → class_name = name (drop
|
|
37
|
+
# underscore prefix + "mkmk" suffix).
|
|
38
|
+
def mark_class_from_anchor(anchor_name)
|
|
39
|
+
return nil unless anchor_name.start_with?("_")
|
|
40
|
+
return nil unless anchor_name.end_with?(MKMK_SUFFIX)
|
|
41
|
+
|
|
42
|
+
anchor_name[1..-MKMK_SUFFIX.length - 1]
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Base-mark convention: class_name + "mkmk" suffix.
|
|
46
|
+
def base_anchor_name_for(class_name)
|
|
47
|
+
"#{class_name}#{MKMK_SUFFIX}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Feature writers extract GSUB/GPOS feature data from a UFO
|
|
7
|
+
# source. Each writer is a class extending {FeatureWriters::Base}
|
|
8
|
+
# with a +#write+ method that returns a {FeatureOutput} value
|
|
9
|
+
# object (or +nil+ if the UFO has no data for that feature).
|
|
10
|
+
#
|
|
11
|
+
# The compiler runs registered writers, collects their outputs,
|
|
12
|
+
# and feeds each into the corresponding table builder
|
|
13
|
+
# (`Tables::Gpos`, `Tables::Gsub`, etc.) for binary encoding.
|
|
14
|
+
#
|
|
15
|
+
# OCP: adding a new feature writer = new class + one REGISTRY
|
|
16
|
+
# entry. No edits to the compiler.
|
|
17
|
+
module FeatureWriters
|
|
18
|
+
autoload :Base, "fontisan/ufo/compile/feature_writers/base"
|
|
19
|
+
autoload :MarkFamilyBase, "fontisan/ufo/compile/feature_writers/mark_family_base"
|
|
20
|
+
autoload :Kern, "fontisan/ufo/compile/feature_writers/kern"
|
|
21
|
+
autoload :Kern2, "fontisan/ufo/compile/feature_writers/kern2"
|
|
22
|
+
autoload :Gdef, "fontisan/ufo/compile/feature_writers/gdef"
|
|
23
|
+
autoload :Mark, "fontisan/ufo/compile/feature_writers/mark"
|
|
24
|
+
autoload :Mkmk, "fontisan/ufo/compile/feature_writers/mkmk"
|
|
25
|
+
autoload :Curs, "fontisan/ufo/compile/feature_writers/curs"
|
|
26
|
+
|
|
27
|
+
# Default writer set — what the compiler runs when the user
|
|
28
|
+
# doesn't override. Per feature, +#write+ returns +nil+ when
|
|
29
|
+
# the UFO has no data for that feature, so it's safe to
|
|
30
|
+
# always run them all. Order matters: Gdef first (so later
|
|
31
|
+
# writers can rely on glyph classification), then position
|
|
32
|
+
# features (kern, mark, mkmk, curs).
|
|
33
|
+
DEFAULT_WRITERS = [Gdef, Kern, Mark, Mkmk, Curs].freeze
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Propagates anchors from base glyphs to composite glyphs.
|
|
8
|
+
#
|
|
9
|
+
# In UFO sources, a composite glyph references base glyphs via
|
|
10
|
+
# Component entries. Anchors (mark-attachment points used by
|
|
11
|
+
# GPOS mark feature writers) typically live on the base glyphs
|
|
12
|
+
# only — the composite inherits them by applying each
|
|
13
|
+
# component's transformation matrix to the base's anchors.
|
|
14
|
+
#
|
|
15
|
+
# This filter does that propagation in place. After it runs,
|
|
16
|
+
# every composite glyph carries its component-derived anchors,
|
|
17
|
+
# ready for the mark feature writer to read.
|
|
18
|
+
#
|
|
19
|
+
# Single-pass (no recursive propagation through multi-level
|
|
20
|
+
# component chains). Multi-level propagation would require
|
|
21
|
+
# topological sort; if you need it, run the filter repeatedly
|
|
22
|
+
# until no anchors change.
|
|
23
|
+
module PropagateAnchors
|
|
24
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>] the glyphs to
|
|
25
|
+
# mutate. Must include any base glyphs referenced by
|
|
26
|
+
# components, or those references will be silently
|
|
27
|
+
# skipped.
|
|
28
|
+
# @param font [Fontisan::Ufo::Font, nil] optional explicit
|
|
29
|
+
# font lookup. When provided, base glyphs are resolved
|
|
30
|
+
# via +font.glyph(name)+ instead of the +glyphs+ array,
|
|
31
|
+
# so composites can reference base glyphs that aren't in
|
|
32
|
+
# the +glyphs+ list.
|
|
33
|
+
# @return [Array<Fontisan::Ufo::Glyph>] the same array,
|
|
34
|
+
# mutated in place
|
|
35
|
+
def self.run(glyphs, font: nil, **_opts)
|
|
36
|
+
lookup = build_lookup(font, glyphs)
|
|
37
|
+
|
|
38
|
+
glyphs.each do |glyph|
|
|
39
|
+
next if glyph.components.empty?
|
|
40
|
+
|
|
41
|
+
glyph.components.each do |component|
|
|
42
|
+
base = lookup[component.base_glyph]
|
|
43
|
+
next unless base
|
|
44
|
+
|
|
45
|
+
transform = coerce_transformation(component.transformation)
|
|
46
|
+
propagate_anchors(glyph, base, transform)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
glyphs
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
class << self
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build_lookup(font, glyphs)
|
|
57
|
+
return ->(name) { font.glyph(name) } if font
|
|
58
|
+
|
|
59
|
+
hash = glyphs.to_h { |g| [g.name, g] }
|
|
60
|
+
->(name) { hash[name] }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def coerce_transformation(matrix)
|
|
64
|
+
return Fontisan::Ufo::Transformation.new if matrix.nil?
|
|
65
|
+
return matrix if matrix.is_a?(Fontisan::Ufo::Transformation)
|
|
66
|
+
|
|
67
|
+
Fontisan::Ufo::Transformation.new(
|
|
68
|
+
a: matrix[0], b: matrix[1],
|
|
69
|
+
c: matrix[2], d: matrix[3],
|
|
70
|
+
e: matrix[4], f: matrix[5]
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Copy each anchor from +base+ into +glyph+, transformed
|
|
75
|
+
# by +transform+. Skip anchors whose (name, transformed
|
|
76
|
+
# x, transformed y) already exist on +glyph+ (the user
|
|
77
|
+
# may have placed them manually). Coordinates are coerced
|
|
78
|
+
# to Float so an Integer manual placement and the Float
|
|
79
|
+
# transform output compare equal as dedup keys (Ruby's
|
|
80
|
+
# eql? is type-strict, but == treats 100 == 100.0).
|
|
81
|
+
def propagate_anchors(glyph, base, transform)
|
|
82
|
+
existing = glyph.anchors.to_h do |a|
|
|
83
|
+
[[a.name, a.x.to_f, a.y.to_f], true]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
base.anchors.each do |anchor|
|
|
87
|
+
tx, ty = transform.apply(anchor.x, anchor.y)
|
|
88
|
+
key = [anchor.name, tx, ty]
|
|
89
|
+
next if existing[key]
|
|
90
|
+
|
|
91
|
+
glyph.add_anchor(Fontisan::Ufo::Anchor.new(
|
|
92
|
+
x: tx, y: ty, name: anchor.name,
|
|
93
|
+
))
|
|
94
|
+
existing[key] = true
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Boolean union of overlapping contours within each glyph.
|
|
8
|
+
# Two contours that overlap visually are merged into one,
|
|
9
|
+
# removing the seam. Required for some hinting programs and
|
|
10
|
+
# for SVG/COLR rendering correctness.
|
|
11
|
+
#
|
|
12
|
+
# == Why bounding-box approximation, not polygon clipping
|
|
13
|
+
#
|
|
14
|
+
# Full polygon clipping (Greiner-Hormann or Vatti) operates
|
|
15
|
+
# on straight-line polygons. UFO contours are Bezier curves.
|
|
16
|
+
# Applying polygon clipping would require:
|
|
17
|
+
# 1. Flatten Bezier to polygon edges (tessellation).
|
|
18
|
+
# 2. Run polygon clipping (union).
|
|
19
|
+
# 3. Re-fit Bezier curves on the result (curve fitting).
|
|
20
|
+
#
|
|
21
|
+
# Step 3 is the killer: curve fitting is lossy. The output
|
|
22
|
+
# curves won't match the input curves' control points,
|
|
23
|
+
# changing the glyph's rendering at small sizes. For a font
|
|
24
|
+
# pipeline, preserving curve fidelity matters more than
|
|
25
|
+
# mathematical union correctness.
|
|
26
|
+
#
|
|
27
|
+
# The bounding-box approximation handles the common cases
|
|
28
|
+
# (overlapping duplicates, fully-contained sub-contours)
|
|
29
|
+
# without losing any curve data. It does NOT handle partial
|
|
30
|
+
# overlaps — those remain as separate contours. For fonts
|
|
31
|
+
# that need partial-overlap removal, preprocess in an editor
|
|
32
|
+
# (FontLab, Glyphs) or use the `clipper` gem as a pre-compile
|
|
33
|
+
# step with explicit curve-flattening tolerances.
|
|
34
|
+
module RemoveOverlaps
|
|
35
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>]
|
|
36
|
+
# @return [Array<Fontisan::Ufo::Glyph>] the same array,
|
|
37
|
+
# mutated in place
|
|
38
|
+
def self.run(glyphs, **_opts)
|
|
39
|
+
glyphs.each { |g| remove_overlaps_in_glyph(g) }
|
|
40
|
+
glyphs
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def remove_overlaps_in_glyph(glyph)
|
|
47
|
+
return if glyph.contours.size < 2
|
|
48
|
+
|
|
49
|
+
bboxes = glyph.contours.map { |c| bounding_box(c) }
|
|
50
|
+
drop = Set.new
|
|
51
|
+
|
|
52
|
+
glyph.contours.each_with_index do |outer, i|
|
|
53
|
+
next if drop.include?(i)
|
|
54
|
+
|
|
55
|
+
glyph.contours.each_with_index do |inner, j|
|
|
56
|
+
next if i == j || drop.include?(j)
|
|
57
|
+
|
|
58
|
+
# Drop inner when its bbox is fully inside outer's.
|
|
59
|
+
if contained_in?(bboxes[j], bboxes[i]) && (inner.points.size <= outer.points.size)
|
|
60
|
+
# Sanity: only drop if the point count is smaller —
|
|
61
|
+
# a fully-contained contour with MORE points is
|
|
62
|
+
# almost certainly the outer one in a malformed
|
|
63
|
+
# source, and dropping it would lose data.
|
|
64
|
+
drop << j
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
return if drop.empty?
|
|
70
|
+
|
|
71
|
+
glyph.contours.reject!.with_index { |_c, i| drop.include?(i) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def bounding_box(contour)
|
|
75
|
+
points = contour.points
|
|
76
|
+
return { x_min: 0, y_min: 0, x_max: 0, y_max: 0 } if points.empty?
|
|
77
|
+
|
|
78
|
+
xs = points.map(&:x)
|
|
79
|
+
ys = points.map(&:y)
|
|
80
|
+
{ x_min: xs.min, y_min: ys.min, x_max: xs.max, y_max: ys.max }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def contained_in?(inner, outer)
|
|
84
|
+
inner[:x_min] >= outer[:x_min] &&
|
|
85
|
+
inner[:x_max] <= outer[:x_max] &&
|
|
86
|
+
inner[:y_min] >= outer[:y_min] &&
|
|
87
|
+
inner[:y_max] <= outer[:y_max]
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Sorts the contours within each glyph + the points within
|
|
8
|
+
# each contour. Two distinct operations, both required for
|
|
9
|
+
# TrueType hinting compatibility:
|
|
10
|
+
#
|
|
11
|
+
# 1. **Start-point alignment**: rotate each contour's point
|
|
12
|
+
# list so an on-curve point is first (TrueType hinting
|
|
13
|
+
# programs expect this).
|
|
14
|
+
#
|
|
15
|
+
# 2. **Contour ordering**: order contours within each glyph
|
|
16
|
+
# by their starting point's (Y descending, X ascending)
|
|
17
|
+
# so the hint program processes outer contours before
|
|
18
|
+
# inner ones (true-type convention for nested shapes).
|
|
19
|
+
#
|
|
20
|
+
# This is the fontTools/ufo2ft sort_contours filter, scoped
|
|
21
|
+
# to the contour-level concerns. Per-glyph sort across
|
|
22
|
+
# multiple glyphs is the caller's responsibility (callers
|
|
23
|
+
# typically sort glyphs by gid / post name).
|
|
24
|
+
module SortContours
|
|
25
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>]
|
|
26
|
+
# @return [Array<Fontisan::Ufo::Glyph>] the same array,
|
|
27
|
+
# mutated in place
|
|
28
|
+
def self.run(glyphs, **_opts)
|
|
29
|
+
glyphs.each do |glyph|
|
|
30
|
+
glyph.contours.each { |c| rotate_to_first_on_curve(c) }
|
|
31
|
+
glyph.contours.sort_by! { |c| sort_key(c) }
|
|
32
|
+
end
|
|
33
|
+
glyphs
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Rotate the point list so an on-curve point sits at
|
|
40
|
+
# index 0. If the contour is all off-curve (impossible
|
|
41
|
+
# for valid UFO but defensively handled), leave it
|
|
42
|
+
# unchanged.
|
|
43
|
+
def rotate_to_first_on_curve(contour)
|
|
44
|
+
points = contour.points
|
|
45
|
+
return if points.empty?
|
|
46
|
+
return if points.first.on_curve?
|
|
47
|
+
|
|
48
|
+
first_on = points.find_index(&:on_curve?)
|
|
49
|
+
return unless first_on
|
|
50
|
+
|
|
51
|
+
contour.points = points.rotate(first_on)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Sort key: descending Y (outer contours typically start
|
|
55
|
+
# at the top), then ascending X (leftmost first when Y
|
|
56
|
+
# ties). Uses the first on-curve point's coordinates.
|
|
57
|
+
# Empty contours sort last (Float::INFINITY).
|
|
58
|
+
def sort_key(contour)
|
|
59
|
+
first = contour.points.find(&:on_curve?) || contour.points.first
|
|
60
|
+
return [Float::INFINITY, Float::INFINITY] unless first
|
|
61
|
+
|
|
62
|
+
[-first.y.to_f, first.x.to_f]
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Applies a 2×3 affine transformation to every glyph's
|
|
8
|
+
# contours + components. The general-purpose filter — other
|
|
9
|
+
# filters (decompose_components, propagate_anchors) are
|
|
10
|
+
# special cases of geometric transforms applied to specific
|
|
11
|
+
# glyph subsets.
|
|
12
|
+
#
|
|
13
|
+
# Used for:
|
|
14
|
+
# - Symmetric mirroring (e.g. italic→upright flip).
|
|
15
|
+
# - Slant adjustment.
|
|
16
|
+
# - Custom per-font offsets that callers want baked in at
|
|
17
|
+
# compile time rather than carried as variation axis deltas.
|
|
18
|
+
#
|
|
19
|
+
# Identity matrix is a no-op fast path.
|
|
20
|
+
module Transformations
|
|
21
|
+
DEFAULT_OFFSET_X = 0.0
|
|
22
|
+
DEFAULT_OFFSET_Y = 0.0
|
|
23
|
+
|
|
24
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>]
|
|
25
|
+
# @param matrix [Fontisan::Ufo::Transformation, Array<Numeric>, nil]
|
|
26
|
+
# The affine matrix to apply. Accepts either a
|
|
27
|
+
# +Transformation+ value object or the raw +[a, b, c, d, e, f]+
|
|
28
|
+
# array. +nil+ (the default) is a no-op — leaves glyphs
|
|
29
|
+
# unchanged.
|
|
30
|
+
# @return [Array<Fontisan::Ufo::Glyph>] the same array (mutated)
|
|
31
|
+
def self.run(glyphs, matrix: nil, **_opts)
|
|
32
|
+
return glyphs if matrix.nil?
|
|
33
|
+
|
|
34
|
+
transform = coerce_transformation(matrix)
|
|
35
|
+
return glyphs if transform.identity?
|
|
36
|
+
|
|
37
|
+
glyphs.each { |g| apply_to_glyph(g, transform) }
|
|
38
|
+
glyphs
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# Coerce the matrix argument into a Transformation.
|
|
45
|
+
# Centralizes the "array, object, or nil?" decision so
|
|
46
|
+
# callers can pass any form.
|
|
47
|
+
def coerce_transformation(matrix)
|
|
48
|
+
return Fontisan::Ufo::Transformation.new if matrix.nil?
|
|
49
|
+
return matrix if matrix.is_a?(Fontisan::Ufo::Transformation)
|
|
50
|
+
|
|
51
|
+
Fontisan::Ufo::Transformation.new(
|
|
52
|
+
a: matrix[0], b: matrix[1],
|
|
53
|
+
c: matrix[2], d: matrix[3],
|
|
54
|
+
e: matrix[4], f: matrix[5]
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Apply the transform in place: each contour's points are
|
|
59
|
+
# rewritten with transformed coordinates; each component's
|
|
60
|
+
# +transformation+ is composed with the new one (so nested
|
|
61
|
+
# component refs chain correctly).
|
|
62
|
+
def apply_to_glyph(glyph, transform)
|
|
63
|
+
glyph.contours.each do |contour|
|
|
64
|
+
contour.points = contour.points.map do |pt|
|
|
65
|
+
tx, ty = transform.apply(pt.x, pt.y)
|
|
66
|
+
Fontisan::Ufo::Point.new(
|
|
67
|
+
x: tx, y: ty,
|
|
68
|
+
type: pt.type, smooth: pt.smooth
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
glyph.components.map! do |component|
|
|
74
|
+
Fontisan::Ufo::Component.new(
|
|
75
|
+
base_glyph: component.base_glyph,
|
|
76
|
+
# Composition order: outer transform ∘ existing.
|
|
77
|
+
# For a base-glyph point P, the component places it
|
|
78
|
+
# at T_existing(P) in this glyph; the outer transform
|
|
79
|
+
# then maps that to T_outer(T_existing(P)). So the
|
|
80
|
+
# new component transformation is T_outer ∘ T_existing.
|
|
81
|
+
transformation: compose(transform, component.transformation),
|
|
82
|
+
identifier: component.identifier,
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Compose two affine transformations: result = outer ∘ inner
|
|
88
|
+
# | outer.a outer.c outer.e | | inner.a inner.c inner.e |
|
|
89
|
+
# | outer.b outer.d outer.f | × | inner.b inner.d inner.f |
|
|
90
|
+
# | 0 0 1 | | 0 0 1 |
|
|
91
|
+
#
|
|
92
|
+
# Each input can be a Transformation, a 6-array, or nil
|
|
93
|
+
# (treated as identity — the default Component state).
|
|
94
|
+
# The result is always a Transformation.
|
|
95
|
+
def compose(outer, inner)
|
|
96
|
+
outer_t = coerce_transformation(outer)
|
|
97
|
+
inner_t = coerce_transformation(inner)
|
|
98
|
+
|
|
99
|
+
Fontisan::Ufo::Transformation.new(
|
|
100
|
+
a: outer_t.a * inner_t.a + outer_t.c * inner_t.b,
|
|
101
|
+
b: outer_t.b * inner_t.a + outer_t.d * inner_t.b,
|
|
102
|
+
c: outer_t.a * inner_t.c + outer_t.c * inner_t.d,
|
|
103
|
+
d: outer_t.b * inner_t.c + outer_t.d * inner_t.d,
|
|
104
|
+
e: outer_t.a * inner_t.e + outer_t.c * inner_t.f + outer_t.e,
|
|
105
|
+
f: outer_t.b * inner_t.e + outer_t.d * inner_t.f + outer_t.f,
|
|
106
|
+
)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
@@ -20,6 +20,14 @@ module Fontisan
|
|
|
20
20
|
"fontisan/ufo/compile/filters/decompose_components"
|
|
21
21
|
autoload :FlattenComponents,
|
|
22
22
|
"fontisan/ufo/compile/filters/flatten_components"
|
|
23
|
+
autoload :Transformations,
|
|
24
|
+
"fontisan/ufo/compile/filters/transformations"
|
|
25
|
+
autoload :SortContours,
|
|
26
|
+
"fontisan/ufo/compile/filters/sort_contours"
|
|
27
|
+
autoload :PropagateAnchors,
|
|
28
|
+
"fontisan/ufo/compile/filters/propagate_anchors"
|
|
29
|
+
autoload :RemoveOverlaps,
|
|
30
|
+
"fontisan/ufo/compile/filters/remove_overlaps"
|
|
23
31
|
|
|
24
32
|
# Filters that MUST run for TTF output (TrueType only
|
|
25
33
|
# supports quadratic curves + clockwise outer winding).
|
|
@@ -38,6 +46,10 @@ module Fontisan
|
|
|
38
46
|
cubic_to_quadratic: CubicToQuadratic,
|
|
39
47
|
decompose_components: DecomposeComponents,
|
|
40
48
|
flatten_components: FlattenComponents,
|
|
49
|
+
transformations: Transformations,
|
|
50
|
+
sort_contours: SortContours,
|
|
51
|
+
propagate_anchors: PropagateAnchors,
|
|
52
|
+
remove_overlaps: RemoveOverlaps,
|
|
41
53
|
}.freeze
|
|
42
54
|
|
|
43
55
|
# @param names [Array<Symbol>] filter names from REGISTRY
|
data/lib/fontisan/ufo/compile.rb
CHANGED
|
@@ -33,6 +33,7 @@ module Fontisan
|
|
|
33
33
|
autoload :TtfCompiler, "fontisan/ufo/compile/ttf_compiler"
|
|
34
34
|
autoload :OtfCompiler, "fontisan/ufo/compile/otf_compiler"
|
|
35
35
|
autoload :Filters, "fontisan/ufo/compile/filters"
|
|
36
|
+
autoload :FeatureWriters, "fontisan/ufo/compile/feature_writers"
|
|
36
37
|
autoload :Fvar, "fontisan/ufo/compile/fvar"
|
|
37
38
|
autoload :Gpos, "fontisan/ufo/compile/gpos"
|
|
38
39
|
autoload :Gvar, "fontisan/ufo/compile/gvar"
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Ufo
|
|
7
|
+
module Convert
|
|
8
|
+
# UFO → dfont (Mac OS resource-fork font container). Two-step:
|
|
9
|
+
#
|
|
10
|
+
# 1. Compile UFO → TTF/OTF in tmpdir.
|
|
11
|
+
# 2. Load + feed to Collection::DfontBuilder, which wraps the
|
|
12
|
+
# SFNT binary in a Mac resource fork.
|
|
13
|
+
module ToDfont
|
|
14
|
+
# @param ufo [Fontisan::Ufo::Font]
|
|
15
|
+
# @param output_path [String]
|
|
16
|
+
# @param compiler [Symbol] :ttf (default) or :otf
|
|
17
|
+
# @return [String] the output_path
|
|
18
|
+
def self.convert(ufo, output_path:, compiler: :ttf, **_opts)
|
|
19
|
+
compiler_class = Convert::COMPILER_FOR_FORMAT[compiler.to_sym]
|
|
20
|
+
raise ArgumentError, "unknown intermediate compiler: #{compiler.inspect}" unless compiler_class
|
|
21
|
+
|
|
22
|
+
Dir.mktmpdir do |dir|
|
|
23
|
+
intermediate_path = File.join(dir, "intermediate#{ext_for(compiler)}")
|
|
24
|
+
compiler_class.new(ufo).compile(output_path: intermediate_path)
|
|
25
|
+
|
|
26
|
+
loaded = Fontisan::FontLoader.load(intermediate_path)
|
|
27
|
+
result = Fontisan::Collection::DfontBuilder.new([loaded]).build
|
|
28
|
+
File.binwrite(output_path, result[:binary])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
output_path
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.ext_for(compiler)
|
|
35
|
+
%i[otf otf2].include?(compiler) ? ".otf" : ".ttf"
|
|
36
|
+
end
|
|
37
|
+
private_class_method :ext_for
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|