fontisan 0.2.23 → 0.3.1

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.adoc +100 -0
  3. data/lib/fontisan/cli.rb +6 -0
  4. data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
  5. data/lib/fontisan/stitcher/selector/gid.rb +25 -0
  6. data/lib/fontisan/stitcher/selector/range.rb +30 -0
  7. data/lib/fontisan/stitcher/selector.rb +26 -0
  8. data/lib/fontisan/stitcher/source.rb +97 -0
  9. data/lib/fontisan/stitcher.rb +182 -0
  10. data/lib/fontisan/stitcher_cli.rb +69 -0
  11. data/lib/fontisan/ufo/anchor.rb +17 -0
  12. data/lib/fontisan/ufo/cli.rb +85 -0
  13. data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
  14. data/lib/fontisan/ufo/compile/cff.rb +225 -0
  15. data/lib/fontisan/ufo/compile/cmap.rb +129 -0
  16. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
  17. data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
  18. data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
  19. data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
  20. data/lib/fontisan/ufo/compile/filters.rb +57 -0
  21. data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
  22. data/lib/fontisan/ufo/compile/head.rb +98 -0
  23. data/lib/fontisan/ufo/compile/hhea.rb +36 -0
  24. data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
  25. data/lib/fontisan/ufo/compile/maxp.rb +57 -0
  26. data/lib/fontisan/ufo/compile/name.rb +79 -0
  27. data/lib/fontisan/ufo/compile/os2.rb +81 -0
  28. data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
  29. data/lib/fontisan/ufo/compile/post.rb +32 -0
  30. data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
  31. data/lib/fontisan/ufo/compile.rb +48 -0
  32. data/lib/fontisan/ufo/component.rb +18 -0
  33. data/lib/fontisan/ufo/contour.rb +29 -0
  34. data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
  35. data/lib/fontisan/ufo/convert.rb +18 -0
  36. data/lib/fontisan/ufo/data_set.rb +21 -0
  37. data/lib/fontisan/ufo/features.rb +17 -0
  38. data/lib/fontisan/ufo/font.rb +61 -0
  39. data/lib/fontisan/ufo/glyph.rb +421 -0
  40. data/lib/fontisan/ufo/guideline.rb +19 -0
  41. data/lib/fontisan/ufo/image.rb +16 -0
  42. data/lib/fontisan/ufo/image_set.rb +19 -0
  43. data/lib/fontisan/ufo/info.rb +79 -0
  44. data/lib/fontisan/ufo/kerning.rb +32 -0
  45. data/lib/fontisan/ufo/layer.rb +38 -0
  46. data/lib/fontisan/ufo/layer_set.rb +37 -0
  47. data/lib/fontisan/ufo/lib.rb +24 -0
  48. data/lib/fontisan/ufo/plist.rb +118 -0
  49. data/lib/fontisan/ufo/point.rb +38 -0
  50. data/lib/fontisan/ufo/reader.rb +144 -0
  51. data/lib/fontisan/ufo/transformation.rb +39 -0
  52. data/lib/fontisan/ufo/writer.rb +115 -0
  53. data/lib/fontisan/ufo.rb +44 -0
  54. data/lib/fontisan/version.rb +1 -1
  55. data/lib/fontisan.rb +3 -0
  56. metadata +52 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f84b59fae400f9db9dd05b2ddebce11bedb865138026f7a3a6d1b7ce0b3d8f2a
4
- data.tar.gz: 563b27ed9dc91412fb83d7769a72a0d618109e3a6394236c7648524a60ad4fb6
3
+ metadata.gz: '0979321b74749068da214fbb4880701c1f78905cce2db2aa7a8a2263da669af3'
4
+ data.tar.gz: 3ccbb49464b62e831475fe601fecc9b7085c6dda5a16ec66d9b9c56c14ff0742
5
5
  SHA512:
