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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +27 -1
  3. data/docs/AFDKO_MIGRATION.adoc +175 -0
  4. data/docs/FEATURE_PARITY.adoc +263 -0
  5. data/docs/STITCHER_GUIDE.adoc +100 -6
  6. data/docs/TTX.adoc +176 -0
  7. data/docs/UFO_COMPILATION.adoc +281 -3
  8. data/lib/fontisan/stitcher/source.rb +11 -7
  9. data/lib/fontisan/subset/table_subsetter.rb +104 -6
  10. data/lib/fontisan/svg/standalone_glyph.rb +129 -0
  11. data/lib/fontisan/svg.rb +1 -0
  12. data/lib/fontisan/ufo/cli.rb +34 -21
  13. data/lib/fontisan/ufo/compile/feature_writers/base.rb +56 -0
  14. data/lib/fontisan/ufo/compile/feature_writers/curs.rb +72 -0
  15. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +90 -0
  16. data/lib/fontisan/ufo/compile/feature_writers/kern.rb +51 -0
  17. data/lib/fontisan/ufo/compile/feature_writers/kern2.rb +57 -0
  18. data/lib/fontisan/ufo/compile/feature_writers/mark.rb +45 -0
  19. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +122 -0
  20. data/lib/fontisan/ufo/compile/feature_writers/mkmk.rb +53 -0
  21. data/lib/fontisan/ufo/compile/feature_writers.rb +37 -0
  22. data/lib/fontisan/ufo/compile/filters/propagate_anchors.rb +102 -0
  23. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +94 -0
  24. data/lib/fontisan/ufo/compile/filters/sort_contours.rb +69 -0
  25. data/lib/fontisan/ufo/compile/filters/transformations.rb +113 -0
  26. data/lib/fontisan/ufo/compile/filters.rb +12 -0
  27. data/lib/fontisan/ufo/compile.rb +1 -0
  28. data/lib/fontisan/ufo/convert/to_dfont.rb +41 -0
  29. data/lib/fontisan/ufo/convert/to_otc.rb +37 -0
  30. data/lib/fontisan/ufo/convert/to_otf.rb +18 -0
  31. data/lib/fontisan/ufo/convert/to_otf2.rb +20 -0
  32. data/lib/fontisan/ufo/convert/to_postscript.rb +59 -0
  33. data/lib/fontisan/ufo/convert/to_ttc.rb +35 -0
  34. data/lib/fontisan/ufo/convert/to_ttf.rb +21 -0
  35. data/lib/fontisan/ufo/convert/to_woff.rb +56 -0
  36. data/lib/fontisan/ufo/convert/to_woff2.rb +45 -0
  37. data/lib/fontisan/ufo/convert.rb +68 -5
  38. data/lib/fontisan/ufo/transformation.rb +11 -0
  39. data/lib/fontisan/version.rb +1 -1
  40. 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.
@@ -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
- - link:STITCHER_GUIDE.adoc[Stitcher Guide] — multi-source assembly
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
- # x' = a*x + c*y + e
340
- # y' = b*x + d*y + f
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
- a, b, c, d, e, f = matrix
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 = a * x + c * y + e
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
- Fontisan::Ufo::Point.new(x: tx, y: ty, type: type)
361
+ Ufo::Point.new(x: tx, y: ty, type: type)
358
362
  end
359
- ufo_glyph.add_contour(Fontisan::Ufo::Contour.new(ufo_points))
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 the numberOfHMetrics field to reflect the number of
116
- # horizontal metrics in the subset font.
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
- # Update numberOfHMetrics field (at offset 34, uint16)
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
- data << [metric[:advance_width]].pack("n")
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
- font.table_data["head"]
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