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.
- checksums.yaml +4 -4
- data/Gemfile.lock +4 -20
- data/TODO.pdf/63-replace-fontmetrics-with-pdfrb.md +59 -58
- data/TODO.pdf/65-pdfrb-019-integration.md +57 -29
- data/TODO.pdf/67-text-rich-multi-run.md +35 -39
- data/TODO.pdf/76-tagged-pdf-structure.md +63 -87
- data/TODO.pdf/77-pdfa-xmp-output-intent.md +51 -59
- data/TODO.pdf/78-hyperlinks.md +79 -0
- data/TODO.pdf/79-bookmarks-outline.md +72 -0
- data/idml.gemspec +0 -1
- data/lib/idml/render/image_collector.rb +107 -0
- data/lib/idml/render/page_item_renderer.rb +18 -1
- data/lib/idml/render/pdfa_packet.rb +135 -0
- data/lib/idml/render/pdfrb_writer.rb +2 -1
- data/lib/idml/render/pipeline.rb +47 -101
- data/lib/idml/render/render_context.rb +3 -1
- data/lib/idml/render/renderers/group_renderer.rb +3 -1
- data/lib/idml/render/renderers/text_frame_renderer.rb +34 -25
- data/lib/idml/render/spread_renderer.rb +9 -5
- data/lib/idml/render/structure_mapper.rb +54 -0
- data/lib/idml/render/structure_tracker.rb +55 -0
- data/lib/idml/render.rb +4 -0
- data/lib/idml/text_engine/pdfrb_font_metrics.rb +81 -0
- data/lib/idml/text_engine.rb +1 -2
- data/lib/idml/version.rb +1 -1
- metadata +8 -17
- data/lib/idml/text_engine/font_metrics.rb +0 -226
- data/lib/idml/text_engine/font_resolver.rb +0 -105
|
@@ -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
|