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
@@ -0,0 +1,118 @@
1
+ = SVG to Glyph Guide
2
+
3
+ == General
4
+
5
+ `Fontisan::SvgToGlyf` converts SVG path data — as produced by ucode's
6
+ code-chart extraction — into `Ufo::Glyph` objects that feed directly
7
+ into the UFO → TTF/OTF compile pipeline.
8
+
9
+ SvgToGlyf emits cubic Bezier contours (matching UFO/PostScript
10
+ conventions). The cubic-to-quadratic conversion and winding-order
11
+ correction happen later, in `Ufo::Compile::Filters::CubicToQuadratic`
12
+ and `ReverseContourDirection`, when the glyph is compiled to TTF.
13
+
14
+ == API
15
+
16
+ [cols="2,3", options="header"]
17
+ |===
18
+ | Method | Purpose
19
+
20
+ | `SvgToGlyf.convert(path_data, upm:, codepoint:, name:, viewbox:, transform:)`
21
+ | Convert a single SVG path `d` string into a `Ufo::Glyph`
22
+
23
+ | `SvgToGlyf.from_svg_file(file_path, upm:, codepoint:)`
24
+ | Convert one `.svg` file into a `Ufo::Glyph`
25
+
26
+ | `SvgToGlyf.from_directory(dir, upm:)`
27
+ | Convert a directory of `.svg` files into a `Ufo::Font` (one glyph
28
+ per file)
29
+ |===
30
+
31
+ The default `upm` is 1000.
32
+
33
+ == Single path
34
+
35
+ [source,ruby]
36
+ ----
37
+ glyph = Fontisan::SvgToGlyf.convert(
38
+ "M 0 0 L 500 0 L 500 700 L 0 700 Z",
39
+ upm: 1000,
40
+ codepoint: 0x10940,
41
+ )
42
+ # → #<Fontisan::Ufo::Glyph name="uni10940" ...>
43
+ ----
44
+
45
+ == Single file
46
+
47
+ [source,ruby]
48
+ ----
49
+ glyph = Fontisan::SvgToGlyf.from_svg_file("U+10940.svg", upm: 1000)
50
+ ----
51
+
52
+ If the codepoint is not supplied, it is derived from the filename when
53
+ it matches `U+XXXX.svg` or `hexcode.svg`.
54
+
55
+ == Directory → Font
56
+
57
+ For batch conversion of a code-chart directory (one SVG per
58
+ codepoint):
59
+
60
+ [source,ruby]
61
+ ----
62
+ font = Fontisan::SvgToGlyf.from_directory(
63
+ "/tmp/chart-svg/Khitan",
64
+ upm: 1000,
65
+ )
66
+ Fontisan::Ufo::Compile::TtfCompiler.new(font)
67
+ .compile(output_path: "Khitan.ttf")
68
+ ----
69
+
70
+ Or feed it directly to the Stitcher:
71
+
72
+ [source,ruby]
73
+ ----
74
+ stitcher = Fontisan::Stitcher.new
75
+ stitcher.add_source(:khitan, font)
76
+ stitcher.include_range(0x18B00..0x18CFF, from: :khitan, into: :main)
77
+ stitcher.write_to("Khitan.ttf", format: :ttf, subfont: :main)
78
+ ----
79
+
80
+ == SVG subset supported
81
+
82
+ SvgToGlyf handles the subset of SVG that real chart extractions use:
83
+
84
+ * Path commands: `M`, `L`, `H`, `V`, `C`, `S`, `Q`, `T`, `A`, `Z`
85
+ (absolute and lowercase relative variants)
86
+ * `<svg>` `viewBox` and `width`/`height` for coordinate scaling
87
+ * `<g transform="...">` group transforms (matrix, translate, scale,
88
+ rotate)
89
+ * `<path d="...">` elements
90
+
91
+ Unsupported SVG features (filters, masks, embedded raster images) are
92
+ ignored. SvgToGlyf is a vector-only converter — for raster emoji, use
93
+ the CBDT/CBLC passthrough path (see link:STITCHER_GUIDE.adoc[Stitcher Guide]).
94
+
95
+ == Coordinate system
96
+
97
+ SVG uses a y-down coordinate system; UFO uses y-up. SvgToGlyf flips
98
+ the y-axis using the SVG `viewBox` height so glyphs render right-way-up
99
+ in font rendering contexts. When no `viewBox` is present, the
100
+ `<svg>` `width`/`height` attributes are used as a fallback.
101
+
102
+ == Namespace layout
103
+
104
+ [source]
105
+ ----
106
+ Fontisan::SvgToGlyf
107
+ ├── Path # SVG path parser → contour builder
108
+ ├── Geometry # AffineTransform, vector math
109
+ ├── Document # <svg> document parsing, viewBox handling
110
+ └── Assembler # top-level glue: builds Ufo::Glyph / Ufo::Font
111
+ ----
112
+
113
+ == See also
114
+
115
+ * link:UFO_COMPILATION.adoc[UFO Compilation Guide] — compile converted
116
+ glyphs to binary fonts
117
+ * link:STITCHER_GUIDE.adoc[Stitcher Guide] — combine converted fonts
118
+ with other donors
@@ -0,0 +1,119 @@
1
+ = UFO Compilation Guide
2
+
3
+ == General
4
+
5
+ Fontisan compiles UFO (Unified Font Object) v2/v3 sources into binary
6
+ OpenType fonts — TTF, OTF (CFF1), and OTF2 (CFF2) — entirely in pure
7
+ Ruby. No AFDKO or Python fonttools required.
8
+
9
+ All compilers live under `Fontisan::Ufo::Compile` and share a common
10
+ `BaseCompiler` orchestrator that emits the required set of OpenType
11
+ tables (head, hhea, maxp, OS/2, name, post, hmtx, cmap, plus an
12
+ optional GPOS kern table when the source has kerning data). Subclasses
13
+ add format-specific outline tables.
14
+
15
+ == Compilers
16
+
17
+ [cols="1,1,3", options="header"]
18
+ |===
19
+ | Class | Output | Outline format
20
+
21
+ | `Ufo::Compile::TtfCompiler`
22
+ | `.ttf`
23
+ | Quadratic Bezier curves in the `glyf` table
24
+
25
+ | `Ufo::Compile::OtfCompiler`
26
+ | `.otf` (CFF1)
27
+ | Cubic Bezier curves in the `CFF ` table using Type 2 charstrings
28
+
29
+ | `Ufo::Compile::Otf2Compiler`
30
+ | `.otf` (CFF2)
31
+ | Cubic Bezier curves in the `CFF2` table — modern format, supports
32
+ variable fonts via blend/vsindex operators
33
+ |===
34
+
35
+ == Building a single font
36
+
37
+ [source,ruby]
38
+ ----
39
+ require "fontisan"
40
+
41
+ font = Fontisan::Ufo::Font.open("MyFont.ufo")
42
+
43
+ # TrueType
44
+ Fontisan::Ufo::Compile::TtfCompiler.new(font)
45
+ .compile(output_path: "MyFont.ttf")
46
+
47
+ # OpenType / CFF1
48
+ Fontisan::Ufo::Compile::OtfCompiler.new(font)
49
+ .compile(output_path: "MyFont.otf")
50
+
51
+ # OpenType / CFF2 (modern, supports variable fonts)
52
+ Fontisan::Ufo::Compile::Otf2Compiler.new(font)
53
+ .compile(output_path: "MyFont-CFF2.otf")
54
+ ----
55
+
56
+ == TTF filters
57
+
58
+ The TTF compiler applies two UFO glyph filters automatically:
59
+
60
+ 1. `CubicToQuadratic` — converts cubic Bezier control points to
61
+ quadratic. TTF `glyf` only supports quadratic curves; UFO sources
62
+ typically use cubics (matching PostScript/CFF conventions).
63
+ 2. `ReverseContourDirection` — reverses winding order. CFF and TTF
64
+ use opposite fill conventions (CFF: non-zero wind; TTF: even-odd by
65
+ default), so contour direction must be flipped.
66
+
67
+ These filters live under `Ufo::Compile::Filters` and are applied to
68
+ each glyph before serialization to `glyf`.
69
+
70
+ == Tables emitted
71
+
72
+ Every compiler emits this shared set:
73
+
74
+ [cols="1,3", options="header"]
75
+ |===
76
+ | Tag | Purpose
77
+
78
+ | `head` | Font header (units per em, bounding box, timestamps)
79
+ | `hhea` | Horizontal header (ascender, descender, line gap)
80
+ | `maxp` | Maximum profile (glyph count, point/contour limits)
81
+ | `OS/2` | OS/2 and Windows-specific metrics
82
+ | `name` | Name records (family, subfamily, copyright, etc.)
83
+ | `post` | PostScript name table (glyph names)
84
+ | `hmtx` | Horizontal metrics (advance width, left side bearing)
85
+ | `cmap` | Character to glyph mapping
86
+ | `GPOS` | Optional — present only when the UFO has kerning data
87
+ |===
88
+
89
+ Format-specific outline tables:
90
+
91
+ * TTF → `glyf` + `loca`
92
+ * OTF (CFF1) → `CFF `
93
+ * OTF2 (CFF2) → `CFF2`
94
+
95
+ == GPOS kerning
96
+
97
+ If the UFO source includes kerning data (`kerning.plist`), the compiler
98
+ emits a `GPOS` table with a pair-positioning lookup (feature `kern`).
99
+ When no kerning is present, no `GPOS` table is written — the file stays
100
+ smaller.
101
+
102
+ == Round-tripping binary ↔ UFO
103
+
104
+ To go the other way — extract a UFO from an existing binary font:
105
+
106
+ [source,ruby]
107
+ ----
108
+ ttf = Fontisan::FontLoader.load("MyFont.ttf")
109
+ ufo = Fontisan::Ufo::Convert::FromBinData.convert(ttf)
110
+ Fontisan::Ufo::Writer.new(ufo).write("MyFont.ufo")
111
+ ----
112
+
113
+ == See also
114
+
115
+ * link:CFF2_SUPPORT.adoc[CFF2 Support Guide] — variable-font blend
116
+ operators, FDSelect, subroutines, VariationStore
117
+ * 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
@@ -19,6 +19,10 @@ module Fontisan
19
19
  VERSION_1_0_MAJOR = 1
