fontisan 0.4.6 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. checksums.yaml +4 -4
  2. data/BUG-stitcher-drops-isolated-cps.md +58 -0
  3. data/BUG-stitcher-drops-plane1-codepoints.md +310 -0
  4. data/BUG-stitcher-gid-cap-65535.md +110 -0
  5. data/CHANGELOG.md +106 -0
  6. data/README.adoc +121 -68
  7. data/benchmark/compile_benchmark.rb +70 -0
  8. data/docs/CFF2_SUPPORT.adoc +184 -0
  9. data/docs/STITCHER_GUIDE.adoc +151 -0
  10. data/docs/SVG_TO_GLYF.adoc +118 -0
  11. data/docs/UFO_COMPILATION.adoc +119 -0
  12. data/lib/fontisan/collection/writer.rb +5 -6
  13. data/lib/fontisan/error.rb +31 -0
  14. data/lib/fontisan/stitcher/deduplicator.rb +47 -0
  15. data/lib/fontisan/stitcher/glyph_limit.rb +53 -0
  16. data/lib/fontisan/stitcher/glyph_signature.rb +51 -0
  17. data/lib/fontisan/stitcher.rb +188 -167
  18. data/lib/fontisan/svg_to_glyf/assembler.rb +132 -0
  19. data/lib/fontisan/svg_to_glyf/document.rb +83 -0
  20. data/lib/fontisan/svg_to_glyf/geometry/affine_transform.rb +112 -0
  21. data/lib/fontisan/svg_to_glyf/geometry/normalizer.rb +45 -0
  22. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +91 -0
  23. data/lib/fontisan/svg_to_glyf/geometry.rb +13 -0
  24. data/lib/fontisan/svg_to_glyf/path/command.rb +18 -0
  25. data/lib/fontisan/svg_to_glyf/path/contour_builder.rb +140 -0
  26. data/lib/fontisan/svg_to_glyf/path/parser.rb +98 -0
  27. data/lib/fontisan/svg_to_glyf/path/state.rb +79 -0
  28. data/lib/fontisan/svg_to_glyf/path.rb +14 -0
  29. data/lib/fontisan/svg_to_glyf.rb +62 -0
  30. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +216 -0
  31. data/lib/fontisan/tables/cff.rb +1 -0
  32. data/lib/fontisan/tables/cff2/dict_encoder.rb +94 -0
  33. data/lib/fontisan/tables/cff2/fd_select.rb +69 -0
  34. data/lib/fontisan/tables/cff2/header.rb +34 -0
  35. data/lib/fontisan/tables/cff2/index_builder.rb +79 -0
  36. data/lib/fontisan/tables/cff2.rb +4 -0
  37. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +103 -0
  38. data/lib/fontisan/ufo/compile/cff2.rb +181 -0
  39. data/lib/fontisan/ufo/compile/cff2_subroutines.rb +39 -0
  40. data/lib/fontisan/ufo/compile/colr.rb +80 -0
  41. data/lib/fontisan/ufo/compile/cpal.rb +61 -0
  42. data/lib/fontisan/ufo/compile/math.rb +143 -0
  43. data/lib/fontisan/ufo/compile/meta.rb +51 -0
  44. data/lib/fontisan/ufo/compile/otf2_compiler.rb +46 -0
  45. data/lib/fontisan/ufo/compile/sbix.rb +99 -0
  46. data/lib/fontisan/ufo/compile/svg_table.rb +60 -0
  47. data/lib/fontisan/ufo/compile/variable_otf.rb +75 -0
  48. data/lib/fontisan/ufo/compile.rb +11 -0
  49. data/lib/fontisan/version.rb +1 -1
  50. data/lib/fontisan.rb +3 -0
  51. metadata +41 -2
data/README.adoc CHANGED
@@ -56,9 +56,19 @@ gem install fontisan
56
56
 
57
57
  == Features
58
58
 
59
+ Font compilation and assembly::
60
+ * UFO → TTF compilation with cubic-to-quadratic conversion and winding-order correction (see link:docs/UFO_COMPILATION.adoc[UFO Compilation Guide])
61
+ * UFO → OTF (CFF1) compilation with Type 2 charstring encoding
62
+ * UFO → OTF (CFF2) compilation with variable-font blend/vsindex operators (see link:docs/CFF2_SUPPORT.adoc[CFF2 Support Guide])
63
+ * Multi-source font stitching with explicit subfont declaration and TTC/OTC collection output (see link:docs/STITCHER_GUIDE.adoc[Stitcher Guide])
64
+ * SVG path → UFO glyph conversion for chart-extracted glyphs (see link:docs/SVG_TO_GLYF.adoc[SVG to Glyph Guide])
65
+ * Variable font compilation: fvar, gvar, HVAR, MVAR, avar, STAT tables
66
+ * Signature-based glyph deduplication with 65,535 glyph cap enforcement
67
+ * Compound (composite) TrueType glyph flattening
68
+
59
69
  Color fonts and collections::