6
- metadata.gz: 7f2d94e40b9444414113ebf842a4a84e2188199829b4caf8b9e707bea6bca89c07b5e11fa04ba2f4aff1ce7ea658004385a5e2ae2ae4b4d4094d5bb90c4d5345
7
- data.tar.gz: becf5ccba115f938caeb23d29e3cc0685edc9e0cb8452f8b1ed8b7e76cc8e742adcf3f9d1f5c8b1f01c1631fcb28a4ff2305068ce6b9677ef339d8f5fb4765b9
6
+ metadata.gz: 87538383c69009f3610778a717ae6263accb950e07b8803914358927bfc1e7510c18c664d88267b17dcabbeaab4fa97d4ac72b3294ae492472d0659843dbe540
7
+ data.tar.gz: 459343281dd3b295141eed4c8091ccc3d9ce6228d49c293eb6a182d26676428eaf4cb571295b8f55ac4101ba88d879c7525b06f5ad1596a83ea544d2f88a8351
data/README.adoc CHANGED
@@ -2376,6 +2376,106 @@ Fontisan can:
2376
2376
  * Importing and generation PNG block ruler layers
2377
2377
 
2378
2378
 
2379
+ == UFO source operations
2380
+
2381
+ 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.
2384
+
2385
+ === Build a UFO to binary
2386
+
2387
+ [source,bash]
2388
+ ----
2389
+ # TTF (TrueType outlines)
2390
+ fontisan ufo build font.ufo --output out.ttf --to ttf
2391
+
2392
+ # OTF (CFF outlines)
2393
+ fontisan ufo build font.ufo --output out.otf --to otf
2394
+ ----
2395
+
2396
+ The TTF compiler automatically applies:
2397
+ - `cubic_to_quadratic` filter (UFO cubic Beziers -> TTF quadratic)
2398
+ - `reverse_contour_direction` filter (TTF winding convention)
2399
+
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
2421
+
2422
+ [source,ruby]
2423
+ ----
2424
+ require "fontisan"
2425
+
2426
+ # Read a UFO
2427
+ font = Fontisan::Ufo::Font.open("font.ufo")
2428
+ puts font.family_name
2429
+ puts font.glyphs.size
2430
+
2431
+ # Compile to TTF
2432
+ Fontisan::Ufo::Compile::TtfCompiler.new(font).compile(output_path: "out.ttf")
2433
+
2434
+ # Compile to OTF
2435
+ Fontisan::Ufo::Compile::OtfCompiler.new(font).compile(output_path: "out.otf")
2436
+
2437
+ # Write back to UFO (round-trip)
2438
+ Fontisan::Ufo::Writer.new(font).write("out.ufo")
2439
+
2440
+ # Convert a binary font to UFO
2441
+ ttf = Fontisan::FontLoader.load("font.ttf")
2442
+ ufo = Fontisan::Ufo::Convert::FromBinData.convert(ttf)
2443
+ ----
2444
+
2445
+ == Font stitching
2446
+
2447
+ The Stitcher combines glyphs from multiple source fonts into one new
2448
+ font. Sources can be UFO directories or loaded TTF/OTF files.
2449
+
2450
+ === Ruby API
2451
+
2452
+ [source,ruby]
2453
+ ----
2454
+ stitcher = Fontisan::Stitcher.new
2455
+ stitcher.add_source(:latin, Fontisan::FontLoader.load("NotoSans.ttf"))
2456
+ stitcher.add_source(:cjk, Fontisan::FontLoader.load("FSung-m.ttf"))
2457
+
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)
2461
+
2462
+ stitcher.write_to("stitched.ttf", format: :ttf)
2463
+ ----
2464
+
2465
+ === CLI
2466
+
2467
+ [source,bash]
2468
+ ----
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
2476
+ ----
2477
+
2478
+
2379
2479
  == Testing
2380
2480
 
2381
2481
  === General
data/lib/fontisan/cli.rb CHANGED
@@ -28,6 +28,12 @@ module Fontisan
28
28
  desc "cldr", "Manage local CLDR cache (subcommands)", hide: true
