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,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
|
|
@@ -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
|