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.
- checksums.yaml +4 -4
- data/BUG-stitcher-drops-isolated-cps.md +58 -0
- data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
- data/BUG-stitcher-gid-cap-65535.md +110 -0
- data/CHANGELOG.md +106 -0
- data/README.adoc +121 -68
- data/benchmark/compile_benchmark.rb +70 -0
- data/docs/CFF2_SUPPORT.adoc +184 -0
- data/docs/STITCHER_GUIDE.adoc +151 -0
- data/docs/SVG_TO_GLYF.adoc +118 -0
- data/docs/UFO_COMPILATION.adoc +119 -0
- data/lib/fontisan/collection/writer.rb +5 -6
- data/lib/fontisan/error.rb +31 -0
- data/lib/fontisan/stitcher/deduplicator.rb +47 -0
- data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
- data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
- data/lib/fontisan/stitcher.rb +188 -167
- data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
- data/lib/fontisan/svg_to_glyf/document.rb +83 -0
- data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
- data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
- data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
- data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
- data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
- data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
- data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
- data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
- data/lib/fontisan/svg_to_glyf/path.rb +14 -0
- data/lib/fontisan/svg_to_glyf.rb +62 -0
- data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
- data/lib/fontisan/tables/cff.rb +1 -0
- data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
- data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
- data/lib/fontisan/tables/cff2/header.rb +34 -0
- data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
- data/lib/fontisan/tables/cff2.rb +4 -0
- data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
- data/lib/fontisan/ufo/compile/cff2.rb +181 -0
- data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
- data/lib/fontisan/ufo/compile/colr.rb +80 -0
- data/lib/fontisan/ufo/compile/cpal.rb +61 -0
- data/lib/fontisan/ufo/compile/math.rb +143 -0
- data/lib/fontisan/ufo/compile/meta.rb +51 -0
- data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
- data/lib/fontisan/ufo/compile/sbix.rb +99 -0
- data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
- data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
- data/lib/fontisan/ufo/compile.rb +11 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +3 -0
- 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
|