60
- * Color font support: COLR/CPAL layered glyphs, sbix bitmap glyphs, and SVG table (see link:docs/COLOR_FONTS.adoc[Color Fonts Guide])
61
- * Font collection validation with per-font reporting (see link:docs/COLLECTION_VALIDATION.adoc[Collection Validation Guide])
70
+ * Color font table builders: COLR v0, CPAL, SVG table, sbix, CBDT/CBLC (see link:docs/COLOR_FONTS.adoc[Color Fonts Guide])
71
+ * Font collection management: pack/unpack TTC/OTC with table deduplication (see link:docs/COLLECTION_VALIDATION.adoc[Collection Validation Guide])
62
72
 
63
73
  Font analysis and information::
64
74
  * Comprehensive per-face font metadata extraction (replaces `otfinfo`) covering identity, style, metrics, codepoint coverage, licensing, hinting, color capabilities, variable-font detail, and OpenType layout features
@@ -81,21 +91,26 @@ Font operations::
81
91
  * Collection management (pack/unpack TTC/OTC/dfont files with table deduplication)
82
92
 
83
93
  Font format support::
84
- * TTF, OTF, TTC, OTC font formats (production ready)
94
+ * TTF, OTF (CFF1), OTF2 (CFF2), TTC, OTC font formats (production ready)
95
+ * CFF2 with variable-font blend/vsindex operators, FDSelect, subroutines
85
96
  * Adobe Type 1 fonts (PFB/PFA) with bidirectional conversion (see link:docs/TYPE1_FONTS.adoc[Type 1 Fonts Guide])
86
97
  * WOFF/WOFF2 format support with reading, writing, and conversion (see link:docs/WOFF_WOFF2_FORMATS.adoc[WOFF/WOFF2 Guide])
87
98
  * Apple legacy font support: 'true' signature TrueType fonts and dfont format (see link:docs/APPLE_LEGACY_FONTS.adoc[Apple Legacy Fonts Guide])
88
- * SVG font generation (complete)
99
+ * SVG font generation and SVG-to-glyph conversion (complete)
89
100
 
90
101
  Font engineering::
91
102
  * Universal outline model for format-agnostic glyph representation
92
- * CFF CharString encoding/decoding (complete)
93
- * CFF INDEX structure building (complete)
94
- * CFF DICT structure building (complete)
103
+ * CFF/CFF2 CharString encoding/decoding with blend operator support
104
+ * CFF/CFF2 INDEX structure building (uint16 for CFF1, uint32 for CFF2)
105
+ * CFF/CFF2 DICT structure building
95
106
  * TrueType curve converter for bi-directional quadratic/cubic conversion (complete)
96
- * Compound glyph decomposition with transformation support (complete)
97
- * CFF subroutine optimization for space-efficient OTF generation (preview mode)
107
+ * Compound glyph decomposition with affine transformation flattening (complete)
108
+ * CFF2 subroutine bias calculation and INDEX management
109
+ * FDSelect (format 0, 3) for CID-keyed CFF2 fonts
98
110
  * Bidirectional hint conversion (TrueType ↔ PostScript) with validation (complete)
111
+ * MATH table builder for mathematical formula layout
112
+ * Meta table builder for tagged metadata
113
+ * Glyph signature-based deduplication across donor fonts
99
114
  * CFF2 variable font support for PostScript hint conversion (complete)
100
115
 
101
116
  Export and interfaces::
@@ -2379,8 +2394,8 @@ Fontisan can:
2379
2394
  == UFO source operations
2380
2395
 
2381
2396
  fontisan reads, writes, and compiles UFO (Unified Font Object) v2/v3
2382
- font sources into binary font formats (TTF, OTF). No AFDKO or Python
2383
- fonttools needed.
2397
+ font sources into binary font formats (TTF, OTF, OTF2/CFF2). No AFDKO
2398
+ or Python fonttools needed.
2384
2399
 
2385
2400
  === Build a UFO to binary
2386
2401
 
@@ -2397,43 +2412,23 @@ The TTF compiler automatically applies:
2397
2412
  - `cubic_to_quadratic` filter (UFO cubic Beziers -> TTF quadratic)
2398
2413
  - `reverse_contour_direction` filter (TTF winding convention)
2399
2414
 
2400
- === Convert between UFO and binary
2401
-
2402
- [source,bash]
2403
- ----
2404
- # UFO -> binary
2405
- fontisan ufo convert font.ufo out.ttf --to ttf
2406
-
2407
- # Binary -> UFO (reverse direction)
2408
- fontisan ufo convert font.ttf font.ufo/
2409
- ----
2410
-
2411
- === Validate a UFO source
2412
-
2413
- [source,bash]
2414
- ----
2415
- fontisan ufo validate font.ufo
2416
- ----
2417
-
2418
- Checks: glyphs present, family name set, unitsPerEm set, .notdef exists.
2419
-
2420
- === Ruby API
2415
+ === Ruby API compile to TTF, OTF, or CFF2
2421
2416
 
