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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +27 -1
  3. data/docs/AFDKO_MIGRATION.adoc +175 -0
  4. data/docs/FEATURE_PARITY.adoc +263 -0
  5. data/docs/STITCHER_GUIDE.adoc +100 -6
  6. data/docs/TTX.adoc +176 -0
  7. data/docs/UFO_COMPILATION.adoc +281 -3
  8. data/lib/fontisan/stitcher/source.rb +11 -7
  9. data/lib/fontisan/subset/table_subsetter.rb +104 -6
  10. data/lib/fontisan/svg/standalone_glyph.rb +129 -0
  11. data/lib/fontisan/svg.rb +1 -0
  12. data/lib/fontisan/ufo/cli.rb +34 -21
  13. data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
  14. data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
  15. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
  16. data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
  17. data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
  18. data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
  19. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
  20. data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
  21. data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
  22. data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
  23. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
  24. data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
  25. data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
  26. data/lib/fontisan/ufo/compile/filters.rb +12 -0
  27. data/lib/fontisan/ufo/compile.rb +1 -0
  28. data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
  29. data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
  30. data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
  31. data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
  32. data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
  33. data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
  34. data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
  35. data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
  36. data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
  37. data/lib/fontisan/ufo/convert.rb +68 -5
  38. data/lib/fontisan/ufo/transformation.rb +11 -0
  39. data/lib/fontisan/version.rb +1 -1
  40. 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
@@ -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