fontisan 0.4.23 → 0.4.25

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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop_todo.yml +240 -29
  3. data/Gemfile +6 -0
  4. data/Rakefile +9 -6
  5. data/TODO.improvements/01-cbdt-cblc-gid-stable-propagation.md +73 -0
  6. data/TODO.improvements/02-collection-outline-priority.md +48 -0
  7. data/TODO.improvements/03-fontisan-audit-command.md +189 -0
  8. data/TODO.improvements/04-ufo-composite-glyph-encoding.md +46 -0
  9. data/TODO.improvements/05-otf-compiler-real-cff.md +63 -0
  10. data/TODO.improvements/06-cff2-blend-vsindex-operators.md +51 -0
  11. data/TODO.improvements/07-cpal-v1-header-fields.md +71 -0
  12. data/TODO.improvements/08-cff-standard-string-table.md +52 -0
  13. data/TODO.improvements/09-type1-seac-expansion.md +57 -0
  14. data/TODO.improvements/10-ufo-image-set-feature-writers.md +71 -0
  15. data/TODO.improvements/11-kern-groups-plist.md +49 -0
  16. data/TODO.improvements/12-cbdt-fixture-bindata-conversion.md +68 -0
  17. data/TODO.improvements/13-split-octokit-fetcher.md +45 -0
  18. data/TODO.improvements/14-rubocop-baseline-chip.md +62 -0
  19. data/TODO.improvements/README.md +57 -0
  20. data/benchmark/compile_benchmark.rb +13 -5
  21. data/docs/.vitepress/config.ts +1 -0
  22. data/docs/STITCHER_GUIDE.adoc +47 -3
  23. data/docs/api/layer.md +89 -0
  24. data/docs/cli/subset.md +25 -6
  25. data/docs/guide/cli/subset.md +31 -0
  26. data/docs/guide/color-fonts/bitmaps.md +17 -0
  27. data/lib/fontisan/cli.rb +2 -1
  28. data/lib/fontisan/collection/writer.rb +2 -1
  29. data/lib/fontisan/commands/validate_collection_command.rb +7 -1
  30. data/lib/fontisan/commands.rb +2 -1
  31. data/lib/fontisan/converters/collection_converter.rb +6 -1
  32. data/lib/fontisan/converters/svg_generator.rb +3 -1
  33. data/lib/fontisan/stitcher/collection_result.rb +2 -1
  34. data/lib/fontisan/stitcher/glyph_copier.rb +2 -1
  35. data/lib/fontisan/stitcher/partition_strategy/by_block.rb +3 -1
  36. data/lib/fontisan/stitcher/partition_strategy/by_script.rb +6 -2
  37. data/lib/fontisan/stitcher/source.rb +2 -1
  38. data/lib/fontisan/stitcher.rb +2 -1
  39. data/lib/fontisan/subset/table_strategy/glyf_loca_builder.rb +2 -1
  40. data/lib/fontisan/svg/standalone_glyph.rb +4 -1
  41. data/lib/fontisan/svg_to_glyf/assembler.rb +6 -3
  42. data/lib/fontisan/svg_to_glyf/geometry/transform_parser.rb +11 -4
  43. data/lib/fontisan/svg_to_glyf/geometry.rb +4 -2
  44. data/lib/fontisan/tables/cff/cff2_charstring_builder.rb +11 -5
  45. data/lib/fontisan/tables/cff.rb +2 -1
  46. data/lib/fontisan/tables/cff2/index_builder.rb +2 -1
  47. data/lib/fontisan/tables/cpal.rb +112 -11
  48. data/lib/fontisan/tables.rb +2 -1
  49. data/lib/fontisan/ufo/cli.rb +4 -1
  50. data/lib/fontisan/ufo/compile/cbdt_cblc.rb +2 -1
  51. data/lib/fontisan/ufo/compile/cff.rb +6 -2
  52. data/lib/fontisan/ufo/compile/cmap.rb +11 -5
  53. data/lib/fontisan/ufo/compile/cpal.rb +2 -1
  54. data/lib/fontisan/ufo/compile/feature_writers/gdef.rb +3 -1
  55. data/lib/fontisan/ufo/compile/feature_writers/mark_family_base.rb +3 -1
  56. data/lib/fontisan/ufo/compile/feature_writers.rb +2 -1
  57. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +4 -2
  58. data/lib/fontisan/ufo/compile/filters/remove_overlaps.rb +2 -1
  59. data/lib/fontisan/ufo/compile/fvar.rb +7 -3
  60. data/lib/fontisan/ufo/compile/glyf_loca.rb +4 -2
  61. data/lib/fontisan/ufo/compile/gvar.rb +5 -2
  62. data/lib/fontisan/ufo/compile/head.rb +2 -1
  63. data/lib/fontisan/ufo/compile/item_variation_store.rb +2 -1
  64. data/lib/fontisan/ufo/compile/name.rb +3 -1
  65. data/lib/fontisan/ufo/compile/sbix.rb +2 -1
  66. data/lib/fontisan/ufo/compile/stat.rb +6 -3
  67. data/lib/fontisan/ufo/convert/from_bin_data.rb +6 -2
  68. data/lib/fontisan/ufo/convert/to_dfont.rb +6 -2
  69. data/lib/fontisan/ufo/convert/to_otc.rb +4 -1
  70. data/lib/fontisan/ufo/convert/to_postscript.rb +8 -3
  71. data/lib/fontisan/ufo/convert/to_woff.rb +8 -3
  72. data/lib/fontisan/ufo/convert/to_woff2.rb +8 -3
  73. data/lib/fontisan/ufo/convert.rb +4 -1
  74. data/lib/fontisan/ufo/info.rb +4 -2
  75. data/lib/fontisan/ufo/plist.rb +7 -2
  76. data/lib/fontisan/ufo/reader.rb +3 -1
  77. data/lib/fontisan/ufo/writer.rb +13 -5
  78. data/lib/fontisan/version.rb +1 -1
  79. data/lib/fontisan/woff2/collection_decoder.rb +4 -1
  80. data/lib/fontisan/woff2/collection_encoder.rb +4 -1
  81. data/lib/fontisan/woff2/glyf_canonicalizer.rb +2 -1
  82. data/lib/fontisan/woff2/glyf_loca_reconstruct.rb +5 -2
  83. data/lib/fontisan/woff2/sfnt_checksum.rb +2 -1
  84. data/lib/fontisan.rb +0 -1
  85. metadata +18 -4
  86. data/lib/fontisan/tasks/fixture_downloader.rb +0 -162
  87. data/lib/fontisan/tasks.rb +0 -11