2422
2417
  [source,ruby]
2423
2418
  ----
2424
2419
  require "fontisan"
2425
2420
 
2426
- # Read a UFO
2427
2421
  font = Fontisan::Ufo::Font.open("font.ufo")
2428
- puts font.family_name
2429
- puts font.glyphs.size
2430
2422
 
2431
- # Compile to TTF
2423
+ # TrueType (.ttf) — quadratic outlines
2432
2424
  Fontisan::Ufo::Compile::TtfCompiler.new(font).compile(output_path: "out.ttf")
2433
2425
 
2434
- # Compile to OTF
2426
+ # OpenType/CFF1 (.otf) — cubic outlines
2435
2427
  Fontisan::Ufo::Compile::OtfCompiler.new(font).compile(output_path: "out.otf")
2436
2428
 
2429
+ # OpenType/CFF2 (.otf) — modern format, supports variable fonts
2430
+ Fontisan::Ufo::Compile::Otf2Compiler.new(font).compile(output_path: "out.otf")
2431
+
2437
2432
  # Write back to UFO (round-trip)
2438
2433
  Fontisan::Ufo::Writer.new(font).write("out.ufo")
2439
2434
 
@@ -2442,12 +2437,39 @@ ttf = Fontisan::FontLoader.load("font.ttf")
2442
2437
  ufo = Fontisan::Ufo::Convert::FromBinData.convert(ttf)
2443
2438
  ----
2444
2439
 
2440
+ === Variable font compilation
2441
+
2442
+ [source,ruby]
2443
+ ----
2444
+ # Variable TTF (glyf outlines)
2445
+ orchestrator = Fontisan::Ufo::Compile::VariableTtf.new(
2446
+ font: default_font,
2447
+ axes: [{ tag: "wght", min: 100, default: 400, max: 900 }],
2448
+ masters: [{ font: bold_font, axes: { "wght" => 1.0 } }],
2449
+ )
2450
+ orchestrator.compile(output_path: "variable.ttf")
2451
+
2452
+ # Variable OTF (CFF2 outlines)
2453
+ orchestrator = Fontisan::Ufo::Compile::VariableOtf.new(
2454
+ default_font,
2455
+ axes: [{ tag: "wght", min: 100, default: 400, max: 900 }],
2456
+ )
2457
+ orchestrator.compile(output_path: "variable.otf")
2458
+ ----
2459
+
2460
+ See link:docs/CFF2_SUPPORT.adoc[CFF2 Support Guide] for CFF2-specific
2461
+ features (blend operators, FDSelect, subroutines, VariationStore).
2462
+
2445
2463
  == Font stitching
2446
2464
 
2447
- The Stitcher combines glyphs from multiple source fonts into one new
2448
- font. Sources can be UFO directories or loaded TTF/OTF files.
2465
+ The Stitcher combines glyphs from multiple source fonts into one or
2466
+ more new fonts. Sources can be UFO directories or loaded TTF/OTF files.
2449
2467
 
2450
- === Ruby API
2468
+ Every `include_*` method requires an `into:` keyword naming the target
2469
+ subfont. `write_to` requires a `subfont:` name. No defaults — the user
2470
+ controls the collection structure explicitly.
2471
+
2472
+ === Single font
2451
2473
 
2452
2474
  [source,ruby]
2453
2475
  ----
@@ -2455,32 +2477,37 @@ stitcher = Fontisan::Stitcher.new
2455
2477
  stitcher.add_source(:latin, Fontisan::FontLoader.load("NotoSans.ttf"))
2456
2478
  stitcher.add_source(:cjk, Fontisan::FontLoader.load("FSung-m.ttf"))
2457
2479
 
2458
- stitcher.include_range(0x41..0x5A, from: :latin) # A-Z
2459
- stitcher.include_range(0x4E00..0x9FFF, from: :cjk) # CJK Unified
2460
- stitcher.include_notdef(from: :latin)
2480
+ stitcher.include_notdef(from: :latin, into: :main)
2481
+ stitcher.include_range(0x41..0x5A, from: :latin, into: :main) # A-Z
2482
+ stitcher.include_range(0x4E00..0x9FFF, from: :cjk, into: :main) # CJK
2461
2483
 
2462
- stitcher.write_to("stitched.ttf", format: :ttf)
2484
+ stitcher.write_to("stitched.ttf", format: :ttf, subfont: :main)
2463
2485
  ----
2464
2486
 
