idml 0.2.9 → 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.
@@ -1,226 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fontisan"
4
-
5
- module Idml
6
- module TextEngine
7
- # Reads OpenType/TrueType font metrics via Fontisan. Uses Fontisan's
8
- # FontLoader to open the font file and locate table offsets, then
9
- # parses the head, hhea, hmtx, and cmap tables directly from the
10
- # binary. No ttfunk.
11
- class FontMetrics
12
- @cache = {}
13
-
14
- def self.open(path)
15
- @cache[path] ||= new(path)
16
- end
17
-
18
- def self.clear_cache
19
- @cache.clear
20
- end
21
-
22
- def initialize(path)
23
- @path = path
24
- @font = Fontisan::FontLoader.load(path)
25
- @io = File.open(path, "rb")
26
- parse_head
27
- parse_hhea
28
- parse_hmtx
29
- parse_cmap
30
- @width_cache = {}
31
- end
32
-
33
- attr_reader :units_per_em, :ascent, :descent, :line_gap, :path
34
-
35
- def glyph_width(codepoint)
36
- @width_cache[codepoint] ||=
37
- begin
38
- glyph_id = @code_map[codepoint] || 0
39
- metric = @advance_widths[glyph_id]
40
- metric || @advance_widths.last || 0
41
- end
42
- end
43
-
44
- def kerning_pair(_left_cp, _right_cp)
45
- 0
46
- end
47
-
48
- def measure_text(text, size:)
49
- total = 0
50
- text.each_codepoint { |cp| total += glyph_width(cp) }
51
- total.to_f / units_per_em * size
52
- end
53
-
54
- def postscript_name
55
- @postscript_name ||= parse_name_entry(6) || "Unknown"
56
- end
57
-
58
- def family_name
59
- @family_name ||= parse_name_entry(1) || "Unknown"
60
- end
61
-
62
- def style_name
63
- @style_name ||= parse_name_entry(2) || "Regular"
64
- end
65
-
66
- private
67
-
68
- def find_table(tag)
69
- @font.tables.find { |entry| entry.tag == tag }
70
- end
71
-
72
- def read_table_bytes(tag)
73
- entry = find_table(tag)
74
- return nil unless entry
75
-
76
- @io.seek(entry.offset)
77
- @io.read(entry.table_length)
78
- end
79
-
80
- def parse_head
81
- data = read_table_bytes("head")
82
- return unless data
83
-
84
- @units_per_em = data[18, 2].unpack1("n")
85
- end
86
-
87
- def parse_hhea
88
- data = read_table_bytes("hhea")
89
- return unless data
90
-
91
- @ascent = sign_extend(data[4, 2].unpack1("n"))
92
- @descent = sign_extend(data[6, 2].unpack1("n"))
93
- @line_gap = sign_extend(data[8, 2].unpack1("n"))
94
- @num_hmetrics = data[34, 2].unpack1("n")
95
- end
96
-
97
- def parse_hmtx
98
- data = read_table_bytes("hmtx")
99
- return unless data
100
-
101
- @advance_widths = []
102
- @num_hmetrics.times do |i|
103
- offset = i * 4
104
- @advance_widths[i] = data[offset, 2].unpack1("n")
105
- end
106
- end
107
-
108
- def parse_cmap
109
- data = read_table_bytes("cmap")
110
- return unless data
111
-
112
- _, num_subtables = data[0, 4].unpack("nn")
113
- @code_map = {}
114
-
115
- best_offset = nil
116
- num_subtables.times do |i|
117
- rec_off = 4 + (i * 4)
118
- platform_id, encoding_id, offset = data[rec_off, 8].unpack("nnN")
119
- if platform_id == 3 && encoding_id == 1
120
- best_offset = offset
121
- break
122
- end
123
- best_offset ||= offset if platform_id.zero?
124
- end
125
- return unless best_offset
126
-
127
- parse_cmap_subtable(data, best_offset)
128
- end
129
-
130
- def parse_cmap_subtable(data, offset)
131
- format = data[offset, 2].unpack1("n")
132
- parse_cmap_format4(data, offset) if format == 4
133
- parse_cmap_format0(data, offset) if format.zero?
134
- end
135
-
136
- def sign_extend(uint16)
137
- uint16 >= 32768 ? uint16 - 65536 : uint16
138
- end
139
-
140
- def parse_cmap_format4(data, offset)
141
- seg_count_x2 = data[offset + 6, 2].unpack1("n")
142
- seg_count = seg_count_x2 / 2
143
-
144
- end_start = offset + 14
145
- start_start = end_start + seg_count_x2 + 2
146
- delta_start = start_start + seg_count_x2
147
- range_start = delta_start + seg_count_x2
148
-
149
- seg_count.times do |i|
150
- end_code = data[end_start + (i * 2), 2].unpack1("n")
151
- start_code = data[start_start + (i * 2), 2].unpack1("n")
152
- delta = sign_extend(data[delta_start + (i * 2), 2].unpack1("n"))
153
- range_off = data[range_start + (i * 2), 2].unpack1("n")
154
-
155
- (start_code..end_code).each do |cp|
156
- if range_off.zero?
157
- glyph_id = (cp + delta) & 0xFFFF
158
- else
159
- idx = range_start + (i * 2) + range_off + ((cp - start_code) * 2)
160
- glyph_id = data[idx, 2].unpack1("n")
161
- glyph_id = (glyph_id + delta) & 0xFFFF if glyph_id != 0
162
- end
163
- @code_map[cp] = glyph_id if glyph_id != 0
164
- end
165
- end
166
- end
167
-
168
- def parse_cmap_format0(data, offset)
169
- 256.times do |cp|
170
- glyph_id = data[offset + 6 + cp, 1].unpack1("C")
171
- @code_map[cp] = glyph_id if glyph_id != 0
172
- end
173
- end
174
-
175
- def parse_name_entry(name_id)
176
- data = read_table_bytes("name")
177
- return nil unless data
178
-
179
- _format, count, string_offset = data[0, 6].unpack("nnn")
180
- best = nil
181
- best_priority = 99
182
- count.times do |i|
183
- rec = 6 + (i * 12)
184
- platform_id, encoding_id, _lang_id, id, length, str_off =
185
- data[rec, 12].unpack("nnnnnn")
186
- next unless id == name_id
187
-
188
- priority = name_platform_priority(platform_id, encoding_id)
189
- next if priority >= best_priority
190
-
191
- raw = data[string_offset + str_off, length]
192
- decoded = decode_name_string(raw, platform_id)
193
- next unless decoded && !decoded.empty?
194
-
195
- best = decoded
196
- best_priority = priority
197
- end
198
- best
199
- end
200
-
201
- # Prefer Windows (platform 3, encoding 1) name records, then
202
- # any Windows record, then Macintosh, then Unicode.
203
- def name_platform_priority(platform_id, encoding_id)
204
- case platform_id
205
- when 3 then encoding_id == 1 ? 0 : 1
206
- when 0 then 3
207
- when 1 then 2
208
- else 4
209
- end
210
- end
211
-
212
- def decode_name_string(raw, platform_id)
213
- result =
214
- case platform_id
215
- when 3, 0
216
- raw.encode("UTF-8", "UTF-16BE", invalid: :replace, undef: :replace)
217
- when 1
218
- raw.encode("UTF-8", "MacRoman", invalid: :replace, undef: :replace)
219
- else
220
- raw.dup.force_encoding("UTF-8").scrub("")
221
- end
222
- result.strip
223
- end
224
- end
225
- end
226
- end
@@ -1,105 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Idml
4
- module TextEngine
5
- # Maps IDML font references (family + style name) to .ttf/.otf
6
- # file paths on disk via Fontisan. Searches system font dirs and
7
- # any user-configured search paths.
8
- class FontResolver
9
- DEFAULT_SEARCH_PATHS = [
10
- "/System/Library/Fonts",
11
- "/Library/Fonts",
12
- File.expand_path("~/Library/Fonts"),
13
- "/usr/share/fonts",
14
- File.expand_path("~/.local/share/fonts"),
15
- ].freeze
16
-
17
- STYLE_ALIASES = {
18
- "Regular" => %w[Regular Normal Book Roman Medium],
19
- "Bold" => %w[Bold Semibold Heavy Black],
20
- "Italic" => %w[Italic Oblique Slanted],
21
- "Bold Italic" => %w[Bold\ Italic BoldOblique SemiboldItalic],
22
- }.freeze
23
-
24
- def initialize(search_paths: DEFAULT_SEARCH_PATHS)
25
- @search_paths = search_paths
26
- @cache = {}
27
- end
28
-
29
- def resolve(family_name:, style_name: "Regular")
30
- key = [family_name, style_name]
31
- @cache[key] ||= find_font(family_name, style_name)
32
- end
33
-
34
- # Search by PostScriptName (e.g., "MinionPro-Regular").
35
- # More precise than family+style — maps directly to the font's
36
- # internal PostScript name. Used when the IDML document references
37
- # a specific PostScriptName from Fonts.xml.
38
- def resolve_by_ps_name(ps_name)
39
- return nil unless ps_name
40
-
41
- @ps_cache ||= {}
42
- return @ps_cache[ps_name] if @ps_cache.key?(ps_name)
43
-
44
- @ps_cache[ps_name] = find_by_ps_name(ps_name)
45
- end
46
-
47
- private
48
-
49
- def find_by_ps_name(ps_name)
50
- @search_paths.each do |dir|
51
- next unless Dir.exist?(dir)
52
-
53
- Dir.glob(File.join(dir, "**", "*.ttf")).each do |path|
54
- font = safe_load(path)
55
- next unless font
56
-
57
- return FontMetrics.open(path) if font.postscript_name == ps_name
58
- end
59
- Dir.glob(File.join(dir, "**", "*.otf")).each do |path|
60
- font = safe_load(path)
61
- next unless font
62
-
63
- return FontMetrics.open(path) if font.postscript_name == ps_name
64
- end
65
- end
66
- nil
67
- end
68
-
69
- def find_font(family, style)
70
- candidates = STYLE_ALIASES[style] || [style]
71
- @search_paths.each do |dir|
72
- next unless Dir.exist?(dir)
73
-
74
- Dir.glob(File.join(dir, "**", "*.ttf")).each do |path|
75
- font = safe_load(path)
76
- next unless font
77
-
78
- return FontMetrics.open(path) if matches?(font, family, candidates)
79
- end
80
- Dir.glob(File.join(dir, "**", "*.otf")).each do |path|
81
- font = safe_load(path)
82
- next unless font
83
-
84
- return FontMetrics.open(path) if matches?(font, family, candidates)
85
- end
86
- end
87
- nil
88
- end
89
-
90
- def matches?(font, family, style_candidates)
91
- return false unless font.family_name.match?(/#{Regexp.escape(family)}/i)
92
-
93
- style_candidates.any? do |s|
94
- font.style_name.match?(/#{Regexp.escape(s)}/i)
95
- end
96
- end
97
-
98
- def safe_load(path)
99
- FontMetrics.open(path)
100
- rescue StandardError
101
- nil
102
- end
103
- end
104
- end
105
- end