@@ -0,0 +1,68 @@
1
+ # 12 — `cbdt_fixture.rb` full BinData conversion
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+
8
+ `spec/support/cbdt_fixture.rb` was partially refactored in PR #107 (CBDT/CBLC use the new BinData models), but the remaining tables still hand-roll binary packing:
9
+
10
+ - `head_table` — 19-element `[...].pack("NNNNnnNNnnnnnnnnnn")`
11
+ - `hhea_table` — 16-element pack
12
+ - `os2_table` — 43-element pack
13
+ - `name_table` — manual record + offset arithmetic
14
+ - `post_table` — manual pack
15
+ - `hmtx_table` — `Array.new(num_glyphs) { [1000, 0] }.flatten.pack("n*")`
16
+ - `cmap_table` / `format4_subtable` / `format12_subtable` — full cmap builder, ~80 lines
17
+
18
+ ~200 lines of pack/unpack that duplicate the layout knowledge in `Tables::*`. If any table layout changes, the fixture breaks silently — the same anti-pattern PR #107 already fixed for CBDT/CBLC.
19
+
20
+ ## Goal
21
+
22
+ Every table builder in `cbdt_fixture.rb` constructs via BinData record (`Tables::Head`, `Tables::Hhea`, `Tables::Maxp`, `Tables::Os2`, `Tables::Name`, `Tables::Post`, `Tables::Hmtx`, `Tables::Cmap`). No more `[...].pack(...)` calls.
23
+
24
+ The fixture's self-test (`spec/support/cbdt_fixture_spec.rb`) catches any drift between the fixture's output and the BinData models.
25
+
26
+ ## Approach
27
+
28
+ For each table, replace the hand-rolled pack with a `Tables::*` BinData construction:
29
+
30
+ ```ruby
31
+ # Before
32
+ def head_table
33
+ [0x00010000, 0x00005000, ...].pack("NNNNnnNNnnnnnnnnnn")
34
+ end
35
+
36
+ # After
37
+ def head_table
38
+ Tables::Head.new(
39
+ version_raw: 0x00010000,
40
+ font_revision: 0x00005000,
41
+ ...
42
+ ).to_binary_s
43
+ end
44
+ ```
45
+
46
+ For tables with field shapes that don't match the BinData record cleanly (e.g., `name_table`'s variable-length string storage), use the BinData record's nested array attributes.
47
+
48
+ For `cmap_table`: extract the format-4 / format-12 subtable builders to a reusable `Tables::Cmap.from_mappings(mappings)` class method. The same logic is duplicated in `Subset::TableStrategy::Cmap::Builder` and `Subset::TableStrategy::ColorBitmapSubsetter` — DRY win across three call sites.
49
+
50
+ ## Out of scope
51
+
52
+ - Refactoring the BinData models themselves — only changes the fixture's consumers.
53
+ - Adding `Tables::Cmap.from_mappings` if the DRY extraction scope proves larger than the fixture alone (split into separate PR).
54
+
55
+ ## Effort
56
+
57
+ ~4 hours.
58
+
59
+ ## Dependencies
60
+
61
+ None.
62
+
63
+ ## Acceptance criteria
64
+
65
+ - `spec/support/cbdt_fixture.rb` contains zero `[...].pack` calls.
66
+ - `spec/support/cbdt_fixture_spec.rb` self-test still passes (it round-trips via the BinData models; the assertions don't change).
67
+ - All downstream specs that use `CbdtFixture` still pass.
68
+ - Bundle size of the fixture file drops from ~300 lines to ~150.
@@ -0,0 +1,45 @@
1
+ # 13 — Split `OctokitFetcher` out of `fixture_downloader.rb`
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+
8
+ `spec/support/fixture_downloader.rb` (post-PR #111) is ~350 lines with two distinct concerns:
9
+
10
+ 1. **Retry / routing loop** (the `Downloader` class) — backoff, Retry-After, permanent-vs-transient classification.
11
+ 2. **Octokit URL dispatch** (the `OctokitFetcher` module) — release-asset, raw-file, contents-API routing.
12
+
13
+ These are MECE-distinct. Bundling them in one file muddies the boundary; future changes to Octokit routing shouldn't require reading the retry loop.
14
+
15
+ ## Goal
16
+
17
+ `OctokitFetcher` moves to its own file `spec/support/fixture_downloader/octokit_fetcher.rb`. The `Downloader` class becomes the only public surface in `spec/support/fixture_downloader.rb` and delegates to `OctokitFetcher` when needed.
18
+
19
+ ## Approach
20
+
21
+ 1. Create `spec/support/fixture_downloader/` directory.
22
+ 2. Move `OctokitFetcher` module to `spec/support/fixture_downloader/octokit_fetcher.rb`.
23
+ 3. Move `HttpStatusIo` and `Downloader` constants stay in `spec/support/fixture_downloader.rb`.
24
+ 4. Add a `module FixtureFonts::Downloader` autoload or simple `require_relative "fixture_downloader/octokit_fetcher"` from `fixture_downloader.rb`.
25
+
26
+ Actually — for spec/support files, `require_relative` is acceptable (CLAUDE.md rule on autoload is for `lib/`). No autoload needed; the Rakefile's `require_relative` chain handles loading.
27
+
28
+ ## Out of scope
29
+
30
+ - Splitting the Downloader further — it's cohesive.
31
+ - Moving `fixture_downloader_spec.rb` — already sibling, fine where it is.
32
+
33
+ ## Effort
34
+
35
+ ~1 hour.
36
+
37
+ ## Dependencies
38
+
39
+ None.
40
+
41
+ ## Acceptance criteria
42
+
43
+ - `spec/support/fixture_downloader.rb` < 200 lines.
44
+ - `spec/support/fixture_downloader/octokit_fetcher.rb` exists and contains all Octokit-specific code.
45
+ - All existing specs pass unchanged.
@@ -0,0 +1,62 @@
1
+ # 14 — Rubocop baseline chip (per-namespace)
2
+
3
+ ## Priority
4
+ P3
5
+
6
+ ## Problem
7
+
8
+ PR #111's `rubocop -A --auto-gen-config` baseline refresh tracked ~9600 remaining offenses. The `.rubocop_todo.yml` file is now ~800 lines of `Exclude:` lists. Layout/Style cops dominate (most are auto-correctable; the rest need manual fixes).
9
+
10
+ Leaving the baseline in this state makes future rubocop improvements invisible — new offenses are buried in baseline noise, and contributors can't see whether their PR is clean vs. baseline-burdened.
11
+
12
+ ## Goal
13
+
14
+ Reduce baseline count to < 1000 offenses via per-namespace PRs:
15
+
16
+ | Namespace | Approximate count | Priority |
17
+ |-----------|-------------------|----------|
18
+ | `lib/fontisan/tables/` | ~3000 | High (well-understood) |
19
+ | `lib/fontisan/ufo/compile/` | ~2500 | High |
20
+ | `lib/fontisan/stitcher/` | ~800 | Medium |
21
+ | `lib/fontisan/converters/` | ~500 | Medium |
22
+ | `lib/fontisan/type1/` | ~400 | Medium |
23
+ | `lib/fontisan/collection/` | ~300 | Medium |
24
+ | `lib/fontisan/subset/` | ~300 | Medium (recent PR #106 work) |
25
+ | `lib/fontisan/woff2/` | ~250 | Medium |
26
+ | `lib/fontisan/optimizers/` | ~200 | Low |
27
+ | `lib/fontisan/pipeline/` | ~200 | Low |
28
+ | `lib/fontisan/commands/` | ~150 | Low |
29
+ | `lib/fontisan/validators/` | ~100 | Low |
30
+ | `lib/fontisan/binary/` | ~100 | Low |
31
+ | `spec/` | ~1500 | Low |
32
+
33
+ ## Approach
34
+
35
+ One PR per namespace. Each PR:
36
+
37
+ 1. Runs `rubocop -A lib/fontisan/<namespace>/` to auto-correct everything possible.
38
+ 2. Manually fixes what auto-correct couldn't (typically: complex Layout cops, naming).
39
+ 3. Updates `.rubocop_todo.yml` to drop the now-cleaned `Exclude:` entries.
40
+ 4. Verifies the full test suite still passes.
41
+
42
+ Order: tables first (highest count, lowest risk — table parsing is purely structural).
43
+
44
+ ## Out of scope
45
+
46
+ - Disabling cops project-wide — these are real issues, not noise.
47
+ - Refactoring architectural patterns rubocop flags (e.g., long methods) — separate refactors.
48
+
49
+ ## Effort
50
+
51
+ ~30 minutes per namespace on average. ~7 hours total.
52
+
53
+ ## Dependencies
54
+
55
+ None.
56
+
57
+ ## Acceptance criteria
58
+
59
+ - `.rubocop_todo.yml` < 100 lines (from ~800).
60
+ - `bundle exec rubocop` reports 0 offenses.
61
+ - All existing specs pass after each namespace PR.
62
+ - No behavior change (pure style/layout).
@@ -0,0 +1,57 @@
1
+ # Fontisan Improvements Backlog
2
+
3
+ This directory tracks all known improvement work, ordered by priority.
4
+ Each file is `NN-short-name.md` where `NN` is the priority order.
5
+
6
+ ## Priorities
7
+
8
+ ### P0 — Correctness gaps (open bugs / silent failures)
9
+ - [01 — CBDT/CBLC GID-stable propagation](01-cbdt-cblc-gid-stable-propagation.md)
10
+ - [02 — Collection-mode outline-priority regression](02-collection-outline-priority.md)
11
+
12
+ ### P1 — High-value features
13
+ - [03 — `fontisan audit` command (identity+style+features lens)](03-fontisan-audit-command.md)
14
+ - [04 — UFO composite glyph encoding](04-ufo-composite-glyph-encoding.md)
15
+ - [05 — OTF compiler real CFF charstrings](05-otf-compiler-real-cff.md)
16
+
17
+ ### P2 — Specialist feature parity
18
+ - [x] ~~[07 — CPAL v1 header fields](07-cpal-v1-header-fields.md)~~ ✓ Done
19
+ - [06 — CFF2 blend/vsindex operators](06-cff2-blend-vsindex-operators.md)
20
+ - [08 — CFF standard string table](08-cff-standard-string-table.md)
21
+ - [09 — Type 1 seac expansion](09-type1-seac-expansion.md)
22
+ - [10 — UFO image set + feature writers](10-ufo-image-set-feature-writers.md)
23
+ - [11 — kern groups.plist support](11-kern-groups-plist.md)
24
+
25
+ ### P3 — Code-quality cleanup
26
+ - [x] ~~[13 — Split `OctokitFetcher` out of `fixture_downloader.rb`](13-split-octokit-fetcher.md)~~ ✓ Done (commit `4c68f2a`)
27
+ - [12 — `cbdt_fixture.rb` full BinData conversion](12-cbdt-fixture-bindata-conversion.md)
28
+ - [14 — Rubocop baseline chip (per-namespace)](14-rubocop-baseline-chip.md)
29
+
30
+ ## Convention
31
+
32
+ Each TODO file follows this template:
33
+
34
+ ```markdown
35
+ # NN — Title
36
+
37
+ ## Priority
38
+ P0 / P1 / P2 / P3
39
+
40
+ ## Problem
41
+ What's broken or missing. Concrete examples.
42
+
43
+ ## Goal
44
+ What good looks like. Acceptance criteria.
45
+
46
+ ## Approach
47
+ Architecture-level sketch. Files to add/change. Constraints.
48
+
49
+ ## Out of scope
50
+ Explicit non-goals.
51
+
52
+ ## Effort
53
+ Rough estimate (hours/days).
54
+
55
+ ## Dependencies
56
+ Other TODOs that must land first.
57
+ ```
@@ -24,10 +24,17 @@ module FontisanBench
24
24
  g.width = 500
25
25
  g.add_unicode(0xE000 + i) if i < 0x1000
26
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"),
27
+ Fontisan::Ufo::Point.new(x: 0,
28
+ y: 0, type: "line"),
29
+ Fontisan::Ufo::Point.new(
30
+ x: 100, y: 0, type: "line",
31
+ ),
32
+ Fontisan::Ufo::Point.new(
33
+ x: 100, y: 100, type: "offcurve",
34
+ ),
35
+ Fontisan::Ufo::Point.new(
36
+ x: 50, y: 150, type: "curve",
37
+ ),
31
38
  ]))