2465
- === CLI
2487
+ === Collection (TTC/OTC) with explicit subfonts
2466
2488
 
2467
- [source,bash]
2489
+ When the glyph count exceeds 65,535 (the OpenType cap), split across
2490
+ named subfonts and write a collection:
2491
+
2492
+ [source,ruby]
2468
2493
  ----
2469
- fontisan stitch \
2470
- --source latin=NotoSans.ttf \
2471
- --source cjk=FSung-m.ttf \
2472
- --include-range latin=0x41-0x5A \
2473
- --include-range cjk=0x4E00-0x9FFF \
2474
- --notdef-from latin \
2475
- --output stitched.ttf
2494
+ stitcher = Fontisan::Stitcher.new
2495
+ stitcher.add_source(:noto_sans, Fontisan::FontLoader.load("NotoSans.ttf"))
2496
+ stitcher.add_source(:noto_cjk, Fontisan::FontLoader.load("NotoSansCJK.ttf"))
2497
+
2498
+ # Explicitly assign codepoints to named subfonts
2499
+ stitcher.include_range(0x41..0x5A, from: :noto_sans, into: :latin)
2500
+ stitcher.include_range(0x4E00..0x9FFF, from: :noto_cjk, into: :cjk)
2501
+
2502
+ # Write as OTC with CFF2 subfonts and table deduplication
2503
+ stitcher.write_collection("out.otc", format: :otf2)
2476
2504
  ----
2477
2505
 
2478
- === Color emoji (CBDT/CBLC passthrough)
2506
+ === CBDT/CBLC passthrough (color emoji)
2479
2507
 
2480
2508
  Sources with CBDT/CBLC tables (e.g. NotoColorEmoji) can be used as
2481
2509
  Stitcher sources. The bitmap data is propagated byte-for-byte from the
2482
- source into the output. The Stitcher automatically detects the source's
2483
- storage mode via `bitmap_mode`:
2510
+ source into the output.
2484
2511
 
2485
2512
  [source,ruby]
2486
2513
  ----
@@ -2488,22 +2515,48 @@ stitcher = Fontisan::Stitcher.new
2488
2515
  stitcher.add_source(:text, Fontisan::FontLoader.load("NotoSans.ttf"))
2489
2516
  stitcher.add_source(:emoji, Fontisan::FontLoader.load("NotoColorEmoji.ttf"))
2490
2517
 
2491
- # All emoji glyphs from the CBDT source are added; the CBLC table
2492
- # is copied byte-for-byte and references the right GIDs.
2493
- stitcher.include_range(0x41..0x5A, from: :text)
2494
- stitcher.include_range(0x1F600..0x1F64F, from: :emoji)
2518
+ stitcher.include_notdef(from: :text, into: :main)
2519
+ stitcher.include_range(0x41..0x5A, from: :text, into: :main)
2520
+ stitcher.include_range(0x1F600..0x1F64F, from: :emoji, into: :main)
2495
2521
 
2496
- stitcher.write_to("out.ttf", format: :ttf)
2522
+ stitcher.write_to("out.ttf", format: :ttf, subfont: :main)
2497
2523
  # → out.ttf has glyf + CBDT + CBLC, emoji renders correctly
2498
2524
  ----
2499
2525
 
2500
- **Constraints:**
2501
- - Only ONE CBDT source per Stitcher (multiple raise
2502
- `Fontisan::MultipleCbdtSourcesError`)
2503
- - The CBDT source is processed first (GIDs 0..N-1) so the CBLC's GID
2504
- references remain valid in the output
2505
- - Multi-source CBDT merge is tracked as TODO (REVIEW
2506
- `TODO.full/ufo/23-cbdt-passthrough.md`)
2526
+ === SVG path → glyph conversion
2527
+
2528
+ Convert SVG path data (from ucode code-chart extraction) into UFO glyphs:
2529
+
2530
+ [source,ruby]
2531
+ ----
2532
+ # Single path → glyph
2533
+ glyph = Fontisan::SvgToGlyf.convert(
2534
+ "M 0 0 L 500 0 L 500 700 Z",
2535
+ upm: 1000,
2536
+ codepoint: 0x10940,
2537
+ )
2538
+
2539
+ # SVG file → glyph
2540
+ glyph = Fontisan::SvgToGlyf.from_svg_file("U+10940.svg", upm: 1000)
2541
+
2542
+ # Directory of SVGs → font (for Stitcher#add_source)
2543
+ font = Fontisan::SvgToGlyf.from_directory("/tmp/chart-svg/Khitan", upm: 1000)
2544
+ ----
2545
+
2546
+ See link:docs/SVG_TO_GLYF.adoc[SVG to Glyph Guide] for details.
2547
+
2548
+ === CLI
2549
+
2550
+ [source,bash]
2551
+ ----
2552
+ fontisan stitch \
2553
+ --source latin=NotoSans.ttf \
2554
+ --source cjk=FSung-m.ttf \
2555
+ --include-range latin=0x41-0x5A \
2556
+ --include-range cjk=0x4E00-0x9FFF \
2557
+ --notdef-from latin \
2558
+ --output stitched.ttf
2559
+ ----
2507
2560
 
