fontisan 0.2.23 → 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/lib/fontisan/cli.rb +6 -0
- 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/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 -0
- metadata +51 -1
|
@@ -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
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `hhea` (horizontal header) table.
|
|
7
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/hhea
|
|
8
|
+
module Hhea
|
|
9
|
+
VERSION_1_0 = 0x00010000
|
|
10
|
+
|
|
11
|
+
# @param font [Fontisan::Ufo::Font]
|
|
12
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>]
|
|
13
|
+
# @return [Fontisan::Tables::Hhea]
|
|
14
|
+
def self.build(font, glyphs:)
|
|
15
|
+
info = font.info
|
|
16
|
+
widths = glyphs.map { |g| g.width.to_i }
|
|
17
|
+
Fontisan::Tables::Hhea.new(
|
|
18
|
+
version_raw: VERSION_1_0,
|
|
19
|
+
ascent: info.ascender || 800,
|
|
20
|
+
descent: info.descender || -200,
|
|
21
|
+
line_gap: info.open_type_hhea_line_gap || 0,
|
|
22
|
+
advance_width_max: widths.max || 0,
|
|
23
|
+
min_left_side_bearing: 0,
|
|
24
|
+
min_right_side_bearing: 0,
|
|
25
|
+
x_max_extent: widths.max || 0,
|
|
26
|
+
caret_slope_rise: 1,
|
|
27
|
+
caret_slope_run: 0,
|
|
28
|
+
caret_offset: 0,
|
|
29
|
+
metric_data_format: 0,
|
|
30
|
+
number_of_h_metrics: glyphs.size,
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `hmtx` (horizontal metrics) table.
|
|
7
|
+
# One LongHorMetric per glyph (4 bytes each):
|
|
8
|
+
# uint16 advanceWidth, int16 lsb
|
|
9
|
+
# No trailing "leftSideBearing" array (use numberOfHMetrics = numGlyphs).
|
|
10
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx
|
|
11
|
+
module Hmtx
|
|
12
|
+
# @param _font [Fontisan::Ufo::Font]
|
|
13
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
|
|
14
|
+
# @return [String] hmtx table bytes
|
|
15
|
+
def self.build(_font, glyphs:)
|
|
16
|
+
data = +""
|
|
17
|
+
glyphs.each do |glyph|
|
|
18
|
+
bbox = glyph.bbox
|
|
19
|
+
lsb = bbox ? bbox.x_min.to_i : 0
|
|
20
|
+
data << [glyph.width.to_i, lsb].pack("nn")
|
|
21
|
+
end
|
|
22
|
+
data
|
|
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
|
+
# Builds the OpenType `maxp` (maximum profile) table.
|
|
7
|
+
# TrueType (0x00010000) carries 13 metrics; CFF (0x00005000)
|
|
8
|
+
# carries just num_glyphs. We pick the version based on which
|
|
9
|
+
# outline compiler is in use — caller passes `version:`.
|
|
10
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/maxp
|
|
11
|
+
module Maxp
|
|
12
|
+
VERSION_TRUE_TYPE = 0x00010000
|
|
13
|
+
VERSION_OPEN_TYPE = 0x00005000
|
|
14
|
+
|
|
15
|
+
# @param _font [Fontisan::Ufo::Font]
|
|
16
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>]
|
|
17
|
+
# @param version [Integer] one of VERSION_TRUE_TYPE / VERSION_OPEN_TYPE
|
|
18
|
+
# @return [Fontisan::Tables::Maxp]
|
|
19
|
+
def self.build(_font, glyphs:, version: VERSION_OPEN_TYPE)
|
|
20
|
+
if version == VERSION_TRUE_TYPE
|
|
21
|
+
build_truetype(glyphs)
|
|
22
|
+
else
|
|
23
|
+
Fontisan::Tables::Maxp.new(
|
|
24
|
+
version_raw: VERSION_OPEN_TYPE,
|
|
25
|
+
num_glyphs: glyphs.size,
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.build_truetype(glyphs)
|
|
31
|
+
max_points = glyphs.map(&:point_count).max || 0
|
|
32
|
+
max_contours = glyphs.map { |g| g.contours.size }.max || 0
|
|
33
|
+
max_components = glyphs.map { |g| g.components.size }.max || 0
|
|
34
|
+
|
|
35
|
+
Fontisan::Tables::Maxp.new(
|
|
36
|
+
version_raw: VERSION_TRUE_TYPE,
|
|
37
|
+
num_glyphs: glyphs.size,
|
|
38
|
+
max_points: max_points,
|
|
39
|
+
max_contours: max_contours,
|
|
40
|
+
max_composite_points: max_points,
|
|
41
|
+
max_composite_contours: max_contours,
|
|
42
|
+
max_zones: 2,
|
|
43
|
+
max_twilight_points: 0,
|
|
44
|
+
max_storage: 0,
|
|
45
|
+
max_function_defs: 0,
|
|
46
|
+
max_instruction_defs: 0,
|
|
47
|
+
max_stack_elements: 0,
|
|
48
|
+
max_size_of_instructions: 0,
|
|
49
|
+
max_component_elements: max_components,
|
|
50
|
+
max_component_depth: max_components.positive? ? 1 : 0,
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
private_class_method :build_truetype
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `name` table from UFO fontinfo data.
|
|
7
|
+
# Writes Windows-Unicode (platform 3, encoding 1) name records
|
|
8
|
+
# for the standard 6 name IDs (copyright, family, subfamily,
|
|
9
|
+
# unique ID, full name, version, PostScript name).
|
|
10
|
+
#
|
|
11
|
+
# BinData's Tables::Name structure doesn't make construction
|
|
12
|
+
# from a record list easy (it has a custom after_read_hook and
|
|
13
|
+
# a `rest :string_storage`); this builder produces the bytes
|
|
14
|
+
# directly so we don't fight the BinData shape.
|
|
15
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/name
|
|
16
|
+
module Name
|
|
17
|
+
PLATFORM_WINDOWS_UNICODE = 3
|
|
18
|
+
ENCODING_WINDOWS_UNICODE_BMP = 1
|
|
19
|
+
LANGUAGE_WINDOWS_ENGLISH_US = 0x0409
|
|
20
|
+
|
|
21
|
+
# @param font [Fontisan::Ufo::Font]
|
|
22
|
+
# @return [String] the name table bytes
|
|
23
|
+
def self.build(font, **_opts)
|
|
24
|
+
records = default_records(font)
|
|
25
|
+
format0_bytes(records)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param font [Fontisan::Ufo::Font] (used without glyph info)
|
|
29
|
+
# @return [Array<Hash{name_id: Integer, value: String}>]
|
|
30
|
+
def self.default_records(font)
|
|
31
|
+
family = font.info.family_name || "Untitled"
|
|
32
|
+
subfamily = font.info.style_name || "Regular"
|
|
33
|
+
ps_name = font.info.postscript_font_name || "#{family}-#{subfamily}"
|
|
34
|
+
full_name = "#{family} #{subfamily}".strip
|
|
35
|
+
major = font.info.version_major || 0
|
|
36
|
+
minor = font.info.version_minor || 0
|
|
37
|
+
version_str = "Version #{major}.#{minor}"
|
|
38
|
+
unique_id = "#{family}-#{subfamily};#{version_str}"
|
|
39
|
+
copyright = font.info.copyright || ""
|
|
40
|
+
|
|
41
|
+
[
|
|
42
|
+
{ name_id: 0, value: copyright }, # copyright
|
|
43
|
+
{ name_id: 1, value: family }, # family
|
|
44
|
+
{ name_id: 2, value: subfamily }, # subfamily
|
|
45
|
+
{ name_id: 3, value: unique_id }, # unique ID
|
|
46
|
+
{ name_id: 4, value: full_name }, # full name
|
|
47
|
+
{ name_id: 5, value: version_str }, # version
|
|
48
|
+
{ name_id: 6, value: ps_name }, # PostScript name
|
|
49
|
+
]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.format0_bytes(records)
|
|
53
|
+
# Strings are stored UTF-16BE on disk (Windows Unicode).
|
|
54
|
+
encoded = records.map { |r| r[:value].encode("UTF-16BE").force_encoding("BINARY") }
|
|
55
|
+
storage = encoded.join
|
|
56
|
+
storage_offset = 6 + (records.size * 12)
|
|
57
|
+
|
|
58
|
+
header = [0, records.size, storage_offset].pack("nnn")
|
|
59
|
+
body = +""
|
|
60
|
+
offset = 0
|
|
61
|
+
records.zip(encoded).each do |r, bytes|
|
|
62
|
+
body << [
|
|
63
|
+
PLATFORM_WINDOWS_UNICODE,
|
|
64
|
+
ENCODING_WINDOWS_UNICODE_BMP,
|
|
65
|
+
LANGUAGE_WINDOWS_ENGLISH_US,
|
|
66
|
+
r[:name_id],
|
|
67
|
+
bytes.bytesize,
|
|
68
|
+
offset,
|
|
69
|
+
].pack("nnnnnn")
|
|
70
|
+
offset += bytes.bytesize
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
header + body + storage
|
|
74
|
+
end
|
|
75
|
+
private_class_method :format0_bytes, :default_records
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `OS/2` table from UFO fontinfo data.
|
|
7
|
+
# Default version is 4 (modern). Most fields are 0 unless the
|
|
8
|
+
# UFO has explicit values.
|
|
9
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/os2
|
|
10
|
+
module Os2
|
|
11
|
+
VERSION_DEFAULT = 4
|
|
12
|
+
WEIGHT_REGULAR = 400
|
|
13
|
+
WIDTH_NORMAL = 5
|
|
14
|
+
FS_SELECTION_REGULAR = 0x0040
|
|
15
|
+
|
|
16
|
+
# @param font [Fontisan::Ufo::Font]
|
|
17
|
+
# @param glyphs [Array<Fontisan::Ufo::Glyph>] used to compute
|
|
18
|
+
# usFirstCharIndex/usLastCharIndex + xAvgCharWidth
|
|
19
|
+
# @return [Fontisan::Tables::Os2]
|
|
20
|
+
def self.build(font, glyphs:)
|
|
21
|
+
info = font.info
|
|
22
|
+
cp_min, cp_max = unicode_range(glyphs)
|
|
23
|
+
Fontisan::Tables::Os2.new(
|
|
24
|
+
version: VERSION_DEFAULT,
|
|
25
|
+
x_avg_char_width: avg_advance(glyphs),
|
|
26
|
+
us_weight_class: info.open_type_os2_weight_class || WEIGHT_REGULAR,
|
|
27
|
+
us_width_class: info.open_type_os2_width_class || WIDTH_NORMAL,
|
|
28
|
+
fs_type: 0,
|
|
29
|
+
y_subscript_x_size: 650,
|
|
30
|
+
y_subscript_y_size: 600,
|
|
31
|
+
y_subscript_x_offset: 0,
|
|
32
|
+
y_subscript_y_offset: 75,
|
|
33
|
+
y_superscript_x_size: 650,
|
|
34
|
+
y_superscript_y_size: 600,
|
|
35
|
+
y_superscript_x_offset: 0,
|
|
36
|
+
y_superscript_y_offset: 350,
|
|
37
|
+
y_strikeout_size: 50,
|
|
38
|
+
y_strikeout_position: 300,
|
|
39
|
+
s_family_class: 0,
|
|
40
|
+
panose: Array.new(10, 0),
|
|
41
|
+
ul_unicode_range1: 1, # Basic Latin
|
|
42
|
+
ul_unicode_range2: 0,
|
|
43
|
+
ul_unicode_range3: 0,
|
|
44
|
+
ul_unicode_range4: 0,
|
|
45
|
+
ach_vend_id: "NONE",
|
|
46
|
+
fs_selection: FS_SELECTION_REGULAR,
|
|
47
|
+
us_first_char_index: cp_min,
|
|
48
|
+
us_last_char_index: cp_max,
|
|
49
|
+
s_typo_ascender: info.ascender || 800,
|
|
50
|
+
s_typo_descender: info.descender || -200,
|
|
51
|
+
s_typo_line_gap: info.open_type_hhea_line_gap || 0,
|
|
52
|
+
us_win_ascent: info.ascender || 1000,
|
|
53
|
+
us_win_descent: -(info.descender || -200),
|
|
54
|
+
ul_code_page_range1: 1, # Latin 1
|
|
55
|
+
ul_code_page_range2: 0,
|
|
56
|
+
sx_height: info.x_height || 500,
|
|
57
|
+
s_cap_height: info.cap_height || 700,
|
|
58
|
+
us_default_char: 0,
|
|
59
|
+
us_break_char: 0x20,
|
|
60
|
+
us_max_context: 0,
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.unicode_range(glyphs)
|
|
65
|
+
cps = glyphs.flat_map(&:unicodes).sort
|
|
66
|
+
return [0xFFFF, 0] if cps.empty?
|
|
67
|
+
|
|
68
|
+
[cps.first, cps.last]
|
|
69
|
+
end
|
|
70
|
+
private_class_method :unicode_range
|
|
71
|
+
|
|
72
|
+
def self.avg_advance(glyphs)
|
|
73
|
+
return 0 if glyphs.empty?
|
|
74
|
+
|
|
75
|
+
(glyphs.sum { |g| g.width.to_i } / glyphs.size.to_f).round
|
|
76
|
+
end
|
|
77
|
+
private_class_method :avg_advance
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# UFO → OTF. Uses CFF outlines (instead of TrueType glyf/loca).
|
|
7
|
+
# Maxp version 0.5 (no TrueType metrics); sfnt version OTTO.
|
|
8
|
+
#
|
|
9
|
+
# TODO.full/10: this currently emits a placeholder CFF table
|
|
10
|
+
# that satisfies the OTTO signature but does NOT yet encode
|
|
11
|
+
# real charstrings. Full CFF construction lands when TODO 10
|
|
12
|
+
# ships.
|
|
13
|
+
class OtfCompiler < BaseCompiler
|
|
14
|
+
SFNT_VERSION = SFNT_VERSION_OPEN_TYPE
|
|
15
|
+
|
|
16
|
+
def build_outline_tables
|
|
17
|
+
{
|
|
18
|
+
"CFF " => Cff.build(font, glyphs: font.glyphs.values),
|
|
19
|
+
}
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def compile(output_path:)
|
|
23
|
+
glyphs = font.glyphs.values
|
|
24
|
+
|
|
25
|
+
tables = {
|
|
26
|
+
"head" => Head.build(font, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
|
|
27
|
+
"hhea" => Hhea.build(font, glyphs: glyphs),
|
|
28
|
+
"maxp" => Maxp.build(font, glyphs: glyphs, version: Maxp::VERSION_OPEN_TYPE),
|
|
29
|
+
"OS/2" => Os2.build(font, glyphs: glyphs),
|
|
30
|
+
"name" => Name.build(font),
|
|
31
|
+
"post" => Post.build(font),
|
|
32
|
+
"hmtx" => Hmtx.build(font, glyphs: glyphs),
|
|
33
|
+
"cmap" => Cmap.build(font, glyphs: glyphs),
|
|
34
|
+
"CFF " => Cff.build(font, glyphs: glyphs),
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
write(tables, output_path)
|
|
38
|
+
output_path
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `post` (PostScript name) table.
|
|
7
|
+
# Default version is 3.0 (no per-glyph names; smallest).
|
|
8
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/post
|
|
9
|
+
module Post
|
|
10
|
+
VERSION_3_0_RAW = 0x00030000
|
|
11
|
+
|
|
12
|
+
# @param _font [Fontisan::Ufo::Font] italic angle + underline
|
|
13
|
+
# read from info, but everything else is zero (no hinting)
|
|
14
|
+
# @return [String] the post table bytes (32 bytes for v3.0)
|
|
15
|
+
def self.build(font, **_opts)
|
|
16
|
+
italic_angle = font.info.italic_angle || 0.0
|
|
17
|
+
[
|
|
18
|
+
VERSION_3_0_RAW, # version 3.0
|
|
19
|
+
(italic_angle * 0x10000).to_i, # italicAngle (Fixed 16.16)
|
|
20
|
+
-100, # underlinePosition (FUnits)
|
|
21
|
+
50, # underlineThickness
|
|
22
|
+
0, # isFixedPitch
|
|
23
|
+
0, # minMemType42
|
|
24
|
+
0, # maxMemType42
|
|
25
|
+
0, # minMemType1
|
|
26
|
+
0, # maxMemType1
|
|
27
|
+
].pack("NNnnNNNNN")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# UFO → TTF. Same tables as the OTF compiler plus glyf + loca
|
|
7
|
+
# (no CFF). Maxp version 1.0 carries the TrueType metrics.
|
|
8
|
+
#
|
|
9
|
+
# Before glyf encoding, the TTF-required filters run:
|
|
10
|
+
# - cubic_to_quadratic (TTF only supports quadratic Beziers)
|
|
11
|
+
# - reverse_contour_direction (TTF winding convention)
|
|
12
|
+
class TtfCompiler < BaseCompiler
|
|
13
|
+
SFNT_VERSION = SFNT_VERSION_TRUE_TYPE
|
|
14
|
+
|
|
15
|
+
def compile(output_path:)
|
|
16
|
+
glyphs = font.glyphs.values
|
|
17
|
+
|
|
18
|
+
# Deep-clone glyphs so filters don't mutate the source UFO.
|
|
19
|
+
filtered = clone_glyphs(glyphs)
|
|
20
|
+
Filters.apply(Filters::TTF_REQUIRED, filtered)
|
|
21
|
+
|
|
22
|
+
glyf_loca = GlyfLoca.build(font, glyphs: filtered)
|
|
23
|
+
loca_format = glyf_loca.delete(:loca_format)
|
|
24
|
+
|
|
25
|
+
tables = {
|
|
26
|
+
"head" => Head.build(font, glyphs: filtered,
|
|
27
|
+
loca_format: loca_format || Head::LOCA_FORMAT_LONG),
|
|
28
|
+
"hhea" => Hhea.build(font, glyphs: filtered),
|
|
29
|
+
"maxp" => Maxp.build(font, glyphs: filtered,
|
|
30
|
+
version: Maxp::VERSION_TRUE_TYPE),
|
|
31
|
+
"OS/2" => Os2.build(font, glyphs: filtered),
|
|
32
|
+
"name" => Name.build(font),
|
|
33
|
+
"post" => Post.build(font),
|
|
34
|
+
"hmtx" => Hmtx.build(font, glyphs: filtered),
|
|
35
|
+
"cmap" => Cmap.build(font, glyphs: filtered),
|
|
36
|
+
"glyf" => glyf_loca["glyf"],
|
|
37
|
+
"loca" => glyf_loca["loca"],
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
write(tables, output_path)
|
|
41
|
+
output_path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def clone_glyphs(glyphs)
|
|
47
|
+
glyphs.map { |g| clone_glyph(g) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def clone_glyph(original)
|
|
51
|
+
copy = Ufo::Glyph.new(name: original.name)
|
|
52
|
+
copy.width = original.width
|
|
53
|
+
copy.height = original.height
|
|
54
|
+
original.unicodes.each { |cp| copy.add_unicode(cp) }
|
|
55
|
+
original.contours.each { |c| copy.add_contour(clone_contour(c)) }
|
|
56
|
+
original.components.each { |c| copy.add_component(c) }
|
|
57
|
+
copy
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def clone_contour(original)
|
|
61
|
+
points = original.points.map do |p|
|
|
62
|
+
Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
|
|
63
|
+
end
|
|
64
|
+
Ufo::Contour.new(points)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|