fontisan 0.4.46 → 0.4.48
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/BENCHMARKS.md +56 -0
- data/CHANGELOG.md +12 -0
- data/Cargo.lock +550 -0
- data/Cargo.toml +12 -0
- data/Gemfile +10 -0
- data/README.adoc +52 -0
- data/Rakefile +5 -1
- data/benchmark/baseline.json +5 -0
- data/benchmark/ci_gate.rb +217 -0
- data/benchmark/corpus/harness.rb +175 -0
- data/benchmark/corpus/ops.rb +150 -0
- data/benchmark/corpus/run.rb +182 -0
- data/benchmark/corpus/scan_child.rb +8 -0
- data/benchmark/corpus/tiers.rb +176 -0
- data/benchmark/tier_baseline.json +12 -0
- data/docs/.vitepress/config.ts +2 -1
- data/docs/guide/engines.md +117 -0
- data/docs/reference-facade.md +130 -0
- data/ext/fontisan_cdylib/Cargo.lock +313 -0
- data/ext/fontisan_cdylib/Cargo.toml +18 -0
- data/ext/fontisan_cdylib/build.rs +22 -0
- data/ext/fontisan_cdylib/extconf.rb +36 -0
- data/ext/fontisan_cdylib/src/lib.rs +13 -0
- data/ext/fontisan_native/Cargo.toml +21 -0
- data/ext/fontisan_native/build.rs +22 -0
- data/ext/fontisan_native/clippy.toml +10 -0
- data/ext/fontisan_native/extconf.rb +59 -0
- data/ext/fontisan_native/src/lib.rs +463 -0
- data/lib/fontisan/audit/checks/cmap_check.rb +7 -0
- data/lib/fontisan/audit/checks/hinting_check.rb +1 -1
- data/lib/fontisan/audit/checks/maxp_check.rb +1 -1
- data/lib/fontisan/audit/checks/opentype_conformance_check.rb +5 -2
- data/lib/fontisan/base_collection.rb +30 -0
- data/lib/fontisan/byte_ops.rb +219 -0
- data/lib/fontisan/cli.rb +5 -5
- data/lib/fontisan/commands/audit_command.rb +38 -43
- data/lib/fontisan/commands/convert_command.rb +53 -0
- data/lib/fontisan/commands/instance_command.rb +75 -0
- data/lib/fontisan/commands/subset_command.rb +43 -0
- data/lib/fontisan/commands/unpack_command.rb +56 -0
- data/lib/fontisan/converters/cff_table_builder.rb +1 -1
- data/lib/fontisan/converters/collection_converter.rb +13 -5
- data/lib/fontisan/converters/format_converter.rb +1 -1
- data/lib/fontisan/converters/woff2_encoder.rb +8 -6
- data/lib/fontisan/converters/woff_writer.rb +6 -5
- data/lib/fontisan/engine.rb +350 -0
- data/lib/fontisan/ffi/client.rb +121 -0
- data/lib/fontisan/ffi.rb +343 -0
- data/lib/fontisan/ffi_platform.rb +24 -0
- data/lib/fontisan/font_loader.rb +5 -1
- data/lib/fontisan/font_writer.rb +13 -1
- data/lib/fontisan/native.rb +231 -0
- data/lib/fontisan/pipeline/transformation_pipeline.rb +10 -8
- data/lib/fontisan/pipeline.rb +1 -1
- data/lib/fontisan/reference/table_digests.rb +251 -0
- data/lib/fontisan/reference.rb +619 -0
- data/lib/fontisan/scan.rb +372 -0
- data/lib/fontisan/sfnt_directory.rb +41 -0
- data/lib/fontisan/sfnt_font.rb +16 -6
- data/lib/fontisan/subset/builder.rb +2 -1
- data/lib/fontisan/tables/cff/charstring.rb +16 -0
- data/lib/fontisan/tables/cff.rb +15 -5
- data/lib/fontisan/tables/cmap.rb +11 -1
- data/lib/fontisan/tables/glyf.rb +4 -6
- data/lib/fontisan/tables/gpos.rb +7 -0
- data/lib/fontisan/tables/gsub.rb +7 -0
- data/lib/fontisan/tables/layout_common.rb +172 -0
- data/lib/fontisan/tables/name.rb +10 -4
- data/lib/fontisan/utilities/brotli_wrapper.rb +39 -4
- data/lib/fontisan/validators/opentype_validator.rb +15 -3
- data/lib/fontisan/variable/instancer.rb +23 -192
- data/lib/fontisan/variable/static_font_builder.rb +4 -1
- data/lib/fontisan/variable/table_updater.rb +4 -2
- data/lib/fontisan/variation/instance_generator.rb +3 -149
- data/lib/fontisan/variation/instancing/axis_space.rb +136 -0
- data/lib/fontisan/variation/instancing/glyph_outline.rb +204 -0
- data/lib/fontisan/variation/instancing/gvar_reader.rb +146 -0
- data/lib/fontisan/variation/instancing/item_variation_store.rb +92 -0
- data/lib/fontisan/variation/instancing/name_rewriter.rb +90 -0
- data/lib/fontisan/variation/instancing/true_type_instancer.rb +326 -0
- data/lib/fontisan/variation/instancing.rb +20 -0
- data/lib/fontisan/variation/variable_svg_generator.rb +19 -1
- data/lib/fontisan/variation.rb +1 -0
- data/lib/fontisan/version.rb +1 -1
- data/lib/fontisan/woff2/collection_decoder.rb +15 -1
- data/lib/fontisan/woff2/collection_encoder.rb +29 -6
- data/lib/fontisan/woff2/encoder_rules.rb +12 -4
- data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +1 -1
- data/lib/fontisan/woff2_collection.rb +7 -5
- data/lib/fontisan/woff2_font.rb +12 -3
- data/lib/fontisan/woff_container.rb +83 -0
- data/lib/fontisan/woff_font.rb +24 -20
- data/lib/fontisan.rb +24 -1
- data/rakelib/bench.rake +9 -0
- data/rakelib/cdylib.rake +39 -0
- data/rakelib/corpus.rake +272 -0
- data/rakelib/corpus_convert.rake +43 -0
- data/rakelib/corpus_goldens.rake +22 -0
- data/rakelib/corpus_malformed.rake +20 -0
- data/rakelib/corpus_scan.rake +24 -0
- data/rakelib/java.rake +61 -0
- data/rakelib/native.rake +43 -0
- data/scripts/build-woff2.sh +57 -0
- data/scripts/corpus/generate_convert_goldens.rb +478 -0
- data/scripts/corpus/generate_goldens.rb +282 -0
- data/scripts/corpus/generate_malformed_goldens.rb +254 -0
- data/scripts/corpus/generate_scan_goldens.rb +98 -0
- data/scripts/tier_fallback.rb +219 -0
- data/scripts/tier_smoke.rb +64 -0
- metadata +75 -3
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
module Instancing
|
|
6
|
+
# Evaluates HVAR advance-width deltas (ItemVariationStore plus the
|
|
7
|
+
# optional DeltaSetIndexMap) at a normalized location.
|
|
8
|
+
class ItemVariationStore
|
|
9
|
+
# @param hvar [String] raw HVAR table
|
|
10
|
+
def initialize(hvar)
|
|
11
|
+
@hvar = hvar
|
|
12
|
+
store_offset, @advance_map_offset = hvar[4, 8].unpack("NN")
|
|
13
|
+
@store = hvar.byteslice(store_offset..)
|
|
14
|
+
region_list_offset, data_count = @store[2, 6].unpack("Nn")
|
|
15
|
+
@regions = parse_regions(region_list_offset)
|
|
16
|
+
@data_offsets = @store[8, data_count * 4].unpack("N*")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return [Float] advance-width delta for +gid+
|
|
20
|
+
def advance_delta(gid, coords)
|
|
21
|
+
outer, inner = delta_index(gid)
|
|
22
|
+
delta(outer, inner, coords)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def parse_regions(offset)
|
|
28
|
+
axis_count, region_count = @store[offset, 4].unpack("nn")
|
|
29
|
+
Array.new(region_count) do |r|
|
|
30
|
+
pos = offset + 4 + (r * axis_count * 6)
|
|
31
|
+
@store[pos, axis_count * 6].unpack("s>*").map { |v| v / 16384.0 }
|
|
32
|
+
.each_slice(3).to_a
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delta_index(gid)
|
|
37
|
+
return [0, gid] if @advance_map_offset.zero?
|
|
38
|
+
|
|
39
|
+
format, entry_format = @hvar[@advance_map_offset, 2].unpack("CC")
|
|
40
|
+
if format.zero?
|
|
41
|
+
count = @hvar[@advance_map_offset + 2, 2].unpack1("n")
|
|
42
|
+
pos = @advance_map_offset + 4
|
|
43
|
+
else
|
|
44
|
+
count = @hvar[@advance_map_offset + 2, 4].unpack1("N")
|
|
45
|
+
pos = @advance_map_offset + 6
|
|
46
|
+
end
|
|
47
|
+
inner_bits = (entry_format & 0x0F) + 1
|
|
48
|
+
size = ((entry_format & 0x30) >> 4) + 1
|
|
49
|
+
entry_gid = [gid, count - 1].min
|
|
50
|
+
entry = @hvar[pos + (entry_gid * size), size].bytes.inject(0) { |a, b| (a << 8) | b }
|
|
51
|
+
[entry >> inner_bits, entry & ((1 << inner_bits) - 1)]
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def delta(outer, inner, coords)
|
|
55
|
+
offset = @data_offsets[outer]
|
|
56
|
+
return 0.0 unless offset
|
|
57
|
+
|
|
58
|
+
item_count, word_field, region_count = @store[offset, 6].unpack("nnn")
|
|
59
|
+
return 0.0 if inner >= item_count
|
|
60
|
+
|
|
61
|
+
long = word_field.anybits?(0x8000)
|
|
62
|
+
word_count = word_field & 0x7FFF
|
|
63
|
+
indexes = @store[offset + 6, region_count * 2].unpack("n*")
|
|
64
|
+
big = long ? 4 : 2
|
|
65
|
+
small = long ? 2 : 1
|
|
66
|
+
row_size = (word_count * big) + ((region_count - word_count) * small)
|
|
67
|
+
pos = offset + 6 + (region_count * 2) + (inner * row_size)
|
|
68
|
+
words = @store[pos, word_count * big].unpack(long ? "l>*" : "s>*")
|
|
69
|
+
rest = @store[pos + (word_count * big), (region_count - word_count) * small]
|
|
70
|
+
.unpack(long ? "s>*" : "c*")
|
|
71
|
+
(words + rest).each_with_index.sum do |d, i|
|
|
72
|
+
d * region_scalar(@regions[indexes[i]], coords)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def region_scalar(region, coords)
|
|
77
|
+
scalar = 1.0
|
|
78
|
+
region.each_with_index do |(lower, peak, upper), i|
|
|
79
|
+
v = coords[i]
|
|
80
|
+
next if peak.zero? || lower > peak || peak > upper
|
|
81
|
+
next if lower.negative? && upper.positive?
|
|
82
|
+
next if v == peak
|
|
83
|
+
return 0.0 if v <= lower || upper <= v
|
|
84
|
+
|
|
85
|
+
scalar *= v < peak ? (v - lower) / (peak - lower) : (upper - v) / (upper - peak)
|
|
86
|
+
end
|
|
87
|
+
scalar
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
module Instancing
|
|
6
|
+
# Rewrites a name table so it describes one instance of a family.
|
|
7
|
+
class NameRewriter
|
|
8
|
+
Record = Struct.new(:platform, :encoding, :language, :name_id, :text)
|
|
9
|
+
|
|
10
|
+
# @param name_data [String] raw name table
|
|
11
|
+
def initialize(name_data)
|
|
12
|
+
@records = parse(name_data)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def lookup(name_id)
|
|
16
|
+
same = @records.select { |r| r.name_id == name_id }
|
|
17
|
+
rec = same.find { |r| r.platform == 3 && r.language == 0x409 } ||
|
|
18
|
+
same.find { |r| r.platform == 3 } || same.first
|
|
19
|
+
rec&.text
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Names the instance +label+ (e.g. "Light") of the family.
|
|
23
|
+
def rename(label)
|
|
24
|
+
family = lookup(16) || lookup(1) || "Font"
|
|
25
|
+
legacy = ["Regular", "Bold", "Italic", "Bold Italic"].include?(label)
|
|
26
|
+
full = "#{family} #{label}".strip
|
|
27
|
+
ps = "#{family}-#{label}".delete(" ")
|
|
28
|
+
set(1, legacy ? family : full)
|
|
29
|
+
set(2, legacy ? label : "Regular")
|
|
30
|
+
set(3, "#{lookup(5) || ''};#{ps}".delete_prefix(";"))
|
|
31
|
+
set(4, full)
|
|
32
|
+
set(6, ps)
|
|
33
|
+
set(16, family)
|
|
34
|
+
set(17, label)
|
|
35
|
+
@records.reject! { |r| r.name_id == 25 }
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_binary
|
|
40
|
+
recs = @records.sort_by { |r| [r.platform, r.encoding, r.language, r.name_id] }
|
|
41
|
+
strings = +"".b
|
|
42
|
+
entries = recs.map do |r|
|
|
43
|
+
bin = encode(r)
|
|
44
|
+
off = strings.bytesize
|
|
45
|
+
strings << bin
|
|
46
|
+
[r.platform, r.encoding, r.language, r.name_id, bin.bytesize, off]
|
|
47
|
+
end
|
|
48
|
+
header = [0, recs.size, 6 + (recs.size * 12)].pack("nnn")
|
|
49
|
+
header + entries.map { |e| e.pack("n6") }.join + strings
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def set(name_id, text)
|
|
55
|
+
same = @records.select { |r| r.name_id == name_id }
|
|
56
|
+
same.each { |r| r.text = text }
|
|
57
|
+
return if same.any? { |r| r.platform == 3 }
|
|
58
|
+
|
|
59
|
+
@records << Record.new(3, 1, 0x409, name_id, text)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def parse(data)
|
|
63
|
+
count, str_off = data[2, 4].unpack("nn")
|
|
64
|
+
recs = Array.new(count) do |i|
|
|
65
|
+
plat, enc, lang, nid, len, off = data[6 + (i * 12), 12].unpack("n6")
|
|
66
|
+
raw = data[str_off + off, len]
|
|
67
|
+
Record.new(plat, enc, lang, nid, decode(plat, raw))
|
|
68
|
+
end
|
|
69
|
+
recs.reject { |r| r.language >= 0x8000 }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def decode(platform, raw)
|
|
73
|
+
if [0, 3].include?(platform)
|
|
74
|
+
raw.dup.force_encoding("UTF-16BE").encode("UTF-8", invalid: :replace)
|
|
75
|
+
else
|
|
76
|
+
raw.dup.force_encoding("ISO-8859-1").encode("UTF-8")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def encode(rec)
|
|
81
|
+
if [0, 3].include?(rec.platform)
|
|
82
|
+
rec.text.encode("UTF-16BE").b
|
|
83
|
+
else
|
|
84
|
+
rec.text.encode("ISO-8859-1", invalid: :replace, undef: :replace).b
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Variation
|
|
5
|
+
module Instancing
|
|
6
|
+
# Produces a static TrueType font from a gvar-based variable font by
|
|
7
|
+
# applying gvar deltas (with IUP for untouched points) to glyf, HVAR or
|
|
8
|
+
# phantom-point deltas to hmtx, and updating OS/2 and name for the
|
|
9
|
+
# chosen location. cvar, GPOS/GDEF variations and MVAR are not applied.
|
|
10
|
+
class TrueTypeInstancer
|
|
11
|
+
DROPPED = %w[fvar gvar avar HVAR VVAR MVAR STAT cvar DSIG].freeze
|
|
12
|
+
# Tables read by #call besides the variation tables checked below.
|
|
13
|
+
REQUIRED = %w[head hhea hmtx maxp loca].freeze
|
|
14
|
+
WIDTH_CLASSES = [50, 62.5, 75, 87.5, 100, 112.5, 125, 150, 200].freeze
|
|
15
|
+
|
|
16
|
+
# @param tables [Hash{String=>String}] raw tables of the variable font
|
|
17
|
+
# @param coordinates [Hash{String=>Numeric}] axis tag => user value
|
|
18
|
+
def initialize(tables, coordinates)
|
|
19
|
+
@tables = tables
|
|
20
|
+
@coordinates = coordinates
|
|
21
|
+
check_font!
|
|
22
|
+
@space = AxisSpace.new(tables["fvar"], tables["avar"])
|
|
23
|
+
@location = @space.validate(coordinates)
|
|
24
|
+
@norm = @space.normalize(@location)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# @return [Hash{String=>String}] raw tables of the static instance
|
|
28
|
+
def call
|
|
29
|
+
num_glyphs = @tables["maxp"][4, 2].unpack1("n")
|
|
30
|
+
read_metrics(num_glyphs)
|
|
31
|
+
glyphs = Array.new(num_glyphs) { |gid| instance_glyph(gid) }
|
|
32
|
+
resolve_bounds(glyphs)
|
|
33
|
+
out = @tables.except(*DROPPED)
|
|
34
|
+
write_glyf_loca(out, glyphs)
|
|
35
|
+
write_hmtx(out, glyphs)
|
|
36
|
+
update_head_hhea(out, glyphs)
|
|
37
|
+
update_os2(out)
|
|
38
|
+
update_name(out)
|
|
39
|
+
out
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
Glyph = Struct.new(:outline, :points, :advance, :bbox, :data)
|
|
45
|
+
|
|
46
|
+
def check_font!
|
|
47
|
+
unless @tables["fvar"]
|
|
48
|
+
raise MissingVariationTableError.new(table: "fvar")
|
|
49
|
+
end
|
|
50
|
+
unless @tables["gvar"] && @tables["glyf"]
|
|
51
|
+
raise MissingVariationTableError.new(
|
|
52
|
+
table: "gvar",
|
|
53
|
+
message: "Static instancing needs TrueType outlines with gvar " \
|
|
54
|
+
"(CFF2 variable fonts are not supported yet)",
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
REQUIRED.each do |tag|
|
|
59
|
+
next if @tables[tag]
|
|
60
|
+
|
|
61
|
+
raise MissingTableError, "Font missing required #{tag} table"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def read_metrics(num_glyphs)
|
|
66
|
+
nhm = @tables["hhea"][34, 2].unpack1("n")
|
|
67
|
+
hmtx = @tables["hmtx"]
|
|
68
|
+
@advances = []
|
|
69
|
+
@lsbs = []
|
|
70
|
+
num_glyphs.times do |gid|
|
|
71
|
+
if gid < nhm
|
|
72
|
+
@advances << hmtx[gid * 4, 2].unpack1("n")
|
|
73
|
+
@lsbs << hmtx[(gid * 4) + 2, 2].unpack1("s>")
|
|
74
|
+
else
|
|
75
|
+
@advances << @advances.last
|
|
76
|
+
@lsbs << hmtx[(nhm * 4) + ((gid - nhm) * 2), 2].unpack1("s>")
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
long = @tables["head"][50, 2].unpack1("s>") == 1
|
|
80
|
+
@loca = long ? @tables["loca"].unpack("N*") : @tables["loca"].unpack("n*").map { |v| v * 2 }
|
|
81
|
+
@gvar = GvarReader.new(@tables["gvar"])
|
|
82
|
+
@hvar = @tables["HVAR"] ? ItemVariationStore.new(@tables["HVAR"]) : nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def raw_glyph(gid)
|
|
86
|
+
@tables["glyf"].byteslice(@loca[gid]...@loca[gid + 1]) || "".b
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def instance_glyph(gid)
|
|
90
|
+
outline = GlyphOutline.parse(raw_glyph(gid))
|
|
91
|
+
orig = outline.points
|
|
92
|
+
n = orig.size
|
|
93
|
+
xmin = outline.empty? ? 0 : raw_glyph(gid)[2, 2].unpack1("s>")
|
|
94
|
+
pp1x = xmin - @lsbs[gid]
|
|
95
|
+
total = Array.new(n + 4) { [0.0, 0.0] }
|
|
96
|
+
@gvar.tuples_for(gid, n + 4).each do |t|
|
|
97
|
+
s = tuple_scalar(t)
|
|
98
|
+
next if s.zero?
|
|
99
|
+
|
|
100
|
+
dx, dy = expand_deltas(t, outline, orig)
|
|
101
|
+
(n + 4).times do |i|
|
|
102
|
+
total[i][0] += s * dx[i]
|
|
103
|
+
total[i][1] += s * dy[i]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
pts = orig.each_with_index.map do |(x, y), i|
|
|
107
|
+
[round(x + total[i][0]), round(y + total[i][1])]
|
|
108
|
+
end
|
|
109
|
+
advance = if @hvar
|
|
110
|
+
@advances[gid] + round(@hvar.advance_delta(gid, @norm))
|
|
111
|
+
else
|
|
112
|
+
round(pp1x + @advances[gid] + total[n + 1][0]) -
|
|
113
|
+
round(pp1x + total[n][0])
|
|
114
|
+
end
|
|
115
|
+
Glyph.new(outline, pts, [advance, 0].max, nil, nil)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def round(value)
|
|
119
|
+
(value + 0.5).floor
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def tuple_scalar(tuple)
|
|
123
|
+
scalar = 1.0
|
|
124
|
+
tuple.peak.each_with_index do |peak, i|
|
|
125
|
+
next if peak.zero?
|
|
126
|
+
|
|
127
|
+
v = @norm[i]
|
|
128
|
+
next if v == peak
|
|
129
|
+
|
|
130
|
+
lo, hi = if tuple.intermediate?
|
|
131
|
+
[tuple.start[i], tuple.end[i]]
|
|
132
|
+
else
|
|
133
|
+
[0.0, peak].minmax
|
|
134
|
+
end
|
|
135
|
+
next if lo > peak || peak > hi || (lo.negative? && hi.positive?)
|
|
136
|
+
return 0.0 if v <= lo || hi <= v
|
|
137
|
+
|
|
138
|
+
scalar *= v < peak ? (v - lo) / (peak - lo) : (hi - v) / (hi - peak)
|
|
139
|
+
end
|
|
140
|
+
scalar
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Full per-point delta arrays (length n + 4), IUP applied when only
|
|
144
|
+
# some points were given.
|
|
145
|
+
def expand_deltas(tuple, outline, orig)
|
|
146
|
+
total = orig.size + 4
|
|
147
|
+
return [tuple.dx, tuple.dy] if tuple.points.nil?
|
|
148
|
+
|
|
149
|
+
dx = Array.new(total)
|
|
150
|
+
dy = Array.new(total)
|
|
151
|
+
tuple.points.each_with_index do |p, i|
|
|
152
|
+
next if p >= total
|
|
153
|
+
|
|
154
|
+
dx[p] = tuple.dx[i]
|
|
155
|
+
dy[p] = tuple.dy[i]
|
|
156
|
+
end
|
|
157
|
+
unless outline.composite?
|
|
158
|
+
iup(orig.map(&:first), dx, outline.end_pts)
|
|
159
|
+
iup(orig.map(&:last), dy, outline.end_pts)
|
|
160
|
+
end
|
|
161
|
+
[dx.map { |v| v || 0 }, dy.map { |v| v || 0 }]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def iup(coords, deltas, ends)
|
|
165
|
+
start = 0
|
|
166
|
+
ends.each do |last|
|
|
167
|
+
touched = (start..last).select { |i| deltas[i] }
|
|
168
|
+
if touched.empty?
|
|
169
|
+
(start..last).each { |i| deltas[i] = 0 }
|
|
170
|
+
elsif touched.size == 1
|
|
171
|
+
(start..last).each { |i| deltas[i] = deltas[touched.first] }
|
|
172
|
+
else
|
|
173
|
+
touched.each_with_index do |t, k|
|
|
174
|
+
nxt = touched[(k + 1) % touched.size]
|
|
175
|
+
i = t
|
|
176
|
+
loop do
|
|
177
|
+
i = i == last ? start : i + 1
|
|
178
|
+
break if i == nxt
|
|
179
|
+
|
|
180
|
+
deltas[i] = iup_value(coords[i], coords[t], deltas[t],
|
|
181
|
+
coords[nxt], deltas[nxt])
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
start = last + 1
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def iup_value(coord, ref1, delta1, ref2, delta2)
|
|
190
|
+
return (delta1 == delta2 ? delta1 : 0) if ref1 == ref2
|
|
191
|
+
|
|
192
|
+
ref1, delta1, ref2, delta2 = ref2, delta2, ref1, delta1 if ref1 > ref2
|
|
193
|
+
return delta1 if coord <= ref1
|
|
194
|
+
return delta2 if coord >= ref2
|
|
195
|
+
|
|
196
|
+
delta1 + ((coord - ref1) * (delta2 - delta1) / (ref2 - ref1).to_f)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Bounding boxes; composites derive theirs from their components.
|
|
200
|
+
def resolve_bounds(glyphs)
|
|
201
|
+
glyphs.each_index { |gid| bbox_for(glyphs, gid, []) }
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def bbox_for(glyphs, gid, stack)
|
|
205
|
+
g = glyphs[gid]
|
|
206
|
+
return g.bbox if g.bbox
|
|
207
|
+
return g.bbox = [0, 0, 0, 0] if g.outline.empty? || stack.include?(gid)
|
|
208
|
+
|
|
209
|
+
if g.outline.composite?
|
|
210
|
+
corners = []
|
|
211
|
+
g.outline.components.each_with_index do |c, i|
|
|
212
|
+
sub = bbox_for(glyphs, c.gid, stack + [gid])
|
|
213
|
+
next if glyphs[c.gid].outline.empty?
|
|
214
|
+
|
|
215
|
+
corners.concat(transform_corners(sub, c, g.points[i]))
|
|
216
|
+
end
|
|
217
|
+
g.bbox = corners.empty? ? [0, 0, 0, 0] : g.outline.bounds_of(corners).map(&:round)
|
|
218
|
+
else
|
|
219
|
+
g.bbox = g.outline.bounds_of(g.points)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def transform_corners(bbox, comp, offset)
|
|
224
|
+
xmin, ymin, xmax, ymax = bbox
|
|
225
|
+
t = comp.transform
|
|
226
|
+
a, b, c, d = case t.bytesize
|
|
227
|
+
when 2 then s = t.unpack1("s>") / 16384.0
|
|
228
|
+
[s, 0, 0, s]
|
|
229
|
+
when 4 then sx, sy = t.unpack("s>s>").map { |v| v / 16384.0 }
|
|
230
|
+
[sx, 0, 0, sy]
|
|
231
|
+
when 8 then xs, s01, s10, ys = t.unpack("s>4").map { |v| v / 16384.0 }
|
|
232
|
+
[xs, s01, s10, ys]
|
|
233
|
+
else [1, 0, 0, 1]
|
|
234
|
+
end
|
|
235
|
+
[[xmin, ymin], [xmin, ymax], [xmax, ymin], [xmax, ymax]].map do |x, y|
|
|
236
|
+
[(a * x) + (c * y) + offset[0], (b * x) + (d * y) + offset[1]]
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def write_glyf_loca(out, glyphs)
|
|
241
|
+
glyf = +"".b
|
|
242
|
+
loca = [0]
|
|
243
|
+
glyphs.each do |g|
|
|
244
|
+
data = g.outline.serialize(g.points, g.bbox)
|
|
245
|
+
data << "\0".b until (data.bytesize % 4).zero?
|
|
246
|
+
glyf << data
|
|
247
|
+
loca << glyf.bytesize
|
|
248
|
+
end
|
|
249
|
+
out["glyf"] = glyf
|
|
250
|
+
out["loca"] = loca.pack("N*")
|
|
251
|
+
head = out["head"].dup
|
|
252
|
+
head[50, 2] = [1].pack("s>")
|
|
253
|
+
out["head"] = head
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def write_hmtx(out, glyphs)
|
|
257
|
+
hmtx = +"".b
|
|
258
|
+
glyphs.each_with_index do |g, gid|
|
|
259
|
+
lsb = g.outline.empty? ? @lsbs[gid] : g.bbox[0]
|
|
260
|
+
g.data = [g.advance, lsb]
|
|
261
|
+
hmtx << [g.advance, lsb].pack("ns>")
|
|
262
|
+
end
|
|
263
|
+
out["hmtx"] = hmtx
|
|
264
|
+
hhea = out["hhea"].dup
|
|
265
|
+
hhea[34, 2] = [glyphs.size].pack("n")
|
|
266
|
+
out["hhea"] = hhea
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def update_head_hhea(out, glyphs)
|
|
270
|
+
drawn = glyphs.reject { |g| g.outline.empty? }
|
|
271
|
+
return if drawn.empty?
|
|
272
|
+
|
|
273
|
+
head = out["head"].dup
|
|
274
|
+
head[36, 8] = [drawn.map { |g| g.bbox[0] }.min, drawn.map { |g| g.bbox[1] }.min,
|
|
275
|
+
drawn.map { |g| g.bbox[2] }.max, drawn.map { |g| g.bbox[3] }.max].pack("s>4")
|
|
276
|
+
out["head"] = head
|
|
277
|
+
hhea = out["hhea"].dup
|
|
278
|
+
min_lsb = drawn.map { |g| g.data[1] }.min
|
|
279
|
+
min_rsb = drawn.map { |g| g.data[0] - g.data[1] - (g.bbox[2] - g.bbox[0]) }.min
|
|
280
|
+
max_ext = drawn.map { |g| g.data[1] + (g.bbox[2] - g.bbox[0]) }.max
|
|
281
|
+
hhea[10, 8] = [glyphs.map(&:advance).max, min_lsb, min_rsb, max_ext].pack("ns>s>s>")
|
|
282
|
+
out["hhea"] = hhea
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def update_os2(out)
|
|
286
|
+
return unless out["OS/2"]
|
|
287
|
+
|
|
288
|
+
os2 = out["OS/2"].dup
|
|
289
|
+
if @location.key?("wght")
|
|
290
|
+
os2[4, 2] = [@location["wght"].round.clamp(1, 1000)].pack("n")
|
|
291
|
+
end
|
|
292
|
+
if @location.key?("wdth")
|
|
293
|
+
nearest = WIDTH_CLASSES.each_index.min_by { |i| (WIDTH_CLASSES[i] - @location["wdth"]).abs }
|
|
294
|
+
os2[6, 2] = [nearest + 1].pack("n")
|
|
295
|
+
end
|
|
296
|
+
out["OS/2"] = os2
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def update_name(out)
|
|
300
|
+
return unless out["name"]
|
|
301
|
+
|
|
302
|
+
rewriter = NameRewriter.new(out["name"])
|
|
303
|
+
out["name"] = rewriter.rename(instance_label(rewriter)).to_binary
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def instance_label(rewriter)
|
|
307
|
+
match = AxisSpace.named_instances(@tables["fvar"]).find do |inst|
|
|
308
|
+
@space.tags.each_with_index.all? do |tag, i|
|
|
309
|
+
(inst[:values][i] - @location[tag]).abs < 1e-6
|
|
310
|
+
end
|
|
311
|
+
end
|
|
312
|
+
named = match && rewriter.lookup(match[:name_id])
|
|
313
|
+
return named if named
|
|
314
|
+
|
|
315
|
+
parts = @space.axes.reject { |a| @location[a.tag] == a.default }
|
|
316
|
+
.map { |a| "#{a.tag.capitalize}#{format_value(@location[a.tag])}" }
|
|
317
|
+
parts.empty? ? "Regular" : parts.join(" ")
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def format_value(value)
|
|
321
|
+
value == value.to_i ? value.to_i.to_s : value.to_s
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Autoload hub for the Fontisan::Variation::Instancing namespace.
|
|
4
|
+
|
|
5
|
+
module Fontisan
|
|
6
|
+
module Variation
|
|
7
|
+
# Static instancing of TrueType variable fonts: gvar/HVAR delta
|
|
8
|
+
# application, glyf/hmtx rebuilding and OS/2 + name updates.
|
|
9
|
+
module Instancing
|
|
10
|
+
autoload :AxisSpace, "fontisan/variation/instancing/axis_space"
|
|
11
|
+
autoload :GlyphOutline, "fontisan/variation/instancing/glyph_outline"
|
|
12
|
+
autoload :GvarReader, "fontisan/variation/instancing/gvar_reader"
|
|
13
|
+
autoload :ItemVariationStore,
|
|
14
|
+
"fontisan/variation/instancing/item_variation_store"
|
|
15
|
+
autoload :NameRewriter, "fontisan/variation/instancing/name_rewriter"
|
|
16
|
+
autoload :TrueTypeInstancer,
|
|
17
|
+
"fontisan/variation/instancing/true_type_instancer"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -107,6 +107,20 @@ module Fontisan
|
|
|
107
107
|
# Returns all axes at their default values.
|
|
108
108
|
#
|
|
109
109
|
# @return [Hash<String, Float>] Default coordinates
|
|
110
|
+
# Out-of-range requests are pulled to the nearest axis limit and
|
|
111
|
+
# unknown axes dropped, so SVG preview stays tolerant.
|
|
112
|
+
def clamp_to_axes(coords)
|
|
113
|
+
return coords unless @font.has_table?("fvar")
|
|
114
|
+
|
|
115
|
+
by_tag = @font.table("fvar").axes.to_h { |a| [a.axis_tag.to_s, a] }
|
|
116
|
+
coords.each_with_object({}) do |(tag, value), clamped|
|
|
117
|
+
axis = by_tag[tag.to_s]
|
|
118
|
+
next unless axis
|
|
119
|
+
|
|
120
|
+
clamped[tag.to_s] = Float(value).clamp(axis.min_value, axis.max_value)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
110
124
|
def default_coordinates
|
|
111
125
|
return {} unless @font.has_table?("fvar")
|
|
112
126
|
|
|
@@ -164,7 +178,11 @@ module Fontisan
|
|
|
164
178
|
# @return [Hash<String, String>] Instance tables
|
|
165
179
|
def generate_static_instance
|
|
166
180
|
# Use coordinates or defaults if none specified
|
|
167
|
-
coords = @coordinates.empty?
|
|
181
|
+
coords = if @coordinates.empty?
|
|
182
|
+
default_coordinates
|
|
183
|
+
else
|
|
184
|
+
clamp_to_axes(@coordinates)
|
|
185
|
+
end
|
|
168
186
|
|
|
169
187
|
instance_generator = InstanceGenerator.new(@font, coords)
|
|
170
188
|
instance_generator.generate
|
data/lib/fontisan/variation.rb
CHANGED
|
@@ -15,6 +15,7 @@ module Fontisan
|
|
|
15
15
|
autoload :InstanceGenerator, "fontisan/variation/instance_generator"
|
|
16
16
|
autoload :InstanceFontWrapper, "fontisan/variation/instance_font_wrapper"
|
|
17
17
|
autoload :InstanceWriter, "fontisan/variation/instance_writer"
|
|
18
|
+
autoload :Instancing, "fontisan/variation/instancing"
|
|
18
19
|
autoload :Interpolator, "fontisan/variation/interpolator"
|
|
19
20
|
autoload :MetricsAdjuster, "fontisan/variation/metrics_adjuster"
|
|
20
21
|
autoload :Optimizer, "fontisan/variation/optimizer"
|
data/lib/fontisan/version.rb
CHANGED
|
@@ -59,15 +59,29 @@ module Fontisan
|
|
|
59
59
|
# the glyf was transformed.
|
|
60
60
|
font_entries.map do |fe|
|
|
61
61
|
font_tables = {}
|
|
62
|
+
glyf_transformed = false
|
|
62
63
|
fe.table_indices.each do |idx|
|
|
63
64
|
entry = table_entries.fetch(idx)
|
|
64
65
|
font_tables[entry.tag] = entry.raw_bytes
|
|
66
|
+
glyf_transformed = !entry.transform_length.nil? if entry.tag == "glyf"
|
|
65
67
|
end
|
|
66
|
-
reconstruct_glyf_loca!(font_tables) if
|
|
68
|
+
reconstruct_glyf_loca!(font_tables) if glyf_transformed
|
|
67
69
|
{ flavor: fe.flavor, tables: font_tables }
|
|
68
70
|
end
|
|
69
71
|
end
|
|
70
72
|
|
|
73
|
+
# Number of fonts, read from the CollectionDirectory alone: no Brotli
|
|
74
|
+
# inflate, so it works where the `brotli` gem is absent (JRuby).
|
|
75
|
+
#
|
|
76
|
+
# @return [Integer]
|
|
77
|
+
def font_count
|
|
78
|
+
validate_signature!
|
|
79
|
+
validate_collection_flavor!
|
|
80
|
+
|
|
81
|
+
_entries, post_dir_pos = read_table_directory
|
|
82
|
+
read_collection_directory(post_dir_pos).first.length
|
|
83
|
+
end
|
|
84
|
+
|
|
71
85
|
private
|
|
72
86
|
|
|
73
87
|
def validate_signature!
|
|
@@ -21,10 +21,15 @@ module Fontisan
|
|
|
21
21
|
# adjacent (loca immediately follows glyf) in the directory.
|
|
22
22
|
class CollectionEncoder
|
|
23
23
|
TTC_FLAVOR = 0x74746366 # 'ttcf'
|
|
24
|
+
# Largest padded glyf a short (uint16, offset / 2) loca can index.
|
|
25
|
+
SHORT_LOCA_GLYF_MAX = 0x1FFFE
|
|
24
26
|
|
|
25
27
|
# @param brotli_quality [Integer] 0-11
|
|
26
|
-
|
|
28
|
+
# @param timestamp [Integer, nil] Unix seconds stamped into every
|
|
29
|
+
# head.modified; nil uses the clock
|
|
30
|
+
def initialize(brotli_quality: 11, timestamp: nil)
|
|
27
31
|
@brotli_quality = brotli_quality
|
|
32
|
+
@timestamp = timestamp
|
|
28
33
|
end
|
|
29
34
|
|
|
30
35
|
# Encode an array of fonts as a WOFF2 collection.
|
|
@@ -58,7 +63,7 @@ module Fontisan
|
|
|
58
63
|
tables[tag] = data
|
|
59
64
|
end
|
|
60
65
|
|
|
61
|
-
EncoderRules.apply!(tables)
|
|
66
|
+
EncoderRules.apply!(tables, timestamp: @timestamp)
|
|
62
67
|
loca_orig_length = apply_glyf_loca_transform!(tables, font)
|
|
63
68
|
|
|
64
69
|
{ index:, tables:, loca_orig_length: }
|
|
@@ -73,12 +78,30 @@ module Fontisan
|
|
|
73
78
|
head = font.table("head")
|
|
74
79
|
return nil unless maxp && head
|
|
75
80
|
|
|
76
|
-
|
|
81
|
+
glyf = tables["glyf"]
|
|
82
|
+
loca = tables["loca"]
|
|
83
|
+
index_format = head.index_to_loc_format
|
|
84
|
+
# The WOFF2 decoder pads every glyph to 4 bytes; a short loca
|
|
85
|
+
# (offset / 2 as uint16) wraps once that padded glyf passes
|
|
86
|
+
# SHORT_LOCA_GLYF_MAX, so such a member is re-emitted with a long loca.
|
|
87
|
+
if index_format.zero? &&
|
|
88
|
+
glyf.bytesize + 3 * maxp.num_glyphs > SHORT_LOCA_GLYF_MAX
|
|
89
|
+
canonical = GlyfCanonicalizer.new(
|
|
90
|
+
glyf_data: glyf, loca_data: loca, num_glyphs: maxp.num_glyphs,
|
|
91
|
+
source_format: 0, target_format: 1
|
|
92
|
+
).canonical
|
|
93
|
+
glyf = canonical[:glyf]
|
|
94
|
+
loca = canonical[:loca]
|
|
95
|
+
index_format = 1
|
|
96
|
+
EncoderRules.set_head_index_to_loc_format!(tables, 1)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
loca_orig_length = loca.bytesize
|
|
77
100
|
tables["glyf"] = GlyfLocaTransform.new(
|
|
78
|
-
glyf_data:
|
|
79
|
-
loca_data:
|
|
101
|
+
glyf_data: glyf,
|
|
102
|
+
loca_data: loca,
|
|
80
103
|
num_glyphs: maxp.num_glyphs,
|
|
81
|
-
index_format
|
|
104
|
+
index_format:,
|
|
82
105
|
).transform
|
|
83
106
|
tables.delete("loca")
|
|
84
107
|
loca_orig_length
|