fontisan 0.4.5 → 0.4.6
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/CHANGELOG.md +36 -0
- data/lib/fontisan/stitcher/source.rb +67 -4
- data/lib/fontisan/ufo/compile/avar.rb +46 -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/mvar.rb +59 -0
- data/lib/fontisan/ufo/compile/stat.rb +103 -0
- data/lib/fontisan/ufo/compile/ttf_compiler.rb +6 -3
- data/lib/fontisan/ufo/compile/variable_ttf.rb +161 -0
- data/lib/fontisan/ufo/compile.rb +7 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +8 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 519952f147a10de9f78f3e30eb4456a077f35cb24099bb65781e7b1c2c4e59a3
|
|
4
|
+
data.tar.gz: c37e0f9d37bf0512cd7c0ed9be19e0eac5a9c74eb057c951d1a2dba4237c5f11
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c5702491e2326da5f357b5c89904896763c0f57dc807022b11b1d203ab1b4158372595c5059f0fff3143631323048a543e9b2f3046ded97e868d5cf105e518cc
|
|
7
|
+
data.tar.gz: 2f9f010ab0079fedfec7932313231bf7d002b2fed7af6476c459918e92e6d9c01300dac46594b64d16874b95924026bcd1a9779fe53ae1d2ea65240464256616
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `Fontisan::Ufo::Compile::Avar` — builds the OpenType `avar` (Axis
|
|
13
|
+
Variation) table with per-axis non-linear maps (defaults to identity
|
|
14
|
+
-1/0/1 mapping).
|
|
15
|
+
- `Fontisan::Ufo::Compile::Hvar` — builds the OpenType `HVAR`
|
|
16
|
+
(Horizontal Metrics Variation) table with advance-width deltas per
|
|
17
|
+
glyph.
|
|
18
|
+
- `Fontisan::Ufo::Compile::Mvar` — builds the OpenType `MVAR`
|
|
19
|
+
(Metrics Variation) table for font-wide metric deltas (ascender,
|
|
20
|
+
descender, etc.).
|
|
21
|
+
- `Fontisan::Ufo::Compile::Stat` — builds the OpenType `STAT` (Style
|
|
22
|
+
Attributes) table with design axes, axis value tables, and elided
|
|
23
|
+
fallback name ID.
|
|
24
|
+
- `Fontisan::Ufo::Compile::ItemVariationStore` — shared builder for
|
|
25
|
+
the ItemVariationStore structure used by HVAR and MVAR
|
|
26
|
+
(VariationRegionList + ItemVariationData with int8/int16 delta
|
|
27
|
+
packing).
|
|
28
|
+
- `Fontisan::Ufo::Compile::VariableTtf` — orchestrator that compiles
|
|
29
|
+
a default UFO master plus variation masters into a single variable
|
|
30
|
+
TTF (emits fvar, gvar, HVAR, MVAR, avar, STAT alongside the standard
|
|
31
|
+
TTF tables).
|
|
32
|
+
- `TtfCompiler#build_tables` — extracted public method returning the
|
|
33
|
+
TTF table hash without writing, so `VariableTtf` can reuse the
|
|
34
|
+
standard table pipeline.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `Stitcher` silently dropped compound (composite) TrueType glyphs
|
|
39
|
+
from TTF donors. The O(1) extraction path only handled `simple?`
|
|
40
|
+
glyphs and returned `nil` for compounds. Compound glyphs are now
|
|
41
|
+
recursively flattened (with affine transforms applied) into simple
|
|
42
|
+
contours, making them self-contained. Affected donors include
|
|
43
|
+
NotoSansCuneiform (U+12399), NotoSansTaiTham (594 glyphs),
|
|
44
|
+
NotoSerifDivesAkuru (414), NotoSerifTaiYo (1007), and others.
|
|
45
|
+
|
|
10
46
|
### Removed
|
|
11
47
|
|
|
12
48
|
- Audit subsystem (`Fontisan::Audit`, `Fontisan::Commands::Audit*`,
|
|
@@ -16,6 +16,8 @@ module Fontisan
|
|
|
16
16
|
# the raw CBDT/CBLC tables into the output instead of extracting
|
|
17
17
|
# outlines. The glyph data lives in the bitmap tables, not in glyf.
|
|
18
18
|
class Source
|
|
19
|
+
MAX_COMPOUND_DEPTH = 32
|
|
20
|
+
|
|
19
21
|
attr_reader :font
|
|
20
22
|
|
|
21
23
|
def initialize(font)
|
|
@@ -219,14 +221,19 @@ module Fontisan
|
|
|
219
221
|
end
|
|
220
222
|
|
|
221
223
|
def extract_truetype_glyph(gid, cache)
|
|
222
|
-
|
|
223
|
-
return nil unless
|
|
224
|
-
return nil unless simple.respond_to?(:simple?) && simple.simple?
|
|
224
|
+
raw = cache[:glyf].glyph_for(gid, cache[:loca], cache[:head])
|
|
225
|
+
return nil unless raw
|
|
225
226
|
|
|
226
227
|
name = gid.zero? ? ".notdef" : "gid#{gid}"
|
|
227
228
|
glyph = Fontisan::Ufo::Glyph.new(name: name)
|
|
228
229
|
glyph.width = glyph_width(gid)
|
|
229
|
-
|
|
230
|
+
|
|
231
|
+
if raw.respond_to?(:simple?) && raw.simple?
|
|
232
|
+
copy_simple_contours(raw, glyph)
|
|
233
|
+
elsif raw.respond_to?(:compound?) && raw.compound?
|
|
234
|
+
flatten_compound_into(raw, glyph, cache, Set.new)
|
|
235
|
+
end
|
|
236
|
+
|
|
230
237
|
add_cmap_unicodes(gid, glyph)
|
|
231
238
|
glyph
|
|
232
239
|
rescue StandardError
|
|
@@ -253,6 +260,62 @@ module Fontisan
|
|
|
253
260
|
end
|
|
254
261
|
end
|
|
255
262
|
|
|
263
|
+
# Recursively flatten a CompoundGlyph's components into the UFO
|
|
264
|
+
# glyph as contours (with transforms applied). This makes the
|
|
265
|
+
# extracted glyph self-contained — it doesn't depend on the
|
|
266
|
+
# component glyphs being present in the target font.
|
|
267
|
+
#
|
|
268
|
+
# Only components with ARGS_ARE_XY_VALUES are flattened by offset.
|
|
269
|
+
# Point-index alignment (rare) is skipped — those components
|
|
270
|
+
# contribute nothing, but the rest of the compound is preserved.
|
|
271
|
+
def flatten_compound_into(compound, ufo_glyph, cache, visited, depth = 0)
|
|
272
|
+
return if depth > MAX_COMPOUND_DEPTH
|
|
273
|
+
return if visited.include?(compound.glyph_id)
|
|
274
|
+
|
|
275
|
+
visited = visited.dup.add(compound.glyph_id)
|
|
276
|
+
|
|
277
|
+
compound.components.each do |component|
|
|
278
|
+
next unless component.args_are_xy?
|
|
279
|
+
|
|
280
|
+
raw = cache[:glyf].glyph_for(component.glyph_index, cache[:loca], cache[:head])
|
|
281
|
+
next unless raw
|
|
282
|
+
|
|
283
|
+
matrix = component.transformation_matrix
|
|
284
|
+
|
|
285
|
+
if raw.respond_to?(:simple?) && raw.simple?
|
|
286
|
+
flatten_simple_component(raw, ufo_glyph, matrix)
|
|
287
|
+
elsif raw.respond_to?(:compound?) && raw.compound?
|
|
288
|
+
flatten_compound_into(raw, ufo_glyph, cache, visited, depth + 1)
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Apply a 2×3 affine matrix [a, b, c, d, e, f] to each point of
|
|
294
|
+
# a simple component, appending the transformed contours.
|
|
295
|
+
# x' = a*x + c*y + e
|
|
296
|
+
# y' = b*x + d*y + f
|
|
297
|
+
def flatten_simple_component(simple, ufo_glyph, matrix)
|
|
298
|
+
a, b, c, d, e, f = matrix
|
|
299
|
+
num_contours = simple.end_pts_of_contours&.size || 0
|
|
300
|
+
return if num_contours.zero?
|
|
301
|
+
|
|
302
|
+
num_contours.times do |ci|
|
|
303
|
+
points = simple.points_for_contour(ci)
|
|
304
|
+
next unless points && !points.empty?
|
|
305
|
+
|
|
306
|
+
ufo_points = points.map do |pt|
|
|
307
|
+
x = (pt[:x] || pt["x"]).to_f
|
|
308
|
+
y = (pt[:y] || pt["y"]).to_f
|
|
309
|
+
tx = a * x + c * y + e
|
|
310
|
+
ty = b * x + d * y + f
|
|
311
|
+
on_curve = pt[:on_curve].nil? || pt[:on_curve]
|
|
312
|
+
type = on_curve ? "line" : "offcurve"
|
|
313
|
+
Fontisan::Ufo::Point.new(x: tx, y: ty, type: type)
|
|
314
|
+
end
|
|
315
|
+
ufo_glyph.add_contour(Fontisan::Ufo::Contour.new(ufo_points))
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
256
319
|
# Add Unicode codepoints from the cmap that map to this gid.
|
|
257
320
|
def add_cmap_unicodes(gid, glyph)
|
|
258
321
|
cmap = @font.table("cmap")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `avar` (Axis Variation) table.
|
|
7
|
+
#
|
|
8
|
+
# avar defines non-linear interpolation curves for each axis.
|
|
9
|
+
# For axes with linear interpolation (the common case), each
|
|
10
|
+
# axis gets 3 default maps: (-1→-1, 0→0, 1→1).
|
|
11
|
+
#
|
|
12
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/avar
|
|
13
|
+
module Avar
|
|
14
|
+
# @param axes [Array<Hash>] axis definitions (tag + optional maps)
|
|
15
|
+
# @return [String] avar table bytes
|
|
16
|
+
def self.build(axes:)
|
|
17
|
+
return nil if axes.nil? || axes.empty?
|
|
18
|
+
|
|
19
|
+
io = +""
|
|
20
|
+
io << [0x00010000].pack("N") # version 1.0
|
|
21
|
+
io << [0].pack("n") # reserved
|
|
22
|
+
io << [axes.size].pack("n") # axisCount
|
|
23
|
+
|
|
24
|
+
axes.each do |axis|
|
|
25
|
+
maps = axis[:maps] || default_maps
|
|
26
|
+
io << [maps.size].pack("n")
|
|
27
|
+
maps.each do |from, to|
|
|
28
|
+
io << [f2dot14(from), f2dot14(to)].pack("nn")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
io
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.default_maps
|
|
36
|
+
[[-1.0, -1.0], [0.0, 0.0], [1.0, 1.0]].freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.f2dot14(value)
|
|
40
|
+
(value.to_f * 16384).to_i.clamp(-16384, 16384)
|
|
41
|
+
end
|
|
42
|
+
private_class_method :default_maps, :f2dot14
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Builds the OpenType `HVAR` (Horizontal Metrics Variation) table.
|
|
7
|
+
#
|
|
8
|
+
# Stores advance-width variation deltas per glyph, enabling
|
|
9
|
+
# variable-font renderers to adjust spacing without loading gvar.
|
|
10
|
+
#
|
|
11
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/HVAR
|
|
12
|
+
module Hvar
|
|
13
|
+
# @param default_widths [Array<Integer>] advance widths for default master
|
|
14
|
+
# @param master_widths [Array<Array<Integer>>] advance widths per master
|
|
15
|
+
# @param axis_count [Integer]
|
|
16
|
+
# @return [String] HVAR table bytes
|
|
17
|
+
def self.build(default_widths:, master_widths:, axis_count:)
|
|
18
|
+
glyph_count = default_widths.size
|
|
19
|
+
master_count = master_widths.size
|
|
20
|
+
|
|
21
|
+
# Compute deltas: deltas[glyph][master] = master_width - default_width
|
|
22
|
+
deltas = Array.new(glyph_count) do |gid|
|
|
23
|
+
Array.new(master_count) do |mid|
|
|
24
|
+
master_widths.dig(mid, gid).to_i - default_widths[gid].to_i
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
store = ItemVariationStore.build(
|
|
29
|
+
axis_count: axis_count,
|
|
30
|
+
master_count: master_count,
|
|
31
|
+
item_count: glyph_count,
|
|
32
|
+
deltas: deltas,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# HVAR header: version(4) + itemVariationStoreOffset(4) +
|
|
36
|
+
# advanceWidthMappingOffset(4) + lsbMappingOffset(4) + rsbMappingOffset(4)
|
|
37
|
+
[0x00010000, 20].pack("NN") + [0, 0, 0].pack("NNN") + store
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Fontisan
|
|
4
|
+
module Ufo
|
|
5
|
+
module Compile
|
|
6
|
+
# Shared builder for the ItemVariationStore structure used by
|
|
7
|
+
# HVAR, MVAR, and other variation tables.
|
|
8
|
+
#
|
|
9
|
+
# The ItemVariationStore consists of:
|
|
10
|
+
# 1. A VariationRegionList (defines axis regions)
|
|
11
|
+
# 2. One or more ItemVariationData (delta sets per item)
|
|
12
|
+
#
|
|
13
|
+
# @see https://learn.microsoft.com/en-us/typography/opentype/spec/otvarcommonformats
|
|
14
|
+
module ItemVariationStore
|
|
15
|
+
# @param axis_count [Integer]
|
|
16
|
+
# @param master_count [Integer] number of masters (one region per master)
|
|
17
|
+
# @param item_count [Integer] number of items (glyphs for HVAR, metrics for MVAR)
|
|
18
|
+
# @param deltas [Array<Array<Integer>>] deltas[item][master] = delta value
|
|
19
|
+
# @return [String] ItemVariationStore bytes
|
|
20
|
+
def self.build(axis_count:, master_count:, item_count:, deltas:)
|
|
21
|
+
# Build regions: one per master, each with peak=1.0 on its axis
|
|
22
|
+
regions = build_regions(axis_count, master_count)
|
|
23
|
+
region_list = serialize_region_list(regions, axis_count)
|
|
24
|
+
|
|
25
|
+
# Build ItemVariationData: one data block covering all items
|
|
26
|
+
var_data = serialize_variation_data(item_count, master_count, deltas)
|
|
27
|
+
|
|
28
|
+
# Assemble the store
|
|
29
|
+
# Header layout: format(uint16) + variationRegionListOffset(uint32)
|
|
30
|
+
# + itemVariationDataCount(uint16) + itemVariationDataOffsets[1](uint32)
|
|
31
|
+
# = 2 + 4 + 2 + 4 = 12 bytes total before region list
|
|
32
|
+
header_size = 8 # format(2) + regionListOffset(4) + itemVariationDataCount(2)
|
|
33
|
+
offsets_array_size = 4 # one data block → one offset
|
|
34
|
+
region_list_offset = header_size + offsets_array_size
|
|
35
|
+
var_data_offset = region_list_offset + region_list.bytesize
|
|
36
|
+
|
|
37
|
+
store = +""
|
|
38
|
+
store << [1].pack("n") # format = 1
|
|
39
|
+
store << [region_list_offset].pack("N") # variationRegionListOffset
|
|
40
|
+
store << [1].pack("n") # itemVariationDataCount
|
|
41
|
+
store << [var_data_offset].pack("N") # itemVariationDataOffsets[0]
|
|
42
|
+
store << region_list
|
|
43
|
+
store << var_data
|
|
44
|
+
store
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.build_regions(axis_count, master_count)
|
|
48
|
+
Array.new(master_count) do |master_idx|
|
|
49
|
+
Array.new(axis_count) do |axis_idx|
|
|
50
|
+
if axis_idx == master_idx
|
|
51
|
+
{ start: -1.0, peak: 1.0, end: 1.0 }
|
|
52
|
+
else
|
|
53
|
+
{ start: -1.0, peak: 0.0, end: 1.0 }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.serialize_region_list(regions, axis_count)
|
|
60
|
+
io = +""
|
|
61
|
+
io << [axis_count].pack("n") # axisCount
|
|
62
|
+
io << [regions.size].pack("n") # regionCount
|
|
63
|
+
regions.each do |region|
|
|
64
|
+
region.each do |coords|
|
|
65
|
+
io << [f2dot14(coords[:start]), f2dot14(coords[:peak]), f2dot14(coords[:end])].pack("nnn")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
io
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.serialize_variation_data(item_count, region_count, deltas)
|
|
72
|
+
# Determine if all deltas fit in int8 (-128..127)
|
|
73
|
+
all_short = deltas.flatten.any? { |d| !d.between?(-127, 127) }
|
|
74
|
+
short_count = all_short ? region_count : 0
|
|
75
|
+
|
|
76
|
+
io = +""
|
|
77
|
+
io << [item_count].pack("n") # itemCount
|
|
78
|
+
io << [short_count].pack("n") # shortDeltaCount
|
|
79
|
+
io << [region_count].pack("n") # regionIndexCount (all regions)
|
|
80
|
+
region_count.times { |i| io << [i].pack("n") } # regionIndices
|
|
81
|
+
|
|
82
|
+
deltas.each do |item_deltas|
|
|
83
|
+
item_deltas.each_with_index do |delta, i|
|
|
84
|
+
io << (i < short_count ? [delta].pack("s>") : [delta].pack("c"))
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
io
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.f2dot14(value)
|
|
92
|
+
(value.to_f * 16384).to_i.clamp(-16384, 16384)
|
|
93
|
+
end
|
|
94
|
+
private_class_method :build_regions, :serialize_region_list,
|
|
95
|
+
:serialize_variation_data, :f2dot14
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
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,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
|
|
@@ -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,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
|
data/lib/fontisan/ufo/compile.rb
CHANGED
|
@@ -46,6 +46,13 @@ module Fontisan
|
|
|
46
46
|
autoload :Cmap, "fontisan/ufo/compile/cmap"
|
|
47
47
|
autoload :GlyfLoca, "fontisan/ufo/compile/glyf_loca"
|
|
48
48
|
autoload :Cff, "fontisan/ufo/compile/cff"
|
|
49
|
+
autoload :Avar, "fontisan/ufo/compile/avar"
|
|
50
|
+
autoload :Hvar, "fontisan/ufo/compile/hvar"
|
|
51
|
+
autoload :Mvar, "fontisan/ufo/compile/mvar"
|
|
52
|
+
autoload :Stat, "fontisan/ufo/compile/stat"
|
|
53
|
+
autoload :VariableTtf, "fontisan/ufo/compile/variable_ttf"
|
|
54
|
+
autoload :ItemVariationStore,
|
|
55
|
+
"fontisan/ufo/compile/item_variation_store"
|
|
49
56
|
end
|
|
50
57
|
end
|
|
51
58
|
end
|
data/lib/fontisan/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fontisan
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.4.
|
|
4
|
+
version: 0.4.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-07-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: base64
|
|
@@ -555,6 +555,7 @@ files:
|
|
|
555
555
|
- lib/fontisan/ufo/anchor.rb
|
|
556
556
|
- lib/fontisan/ufo/cli.rb
|
|
557
557
|
- lib/fontisan/ufo/compile.rb
|
|
558
|
+
- lib/fontisan/ufo/compile/avar.rb
|
|
558
559
|
- lib/fontisan/ufo/compile/base_compiler.rb
|
|
559
560
|
- lib/fontisan/ufo/compile/cff.rb
|
|
560
561
|
- lib/fontisan/ufo/compile/cmap.rb
|
|
@@ -570,12 +571,17 @@ files:
|
|
|
570
571
|
- lib/fontisan/ufo/compile/head.rb
|
|
571
572
|
- lib/fontisan/ufo/compile/hhea.rb
|
|
572
573
|
- lib/fontisan/ufo/compile/hmtx.rb
|
|
574
|
+
- lib/fontisan/ufo/compile/hvar.rb
|
|
575
|
+
- lib/fontisan/ufo/compile/item_variation_store.rb
|
|
573
576
|
- lib/fontisan/ufo/compile/maxp.rb
|
|
577
|
+
- lib/fontisan/ufo/compile/mvar.rb
|
|
574
578
|
- lib/fontisan/ufo/compile/name.rb
|
|
575
579
|
- lib/fontisan/ufo/compile/os2.rb
|
|
576
580
|
- lib/fontisan/ufo/compile/otf_compiler.rb
|
|
577
581
|
- lib/fontisan/ufo/compile/post.rb
|
|
582
|
+
- lib/fontisan/ufo/compile/stat.rb
|
|
578
583
|
- lib/fontisan/ufo/compile/ttf_compiler.rb
|
|
584
|
+
- lib/fontisan/ufo/compile/variable_ttf.rb
|
|
579
585
|
- lib/fontisan/ufo/component.rb
|
|
580
586
|
- lib/fontisan/ufo/contour.rb
|
|
581
587
|
- lib/fontisan/ufo/convert.rb
|