fontisan 0.4.6 → 0.4.7

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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/BUG-stitcher-drops-isolated-cps.md +58 -0
  3. data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
  4. data/BUG-stitcher-gid-cap-65535.md +110 -0
  5. data/CHANGELOG.md +106 -0
  6. data/README.adoc +121 -68
  7. data/benchmark/compile_benchmark.rb +70 -0
  8. data/docs/CFF2_SUPPORT.adoc +184 -0
  9. data/docs/STITCHER_GUIDE.adoc +151 -0
  10. data/docs/SVG_TO_GLYF.adoc +118 -0
  11. data/docs/UFO_COMPILATION.adoc +119 -0
  12. data/lib/fontisan/collection/writer.rb +5 -6
  13. data/lib/fontisan/error.rb +31 -0
  14. data/lib/fontisan/stitcher/deduplicator.rb +47 -0
  15. data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
  16. data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
  17. data/lib/fontisan/stitcher.rb +188 -167
  18. data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
  19. data/lib/fontisan/svg_to_glyf/document.rb +83 -0
  20. data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
  21. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
  22. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
  23. data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
  24. data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
  25. data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
  26. data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
  27. data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
  28. data/lib/fontisan/svg_to_glyf/path.rb +14 -0
  29. data/lib/fontisan/svg_to_glyf.rb +62 -0
  30. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
  31. data/lib/fontisan/tables/cff.rb +1 -0
  32. data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
  33. data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
  34. data/lib/fontisan/tables/cff2/header.rb +34 -0
  35. data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
  36. data/lib/fontisan/tables/cff2.rb +4 -0
  37. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
  38. data/lib/fontisan/ufo/compile/cff2.rb +181 -0
  39. data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
  40. data/lib/fontisan/ufo/compile/colr.rb +80 -0
  41. data/lib/fontisan/ufo/compile/cpal.rb +61 -0
  42. data/lib/fontisan/ufo/compile/math.rb +143 -0
  43. data/lib/fontisan/ufo/compile/meta.rb +51 -0
  44. data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
  45. data/lib/fontisan/ufo/compile/sbix.rb +99 -0
  46. data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
  47. data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
  48. data/lib/fontisan/ufo/compile.rb +11 -0
  49. data/lib/fontisan/version.rb +1 -1
  50. data/lib/fontisan.rb +3 -0
  51. metadata +41 -2
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ module Geometry
6
+ # A 2×3 affine transform representing:
7
+ #
8
+ # x' = a·x + c·y + e
9
+ # y' = b·x + d·y + f
10
+ #
11
+ # Every coordinate operation in SvgToGlyf (SVG group transforms,
12
+ # viewBox mapping, Y-flip, UPM scaling) is modeled as an
13
+ # AffineTransform so they can be composed into a single matrix
14
+ # applied once per point.
15
+ class AffineTransform
16
+ attr_reader :a, :b, :c, :d, :e, :f
17
+
18
+ # @param a [Float] x-scale
19
+ # @param b [Float] y-skew-x
20
+ # @param c [Float] x-skew-y
21
+ # @param d [Float] y-scale
22
+ # @param e [Float] x-translate
23
+ # @param f [Float] y-translate
24
+ def initialize(a, b, c, d, e, f)
25
+ @a = a.to_f
26
+ @b = b.to_f
27
+ @c = c.to_f
28
+ @d = d.to_f
29
+ @e = e.to_f
30
+ @f = f.to_f
31
+ end
32
+
33
+ def self.identity
34
+ new(1, 0, 0, 1, 0, 0)
35
+ end
36
+
37
+ def self.translate(tx, ty = 0)
38
+ new(1, 0, 0, 1, tx, ty)
39
+ end
40
+
41
+ def self.scale(sx, sy = nil)
42
+ sy = sx if sy.nil?
43
+ new(sx, 0, 0, sy, 0, 0)
44
+ end
45
+
46
+ def self.rotate_radians(angle)
47
+ cos = Math.cos(angle)
48
+ sin = Math.sin(angle)
49
+ new(cos, sin, -sin, cos, 0, 0)
50
+ end
51
+
52
+ def self.rotate_degrees(angle)
53
+ rotate_radians(angle.to_f * Math::PI / 180.0)
54
+ end
55
+
56
+ def self.skew_x_radians(angle)
57
+ new(1, 0, Math.tan(angle), 1, 0, 0)
58
+ end
59
+
60
+ def self.skew_y_radians(angle)
61
+ new(1, Math.tan(angle), 0, 1, 0, 0)
62
+ end
63
+
64
+ # Reflect across a horizontal line at y = axis.
65
+ # Points above the axis map below it and vice versa.
66
+ def self.flip_y(axis)
67
+ new(1, 0, 0, -1, 0, 2 * axis)
68
+ end
69
+
70
+ # Compose this transform with `other`, returning a new
71
+ # AffineTransform equivalent to applying `other` first,
72
+ # then `self`. This matches SVG's `transform="self other"`
73
+ # convention.
74
+ #
75
+ # result.apply(x, y) == self.apply(*other.apply(x, y))
76
+ def compose(other)
77
+ AffineTransform.new(
78
+ @a * other.a + @c * other.b,
79
+ @b * other.a + @d * other.b,
80
+ @a * other.c + @c * other.d,
81
+ @b * other.c + @d * other.d,
82
+ @a * other.e + @c * other.f + @e,
83
+ @b * other.e + @d * other.f + @f,
84
+ )
85
+ end
86
+
87
+ # @param x [Numeric]
88
+ # @param y [Numeric]
89
+ # @return [Array(Float, Float)] transformed [x', y']
90
+ def apply(x, y)
91
+ [@a * x + @c * y + @e, @b * x + @d * y + @f]
92
+ end
93
+
94
+ def identity?
95
+ @a == 1 && @b.zero? && @c.zero? && @d == 1 && @e.zero? && @f.zero?
96
+ end
97
+
98
+ def ==(other)
99
+ other.is_a?(AffineTransform) &&
100
+ @a == other.a && @b == other.b && @c == other.c &&
101
+ @d == other.d && @e == other.e && @f == other.f
102
+ end
103
+
104
+ alias eql? ==
105
+
106
+ def hash
107
+ [@a, @b, @c, @d, @e, @f].hash
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ module Geometry
6
+ # Computes the affine transform that maps SVG viewBox coordinates
7
+ # (Y-down, origin at top-left) into font coordinates (Y-up, origin
8
+ # at bottom-left, scaled to UPM).
9
+ #
10
+ # The normalization combines a Y-flip across the viewBox midline
11
+ # with a uniform scale from viewBox units to font units. The
12
+ # resulting matrix is then composed with the SVG document's
13
+ # accumulated group transform to produce the final per-point
14
+ # transform.
15
+ class Normalizer
16
+ attr_reader :viewbox_width, :viewbox_height, :upm
17
+
18
+ # @param viewbox_width [Float] SVG viewBox width
19
+ # @param viewbox_height [Float] SVG viewBox height
20
+ # @param upm [Integer] font units-per-em
21
+ def initialize(viewbox_width:, viewbox_height:, upm:)
22
+ @viewbox_width = viewbox_width.to_f
23
+ @viewbox_height = viewbox_height.to_f
24
+ @upm = upm.to_f
25
+ end
26
+
27
+ # @return [AffineTransform] the viewBox→font normalization
28
+ def matrix
29
+ sx = @upm / @viewbox_width
30
+ sy = @upm / @viewbox_height
31
+ AffineTransform.new(sx, 0, 0, -sy, 0, @upm)
32
+ end
33
+
34
+ # Compose the normalization with an SVG group transform,
35
+ # producing the final per-point transform.
36
+ #
37
+ # @param group_transform [AffineTransform] accumulated <g> transforms
38
+ # @return [AffineTransform] final transform: font_point = N · T · path_point
39
+ def final_transform(group_transform = AffineTransform.identity)
40
+ matrix.compose(group_transform)
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ module Geometry
6
+ # Parses an SVG `transform="..."` attribute string into a single
7
+ # AffineTransform representing the accumulated composition.
8
+ #
9
+ # Supports all six SVG transform functions:
10
+ # translate, scale, rotate, matrix, skewX, skewY
11
+ #
12
+ # Multiple functions compose left-to-right (leftmost is applied
13
+ # last to the point), matching the SVG specification.
14
+ module TransformParser
15
+ FUNCTION_RE = /(\w+)\s*\(([^)]*)\)/
16
+
17
+ # @param transform_string [String, nil] the SVG transform attribute
18
+ # @return [AffineTransform]
19
+ def self.parse(transform_string)
20
+ return AffineTransform.identity if transform_string.nil? || transform_string.strip.empty?
21
+
22
+ transforms = transform_string.scan(FUNCTION_RE).map do |name, args|
23
+ build_transform(name, args)
24
+ end
25
+ transforms.reduce(AffineTransform.identity) { |acc, t| acc.compose(t) }
26
+ end
27
+
28
+ def self.build_transform(name, args_string)
29
+ args = parse_args(args_string)
30
+ case name
31
+ when "translate" then build_translate(args)
32
+ when "scale" then build_scale(args)
33
+ when "rotate" then build_rotate(args)
34
+ when "matrix" then build_matrix(args)
35
+ when "skewX" then AffineTransform.skew_x_radians(degrees_to_radians(args.fetch(0)))
36
+ when "skewY" then AffineTransform.skew_y_radians(degrees_to_radians(args.fetch(0)))
37
+ else
38
+ raise ArgumentError, "unknown SVG transform function: #{name.inspect}"
39
+ end
40
+ end
41
+
42
+ def self.build_translate(args)
43
+ AffineTransform.translate(args.fetch(0, 0), args.fetch(1, 0))
44
+ end
45
+
46
+ def self.build_scale(args)
47
+ sx = args.fetch(0, 1)
48
+ sy = args.fetch(1, sx)
49
+ AffineTransform.scale(sx, sy)
50
+ end
51
+
52
+ def self.build_rotate(args)
53
+ angle = args.fetch(0)
54
+ return AffineTransform.rotate_degrees(angle) if args.size < 3
55
+
56
+ cx = args.fetch(1)
57
+ cy = args.fetch(2)
58
+ around_point(AffineTransform.rotate_degrees(angle), cx, cy)
59
+ end
60
+
61
+ def self.build_matrix(args)
62
+ raise ArgumentError, "matrix() requires 6 arguments, got #{args.size}" if args.size != 6
63
+
64
+ AffineTransform.new(args[0], args[1], args[2], args[3], args[4], args[5])
65
+ end
66
+
67
+ # Rotate around a specific point: translate to origin, rotate,
68
+ # translate back.
69
+ def self.around_point(rotation, cx, cy)
70
+ AffineTransform.translate(cx, cy)
71
+ .compose(rotation)
72
+ .compose(AffineTransform.translate(-cx, -cy))
73
+ end
74
+
75
+ # Parse comma/whitespace-separated numbers from a function arg list.
76
+ def self.parse_args(args_string)
77
+ args_string.to_s.scan(/[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/)
78
+ .map(&:to_f)
79
+ end
80
+
81
+ def self.degrees_to_radians(degrees)
82
+ degrees.to_f * Math::PI / 180.0
83
+ end
84
+
85
+ private_class_method :build_transform, :build_translate, :build_scale,
86
+ :build_rotate, :build_matrix, :around_point,
87
+ :parse_args, :degrees_to_radians
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ # Geometry primitives for SvgToGlyf: affine transforms, SVG
6
+ # transform attribute parsing, and coordinate normalization.
7
+ module Geometry
8
+ autoload :AffineTransform, "fontisan/svg_to_glyf/geometry/affine_transform"
9
+ autoload :TransformParser, "fontisan/svg_to_glyf/geometry/transform_parser"
10
+ autoload :Normalizer, "fontisan/svg_to_glyf/geometry/normalizer"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ module Path
6
+ # A single parsed SVG path command.
7
+ #
8
+ # @attr type [Symbol] one of :M, :L, :H, :V, :C, :S, :Q, :T, :Z
9
+ # @attr absolute [Boolean] true for uppercase (absolute), false for lowercase (relative)
10
+ # @attr args [Array<Float>] the numeric arguments in order
11
+ Command = Struct.new(:type, :absolute, :args, keyword_init: true) do
12
+ def relative?
13
+ !absolute
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ module Path
6
+ # Converts an array of Path::Command objects into an array of
7
+ # Fontisan::Ufo::Contour objects.
8
+ #
9
+ # Tracks current-point, subpath-start, and previous-control-point
10
+ # state to resolve relative coordinates and smooth-curve reflections.
11
+ #
12
+ # Point type mapping (Ufo::Point types):
13
+ # M (first in subpath) → first point of contour, type "line"
14
+ # L / H / V → "line"
15
+ # C → "offcurve", "offcurve", "curve"
16
+ # S → reflected "offcurve", "offcurve", "curve"
17
+ # Q → "offcurve", "qcurve"
18
+ # T → reflected "offcurve", "qcurve"
19
+ # Z → closes the contour (no point emitted)
20
+ class ContourBuilder
21
+ # @param commands [Array<Path::Command>]
22
+ # @return [Array<Fontisan::Ufo::Contour>]
23
+ def build(commands)
24
+ state = State.new
25
+ commands.each { |cmd| apply(cmd, state) }
26
+ state.finalize_contour
27
+ state.contours
28
+ end
29
+
30
+ private
31
+
32
+ def apply(cmd, state)
33
+ case cmd.type
34
+ when :M then handle_move(cmd, state)
35
+ when :L then handle_line(cmd, state)
36
+ when :H then handle_horizontal(cmd, state)
37
+ when :V then handle_vertical(cmd, state)
38
+ when :C then handle_cubic(cmd, state)
39
+ when :S then handle_smooth_cubic(cmd, state)
40
+ when :Q then handle_quadratic(cmd, state)
41
+ when :T then handle_smooth_quadratic(cmd, state)
42
+ when :Z then state.close_contour
43
+ end
44
+ end
45
+
46
+ def handle_move(cmd, state)
47
+ x, y = resolve_pair(cmd, state.current)
48
+ state.start_contour(x, y)
49
+ end
50
+
51
+ def handle_line(cmd, state)
52
+ x, y = resolve_pair(cmd, state.current)
53
+ state.add_point(x, y, "line")
54
+ state.reset_controls
55
+ end
56
+
57
+ def handle_horizontal(cmd, state)
58
+ dx = cmd.absolute ? cmd.args[0] - state.current[0] : cmd.args[0]
59
+ x = state.current[0] + dx
60
+ y = state.current[1]
61
+ state.add_point(x, y, "line")
62
+ state.reset_controls
63
+ end
64
+
65
+ def handle_vertical(cmd, state)
66
+ dy = cmd.absolute ? cmd.args[0] - state.current[1] : cmd.args[0]
67
+ x = state.current[0]
68
+ y = state.current[1] + dy
69
+ state.add_point(x, y, "line")
70
+ state.reset_controls
71
+ end
72
+
73
+ def handle_cubic(cmd, state)
74
+ base = state.current
75
+ c1 = resolve_pair_at(cmd, base, 0)
76
+ c2 = resolve_pair_at(cmd, base, 2)
77
+ endpoint = resolve_pair_at(cmd, base, 4)
78
+
79
+ state.add_point(c1[0], c1[1], "offcurve")
80
+ state.add_point(c2[0], c2[1], "offcurve")
81
+ state.add_point(endpoint[0], endpoint[1], "curve")
82
+ state.cubic_control = c2
83
+ end
84
+
85
+ def handle_smooth_cubic(cmd, state)
86
+ base = state.current
87
+ reflected = reflect(state.cubic_control, base)
88
+ c2 = resolve_pair_at(cmd, base, 0)
89
+ endpoint = resolve_pair_at(cmd, base, 2)
90
+
91
+ state.add_point(reflected[0], reflected[1], "offcurve")
92
+ state.add_point(c2[0], c2[1], "offcurve")
93
+ state.add_point(endpoint[0], endpoint[1], "curve")
94
+ state.cubic_control = c2
95
+ end
96
+
97
+ def handle_quadratic(cmd, state)
98
+ base = state.current
99
+ control = resolve_pair_at(cmd, base, 0)
100
+ endpoint = resolve_pair_at(cmd, base, 2)
101
+
102
+ state.add_point(control[0], control[1], "offcurve")
103
+ state.add_point(endpoint[0], endpoint[1], "qcurve")
104
+ state.quad_control = control
105
+ end
106
+
107
+ def handle_smooth_quadratic(cmd, state)
108
+ base = state.current
109
+ reflected = reflect(state.quad_control, base)
110
+ endpoint = resolve_pair_at(cmd, base, 0)
111
+
112
+ state.add_point(reflected[0], reflected[1], "offcurve")
113
+ state.add_point(endpoint[0], endpoint[1], "qcurve")
114
+ state.quad_control = reflected
115
+ end
116
+
117
+ # Resolve an (x,y) pair from a command at the given arg offset.
118
+ # Absolute: use args directly. Relative: add to base point.
119
+ def resolve_pair_at(cmd, base, offset)
120
+ if cmd.absolute
121
+ [cmd.args[offset], cmd.args[offset + 1]]
122
+ else
123
+ [base[0] + cmd.args[offset], base[1] + cmd.args[offset + 1]]
124
+ end
125
+ end
126
+
127
+ def resolve_pair(cmd, base)
128
+ resolve_pair_at(cmd, base, 0)
129
+ end
130
+
131
+ # Reflect a control point through the current point.
132
+ def reflect(control, current)
133
+ return current unless control
134
+
135
+ [2 * current[0] - control[0], 2 * current[1] - control[1]]
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ module Path
6
+ # Tokenizes an SVG path `d` attribute string and groups the
7
+ # tokens into typed Command objects, handling implicit command
8
+ # repetition.
9
+ #
10
+ # Arc (A/a) is not supported — ucode chart SVGs use cubic
11
+ # curves exclusively. Encountering A raises a clear ArgumentError.
12
+ module Parser
13
+ COMMAND_RE = /[MmLlHhVvCcSsQqTtAaZz]/
14
+ NUMBER_RE = /[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/
15
+ TOKEN_RE = /#{COMMAND_RE}|#{NUMBER_RE}/
16
+
17
+ # Expected argument count per command letter.
18
+ ARITY = {
19
+ "M" => 2, "L" => 2, "H" => 1, "V" => 1,
20
+ "C" => 6, "S" => 4, "Q" => 4, "T" => 2,
21
+ "Z" => 0
22
+ }.freeze
23
+
24
+ # @param d_string [String] SVG path data
25
+ # @return [Array<Command>]
26
+ def self.parse(d_string)
27
+ tokens = tokenize(d_string)
28
+ group_into_commands(tokens)
29
+ end
30
+
31
+ def self.tokenize(d_string)
32
+ d_string.to_s.scan(TOKEN_RE).map do |tok|
33
+ COMMAND_RE.match?(tok) ? [:command, tok] : [:number, tok.to_f]
34
+ end
35
+ end
36
+
37
+ # Walk the token stream. After a command letter, consume its
38
+ # arity's worth of numbers per command; extra numbers repeat
39
+ # the command. After M/m, subsequent pairs become implicit L/l.
40
+ def self.group_into_commands(tokens)
41
+ commands = []
42
+ current = nil
43
+ args = []
44
+
45
+ tokens.each do |type, value|
46
+ if type == :command
47
+ check_arc!(value)
48
+ if value.upcase == "Z"
49
+ commands << build_command(value, [])
50
+ current = nil
51
+ else
52
+ current = value
53
+ end
54
+ args = []
55
+ next
56
+ end
57
+
58
+ next unless current
59
+
60
+ arity = ARITY.fetch(current.upcase)
61
+ args << value
62
+
63
+ next unless args.size >= arity
64
+
65
+ commands << build_command(current, args.shift(arity))
66
+ current = implicit_repeat_letter(current) if current.upcase == "M"
67
+ end
68
+
69
+ commands
70
+ end
71
+
72
+ def self.build_command(letter, args)
73
+ Command.new(
74
+ type: letter.upcase.to_sym,
75
+ absolute: letter == letter.upcase,
76
+ args: args,
77
+ )
78
+ end
79
+
80
+ # After M/m, subsequent coordinate pairs become L/l (SVG spec).
81
+ def self.implicit_repeat_letter(letter)
82
+ letter == letter.upcase ? "L" : "l"
83
+ end
84
+
85
+ def self.check_arc!(letter)
86
+ return unless letter && letter.upcase == "A"
87
+
88
+ raise ArgumentError,
89
+ "SVG arc command (A/a) is not supported by SvgToGlyf. " \
90
+ "Convert arcs to cubic curves first."
91
+ end
92
+
93
+ private_class_method :tokenize, :group_into_commands, :build_command,
94
+ :implicit_repeat_letter, :check_arc!
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ module Path
6
+ # Mutable state carried through the contour-building walk.
7
+ # Tracks the current pen position, the subpath start (for Z
8
+ # closure), the last cubic and quadratic control points (for
9
+ # smooth-curve reflection), and the contour being assembled.
10
+ class State
11
+ attr_reader :contours, :current, :subpath_start,
12
+ :cubic_control, :quad_control
13
+
14
+ def initialize
15
+ @contours = []
16
+ @current = nil
17
+ @subpath_start = nil
18
+ @cubic_control = nil
19
+ @quad_control = nil
20
+ @pending = nil
21
+ end
22
+
23
+ # Start a new subpath at (x, y). Flushes any in-progress contour.
24
+ def start_contour(x, y)
25
+ finalize_contour
26
+ @pending = []
27
+ add_point(x, y, "line")
28
+ @subpath_start = [x, y]
29
+ @cubic_control = nil
30
+ @quad_control = nil
31
+ end
32
+
33
+ # Append a point to the current contour and advance current position
34
+ # for on-curve points (off-curve control points do not advance).
35
+ def add_point(x, y, type)
36
+ return unless @pending
37
+
38
+ @pending << Fontisan::Ufo::Point.new(x: x, y: y, type: type)
39
+ return if type == "offcurve"
40
+
41
+ @current = [x, y]
42
+ end
43
+
44
+ # Mark the end of the current subpath. The contour is closed
45
+ # implicitly (UFO contours wrap around). Reset current to the
46
+ # subpath start so a subsequent M picks up from the right place.
47
+ def close_contour
48
+ return unless @pending
49
+
50
+ finalize_contour
51
+ @current = @subpath_start
52
+ end
53
+
54
+ # Flush the in-progress contour into the contours list if it
55
+ # has more than one point (a lone M with nothing else is
56
+ # degenerate and dropped).
57
+ def finalize_contour
58
+ return unless @pending
59
+
60
+ @contours << Fontisan::Ufo::Contour.new(@pending) if @pending.size > 1
61
+ @pending = nil
62
+ end
63
+
64
+ def cubic_control=(value)
65
+ @cubic_control = value
66
+ end
67
+
68
+ def quad_control=(value)
69
+ @quad_control = value
70
+ end
71
+
72
+ def reset_controls
73
+ @cubic_control = nil
74
+ @quad_control = nil
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module SvgToGlyf
5
+ # SVG path parsing: tokenize the `d` attribute and produce typed
6
+ # Command value objects ready for contour assembly.
7
+ module Path
8
+ autoload :Command, "fontisan/svg_to_glyf/path/command"
9
+ autoload :Parser, "fontisan/svg_to_glyf/path/parser"
10
+ autoload :State, "fontisan/svg_to_glyf/path/state"
11
+ autoload :ContourBuilder, "fontisan/svg_to_glyf/path/contour_builder"
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Autoload hub for the Fontisan::SvgToGlyf namespace.
4
+ #
5
+ # SvgToGlyf converts SVG path data (as produced by ucode's code-chart
6
+ # extraction) into Ufo::Glyph objects that feed directly into the
7
+ # existing UFO → TTF compile pipeline.
8
+ #
9
+ # The cubic-to-quadratic conversion and contour winding correction
10
+ # are handled automatically by Ufo::Compile::Filters::CubicToQuadratic
11
+ # and ReverseContourDirection when the glyph is compiled to TTF.
12
+ # SvgToGlyf only needs to parse the SVG and emit cubic contours.
13
+ module Fontisan
14
+ module SvgToGlyf
15
+ autoload :Geometry, "fontisan/svg_to_glyf/geometry"
16
+ autoload :Path, "fontisan/svg_to_glyf/path"
17
+ autoload :Document, "fontisan/svg_to_glyf/document"
18
+ autoload :Assembler, "fontisan/svg_to_glyf/assembler"
19
+
20
+ DEFAULT_UPM = 1000
21
+
22
+ # Convert an SVG path `d` string into a Ufo::Glyph.
23
+ #
24
+ # @param path_data [String] SVG path commands (e.g., "M 0 0 L 100 100 Z")
25
+ # @param upm [Integer] target font units-per-em
26
+ # @param codepoint [Integer, nil] Unicode codepoint to assign
27
+ # @param name [String, nil] glyph name (defaults to uni{codepoint.hex})
28
+ # @param viewbox [Hash{Symbol=>Float}, nil] SVG viewbox with :width, :height
29
+ # @param transform [Fontisan::SvgToGlyf::Geometry::AffineTransform, nil]
30
+ # accumulated group transform
31
+ # @return [Fontisan::Ufo::Glyph]
32
+ def self.convert(path_data, upm: DEFAULT_UPM, codepoint: nil, name: nil,
33
+ viewbox: nil, transform: nil)
34
+ Assembler.new(upm: upm).build_from_path_data(
35
+ path_data, codepoint: codepoint, name: name,
36
+ viewbox: viewbox, transform: transform
37
+ )
38
+ end
39
+
40
+ # Convert an SVG file into a Ufo::Glyph.
41
+ #
42
+ # @param file_path [String] path to an .svg file
43
+ # @param upm [Integer] target font units-per-em
44
+ # @param codepoint [Integer, nil] override the codepoint (otherwise
45
+ # derived from the filename if it matches U+XXXX.svg)
46
+ # @return [Fontisan::Ufo::Glyph]
47
+ def self.from_svg_file(file_path, upm: DEFAULT_UPM, codepoint: nil)
48
+ Assembler.new(upm: upm).build_from_file(file_path, codepoint: codepoint)
49
+ end
50
+
51
+ # Convert a directory of SVG files into a Ufo::Font, one glyph per file.
52
+ #
53
+ # Filenames must encode a codepoint: U+XXXX.svg or hexcode.svg.
54
+ #
55
+ # @param dir [String] directory containing .svg files
56
+ # @param upm [Integer] target font units-per-em
57
+ # @return [Fontisan::Ufo::Font]
58
+ def self.from_directory(dir, upm: DEFAULT_UPM)
59
+ Assembler.new(upm: upm).build_from_directory(dir)
60
+ end
61
+ end
62
+ end