20
20
  VERSION_1_0_MINOR = 0
21
21
 
22
+ # TTC version 2.0 (major=2, minor=0) — supports DSIG reference
23
+ VERSION_2_0_MAJOR = 2
24
+ VERSION_2_0_MINOR = 0
25
+
22
26
  # Initialize writer
23
27
  #
24
28
  # @param fonts [Array<TrueTypeFont, OpenTypeFont>] Source fonts
@@ -91,12 +95,7 @@ module Fontisan
91
95
  #
92
96
  # @return [String] TTC header binary
93
97
  def write_ttc_header
94
- [
95
- TTC_TAG, # char[4] - tag
96
- VERSION_1_0_MAJOR, # uint16 - major version
97
- VERSION_1_0_MINOR, # uint16 - minor version
98
- @fonts.size, # uint32 - number of fonts
99
- ].pack("a4 n n N")
98
+ [TTC_TAG, VERSION_1_0_MAJOR, VERSION_1_0_MINOR, @fonts.size].pack("a4nnN")
100
99
  end
101
100
 
102
101
  # Write offset table
@@ -206,6 +206,37 @@ module Fontisan
206
206
  # CBDT/CBLC across multiple sources requires a dedicated rebuild.
207
207
  class MultipleCbdtSourcesError < Error; end
