fontisan 0.4.5 → 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 +142 -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/source.rb +67 -4
- 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/avar.rb +46 -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/hvar.rb +42 -0
- data/lib/fontisan/ufo/compile/item_variation_store.rb +99 -0
- data/lib/fontisan/ufo/compile/math.rb +143 -0
- data/lib/fontisan/ufo/compile/meta.rb +51 -0
- data/lib/fontisan/ufo/compile/mvar.rb +59 -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/stat.rb +103 -0
- data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
- data/lib/fontisan/ufo/compile/ttf_compiler.rb +6 -3
- data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
- data/lib/fontisan/ufo/compile/variable_ttf.rb +161 -0
- data/lib/fontisan/ufo/compile.rb +18 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan.rb +3 -0
- metadata +47 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `meta` table.
|
|
7
|
+
#
|
|
8
|
+
# The meta table stores font metadata as tagged data — longer-form
|
|
9
|
+
# strings than the `name` table supports. Common tags:
|
|
10
|
+
# "dlng" — design languages (BCP-47 tags)
|
|
11
|
+
# "slng" — supported languages
|
|
12
|
+
#
|
|
13
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/meta
|
|
14
|
+
module Meta
|
|
15
|
+
VERSION = 1
|
|
16
|
+
HEADER_SIZE = 16
|
|
17
|
+
DATA_MAP_SIZE = 12 # tag(4) + offset(4) + length(4)
|
|
18
|
+
|
|
19
|
+
# @param data [Hash<String,String>] tag → UTF-8 string value
|
|
20
|
+
# @return [String, nil] meta table bytes, or nil if no data
|
|
21
|
+
def self.build(data:)
|
|
22
|
+
return nil if data.nil? || data.empty?
|
|
23
|
+
|
|
24
|
+
count = data.size
|
|
25
|
+
data_offset = HEADER_SIZE + (count * DATA_MAP_SIZE)
|
|
26
|
+
|
|
27
|
+
io = +""
|
|
28
|
+
io << [VERSION, 0, count, data_offset].pack("NNNN")
|
|
29
|
+
|
|
30
|
+
# Two iterations over the same data: the meta table has two
|
|
31
|
+
# distinct output sections (data map entries then data values)
|
|
32
|
+
# that cannot be interleaved. The map-entry section records
|
|
33
|
+
# offsets that depend on the total size of the value section,
|
|
34
|
+
# so the value section must be produced last.
|
|
35
|
+
#
|
|
36
|
+
# rubocop:disable Style/CombinableLoops
|
|
37
|
+
offset = data_offset
|
|
38
|
+
data.each do |tag, value|
|
|
39
|
+
io << tag.ljust(4, " ")[0, 4]
|
|
40
|
+
io << [offset, value.bytesize].pack("NN")
|
|
41
|
+
offset += value.bytesize
|
|
42
|
+
end
|
|
43
|
+
data.each_value { |value| io << value.b }
|
|
44
|
+
# rubocop:enable Style/CombinableLoops
|
|
45
|
+
|
|
46
|
+
io
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `MVAR` (Metrics Variation) table.
|
|
7
|
+
#
|
|
8
|
+
# Stores deltas for font-wide metrics (ascender, descender, etc.)
|
|
9
|
+
# so they can vary across the design space.
|
|
10
|
+
#
|
|
11
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/MVAR
|
|
12
|
+
module Mvar
|
|
13
|
+
HEADER_SIZE = 10 # majorVersion(2) + minorVersion(2) + valueRecordSize(2) +
|
|
14
|
+
# valueRecordCount(2) + itemVariationStoreOffset(2)
|
|
15
|
+
VALUE_RECORD_SIZE = 8 # tag(4) + outerIndex(2) + innerIndex(2)
|
|
16
|
+
|
|
17
|
+
# @param default_metrics [Hash<Symbol, Integer>] e.g. { hasc: 800, hdsc: -200 }
|
|
18
|
+
# @param master_metrics [Array<Hash<Symbol, Integer>>] per master
|
|
19
|
+
# @param axis_count [Integer]
|
|
20
|
+
# @return [String] MVAR table bytes
|
|
21
|
+
def self.build(default_metrics:, master_metrics:, axis_count:)
|
|
22
|
+
tags = default_metrics.keys
|
|
23
|
+
return nil if tags.empty?
|
|
24
|
+
|
|
25
|
+
master_count = master_metrics.size
|
|
26
|
+
|
|
27
|
+
deltas = []
|
|
28
|
+
records = +""
|
|
29
|
+
tags.each_with_index do |tag, idx|
|
|
30
|
+
tag_bytes = tag.to_s.ljust(4, " ")[0, 4]
|
|
31
|
+
delta = master_metrics.dig(0, tag).to_i - default_metrics[tag].to_i
|
|
32
|
+
records << tag_bytes
|
|
33
|
+
records << [0, idx].pack("nn") # outerIndex=0, innerIndex=idx
|
|
34
|
+
deltas << [delta]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
store = ItemVariationStore.build(
|
|
38
|
+
axis_count: axis_count,
|
|
39
|
+
master_count: master_count,
|
|
40
|
+
item_count: tags.size,
|
|
41
|
+
deltas: deltas,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
store_offset = HEADER_SIZE + records.bytesize
|
|
45
|
+
|
|
46
|
+
io = +""
|
|
47
|
+
io << [1].pack("n") # majorVersion
|
|
48
|
+
io << [0].pack("n") # minorVersion
|
|
49
|
+
io << [VALUE_RECORD_SIZE].pack("n")
|
|
50
|
+
io << [tags.size].pack("n") # valueRecordCount
|
|
51
|
+
io << [store_offset].pack("n") # itemVariationStoreOffset (Offset16)
|
|
52
|
+
io << records
|
|
53
|
+
io << store
|
|
54
|
+
io
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# UFO → OTF (CFF2). Uses CFF2 outlines instead of CFF1.
|
|
7
|
+
#
|
|
8
|
+
# CFF2 uses the same OTTO sfnt signature as CFF1. The difference
|
|
9
|
+
# is the table tag: `CFF2` (vs `CFF ` for CFF1). CFF2 enables
|
|
10
|
+
# variable font support and improved subroutinization.
|
|
11
|
+
#
|
|
12
|
+
# Note: CFF2 does NOT bypass the 65,535 glyph cap. The maxp
|
|
13
|
+
# table's numGlyphs is uint16 in all OpenType versions, and the
|
|
14
|
+
# CFF2 CharStrings INDEX count must match it. For > 65,535
|
|
15
|
+
# glyphs, use TTC splitting.
|
|
16
|
+
class Otf2Compiler < BaseCompiler
|
|
17
|
+
SFNT_VERSION = SFNT_VERSION_OPEN_TYPE
|
|
18
|
+
|
|
19
|
+
def build_outline_tables
|
|
20
|
+
{
|
|
21
|
+
"CFF2" => Cff2.build(font, glyphs: font.glyphs.values),
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def compile(output_path:)
|
|
26
|
+
glyphs = font.glyphs.values
|
|
27
|
+
|
|
28
|
+
tables = {
|
|
29
|
+
"head" => Head.build(font, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
|
|
30
|
+
"hhea" => Hhea.build(font, glyphs: glyphs),
|
|
31
|
+
"maxp" => Maxp.build(font, glyphs: glyphs, version: Maxp::VERSION_OPEN_TYPE),
|
|
32
|
+
"OS/2" => Os2.build(font, glyphs: glyphs),
|
|
33
|
+
"name" => Name.build(font),
|
|
34
|
+
"post" => Post.build(font),
|
|
35
|
+
"hmtx" => Hmtx.build(font, glyphs: glyphs),
|
|
36
|
+
"cmap" => Cmap.build(font, glyphs: glyphs),
|
|
37
|
+
"CFF2" => Cff2.build(font, glyphs: glyphs),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
write(tables, output_path)
|
|
41
|
+
output_path
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `sbix` (Apple Bitmap) table.
|
|
7
|
+
#
|
|
8
|
+
# sbix stores bitmap strikes (PNG/JPEG) at multiple sizes. Each
|
|
9
|
+
# strike is a set of glyph-to-bitmap mappings at a specific ppem
|
|
10
|
+
# and resolution.
|
|
11
|
+
#
|
|
12
|
+
# Layout:
|
|
13
|
+
# Header (8 bytes):
|
|
14
|
+
# uint16 version (= 1)
|
|
15
|
+
# uint16 flags
|
|
16
|
+
# uint32 numStrikes
|
|
17
|
+
# Offset32 strikeOffsets[numStrikes]
|
|
18
|
+
#
|
|
19
|
+
# Strike (per ppem):
|
|
20
|
+
# uint16 ppem
|
|
21
|
+
# uint16 resolution (ppi)
|
|
22
|
+
# Offset32 glyphDataOffsets[numGlyphs + 1]
|
|
23
|
+
#
|
|
24
|
+
# Glyph Data (per glyph):
|
|
25
|
+
# int16 originOffsetX
|
|
26
|
+
# int16 originOffsetY
|
|
27
|
+
# uint16 graphicType ("png " or "jpeg")
|
|
28
|
+
# uint8 bitmapData[]
|
|
29
|
+
#
|
|
30
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/sbix
|
|
31
|
+
module Sbix
|
|
32
|
+
VERSION = 1
|
|
33
|
+
PNG_TYPE = "png "
|
|
34
|
+
JPEG_TYPE = "jpeg"
|
|
35
|
+
|
|
36
|
+
# @param strikes [Array<Hash>] each with :ppem, :resolution,
|
|
37
|
+
# and :glyphs (Array<Hash> with :origin_x, :origin_y,
|
|
38
|
+
# :graphic_type, :data per glyph)
|
|
39
|
+
# @param num_glyphs [Integer] total glyph count
|
|
40
|
+
# @return [String, nil] sbix bytes, or nil if no strikes
|
|
41
|
+
def self.build(strikes:, num_glyphs:)
|
|
42
|
+
return nil if strikes.nil? || strikes.empty?
|
|
43
|
+
|
|
44
|
+
num_strikes = strikes.size
|
|
45
|
+
header_size = 4 + 4 + (num_strikes * 4) # version + flags + offsets
|
|
46
|
+
|
|
47
|
+
strike_bytes = strikes.map { |s| build_strike(s, num_glyphs) }
|
|
48
|
+
|
|
49
|
+
io = +""
|
|
50
|
+
io << [VERSION, 0, num_strikes].pack("nnN")
|
|
51
|
+
|
|
52
|
+
offset = header_size
|
|
53
|
+
strike_bytes.each do |s|
|
|
54
|
+
io << [offset].pack("N")
|
|
55
|
+
offset += s.bytesize
|
|
56
|
+
io << s
|
|
57
|
+
end
|
|
58
|
+
io
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def self.build_strike(strike, num_glyphs)
|
|
62
|
+
glyphs = strike[:glyphs] || []
|
|
63
|
+
ppem = strike[:ppem] || 0
|
|
64
|
+
resolution = strike[:resolution] || 72
|
|
65
|
+
|
|
66
|
+
# Offset table: ppem(2) + resolution(2) + (numGlyphs+1) × offset(4)
|
|
67
|
+
offset_table_size = 4 + ((num_glyphs + 1) * 4)
|
|
68
|
+
|
|
69
|
+
offsets = []
|
|
70
|
+
glyph_data = +""
|
|
71
|
+
current_offset = offset_table_size
|
|
72
|
+
|
|
73
|
+
num_glyphs.times do |gid|
|
|
74
|
+
glyph = glyphs[gid]
|
|
75
|
+
if glyph && glyph[:data]
|
|
76
|
+
offsets << current_offset
|
|
77
|
+
data = glyph[:data].b
|
|
78
|
+
glyph_data << [glyph[:origin_x] || 0, glyph[:origin_y] || 0].pack("nn")
|
|
79
|
+
glyph_data << (glyph[:graphic_type] || PNG_TYPE).ljust(4)[0, 4]
|
|
80
|
+
glyph_data << data
|
|
81
|
+
current_offset += 8 + data.bytesize
|
|
82
|
+
else
|
|
83
|
+
offsets << current_offset
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
offsets << current_offset # sentinel
|
|
87
|
+
|
|
88
|
+
io = +""
|
|
89
|
+
io << [ppem, resolution].pack("nn")
|
|
90
|
+
offsets.each { |o| io << [o].pack("N") }
|
|
91
|
+
io << glyph_data
|
|
92
|
+
io
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method :build_strike
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `STAT` (Style Attributes) table.
|
|
7
|
+
#
|
|
8
|
+
# Describes style attributes for each axis and named instances.
|
|
9
|
+
# Required for proper font matching in operating systems.
|
|
10
|
+
#
|
|
11
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/STAT
|
|
12
|
+
module Stat
|
|
13
|
+
HEADER_SIZE = 20
|
|
14
|
+
DESIGN_AXIS_RECORD_SIZE = 8 # tag(4) + nameID(2) + ordering(2)
|
|
15
|
+
AXIS_VALUE_OFFSET_SIZE = 4 # uint32 per axis value
|
|
16
|
+
|
|
17
|
+
# @param axes [Array<Hash>] axis definitions with tag + name_id + ordering
|
|
18
|
+
# @param axis_values [Array<Hash>] value records per axis
|
|
19
|
+
# @param elided_name_id [Integer, nil] fallback name ID
|
|
20
|
+
# @return [String] STAT table bytes
|
|
21
|
+
def self.build(axes:, axis_values: nil, elided_name_id: nil)
|
|
22
|
+
return nil if axes.nil? || axes.empty?
|
|
23
|
+
|
|
24
|
+
design_axis_count = axes.size
|
|
25
|
+
axis_value_list = axis_values || []
|
|
26
|
+
axis_value_count = axis_value_list.size
|
|
27
|
+
|
|
28
|
+
design_axes = serialize_design_axes(axes)
|
|
29
|
+
value_tables, value_offsets = serialize_axis_values(axis_value_list, design_axes.bytesize)
|
|
30
|
+
|
|
31
|
+
header = serialize_header(
|
|
32
|
+
design_axis_count: design_axis_count,
|
|
33
|
+
design_axes_size: design_axes.bytesize,
|
|
34
|
+
axis_value_count: axis_value_count,
|
|
35
|
+
elided_name_id: elided_name_id,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
io = +""
|
|
39
|
+
io << header
|
|
40
|
+
io << design_axes
|
|
41
|
+
axis_value_list.each_index do |i|
|
|
42
|
+
io << [value_offsets[i]].pack("N")
|
|
43
|
+
end
|
|
44
|
+
io << value_tables
|
|
45
|
+
io
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.serialize_header(design_axis_count:, design_axes_size:, axis_value_count:, elided_name_id:)
|
|
49
|
+
design_axes_offset = HEADER_SIZE
|
|
50
|
+
offset_to_axis_value_offsets = design_axes_offset + design_axes_size
|
|
51
|
+
|
|
52
|
+
[
|
|
53
|
+
0x00010001, # version 1.1
|
|
54
|
+
DESIGN_AXIS_RECORD_SIZE,
|
|
55
|
+
design_axis_count,
|
|
56
|
+
design_axes_offset,
|
|
57
|
+
axis_value_count,
|
|
58
|
+
offset_to_axis_value_offsets,
|
|
59
|
+
elided_name_id || 0,
|
|
60
|
+
].pack("NnnNnNn")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.serialize_design_axes(axes)
|
|
64
|
+
io = +""
|
|
65
|
+
axes.each_with_index do |axis, i|
|
|
66
|
+
tag = (axis[:tag] || axis["tag"] || " ").to_s.ljust(4, " ")[0, 4]
|
|
67
|
+
name_id = axis[:name_id] || axis["name_id"] || 0
|
|
68
|
+
ordering = axis[:ordering] || axis["ordering"] || i
|
|
69
|
+
io << tag
|
|
70
|
+
io << [name_id, ordering].pack("nn")
|
|
71
|
+
end
|
|
72
|
+
io
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Each AxisValueTable is Format 1 (nominal): format(2) + axisIndex(2)
|
|
76
|
+
# + flags(2) + valueNameID(2) + value(F2DOT14) = 10 bytes.
|
|
77
|
+
def self.serialize_axis_values(axis_values, design_axes_size)
|
|
78
|
+
value_tables = +""
|
|
79
|
+
value_offsets = []
|
|
80
|
+
base = HEADER_SIZE + design_axes_size + (axis_values.size * AXIS_VALUE_OFFSET_SIZE)
|
|
81
|
+
|
|
82
|
+
axis_values.each do |av|
|
|
83
|
+
value_offsets << (base + value_tables.bytesize)
|
|
84
|
+
format = 1
|
|
85
|
+
axis_idx = av[:axis_index] || av["axis_index"] || 0
|
|
86
|
+
flags = av[:flags] || av["flags"] || 0
|
|
87
|
+
name_id = av[:name_id] || av["name_id"] || 0
|
|
88
|
+
value = f2dot14(av[:value] || av["value"] || 0)
|
|
89
|
+
value_tables << [format, axis_idx, flags, name_id, value].pack("nnnnn")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
[value_tables, value_offsets]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def self.f2dot14(value)
|
|
96
|
+
(value.to_f * 16384).to_i.clamp(-16384, 16384)
|
|
97
|
+
end
|
|
98
|
+
private_class_method :serialize_header, :serialize_design_axes,
|
|
99
|
+
:serialize_axis_values, :f2dot14
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `SVG ` table for SVG-in-OpenType color glyphs.
|
|
7
|
+
#
|
|
8
|
+
# Layout:
|
|
9
|
+
# Header (10 bytes):
|
|
10
|
+
# uint16 version (= 0)
|
|
11
|
+
# Offset32 documentListOffset (= 10, immediately after header)
|
|
12
|
+
# uint32 reserved (= 0)
|
|
13
|
+
#
|
|
14
|
+
# Document List:
|
|
15
|
+
# uint16 numEntries
|
|
16
|
+
# DocumentRecord[numEntries]:
|
|
17
|
+
# uint16 startGlyphID
|
|
18
|
+
# uint16 endGlyphID
|
|
19
|
+
# Offset32 svgDocOffset (from start of SVG table)
|
|
20
|
+
# uint32 svgDocLength
|
|
21
|
+
#
|
|
22
|
+
# SVG Documents (raw XML, optionally gzipped)
|
|
23
|
+
#
|
|
24
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/svg
|
|
25
|
+
module SvgTable
|
|
26
|
+
VERSION = 0
|
|
27
|
+
HEADER_SIZE = 10
|
|
28
|
+
DOCUMENT_RECORD_SIZE = 12
|
|
29
|
+
|
|
30
|
+
# @param entries [Array<Hash>] each with :start_gid (Integer),
|
|
31
|
+
# :end_gid (Integer), :svg (String — raw SVG XML)
|
|
32
|
+
# @return [String, nil] SVG table bytes, or nil if no entries
|
|
33
|
+
def self.build(entries:)
|
|
34
|
+
return nil if entries.nil? || entries.empty?
|
|
35
|
+
|
|
36
|
+
num_entries = entries.size
|
|
37
|
+
doc_list_size = 2 + (num_entries * DOCUMENT_RECORD_SIZE)
|
|
38
|
+
doc_data_offset = HEADER_SIZE + doc_list_size
|
|
39
|
+
|
|
40
|
+
header = [VERSION, HEADER_SIZE, 0].pack("nNN")
|
|
41
|
+
|
|
42
|
+
records = +""
|
|
43
|
+
docs = +""
|
|
44
|
+
offset = doc_data_offset
|
|
45
|
+
entries.each do |entry|
|
|
46
|
+
svg_bytes = entry[:svg].b
|
|
47
|
+
records << [entry[:start_gid], entry[:end_gid],
|
|
48
|
+
offset, svg_bytes.bytesize].pack("nnNN")
|
|
49
|
+
docs << svg_bytes
|
|
50
|
+
offset += svg_bytes.bytesize
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
doc_list = [num_entries].pack("n") + records
|
|
54
|
+
|
|
55
|
+
header + doc_list + docs
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -12,7 +12,8 @@ module Fontisan
|
|
|
12
12
|
class TtfCompiler < BaseCompiler
|
|
13
13
|
SFNT_VERSION = SFNT_VERSION_TRUE_TYPE
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
# @return [Hash<String, #to_binary_s>] all TTF tables, not yet written
|
|
16
|
+
def build_tables
|
|
16
17
|
glyphs = font.glyphs.values
|
|
17
18
|
|
|
18
19
|
# Deep-clone glyphs so filters don't mutate the source UFO.
|
|
@@ -22,7 +23,7 @@ module Fontisan
|
|
|
22
23
|
glyf_loca = GlyfLoca.build(font, glyphs: filtered)
|
|
23
24
|
loca_format = glyf_loca.delete(:loca_format)
|
|
24
25
|
|
|
25
|
-
|
|
26
|
+
{
|
|
26
27
|
"head" => Head.build(font, glyphs: filtered,
|
|
27
28
|
loca_format: loca_format || Head::LOCA_FORMAT_LONG),
|
|
28
29
|
"hhea" => Hhea.build(font, glyphs: filtered),
|
|
@@ -36,8 +37,10 @@ module Fontisan
|
|
|
36
37
|
"glyf" => glyf_loca["glyf"],
|
|
37
38
|
"loca" => glyf_loca["loca"],
|
|
38
39
|
}
|
|
40
|
+
end
|
|
39
41
|
|
|
40
|
-
|
|
42
|
+
def compile(output_path:)
|
|
43
|
+
write(build_tables, output_path)
|
|
41
44
|
output_path
|
|
42
45
|
end
|
|
43
46
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Ufo
|
|
7
|
+
module Compile
|
|
8
|
+
# VariableOtf — produces a variable OTF with CFF2 outlines.
|
|
9
|
+
#
|
|
10
|
+
# The orchestrator assembles a default-master UFO plus variation
|
|
11
|
+
# masters into a variable OpenType font with:
|
|
12
|
+
# - CFF2 outlines (static — blend/vsindex operators are TODO 07/18)
|
|
13
|
+
# - fvar (axes + instances)
|
|
14
|
+
# - STAT (style attributes)
|
|
15
|
+
# - avar (axis remapping, when maps are provided)
|
|
16
|
+
#
|
|
17
|
+
# NOTE: the CFF2 table currently contains static outlines (no
|
|
18
|
+
# VariationStore, no blend operators). Full variable CFF2 requires
|
|
19
|
+
# TODO 07 (blend/vsindex) and TODO 18 (blend integration) to be
|
|
20
|
+
# wired into the charstring builder.
|
|
21
|
+
#
|
|
22
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/cff2
|
|
23
|
+
class VariableOtf
|
|
24
|
+
# @param default_font [Fontisan::Ufo::Font] the default master
|
|
25
|
+
# @param master_fonts [Array<Fontisan::Ufo::Font>] variation masters
|
|
26
|
+
# @param axes [Array<Hash>] fvar axes (tag, min, default, max, name_id)
|
|
27
|
+
# @param instances [Array<Hash>] named instances
|
|
28
|
+
def initialize(default_font, master_fonts: [], axes: [], instances: [])
|
|
29
|
+
@default = default_font
|
|
30
|
+
@masters = master_fonts
|
|
31
|
+
@axes = axes
|
|
32
|
+
@instances = instances
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Compile the variable OTF directly to the output path.
|
|
36
|
+
# @param output_path [String] output file path
|
|
37
|
+
# @return [String] the output path
|
|
38
|
+
def compile(output_path:)
|
|
39
|
+
tables = build_tables
|
|
40
|
+
Fontisan::FontWriter.write_to_file(
|
|
41
|
+
tables.transform_values { |t| t.is_a?(String) ? t : t.to_binary_s },
|
|
42
|
+
output_path,
|
|
43
|
+
sfnt_version: 0x4F54544F,
|
|
44
|
+
)
|
|
45
|
+
output_path
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Build all tables for the variable OTF.
|
|
49
|
+
# @return [Hash<String,String>] table tag → bytes
|
|
50
|
+
def build_tables
|
|
51
|
+
glyphs = @default.glyphs.values
|
|
52
|
+
|
|
53
|
+
tables = {
|
|
54
|
+
"head" => Head.build(@default, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
|
|
55
|
+
"hhea" => Hhea.build(@default, glyphs: glyphs),
|
|
56
|
+
"maxp" => Maxp.build(@default, glyphs: glyphs, version: Maxp::VERSION_OPEN_TYPE),
|
|
57
|
+
"OS/2" => Os2.build(@default, glyphs: glyphs),
|
|
58
|
+
"name" => Name.build(@default),
|
|
59
|
+
"post" => Post.build(@default),
|
|
60
|
+
"hmtx" => Hmtx.build(@default, glyphs: glyphs),
|
|
61
|
+
"cmap" => Cmap.build(@default, glyphs: glyphs),
|
|
62
|
+
"CFF2" => Cff2.build(@default, glyphs: glyphs),
|
|
63
|
+
"fvar" => Fvar.build(@default, axes: @axes, instances: @instances),
|
|
64
|
+
"STAT" => Stat.build(axes: @axes),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
avar_bytes = Avar.build(axes: @axes)
|
|
68
|
+
tables["avar"] = avar_bytes if avar_bytes
|
|
69
|
+
|
|
70
|
+
tables
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Orchestrates compilation of a UFO source plus its variation
|
|
7
|
+
# masters into a single variable TrueType font.
|
|
8
|
+
#
|
|
9
|
+
# Pipeline:
|
|
10
|
+
#
|
|
11
|
+
# default UFO font
|
|
12
|
+
# │
|
|
13
|
+
# ├─ TtfCompiler tables (head, hhea, maxp, OS/2, name,
|
|
14
|
+
# │ post, hmtx, cmap, glyf, loca)
|
|
15
|
+
# │
|
|
16
|
+
# ├─ fvar (axes + named instances)
|
|
17
|
+
# ├─ gvar (per-glyph point deltas across masters)
|
|
18
|
+
# ├─ HVAR (advance-width deltas across masters)
|
|
19
|
+
# ├─ MVAR (font-wide metric deltas across masters)
|
|
20
|
+
# ├─ avar (per-axis non-linear maps; defaults to identity)
|
|
21
|
+
# └─ STAT (style attributes for OS matching)
|
|
22
|
+
#
|
|
23
|
+
# Masters are supplied as an Array of Hashes, each with:
|
|
24
|
+
# - :font — the master UFO::Font
|
|
25
|
+
# - :axes — Hash<String, Float> mapping axis tag → region peak
|
|
26
|
+
#
|
|
27
|
+
class VariableTtf
|
|
28
|
+
SFNT_VERSION = BaseCompiler::SFNT_VERSION_TRUE_TYPE
|
|
29
|
+
|
|
30
|
+
# @param font [Fontisan::Ufo::Font] default master
|
|
31
|
+
# @param axes [Array<Hash>] axis definitions (tag, min/default/max,
|
|
32
|
+
# optional name_id, ordering, maps)
|
|
33
|
+
# @param masters [Array<Hash>] each master: { font:, axes: }
|
|
34
|
+
# @param instances [Array<Hash>] named instances for fvar
|
|
35
|
+
# @param stat_axis_values [Array<Hash>, nil] STAT axis value records
|
|
36
|
+
# @param stat_elided_name_id [Integer, nil] STAT elided fallback name
|
|
37
|
+
# @param default_metrics [Hash<Symbol, Integer>, nil] MVAR defaults
|
|
38
|
+
# @param master_metrics [Array<Hash<Symbol, Integer>>, nil] MVAR per-master
|
|
39
|
+
def initialize(font:, axes:, masters:, instances: nil,
|
|
40
|
+
stat_axis_values: nil, stat_elided_name_id: nil,
|
|
41
|
+
default_metrics: nil, master_metrics: nil)
|
|
42
|
+
@font = font
|
|
43
|
+
@axes = axes
|
|
44
|
+
@masters = masters
|
|
45
|
+
@instances = instances
|
|
46
|
+
@stat_axis_values = stat_axis_values
|
|
47
|
+
@stat_elided_name_id = stat_elided_name_id
|
|
48
|
+
@default_metrics = default_metrics
|
|
49
|
+
@master_metrics = master_metrics
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# @param output_path [String]
|
|
53
|
+
# @return [String] the output path
|
|
54
|
+
def compile(output_path:)
|
|
55
|
+
tables = base_tables.merge(variation_tables)
|
|
56
|
+
write(tables, output_path)
|
|
57
|
+
output_path
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def base_tables
|
|
63
|
+
@base_tables ||= TtfCompiler.new(@font).build_tables
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def variation_tables
|
|
67
|
+
axis_count = @axes.size
|
|
68
|
+
tables = {}
|
|
69
|
+
|
|
70
|
+
fvar_bytes = Fvar.build(@font, axes: @axes, instances: @instances)
|
|
71
|
+
tables["fvar"] = fvar_bytes if fvar_bytes
|
|
72
|
+
|
|
73
|
+
avar_bytes = Avar.build(axes: @axes)
|
|
74
|
+
tables["avar"] = avar_bytes if avar_bytes
|
|
75
|
+
|
|
76
|
+
stat_bytes = Stat.build(
|
|
77
|
+
axes: stat_axes,
|
|
78
|
+
axis_values: @stat_axis_values,
|
|
79
|
+
elided_name_id: @stat_elided_name_id,
|
|
80
|
+
)
|
|
81
|
+
tables["STAT"] = stat_bytes if stat_bytes
|
|
82
|
+
|
|
83
|
+
default_glyphs = @font.glyphs.values
|
|
84
|
+
glyph_order = default_glyphs.map(&:name)
|
|
85
|
+
|
|
86
|
+
gvar_masters = @masters.map do |m|
|
|
87
|
+
{
|
|
88
|
+
axes: m[:axes],
|
|
89
|
+
glyphs: glyphs_for_master(m[:font], glyph_order),
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
tables["gvar"] = Gvar.build(
|
|
93
|
+
default_glyphs: default_glyphs,
|
|
94
|
+
masters: gvar_masters,
|
|
95
|
+
axis_count: axis_count,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
default_widths = default_glyphs.map { |g| g.width.to_i }
|
|
99
|
+
master_widths = @masters.map do |m|
|
|
100
|
+
glyphs_for_master(m[:font], glyph_order).map { |g| g.width.to_i }
|
|
101
|
+
end
|
|
102
|
+
tables["HVAR"] = Hvar.build(
|
|
103
|
+
default_widths: default_widths,
|
|
104
|
+
master_widths: master_widths,
|
|
105
|
+
axis_count: axis_count,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if @default_metrics && @master_metrics
|
|
109
|
+
mvar = Mvar.build(
|
|
110
|
+
default_metrics: @default_metrics,
|
|
111
|
+
master_metrics: @master_metrics,
|
|
112
|
+
axis_count: axis_count,
|
|
113
|
+
)
|
|
114
|
+
tables["MVAR"] = mvar if mvar
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
tables
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Project a master's glyphs onto the default master's glyph order
|
|
121
|
+
# so deltas align by index. Missing glyphs default to the master's
|
|
122
|
+
# .notdef (index 0).
|
|
123
|
+
def glyphs_for_master(master_font, glyph_order)
|
|
124
|
+
by_name = master_font.glyphs
|
|
125
|
+
notdef = by_name.values.first
|
|
126
|
+
glyph_order.map { |name| by_name[name] || notdef }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# STAT design-axis records derived from @axes. Each axis may
|
|
130
|
+
# supply :name_id and :ordering; missing values default sensibly.
|
|
131
|
+
def stat_axes
|
|
132
|
+
@axes.each_with_index.map do |axis, i|
|
|
133
|
+
{
|
|
134
|
+
tag: axis[:tag] || axis["tag"],
|
|
135
|
+
name_id: axis[:name_id] || axis["name_id"] || 0,
|
|
136
|
+
ordering: axis[:ordering] || axis["ordering"] || i,
|
|
137
|
+
}
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def write(tables_hash, output_path)
|
|
142
|
+
dir = File.dirname(output_path)
|
|
143
|
+
FileUtils.mkpath(dir) unless dir == "."
|
|
144
|
+
Fontisan::FontWriter.write_to_file(
|
|
145
|
+
tables_hash.transform_values { |t| serialize_table(t) },
|
|
146
|
+
output_path,
|
|
147
|
+
sfnt_version: SFNT_VERSION,
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# BinData records respond to to_binary_s; raw String values pass through.
|
|
152
|
+
def serialize_table(table)
|
|
153
|
+
case table
|
|
154
|
+
when String then table
|
|
155
|
+
else table.to_binary_s
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|