2508
2561
 
2509
2562
  == Testing
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Benchmark: large font compilation performance.
4
+ #
5
+ # Measures the time taken to compile a synthetic large UFO font into
6
+ # TTF, OTF, and CFF2 OTF. Catches regressions in the charstring
7
+ # pipeline and the table builders.
8
+
9
+ require "tmpdir"
10
+ require "fontisan"
11
+ require "fontisan/ufo/compile"
12
+
13
+ module FontisanBench
14
+ module_function
15
+
16
+ def build_synthetic_font(num_glyphs)
17
+ font = Fontisan::Ufo::Font.new
18
+ font.info.family_name = "Bench"
19
+ font.info.units_per_em = 1000
20
+ font.glyphs[".notdef"] = Fontisan::Ufo::Glyph.new(name: ".notdef")
21
+
22
+ num_glyphs.times do |i|
23
+ g = Fontisan::Ufo::Glyph.new(name: "g#{i}")
24
+ g.width = 500
25
+ g.add_unicode(0xE000 + i) if i < 0x1000
26
+ g.add_contour(Fontisan::Ufo::Contour.new([
27
+ Fontisan::Ufo::Point.new(x: 0, y: 0, type: "line"),
28
+ Fontisan::Ufo::Point.new(x: 100, y: 0, type: "line"),
29
+ Fontisan::Ufo::Point.new(x: 100, y: 100, type: "offcurve"),
30
+ Fontisan::Ufo::Point.new(x: 50, y: 150, type: "curve"),
31
+ ]))
32
+ font.glyphs["g#{i}"] = g
33
+ end
34
+ font
35
+ end
36
+
37
+ def measure(label)
38
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
+ yield
40
+ elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
41
+ puts format("%-40<label>s %8.<elapsed>.2f ms", label: label, elapsed: elapsed * 1000)
42
+ elapsed
43
+ end
44
+ end
45
+
46
+ if __FILE__ == $PROGRAM_NAME
47
+ ufo = FontisanBench.build_synthetic_font(1000)
48
+
49
+ Dir.mktmpdir do |dir|
50
+ FontisanBench.measure("TTF compile (1000 glyphs)") do
51
+ Fontisan::Ufo::Compile::TtfCompiler.new(ufo).compile(output_path: "#{dir}/a.ttf")
52
+ end
53
+ FontisanBench.measure("OTF compile (1000 glyphs)") do
54
+ Fontisan::Ufo::Compile::OtfCompiler.new(ufo).compile(output_path: "#{dir}/a.otf")
55
+ end
56
+ FontisanBench.measure("CFF2 OTF compile (1000 glyphs)") do
57
+ Fontisan::Ufo::Compile::Otf2Compiler.new(ufo).compile(output_path: "#{dir}/a-cff2.otf")
58
+ end
59
+ end
60
+
61
+ Dir.mktmpdir do |dir|
62
+ ufo_big = FontisanBench.build_synthetic_font(10_000)
63
+ FontisanBench.measure("TTF compile (10k glyphs)") do
64
+ Fontisan::Ufo::Compile::TtfCompiler.new(ufo_big).compile(output_path: "#{dir}/a.ttf")
65
+ end
66
+ FontisanBench.measure("CFF2 OTF compile (10k glyphs)") do
67
+ Fontisan::Ufo::Compile::Otf2Compiler.new(ufo_big).compile(output_path: "#{dir}/a-cff2.otf")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,184 @@
1
+ = CFF2 Support Guide
2
+
3
+ == General
4
+
5
+ CFF2 (Compact Font Format version 2) is the modern PostScript outline
6
+ format defined in OpenType 1.9+. It is required for variable OTF fonts
7
+ that use PostScript outlines, and it replaces the legacy CFF (CFF1)
8
+ format's Name INDEX, String INDEX, Encoding, and Charset with a
9
+ simpler, variation-aware structure.
10
+
11
+ Fontisan implements CFF2 entirely in pure Ruby. The pieces:
12
+
13
+ * `Tables::Cff2::Header` — 5-byte header (major, minor, headerSize, topDictLength)
14
+ * `Tables::Cff2::IndexBuilder` — uint32-count INDEX (CFF1 uses uint16)
15
+ * `Tables::Cff2::DictEncoder` — DICT operand/operator encoding
16
+ * `Tables::Cff2::FdSelect` — format 0 and format 3 FDSelect for CID-keyed fonts
17
+ * `Tables::Cff::Cff2CharStringBuilder` — charstring builder with
18
+ vsindex (operator 22, replacing CFF1's hmoveto) and blend (operator 23)
19
+ * `Ufo::Compile::Cff2` — assembles the CFF2 table from UFO glyphs
20
+ * `Ufo::Compile::Cff2Subrs` — bias calculation + GlobalSubr/LocalSubr INDEXes
21
+
22
+ == Building a CFF2 table directly
23
+
24
+ [source,ruby]
25
+ ----
26
+ glyphs = font.glyphs.values
27
+ cff2_bytes = Fontisan::Ufo::Compile::Cff2.build(font, glyphs: glyphs)
28
+ ----
29
+
30
+ For variable CFF2, pass an `ItemVariationStore`:
31
+
32
+ [source,ruby]
33
+ ----
34
+ vs_bytes = Fontisan::Ufo::Compile::ItemVariationStore.build(...)
35
+ cff2_bytes = Fontisan::Ufo::Compile::Cff2.build(
36
+ font, glyphs: glyphs, variation_store: vs_bytes
37
+ )
38
+ ----
39
+
40
+ When `variation_store:` is present, it is placed between the GlobalSubr
41
+ INDEX and CharStrings INDEX, and the Top DICT records its offset via
42
+ operator 24.
43
+
44
+ == CFF2 table layout
45
+
46
+ [cols="1,3", options="header"]
47
+ |===
48
+ | Section | Notes
49
+
50
+ | Header (5 bytes) | major=2, minor=0, headerSize=5, topDictLength=N
51
+
52
+ | Top DICT | Offsets to CharStrings (op 17), Font DICT INDEX (op [12,36]),
53
+ VariationStore (op 24, variable fonts only), Private
54
+ (op 18)
55
+
56
+ | GlobalSubr INDEX | uint32 count — empty by default
57
+
58
+ | VariationStore | Present only for variable CFF2 fonts
59
+
60
+ | CharStrings INDEX | uint32 count, one entry per glyph
61
+
62
+ | Font DICT INDEX | uint32 count, wraps one or more Font DICTs
63
+
64
+ | Font DICT | DICT with Private operator `[size, offset]`
65
+ |===
66
+
67
+ The Top DICT's size determines where GlobalSubr INDEX starts, which
68
+ cascades to all subsequent offsets — a circular dependency that
69
+ `Ufo::Compile::Cff2` resolves with fixed-point iteration (typically
70
+ converges in 2-3 passes).
71
+
72
+ == Variable charstrings: blend and vsindex
73
+
74
+ CFF2 introduces two new charstring operators:
75
+
76
+ [cols="1,1,3", options="header"]
77
+ |===
78
+ | Operator | Code | Purpose
79
+
80
+ | `vsindex` | 22 | Selects the variation region scalars (replaces CFF1 `hmoveto`)
81
+
82
+ | `blend` | 23 | Pops n+1 numbers per operand, blends using region scalars
83
+ |===
84
+
85
+ The blend protocol:
86
+
87
+ stack before: [base_value, delta_r0, delta_r1, ..., delta_r(n-1), n, blend]
88
+ stack after: [blended_value]
89
+
90
+ The blended value is `base_value + Σ(delta_i × scalar_i)` for the
91
+ currently selected regions.
92
+
93
+ `Tables::Cff::Cff2CharStringBuilder.build_variable` encodes variable
94
+ outlines directly:
95
+
96
+ [source,ruby]
97
+ ----
98
+ charstring = Fontisan::Tables::Cff::Cff2CharStringBuilder.build_variable(
99
+ default_outline,
100
+ master_outlines: [bold_outline, light_outline],
101
+ num_regions: 2,
102
+ width: 600,
103
+ )
104
+ # → charstring bytes with base values + per-region deltas + blend
105
+ ----
106
+
107
+ The builder tracks per-master position via
108
+ `MasterState = Struct.new(:current_x, :current_y)` to compute relative
109
+ deltas as the command sequence is walked. All masters must share the
110
+ same command structure as the default outline.
111
+
112
+ When all deltas for a coordinate are zero, the blend operator is
113
+ omitted for that coordinate — visually identical output, smaller file.
114
+
115
+ == FDSelect
116
+
117
+ CID-keyed CFF2 fonts use FDSelect to map each GID to a Font DICT.
118
+ `Tables::Cff2::FdSelect` supports both formats:
119
+
120
+ * **Format 0** — one byte per glyph (small fonts)
121
+ * **Format 3** — run-length encoded (large fonts with contiguous FD
122
+ assignments)
123
+
124
+ [source,ruby]
125
+ ----
126
+ fdselect_bytes = Fontisan::Tables::Cff2::FdSelect.build(
127
+ format: 3,
128
+ fd_indices: [0, 0, 1, 1, 1, 2]
129
+ )
130
+ ----
131
+
132
+ == Subroutines
133
+
134
+ `Ufo::Compile::Cff2Subrs` provides:
135
+
136
+ * `bias(count)` — subroutine bias per spec:
137
+ ** 107 when count ≤ 1240
138
+ ** 1131 when count ≤ 33800
139
+ ** 32768 above
140
+ * `build_index(subrs)` — uint32 INDEX builder for both GlobalSubr and
141
+ LocalSubr (same structure — DRY)
142
+
143
+ By default the GlobalSubr INDEX is empty (count=0). Per-FontDICT
144
+ LocalSubr INDEXes are also empty in the static-font case.
145
+
146
+ == Compiling to a CFF2 OTF
147
+
148
+ The high-level path is `Ufo::Compile::Otf2Compiler`:
149
+
150
+ [source,ruby]
151
+ ----
152
+ Fontisan::Ufo::Compile::Otf2Compiler.new(font)
153
+ .compile(output_path: "Modern.otf")
154
+ ----
155
+
156
+ For variable CFF2:
157
+
158
+ [source,ruby]
159
+ ----
160
+ orchestrator = Fontisan::Ufo::Compile::VariableOtf.new(
161
+ default_font,
162
+ master_fonts: [bold_font, light_font],
163
+ axes: [{ tag: "wght", min: 100, default: 400, max: 900 }],
164
+ )
165
+ orchestrator.compile(output_path: "Variable.otf")
166
+ ----
167
+
168
+ NOTE: The current `VariableOtf` orchestrator emits a static CFF2 table
169
+ with `fvar`, `STAT`, and `avar` for axes — the blend/vsindex operators
170
+ exist in `Cff2CharStringBuilder` but are not yet wired through the
171
+ default compile path. Until that integration lands, instances
172
+ generated from the variable OTF will all render the default master.
173
+
174
+ == Spec reference
175
+
176
+ * https://learn.microsoft.com/en-us/typography/opentype/spec/cff2[OpenType CFF2 spec]
177
+ * https://learn.microsoft.com/en-us/typography/opentype/spec/cff2charstr[CFF2 CharString spec]
178
+
179
+ == See also
180
+
181
+ * link:UFO_COMPILATION.adoc[UFO Compilation Guide] — high-level
182
+ compiler overview
183
+ * link:VARIABLE_FONT_OPERATIONS.adoc[Variable Font Operations] —
184
+ reading and modifying existing variable fonts
@@ -0,0 +1,151 @@
1
+ = Stitcher Guide
2
+
3
+ == General
4
+
5
+ The Stitcher combines glyphs from multiple source fonts into one or
6
+ more new fonts. Sources can be UFO directories, loaded TTF/OTF files,
7
+ or any object responding to the `Ufo::Font` interface.
8
+
9
+ The design rule: **the user controls the collection structure
10
+ explicitly**. Every `include_*` method requires an `into:` keyword
11
+ naming the target subfont, and `write_to` requires a `subfont:` name.
12
+ There are no defaults and no after-the-fact splitting.
13
+
14
+ == API
15
+
16
+ [cols="2,3", options="header"]
17
+ |===
18
+ | Method | Purpose
19
+
20
+ | `Stitcher.new(deduplicate: true)` | Create a stitcher; dedup is on by default
21
+
22
+ | `add_source(label, font)` | Register a source font under a Symbol label
23
+
24
+ | `include_range(range, from:, into:)` | Add codepoint range from `from` into `into`
25
+
26
+ | `include_codepoints(cps, from:, into:)` | Add an explicit Array of codepoints
27
+
28
+ | `include_gid(donor_gid, from:, into:)` | Add a specific GID from the source
29
+
30
+ | `include_notdef(from:, into:)` | Convenience for `include_gid(0, ...)`
31
+
32
+ | `set_info(hash)` | Override font info (family name, etc.)
33
+
34
+ | `write_to(path, format:, subfont:)` | Compile one subfont to a single file
35
+
36
+ | `write_collection(path, format:)` | Compile all declared subfonts into a TTC/OTC
37
+ |===
38
+
39
+ `format:` is one of `:ttf`, `:otf`, `:otf2`. For collections, `:ttf`
40
+ produces a `.ttc` and `:otf`/`:otf2` produce an `.otc`.
41
+
42
+ == Single-font example
43
+
44
+ [source,ruby]
45
+ ----
46
+ stitcher = Fontisan::Stitcher.new
47
+ stitcher.add_source(:latin, Fontisan::FontLoader.load("NotoSans.ttf"))
48
+ stitcher.add_source(:cjk, Fontisan::FontLoader.load("FSung-m.ttf"))
49
+
50
+ stitcher.include_notdef(from: :latin, into: :main)
51
+ stitcher.include_range(0x41..0x5A, from: :latin, into: :main) # A-Z
52
+ stitcher.include_range(0x4E00..0x9FFF, from: :cjk, into: :main) # CJK
53
+
54
+ stitcher.write_to("stitched.ttf", format: :ttf, subfont: :main)
55
+ ----
56
+
57
+ == Collection example (TTC/OTC)
58
+
59
+ When the total glyph count would exceed 65,535 (the OpenType
60
+ `maxp.numGlyphs` cap), split the work across named subfonts and write
61
+ a collection:
62
+
63
+ [source,ruby]
64
+ ----
65
+ stitcher = Fontisan::Stitcher.new
66
+ stitcher.add_source(:noto_sans, Fontisan::FontLoader.load("NotoSans.ttf"))
67
+ stitcher.add_source(:noto_cjk, Fontisan::FontLoader.load("NotoSansCJK.ttf"))
68
+
69
+ stitcher.include_range(0x41..0x5A, from: :noto_sans, into: :latin)
70
+ stitcher.include_range(0x4E00..0x9FFF, from: :noto_cjk, into: :cjk)
71
+
72
+ stitcher.write_collection("out.otc", format: :otf2)
73
+ ----
74
+
75
+ Each subfont is compiled independently, then packed by
76
+ `Collection::Builder` with table deduplication enabled — shared tables
77
+ (head, name, OS/2, ...) are stored once and referenced from each
78
+ subfont's offset table.
79
+
80
+ == Glyph deduplication
81
+
82
+ By default the Stitcher deduplicates glyphs across all sources using
83
+ SHA-256 outline signatures (`Stitcher::GlyphSignature`). Two donor
84
+ glyphs with identical width + contours + components are merged into a
85
+ single target glyph; subsequent references are aliased by adding the
86
+ extra codepoint to the merged glyph's unicode set.
87
+
88
+ To disable deduplication (e.g., for forensic comparison):
89
+
90
+ [source,ruby]
91
+ ----
92
+ stitcher = Fontisan::Stitcher.new(deduplicate: false)
93
+ ----
94
+
95
+ == 65,535 glyph cap
96
+
97
+ OpenType `maxp.numGlyphs` is uint16 — no format (TTF, CFF1, or CFF2)
98
+ can hold more than 65,535 glyphs in a single font. The Stitcher
99
+ enforces this:
100
+
101
+ * `Stitcher::GlyphLimit.check!(count, format:)` raises
102
+ `GlyphLimitExceededError` when a single subfont would exceed the cap.
103
+ * The only path to >65,535 glyphs is a TTC/OTC collection — each
104
+ subfont is its own 65,535-space.
105
+
106
+ == CBDT/CBLC passthrough (color emoji)
107
+
108
+ Sources that contain CBDT/CBLC tables (e.g. NotoColorEmoji) can be
109
+ used as Stitcher sources. The bitmap data is propagated byte-for-byte
110
+ into the output:
111
+
112
+ [source,ruby]
113
+ ----
114
+ stitcher = Fontisan::Stitcher.new
115
+ stitcher.add_source(:text, Fontisan::FontLoader.load("NotoSans.ttf"))
116
+ stitcher.add_source(:emoji, Fontisan::FontLoader.load("NotoColorEmoji.ttf"))
117
+
118
+ stitcher.include_notdef(from: :text, into: :main)
119
+ stitcher.include_range(0x41..0x5A, from: :text, into: :main)
120
+ stitcher.include_range(0x1F600..0x1F64F, from: :emoji, into: :main)
121
+
122
+ stitcher.write_to("out.ttf", format: :ttf, subfont: :main)
123
+ # → out.ttf has glyf + CBDT + CBLC, emoji renders correctly
124
+ ----
125
+
126
+ Constraints:
127
+
128
+ * Only ONE CBDT source per Stitcher. Multiple CBDT sources raise
129
+ `Fontisan::MultipleCbdtSourcesError`.
130
+ * The CBDT source is processed first (GIDs 0..N-1) so the CBLC's GID
131
+ references remain valid in the output.
132
+
133
+ == CLI
134
+
135
+ [source,bash]
136
+ ----
137
+ fontisan stitch \
138
+ --source latin=NotoSans.ttf \
139
+ --source cjk=FSung-m.ttf \
140
+ --include-range latin=0x41-0x5A \
141
+ --include-range cjk=0x4E00-0x9FFF \
142
+ --notdef-from latin \
143
+ --output stitched.ttf
144
+ ----
145
+
146
+ == See also
147
+
148
+ * link:UFO_COMPILATION.adoc[UFO Compilation Guide] — TTF/OTF/CFF2 compilers
149
+ - link:COLLECTION_VALIDATION.adoc[Collection Validation Guide]
150
+ - link:SVG_TO_GLYF.adoc[SVG to Glyph Guide] — turn chart SVGs into
151
+ Stitcher sources