208
208
 
209
+ # Stitcher produced more glyphs than the output format supports.
210
+ #
211
+ # Raised by Stitcher::GlyphLimit.check! before writing, so the user
212
+ # gets an actionable message instead of a silently truncated font.
213
+ class GlyphLimitExceededError < Error
214
+ attr_reader :actual, :limit, :format
215
+
216
+ def initialize(actual:, limit:, format:)
217
+ @actual = actual
218
+ @limit = limit
219
+ @format = format
220
+ super(build_message)
221
+ end
222
+
223
+ private
224
+
225
+ def build_message
226
+ "Stitcher produced #{actual} unique glyphs, exceeding the " \
227
+ "#{format.to_s.upcase} limit of #{format_limit}. Both TTF and OTF " \
228
+ "(CFF1) cap at 65,535 glyphs — the maxp.num_glyphs field is uint16 " \
229
+ "and the CFF CharStrings INDEX count is card16. Options: " \
230
+ "(1) split the output into a TTC (TrueType Collection) by Unicode plane, " \
231
+ "(2) reduce the number of donors, " \
232
+ "(3) wait for CFF2 support in fontisan (card24 INDEX counts, no glyph cap)."
233
+ end
234
+
235
+ def format_limit
236
+ limit == Float::INFINITY ? "infinity" : limit.to_s
237
+ end
238
+ end
239
+
209
240
  # Variation data corrupted (for use in data_extractor)
210
241
  #
