fontisan 0.4.11 → 0.4.12
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/README.adoc +27 -1
- data/docs/AFDKO_MIGRATION.adoc +175 -0
- data/docs/FEATURE_PARITY.adoc +263 -0
- data/docs/STITCHER_GUIDE.adoc +100 -6
- data/docs/TTX.adoc +176 -0
- data/docs/UFO_COMPILATION.adoc +281 -3
- data/lib/fontisan/stitcher/source.rb +11 -7
- data/lib/fontisan/subset/table_subsetter.rb +104 -6
- data/lib/fontisan/svg/standalone_glyph.rb +129 -0
- data/lib/fontisan/svg.rb +1 -0
- data/lib/fontisan/ufo/cli.rb +34 -21
- data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
- data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
- data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
- data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
- data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
- data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
- data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
- data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
- data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
- data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
- data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
- data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
- data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
- data/lib/fontisan/ufo/compile/filters.rb +12 -0
- data/lib/fontisan/ufo/compile.rb +1 -0
- data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
- data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
- data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
- data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
- data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
- data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
- data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
- data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
- data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
- data/lib/fontisan/ufo/convert.rb +68 -5
- data/lib/fontisan/ufo/transformation.rb +11 -0
- data/lib/fontisan/version.rb +1 -1
- metadata +27 -1
data/docs/TTX.adoc
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
= TTX (FontTools XML) Support
|
|
2
|
+
|
|
3
|
+
fontisan can read and write the TTX format — the human-readable
|
|
4
|
+
XML representation of OpenType fonts defined by Adobe/FontTools'.
|
|
5
|
+
`ttx` tool. TTX is the standard interchange format for OpenType
|
|
6
|
+
table data: every binary table has a corresponding XML element,
|
|
7
|
+
and round-tripping TTX ↔ binary preserves the table contents.
|
|
8
|
+
|
|
9
|
+
== Why TTX
|
|
10
|
+
|
|
11
|
+
* **Diff-friendly**: TTX is text, so changes to a font's tables can
|
|
12
|
+
be reviewed in version control.
|
|
13
|
+
* **Edit-friendly**: you can hand-edit table values without writing
|
|
14
|
+
binary parsers.
|
|
15
|
+
* **Tool interchange**: any tool that understands TTX (fontTools,
|
|
16
|
+
AFDKO's `ttx`, several IDE plugins) can read fontisan's output
|
|
17
|
+
and vice versa.
|
|
18
|
+
|
|
19
|
+
== What fontisan supports
|
|
20
|
+
|
|
21
|
+
[cols="1,3", options="header"]
|
|
22
|
+
|===
|
|
23
|
+
| Capability | Notes
|
|
24
|
+
|
|
25
|
+
| Read `.ttx` (parse XML → typed tables)
|
|
26
|
+
| Every standard table that fontisan parses as binary also has a
|
|
27
|
+
TTX parser entry. Unknown tables fall through to a binary-blob
|
|
28
|
+
representation.
|
|
29
|
+
|
|
30
|
+
| Write `.ttx` (typed tables → XML)
|
|
31
|
+
| Same coverage as the read path. Each table knows how to render
|
|
32
|
+
itself as TTX.
|
|
33
|
+
|
|
34
|
+
| CLI `fontisan export FONT --format ttx`
|
|
35
|
+
| Dumps every table or a subset via `--tables`.
|
|
36
|
+
|
|
37
|
+
| CLI `fontisan info FONT` (auto-detect)
|
|
38
|
+
| Auto-detects `.ttx` files alongside binary formats. No
|
|
39
|
+
`--format` flag needed.
|
|
40
|
+
|
|
41
|
+
| Per-table type mapping (lib/fontisan/models/ttx/tables/*.rb)
|
|
42
|
+
| `head`, `hhea`, `maxp`, `name`, `OS/2`, `post` have typed
|
|
43
|
+
representations; everything else is `BinaryTable` (raw bytes).
|
|
44
|
+
|===
|
|
45
|
+
|
|
46
|
+
== CLI usage
|
|
47
|
+
|
|
48
|
+
[source,bash]
|
|
49
|
+
----
|
|
50
|
+
# Dump the entire font as TTX
|
|
51
|
+
fontisan export MyFont.ttf --format ttx --output MyFont.ttx
|
|
52
|
+
|
|
53
|
+
# Dump just the head + name tables
|
|
54
|
+
fontisan export MyFont.ttf --format ttx --tables head name --output subset.ttx
|
|
55
|
+
|
|
56
|
+
# Read a TTX file (auto-detected)
|
|
57
|
+
fontisan info MyFont.ttx
|
|
58
|
+
fontisan validate MyFont.ttx
|
|
59
|
+
----
|
|
60
|
+
|
|
61
|
+
== Ruby API
|
|
62
|
+
|
|
63
|
+
=== Generate TTX from a loaded font
|
|
64
|
+
|
|
65
|
+
[source,ruby]
|
|
66
|
+
----
|
|
67
|
+
require "fontisan"
|
|
68
|
+
|
|
69
|
+
font = Fontisan::FontLoader.load("MyFont.ttf")
|
|
70
|
+
generator = Fontisan::Export::TtxGenerator.new(font, "MyFont.ttf")
|
|
71
|
+
ttx_xml = generator.generate
|
|
72
|
+
File.write("MyFont.ttx", ttx_xml)
|
|
73
|
+
----
|
|
74
|
+
|
|
75
|
+
=== Parse TTX into typed tables
|
|
76
|
+
|
|
77
|
+
[source,ruby]
|
|
78
|
+
----
|
|
79
|
+
require "fontisan/export"
|
|
80
|
+
|
|
81
|
+
ttx_xml = File.read("MyFont.ttx")
|
|
82
|
+
parser = Fontisan::Export::TtxParser.new
|
|
83
|
+
tt_font = parser.parse(ttx_xml)
|
|
84
|
+
|
|
85
|
+
puts "sfnt version: #{tt_font.sfnt_version}"
|
|
86
|
+
puts "tables: #{tt_font.tables.keys.join(', ')}"
|
|
87
|
+
|
|
88
|
+
head = tt_font.tables["head"]
|
|
89
|
+
puts "units per em: #{head.units_per_em}" if head
|
|
90
|
+
----
|
|
91
|
+
|
|
92
|
+
=== Round-trip
|
|
93
|
+
|
|
94
|
+
Read TTX → modify → write TTX:
|
|
95
|
+
|
|
96
|
+
[source,ruby]
|
|
97
|
+
----
|
|
98
|
+
parser = Fontisan::Export::TtxParser.new
|
|
99
|
+
tt_font = parser.parse(File.read("MyFont.ttx"))
|
|
100
|
+
|
|
101
|
+
# Bump units-per-em (caution: this rescales visually)
|
|
102
|
+
tt_font.tables["head"].units_per_em = 2048
|
|
103
|
+
|
|
104
|
+
# Re-emit as TTX
|
|
105
|
+
generator = Fontisan::Export::TtxGenerator.from_tt_font(tt_font)
|
|
106
|
+
File.write("MyFont-edited.ttx", generator.generate)
|
|
107
|
+
----
|
|
108
|
+
|
|
109
|
+
== Typed table coverage
|
|
110
|
+
|
|
111
|
+
fontisan's TTX layer is a parallel hierarchy to the binary
|
|
112
|
+
`Tables::*` classes. Each TTX table class lives under
|
|
113
|
+
`Fontisan::Models::Ttx::Tables`:
|
|
114
|
+
|
|
115
|
+
[cols="1,1", options="header"]
|
|
116
|
+
|===
|
|
117
|
+
| Binary table | TTX class
|
|
118
|
+
|
|
119
|
+
| `head`
|
|
120
|
+
| `Models::Ttx::Tables::HeadTable`
|
|
121
|
+
|
|
122
|
+
| `hhea`
|
|
123
|
+
| `Models::Ttx::Tables::HheaTable`
|
|
124
|
+
|
|
125
|
+
| `maxp`
|
|
126
|
+
| `Models::Ttx::Tables::MaxpTable`
|
|
127
|
+
|
|
128
|
+
| `name`
|
|
129
|
+
| `Models::Ttx::Tables::NameTable`
|
|
130
|
+
|
|
131
|
+
| `OS/2`
|
|
132
|
+
| `Models::Ttx::Tables::Os2Table`
|
|
133
|
+
|
|
134
|
+
| `post`
|
|
135
|
+
| `Models::Ttx::Tables::PostTable`
|
|
136
|
+
|
|
137
|
+
| (any other)
|
|
138
|
+
| `Models::Ttx::Tables::BinaryTable` — raw bytes, no typed
|
|
139
|
+
interpretation
|
|
140
|
+
|===
|
|
141
|
+
|
|
142
|
+
Adding a new typed TTX representation = one file under
|
|
143
|
+
`models/ttx/tables/` + one entry in the parser dispatch table.
|
|
144
|
+
|
|
145
|
+
== Compatibility with fontTools `ttx`
|
|
146
|
+
|
|
147
|
+
fontisan's TTX output is byte-compatible with fontTools `ttx`
|
|
148
|
+
output for the typed tables. The XML element names, attribute
|
|
149
|
+
names, and value formats match the fontTools schema.
|
|
150
|
+
|
|
151
|
+
The `BinaryTable` representation differs slightly: fontTools
|
|
152
|
+
emits a hex-dumped `<hexdata>` block for tables it doesn't
|
|
153
|
+
understand; fontisan emits the same format.
|
|
154
|
+
|
|
155
|
+
== Related formats
|
|
156
|
+
|
|
157
|
+
fontisan also exports YAML and JSON via the same `fontisan export`
|
|
158
|
+
command — useful for scripting and AI/automation pipelines where
|
|
159
|
+
XML's verbosity is a drawback:
|
|
160
|
+
|
|
161
|
+
[source,bash]
|
|
162
|
+
----
|
|
163
|
+
fontisan export MyFont.ttf --format yaml --output MyFont.yaml
|
|
164
|
+
fontisan export MyFont.ttf --format json --output MyFont.json
|
|
165
|
+
----
|
|
166
|
+
|
|
167
|
+
== See also
|
|
168
|
+
|
|
169
|
+
* link:UFO_COMPILATION.adoc[UFO Compilation Guide] — UFO is a
|
|
170
|
+
richer source format than TTX (full glyph outlines + naming +
|
|
171
|
+
features); use TTX for binary-table inspection, UFO for source
|
|
172
|
+
editing.
|
|
173
|
+
* link:AFDKO_MIGRATION.adoc[AFDKO Migration Guide] — fontisan
|
|
174
|
+
replaces AFDKO's `ttx` command.
|
|
175
|
+
* link:FEATURE_PARITY.adoc[Feature Parity] — every external tool
|
|
176
|
+
fontisan replaces.
|
data/docs/UFO_COMPILATION.adoc
CHANGED
|
@@ -110,10 +110,288 @@ ufo = Fontisan::Ufo::Convert::FromBinData.convert(ttf)
|
|
|
110
110
|
Fontisan::Ufo::Writer.new(ufo).write("MyFont.ufo")
|
|
111
111
|
----
|
|
112
112
|
|
|
113
|
+
== Conversion wrappers (Phase 3)
|
|
114
|
+
|
|
115
|
+
For every binary format fontisan can write, there's a UFO → that
|
|
116
|
+
format wrapper under `Ufo::Convert`. They split into two flavors:
|
|
117
|
+
|
|
118
|
+
1-step wrappers (compile + write directly): `ToTtf`, `ToOtf`,
|
|
119
|
+
`ToOtf2`.
|
|
120
|
+
|
|
121
|
+
2-step wrappers (compile to TTF/OTF in a tmpfile, then encode):
|
|
122
|
+
`ToWoff`, `ToWoff2`, `ToDfont`, `ToTtc`, `ToOtc`, `ToPfb`,
|
|
123
|
+
`ToPfa`.
|
|
124
|
+
|
|
125
|
+
The 2-step chain looks like:
|
|
126
|
+
|
|
127
|
+
....
|
|
128
|
+
UFO → Compile::*Compiler → tmpfile → FontLoader.load →
|
|
129
|
+
WoffWriter / Woff2Encoder / DfontBuilder /
|
|
130
|
+
Collection::Builder / Type1::PFB/PFAGenerator → output
|
|
131
|
+
....
|
|
132
|
+
|
|
133
|
+
The tmpfile is auto-cleaned (`Dir.mktmpdir`); the original UFO is
|
|
134
|
+
never mutated.
|
|
135
|
+
|
|
136
|
+
Use the unified dispatcher to pick a format by symbol:
|
|
137
|
+
|
|
138
|
+
[source,ruby]
|
|
139
|
+
----
|
|
140
|
+
require "fontisan"
|
|
141
|
+
|
|
142
|
+
ufo = Fontisan::Ufo::Font.open("MyFont.ufo")
|
|
143
|
+
|
|
144
|
+
# 1-step
|
|
145
|
+
Fontisan::Ufo::Convert.convert(ufo, to: :ttf, output_path: "MyFont.ttf")
|
|
146
|
+
Fontisan::Ufo::Convert.convert(ufo, to: :otf, output_path: "MyFont.otf")
|
|
147
|
+
|
|
148
|
+
# 2-step
|
|
149
|
+
Fontisan::Ufo::Convert.convert(ufo, to: :woff2, output_path: "MyFont.woff2")
|
|
150
|
+
Fontisan::Ufo::Convert.convert(ufo, to: :pfb, output_path: "MyFont.pfb")
|
|
151
|
+
----
|
|
152
|
+
|
|
153
|
+
Format keys: `:ttf`, `:otf`, `:otf2`, `:woff`, `:woff2`, `:dfont`,
|
|
154
|
+
`:ttc`, `:otc`, `:pfb`, `:pfa`. Unknown keys raise `ArgumentError`.
|
|
155
|
+
|
|
156
|
+
For per-format options (zlib level, brotli quality, etc.) call the
|
|
157
|
+
wrapper module directly — options forward through:
|
|
158
|
+
|
|
159
|
+
[source,ruby]
|
|
160
|
+
----
|
|
161
|
+
Fontisan::Ufo::Convert::ToWoff2.convert(ufo,
|
|
162
|
+
output_path: "MyFont.woff2",
|
|
163
|
+
compiler: :otf, # intermediate format
|
|
164
|
+
brotli_quality: 11, # passed to Woff2Encoder
|
|
165
|
+
transform_tables: true)
|
|
166
|
+
----
|
|
167
|
+
|
|
168
|
+
== Glyph-processing filters (Phase 2)
|
|
169
|
+
|
|
170
|
+
Filters sit between the UFO model and the binary compilers. Each is
|
|
171
|
+
a stateless module under `Ufo::Compile::Filters` with a `.run(glyphs,
|
|
172
|
+
**opts)` entry that mutates the glyph list in place. They're
|
|
173
|
+
registered in `Filters::REGISTRY` so the compiler (or any caller)
|
|
174
|
+
can apply them by symbol:
|
|
175
|
+
|
|
176
|
+
[source,ruby]
|
|
177
|
+
----
|
|
178
|
+
glyphs = ufo.layers.default_layer.glyphs.values
|
|
179
|
+
Fontisan::Ufo::Compile::Filters.apply(
|
|
180
|
+
%i[cubic_to_quadratic reverse_contour_direction sort_contours],
|
|
181
|
+
glyphs
|
|
182
|
+
)
|
|
183
|
+
----
|
|
184
|
+
|
|
185
|
+
Available filters:
|
|
186
|
+
|
|
187
|
+
[cols="1,3", options="header"]
|
|
188
|
+
|===
|
|
189
|
+
| Filter | What it does
|
|
190
|
+
|
|
191
|
+
| `cubic_to_quadratic`
|
|
192
|
+
| Convert cubic Bezier curves to quadratic (TrueType only supports
|
|
193
|
+
quadratic; UFO sources typically use cubic).
|
|
194
|
+
|
|
195
|
+
| `reverse_contour_direction`
|
|
196
|
+
| Reverse point order in every contour (TrueType wants clockwise
|
|
197
|
+
outer winding; UFO is PostScript counter-clockwise).
|
|
198
|
+
|
|
199
|
+
| `decompose_components`
|
|
200
|
+
| Replace composite-glyph components with their base glyph's
|
|
201
|
+
contours.
|
|
202
|
+
|
|
203
|
+
| `flatten_components`
|
|
204
|
+
| Collapse nested component chains (A→B→C becomes A→C).
|
|
205
|
+
|
|
206
|
+
| `transformations`
|
|
207
|
+
| Apply a 2×3 affine matrix to every glyph's contours + components
|
|
208
|
+
(mirroring, slant, scaling, custom offsets).
|
|
209
|
+
|
|
210
|
+
| `sort_contours`
|
|
211
|
+
| Rotate each contour to start with an on-curve point and sort
|
|
212
|
+
contours within each glyph by Y (descending) for hinting
|
|
213
|
+
compatibility.
|
|
214
|
+
|
|
215
|
+
| `propagate_anchors`
|
|
216
|
+
| Copy anchors from base glyphs to composite glyphs (transformed by
|
|
217
|
+
the component matrix) so the GPOS mark feature writer can find
|
|
218
|
+
them on composites.
|
|
219
|
+
|===
|
|
220
|
+
|
|
221
|
+
Adding a new filter = one file under `compile/filters/` + one entry
|
|
222
|
+
in `REGISTRY`. No edits to other filters required (OCP).
|
|
223
|
+
|
|
224
|
+
== Feature writers (Phase 2)
|
|
225
|
+
|
|
226
|
+
Feature writers extract GSUB/GPOS feature data from a UFO source
|
|
227
|
+
into structured form for the table builders to encode. Each writer
|
|
228
|
+
extends `FeatureWriters::Base` with a `#write` method that returns
|
|
229
|
+
either a `FeatureOutput` value object or `nil` (when the UFO has
|
|
230
|
+
no data for the feature).
|
|
231
|
+
|
|
232
|
+
[source,ruby]
|
|
233
|
+
----
|
|
234
|
+
require "fontisan/ufo/compile/feature_writers"
|
|
235
|
+
|
|
236
|
+
font = Fontisan::Ufo::Font.open("MyFont.ufo")
|
|
237
|
+
Fontisan::Ufo::Compile::FeatureWriters::DEFAULT_WRITERS.each do |writer_cls|
|
|
238
|
+
output = writer_cls.new(font).write
|
|
239
|
+
next unless output
|
|
240
|
+
|
|
241
|
+
puts "#{output.table_tag} / #{output.feature_tag} (lookup #{output.lookup_type})"
|
|
242
|
+
end
|
|
243
|
+
----
|
|
244
|
+
|
|
245
|
+
Available writers:
|
|
246
|
+
|
|
247
|
+
[cols="1,1,3", options="header"]
|
|
248
|
+
|===
|
|
249
|
+
| Writer | GPOS / GSUB | What it emits
|
|
250
|
+
|
|
251
|
+
| `FeatureWriters::Kern`
|
|
252
|
+
| GPOS lookup 2 (`kern`)
|
|
253
|
+
| PairPos kerning pairs from `font.kerning` (parsed from
|
|
254
|
+
`kerning.plist`).
|
|
255
|
+
|
|
256
|
+
| `FeatureWriters::Kern2`
|
|
257
|
+
| GPOS lookup 2 (`kern`, format 2)
|
|
258
|
+
| Same pair data as Kern, tagged for the extended PairPos format
|
|
259
|
+
(device tables, cross-stream values). Use when you need
|
|
260
|
+
sub-pixel kerning.
|
|
261
|
+
|
|
262
|
+
| `FeatureWriters::Gdef`
|
|
263
|
+
| GDEF
|
|
264
|
+
| GlyphClassDef — classifies every glyph as base/ligature/mark/
|
|
265
|
+
spacing. Prefers `public.openTypeCategory` lib entry; falls
|
|
266
|
+
back to heuristic (mark anchors, `.liga` suffix).
|
|
267
|
+
|
|
268
|
+
| `FeatureWriters::Mark`
|
|
269
|
+
| GPOS lookup 4 (`mark`)
|
|
270
|
+
| MarkToBase attachment. Pairs every glyph with a `_<name>`
|
|
271
|
+
anchor (mark) against every glyph with the matching `<name>`
|
|
272
|
+
anchor (base).
|
|
273
|
+
|
|
274
|
+
| `FeatureWriters::Mkmk`
|
|
275
|
+
| GPOS lookup 6 (`mkmk`)
|
|
276
|
+
| MarkToMark attachment. Same convention as Mark but pairs marks
|
|
277
|
+
with other marks. Anchors are `_<name>mkmk` (attach-from) ↔
|
|
278
|
+
`<name>mkmk` (attach-to).
|
|
279
|
+
|
|
280
|
+
| `FeatureWriters::Curs`
|
|
281
|
+
| GPOS lookup 3 (`curs`)
|
|
282
|
+
| Cursive attachment. Emits entry/exit anchor positions for
|
|
283
|
+
joining-script glyphs.
|
|
284
|
+
|===
|
|
285
|
+
|
|
286
|
+
`FeatureWriters::DEFAULT_WRITERS = [Gdef, Kern, Mark, Mkmk, Curs]`.
|
|
287
|
+
The compiler runs all by default; each returns `nil` when the UFO
|
|
288
|
+
has no data for its feature, so it's safe to always run them all.
|
|
289
|
+
|
|
290
|
+
Adding a new writer = one file under `compile/feature_writers/` +
|
|
291
|
+
one entry in `DEFAULT_WRITERS`. No edits to other writers required
|
|
292
|
+
(OCP).
|
|
293
|
+
|
|
294
|
+
Prerequisite for Mark / Mkmk / Curs: run
|
|
295
|
+
`Filters::PropagateAnchors` first so composite glyphs carry their
|
|
296
|
+
inherited anchors.
|
|
297
|
+
|
|
298
|
+
=== Usage examples
|
|
299
|
+
|
|
300
|
+
==== Kern (pair kerning)
|
|
301
|
+
|
|
302
|
+
Extract kerning pairs from a UFO's `kerning.plist`:
|
|
303
|
+
|
|
304
|
+
[source,ruby]
|
|
305
|
+
----
|
|
306
|
+
font = Fontisan::Ufo::Font.open("MyFont.ufo")
|
|
307
|
+
out = Fontisan::Ufo::Compile::FeatureWriters::Kern.new(font).write
|
|
308
|
+
|
|
309
|
+
out.data[:pairs].each do |pair|
|
|
310
|
+
puts "#{pair[:left]} + #{pair[:right]} = #{pair[:value]}"
|
|
311
|
+
end
|
|
312
|
+
# A + V = -80.0
|
|
313
|
+
# A + W = -60.0
|
|
314
|
+
# ...
|
|
315
|
+
----
|
|
316
|
+
|
|
317
|
+
==== Gdef (glyph classification)
|
|
318
|
+
|
|
319
|
+
Classify every glyph for GDEF:
|
|
320
|
+
|
|
321
|
+
[source,ruby]
|
|
322
|
+
----
|
|
323
|
+
out = Fontisan::Ufo::Compile::FeatureWriters::Gdef.new(font).write
|
|
324
|
+
out.data[:classes].each { |name, cls| puts "#{name}: #{cls}" }
|
|
325
|
+
# A: 1 (base)
|
|
326
|
+
# f_f_i.liga: 2 (ligature)
|
|
327
|
+
# acutecomb: 3 (mark)
|
|
328
|
+
# ...
|
|
329
|
+
----
|
|
330
|
+
|
|
331
|
+
==== Mark (mark-to-base attachment)
|
|
332
|
+
|
|
333
|
+
Pair diacritical marks with base glyphs:
|
|
334
|
+
|
|
335
|
+
[source,ruby]
|
|
336
|
+
----
|
|
337
|
+
out = Fontisan::Ufo::Compile::FeatureWriters::Mark.new(font).write
|
|
338
|
+
out.data[:attachments]["top"][:marks] # => {"acutecomb" => [100, 600], ...}
|
|
339
|
+
out.data[:attachments]["top"][:bases] # => {"A" => [100, 700], "B" => [90, 700], ...}
|
|
340
|
+
----
|
|
341
|
+
|
|
342
|
+
==== Mkmk (mark-to-mark attachment)
|
|
343
|
+
|
|
344
|
+
Stack marks above each other:
|
|
345
|
+
|
|
346
|
+
[source,ruby]
|
|
347
|
+
----
|
|
348
|
+
out = Fontisan::Ufo::Compile::FeatureWriters::Mkmk.new(font).write
|
|
349
|
+
out.data[:attachments]["top"][:marks] # => {"acutecomb" => [100, 650]}
|
|
350
|
+
out.data[:attachments]["top"][:base_marks] # => {"dieresiscomb" => [100, 700]}
|
|
351
|
+
----
|
|
352
|
+
|
|
353
|
+
==== Curs (cursive joining)
|
|
354
|
+
|
|
355
|
+
Emit entry/exit anchors for Arabic-script glyphs:
|
|
356
|
+
|
|
357
|
+
[source,ruby]
|
|
358
|
+
----
|
|
359
|
+
out = Fontisan::Ufo::Compile::FeatureWriters::Curs.new(font).write
|
|
360
|
+
out.data[:attachments]["alef"] # => {entry: [0, 100], exit: [100, 100]}
|
|
361
|
+
out.data[:attachments"]["beh"] # => {entry: [50, 50], exit: nil}
|
|
362
|
+
----
|
|
363
|
+
|
|
364
|
+
== CLI
|
|
365
|
+
|
|
366
|
+
`fontisan ufo` exposes the four common operations:
|
|
367
|
+
|
|
368
|
+
[source,bash]
|
|
369
|
+
----
|
|
370
|
+
# Compile a UFO to a binary font
|
|
371
|
+
fontisan ufo build MyFont.ufo --output MyFont.ttf [--to otf|otf2]
|
|
372
|
+
|
|
373
|
+
# Convert in either direction (UFO ↔ binary)
|
|
374
|
+
fontisan ufo convert MyFont.ufo MyFont.woff2
|
|
375
|
+
fontisan ufo convert MyFont.ttf MyFont.ufo
|
|
376
|
+
|
|
377
|
+
# Structural checks (no .notdef, no family name, etc.)
|
|
378
|
+
fontisan ufo validate MyFont.ufo
|
|
379
|
+
|
|
380
|
+
# Render one glyph as a standalone SVG
|
|
381
|
+
fontisan ufo extract MyFont.ufo A A.svg
|
|
382
|
+
----
|
|
383
|
+
|
|
384
|
+
The `build` and `convert` commands route through the
|
|
385
|
+
`Ufo::Convert.convert` dispatcher, so adding a new format = one
|
|
386
|
+
registry entry — no CLI edits.
|
|
387
|
+
|
|
113
388
|
== See also
|
|
114
389
|
|
|
390
|
+
* link:AFDKO_MIGRATION.adoc[AFDKO Migration Guide] — switching from
|
|
391
|
+
`makeotf` / `otf2ttf` / `ttx` to fontisan's UFO pipeline.
|
|
115
392
|
* link:CFF2_SUPPORT.adoc[CFF2 Support Guide] — variable-font blend
|
|
116
|
-
operators, FDSelect, subroutines, VariationStore
|
|
393
|
+
operators, FDSelect, subroutines, VariationStore.
|
|
117
394
|
* link:VARIABLE_FONT_OPERATIONS.adoc[Variable Font Operations] —
|
|
118
|
-
reading and modifying existing variable fonts
|
|
119
|
-
|
|
395
|
+
reading and modifying existing variable fonts.
|
|
396
|
+
* link:STITCHER_GUIDE.adoc[Stitcher Guide] — multi-source assembly.
|
|
397
|
+
|
|
@@ -336,10 +336,15 @@ module Fontisan
|
|
|
336
336
|
|
|
337
337
|
# Apply a 2×3 affine matrix [a, b, c, d, e, f] to each point of
|
|
338
338
|
# a simple component, appending the transformed contours.
|
|
339
|
-
#
|
|
340
|
-
#
|
|
339
|
+
# The math is delegated to +Transformation#apply+ so the
|
|
340
|
+
# affine-matrix logic lives in one place (DRY) — same code path
|
|
341
|
+
# the UFO Transformations filter uses.
|
|
341
342
|
def flatten_simple_component(simple, ufo_glyph, matrix)
|
|
342
|
-
|
|
343
|
+
transform = Ufo::Transformation.new(
|
|
344
|
+
a: matrix[0], b: matrix[1],
|
|
345
|
+
c: matrix[2], d: matrix[3],
|
|
346
|
+
e: matrix[4], f: matrix[5]
|
|
347
|
+
)
|
|
343
348
|
num_contours = simple.end_pts_of_contours&.size || 0
|
|
344
349
|
return if num_contours.zero?
|
|
345
350
|
|
|
@@ -350,13 +355,12 @@ module Fontisan
|
|
|
350
355
|
ufo_points = points.map do |pt|
|
|
351
356
|
x = (pt[:x] || pt["x"]).to_f
|
|
352
357
|
y = (pt[:y] || pt["y"]).to_f
|
|
353
|
-
tx =
|
|
354
|
-
ty = b * x + d * y + f
|
|
358
|
+
tx, ty = transform.apply(x, y)
|
|
355
359
|
on_curve = pt[:on_curve].nil? || pt[:on_curve]
|
|
356
360
|
type = on_curve ? "line" : "offcurve"
|
|
357
|
-
|
|
361
|
+
Ufo::Point.new(x: tx, y: ty, type: type)
|
|
358
362
|
end
|
|
359
|
-
ufo_glyph.add_contour(
|
|
363
|
+
ufo_glyph.add_contour(Ufo::Contour.new(ufo_points))
|
|
360
364
|
end
|
|
361
365
|
end
|
|
362
366
|
|
|
@@ -56,6 +56,8 @@ module Fontisan
|
|
|
56
56
|
@options = options
|
|
57
57
|
@glyf_data = nil
|
|
58
58
|
@loca_offsets = nil
|
|
59
|
+
@subset_bbox = nil # [xMin, yMin, xMax, yMax] over actual subset glyphs
|
|
60
|
+
@subset_max_advance = 0 # largest advanceWidth in subset hmtx
|
|
59
61
|
end
|
|
60
62
|
|
|
61
63
|
# Subset a table by tag
|
|
@@ -110,10 +112,12 @@ module Fontisan
|
|
|
110
112
|
data
|
|
111
113
|
end
|
|
112
114
|
|
|
113
|
-
# Subset hhea table (update numberOfHMetrics)
|
|
115
|
+
# Subset hhea table (update numberOfHMetrics + advanceWidthMax)
|
|
114
116
|
#
|
|
115
|
-
# Updates
|
|
116
|
-
#
|
|
117
|
+
# Updates numberOfHMetrics to reflect the subset's glyph count and
|
|
118
|
+
# recomputes advanceWidthMax from the subset's actual hmtx. The
|
|
119
|
+
# source TTC's advanceWidthMax covers every donor font and is far
|
|
120
|
+
# larger than any per-block subset needs.
|
|
117
121
|
#
|
|
118
122
|
# @param table [Hhea] Parsed hhea table
|
|
119
123
|
# @param hmtx [Hmtx, nil] Optional parsed hmtx table (for calculating metrics)
|
|
@@ -128,9 +132,18 @@ module Fontisan
|
|
|
128
132
|
calculate_number_of_h_metrics
|
|
129
133
|
end
|
|
130
134
|
|
|
131
|
-
#
|
|
135
|
+
# numberOfHMetrics at offset 34 (uint16)
|
|
132
136
|
data[34, 2] = [new_num_h_metrics].pack("n")
|
|
133
137
|
|
|
138
|
+
# advanceWidthMax at offset 10 (uint16). Recompute from subset
|
|
139
|
+
# hmtx so a per-block subset doesn't keep the source TTC's
|
|
140
|
+
# max (which can be 4x larger than any glyph in the subset).
|
|
141
|
+
# Tables are processed alphabetically (hhea before hmtx), so
|
|
142
|
+
# we read hmtx directly here rather than relying on a cached
|
|
143
|
+
# value from subset_hmtx.
|
|
144
|
+
new_max = compute_subset_max_advance
|
|
145
|
+
data[10, 2] = [new_max].pack("n") if new_max.positive?
|
|
146
|
+
|
|
134
147
|
data
|
|
135
148
|
end
|
|
136
149
|
|
|
@@ -152,14 +165,18 @@ module Fontisan
|
|
|
152
165
|
# Build new hmtx data
|
|
153
166
|
data = String.new(encoding: Encoding::BINARY)
|
|
154
167
|
|
|
168
|
+
max_advance = 0
|
|
155
169
|
mapping.old_ids.each do |old_id|
|
|
156
170
|
metric = table.metric_for(old_id)
|
|
157
171
|
next unless metric
|
|
158
172
|
|
|
159
|
-
|
|
173
|
+
advance = metric[:advance_width]
|
|
174
|
+
max_advance = advance if advance && advance > max_advance
|
|
175
|
+
data << [advance].pack("n")
|
|
160
176
|
data << [metric[:lsb]].pack("n")
|
|
161
177
|
end
|
|
162
178
|
|
|
179
|
+
@subset_max_advance = max_advance
|
|
163
180
|
data
|
|
164
181
|
end
|
|
165
182
|
|
|
@@ -264,8 +281,32 @@ module Fontisan
|
|
|
264
281
|
#
|
|
265
282
|
# @param table [Head] Parsed head table
|
|
266
283
|
# @return [String] Binary data of subset head table
|
|
284
|
+
# Subset head table (recompute bbox from actual subset glyphs)
|
|
285
|
+
#
|
|
286
|
+
# The source TTC's head.xMin/yMin/xMax/yMax covers every donor
|
|
287
|
+
# font in the collection — far larger than any per-block subset.
|
|
288
|
+
# Browsers and layout engines use head bbox (plus hhea + OS/2)
|
|
289
|
+
# to compute line height and clip text; a too-large bbox makes
|
|
290
|
+
# glyphs render at the wrong visual size.
|
|
291
|
+
#
|
|
292
|
+
# @param _table [Head] Parsed head table (unused; we re-serialize
|
|
293
|
+
# directly from font.table_data so we don't lose other fields)
|
|
294
|
+
# @return [String] Binary data of subset head table
|
|
267
295
|
def subset_head(_table)
|
|
268
|
-
|
|
296
|
+
# Trigger glyf build (which populates @subset_bbox) if not done.
|
|
297
|
+
glyf = font.table("glyf")
|
|
298
|
+
build_glyf_and_loca(glyf) unless @glyf_data
|
|
299
|
+
|
|
300
|
+
data = font.table_data["head"].dup
|
|
301
|
+
|
|
302
|
+
if @subset_bbox
|
|
303
|
+
x_min, y_min, x_max, y_max = @subset_bbox
|
|
304
|
+
# head layout: xMin at offset 36, yMin 38, xMax 40, yMax 42
|
|
305
|
+
# (each int16, big-endian, signed)
|
|
306
|
+
data[36, 8] = [x_min, y_min, x_max, y_max].pack("n4")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
data
|
|
269
310
|
end
|
|
270
311
|
|
|
271
312
|
# Subset OS/2 table (optionally prune Unicode ranges)
|
|
@@ -295,6 +336,32 @@ module Fontisan
|
|
|
295
336
|
mapping.size
|
|
296
337
|
end
|
|
297
338
|
|
|
339
|
+
# Compute the largest advanceWidth across all subset glyphs by
|
|
340
|
+
# reading the source hmtx directly. Called from subset_hhea
|
|
341
|
+
# because hhea is processed before hmtx (alphabetical order).
|
|
342
|
+
#
|
|
343
|
+
# @return [Integer] max advanceWidth, or 0 if no metrics found
|
|
344
|
+
def compute_subset_max_advance
|
|
345
|
+
hmtx = font.table("hmtx")
|
|
346
|
+
return 0 unless hmtx
|
|
347
|
+
|
|
348
|
+
unless hmtx.parsed?
|
|
349
|
+
hhea = font.table("hhea")
|
|
350
|
+
maxp = font.table("maxp")
|
|
351
|
+
hmtx.parse_with_context(hhea.number_of_h_metrics, maxp.num_glyphs)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
max_advance = 0
|
|
355
|
+
mapping.old_ids.each do |old_id|
|
|
356
|
+
metric = hmtx.metric_for(old_id)
|
|
357
|
+
next unless metric
|
|
358
|
+
|
|
359
|
+
advance = metric[:advance_width]
|
|
360
|
+
max_advance = advance if advance && advance > max_advance
|
|
361
|
+
end
|
|
362
|
+
max_advance
|
|
363
|
+
end
|
|
364
|
+
|
|
298
365
|
# Build glyf and loca tables together
|
|
299
366
|
#
|
|
300
367
|
# This method extracts glyph data for all glyphs in the mapping,
|
|
@@ -318,6 +385,17 @@ module Fontisan
|
|
|
318
385
|
@loca_offsets = []
|
|
319
386
|
current_offset = 0
|
|
320
387
|
|
|
388
|
+
# Track union bbox across all subset glyphs. Glyph binary layout:
|
|
389
|
+
# int16 numberOfContours (offset 0)
|
|
390
|
+
# int16 xMin (offset 2)
|
|
391
|
+
# int16 yMin (offset 4)
|
|
392
|
+
# int16 xMax (offset 6)
|
|
393
|
+
# int16 yMax (offset 8)
|
|
394
|
+
bbox_x_min = 1 << 30
|
|
395
|
+
bbox_y_min = 1 << 30
|
|
396
|
+
bbox_x_max = -(1 << 30)
|
|
397
|
+
bbox_y_max = -(1 << 30)
|
|
398
|
+
|
|
321
399
|
# Process glyphs in mapping order
|
|
322
400
|
mapping.old_ids.each do |old_id|
|
|
323
401
|
@loca_offsets << current_offset
|
|
@@ -334,6 +412,21 @@ module Fontisan
|
|
|
334
412
|
# Extract glyph data
|
|
335
413
|
glyph_data = glyf_table.raw_data[offset, size]
|
|
336
414
|
|
|
415
|
+
# Update bbox union. Each glyph has at least 10 bytes of
|
|
416
|
+
# header (numberOfContours + 4 int16 bbox fields).
|
|
417
|
+
if glyph_data.bytesize >= 10
|
|
418
|
+
_n, gx_min, gy_min, gx_max, gy_max = glyph_data[0, 10].unpack("n5")
|
|
419
|
+
# Treat as signed int16
|
|
420
|
+
gx_min = (gx_min ^ 0x8000) - 0x8000
|
|
421
|
+
gy_min = (gy_min ^ 0x8000) - 0x8000
|
|
422
|
+
gx_max = (gx_max ^ 0x8000) - 0x8000
|
|
423
|
+
gy_max = (gy_max ^ 0x8000) - 0x8000
|
|
424
|
+
bbox_x_min = gx_min if gx_min < bbox_x_min
|
|
425
|
+
bbox_y_min = gy_min if gy_min < bbox_y_min
|
|
426
|
+
bbox_x_max = gx_max if gx_max > bbox_x_max
|
|
427
|
+
bbox_y_max = gy_max if gy_max > bbox_y_max
|
|
428
|
+
end
|
|
429
|
+
|
|
337
430
|
# Check if compound glyph and remap components
|
|
338
431
|
if compound_glyph?(glyph_data)
|
|
339
432
|
glyph_data = remap_compound_glyph(glyph_data)
|
|
@@ -346,6 +439,11 @@ module Fontisan
|
|
|
346
439
|
|
|
347
440
|
# Add final offset
|
|
348
441
|
@loca_offsets << current_offset
|
|
442
|
+
|
|
443
|
+
# Stash union bbox if we saw at least one non-empty glyph.
|
|
444
|
+
return if bbox_x_min > bbox_x_max
|
|
445
|
+
|
|
446
|
+
@subset_bbox = [bbox_x_min, bbox_y_min, bbox_x_max, bbox_y_max]
|
|
349
447
|
end
|
|
350
448
|
|
|
351
449
|
# Check if glyph data represents a compound glyph
|