arrolio 0.1.1 → 0.1.2
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/lib/arrolio/generic_flow_builder/bibliography.rb +47 -0
- data/lib/arrolio/generic_flow_builder/figures.rb +90 -0
- data/lib/arrolio/generic_flow_builder/lists.rb +66 -0
- data/lib/arrolio/generic_flow_builder/notes.rb +61 -0
- data/lib/arrolio/generic_flow_builder/sequences.rb +102 -0
- data/lib/arrolio/generic_flow_builder/tables.rb +28 -0
- data/lib/arrolio/generic_flow_builder/terms.rb +89 -0
- data/lib/arrolio/generic_flow_builder.rb +16 -411
- data/lib/arrolio/renderer/pdf/assets.rb +94 -0
- data/lib/arrolio/renderer/pdf/font_embedding.rb +77 -0
- data/lib/arrolio/renderer/pdf/metadata.rb +52 -0
- data/lib/arrolio/renderer/pdf.rb +14 -188
- data/lib/arrolio/text_layout/greedy.rb +15 -1
- data/lib/arrolio/text_layout/knuth_plass/breaker.rb +26 -4
- data/lib/arrolio/text_layout/line.rb +7 -1
- data/lib/arrolio/version.rb +1 -1
- metadata +11 -1
data/lib/arrolio/renderer/pdf.rb
CHANGED
|
@@ -12,6 +12,15 @@ module Arrolio
|
|
|
12
12
|
# subset + embed each font for the codepoints actually used
|
|
13
13
|
# in the document.
|
|
14
14
|
class Pdf
|
|
15
|
+
|
|
16
|
+
autoload :FontEmbedding, 'arrolio/renderer/pdf/font_embedding'
|
|
17
|
+
autoload :Assets, 'arrolio/renderer/pdf/assets'
|
|
18
|
+
autoload :Metadata, 'arrolio/renderer/pdf/metadata'
|
|
19
|
+
|
|
20
|
+
include FontEmbedding
|
|
21
|
+
include Assets
|
|
22
|
+
include Metadata
|
|
23
|
+
|
|
15
24
|
MM_TO_PT = 2.83464567
|
|
16
25
|
|
|
17
26
|
attr_reader :document, :fonts
|
|
@@ -41,83 +50,6 @@ module Arrolio
|
|
|
41
50
|
io: io.is_a?(String) ? nil : io)
|
|
42
51
|
end
|
|
43
52
|
|
|
44
|
-
def apply_metadata(metadata)
|
|
45
|
-
info = @document.catalog.value[:Info]
|
|
46
|
-
info ||= @document.add({})
|
|
47
|
-
@document.catalog.value[:Info] = info
|
|
48
|
-
info.value[:Title] = metadata[:title] if metadata[:title]
|
|
49
|
-
info.value[:Author] = metadata[:author] if metadata[:author]
|
|
50
|
-
info.value[:Creator] = 'Arrolio (Ruby)'
|
|
51
|
-
info.value[:Producer] = 'Arrolio + Pdfrb'
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# Emit an XMP metadata packet (RDF/XML) as a stream on the
|
|
55
|
-
# catalog's /Metadata entry. PDF readers use this for Dublin
|
|
56
|
-
# Core properties alongside the legacy /Info dict.
|
|
57
|
-
def attach_xmp_metadata(metadata)
|
|
58
|
-
xmp = XmpBuilder.new(metadata).build
|
|
59
|
-
stream = @document.add(
|
|
60
|
-
{ Type: :Metadata, Subtype: :XML, Length: xmp.bytesize },
|
|
61
|
-
type: Pdfrb::Model::Cos::Stream
|
|
62
|
-
)
|
|
63
|
-
stream.stream = xmp
|
|
64
|
-
@document.catalog.value[:Metadata] =
|
|
65
|
-
Pdfrb::Model::Reference.new(stream.oid, stream.gen)
|
|
66
|
-
rescue StandardError => e
|
|
67
|
-
Arrolio::Logger.warn "XMP attach failed: #{e.class}: #{e.message[0, 80]}"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def register_logo(path)
|
|
71
|
-
return nil unless path
|
|
72
|
-
|
|
73
|
-
@document.images.add(path)
|
|
74
|
-
rescue StandardError => e
|
|
75
|
-
Arrolio::Logger.warn "logo load failed: #{e.class}: #{e.message[0, 80]}"
|
|
76
|
-
nil
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def register_image(path)
|
|
80
|
-
return @images[path] if @images.key?(path)
|
|
81
|
-
|
|
82
|
-
resolved = resolve_image_for_pdfrb(path)
|
|
83
|
-
return nil unless resolved
|
|
84
|
-
|
|
85
|
-
@images[path] = @document.images.add(resolved)
|
|
86
|
-
rescue StandardError => e
|
|
87
|
-
Arrolio::Logger.warn "register_image failed for #{path}: #{e.class}: #{e.message[0, 80]}"
|
|
88
|
-
nil
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
# Pdfrb supports JPEG, PNG, and PDF — not SVG. If +path+ is an
|
|
92
|
-
# SVG file, rasterize it to PNG via rsvg-convert and cache the
|
|
93
|
-
# result. Returns the path to a pdfrb-compatible image file.
|
|
94
|
-
def resolve_image_for_pdfrb(path)
|
|
95
|
-
return path unless path.to_s.end_with?('.svg', '.SVG')
|
|
96
|
-
return path unless File.exist?(path)
|
|
97
|
-
|
|
98
|
-
png_path = svg_to_png_cache_path(path)
|
|
99
|
-
return png_path if File.exist?(png_path)
|
|
100
|
-
|
|
101
|
-
rasterize_svg(path, png_path)
|
|
102
|
-
File.exist?(png_path) ? png_path : path
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def svg_to_png_cache_path(svg_path)
|
|
106
|
-
digest = Digest::MD5.file(svg_path).hexdigest
|
|
107
|
-
File.join(Dir.tmpdir, 'arrolio-svg-' + digest + '.png')
|
|
108
|
-
end
|
|
109
|
-
|
|
110
|
-
def rasterize_svg(svg_path, png_path)
|
|
111
|
-
require 'open3'
|
|
112
|
-
result = Open3.capture3('rsvg-convert', '-d', '96', '-p', '96',
|
|
113
|
-
'-o', png_path, svg_path)
|
|
114
|
-
return if result[2].success?
|
|
115
|
-
|
|
116
|
-
Arrolio::Logger.warn "rsvg-convert failed: #{result[1]}"
|
|
117
|
-
rescue StandardError => e
|
|
118
|
-
Arrolio::Logger.warn "rasterize_svg failed: #{e.class}: #{e.message[0, 80]}"
|
|
119
|
-
end
|
|
120
|
-
|
|
121
53
|
def header_footer_style
|
|
122
54
|
return @header_footer_style if @header_footer_style
|
|
123
55
|
|
|
@@ -134,102 +66,12 @@ module Arrolio
|
|
|
134
66
|
}.freeze
|
|
135
67
|
end
|
|
136
68
|
|
|
137
|
-
def cover_logo_style
|
|
138
|
-
return @cover_logo_style if @cover_logo_style
|
|
139
|
-
|
|
140
|
-
config = @layout_spec&.cover_logo_config || {}
|
|
141
|
-
width_mm = config['width_mm'] || 35.0
|
|
142
|
-
ratio = config['aspect_ratio'] || (1459.0 / 1667.0)
|
|
143
|
-
margin_mm = config['margin_mm'] || 25.5
|
|
144
|
-
@cover_logo_style = {
|
|
145
|
-
width: width_mm * MM_TO_PT,
|
|
146
|
-
height: width_mm * ratio * MM_TO_PT,
|
|
147
|
-
margin: margin_mm * MM_TO_PT
|
|
148
|
-
}.freeze
|
|
149
|
-
end
|
|
150
69
|
|
|
151
70
|
private
|
|
152
71
|
|
|
153
|
-
# Pre-scan all pages, collect codepoints per font family,
|
|
154
|
-
# then call Font::Embedder for each font_path.
|
|
155
|
-
def prepare_embedded_fonts(pages)
|
|
156
|
-
return if @font_paths.empty?
|
|
157
|
-
|
|
158
|
-
codepoints = Hash.new { |h, k| h[k] = [] }
|
|
159
|
-
pages.each { |page| collect_codepoints(page, codepoints) }
|
|
160
|
-
Arrolio::Logger.debug "collected codepoints for fonts: #{codepoints.keys.inspect}"
|
|
161
|
-
@font_paths.each do |font_name, path|
|
|
162
|
-
exists = File.exist?(path)
|
|
163
|
-
has_cp = codepoints.key?(font_name)
|
|
164
|
-
cp_count = codepoints[font_name]&.length || 0
|
|
165
|
-
Arrolio::Logger.debug "font #{font_name}: exists=#{exists} cp=#{cp_count}"
|
|
166
|
-
next unless exists
|
|
167
|
-
next unless has_cp
|
|
168
|
-
|
|
169
|
-
cps = codepoints[font_name].uniq
|
|
170
|
-
embedder = Font::Embedder.new(@document, path,
|
|
171
|
-
base_font_name: font_name)
|
|
172
|
-
ref = embedder.embed(cps)
|
|
173
|
-
attach_to_resources(font_name, ref)
|
|
174
|
-
@font_embedders[font_name] = embedder
|
|
175
|
-
@font_encoders[font_name] = Font::TextEncoder.new(embedder)
|
|
176
|
-
@font_refs[font_name] = ref
|
|
177
|
-
rescue StandardError => e
|
|
178
|
-
Arrolio::Logger.warn "embed failed for #{font_name}: #{e.class}: #{e.message[0,100]}"
|
|
179
|
-
Arrolio::Logger.debug e.backtrace.first(5).join("\n")
|
|
180
|
-
end
|
|
181
|
-
end
|
|
182
72
|
|
|
183
|
-
def attach_to_resources(font_name, ref)
|
|
184
|
-
catalog = @document.catalog
|
|
185
|
-
res = catalog.value[:Resources]
|
|
186
|
-
Arrolio::Logger.debug "attach_to_resources: Resources=#{res.class}"
|
|
187
|
-
unless res.is_a?(Hash) && res[:Font].is_a?(Hash)
|
|
188
|
-
catalog.value[:Resources] = { Font: {} }
|
|
189
|
-
end
|
|
190
|
-
font_hash = catalog.value[:Resources][:Font]
|
|
191
|
-
key = (format('EF%d', (font_hash.length + 1))).to_sym
|
|
192
|
-
font_hash[key] = ref
|
|
193
|
-
@embedded_resource_keys ||= {}
|
|
194
|
-
@embedded_resource_keys[font_name] = key
|
|
195
|
-
end
|
|
196
73
|
|
|
197
|
-
def collect_codepoints(page, by_font)
|
|
198
|
-
(page.static_regions.values + page.regions.values).each do |region|
|
|
199
|
-
region.placed_boxes.each do |box|
|
|
200
|
-
next unless box.kind == :text
|
|
201
|
-
next unless box.data.is_a?(Hash) && box.data[:lines]
|
|
202
|
-
|
|
203
|
-
box.data[:lines].each do |line|
|
|
204
|
-
next unless line.is_a?(TextLayout::Line)
|
|
205
|
-
line.placed_runs.each do |pr|
|
|
206
|
-
next unless pr.run.is_a?(InlineRun)
|
|
207
|
-
# Collect under the RUN'S font name, not the
|
|
208
|
-
# paragraph's. Italic/bold runs have different
|
|
209
|
-
# font_names and need their own subsets.
|
|
210
|
-
run_font = pr.run.style.font_name
|
|
211
|
-
next unless @font_paths.key?(run_font)
|
|
212
|
-
pr.run.text.each_codepoint { |cp| by_font[run_font] << cp }
|
|
213
|
-
end
|
|
214
|
-
end
|
|
215
|
-
end
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
def build_outline(context, _pages)
|
|
220
|
-
entries = context.heading_entries
|
|
221
|
-
return unless entries&.any?
|
|
222
74
|
|
|
223
|
-
pdfrb_pages = @document.pages.to_a
|
|
224
|
-
ob = OutlineBuilder.new(document: @document,
|
|
225
|
-
entries: entries,
|
|
226
|
-
pdfrb_pages: pdfrb_pages)
|
|
227
|
-
result = ob.build
|
|
228
|
-
Arrolio::Logger.debug "outline build returned: #{result.class}"
|
|
229
|
-
rescue StandardError => e
|
|
230
|
-
Arrolio::Logger.warn "outline build failed: #{e.class}: #{e.message[0,150]}"
|
|
231
|
-
Arrolio::Logger.debug e.backtrace.first(5).join("\n")
|
|
232
|
-
end
|
|
233
75
|
|
|
234
76
|
def render_page(output_page)
|
|
235
77
|
pdfrb_page = @document.pages.add(media_box: [0, 0,
|
|
@@ -280,24 +122,6 @@ module Arrolio
|
|
|
280
122
|
style.nil? || style.font_size.nil? ? Style::Definition.new(font_size: 9.0) : style
|
|
281
123
|
end
|
|
282
124
|
|
|
283
|
-
def render_cover_logo(canvas, page)
|
|
284
|
-
logo_style = cover_logo_style
|
|
285
|
-
w = logo_style[:width]
|
|
286
|
-
h = logo_style[:height]
|
|
287
|
-
margin = logo_style[:margin]
|
|
288
|
-
x = page.width - margin - w
|
|
289
|
-
y = page.height - margin - h
|
|
290
|
-
invoke = invoke_xobject_op
|
|
291
|
-
return unless invoke
|
|
292
|
-
|
|
293
|
-
canvas.save_graphics_state do
|
|
294
|
-
canvas.concat(w, 0, 0, h, x, y)
|
|
295
|
-
canvas.emit_op(invoke, @logo_ref)
|
|
296
|
-
end
|
|
297
|
-
rescue StandardError => e
|
|
298
|
-
Arrolio::Logger.warn "logo render failed: #{e.class}: #{e.message[0, 80]}"
|
|
299
|
-
end
|
|
300
|
-
|
|
301
125
|
def invoke_xobject_op
|
|
302
126
|
return Pdfrb::Content::Operator::InvokeXObject if defined?(Pdfrb::Content::Operator::InvokeXObject)
|
|
303
127
|
|
|
@@ -433,10 +257,9 @@ module Arrolio
|
|
|
433
257
|
font_name = run.style.font_name
|
|
434
258
|
ref = embedded_or_standard(font_name)
|
|
435
259
|
y, size = baseline_position_for(run, baseline_y)
|
|
436
|
-
measurer = GlyphMeasurer.new(font_name: font_name)
|
|
437
260
|
cursor = 0.0
|
|
438
261
|
run_start = nil
|
|
439
|
-
text_chunks(run.text, justify).
|
|
262
|
+
text_chunks(run.text, justify).each_with_index do |chunk, ci|
|
|
440
263
|
x = run_x + extra_offset + cursor
|
|
441
264
|
run_start ||= x
|
|
442
265
|
payload = encoded_or_text(font_name, chunk)
|
|
@@ -449,7 +272,10 @@ module Arrolio
|
|
|
449
272
|
end
|
|
450
273
|
record_link_if_needed(run, x, y, size) if cursor.zero?
|
|
451
274
|
canvas.text(payload, **opts)
|
|
452
|
-
|
|
275
|
+
# Advance by the width the BREAKER computed — the
|
|
276
|
+
# renderer never measures, so layout and emission
|
|
277
|
+
# cannot drift (the word-overlap bug class).
|
|
278
|
+
cursor += pr.chunk_widths[ci].to_f
|
|
453
279
|
# Justify must SHRINK as well as stretch: a compressed
|
|
454
280
|
# line (ratio > -1) relies on negative space adjustment —
|
|
455
281
|
# skipping it draws the line at natural width, past the
|
|
@@ -100,11 +100,25 @@ module Arrolio
|
|
|
100
100
|
next if slice.nil? || slice.empty?
|
|
101
101
|
|
|
102
102
|
sub_run = InlineRun.new(slice, style: run.style)
|
|
103
|
-
placed << Line::PlacedRun.new(run: sub_run, x_offset: x_offset
|
|
103
|
+
placed << Line::PlacedRun.new(run: sub_run, x_offset: x_offset,
|
|
104
|
+
chunk_widths: chunk_widths(run, slice))
|
|
104
105
|
x_offset += sub_run.width(@measurer)
|
|
105
106
|
end
|
|
106
107
|
placed
|
|
107
108
|
end
|
|
109
|
+
|
|
110
|
+
# Width of each emission chunk (text split after
|
|
111
|
+
# whitespace) — the same chunking the renderer uses. The
|
|
112
|
+
# breaker owns measurement; the renderer advances by these.
|
|
113
|
+
def chunk_widths(run, slice)
|
|
114
|
+
font = run.style.font_name
|
|
115
|
+
size = run.style.font_size * run.font_size_scale
|
|
116
|
+
slice.split(/(?<=\s)(?=\S)/).map do |chunk|
|
|
117
|
+
@measurer.width_of_run(chunk, font_size: size,
|
|
118
|
+
character_spacing: run.style.character_spacing,
|
|
119
|
+
word_spacing: 0, font_name: font)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
108
122
|
end
|
|
109
123
|
end
|
|
110
124
|
end
|
|
@@ -76,7 +76,8 @@ module Arrolio
|
|
|
76
76
|
|
|
77
77
|
# Internal: a run of same-signature text being merged into a
|
|
78
78
|
# single PlacedRun (one canvas.text call at render time).
|
|
79
|
-
RunGroup = Struct.new(:signature, :text, :x_offset,
|
|
79
|
+
RunGroup = Struct.new(:signature, :text, :x_offset, :chunk_widths,
|
|
80
|
+
keyword_init: true)
|
|
80
81
|
|
|
81
82
|
private
|
|
82
83
|
|
|
@@ -302,10 +303,16 @@ module Arrolio
|
|
|
302
303
|
# line's last box (at the break) is dropped: it is discarded
|
|
303
304
|
# by width accounting and would otherwise inflate the
|
|
304
305
|
# renderer's justify word count.
|
|
306
|
+
# Chunk widths follow the emission chunking (text split
|
|
307
|
+
# AFTER whitespace): a chunk's width is its boxes plus the
|
|
308
|
+
# trailing glue(s). Hyphen parts join the same chunk (no
|
|
309
|
+
# space between them).
|
|
305
310
|
def build_placed_runs(start_item, stop_item)
|
|
306
311
|
last_box = last_box_index_in(start_item, stop_item)
|
|
307
312
|
placed = []
|
|
308
313
|
group = nil
|
|
314
|
+
open_w = 0.0
|
|
315
|
+
close_pending = false
|
|
309
316
|
|
|
310
317
|
(start_item...stop_item).each do |i|
|
|
311
318
|
item = @items[i]
|
|
@@ -324,14 +331,28 @@ module Arrolio
|
|
|
324
331
|
if group.nil? || group.signature != signature
|
|
325
332
|
flush_placed_group(placed, group) if group
|
|
326
333
|
group = RunGroup.new(signature: signature, text: String.new,
|
|
327
|
-
x_offset: item_offset(first_box_in(start_item, stop_item), i)
|
|
334
|
+
x_offset: item_offset(first_box_in(start_item, stop_item), i),
|
|
335
|
+
chunk_widths: [])
|
|
336
|
+
open_w = 0.0
|
|
337
|
+
close_pending = false
|
|
338
|
+
end
|
|
339
|
+
if close_pending
|
|
340
|
+
group.chunk_widths << open_w
|
|
341
|
+
open_w = 0.0
|
|
342
|
+
close_pending = false
|
|
328
343
|
end
|
|
329
344
|
group.text << slice
|
|
345
|
+
open_w += item.width.to_f
|
|
330
346
|
elsif item.glue? && group && i < last_box
|
|
331
347
|
group.text << ' '
|
|
348
|
+
open_w += item.width.to_f
|
|
349
|
+
close_pending = true
|
|
332
350
|
end
|
|
333
351
|
end
|
|
334
|
-
|
|
352
|
+
if group
|
|
353
|
+
group.chunk_widths << open_w if close_pending || open_w.positive?
|
|
354
|
+
flush_placed_group(placed, group)
|
|
355
|
+
end
|
|
335
356
|
placed
|
|
336
357
|
end
|
|
337
358
|
|
|
@@ -359,7 +380,8 @@ module Arrolio
|
|
|
359
380
|
baseline_shift: baseline_shift,
|
|
360
381
|
font_size_scale: font_size_scale,
|
|
361
382
|
href: href)
|
|
362
|
-
placed << Line::PlacedRun.new(run: run, x_offset: group.x_offset
|
|
383
|
+
placed << Line::PlacedRun.new(run: run, x_offset: group.x_offset,
|
|
384
|
+
chunk_widths: group.chunk_widths)
|
|
363
385
|
end
|
|
364
386
|
end
|
|
365
387
|
end
|
|
@@ -5,7 +5,13 @@ module Arrolio
|
|
|
5
5
|
# One laid-out line: a list of PlacedRun tuples plus the
|
|
6
6
|
# line's content width, target width, and alignment.
|
|
7
7
|
class Line
|
|
8
|
-
|
|
8
|
+
# +chunk_widths+ carries the width of each emission chunk
|
|
9
|
+
# (text split after whitespace), computed by the BREAKER that
|
|
10
|
+
# owns glyph measurement. The renderer advances by these
|
|
11
|
+
# widths — it never measures, so layout and render cannot
|
|
12
|
+
# drift apart (the source of the word-overlap and
|
|
13
|
+
# measure-overflow bugs).
|
|
14
|
+
PlacedRun = Struct.new(:run, :x_offset, :chunk_widths, keyword_init: true) do
|
|
9
15
|
def freeze
|
|
10
16
|
run.freeze
|
|
11
17
|
super
|
data/lib/arrolio/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: arrolio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ribose Inc.
|
|
@@ -384,6 +384,13 @@ files:
|
|
|
384
384
|
- lib/arrolio/generic_adapter/metadata_extraction.rb
|
|
385
385
|
- lib/arrolio/generic_adapter/table_conversion.rb
|
|
386
386
|
- lib/arrolio/generic_flow_builder.rb
|
|
387
|
+
- lib/arrolio/generic_flow_builder/bibliography.rb
|
|
388
|
+
- lib/arrolio/generic_flow_builder/figures.rb
|
|
389
|
+
- lib/arrolio/generic_flow_builder/lists.rb
|
|
390
|
+
- lib/arrolio/generic_flow_builder/notes.rb
|
|
391
|
+
- lib/arrolio/generic_flow_builder/sequences.rb
|
|
392
|
+
- lib/arrolio/generic_flow_builder/tables.rb
|
|
393
|
+
- lib/arrolio/generic_flow_builder/terms.rb
|
|
387
394
|
- lib/arrolio/glyph_measurer.rb
|
|
388
395
|
- lib/arrolio/harness.rb
|
|
389
396
|
- lib/arrolio/harness/pdf_diff.rb
|
|
@@ -411,6 +418,9 @@ files:
|
|
|
411
418
|
- lib/arrolio/renderer/link_annotator.rb
|
|
412
419
|
- lib/arrolio/renderer/outline_builder.rb
|
|
413
420
|
- lib/arrolio/renderer/pdf.rb
|
|
421
|
+
- lib/arrolio/renderer/pdf/assets.rb
|
|
422
|
+
- lib/arrolio/renderer/pdf/font_embedding.rb
|
|
423
|
+
- lib/arrolio/renderer/pdf/metadata.rb
|
|
414
424
|
- lib/arrolio/renderer/signature_config.rb
|
|
415
425
|
- lib/arrolio/renderer/structure_tree_builder.rb
|
|
416
426
|
- lib/arrolio/renderer/xmp_builder.rb
|