211
242
  # Raised when extracted variation data appears corrupted.
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Registry mapping glyph signatures to canonical glyph names in
6
+ # the target font. Enables signature-based deduplication: when
7
+ # two bindings produce glyphs with identical outlines, they share
8
+ # one gid and the duplicate's codepoint is redirected to the
9
+ # canonical glyph.
10
+ #
11
+ # Replaces the Stitcher's previous name-based dedup with
12
+ # outline-based dedup. This merges visually identical glyphs
13
+ # from different donors even when their names differ, reducing
14
+ # the glyph count below the TrueType 65,535 cap.
15
+ class Deduplicator
16
+ attr_reader :signatures
17
+
18
+ def initialize
19
+ @signatures = {}
20
+ end
21
+
22
+ # Record that `glyph` maps to `canonical_name` in the target.
23
+ # @param glyph [Fontisan::Ufo::Glyph]
24
+ # @param canonical_name [String] the name under which the glyph
25
+ # was added to the target font
26
+ def register(glyph, canonical_name)
27
+ @signatures[GlyphSignature.for(glyph)] = canonical_name
28
+ end
29
+
30
+ # @param glyph [Fontisan::Ufo::Glyph]
31
+ # @return [String, nil] the canonical name if an identical
32
+ # glyph was already registered, nil otherwise
33
+ def find(glyph)
34
+ @signatures[GlyphSignature.for(glyph)]
35
+ end
36
+
37
+ # @return [Integer] number of unique signatures registered
38
+ def size
39
+ @signatures.size
40
+ end
41
+
42
+ def empty?
43
+ @signatures.empty?
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Format-specific glyph-count caps.
6
+ #
7
+ # Both TTF and OTF (CFF1) cap at 65,535 because:
8
+ # - TTF: maxp.num_glyphs is uint16
9
+ # - OTF (CFF1): maxp.num_glyphs is uint16 AND the CFF CharStrings
10
+ # INDEX count is card16
11
+ #
12
+ # Exceeding the cap produces a silently truncated font (the BinData
13
+ # uint16 writer truncates without raising). The Stitcher checks
14
+ # the cap BEFORE writing so the user gets a clear error.
15
+ #
16
+ # To exceed 65,535 glyphs, the font must be split into a TTC
17
+ # (TrueType Collection) or fontisan must implement CFF2 (card24
18
+ # INDEX counts). Both are future work.
19
+ module GlyphLimit
20
+ TTF_GLYPH_CAP = 65_535
21
+ OTF_GLYPH_CAP = 65_535
22
+
23
+ # @param format [Symbol] :ttf or :otf
24
+ # @return [Integer, Float::INFINITY] the max glyph count
25
+ def self.for_format(format)
26
+ case format.to_sym
27
+ when :ttf then TTF_GLYPH_CAP
28
+ when :otf then OTF_GLYPH_CAP
29
+ when :otf2 then OTF_GLYPH_CAP # CFF2 CharStrings count must match maxp.numGlyphs
30
+ else
31
+ raise ArgumentError, "unknown format: #{format.inspect}"
32
+ end
33
+ end
34
+
35
+ # Raise GlyphLimitExceededError if `glyph_count` exceeds the cap
36
+ # for the given format.
37
+ #
38
+ # @param glyph_count [Integer]
39
+ # @param format [Symbol]
40
+ # @raise [GlyphLimitExceededError]
41
+ def self.check!(glyph_count, format:)
42
+ limit = for_format(format)
43
+ return if glyph_count <= limit
44
+
45
+ raise GlyphLimitExceededError.new(
46
+ actual: glyph_count,
47
+ limit: limit,
48
+ format: format,
49
+ )
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Fontisan
6
+ class Stitcher
7
+ # Stateless signature computation for a Ufo::Glyph's visual identity.
8
+ #
9
+ # Two glyphs with the same signature are visually interchangeable
10
+ # and can share a gid in the output font. The signature captures
11
+ # advance width, every contour's points (x, y, type), and every
12
+ # component reference (base glyph + transform presence).
13
+ #
14
+ # Used by Deduplicator to merge identical outlines from different
15
+ # donors, reducing the glyph count below the TrueType 65,535 cap.
16
+ module GlyphSignature
17
+ # @param glyph [Fontisan::Ufo::Glyph]
18
+ # @return [String] SHA-256 hex digest of the glyph's outline identity
19
+ def self.for(glyph)
20
+ Digest::SHA256.hexdigest(canonical_representation(glyph))
21
+ end
22
+
23
+ # Build a deterministic string capturing the glyph's visual identity.
24
+ # The representation is ordered and normalized so that semantically
25
+ # identical glyphs produce byte-identical strings.
26
+ #
27
+ # @param glyph [Fontisan::Ufo::Glyph]
28
+ # @return [String]
29
+ def self.canonical_representation(glyph)
30
+ io = +""
31
+ io << "w:#{glyph.width.to_i};"
32
+
33
+ glyph.contours.each_with_index do |contour, ci|
34
+ io << "c#{ci}:"
35
+ contour.points.each do |pt|
36
+ io << "#{pt.x.to_i},#{pt.y.to_i},#{pt.type};"
37
+ end
38
+ end
39
+
40
+ glyph.components.each_with_index do |comp, i|
41
+ io << "comp#{i}:#{comp.base_glyph}"
42
+ io << ":t" if comp.transformation
43
+ end
44
+
45
+ io
46
+ end
47
+
48
+ private_class_method :canonical_representation
49
+ end
50
+ end
51
+ end