29
29
  subcommand "cldr", CldrCli
30
30
 
31
+ desc "ufo", "UFO source operations (build, convert, validate)"
32
+ subcommand "ufo", Fontisan::Ufo::Cli
33
+
34
+ desc "stitch", "Stitch glyphs from multiple source fonts into one output"
35
+ subcommand "stitch", StitcherCli
36
+
31
37
  desc "info PATH", "Display font information"
32
38
  option :brief, type: :boolean, default: false,
33
39
  desc: "Brief mode - only essential info (5x faster, uses metadata loading)",
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module Selector
6
+ # Include an explicit list of codepoints.
7
+ class Codepoints
8
+ attr_reader :codepoints
9
+
10
+ def initialize(codepoints)
11
+ @codepoints = codepoints
12
+ end
13
+
14
+ def apply(source, bindings)
15
+ @codepoints.each do |cp|
16
+ gid = source.gid_for_codepoint(cp)
17
+ next unless gid
18
+
19
+ bindings << {
20
+ codepoint: cp,
21
+ source: source,
22
+ donor_gid: gid,
23
+ }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module Selector
6
+ # Include a single glyph by its donor gid. Used for unencoded
7
+ # glyphs (.notdef, spaces, format-specific specials).
8
+ class Gid
9
+ attr_reader :gid
10
+
11
+ def initialize(gid)
12
+ @gid = gid
13
+ end
14
+
15
+ def apply(source, bindings)
16
+ bindings << {
17
+ codepoint: nil,
18
+ source: source,
19
+ donor_gid: @gid,
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ module Selector
6
+ # Include every codepoint in a Range (e.g. 0x41..0x5A = A-Z).
7
+ # Glyphs missing from the source are silently skipped.
8
+ class Range
9
+ attr_reader :range
10
+
11
+ def initialize(range)
12
+ @range = range
13
+ end
14
+
15
+ def apply(source, bindings)
16
+ @range.each do |cp|
17
+ gid = source.gid_for_codepoint(cp)
18
+ next unless gid
19
+
20
+ bindings << {
21
+ codepoint: cp,
22
+ source: source,
23
+ donor_gid: gid,
24
+ }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Selectors decide which glyphs from a source to include in the
6
+ # stitched font. Each selector appends to the Stitcher's bindings
7
+ # list. OCP: adding a new way to select = adding a new Selector
8
+ # class + a registry entry.
9
+ module Selector
10
+ autoload :Range, "fontisan/stitcher/selector/range"
11
+ autoload :Codepoints, "fontisan/stitcher/selector/codepoints"
12
+ autoload :Gid, "fontisan/stitcher/selector/gid"
13
+
14
+ REGISTRY = {
15
+ range: Range,
16
+ codepoints: Codepoints,
17
+ gid: Gid,
18
+ }.freeze
19
+
20
+ def self.resolve(name)
21
+ REGISTRY[name.to_sym] or
22
+ raise ArgumentError, "unknown selector: #{name.inspect}"
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ class Stitcher
5
+ # Wraps a source font (UFO or loaded TTF/OTF) behind a single
6
+ # extraction API used by the selectors.
7
+ #
8
+ # For UFO sources, glyphs are accessed by name directly. For TTF
9
+ # or OTF sources, the source is lazily converted to a UFO::Font
10
+ # via Ufo::Convert::FromBinData on first glyph access, then cached.
11
+ # This is O(n) in donor glyph count but amortized across all
12
+ # codepoint extractions from that donor.
13
+ class Source
14
+ attr_reader :font
15
+
16
+ def initialize(font)
17
+ @font = font
18
+ @ufo_cache = nil
19
+ end
20
+
21
+ # @return [Symbol] :ufo, :ttf, :otf
22
+ def format
23
+ case @font
24
+ when Fontisan::Ufo::Font then :ufo
25
+ when Fontisan::TrueTypeFont then :ttf
26
+ when Fontisan::OpenTypeFont then :otf
27
+ else :unknown
28
+ end
29
+ end
30
+
31
+ # Find the gid for a Unicode codepoint in this source.
32
+ # @param codepoint [Integer]
33
+ # @return [Integer, nil]
34
+ def gid_for_codepoint(codepoint)
35
+ case @font
36
+ when Fontisan::Ufo::Font then ufo_gid_for(codepoint)
37
+ else bin_data_gid_for(codepoint)
38
+ end
39
+ end
40
+
41
+ # Extract a glyph by gid.
42
+ # @param gid [Integer]
43
+ # @return [Fontisan::Ufo::Glyph, nil]
44
+ def glyph_for_gid(gid)
45
+ case @font
46
+ when Fontisan::Ufo::Font then ufo_glyph_at(gid)
47
+ else converted_ufo_glyph_at(gid)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ # ---------- UFO source ----------
54
+
55
+ def ufo_gid_for(codepoint)
56
+ @font.glyphs.each_with_index do |(_name, glyph), index|
57
+ return index if glyph.unicodes.include?(codepoint)
58
+ end
59
+ nil
60
+ end
61
+
62
+ def ufo_glyph_at(gid)
63
+ names = @font.glyphs.keys
64
+ name = names[gid]
65
+ return nil unless name
66
+
67
+ @font.glyph(name)
68
+ end
69
+
70
+ # ---------- TTF/OTF source ----------
71
+
72
+ def bin_data_gid_for(codepoint)
73
+ cmap = @font.table("cmap")
74
+ return nil unless cmap
75
+
76
+ cmap.unicode_mappings[codepoint]
77
+ end
78
+
79
+ # Lazily convert the loaded TTF/OTF to a UFO::Font, then
80
+ # extract glyphs from the cached UFO model.
81
+ def converted_ufo
82
+ return @ufo_cache if @ufo_cache
83
+
84
+ @ufo_cache = Fontisan::Ufo::Convert::FromBinData.convert(@font)
85
+ end
86
+
87
+ def converted_ufo_glyph_at(gid)
88
+ ufo = converted_ufo
89
+ names = ufo.glyphs.keys
90
+ name = names[gid]
91
+ return nil unless name
92
+
93
+ ufo.glyph(name)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ # Multi-source font stitcher. Combines glyphs from one or more
5
+ # source fonts (UFO or loaded TTF/OTF) into a single new font.
6
+ #
7
+ # The Stitcher builds a Fontisan::Ufo::Font from selected glyphs,
8
+ # then delegates compilation to the existing TtfCompiler or
9
+ # OtfCompiler. Single source of truth: one compiler pipeline,
10
+ # whether the input is one UFO or many sources.
11
+ #
12
+ # @example Stitch ASCII from one UFO, Hiragana from another
13
+ # stitcher = Fontisan::Stitcher.new
14
+ # stitcher.add_source(:latin, Fontisan::Ufo::Font.open("latin.ufo"))
15
+ # stitcher.add_source(:jp, Fontisan::Ufo::Font.open("jp.ufo"))
16
+ # stitcher.include_range(0x41..0x5A, from: :latin)
17
+ # stitcher.include_range(0x3040..0x309F, from: :jp)
18
+ # stitcher.write_to("stitched.ttf", format: :ttf)
19
+ class Stitcher
20
+ autoload :Source, "fontisan/stitcher/source"
21
+ autoload :Selector, "fontisan/stitcher/selector"
22
+
23
+ attr_reader :sources, :bindings
24
+
25
+ def initialize
26
+ @sources = {}
27
+ @bindings = []
28
+ @target = Ufo::Font.new
29
+ end
30
+
31
+ # Register a named source font.
32
+ # @param label [Symbol, String] name to reference this source by
33
+ # @param font [Fontisan::Ufo::Font, Fontisan::SfntFont] the source
34
+ def add_source(label, font)
35
+ @sources[label.to_sym] = Source.new(font)
36
+ end
37
+
38
+ # Include all codepoints in a range from a named source.
39
+ # @param range [Range<Integer>] codepoint range
40
+ # @param from [Symbol, String] source label
41
+ def include_range(range, from:)
42
+ Selector::Range.new(range).apply(source(from), @bindings)
43
+ end
44
+
45
+ # Include an explicit list of codepoints.
46
+ # @param codepoints [Array<Integer>]
47
+ # @param from [Symbol, String] source label
48
+ def include_codepoints(codepoints, from:)
49
+ Selector::Codepoints.new(codepoints).apply(source(from), @bindings)
50
+ end
51
+
52
+ # Include a single glyph by donor gid (rare; for unencoded glyphs
53
+ # like .notdef).
54
+ # @param donor_gid [Integer]
55
+ # @param from [Symbol, String] source label
56
+ def include_gid(donor_gid, from:)
57
+ Selector::Gid.new(donor_gid).apply(source(from), @bindings)
58
+ end
59
+
60
+ # Always include .notdef from a named source.
61
+ # @param from [Symbol, String] source label
62
+ def include_notdef(from:)
63
+ include_gid(0, from: from)
64
+ end
65
+
66
+ # Set font-wide metadata on the stitched font.
67
+ # @param info_hash [Hash] any subset of Fontisan::Ufo::Info fields
68
+ def set_info(info_hash)
69
+ @target.info = Ufo::Info.new(info_hash)
70
+ end
71
+
72
+ # Write the stitched font to disk.
73
+ # @param path [String] output file path
74
+ # @param format [Symbol] :ttf or :otf
75
+ def write_to(path, format: :ttf)
76
+ build_target_font
77
+ compiler = compiler_for(format)
78
+ compiler.new(@target).compile(output_path: path)
79
+ path
80
+ end
81
+
82
+ # Build the internal UFO::Font from the current bindings. Useful
83
+ # for testing or for further manipulation before writing.
84
+ # @return [Fontisan::Ufo::Font]
85
+ def build_target_font
86
+ @target = Ufo::Font.new
87
+ assign_gids_and_copy_glyphs
88
+ @target
89
+ end
90
+
91
+ private
92
+
93
+ def source(label)
94
+ @sources.fetch(label.to_sym) do
95
+ raise ArgumentError, "unknown source: #{label.inspect}"
96
+ end
97
+ end
98
+
99
+ def compiler_for(format)
100
+ case format.to_sym
101
+ when :ttf then Ufo::Compile::TtfCompiler
102
+ when :otf then Ufo::Compile::OtfCompiler
103
+ else
104
+ raise ArgumentError, "unknown format: #{format.inspect}"
105
+ end
106
+ end
107
+
108
+ # Walk bindings in codepoint order, assign sequential new gids,
109
+ # copy each glyph into the target font's default layer.
110
+ def assign_gids_and_copy_glyphs
111
+ # Always put .notdef at gid 0 first.
112
+ notdef_binding = @bindings.find { |b| b[:donor_gid].zero? }
113
+ if notdef_binding
114
+ copy_glyph_into(@target, name: ".notdef",
115
+ source: notdef_binding[:source],
116
+ donor_gid: 0)
117
+ else
118
+ # Synthesize an empty .notdef
119
+ @target.layers.default_layer.add(Ufo::Glyph.new(name: ".notdef"))
120
+ end
121
+
122
+ sorted_bindings.each do |binding|
123
+ next if binding[:donor_gid].zero? # already handled
124
+
125
+ glyph = binding[:source].glyph_for_gid(binding[:donor_gid])
126
+ next unless glyph
127
+
128
+ # If multiple codepoints map to the same glyph, only the first
129
+ # binding creates the glyph; subsequent ones add unicode entries.
130
+ if @target.glyphs.key?(glyph.name)
131
+ add_extra_unicode(glyph.name, binding[:codepoint])
132
+ else
133
+ copy_glyph_into(@target, name: glyph.name,
134
+ source: binding[:source],
135
+ donor_gid: binding[:donor_gid],
136
+ codepoint: binding[:codepoint])
137
+ end
138
+ end
139
+ end
140
+
141
+ # Bindings sorted by codepoint (nil codepoints come last).
142
+ def sorted_bindings
143
+ @bindings.sort_by { |b| [b[:codepoint] || Float::INFINITY, b[:donor_gid]] }
144
+ end
145
+
146
+ def copy_glyph_into(target_font, name:, source:, donor_gid:, codepoint: nil)
147
+ original = source.glyph_for_gid(donor_gid)
148
+ return unless original
149
+
150
+ copy = clone_glyph(original, name: name)
151
+ copy.add_unicode(codepoint) if codepoint
152
+ target_font.layers.default_layer.add(copy)
153
+ end
154
+
155
+ def add_extra_unicode(glyph_name, codepoint)
156
+ return unless codepoint
157
+
158
+ glyph = @target.glyph(glyph_name)
159
+ glyph.add_unicode(codepoint) unless glyph.unicodes.include?(codepoint)
160
+ end
161
+
162
+ # Deep-copy a glyph with a new name. Used so multiple target
163
+ # glyphs can share the same source outline without aliasing.
164
+ def clone_glyph(original, name:)
165
+ copy = Ufo::Glyph.new(name: name)
166
+ copy.width = original.width
167
+ copy.height = original.height
168
+ original.contours.each { |c| copy.add_contour(clone_contour(c)) }
169
+ original.components.each { |c| copy.add_component(c) }
170
+ original.anchors.each { |a| copy.add_anchor(a) }
171
+ original.guidelines.each { |g| copy.add_guideline(g) }
172
+ copy
173
+ end
174
+
175
+ def clone_contour(original)
176
+ points = original.points.map do |p|
177
+ Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
178
+ end
179
+ Ufo::Contour.new(points)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Fontisan
6
+ # CLI subcommand for multi-source font stitching.
7
+ #
8
+ # fontisan stitch --source latin=PATH --source jp=PATH \
9
+ # --output out.ttf \
10
+ # --include-range latin=0x41-0x5A \
11
+ # --include-range jp=0x3040-0x309F
12
+ class StitcherCli < Thor
13
+ desc "stitch", "Stitch glyphs from multiple sources into one font"
14
+ method_option :source, type: :array, required: true,
15
+ desc: "Named source (name=path); repeatable"
16
+ method_option :include_range, type: :array, default: [],
17
+ desc: "Range to include (name=hex-hex); repeatable"
18
+ method_option :include_codepoints, type: :array, default: [],
19
+ desc: "Codepoint list (name=hex,hex,...); repeatable"
20
+ method_option :notdef_from, type: :string,
21
+ desc: "Source label to take .notdef from"
22
+ method_option :output, type: :string, required: true,
23
+ desc: "Output file path"
24
+ method_option :to, type: :string, default: "ttf",
25
+ desc: "Output format (ttf or otf)"
26
+
27
+ def stitch
28
+ stitcher = Stitcher.new
29
+
30
+ options[:source].each do |spec|
31
+ label, path = spec.split("=", 2)
32
+ font = load_source(path)
33
+ stitcher.add_source(label, font)
34
+ end
35
+
36
+ options[:include_range]&.each do |spec|
37
+ label, range_str = spec.split("=", 2)
38
+ lo, hi = parse_range(range_str)
39
+ stitcher.include_range(lo..hi, from: label)
40
+ end
41
+
42
+ options[:include_codepoints]&.each do |spec|
43
+ label, cps_str = spec.split("=", 2)
44
+ cps = cps_str.split(",").map { |h| Integer(h) }
45
+ stitcher.include_codepoints(cps, from: label)
46
+ end
47
+
48
+ stitcher.include_notdef(from: options[:notdef_from]) if options[:notdef_from]
49
+
50
+ stitcher.write_to(options[:output], format: options[:to].to_sym)
51
+ puts "wrote #{options[:output]} (#{File.size(options[:output])} bytes)"
52
+ end
53
+
54
+ private
55
+
56
+ def load_source(path)
57
+ if File.directory?(path) && File.exist?(File.join(path, "fontinfo.plist"))
58
+ Ufo::Font.open(path)
59
+ else
60
+ FontLoader.load(path)
61
+ end
62
+ end
63
+
64
+ def parse_range(str)
65
+ lo, hi = str.split("-", 2)
66
+ [Integer(lo), Integer(hi)]
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ # A mark-attachment anchor on a glyph (used in GSUB/GPOS).
6
+ class Anchor
7
+ attr_reader :x, :y, :name, :identifier
8
+
9
+ def initialize(x:, y:, name: nil, identifier: nil)
10
+ @x = x
11
+ @y = y
12
+ @name = name
13
+ @identifier = identifier
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Fontisan
6
+ module Ufo
7
+ # CLI subcommand for UFO source operations.
8
+ #
9
+ # fontisan ufo build font.ufo --output out.ttf [--format otf]
10
+ # fontisan ufo convert font.ttf font.ufo
11
+ # fontisan ufo validate font.ufo
12
+ class Cli < Thor
13
+ desc "build UFO", "Compile a UFO source to a binary font"
14
+ method_option :output, type: :string, required: true,
15
+ desc: "Output file path"
16
+ method_option :to, type: :string, default: "ttf",
17
+ desc: "Output format (ttf or otf)"
18
+ def build(ufo)
19
+ font = Font.open(ufo)
20
+ format_sym = (options[:to] || "ttf").to_s.downcase.to_sym
21
+ compiler =
22
+ case format_sym
23
+ when :ttf then Compile::TtfCompiler
24
+ when :otf then Compile::OtfCompiler
25
+ else
26
+ warn "unknown format: #{options[:to].inspect}"
27
+ exit 1
28
+ end
29
+ compiler.new(font).compile(output_path: options[:output])
30
+ puts "wrote #{options[:output]} (#{File.size(options[:output])} bytes)"
31
+ rescue Errno::ENOENT
32
+ warn "UFO not found: #{ufo}"
33
+ exit 1
34
+ end
35
+
36
+ desc "convert INPUT OUTPUT", "Convert between UFO and binary formats"
37
+ method_option :to, type: :string,
38
+ desc: "Override format detection (ttf, otf, ufo)"
39
+ def convert(input, output)
40
+ if ufo?(input)
41
+ font = Font.open(input)
42
+ format = options[:to] || File.extname(output).delete(".").downcase
43
+ compiler =
44
+ case format.to_sym
45
+ when :ttf then Compile::TtfCompiler
46
+ when :otf then Compile::OtfCompiler
47
+ else
48
+ warn "unsupported output format: #{format.inspect}"
49
+ exit 1
50
+ end
51
+ compiler.new(font).compile(output_path: output)
52
+ else
53
+ # Binary → UFO
54
+ loaded = Fontisan::FontLoader.load(input)
55
+ ufo = Convert::FromBinData.convert(loaded)
56
+ Writer.new(ufo).write(output)
57
+ end
58
+ puts "wrote #{output}"
59
+ end
60
+
61
+ desc "validate UFO", "Check a UFO source for structural issues"
62
+ def validate(ufo)
63
+ font = Font.open(ufo)
64
+ issues = []
65
+ issues << "no glyphs in default layer" if font.glyphs.empty?
66
+ issues << "no family name" unless font.info.family_name
67
+ issues << "unitsPerEm not set" unless font.info.units_per_em
68
+ issues << "missing .notdef glyph" unless font.glyph(".notdef")
69
+
70
+ if issues.empty?
71
+ puts "OK #{ufo}"
72
+ else
73
+ issues.each { |i| warn "FAIL #{i}" }
74
+ exit 1
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def ufo?(path)
81
+ File.directory?(path) && File.exist?(File.join(path, "fontinfo.plist"))
82
+ end
83
+ end
84
+ end
85
+ end