32
39
  font.glyphs["g#{i}"] = g
33
40
  end
@@ -38,7 +45,8 @@ module FontisanBench
38
45
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
39
46
  yield
40
47
  elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start
41
- puts format("%-40<label>s %8.<elapsed>.2f ms", label: label, elapsed: elapsed * 1000)
48
+ puts format("%-40<label>s %8.<elapsed>.2f ms", label: label,
49
+ elapsed: elapsed * 1000)
42
50
  elapsed
43
51
  end
44
52
  end
@@ -322,6 +322,7 @@ export default defineConfig({
322
322
  { text: "ConversionOptions", link: "/api/conversion-options" },
323
323
  { text: "SfntFont", link: "/api/sfnt-font" },
324
324
  { text: "Type1Font", link: "/api/type1-font" },
325
+ { text: "Ufo::Layer", link: "/api/layer" },
325
326
  ],
326
327
  },
327
328
  {
@@ -364,12 +364,56 @@ stitcher.write_to("out.ttf", format: :ttf, subfont: :main)
364
364
  # → out.ttf has glyf + CBDT + CBLC, emoji renders correctly
365
365
  ----
366
366
 
367
- Constraints:
367
+ === Outline-first cmap priority
368
+
369
+ When a codepoint is covered by BOTH an outline donor and the CBDT
370
+ donor, the cmap maps it to the outline glyph — never to the CBDT
371
+ placeholder. The Cmap compiler follows first-wins semantics, and
372
+ `GlyphCopier` runs before `CbdtPropagator` so the outline GID is
373
+ registered first. This was the fix for the Emoticons cmap-loss bug
374
+ where shared codepoints silently dropped out of the cmap.
375
+
376
+ === Placeholder name deconfliction
377
+
378
+ CBDT placeholders are named after the source's glyph IDs
379
+ (`"gid1"`, `"gid2"`, …), which is the SAME naming scheme
380
+ `Source#extract_truetype_glyph` uses for outline glyphs from TTF
381
+ donors. Without protection, a placeholder at `"gid110"` would
382
+ silently overwrite an outline glyph at `"gid110"` from a different
383
+ donor via `Layer#add` — the original cmap-loss vector for the
384
+ Essenfont CJK Ext G regression.
385
+
386
+ The Stitcher resolves this with two pieces:
387
+
388
+ * `Ufo::Layer#add` RAISES `Ufo::GlyphExistsError` on name conflict.
389
+ Silent overwrites are no longer possible. Callers that want to
390
+ replace an existing glyph use the explicit `Ufo::Layer#put`.
391
+ * `Stitcher::UniqueGlyphName.in(target, base)` allocates a
392
+ non-colliding name (`"gid110"`, then `"gid110.1"`, `"gid110.2"`,
393
+ …). `GlyphCopier` and `CbdtPropagator` both call it before every
394
+ `Layer#add`, so each glyph lands at its own GID slot without
395
+ touching the others.
396
+
397
+ === Limitations
368
398
 
369
399
  * Only ONE CBDT source per Stitcher. Multiple CBDT sources raise
370
400
  `Fontisan::MultipleCbdtSourcesError`.
371
- * The CBDT source is processed first (GIDs 0..N-1) so the CBLC's GID
372
- references remain valid in the output.
401
+ * **GID stability for overlapping codepoint ranges.** The raw-bytes
402
+ CBDT/CBLC propagation (`CbdtPropagator#propagate_tables_into`)
403
+ copies the source's CBLC verbatim. CBLC indexes glyphs by SOURCE
404
+ GID. After placeholder renaming (`"gid110"` → `"gid110.1"`), the
405
+ compiled GID no longer matches the source GID, so CBLC's source-
406
+ GID-indexed bitmaps can dangle. This is masked in the typical
407
+ Essenfont case (CBDT donor NotoColorEmoji and outline donors
408
+ cover disjoint codepoint ranges) but would produce wrong bitmaps
409
+ for any stitch where CBDT and outline sources share codepoint
410
+ ranges. A proper fix requires a CBLC rebuild pass that walks the
411
+ compiled cmap to remap source GIDs to compiled GIDs — tracked as
412
+ follow-up.
413
+ * **Collection-mode single-face coverage.** Each face is compiled
414
+ independently; `CbdtPropagator` currently requires at least one
415
+ outline codepoint per face. Collection-mode coverage of the
416
+ outline-first cmap-priority invariant is tracked as follow-up.
373
417
 
374
418
  == CLI
375
419
 
data/docs/api/layer.md ADDED
@@ -0,0 +1,89 @@
1
+ ---
2
+ title: Ufo::Layer
3
+ ---
4
+
5
+ # `Ufo::Layer`
6
+
7
+ A single layer in a UFO source. A `Layer` holds a set of glyphs keyed by name. The default layer is `public.default` per UFO 3.
8
+
9
+ ```ruby
10
+ layer = Fontisan::Ufo::Layer.new # => "public.default"
11
+ layer.add(Fontisan::Ufo::Glyph.new(name: "A"))
12
+ layer["A"] # => the glyph
13
+ layer.size # => 1
14
+ ```
15
+
16
+ ## Naming contract
17
+
18
+ Glyph names are the layer's primary key. Two distinct glyphs with the same name cannot coexist — adding the second would silently destroy the first, a class of bug that has historically cost real data (e.g. the CJK Ext G cmap loss when CBDT placeholders collided with outline glyphs sharing `"gid{N}"` names).
19
+
20
+ To make that contract unbreakable, the API exposes two methods with named semantics:
21
+
22
+ | Method | Behavior on conflict |
23
+ |--------|----------------------|
24
+ | `Layer#add(glyph)` | **Raises** `Ufo::GlyphExistsError` (the safe default) |
25
+ | `Layer#put(glyph)` | Overwrites the existing glyph (explicit replace) |
26
+
27
+ Callers that need auto-renaming — insert without giving up on a collision — call `Stitcher::UniqueGlyphName.in(target, base)` first to allocate a free name, then `Layer#add` the glyph under that name.
28
+
29
+ ## API
30
+
31
+ ### `Layer.new(name = "public.default")`
32
+
33
+ Initialize an empty layer.
34
+
35
+ ### `#add(glyph) -> Glyph`
36
+
37
+ Insert `glyph`. Raises `Ufo::GlyphExistsError` if a glyph with the same name is already present.
38
+
39
+ ```ruby
40
+ layer = Fontisan::Ufo::Layer.new
41
+ layer.add(Fontisan::Ufo::Glyph.new(name: "A"))
42
+
43
+ layer.add(Fontisan::Ufo::Glyph.new(name: "A"))
44
+ # => raises Fontisan::Ufo::GlyphExistsError: a glyph named "A" already exists
45
+ ```
46
+
47
+ The original glyph is preserved when the second `add` raises — the layer is never partially mutated.
48
+
49
+ ### `#put(glyph) -> Glyph`
50
+
51
+ Insert `glyph`, replacing any existing glyph with the same name. Use this when the caller has positively decided the previous glyph (if any) should be discarded.
52
+
53
+ ```ruby
54
+ layer.put(Fontisan::Ufo::Glyph.new(name: "A")) # overwrites the prior "A"
55
+ ```
56
+
57
+ ### `#[](glyph_name) -> Glyph, nil`
58
+
59
+ Look up a glyph by name.
60
+
61
+ ### `#each { |glyph| ... }`
62
+
63
+ Iterate over glyphs (no ordering guarantee).
64
+
65
+ ### `#size -> Integer`
66
+
67
+ Number of glyphs in the layer.
68
+
69
+ ## `Ufo::GlyphExistsError`
70
+
71
+ Raised by `Layer#add` on a name conflict. Carries the colliding name so callers can branch programmatically:
72
+
73
+ ```ruby
74
+ begin
75
+ layer.add(glyph)
76
+ rescue Fontisan::Ufo::GlyphExistsError => e
77
+ puts "name #{e.name.inspect} already taken"
78
+ # Decide: layer.put(glyph) to overwrite, or
79
+ # unique = Fontisan::Stitcher::UniqueGlyphName.in(target, e.name)
80
+ # layer.add(glyph.dup_as(name: unique))
81
+ end
82
+ ```
83
+
84
+ The error class is a sibling of `Layer` (not nested inside it) so the namespace stays flat — both `Ufo::Layer` and `Ufo::GlyphExistsError` live directly under `Fontisan::Ufo`.
85
+
86
+ ## See also
87
+
88
+ - `docs/STITCHER_GUIDE.adoc` — `UniqueGlyphName`, `GlyphCopier`, and `CbdtPropagator` all build on the Layer naming contract.
89
+ - `docs/UFO_COMPILATION.adoc` — how layers feed the TTF/OTF/CFF2 compilers.
data/docs/cli/subset.md CHANGED
@@ -18,8 +18,23 @@ fontisan subset <font> [options]
18
18
  |--------|-------------|
19
19
  | `--chars TEXT` | Characters to include |
20
20
  | `--file FILE` | File containing characters |
21
+ | `--unicodes RANGE` | Unicode ranges (`"U+0000-007F"`) |
22
+ | `--glyphs LIST` | Glyph names/IDs |
21
23
  | `--output FILE` | Output file path |
22
- | `--format FORMAT` | Output format |
24
+ | `--format FORMAT` | Output format (`ttf`, `woff`, `woff2`, …) |
25
+ | `--profile PROFILE` | Subsetting profile (default: `pdf`) |
26
+ | `--retain-gids` | Preserve original glyph IDs |
27
+
28
+ ## Profiles
29
+
30
+ | Profile | Tables included | Use case |
31
+ |---------|-----------------|----------|
32
+ | `pdf` | core + glyf/loca | PDF embedding (smallest) |
33
+ | `web` | core + glyf/loca + GSUB/GPOS + **CBDT/CBLC** | Web fonts that retain color-emoji bitmaps |
34
+ | `minimal` | core only | Smallest valid font |
35
+ | `full` | every standard table | Lossless re-export |
36
+
37
+ The `web` profile is the one to reach for when the subset must still render color emoji in a browser: it preserves the CBDT (bitmap data) and CBLC (bitmap location) tables, and the paired subsetter rewrites them to keep only the bitmaps for the retained glyphs.
23
38
 
24
39
  ## Examples
25
40
 
@@ -27,13 +42,17 @@ fontisan subset <font> [options]
27
42
  # Subset to specific characters
28
43
  fontisan subset font.ttf --chars "ABCDEF" --output subset.ttf
29
44
 
30
- # Subset from file
31
- fontisan subset font.ttf --file chars.txt --output subset.ttf
45
+ # Web subset that retains color-emoji bitmaps
46
+ fontisan subset NotoColorEmoji.ttf \
47
+ --chars "😀😁😂🤣😊" \
48
+ --profile web \
49
+ --format woff2 \
50
+ --output emoji.woff2
32
51
 
33
- # Subset and convert format
34
- fontisan subset font.ttf --chars "Hello" --format woff2
52
+ # PDF embedding profile (drops CBDT/CBLC, smaller output)
53
+ fontisan subset font.ttf --chars "ABC" --profile pdf --output pdf-subset.ttf
35
54
  ```
36
55
 
37
56
  ## Detailed Documentation
38
57
 
39
- For comprehensive documentation including unicode ranges and advanced subsetting, see the [subset command guide](/guide/cli/subset).
58
+ For comprehensive documentation including unicode ranges, profile selection, and CBDT/CBLC behavior, see the [subset command guide](/guide/cli/subset).
@@ -28,6 +28,7 @@ fontisan subset FONT [options]
28
28
  | `--glyphs LIST` | Glyph names/IDs |
29
29
  | `--output PATH` | Output file |
30
30
  | `--output-format FORMAT` | Output format |
31
+ | `--profile PROFILE` | Subsetting profile (`pdf`, `web`, `minimal`, `full`; default `pdf`) |
31
32
  | `--retain-gids` | Retain glyph IDs |
32
33
 
33
34
  ## Examples
@@ -86,6 +87,36 @@ fontisan subset font.ttf --chars "ABC" --output-format woff2 --output subset.wof
86
87
 
87
88
  ## Subsetting Strategies
88
89
 
90
+ ### Profile selection
91
+
92
+ The `--profile` option controls which font tables the subset retains. The right choice depends on what the subset is for:
93
+
94
+ | Profile | When to use | Notable tables dropped |
95
+ |---------|-------------|------------------------|
96
+ | `pdf` (default) | PDF embedding, smallest valid output | GSUB, GPOS, CBDT, CBLC |
97
+ | `web` | Browser/web font, **retains color-emoji bitmaps** | nothing that browsers need |
98
+ | `minimal` | Absolute smallest core that still renders | glyf/loca, CBDT/CBLC, GSUB/GPOS |
99
+ | `full` | Lossless re-export | (none) |
100
+
101
+ ```bash
102
+ # Web subset that keeps color-emoji bitmaps so 😀 renders in color
103
+ fontisan subset NotoColorEmoji.ttf \
104
+ --chars "😀😁😂🤣😊" \
105
+ --profile web \
106
+ --output-format woff2 \
107
+ --output emoji.woff2
108
+
109
+ # PDF embedding — drops color bitmaps for size
110
+ fontisan subset NotoColorEmoji.ttf \
111
+ --chars "😀" \
112
+ --profile pdf \
113
+ --output emoji-pdf.ttf
114
+ ```
115
+
116
+ #### Web profile and CBDT/CBLC
117
+
118
+ The `web` profile is the only built-in profile that retains CBDT (Color Bitmap Data) and CBLC (Color Bitmap Location) tables. The subsetter walks CBLC to find each retained glyph's bitmap block, rewrites CBDT to keep only those blocks, and rebuilds CBLC's IndexSubTableArray to point at the new offsets. Glyph IDs are remapped through the subset's `GlyphMapping`, so the output CBLC references subset GIDs, not source GIDs.
119
+
89
120
  ### Web Font Optimization
90
121
 
91
122
  ```bash
@@ -144,6 +144,23 @@ fontisan convert bitmap-font.ttf --to ttf --output bitmap-font.ttf
144
144
  fontisan convert bitmap-font.ttf --to ttf --no-bitmaps --output outline-only.ttf
145
145
  ```
146
146
 
147
+ ### Subsetting color-emoji fonts
148
+
149
+ The `web` subsetting profile retains CBDT and CBLC tables and rewrites them to keep only the bitmaps for retained glyphs. Use it when subsetting a color-emoji source for browser delivery:
150
+
151
+ ```bash
152
+ # Keep only 😀 + 😁 bitmaps; output WOFF2 for web
153
+ fontisan subset NotoColorEmoji.ttf \
154
+ --chars "😀😁" \
155
+ --profile web \
156
+ --output-format woff2 \
157
+ --output emoji-subset.woff2
158
+ ```
159
+
160
+ Other profiles (`pdf`, `minimal`, `full`) drop CBDT/CBLC — pick them when the subset doesn't need color-emoji rendering (e.g. PDF embedding where the subsetter is just selecting glyphs for outlines).
161
+
162
+ See the [subset CLI docs](/cli/subset) and `docs/STITCHER_GUIDE.adoc` (CBDT/CBLC passthrough section) for the full story, including the GID-stability caveat when CBDT and outline donors cover overlapping codepoint ranges in stitch mode.
163
+
147
164
  ## Comparison
148
165
 
149
166
  | Feature | sbix | CBDT/CBLC |
data/lib/fontisan/cli.rb CHANGED
@@ -360,7 +360,8 @@ module Fontisan
360
360
 
361
361
  # Handle --show-options
362
362
  if options[:show_options]
363
- show_recommended_options(source_format, options[:to].is_a?(Array) ? options[:to].first : options[:to])
363
+ show_recommended_options(source_format,
364
+ options[:to].is_a?(Array) ? options[:to].first : options[:to])
364
365
  return
365
366
  end
366
367
 
@@ -95,7 +95,8 @@ module Fontisan
95
95
  #
96
96
  # @return [String] TTC header binary
97
97
  def write_ttc_header
98
- [TTC_TAG, VERSION_1_0_MAJOR, VERSION_1_0_MINOR, @fonts.size].pack("a4nnN")
98
+ [TTC_TAG, VERSION_1_0_MAJOR, VERSION_1_0_MINOR,
99
+ @fonts.size].pack("a4nnN")
99
100
  end
100
101
 
101
102
  # Write offset table
@@ -81,7 +81,13 @@ module Fontisan
81
81
  Check.new(
82
82
  name: :glyph_cap,
83
83
  passed: over.empty?,
84
- message: over.empty? ? nil : "faces over cap: #{over.map { |s| "##{s.index}=#{s.glyph_count}" }.join(', ')}",
84
+ message: if over.empty?
85
+ nil
86
+ else
87
+ "faces over cap: #{over.map do |s|
88
+ "##{s.index}=#{s.glyph_count}"
89
+ end.join(', ')}"
90
+ end,
85
91
  )
86
92
  end
87
93
 
@@ -21,7 +21,8 @@ module Fontisan
21
21
  autoload :TablesCommand, "fontisan/commands/tables_command"
22
22
  autoload :UnicodeCommand, "fontisan/commands/unicode_command"
23
23
  autoload :UnpackCommand, "fontisan/commands/unpack_command"
24
- autoload :ValidateCollectionCommand, "fontisan/commands/validate_collection_command"
24
+ autoload :ValidateCollectionCommand,
25
+ "fontisan/commands/validate_collection_command"
25
26
  autoload :ValidateCommand, "fontisan/commands/validate_command"
26
27
  autoload :VariableCommand, "fontisan/commands/variable_command"
27
28
  end
@@ -173,7 +173,12 @@ module Fontisan
173
173
  io.read(8) # signature + flavor
174
174
  io.rewind
175
175
  flavor = File.binread(path, 4, 4)&.unpack1("N")
176
- flavor == Woff2::CollectionDecoder::TTC_FLAVOR ? :woff2_collection : (raise Error, "Not a WOFF2 collection: #{path}")
176
+ if flavor == Woff2::CollectionDecoder::TTC_FLAVOR
177
+ :woff2_collection
178
+ else
179
+ (raise Error,
180
+ "Not a WOFF2 collection: #{path}")
181
+ end
177
182
  elsif Parsers::DfontParser.dfont?(io)
178
183
  :dfont
179
184
  else
@@ -192,7 +192,9 @@ palette_index: 0)
192
192
  def build_glyph_to_codepoints(cmap)
193
193
  return {} unless cmap
194
194
 
195
- cmap.unicode_mappings.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, gid), h|
195
+ cmap.unicode_mappings.each_with_object(Hash.new do |h, k|
196
+ h[k] = []
197
+ end) do |(cp, gid), h|
196
198
  h[gid] << cp
197
199
  end
198
200
  rescue StandardError => e
@@ -9,7 +9,8 @@ module Fontisan
9
9
 
10
10
  # Return value of {Stitcher#write_collection}. Carries the output path,
11
11
  # total bytes, and one {SubfontStats} per declared subfont.
12
- CollectionResult = Struct.new(:path, :bytes, :subfonts, keyword_init: true) do
12
+ CollectionResult = Struct.new(:path, :bytes, :subfonts,
13
+ keyword_init: true) do
13
14
  def face_count
14
15
  subfonts.size
15
16
  end
@@ -89,7 +89,8 @@ module Fontisan
89
89
  bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
90
90
  end
91
91
 
92
- def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
92
+ def copy_glyph_into(target_font, name:, source:, donor_gid:,
93
+ codepoint: nil)
93
94
  original = source.glyph_for_gid(donor_gid)
94
95
  return unless original
95
96
 
@@ -403,7 +403,9 @@ module Fontisan
403
403
  private
404
404
 
405
405
  def group_by_block(cp_map)
406
- cp_map.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(cp, label), h|
406
+ cp_map.each_with_object(Hash.new do |h, k|
407
+ h[k] = []
408
+ end) do |(cp, label), h|
407
409
  block_label = find_block_label(cp) || :other
408
410
  h[block_label] << [cp, label]
409
411
  end