fontisan 0.2.23 → 0.3.0

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/fontisan/cli.rb +6 -0
  3. data/lib/fontisan/stitcher/selector/codepoints.rb +29 -0
  4. data/lib/fontisan/stitcher/selector/gid.rb +25 -0
  5. data/lib/fontisan/stitcher/selector/range.rb +30 -0
  6. data/lib/fontisan/stitcher/selector.rb +26 -0
  7. data/lib/fontisan/stitcher/source.rb +97 -0
  8. data/lib/fontisan/stitcher.rb +182 -0
  9. data/lib/fontisan/stitcher_cli.rb +69 -0
  10. data/lib/fontisan/ufo/anchor.rb +17 -0
  11. data/lib/fontisan/ufo/cli.rb +85 -0
  12. data/lib/fontisan/ufo/compile/base_compiler.rb +81 -0
  13. data/lib/fontisan/ufo/compile/cff.rb +224 -0
  14. data/lib/fontisan/ufo/compile/cmap.rb +129 -0
  15. data/lib/fontisan/ufo/compile/filters/cubic_to_quadratic.rb +174 -0
  16. data/lib/fontisan/ufo/compile/filters/decompose_components.rb +33 -0
  17. data/lib/fontisan/ufo/compile/filters/flatten_components.rb +22 -0
  18. data/lib/fontisan/ufo/compile/filters/reverse_contour_direction.rb +27 -0
  19. data/lib/fontisan/ufo/compile/filters.rb +57 -0
  20. data/lib/fontisan/ufo/compile/glyf_loca.rb +145 -0
  21. data/lib/fontisan/ufo/compile/head.rb +98 -0
  22. data/lib/fontisan/ufo/compile/hhea.rb +36 -0
  23. data/lib/fontisan/ufo/compile/hmtx.rb +27 -0
  24. data/lib/fontisan/ufo/compile/maxp.rb +57 -0
  25. data/lib/fontisan/ufo/compile/name.rb +79 -0
  26. data/lib/fontisan/ufo/compile/os2.rb +81 -0
  27. data/lib/fontisan/ufo/compile/otf_compiler.rb +43 -0
  28. data/lib/fontisan/ufo/compile/post.rb +32 -0
  29. data/lib/fontisan/ufo/compile/ttf_compiler.rb +69 -0
  30. data/lib/fontisan/ufo/compile.rb +48 -0
  31. data/lib/fontisan/ufo/component.rb +18 -0
  32. data/lib/fontisan/ufo/contour.rb +29 -0
  33. data/lib/fontisan/ufo/convert/from_bin_data.rb +246 -0
  34. data/lib/fontisan/ufo/convert.rb +18 -0
  35. data/lib/fontisan/ufo/data_set.rb +21 -0
  36. data/lib/fontisan/ufo/features.rb +17 -0
  37. data/lib/fontisan/ufo/font.rb +61 -0
  38. data/lib/fontisan/ufo/glyph.rb +421 -0
  39. data/lib/fontisan/ufo/guideline.rb +19 -0
  40. data/lib/fontisan/ufo/image.rb +16 -0
  41. data/lib/fontisan/ufo/image_set.rb +19 -0
  42. data/lib/fontisan/ufo/info.rb +79 -0
  43. data/lib/fontisan/ufo/kerning.rb +32 -0
  44. data/lib/fontisan/ufo/layer.rb +38 -0
  45. data/lib/fontisan/ufo/layer_set.rb +37 -0
  46. data/lib/fontisan/ufo/lib.rb +24 -0
  47. data/lib/fontisan/ufo/plist.rb +118 -0
  48. data/lib/fontisan/ufo/point.rb +38 -0
  49. data/lib/fontisan/ufo/reader.rb +144 -0
  50. data/lib/fontisan/ufo/transformation.rb +39 -0
  51. data/lib/fontisan/ufo/writer.rb +115 -0
  52. data/lib/fontisan/ufo.rb +44 -0
  53. data/lib/fontisan/version.rb +1 -1
  54. data/lib/fontisan.rb +3 -0
  55. metadata +51 -1
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the TrueType `glyf` + `loca` tables. Each glyph's
7
+ # outline is delta-encoded into the glyf table; loca is the
8
+ # offset index.
9
+ #
10
+ # For OTF output this module is NOT used — CFF takes its place.
11
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/glyf
12
+ module GlyfLoca
13
+ # Flag bits (simple glyph)
14
+ FLAG_ON_CURVE = 0x01
15
+ FLAG_X_SHORT = 0x02
16
+ FLAG_Y_SHORT = 0x04
17
+ FLAG_REPEAT = 0x08
18
+ FLAG_X_IS_POSITIVE = 0x10 # only meaningful if FLAG_X_SHORT
19
+ FLAG_Y_IS_POSITIVE = 0x20 # only meaningful if FLAG_Y_SHORT
20
+ FLAG_OVERLAP_SIMPLE = 0x40
21
+
22
+ # @param _font [Fontisan::Ufo::Font]
23
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
24
+ # @return [Hash<String, String>] {"glyf" => bytes, "loca" => bytes}
25
+ def self.build(_font, glyphs:)
26
+ glyf_bytes = +""
27
+ offsets = [0]
28
+
29
+ glyphs.each do |glyph|
30
+ glyf_bytes << encode_glyph(glyph)
31
+ glyf_bytes << "\x00" while glyf_bytes.bytesize.odd? # 2-byte align
32
+ offsets << glyf_bytes.bytesize
33
+ end
34
+
35
+ # Choose loca format based on the largest offset.
36
+ use_long = offsets.max > 0x1FFFE # 2 × uint16 max
37
+ loca_bytes =
38
+ if use_long
39
+ offsets.pack("N*")
40
+ else
41
+ offsets.map { |o| o / 2 }.pack("n*")
42
+ end
43
+
44
+ { "glyf" => glyf_bytes, "loca" => loca_bytes, :loca_format => use_long ? 1 : 0 }
45
+ end
46
+
47
+ # Encode a single glyph into glyf bytes. Empty glyphs (no
48
+ # contours, no components) produce zero bytes per spec.
49
+ def self.encode_glyph(glyph)
50
+ return "" if glyph.contours.empty? && glyph.components.empty?
51
+ return encode_composite(glyph) if glyph.composite?
52
+
53
+ encode_simple(glyph)
54
+ end
55
+
56
+ # Encode a simple (non-composite) glyph.
57
+ def self.encode_simple(glyph)
58
+ bbox = glyph.bbox
59
+ header = [
60
+ glyph.contours.size, # numberOfContours (int16)
61
+ bbox.x_min.to_i, bbox.y_min.to_i,
62
+ bbox.x_max.to_i, bbox.y_max.to_i
63
+ ].pack("nnnnn")
64
+ # NB: pack 'n' for int16 truncates negatives to unsigned 16-bit
65
+ # two's complement — which is what OpenType wants.
66
+
67
+ # endPtsOfContours (uint16[numContours])
68
+ end_points = +""
69
+ point_count = 0
70
+ glyph.contours.each do |contour|
71
+ point_count += contour.points.size
72
+ end_points << [point_count - 1].pack("n")
73
+ end
74
+
75
+ # instructions: empty for MVP
76
+ instructions = [0].pack("n")
77
+
78
+ flags, x_bytes, y_bytes = encode_points(glyph.contours)
79
+
80
+ header + end_points + instructions + flags + x_bytes + y_bytes
81
+ end
82
+
83
+ # Encode all points across contours into (flags, x_bytes, y_bytes).
84
+ # Per OpenType spec:
85
+ # - SHORT bit set + POSITIVE bit set → 1-byte positive value
86
+ # - SHORT bit set + POSITIVE bit clear → 1-byte negative value
87
+ # - SHORT bit clear + POSITIVE bit set → delta is 0 (no bytes)
88
+ # - SHORT bit clear + POSITIVE bit clear → 2-byte signed value
89
+ def self.encode_points(contours)
90
+ flags = +""
91
+ x_bytes = +""
92
+ y_bytes = +""
93
+
94
+ prev_x = 0
95
+ prev_y = 0
96
+ contours.flat_map(&:points).each do |point|
97
+ dx = point.x.to_i - prev_x
98
+ dy = point.y.to_i - prev_y
99
+
100
+ flag = point.on_curve? ? FLAG_ON_CURVE : 0
101
+
102
+ # X coordinate
103
+ if dx.zero?
104
+ flag |= FLAG_X_IS_POSITIVE # "same as previous" — no bytes
105
+ elsif dx.between?(-255, 255)
106
+ flag |= FLAG_X_SHORT
107
+ flag |= FLAG_X_IS_POSITIVE if dx.positive?
108
+ x_bytes << [dx.abs].pack("C")
109
+ else
110
+ # SHORT not set, POSITIVE not set → 2-byte signed
111
+ x_bytes << [dx & 0xFFFF].pack("n")
112
+ end
113
+
114
+ # Y coordinate
115
+ if dy.zero?
116
+ flag |= FLAG_Y_IS_POSITIVE # "same as previous" — no bytes
117
+ elsif dy.between?(-255, 255)
118
+ flag |= FLAG_Y_SHORT
119
+ flag |= FLAG_Y_IS_POSITIVE if dy.positive?
120
+ y_bytes << [dy.abs].pack("C")
121
+ else
122
+ y_bytes << [dy & 0xFFFF].pack("n")
123
+ end
124
+
125
+ flags << [flag].pack("C")
126
+ prev_x = point.x.to_i
127
+ prev_y = point.y.to_i
128
+ end
129
+
130
+ [flags, x_bytes, y_bytes]
131
+ end
132
+
133
+ # Composite glyph encoding (component references with optional
134
+ # transformations). Out of scope for MVP — emits empty bytes.
135
+ def self.encode_composite(_glyph)
136
+ # TODO.full/07: implement composite glyph encoding when the
137
+ # spec needs it. For now, return empty (the glyph renders as
138
+ # nothing).
139
+ ""
140
+ end
141
+ private_class_method :encode_glyph, :encode_simple, :encode_points, :encode_composite
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `head` table from a UFO Font.
7
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/head
8
+ module Head
9
+ MAGIC_NUMBER = 0x5F0F3CF5
10
+ DEFAULT_FLAGS = 0x000B
11
+ DEFAULT_LOWEST_REC_PPEM = 8
12
+ DEFAULT_FONT_DIRECTION_HINT = 2
13
+ DEFAULT_GLYPH_DATA_FORMAT = 0
14
+ LOCA_FORMAT_SHORT = 0
15
+ LOCA_FORMAT_LONG = 1
16
+ MAC_STYLE_REGULAR = 0
17
+ # Seconds between 1904-01-01 (Mac epoch) and 1970-01-01 (Unix epoch)
18
+ MAC_EPOCH_OFFSET = 2_082_844_800
19
+
20
+ # @param font [Fontisan::Ufo::Font]
21
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] (unused here;
22
+ # bbox is computed by the caller for performance)
23
+ # @return [Fontisan::Tables::Head]
24
+ def self.build(font, glyphs:, units_per_em: nil, loca_format: LOCA_FORMAT_LONG)
25
+ bbox = font_bbox(font, glyphs)
26
+ Fontisan::Tables::Head.new(
27
+ version_raw: 0x00010000,
28
+ font_revision_raw: font_revision_fixed(font),
29
+ checksum_adjustment: 0, # patched by FontWriter
30
+ magic_number: MAGIC_NUMBER,
31
+ flags: DEFAULT_FLAGS,
32
+ units_per_em: units_per_em || font.info.units_per_em || 1000,
33
+ created_raw: time_to_longdatetime(font.info.created),
34
+ modified_raw: time_to_longdatetime(font.info.modified),
35
+ x_min: bbox[:x_min].to_i,
36
+ y_min: bbox[:y_min].to_i,
37
+ x_max: bbox[:x_max].to_i,
38
+ y_max: bbox[:y_max].to_i,
39
+ mac_style: MAC_STYLE_REGULAR,
40
+ lowest_rec_ppem: DEFAULT_LOWEST_REC_PPEM,
41
+ font_direction_hint: DEFAULT_FONT_DIRECTION_HINT,
42
+ index_to_loc_format: loca_format,
43
+ glyph_data_format: DEFAULT_GLYPH_DATA_FORMAT,
44
+ )
45
+ end
46
+
47
+ # Union bbox of every glyph's outline.
48
+ def self.font_bbox(_font, glyphs)
49
+ return { x_min: 0, y_min: 0, x_max: 0, y_max: 0 } if glyphs.empty?
50
+
51
+ bboxes = glyphs.filter_map(&:bbox)
52
+ return { x_min: 0, y_min: 0, x_max: 0, y_max: 0 } if bboxes.empty?
53
+
54
+ {
55
+ x_min: bboxes.map(&:x_min).min,
56
+ y_min: bboxes.map(&:y_min).min,
57
+ x_max: bboxes.map(&:x_max).max,
58
+ y_max: bboxes.map(&:y_max).max,
59
+ }
60
+ end
61
+ private_class_method :font_bbox
62
+
63
+ # Parse the UFO version string ("1.0" or "Version 1.0") into
64
+ # a 16.16 fixed-point int. Defaults to 1.0 on parse failure.
65
+ def self.font_revision_fixed(font)
66
+ version = [font.info.version_major, font.info.version_minor].compact
67
+ return 0x00010000 if version.empty?
68
+
69
+ major = font.info.version_major || 1
70
+ minor = font.info.version_minor || 0
71
+ ((major & 0xFFFF) << 16) | (minor & 0xFFFF)
72
+ end
73
+ private_class_method :font_revision_fixed
74
+
75
+ # UFO 3 stores `openTypeHeadCreated` as "YYYY/MM/DD HH:MM:SS".
76
+ # OpenType head.created/modified are LONGDATETIME seconds
77
+ # since 1904-01-01. If the UFO field is absent, use now.
78
+ def self.time_to_longdatetime(value)
79
+ return Time.now.to_i + MAC_EPOCH_OFFSET if value.nil?
80
+
81
+ require "time"
82
+ parsed = nil
83
+ begin
84
+ parsed = Time.iso8601(value.to_s)
85
+ rescue ArgumentError
86
+ begin
87
+ parsed = Time.parse(value.to_s)
88
+ rescue ArgumentError
89
+ parsed = Time.now
90
+ end
91
+ end
92
+ parsed.to_i + MAC_EPOCH_OFFSET
93
+ end
94
+ private_class_method :time_to_longdatetime
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `hhea` (horizontal header) table.
7
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/hhea
8
+ module Hhea
9
+ VERSION_1_0 = 0x00010000
10
+
11
+ # @param font [Fontisan::Ufo::Font]
12
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>]
13
+ # @return [Fontisan::Tables::Hhea]
14
+ def self.build(font, glyphs:)
15
+ info = font.info
16
+ widths = glyphs.map { |g| g.width.to_i }
17
+ Fontisan::Tables::Hhea.new(
18
+ version_raw: VERSION_1_0,
19
+ ascent: info.ascender || 800,
20
+ descent: info.descender || -200,
21
+ line_gap: info.open_type_hhea_line_gap || 0,
22
+ advance_width_max: widths.max || 0,
23
+ min_left_side_bearing: 0,
24
+ min_right_side_bearing: 0,
25
+ x_max_extent: widths.max || 0,
26
+ caret_slope_rise: 1,
27
+ caret_slope_run: 0,
28
+ caret_offset: 0,
29
+ metric_data_format: 0,
30
+ number_of_h_metrics: glyphs.size,
31
+ )
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `hmtx` (horizontal metrics) table.
7
+ # One LongHorMetric per glyph (4 bytes each):
8
+ # uint16 advanceWidth, int16 lsb
9
+ # No trailing "leftSideBearing" array (use numberOfHMetrics = numGlyphs).
10
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx
11
+ module Hmtx
12
+ # @param _font [Fontisan::Ufo::Font]
13
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] in gid order
14
+ # @return [String] hmtx table bytes
15
+ def self.build(_font, glyphs:)
16
+ data = +""
17
+ glyphs.each do |glyph|
18
+ bbox = glyph.bbox
19
+ lsb = bbox ? bbox.x_min.to_i : 0
20
+ data << [glyph.width.to_i, lsb].pack("nn")
21
+ end
22
+ data
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `maxp` (maximum profile) table.
7
+ # TrueType (0x00010000) carries 13 metrics; CFF (0x00005000)
8
+ # carries just num_glyphs. We pick the version based on which
9
+ # outline compiler is in use — caller passes `version:`.
10
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/maxp
11
+ module Maxp
12
+ VERSION_TRUE_TYPE = 0x00010000
13
+ VERSION_OPEN_TYPE = 0x00005000
14
+
15
+ # @param _font [Fontisan::Ufo::Font]
16
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>]
17
+ # @param version [Integer] one of VERSION_TRUE_TYPE / VERSION_OPEN_TYPE
18
+ # @return [Fontisan::Tables::Maxp]
19
+ def self.build(_font, glyphs:, version: VERSION_OPEN_TYPE)
20
+ if version == VERSION_TRUE_TYPE
21
+ build_truetype(glyphs)
22
+ else
23
+ Fontisan::Tables::Maxp.new(
24
+ version_raw: VERSION_OPEN_TYPE,
25
+ num_glyphs: glyphs.size,
26
+ )
27
+ end
28
+ end
29
+
30
+ def self.build_truetype(glyphs)
31
+ max_points = glyphs.map(&:point_count).max || 0
32
+ max_contours = glyphs.map { |g| g.contours.size }.max || 0
33
+ max_components = glyphs.map { |g| g.components.size }.max || 0
34
+
35
+ Fontisan::Tables::Maxp.new(
36
+ version_raw: VERSION_TRUE_TYPE,
37
+ num_glyphs: glyphs.size,
38
+ max_points: max_points,
39
+ max_contours: max_contours,
40
+ max_composite_points: max_points,
41
+ max_composite_contours: max_contours,
42
+ max_zones: 2,
43
+ max_twilight_points: 0,
44
+ max_storage: 0,
45
+ max_function_defs: 0,
46
+ max_instruction_defs: 0,
47
+ max_stack_elements: 0,
48
+ max_size_of_instructions: 0,
49
+ max_component_elements: max_components,
50
+ max_component_depth: max_components.positive? ? 1 : 0,
51
+ )
52
+ end
53
+ private_class_method :build_truetype
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `name` table from UFO fontinfo data.
7
+ # Writes Windows-Unicode (platform 3, encoding 1) name records
8
+ # for the standard 6 name IDs (copyright, family, subfamily,
9
+ # unique ID, full name, version, PostScript name).
10
+ #
11
+ # BinData's Tables::Name structure doesn't make construction
12
+ # from a record list easy (it has a custom after_read_hook and
13
+ # a `rest :string_storage`); this builder produces the bytes
14
+ # directly so we don't fight the BinData shape.
15
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/name
16
+ module Name
17
+ PLATFORM_WINDOWS_UNICODE = 3
18
+ ENCODING_WINDOWS_UNICODE_BMP = 1
19
+ LANGUAGE_WINDOWS_ENGLISH_US = 0x0409
20
+
21
+ # @param font [Fontisan::Ufo::Font]
22
+ # @return [String] the name table bytes
23
+ def self.build(font, **_opts)
24
+ records = default_records(font)
25
+ format0_bytes(records)
26
+ end
27
+
28
+ # @param font [Fontisan::Ufo::Font] (used without glyph info)
29
+ # @return [Array<Hash{name_id: Integer, value: String}>]
30
+ def self.default_records(font)
31
+ family = font.info.family_name || "Untitled"
32
+ subfamily = font.info.style_name || "Regular"
33
+ ps_name = font.info.postscript_font_name || "#{family}-#{subfamily}"
34
+ full_name = "#{family} #{subfamily}".strip
35
+ major = font.info.version_major || 0
36
+ minor = font.info.version_minor || 0
37
+ version_str = "Version #{major}.#{minor}"
38
+ unique_id = "#{family}-#{subfamily};#{version_str}"
39
+ copyright = font.info.copyright || ""
40
+
41
+ [
42
+ { name_id: 0, value: copyright }, # copyright
43
+ { name_id: 1, value: family }, # family
44
+ { name_id: 2, value: subfamily }, # subfamily
45
+ { name_id: 3, value: unique_id }, # unique ID
46
+ { name_id: 4, value: full_name }, # full name
47
+ { name_id: 5, value: version_str }, # version
48
+ { name_id: 6, value: ps_name }, # PostScript name
49
+ ]
50
+ end
51
+
52
+ def self.format0_bytes(records)
53
+ # Strings are stored UTF-16BE on disk (Windows Unicode).
54
+ encoded = records.map { |r| r[:value].encode("UTF-16BE").force_encoding("BINARY") }
55
+ storage = encoded.join
56
+ storage_offset = 6 + (records.size * 12)
57
+
58
+ header = [0, records.size, storage_offset].pack("nnn")
59
+ body = +""
60
+ offset = 0
61
+ records.zip(encoded).each do |r, bytes|
62
+ body << [
63
+ PLATFORM_WINDOWS_UNICODE,
64
+ ENCODING_WINDOWS_UNICODE_BMP,
65
+ LANGUAGE_WINDOWS_ENGLISH_US,
66
+ r[:name_id],
67
+ bytes.bytesize,
68
+ offset,
69
+ ].pack("nnnnnn")
70
+ offset += bytes.bytesize
71
+ end
72
+
73
+ header + body + storage
74
+ end
75
+ private_class_method :format0_bytes, :default_records
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `OS/2` table from UFO fontinfo data.
7
+ # Default version is 4 (modern). Most fields are 0 unless the
8
+ # UFO has explicit values.
9
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/os2
10
+ module Os2
11
+ VERSION_DEFAULT = 4
12
+ WEIGHT_REGULAR = 400
13
+ WIDTH_NORMAL = 5
14
+ FS_SELECTION_REGULAR = 0x0040
15
+
16
+ # @param font [Fontisan::Ufo::Font]
17
+ # @param glyphs [Array<Fontisan::Ufo::Glyph>] used to compute
18
+ # usFirstCharIndex/usLastCharIndex + xAvgCharWidth
19
+ # @return [Fontisan::Tables::Os2]
20
+ def self.build(font, glyphs:)
21
+ info = font.info
22
+ cp_min, cp_max = unicode_range(glyphs)
23
+ Fontisan::Tables::Os2.new(
24
+ version: VERSION_DEFAULT,
25
+ x_avg_char_width: avg_advance(glyphs),
26
+ us_weight_class: info.open_type_os2_weight_class || WEIGHT_REGULAR,
27
+ us_width_class: info.open_type_os2_width_class || WIDTH_NORMAL,
28
+ fs_type: 0,
29
+ y_subscript_x_size: 650,
30
+ y_subscript_y_size: 600,
31
+ y_subscript_x_offset: 0,
32
+ y_subscript_y_offset: 75,
33
+ y_superscript_x_size: 650,
34
+ y_superscript_y_size: 600,
35
+ y_superscript_x_offset: 0,
36
+ y_superscript_y_offset: 350,
37
+ y_strikeout_size: 50,
38
+ y_strikeout_position: 300,
39
+ s_family_class: 0,
40
+ panose: Array.new(10, 0),
41
+ ul_unicode_range1: 1, # Basic Latin
42
+ ul_unicode_range2: 0,
43
+ ul_unicode_range3: 0,
44
+ ul_unicode_range4: 0,
45
+ ach_vend_id: "NONE",
46
+ fs_selection: FS_SELECTION_REGULAR,
47
+ us_first_char_index: cp_min,
48
+ us_last_char_index: cp_max,
49
+ s_typo_ascender: info.ascender || 800,
50
+ s_typo_descender: info.descender || -200,
51
+ s_typo_line_gap: info.open_type_hhea_line_gap || 0,
52
+ us_win_ascent: info.ascender || 1000,
53
+ us_win_descent: -(info.descender || -200),
54
+ ul_code_page_range1: 1, # Latin 1
55
+ ul_code_page_range2: 0,
56
+ sx_height: info.x_height || 500,
57
+ s_cap_height: info.cap_height || 700,
58
+ us_default_char: 0,
59
+ us_break_char: 0x20,
60
+ us_max_context: 0,
61
+ )
62
+ end
63
+
64
+ def self.unicode_range(glyphs)
65
+ cps = glyphs.flat_map(&:unicodes).sort
66
+ return [0xFFFF, 0] if cps.empty?
67
+
68
+ [cps.first, cps.last]
69
+ end
70
+ private_class_method :unicode_range
71
+
72
+ def self.avg_advance(glyphs)
73
+ return 0 if glyphs.empty?
74
+
75
+ (glyphs.sum { |g| g.width.to_i } / glyphs.size.to_f).round
76
+ end
77
+ private_class_method :avg_advance
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # UFO → OTF. Uses CFF outlines (instead of TrueType glyf/loca).
7
+ # Maxp version 0.5 (no TrueType metrics); sfnt version OTTO.
8
+ #
9
+ # TODO.full/10: this currently emits a placeholder CFF table
10
+ # that satisfies the OTTO signature but does NOT yet encode
11
+ # real charstrings. Full CFF construction lands when TODO 10
12
+ # ships.
13
+ class OtfCompiler < BaseCompiler
14
+ SFNT_VERSION = SFNT_VERSION_OPEN_TYPE
15
+
16
+ def build_outline_tables
17
+ {
18
+ "CFF " => Cff.build(font, glyphs: font.glyphs.values),
19
+ }
20
+ end
21
+
22
+ def compile(output_path:)
23
+ glyphs = font.glyphs.values
24
+
25
+ tables = {
26
+ "head" => Head.build(font, glyphs: glyphs, loca_format: Head::LOCA_FORMAT_LONG),
27
+ "hhea" => Hhea.build(font, glyphs: glyphs),
28
+ "maxp" => Maxp.build(font, glyphs: glyphs, version: Maxp::VERSION_OPEN_TYPE),
29
+ "OS/2" => Os2.build(font, glyphs: glyphs),
30
+ "name" => Name.build(font),
31
+ "post" => Post.build(font),
32
+ "hmtx" => Hmtx.build(font, glyphs: glyphs),
33
+ "cmap" => Cmap.build(font, glyphs: glyphs),
34
+ "CFF " => Cff.build(font, glyphs: glyphs),
35
+ }
36
+
37
+ write(tables, output_path)
38
+ output_path
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # Builds the OpenType `post` (PostScript name) table.
7
+ # Default version is 3.0 (no per-glyph names; smallest).
8
+ # @see https://learn.microsoft.com/en-us/typography/opentype/spec/post
9
+ module Post
10
+ VERSION_3_0_RAW = 0x00030000
11
+
12
+ # @param _font [Fontisan::Ufo::Font] italic angle + underline
13
+ # read from info, but everything else is zero (no hinting)
14
+ # @return [String] the post table bytes (32 bytes for v3.0)
15
+ def self.build(font, **_opts)
16
+ italic_angle = font.info.italic_angle || 0.0
17
+ [
18
+ VERSION_3_0_RAW, # version 3.0
19
+ (italic_angle * 0x10000).to_i, # italicAngle (Fixed 16.16)
20
+ -100, # underlinePosition (FUnits)
21
+ 50, # underlineThickness
22
+ 0, # isFixedPitch
23
+ 0, # minMemType42
24
+ 0, # maxMemType42
25
+ 0, # minMemType1
26
+ 0, # maxMemType1
27
+ ].pack("NNnnNNNNN")
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fontisan
4
+ module Ufo
5
+ module Compile
6
+ # UFO → TTF. Same tables as the OTF compiler plus glyf + loca
7
+ # (no CFF). Maxp version 1.0 carries the TrueType metrics.
8
+ #
9
+ # Before glyf encoding, the TTF-required filters run:
10
+ # - cubic_to_quadratic (TTF only supports quadratic Beziers)
11
+ # - reverse_contour_direction (TTF winding convention)
12
+ class TtfCompiler < BaseCompiler
13
+ SFNT_VERSION = SFNT_VERSION_TRUE_TYPE
14
+
15
+ def compile(output_path:)
16
+ glyphs = font.glyphs.values
17
+
18
+ # Deep-clone glyphs so filters don't mutate the source UFO.
19
+ filtered = clone_glyphs(glyphs)
20
+ Filters.apply(Filters::TTF_REQUIRED, filtered)
21
+
22
+ glyf_loca = GlyfLoca.build(font, glyphs: filtered)
23
+ loca_format = glyf_loca.delete(:loca_format)
24
+
25
+ tables = {
26
+ "head" => Head.build(font, glyphs: filtered,
27
+ loca_format: loca_format || Head::LOCA_FORMAT_LONG),
28
+ "hhea" => Hhea.build(font, glyphs: filtered),
29
+ "maxp" => Maxp.build(font, glyphs: filtered,
30
+ version: Maxp::VERSION_TRUE_TYPE),
31
+ "OS/2" => Os2.build(font, glyphs: filtered),
32
+ "name" => Name.build(font),
33
+ "post" => Post.build(font),
34
+ "hmtx" => Hmtx.build(font, glyphs: filtered),
35
+ "cmap" => Cmap.build(font, glyphs: filtered),
36
+ "glyf" => glyf_loca["glyf"],
37
+ "loca" => glyf_loca["loca"],
38
+ }
39
+
40
+ write(tables, output_path)
41
+ output_path
42
+ end
43
+
44
+ private
45
+
46
+ def clone_glyphs(glyphs)
47
+ glyphs.map { |g| clone_glyph(g) }
48
+ end
49
+
50
+ def clone_glyph(original)
51
+ copy = Ufo::Glyph.new(name: original.name)
52
+ copy.width = original.width
53
+ copy.height = original.height
54
+ original.unicodes.each { |cp| copy.add_unicode(cp) }
55
+ original.contours.each { |c| copy.add_contour(clone_contour(c)) }
56
+ original.components.each { |c| copy.add_component(c) }
57
+ copy
58
+ end
59
+
60
+ def clone_contour(original)
61
+ points = original.points.map do |p|
62
+ Ufo::Point.new(x: p.x, y: p.y, type: p.type, smooth: p.smooth)
63
+ end
64
+ Ufo::Contour.new(points)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end