fontisan 0.2.22 → 0.3.0
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/.rubocop.yml +6 -0
- data/.rubocop_todo.yml +93 -17
- data/CHANGELOG.md +12 -2
- data/README.adoc +6 -210
- data/fontisan.gemspec +48 -0
- data/lib/fontisan/cldr/unicode_set_parser.rb +23 -6
- data/lib/fontisan/cldr/version_resolver.rb +1 -1
- data/lib/fontisan/cli.rb +4 -168
- data/lib/fontisan/commands.rb +0 -3
- data/lib/fontisan/formatters/text_formatter.rb +0 -6
- data/lib/fontisan/formatters.rb +0 -3
- data/lib/fontisan/hints.rb +6 -3
- data/lib/fontisan/models.rb +4 -4
- data/lib/fontisan/pipeline/strategies.rb +4 -2
- data/lib/fontisan/pipeline.rb +2 -1
- data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
- data/lib/fontisan/stitcher/selector/gid.rb +25 -0
- data/lib/fontisan/stitcher/selector/range.rb +30 -0
- data/lib/fontisan/stitcher/selector.rb +26 -0
- data/lib/fontisan/stitcher/source.rb +97 -0
- data/lib/fontisan/stitcher.rb +182 -0
- data/lib/fontisan/stitcher_cli.rb +69 -0
- data/lib/fontisan/tables/cff.rb +2 -1
- data/lib/fontisan/tables.rb +2 -1
- data/lib/fontisan/ufo/anchor.rb +17 -0
- data/lib/fontisan/ufo/cli.rb +85 -0
- data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
- data/lib/fontisan/ufo/compile/cff.rb +224 -0
- data/lib/fontisan/ufo/compile/cmap.rb +129 -0
- data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
- data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
- data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
- data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
- data/lib/fontisan/ufo/compile/filters.rb +57 -0
- data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
- data/lib/fontisan/ufo/compile/head.rb +98 -0
- data/lib/fontisan/ufo/compile/hhea.rb +36 -0
- data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
- data/lib/fontisan/ufo/compile/maxp.rb +57 -0
- data/lib/fontisan/ufo/compile/name.rb +79 -0
- data/lib/fontisan/ufo/compile/os2.rb +81 -0
- data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
- data/lib/fontisan/ufo/compile/post.rb +32 -0
- data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
- data/lib/fontisan/ufo/compile.rb +48 -0
- data/lib/fontisan/ufo/component.rb +18 -0
- data/lib/fontisan/ufo/contour.rb +29 -0
- data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
- data/lib/fontisan/ufo/convert.rb +18 -0
- data/lib/fontisan/ufo/data_set.rb +21 -0
- data/lib/fontisan/ufo/features.rb +17 -0
- data/lib/fontisan/ufo/font.rb +61 -0
- data/lib/fontisan/ufo/glyph.rb +421 -0
- data/lib/fontisan/ufo/guideline.rb +19 -0
- data/lib/fontisan/ufo/image.rb +16 -0
- data/lib/fontisan/ufo/image_set.rb +19 -0
- data/lib/fontisan/ufo/info.rb +79 -0
- data/lib/fontisan/ufo/kerning.rb +32 -0
- data/lib/fontisan/ufo/layer.rb +38 -0
- data/lib/fontisan/ufo/layer_set.rb +37 -0
- data/lib/fontisan/ufo/lib.rb +24 -0
- data/lib/fontisan/ufo/plist.rb +118 -0
- data/lib/fontisan/ufo/point.rb +38 -0
- data/lib/fontisan/ufo/reader.rb +144 -0
- data/lib/fontisan/ufo/transformation.rb +39 -0
- data/lib/fontisan/ufo/writer.rb +115 -0
- data/lib/fontisan/ufo.rb +44 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +3 -3
- metadata +57 -70
- data/lib/fontisan/audit/codepoint_range_coalescer.rb +0 -41
- data/lib/fontisan/audit/context.rb +0 -122
- data/lib/fontisan/audit/differ.rb +0 -124
- data/lib/fontisan/audit/extractors/aggregations.rb +0 -54
- data/lib/fontisan/audit/extractors/base.rb +0 -26
- data/lib/fontisan/audit/extractors/color_capabilities.rb +0 -141
- data/lib/fontisan/audit/extractors/coverage.rb +0 -48
- data/lib/fontisan/audit/extractors/hinting.rb +0 -197
- data/lib/fontisan/audit/extractors/identity.rb +0 -52
- data/lib/fontisan/audit/extractors/language_coverage.rb +0 -37
- data/lib/fontisan/audit/extractors/licensing.rb +0 -79
- data/lib/fontisan/audit/extractors/metrics.rb +0 -103
- data/lib/fontisan/audit/extractors/opentype_layout.rb +0 -69
- data/lib/fontisan/audit/extractors/provenance.rb +0 -29
- data/lib/fontisan/audit/extractors/style.rb +0 -32
- data/lib/fontisan/audit/extractors/variation_detail.rb +0 -99
- data/lib/fontisan/audit/extractors.rb +0 -27
- data/lib/fontisan/audit/library_aggregator.rb +0 -83
- data/lib/fontisan/audit/library_auditor.rb +0 -90
- data/lib/fontisan/audit/registry.rb +0 -60
- data/lib/fontisan/audit/style_extractor.rb +0 -80
- data/lib/fontisan/audit.rb +0 -20
- data/lib/fontisan/cli/ucd_cli.rb +0 -97
- data/lib/fontisan/commands/audit_command.rb +0 -123
- data/lib/fontisan/commands/audit_compare_command.rb +0 -66
- data/lib/fontisan/commands/audit_library_command.rb +0 -46
- data/lib/fontisan/config/ucd.yml +0 -23
- data/lib/fontisan/formatters/audit_diff_text_renderer.rb +0 -122
- data/lib/fontisan/formatters/audit_text_renderer.rb +0 -324
- data/lib/fontisan/formatters/library_summary_text_renderer.rb +0 -99
- data/lib/fontisan/models/audit/audit_axis.rb +0 -30
- data/lib/fontisan/models/audit/audit_block.rb +0 -32
- data/lib/fontisan/models/audit/audit_diff.rb +0 -77
- data/lib/fontisan/models/audit/audit_report.rb +0 -153
- data/lib/fontisan/models/audit/codepoint_range.rb +0 -40
- data/lib/fontisan/models/audit/codepoint_set_diff.rb +0 -34
- data/lib/fontisan/models/audit/color_capabilities.rb +0 -93
- data/lib/fontisan/models/audit/duplicate_group.rb +0 -23
- data/lib/fontisan/models/audit/embedding_type.rb +0 -76
- data/lib/fontisan/models/audit/field_change.rb +0 -28
- data/lib/fontisan/models/audit/fs_selection_flags.rb +0 -61
- data/lib/fontisan/models/audit/gasp_range.rb +0 -63
- data/lib/fontisan/models/audit/hinting.rb +0 -93
- data/lib/fontisan/models/audit/library_summary.rb +0 -40
- data/lib/fontisan/models/audit/licensing.rb +0 -48
- data/lib/fontisan/models/audit/metrics.rb +0 -111
- data/lib/fontisan/models/audit/named_instance.rb +0 -41
- data/lib/fontisan/models/audit/opentype_layout.rb +0 -40
- data/lib/fontisan/models/audit/script_coverage_row.rb +0 -26
- data/lib/fontisan/models/audit/script_features.rb +0 -28
- data/lib/fontisan/models/audit/variation_detail.rb +0 -44
- data/lib/fontisan/models/audit.rb +0 -33
- data/lib/fontisan/models/ucd/ucd.rb +0 -38
- data/lib/fontisan/models/ucd/ucd_char.rb +0 -67
- data/lib/fontisan/models/ucd.rb +0 -19
- data/lib/fontisan/ucd/aggregator.rb +0 -73
- data/lib/fontisan/ucd/cache_manager.rb +0 -111
- data/lib/fontisan/ucd/config.rb +0 -59
- data/lib/fontisan/ucd/download_error.rb +0 -9
- data/lib/fontisan/ucd/downloader.rb +0 -88
- data/lib/fontisan/ucd/error.rb +0 -8
- data/lib/fontisan/ucd/index.rb +0 -103
- data/lib/fontisan/ucd/index_builder.rb +0 -107
- data/lib/fontisan/ucd/range_entry.rb +0 -56
- data/lib/fontisan/ucd/unknown_version_error.rb +0 -9
- data/lib/fontisan/ucd/version_resolver.rb +0 -79
- data/lib/fontisan/ucd.rb +0 -23
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `cmap` (character-to-glyph mapping) table.
|
|
7
|
+
# Emits two subtables:
|
|
8
|
+
# - Format 4 (BMP), platform 3 encoding 1 (Windows Unicode BMP)
|
|
9
|
+
# - Format 12 (full Unicode), platform 3 encoding 10 (Windows Unicode full)
|
|
10
|
+
#
|
|
11
|
+
# Both subtables share the same segment list (capped to BMP for
|
|
12
|
+
# format 4); format 4 is required by Windows even though format
|
|
13
|
+
# 12 is more capable.
|
|
14
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/cmap
|
|
15
|
+
module Cmap
|
|
16
|
+
PLATFORM_WINDOWS = 3
|
|
17
|
+
ENCODING_WINDOWS_BMP = 1
|
|
18
|
+
ENCODING_WINDOWS_FULL = 10
|
|
19
|
+
|
|
20
|
+
# @param _font [Fontisan::Ufo::Font]
|
|
21
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
|
|
22
|
+
# @return [String] cmap table bytes
|
|
23
|
+
def self.build(_font, glyphs:)
|
|
24
|
+
mappings = {}
|
|
25
|
+
glyphs.each_with_index do |glyph, gid|
|
|
26
|
+
glyph.unicodes.each do |cp|
|
|
27
|
+
mappings[cp] = gid unless mappings.key?(cp)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
subtable_bmp = format4_subtable(mappings.reject { |cp, _| cp > 0xFFFF })
|
|
32
|
+
subtable_full = format12_subtable(mappings)
|
|
33
|
+
|
|
34
|
+
header_size = 4 + (2 * 8) # version + numTables + 2 records
|
|
35
|
+
offset_bmp = header_size
|
|
36
|
+
offset_full = header_size + subtable_bmp.bytesize
|
|
37
|
+
|
|
38
|
+
header = [0, 2].pack("nn")
|
|
39
|
+
header << subtable_record(PLATFORM_WINDOWS, ENCODING_WINDOWS_BMP, offset_bmp)
|
|
40
|
+
header << subtable_record(PLATFORM_WINDOWS, ENCODING_WINDOWS_FULL, offset_full)
|
|
41
|
+
header + subtable_bmp + subtable_full
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.subtable_record(platform_id, encoding_id, offset)
|
|
45
|
+
[platform_id, encoding_id, offset].pack("nnN")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Group adjacent (cp, gid) pairs into contiguous ranges where
|
|
49
|
+
# both cp and gid advance by 1 each step.
|
|
50
|
+
# @return [Array<Range>] Array of inclusive cp ranges
|
|
51
|
+
def self.build_segments(cp_to_gid)
|
|
52
|
+
sorted = cp_to_gid.sort
|
|
53
|
+
return [] if sorted.empty?
|
|
54
|
+
|
|
55
|
+
segments = []
|
|
56
|
+
seg_start_cp, prev_gid = sorted.first
|
|
57
|
+
prev_cp = seg_start_cp
|
|
58
|
+
|
|
59
|
+
sorted[1..].each do |cp, gid|
|
|
60
|
+
contiguous = cp == prev_cp + 1 && gid == prev_gid + 1
|
|
61
|
+
unless contiguous
|
|
62
|
+
segments << (seg_start_cp..prev_cp)
|
|
63
|
+
seg_start_cp = cp
|
|
64
|
+
end
|
|
65
|
+
prev_cp = cp
|
|
66
|
+
prev_gid = gid
|
|
67
|
+
end
|
|
68
|
+
segments << (seg_start_cp..prev_cp)
|
|
69
|
+
segments
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Format 4 segment-encoded cmap subtable for the BMP.
|
|
73
|
+
def self.format4_subtable(mappings)
|
|
74
|
+
segments = build_segments(mappings) + [0xFFFF..0xFFFF] # sentinel
|
|
75
|
+
seg_count = segments.size
|
|
76
|
+
search_range = largest_pow2_le(seg_count) * 2
|
|
77
|
+
entry_selector = (Math.log([1, search_range / 2].max) / Math.log(2)).to_i
|
|
78
|
+
range_shift = seg_count * 2 - search_range
|
|
79
|
+
|
|
80
|
+
end_codes = segments.map(&:end)
|
|
81
|
+
start_codes = segments.map(&:begin)
|
|
82
|
+
# id_delta[i] = (gid_at_start - start_code) mod 65536
|
|
83
|
+
id_deltas = segments.map do |range|
|
|
84
|
+
start_cp = range.begin
|
|
85
|
+
gid = mappings.fetch(start_cp, 0)
|
|
86
|
+
# For the sentinel segment (0xFFFF..0xFFFF) with no mapping,
|
|
87
|
+
# delta 1 maps 0xFFFF → gid 0 (.notdef).
|
|
88
|
+
gid_delta = start_cp == 0xFFFF && gid.zero? ? 1 : (gid - start_cp)
|
|
89
|
+
gid_delta & 0xFFFF
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
body = +""
|
|
93
|
+
body << [seg_count * 2, search_range, entry_selector, range_shift].pack("nnnn")
|
|
94
|
+
body << end_codes.pack("n*")
|
|
95
|
+
body << [0].pack("n") # reservedPad
|
|
96
|
+
body << start_codes.pack("n*")
|
|
97
|
+
body << id_deltas.pack("n*")
|
|
98
|
+
body << Array.new(seg_count, 0).pack("n*") # idRangeOffset (all 0)
|
|
99
|
+
|
|
100
|
+
length = 14 + body.bytesize # 14-byte header
|
|
101
|
+
[4, length, 0].pack("nnn") + body
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Format 12 sparse-coverage subtable for full Unicode.
|
|
105
|
+
def self.format12_subtable(mappings)
|
|
106
|
+
segments = build_segments(mappings)
|
|
107
|
+
|
|
108
|
+
body = +""
|
|
109
|
+
segments.each do |range|
|
|
110
|
+
start_gid = mappings.fetch(range.begin, 0)
|
|
111
|
+
body << [range.begin, range.end, start_gid].pack("NNN")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
length = 16 + body.bytesize # 16-byte header
|
|
115
|
+
[12, 0].pack("nn") + [length, 0, segments.size].pack("NNN") + body
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.largest_pow2_le(n)
|
|
119
|
+
return 0 if n <= 0
|
|
120
|
+
|
|
121
|
+
1 << (n.bit_length - 1)
|
|
122
|
+
end
|
|
123
|
+
private_class_method :subtable_record, :build_segments,
|
|
124
|
+
:format4_subtable, :format12_subtable,
|
|
125
|
+
:largest_pow2_le
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Converts cubic Bezier curves to quadratic. TrueType outlines
|
|
8
|
+
# only support quadratic Beziers; UFO sources typically use
|
|
9
|
+
# cubic. This filter walks each contour, finds cubic segments
|
|
10
|
+
# (two consecutive off-curve points followed by an on-curve),
|
|
11
|
+
# and replaces them with one or more quadratic approximations.
|
|
12
|
+
#
|
|
13
|
+
# Algorithm (fontTools-compatible):
|
|
14
|
+
# For cubic (P0, C1, C2, P3):
|
|
15
|
+
# Q1 = (3*C1 - P0) / 2
|
|
16
|
+
# Q2 = (3*C2 - P3) / 2
|
|
17
|
+
# Q = midpoint(Q1, Q2)
|
|
18
|
+
# Error ≈ |Q1 - Q2| / 3
|
|
19
|
+
# If error ≤ tolerance: emit one quadratic (P0, Q, P3)
|
|
20
|
+
# Else: subdivide at t=0.5, recurse each half.
|
|
21
|
+
module CubicToQuadratic
|
|
22
|
+
DEFAULT_TOLERANCE = 1.0
|
|
23
|
+
|
|
24
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>]
|
|
25
|
+
# @param tolerance [Float] max deviation in font units
|
|
26
|
+
# @return [Array<Fontisan::Ufo::Glyph>] the same array, mutated
|
|
27
|
+
def self.run(glyphs, tolerance: DEFAULT_TOLERANCE, **_opts)
|
|
28
|
+
glyphs.each do |glyph|
|
|
29
|
+
glyph.contours.each_with_index do |contour, _ci|
|
|
30
|
+
contour.points = convert_contour(contour.points, tolerance)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
glyphs
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Walk a contour's point list and replace cubic segments
|
|
37
|
+
# with quadratic approximations. Preserves on-curve points
|
|
38
|
+
# (lines, moves); only touches (off, off, on) triplets.
|
|
39
|
+
#
|
|
40
|
+
# @param points [Array<Fontisan::Ufo::Point>]
|
|
41
|
+
# @param tolerance [Float]
|
|
42
|
+
# @return [Array<Fontisan::Ufo::Point>] new point array
|
|
43
|
+
def self.convert_contour(points, tolerance)
|
|
44
|
+
return points if points.size < 4
|
|
45
|
+
|
|
46
|
+
result = []
|
|
47
|
+
i = 0
|
|
48
|
+
|
|
49
|
+
while i < points.size
|
|
50
|
+
# Check if we have a cubic segment: off, off, on-curve
|
|
51
|
+
if i + 2 < points.size &&
|
|
52
|
+
off_curve?(points[i]) &&
|
|
53
|
+
off_curve?(points[i + 1]) &&
|
|
54
|
+
on_curve?(points[i + 2])
|
|
55
|
+
# The previous on-curve point (P0) is the last on-curve
|
|
56
|
+
# before this segment. If result is empty, use the last
|
|
57
|
+
# point in the original contour (contour wraps around).
|
|
58
|
+
p0 = result.empty? ? last_on_curve(points) : result.last
|
|
59
|
+
|
|
60
|
+
if p0
|
|
61
|
+
c1 = points[i]
|
|
62
|
+
c2 = points[i + 1]
|
|
63
|
+
p3 = points[i + 2]
|
|
64
|
+
|
|
65
|
+
quads = subdivide_cubic(p0, c1, c2, p3, tolerance)
|
|
66
|
+
quads.each { |q| result << q }
|
|
67
|
+
i += 3
|
|
68
|
+
else
|
|
69
|
+
result << points[i]
|
|
70
|
+
i += 1
|
|
71
|
+
end
|
|
72
|
+
else
|
|
73
|
+
result << points[i]
|
|
74
|
+
i += 1
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
result
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Recursive cubic-to-quadratic subdivision.
|
|
82
|
+
# Returns a list of Points: alternating off-curve control
|
|
83
|
+
# points and on-curve endpoints.
|
|
84
|
+
def self.subdivide_cubic(p0, c1, c2, p3, tolerance)
|
|
85
|
+
q1x = (3.0 * c1.x - p0.x) / 2.0
|
|
86
|
+
q1y = (3.0 * c1.y - p0.y) / 2.0
|
|
87
|
+
q2x = (3.0 * c2.x - p3.x) / 2.0
|
|
88
|
+
q2y = (3.0 * c2.y - p3.y) / 2.0
|
|
89
|
+
|
|
90
|
+
# Error metric: half the distance between Q1 and Q2
|
|
91
|
+
dx = (q1x - q2x).abs
|
|
92
|
+
dy = (q1y - q2y).abs
|
|
93
|
+
error = [dx, dy].max / 3.0
|
|
94
|
+
|
|
95
|
+
if error <= tolerance
|
|
96
|
+
# Single quadratic approximation
|
|
97
|
+
mid_x = (q1x + q2x) / 2.0
|
|
98
|
+
mid_y = (q1y + q2y) / 2.0
|
|
99
|
+
[
|
|
100
|
+
Point.new(x: mid_x, y: mid_y, type: "offcurve"),
|
|
101
|
+
Point.new(x: p3.x, y: p3.y, type: p3.type, smooth: p3.smooth),
|
|
102
|
+
]
|
|
103
|
+
else
|
|
104
|
+
# Subdivide at t=0.5
|
|
105
|
+
midpoint_on_cubic(p0, c1, c2, p3, 0.5)
|
|
106
|
+
|
|
107
|
+
# Left half: P0, L1, L2, M
|
|
108
|
+
Point.new(x: (p0.x + c1.x) / 2.0,
|
|
109
|
+
y: (p0.y + c1.y) / 2.0, type: "offcurve")
|
|
110
|
+
Point.new(x: (c1.x + c2.x) / 4.0 + (p0.x + c1.x) / 4.0,
|
|
111
|
+
y: (c1.y + c2.y) / 4.0 + (p0.y + c1.y) / 4.0,
|
|
112
|
+
type: "offcurve")
|
|
113
|
+
# Actually, proper De Casteljau subdivision:
|
|
114
|
+
l1x = (p0.x + c1.x) / 2.0
|
|
115
|
+
l1y = (p0.y + c1.y) / 2.0
|
|
116
|
+
mx = (c1.x + c2.x) / 2.0
|
|
117
|
+
my = (c1.y + c2.y) / 2.0
|
|
118
|
+
r2x = (c2.x + p3.x) / 2.0
|
|
119
|
+
r2y = (c2.y + p3.y) / 2.0
|
|
120
|
+
l2x = (l1x + mx) / 2.0
|
|
121
|
+
l2y = (l1y + my) / 2.0
|
|
122
|
+
r1x = (mx + r2x) / 2.0
|
|
123
|
+
r1y = (my + r2y) / 2.0
|
|
124
|
+
mid_x = (l2x + r1x) / 2.0
|
|
125
|
+
mid_y = (l2y + r1y) / 2.0
|
|
126
|
+
|
|
127
|
+
left_p0 = p0
|
|
128
|
+
left_c1 = Point.new(x: l1x, y: l1y, type: "offcurve")
|
|
129
|
+
left_c2 = Point.new(x: l2x, y: l2y, type: "offcurve")
|
|
130
|
+
left_p3 = Point.new(x: mid_x, y: mid_y, type: "qcurve")
|
|
131
|
+
|
|
132
|
+
right_p0 = left_p3
|
|
133
|
+
right_c1 = Point.new(x: r1x, y: r1y, type: "offcurve")
|
|
134
|
+
right_c2 = Point.new(x: r2x, y: r2y, type: "offcurve")
|
|
135
|
+
right_p3 = p3
|
|
136
|
+
|
|
137
|
+
left = subdivide_cubic(left_p0, left_c1, left_c2, left_p3, tolerance)
|
|
138
|
+
right = subdivide_cubic(right_p0, right_c1, right_c2, right_p3, tolerance)
|
|
139
|
+
|
|
140
|
+
# Drop the duplicated midpoint point between halves
|
|
141
|
+
left + right
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Evaluate a point on a cubic Bezier at parameter t.
|
|
146
|
+
def self.midpoint_on_cubic(p0, c1, c2, p3, t)
|
|
147
|
+
mt = 1.0 - t
|
|
148
|
+
x = mt * mt * mt * p0.x + 3 * mt * mt * t * c1.x +
|
|
149
|
+
3 * mt * t * t * c2.x + t * t * t * p3.x
|
|
150
|
+
y = mt * mt * mt * p0.y + 3 * mt * mt * t * c1.y +
|
|
151
|
+
3 * mt * t * t * c2.y + t * t * t * p3.y
|
|
152
|
+
Point.new(x: x, y: y, type: "qcurve")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.last_on_curve(points)
|
|
156
|
+
points.reverse_each.find { |p| on_curve?(p) }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.on_curve?(point)
|
|
160
|
+
["line", "move", "curve", "qcurve"].include?(point.type)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def self.off_curve?(point)
|
|
164
|
+
point.type == "offcurve"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
private_class_method :convert_contour, :subdivide_cubic,
|
|
168
|
+
:midpoint_on_cubic, :last_on_curve,
|
|
169
|
+
:on_curve?, :off_curve?
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Decomposes composite glyphs (those with Components) into
|
|
8
|
+
# simple glyphs by resolving each component reference to its
|
|
9
|
+
# base glyph's contours and applying the component's
|
|
10
|
+
# transformation matrix.
|
|
11
|
+
#
|
|
12
|
+
# For MVP: components are silently dropped (the glyph retains
|
|
13
|
+
# its own contours but loses component-derived outlines).
|
|
14
|
+
# Full resolution requires the full glyph-name → glyph lookup
|
|
15
|
+
# that the compiler provides. TODO.full/07b will wire this.
|
|
16
|
+
module DecomposeComponents
|
|
17
|
+
def self.run(glyphs, **_opts)
|
|
18
|
+
glyphs.each do |glyph|
|
|
19
|
+
# Drop components; keep contours only.
|
|
20
|
+
# Full implementation would:
|
|
21
|
+
# 1. Look up each component's base glyph by name
|
|
22
|
+
# 2. Clone its contours
|
|
23
|
+
# 3. Apply the component's Transformation matrix
|
|
24
|
+
# 4. Merge into this glyph's contours
|
|
25
|
+
glyph.components.clear
|
|
26
|
+
end
|
|
27
|
+
glyphs
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Flattens nested component references. If glyph A references
|
|
8
|
+
# glyph B which references glyph C, this filter makes A
|
|
9
|
+
# directly reference C (one level deep only).
|
|
10
|
+
#
|
|
11
|
+
# For MVP: same as DecomposeComponents — components are
|
|
12
|
+
# cleared. Full implementation lands with TODO.full/07b.
|
|
13
|
+
module FlattenComponents
|
|
14
|
+
def self.run(glyphs, **_opts)
|
|
15
|
+
glyphs.each { |g| g.components.clear }
|
|
16
|
+
glyphs
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
module Filters
|
|
7
|
+
# Reverses the point order in every contour. TrueType fonts
|
|
8
|
+
# use clockwise winding for outer contours (opposite of the
|
|
9
|
+
# PostScript/UFO convention). Applying this filter before
|
|
10
|
+
# TTF compilation ensures correct glyph rendering.
|
|
11
|
+
module ReverseContourDirection
|
|
12
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>]
|
|
13
|
+
# @return [Array<Fontisan::Ufo::Glyph>] the same array,
|
|
14
|
+
# mutated in place
|
|
15
|
+
def self.run(glyphs, **_opts)
|
|
16
|
+
glyphs.each do |glyph|
|
|
17
|
+
glyph.contours.each do |contour|
|
|
18
|
+
contour.points.reverse!
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
glyphs
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Glyph-processing filters applied between the UFO model and the
|
|
7
|
+
# binary table compilers. Each filter is a stateless module
|
|
8
|
+
# with `.run(glyphs, **opts)` that mutates the glyph list
|
|
9
|
+
# (or returns a new one).
|
|
10
|
+
#
|
|
11
|
+
# OCP: adding a filter = new module + one REGISTRY entry.
|
|
12
|
+
# The compiler picks which filters to run based on the output
|
|
13
|
+
# format — not a switch statement in compiler code.
|
|
14
|
+
module Filters
|
|
15
|
+
autoload :ReverseContourDirection,
|
|
16
|
+
"fontisan/ufo/compile/filters/reverse_contour_direction"
|
|
17
|
+
autoload :CubicToQuadratic,
|
|
18
|
+
"fontisan/ufo/compile/filters/cubic_to_quadratic"
|
|
19
|
+
autoload :DecomposeComponents,
|
|
20
|
+
"fontisan/ufo/compile/filters/decompose_components"
|
|
21
|
+
autoload :FlattenComponents,
|
|
22
|
+
"fontisan/ufo/compile/filters/flatten_components"
|
|
23
|
+
|
|
24
|
+
# Filters that MUST run for TTF output (TrueType only
|
|
25
|
+
# supports quadratic curves + clockwise outer winding).
|
|
26
|
+
TTF_REQUIRED = %i[
|
|
27
|
+
cubic_to_quadratic
|
|
28
|
+
reverse_contour_direction
|
|
29
|
+
].freeze
|
|
30
|
+
|
|
31
|
+
# Filters that MUST run for OTF output (CFF handles cubic
|
|
32
|
+
# natively, so no curve conversion needed; winding is
|
|
33
|
+
# already correct for PostScript).
|
|
34
|
+
OTF_REQUIRED = [].freeze
|
|
35
|
+
|
|
36
|
+
REGISTRY = {
|
|
37
|
+
reverse_contour_direction: ReverseContourDirection,
|
|
38
|
+
cubic_to_quadratic: CubicToQuadratic,
|
|
39
|
+
decompose_components: DecomposeComponents,
|
|
40
|
+
flatten_components: FlattenComponents,
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
# @param names [Array<Symbol>] filter names from REGISTRY
|
|
44
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>] glyphs to filter
|
|
45
|
+
# @param opts [Hash] per-filter options
|
|
46
|
+
# @return [Array<Fontisan::Ufo::Glyph>] the (possibly mutated) glyphs
|
|
47
|
+
def self.apply(names, glyphs, **)
|
|
48
|
+
Array(names).reduce(glyphs) do |current, name|
|
|
49
|
+
klass = REGISTRY[name.to_sym] or
|
|
50
|
+
raise ArgumentError, "unknown filter: #{name.inspect}"
|
|
51
|
+
klass.run(current, **)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the TrueType `glyf` + `loca` tables. Each glyph's
|
|
7
|
+
# outline is delta-encoded into the glyf table; loca is the
|
|
8
|
+
# offset index.
|
|
9
|
+
#
|
|
10
|
+
# For OTF output this module is NOT used — CFF takes its place.
|
|
11
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/glyf
|
|
12
|
+
module GlyfLoca
|
|
13
|
+
# Flag bits (simple glyph)
|
|
14
|
+
FLAG_ON_CURVE = 0x01
|
|
15
|
+
FLAG_X_SHORT = 0x02
|
|
16
|
+
FLAG_Y_SHORT = 0x04
|
|
17
|
+
FLAG_REPEAT = 0x08
|
|
18
|
+
FLAG_X_IS_POSITIVE = 0x10 # only meaningful if FLAG_X_SHORT
|
|
19
|
+
FLAG_Y_IS_POSITIVE = 0x20 # only meaningful if FLAG_Y_SHORT
|
|
20
|
+
FLAG_OVERLAP_SIMPLE = 0x40
|
|
21
|
+
|
|
22
|
+
# @param _font [Fontisan::Ufo::Font]
|
|
23
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
|
|
24
|
+
# @return [Hash<String, String>] {"glyf" => bytes, "loca" => bytes}
|
|
25
|
+
def self.build(_font, glyphs:)
|
|
26
|
+
glyf_bytes = +""
|
|
27
|
+
offsets = [0]
|
|
28
|
+
|
|
29
|
+
glyphs.each do |glyph|
|
|
30
|
+
glyf_bytes << encode_glyph(glyph)
|
|
31
|
+
glyf_bytes << "\x00" while glyf_bytes.bytesize.odd? # 2-byte align
|
|
32
|
+
offsets << glyf_bytes.bytesize
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Choose loca format based on the largest offset.
|
|
36
|
+
use_long = offsets.max > 0x1FFFE # 2 × uint16 max
|
|
37
|
+
loca_bytes =
|
|
38
|
+
if use_long
|
|
39
|
+
offsets.pack("N*")
|
|
40
|
+
else
|
|
41
|
+
offsets.map { |o| o / 2 }.pack("n*")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
{ "glyf" => glyf_bytes, "loca" => loca_bytes, :loca_format => use_long ? 1 : 0 }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Encode a single glyph into glyf bytes. Empty glyphs (no
|
|
48
|
+
# contours, no components) produce zero bytes per spec.
|
|
49
|
+
def self.encode_glyph(glyph)
|
|
50
|
+
return "" if glyph.contours.empty? && glyph.components.empty?
|
|
51
|
+
return encode_composite(glyph) if glyph.composite?
|
|
52
|
+
|
|
53
|
+
encode_simple(glyph)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Encode a simple (non-composite) glyph.
|
|
57
|
+
def self.encode_simple(glyph)
|
|
58
|
+
bbox = glyph.bbox
|
|
59
|
+
header = [
|
|
60
|
+
glyph.contours.size, # numberOfContours (int16)
|
|
61
|
+
bbox.x_min.to_i, bbox.y_min.to_i,
|
|
62
|
+
bbox.x_max.to_i, bbox.y_max.to_i
|
|
63
|
+
].pack("nnnnn")
|
|
64
|
+
# NB: pack 'n' for int16 truncates negatives to unsigned 16-bit
|
|
65
|
+
# two's complement — which is what OpenType wants.
|
|
66
|
+
|
|
67
|
+
# endPtsOfContours (uint16[numContours])
|
|
68
|
+
end_points = +""
|
|
69
|
+
point_count = 0
|
|
70
|
+
glyph.contours.each do |contour|
|
|
71
|
+
point_count += contour.points.size
|
|
72
|
+
end_points << [point_count - 1].pack("n")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# instructions: empty for MVP
|
|
76
|
+
instructions = [0].pack("n")
|
|
77
|
+
|
|
78
|
+
flags, x_bytes, y_bytes = encode_points(glyph.contours)
|
|
79
|
+
|
|
80
|
+
header + end_points + instructions + flags + x_bytes + y_bytes
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Encode all points across contours into (flags, x_bytes, y_bytes).
|
|
84
|
+
# Per OpenType spec:
|
|
85
|
+
# - SHORT bit set + POSITIVE bit set → 1-byte positive value
|
|
86
|
+
# - SHORT bit set + POSITIVE bit clear → 1-byte negative value
|
|
87
|
+
# - SHORT bit clear + POSITIVE bit set → delta is 0 (no bytes)
|
|
88
|
+
# - SHORT bit clear + POSITIVE bit clear → 2-byte signed value
|
|
89
|
+
def self.encode_points(contours)
|
|
90
|
+
flags = +""
|
|
91
|
+
x_bytes = +""
|
|
92
|
+
y_bytes = +""
|
|
93
|
+
|
|
94
|
+
prev_x = 0
|
|
95
|
+
prev_y = 0
|
|
96
|
+
contours.flat_map(&:points).each do |point|
|
|
97
|
+
dx = point.x.to_i - prev_x
|
|
98
|
+
dy = point.y.to_i - prev_y
|
|
99
|
+
|
|
100
|
+
flag = point.on_curve? ? FLAG_ON_CURVE : 0
|
|
101
|
+
|
|
102
|
+
# X coordinate
|
|
103
|
+
if dx.zero?
|
|
104
|
+
flag |= FLAG_X_IS_POSITIVE # "same as previous" — no bytes
|
|
105
|
+
elsif dx.between?(-255, 255)
|
|
106
|
+
flag |= FLAG_X_SHORT
|
|
107
|
+
flag |= FLAG_X_IS_POSITIVE if dx.positive?
|
|
108
|
+
x_bytes << [dx.abs].pack("C")
|
|
109
|
+
else
|
|
110
|
+
# SHORT not set, POSITIVE not set → 2-byte signed
|
|
111
|
+
x_bytes << [dx & 0xFFFF].pack("n")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Y coordinate
|
|
115
|
+
if dy.zero?
|
|
116
|
+
flag |= FLAG_Y_IS_POSITIVE # "same as previous" — no bytes
|
|
117
|
+
elsif dy.between?(-255, 255)
|
|
118
|
+
flag |= FLAG_Y_SHORT
|
|
119
|
+
flag |= FLAG_Y_IS_POSITIVE if dy.positive?
|
|
120
|
+
y_bytes << [dy.abs].pack("C")
|
|
121
|
+
else
|
|
122
|
+
y_bytes << [dy & 0xFFFF].pack("n")
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
flags << [flag].pack("C")
|
|
126
|
+
prev_x = point.x.to_i
|
|
127
|
+
prev_y = point.y.to_i
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
[flags, x_bytes, y_bytes]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Composite glyph encoding (component references with optional
|
|
134
|
+
# transformations). Out of scope for MVP — emits empty bytes.
|
|
135
|
+
def self.encode_composite(_glyph)
|
|
136
|
+
# TODO.full/07: implement composite glyph encoding when the
|
|
137
|
+
# spec needs it. For now, return empty (the glyph renders as
|
|
138
|
+
# nothing).
|
|
139
|
+
""
|
|
140
|
+
end
|
|
141
|
+
private_class_method :encode_glyph, :encode_simple, :encode_points, :encode_composite
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `head` table from a UFO Font.
|
|
7
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/head
|
|
8
|
+
module Head
|
|
9
|
+
MAGIC_NUMBER = 0x5F0F3CF5
|
|
10
|
+
DEFAULT_FLAGS = 0x000B
|
|
11
|
+
DEFAULT_LOWEST_REC_PPEM = 8
|
|
12
|
+
DEFAULT_FONT_DIRECTION_HINT = 2
|
|
13
|
+
DEFAULT_GLYPH_DATA_FORMAT = 0
|
|
14
|
+
LOCA_FORMAT_SHORT = 0
|
|
15
|
+
LOCA_FORMAT_LONG = 1
|
|
16
|
+
MAC_STYLE_REGULAR = 0
|
|
17
|
+
# Seconds between 1904-01-01 (Mac epoch) and 1970-01-01 (Unix epoch)
|
|
18
|
+
MAC_EPOCH_OFFSET = 2_082_844_800
|
|
19
|
+
|
|
20
|
+
# @param font [Fontisan::Ufo::Font]
|
|
21
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>] (unused here;
|
|
22
|
+
# bbox is computed by the caller for performance)
|
|
23
|
+
# @return [Fontisan::Tables::Head]
|
|
24
|
+
def self.build(font, glyphs:, units_per_em: nil, loca_format: LOCA_FORMAT_LONG)
|
|
25
|
+
bbox = font_bbox(font, glyphs)
|
|
26
|
+
Fontisan::Tables::Head.new(
|
|
27
|
+
version_raw: 0x00010000,
|
|
28
|
+
font_revision_raw: font_revision_fixed(font),
|
|
29
|
+
checksum_adjustment: 0, # patched by FontWriter
|
|
30
|
+
magic_number: MAGIC_NUMBER,
|
|
31
|
+
flags: DEFAULT_FLAGS,
|
|
32
|
+
units_per_em: units_per_em || font.info.units_per_em || 1000,
|
|
33
|
+
created_raw: time_to_longdatetime(font.info.created),
|
|
34
|
+
modified_raw: time_to_longdatetime(font.info.modified),
|
|
35
|
+
x_min: bbox[:x_min].to_i,
|
|
36
|
+
y_min: bbox[:y_min].to_i,
|
|
37
|
+
x_max: bbox[:x_max].to_i,
|
|
38
|
+
y_max: bbox[:y_max].to_i,
|
|
39
|
+
mac_style: MAC_STYLE_REGULAR,
|
|
40
|
+
lowest_rec_ppem: DEFAULT_LOWEST_REC_PPEM,
|
|
41
|
+
font_direction_hint: DEFAULT_FONT_DIRECTION_HINT,
|
|
42
|
+
index_to_loc_format: loca_format,
|
|
43
|
+
glyph_data_format: DEFAULT_GLYPH_DATA_FORMAT,
|
|
44
|
+
)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Union bbox of every glyph's outline.
|
|
48
|
+
def self.font_bbox(_font, glyphs)
|
|
49
|
+
return { x_min: 0, y_min: 0, x_max: 0, y_max: 0 } if glyphs.empty?
|
|
50
|
+
|
|
51
|
+
bboxes = glyphs.filter_map(&:bbox)
|
|
52
|
+
return { x_min: 0, y_min: 0, x_max: 0, y_max: 0 } if bboxes.empty?
|
|
53
|
+
|
|
54
|
+
{
|
|
55
|
+
x_min: bboxes.map(&:x_min).min,
|
|
56
|
+
y_min: bboxes.map(&:y_min).min,
|
|
57
|
+
x_max: bboxes.map(&:x_max).max,
|
|
58
|
+
y_max: bboxes.map(&:y_max).max,
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
private_class_method :font_bbox
|
|
62
|
+
|
|
63
|
+
# Parse the UFO version string ("1.0" or "Version 1.0") into
|
|
64
|
+
# a 16.16 fixed-point int. Defaults to 1.0 on parse failure.
|
|
65
|
+
def self.font_revision_fixed(font)
|
|
66
|
+
version = [font.info.version_major, font.info.version_minor].compact
|
|
67
|
+
return 0x00010000 if version.empty?
|
|
68
|
+
|
|
69
|
+
major = font.info.version_major || 1
|
|
70
|
+
minor = font.info.version_minor || 0
|
|
71
|
+
((major & 0xFFFF) << 16) | (minor & 0xFFFF)
|
|
72
|
+
end
|
|
73
|
+
private_class_method :font_revision_fixed
|
|
74
|
+
|
|
75
|
+
# UFO 3 stores `openTypeHeadCreated` as "YYYY/MM/DD HH:MM:SS".
|
|
76
|
+
# OpenType head.created/modified are LONGDATETIME seconds
|
|
77
|
+
# since 1904-01-01. If the UFO field is absent, use now.
|
|
78
|
+
def self.time_to_longdatetime(value)
|
|
79
|
+
return Time.now.to_i + MAC_EPOCH_OFFSET if value.nil?
|
|
80
|
+
|
|
81
|
+
require "time"
|
|
82
|
+
parsed = nil
|
|
83
|
+
begin
|
|
84
|
+
parsed = Time.iso8601(value.to_s)
|
|
85
|
+
rescue ArgumentError
|
|
86
|
+
begin
|
|
87
|
+
parsed = Time.parse(value.to_s)
|
|
88
|
+
rescue ArgumentError
|
|
89
|
+
parsed = Time.now
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
parsed.to_i + MAC_EPOCH_OFFSET
|
|
93
|
+
end
|
|
94
|
+
private_class_method :time_to_longdatetime
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|