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
|
@@ -5,7 +5,22 @@ require 'yaml'
|
|
|
5
5
|
|
|
6
6
|
module Arrolio
|
|
7
7
|
class GenericFlowBuilder
|
|
8
|
-
|
|
8
|
+
autoload :Sequences, 'arrolio/generic_flow_builder/sequences'
|
|
9
|
+
autoload :Terms, 'arrolio/generic_flow_builder/terms'
|
|
10
|
+
autoload :Notes, 'arrolio/generic_flow_builder/notes'
|
|
11
|
+
autoload :Figures, 'arrolio/generic_flow_builder/figures'
|
|
12
|
+
autoload :Lists, 'arrolio/generic_flow_builder/lists'
|
|
13
|
+
autoload :Bibliography, 'arrolio/generic_flow_builder/bibliography'
|
|
14
|
+
autoload :Tables, 'arrolio/generic_flow_builder/tables'
|
|
15
|
+
|
|
16
|
+
include Sequences
|
|
17
|
+
include Terms
|
|
18
|
+
include Notes
|
|
19
|
+
include Figures
|
|
20
|
+
include Lists
|
|
21
|
+
include Bibliography
|
|
22
|
+
include Tables
|
|
23
|
+
|
|
9
24
|
|
|
10
25
|
attr_reader :layout_spec, :rules, :asset_resolver
|
|
11
26
|
|
|
@@ -36,104 +51,14 @@ module Arrolio
|
|
|
36
51
|
flowables
|
|
37
52
|
end
|
|
38
53
|
|
|
39
|
-
def append_endnotes(document, flowables)
|
|
40
|
-
return if document.footnotes.empty?
|
|
41
|
-
return unless @rules['endnotes']
|
|
42
|
-
|
|
43
|
-
flowables << Flowables::PageSequenceStart.new(
|
|
44
|
-
role: :endnotes,
|
|
45
|
-
header_template: nil,
|
|
46
|
-
footer_template: nil
|
|
47
|
-
)
|
|
48
|
-
document.footnotes.each do |footnote|
|
|
49
|
-
marker_text = footnote.marker.to_s
|
|
50
|
-
body = footnote.body.map { |paragraph| paragraph_flowable(paragraph) }
|
|
51
|
-
flowables << Flowables::NoteFlowable.new(
|
|
52
|
-
marker_text,
|
|
53
|
-
body,
|
|
54
|
-
style: resolve(footnote.style_id),
|
|
55
|
-
marker_width: 22.0
|
|
56
|
-
)
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
54
|
|
|
60
55
|
private
|
|
61
56
|
|
|
62
|
-
def page_sequences
|
|
63
|
-
Array(@rules['page_sequences'])
|
|
64
|
-
end
|
|
65
57
|
|
|
66
58
|
def title_block_space
|
|
67
59
|
(@rules.dig('title', 'block_space') || 0.0).to_f
|
|
68
60
|
end
|
|
69
61
|
|
|
70
|
-
def sequence_start(document, sequence)
|
|
71
|
-
title = sequence['role'].to_s == 'body' ? title_text(document) : nil
|
|
72
|
-
Flowables::PageSequenceStart.new(
|
|
73
|
-
role: sequence.fetch('role'),
|
|
74
|
-
header_template: interpolate(sequence['header'], document),
|
|
75
|
-
footer_template: sequence['footer'],
|
|
76
|
-
header_align: sequence['header_align'],
|
|
77
|
-
footer_align: sequence['footer_align'] || :center,
|
|
78
|
-
initial_page_number: sequence['initial_page_number'],
|
|
79
|
-
title_template: title
|
|
80
|
-
)
|
|
81
|
-
end
|
|
82
|
-
|
|
83
|
-
def build_sequence_content(document, source, sequence, out)
|
|
84
|
-
if sequence['build_content'] == 'cover_content'
|
|
85
|
-
build_cover_content(document, out)
|
|
86
|
-
elsif source.is_a?(Array)
|
|
87
|
-
build_sections(source, out,
|
|
88
|
-
between_breaks: preface_clause_breaks?(sequence))
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# mn2pdf gives each preface clause its own page sequence (ToC
|
|
93
|
-
# page, then Foreword on a fresh page) when the flavor opts in.
|
|
94
|
-
def preface_clause_breaks?(sequence)
|
|
95
|
-
sequence['role'].to_s == 'preface' &&
|
|
96
|
-
@rules.dig('preface', 'page_break_between_clauses')
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
# The first body page opens with the part title ("Part 1 -
|
|
100
|
-
# {part title}") - the docidentifier's trailing part number
|
|
101
|
-
# supplies the prefix.
|
|
102
|
-
def title_text(document)
|
|
103
|
-
return nil unless document.title_block
|
|
104
|
-
|
|
105
|
-
text = document.title_block.inline_runs.map(&:text).join.strip
|
|
106
|
-
return nil if text.empty?
|
|
107
|
-
|
|
108
|
-
part = document.metadata[:docidentifier].to_s[/-(\d+)\z/, 1]
|
|
109
|
-
part ? "Part #{part} - #{text}" : text
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def content_for(document, role)
|
|
113
|
-
case role.to_s
|
|
114
|
-
when 'preface' then document.preface
|
|
115
|
-
when 'body' then document.sections
|
|
116
|
-
when 'bibliography' then document.bibliography
|
|
117
|
-
else []
|
|
118
|
-
end
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
def build_cover_content(document, out)
|
|
122
|
-
Array(@rules['cover_content']).each do |entry|
|
|
123
|
-
next unless condition_matches?(entry['when'], document)
|
|
124
|
-
|
|
125
|
-
case entry['type'].to_s
|
|
126
|
-
when 'spacer'
|
|
127
|
-
out << Flowables::Spacer.new(entry.fetch('size').to_f)
|
|
128
|
-
when 'text'
|
|
129
|
-
style = resolve(entry.fetch('style'))
|
|
130
|
-
style = style.with(align: :center) if entry.fetch('align', 'center').to_sym == :center
|
|
131
|
-
text = interpolate(entry.fetch('source'), document)
|
|
132
|
-
out << Flowables::TextFlowable.new([InlineRun.new(text, style: style)], style: style)
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
end
|
|
136
|
-
|
|
137
62
|
def build_sections(sections, out, between_breaks: false)
|
|
138
63
|
section_rules = @rules.fetch('section', {})
|
|
139
64
|
break_between = section_rules.fetch('insert_page_break_before', false)
|
|
@@ -265,230 +190,6 @@ module Arrolio
|
|
|
265
190
|
end
|
|
266
191
|
end
|
|
267
192
|
|
|
268
|
-
def note_flowable(note)
|
|
269
|
-
body = note.body.map { |node| note_body_flowable(node) }
|
|
270
|
-
body = with_note_body_spacing(body)
|
|
271
|
-
Flowables::NoteFlowable.new(
|
|
272
|
-
formatted_note_label(note.label),
|
|
273
|
-
body,
|
|
274
|
-
style: resolve(note.style_id),
|
|
275
|
-
label_style: resolve(:note_label)
|
|
276
|
-
)
|
|
277
|
-
end
|
|
278
|
-
|
|
279
|
-
# A note's trailing list sits ~7pt below its text in the
|
|
280
|
-
# reference (3.1.3.1: text -> list 20pt vs a plain 13pt pitch).
|
|
281
|
-
def with_note_body_spacing(body)
|
|
282
|
-
spacing = (@rules.dig('note', 'body_spacing') || 0.0).to_f
|
|
283
|
-
return body if spacing.zero? || body.length < 2
|
|
284
|
-
|
|
285
|
-
body.flat_map.with_index do |flowable, index|
|
|
286
|
-
index < body.length - 1 ? [flowable, Flowables::Spacer.new(spacing)] : [flowable]
|
|
287
|
-
end
|
|
288
|
-
end
|
|
289
|
-
|
|
290
|
-
def note_body_flowable(node)
|
|
291
|
-
return list_flowable(node, container: :note) if node.is_a?(Content::List)
|
|
292
|
-
|
|
293
|
-
paragraph_flowable(node)
|
|
294
|
-
end
|
|
295
|
-
|
|
296
|
-
def formatted_note_label(label)
|
|
297
|
-
return '' if label.nil? || label.empty?
|
|
298
|
-
|
|
299
|
-
suffix = @rules.dig('note', 'label_suffix') || ':'
|
|
300
|
-
stripped = label.strip.chomp(':').strip
|
|
301
|
-
return '' if stripped.empty?
|
|
302
|
-
|
|
303
|
-
suffix.start_with?(':') ? "#{stripped}#{suffix} " : "#{stripped} #{suffix} "
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Examples render the label as a block heading with the body
|
|
307
|
-
# indented 35.4pt — the FOP example layout.
|
|
308
|
-
def example_flowable(example)
|
|
309
|
-
body = example.body.map { |paragraph| paragraph_flowable(paragraph) }
|
|
310
|
-
Flowables::NoteFlowable.new(
|
|
311
|
-
example.label,
|
|
312
|
-
body,
|
|
313
|
-
style: resolve(example.style_id),
|
|
314
|
-
body_indent: 35.4,
|
|
315
|
-
label_mode: :block
|
|
316
|
-
)
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
# Figures are atomic: image + caption render as ONE flowable
|
|
320
|
-
# so the caption can never be orphaned onto the next page.
|
|
321
|
-
# Caption gap is flavor geometry (ref: image bottom to caption
|
|
322
|
-
# ~28pt; figure blocks separated by ~24pt).
|
|
323
|
-
def figure_group_flowable(group, out)
|
|
324
|
-
figure_config = @rules['figure'] || {}
|
|
325
|
-
caption_gap = figure_config.fetch('caption_gap', 0.0).to_f
|
|
326
|
-
block_gap = figure_config.fetch('block_gap', 0.0).to_f
|
|
327
|
-
|
|
328
|
-
image = image_flowable(group.image) if group.image
|
|
329
|
-
if group.caption
|
|
330
|
-
caption_style = resolve(:figure_caption).with(margin_top: caption_gap)
|
|
331
|
-
runs = group.caption.inline_runs.map do |run|
|
|
332
|
-
InlineRun.new(run.text, style: caption_style)
|
|
333
|
-
end
|
|
334
|
-
caption = Flowables::TextFlowable.new(runs, style: caption_style)
|
|
335
|
-
end
|
|
336
|
-
return out << caption if image.nil?
|
|
337
|
-
|
|
338
|
-
image = with_block_gap(image, block_gap)
|
|
339
|
-
out << if caption
|
|
340
|
-
Flowables::FigureFlowable.new(image, caption)
|
|
341
|
-
else
|
|
342
|
-
image
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
|
|
346
|
-
def with_block_gap(flowable, gap)
|
|
347
|
-
return flowable if gap.zero?
|
|
348
|
-
|
|
349
|
-
style = flowable.style.with(margin_top: flowable.style.margin_top + gap)
|
|
350
|
-
flowable.class.new(flowable.src,
|
|
351
|
-
natural_width: flowable.natural_width,
|
|
352
|
-
natural_height: flowable.natural_height,
|
|
353
|
-
display_width: flowable.display_width,
|
|
354
|
-
alt: flowable.alt,
|
|
355
|
-
style: style)
|
|
356
|
-
end
|
|
357
|
-
|
|
358
|
-
# An entry's head must keep number + preferred + two
|
|
359
|
-
# definition lines together (the reference moves whole entry
|
|
360
|
-
# heads when those don't fit, leaving its characteristic
|
|
361
|
-
# 50-90pt terms-region page-end gaps).
|
|
362
|
-
def widen_first_definition_widows(out)
|
|
363
|
-
widows = term_config.fetch('definition_widows', 1).to_i
|
|
364
|
-
last = out.last
|
|
365
|
-
return unless widows > 1 && last.is_a?(Flowables::TextFlowable)
|
|
366
|
-
|
|
367
|
-
out[-1] = Flowables::TextFlowable.new(
|
|
368
|
-
last.runs,
|
|
369
|
-
style: last.style.with(widows: widows),
|
|
370
|
-
measurer: last.measurer
|
|
371
|
-
)
|
|
372
|
-
end
|
|
373
|
-
|
|
374
|
-
# Re-emits the last paragraph flowable with the configured
|
|
375
|
-
# between-sibling space (XSL-FO space-before semantics).
|
|
376
|
-
def apply_sibling_spacing(_item, out)
|
|
377
|
-
spacing = term_config.fetch('definition_paragraph_space_before', 0.0).to_f
|
|
378
|
-
return if spacing.zero? || out.empty?
|
|
379
|
-
|
|
380
|
-
last = out.last
|
|
381
|
-
return unless last.is_a?(Flowables::TextFlowable)
|
|
382
|
-
|
|
383
|
-
out[-1] = Flowables::TextFlowable.new(last.runs,
|
|
384
|
-
style: last.style.with(margin_top: spacing),
|
|
385
|
-
measurer: last.measurer)
|
|
386
|
-
end
|
|
387
|
-
|
|
388
|
-
def term_config
|
|
389
|
-
@rules['term'] || {}
|
|
390
|
-
end
|
|
391
|
-
|
|
392
|
-
def term_entry_flowable(entry, out)
|
|
393
|
-
if entry.number
|
|
394
|
-
number_style = resolve(:term).with(margin_top: 12.0, margin_bottom: 0.0)
|
|
395
|
-
out << Flowables::TextFlowable.new(
|
|
396
|
-
[InlineRun.new(entry.number, style: number_style)],
|
|
397
|
-
style: number_style
|
|
398
|
-
)
|
|
399
|
-
end
|
|
400
|
-
if entry.preferred
|
|
401
|
-
preferred_style = resolve(:term).with(margin_top: 0.0, margin_bottom: 6.0)
|
|
402
|
-
out << Flowables::TextFlowable.new(
|
|
403
|
-
entry.preferred.inline_runs.map do |run|
|
|
404
|
-
InlineRun.new(run.text, style: resolve(run.style_id),
|
|
405
|
-
baseline_shift: run.baseline_shift,
|
|
406
|
-
font_size_scale: run.font_size_scale,
|
|
407
|
-
href: run.href)
|
|
408
|
-
end,
|
|
409
|
-
style: preferred_style
|
|
410
|
-
)
|
|
411
|
-
end
|
|
412
|
-
# FOP applies space-before BETWEEN sibling blocks (not before
|
|
413
|
-
# the first) — term definitions' second+ paragraphs (the
|
|
414
|
-
# "(For notes...)" line) and the SOURCE line each carry it.
|
|
415
|
-
entry.definition.each_with_index do |item, index|
|
|
416
|
-
append_child(item, out, standalone: false, in_term: true)
|
|
417
|
-
if index.zero?
|
|
418
|
-
widen_first_definition_widows(out)
|
|
419
|
-
else
|
|
420
|
-
apply_sibling_spacing(item, out)
|
|
421
|
-
end
|
|
422
|
-
end
|
|
423
|
-
return unless entry.source
|
|
424
|
-
|
|
425
|
-
source_style = resolve(entry.source.style_id)
|
|
426
|
-
.with(margin_top: term_config.fetch('source_space_before', 2.0).to_f,
|
|
427
|
-
margin_bottom: 2.0)
|
|
428
|
-
source_runs = entry.source.inline_runs.map do |run|
|
|
429
|
-
InlineRun.new(run.text, style: resolve(run.style_id),
|
|
430
|
-
baseline_shift: run.baseline_shift,
|
|
431
|
-
font_size_scale: run.font_size_scale,
|
|
432
|
-
href: run.href)
|
|
433
|
-
end
|
|
434
|
-
out << Flowables::TextFlowable.new(source_runs, style: source_style)
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
def bibliography_item_flowable(item, out)
|
|
438
|
-
tag = item.tag.to_s
|
|
439
|
-
body_para = bibliography_body_paragraph(item)
|
|
440
|
-
if tag.empty?
|
|
441
|
-
out << paragraph_flowable(body_para)
|
|
442
|
-
return
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
body = paragraph_flowable(body_para)
|
|
446
|
-
out << Flowables::NoteFlowable.new(
|
|
447
|
-
tag,
|
|
448
|
-
[body],
|
|
449
|
-
style: resolve(item.style_id || :bibitem),
|
|
450
|
-
label_style: resolve(:bibitem_marker),
|
|
451
|
-
marker_width: 24.0
|
|
452
|
-
)
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
def bibliography_body_paragraph(item)
|
|
456
|
-
runs = []
|
|
457
|
-
if item.formattedref
|
|
458
|
-
runs.concat(item.formattedref.inline_runs.map do |r|
|
|
459
|
-
Content::InlineRun.new(r.text, style_id: r.style_id)
|
|
460
|
-
end)
|
|
461
|
-
end
|
|
462
|
-
Content::Paragraph.new(runs, style_id: item.style_id || :bibitem,
|
|
463
|
-
id: item.id)
|
|
464
|
-
end
|
|
465
|
-
|
|
466
|
-
def marker_width_of(tag)
|
|
467
|
-
return 0.0 if tag.to_s.empty?
|
|
468
|
-
|
|
469
|
-
style = resolve(:bibitem_marker)
|
|
470
|
-
GlyphMeasurer.new(font_name: style.font_name)
|
|
471
|
-
.width_of_string("#{tag} ", font_size: style.font_size)
|
|
472
|
-
end
|
|
473
|
-
|
|
474
|
-
def caption_runs_for(table)
|
|
475
|
-
return nil if table.caption.nil?
|
|
476
|
-
|
|
477
|
-
paragraph_flowable(table.caption, standalone: false).runs
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
# Table geometry (minimum row height, cell padding, footnote
|
|
481
|
-
# size) is flavor configuration — extracted from the flavor's
|
|
482
|
-
# XSL row/cell styles — not engine policy.
|
|
483
|
-
def table_geometry
|
|
484
|
-
config = @rules['table'] || {}
|
|
485
|
-
{
|
|
486
|
-
min_row_height: config.fetch('min_row_height', 0.0).to_f,
|
|
487
|
-
cell_padding: config.fetch('cell_padding', 2.0).to_f,
|
|
488
|
-
footnote_font_size: config['footnote_font_size']&.to_f
|
|
489
|
-
}
|
|
490
|
-
end
|
|
491
|
-
|
|
492
193
|
def paragraph_flowable(paragraph, standalone: false)
|
|
493
194
|
runs = paragraph.inline_runs.map do |run|
|
|
494
195
|
InlineRun.new(
|
|
@@ -514,102 +215,6 @@ module Arrolio
|
|
|
514
215
|
Flowables::TextFlowable.new(runs, style: style)
|
|
515
216
|
end
|
|
516
217
|
|
|
517
|
-
def list_flowable(list, container: nil)
|
|
518
|
-
items = list.items.each_with_index.map do |item, index|
|
|
519
|
-
marker = item.marker.nil? ? default_marker(list, index, container: container) : item.marker
|
|
520
|
-
body = item.content.flat_map do |content|
|
|
521
|
-
flowable_for_list_content(content)
|
|
522
|
-
end
|
|
523
|
-
[marker, body]
|
|
524
|
-
end
|
|
525
|
-
Flowables::ListFlowable.new(items, kind: list.kind,
|
|
526
|
-
style: resolve(list.style_id),
|
|
527
|
-
**list_geometry(list.kind, container: container))
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
# List marker geometry (indent, marker column, inter-item
|
|
531
|
-
# spacing) is flavor configuration extracted from the flavor's
|
|
532
|
-
# XSL list styles — not engine policy.
|
|
533
|
-
# A kind-specific map (e.g. geometry.ordered) overrides the
|
|
534
|
-
# shared defaults — flavors whose bullets indent but whose
|
|
535
|
-
# ordered markers start at the margin (which also restores
|
|
536
|
-
# nested lists' depth).
|
|
537
|
-
def list_geometry(kind, container: nil)
|
|
538
|
-
config = @rules.dig('list', 'geometry') || {}
|
|
539
|
-
config = config.merge(config[kind.to_s] || {})
|
|
540
|
-
# Lists nested in note bodies sit deeper: the reference puts
|
|
541
|
-
# 3.1.3.1's note bullets at +43.7pt from the note column
|
|
542
|
-
# (marker x=143 from the page), pitch 17pt.
|
|
543
|
-
config = config.merge(@rules.dig('note', 'list') || {}) if container == :note
|
|
544
|
-
{
|
|
545
|
-
marker_indent: config.fetch('marker_indent', 0.0).to_f,
|
|
546
|
-
marker_width: config.fetch('marker_width', 18.0).to_f,
|
|
547
|
-
body_indent: config.fetch('body_indent', 6.0).to_f,
|
|
548
|
-
item_spacing: config.fetch('item_spacing', 0.0).to_f
|
|
549
|
-
}
|
|
550
|
-
end
|
|
551
|
-
|
|
552
|
-
def flowable_for_list_content(content)
|
|
553
|
-
case content
|
|
554
|
-
when Content::Paragraph then [paragraph_flowable(content)]
|
|
555
|
-
when Content::List then [list_flowable(content)]
|
|
556
|
-
else []
|
|
557
|
-
end
|
|
558
|
-
end
|
|
559
|
-
|
|
560
|
-
def default_marker(list, index, container: nil)
|
|
561
|
-
defaults = @rules.dig('list', 'defaults') || {}
|
|
562
|
-
if list.ordered?
|
|
563
|
-
format = defaults['ordered_marker'] || '%d.'
|
|
564
|
-
format.sub('%d', (index + 1).to_s)
|
|
565
|
-
else
|
|
566
|
-
nested = @rules.dig('note', 'list') || {}
|
|
567
|
-
return nested['bullet_marker'] if container == :note && nested['bullet_marker']
|
|
568
|
-
|
|
569
|
-
defaults['bullet_marker'] || '■ '
|
|
570
|
-
end
|
|
571
|
-
end
|
|
572
|
-
|
|
573
|
-
def image_flowable(image)
|
|
574
|
-
source = resolve_image_source(image)
|
|
575
|
-
image_rules = @rules['image'] || {}
|
|
576
|
-
default_width = (image_rules['default_natural_width'] || 400).to_f
|
|
577
|
-
default_height = (image_rules['default_natural_height'] || 300).to_f
|
|
578
|
-
max_width = (image_rules['max_display_width'] || 106).to_f
|
|
579
|
-
natural_width = image.width || svg_dimension(source, 'width') || default_width
|
|
580
|
-
natural_height = image.height || svg_dimension(source, 'height') || default_height
|
|
581
|
-
display_width = [image.width || natural_width, max_width].min
|
|
582
|
-
Flowables::ImageFlowable.new(
|
|
583
|
-
source,
|
|
584
|
-
natural_width: natural_width,
|
|
585
|
-
natural_height: natural_height,
|
|
586
|
-
display_width: display_width,
|
|
587
|
-
alt: image.alt,
|
|
588
|
-
style: resolve(image.style_id)
|
|
589
|
-
)
|
|
590
|
-
end
|
|
591
|
-
|
|
592
|
-
def resolve_image_source(image)
|
|
593
|
-
return asset_resolver.resolve(image.src) unless image.src.is_a?(String)
|
|
594
|
-
return write_inline_svg(image.src) if image.src.start_with?(INLINE_SVG_PREFIX)
|
|
595
|
-
|
|
596
|
-
asset_resolver.resolve(image.src)
|
|
597
|
-
end
|
|
598
|
-
|
|
599
|
-
def write_inline_svg(prefixed)
|
|
600
|
-
svg_xml = prefixed[INLINE_SVG_PREFIX.length..]
|
|
601
|
-
path = File.join(Dir.mktmpdir('arrolio-svg'), 'figure.svg')
|
|
602
|
-
File.write(path, svg_xml)
|
|
603
|
-
path
|
|
604
|
-
end
|
|
605
|
-
|
|
606
|
-
def svg_dimension(source, name)
|
|
607
|
-
return nil unless source && File.exist?(source) && source.match?(/\.svg\z/i)
|
|
608
|
-
|
|
609
|
-
value = File.read(source)[/(?:#{name})=["']([\d.]+)/, 1]
|
|
610
|
-
value && (value.to_f * SVG_PX_TO_PT)
|
|
611
|
-
end
|
|
612
|
-
|
|
613
218
|
def resolve(style_id)
|
|
614
219
|
@layout_spec.resolve_style(style_id.to_sym)
|
|
615
220
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Renderer
|
|
5
|
+
class Pdf
|
|
6
|
+
# Extracted concern: Pdfrb coupling points kept out of the
|
|
7
|
+
# emission path.
|
|
8
|
+
module Assets
|
|
9
|
+
def register_logo(path)
|
|
10
|
+
return nil unless path
|
|
11
|
+
|
|
12
|
+
@document.images.add(path)
|
|
13
|
+
rescue StandardError => e
|
|
14
|
+
Arrolio::Logger.warn "logo load failed: #{e.class}: #{e.message[0, 80]}"
|
|
15
|
+
nil
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register_image(path)
|
|
19
|
+
return @images[path] if @images.key?(path)
|
|
20
|
+
|
|
21
|
+
resolved = resolve_image_for_pdfrb(path)
|
|
22
|
+
return nil unless resolved
|
|
23
|
+
|
|
24
|
+
@images[path] = @document.images.add(resolved)
|
|
25
|
+
rescue StandardError => e
|
|
26
|
+
Arrolio::Logger.warn "register_image failed for #{path}: #{e.class}: #{e.message[0, 80]}"
|
|
27
|
+
nil
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Pdfrb supports JPEG, PNG, and PDF — not SVG. If +path+ is an
|
|
31
|
+
# SVG file, rasterize it to PNG via rsvg-convert and cache the
|
|
32
|
+
# result. Returns the path to a pdfrb-compatible image file.
|
|
33
|
+
def resolve_image_for_pdfrb(path)
|
|
34
|
+
return path unless path.to_s.end_with?('.svg', '.SVG')
|
|
35
|
+
return path unless File.exist?(path)
|
|
36
|
+
|
|
37
|
+
png_path = svg_to_png_cache_path(path)
|
|
38
|
+
return png_path if File.exist?(png_path)
|
|
39
|
+
|
|
40
|
+
rasterize_svg(path, png_path)
|
|
41
|
+
File.exist?(png_path) ? png_path : path
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def svg_to_png_cache_path(svg_path)
|
|
45
|
+
digest = Digest::MD5.file(svg_path).hexdigest
|
|
46
|
+
File.join(Dir.tmpdir, 'arrolio-svg-' + digest + '.png')
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def rasterize_svg(svg_path, png_path)
|
|
50
|
+
require 'open3'
|
|
51
|
+
result = Open3.capture3('rsvg-convert', '-d', '96', '-p', '96',
|
|
52
|
+
'-o', png_path, svg_path)
|
|
53
|
+
return if result[2].success?
|
|
54
|
+
|
|
55
|
+
Arrolio::Logger.warn "rsvg-convert failed: #{result[1]}"
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
Arrolio::Logger.warn "rasterize_svg failed: #{e.class}: #{e.message[0, 80]}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def cover_logo_style
|
|
61
|
+
return @cover_logo_style if @cover_logo_style
|
|
62
|
+
|
|
63
|
+
config = @layout_spec&.cover_logo_config || {}
|
|
64
|
+
width_mm = config['width_mm'] || 35.0
|
|
65
|
+
ratio = config['aspect_ratio'] || (1459.0 / 1667.0)
|
|
66
|
+
margin_mm = config['margin_mm'] || 25.5
|
|
67
|
+
@cover_logo_style = {
|
|
68
|
+
width: width_mm * MM_TO_PT,
|
|
69
|
+
height: width_mm * ratio * MM_TO_PT,
|
|
70
|
+
margin: margin_mm * MM_TO_PT
|
|
71
|
+
}.freeze
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def render_cover_logo(canvas, page)
|
|
75
|
+
logo_style = cover_logo_style
|
|
76
|
+
w = logo_style[:width]
|
|
77
|
+
h = logo_style[:height]
|
|
78
|
+
margin = logo_style[:margin]
|
|
79
|
+
x = page.width - margin - w
|
|
80
|
+
y = page.height - margin - h
|
|
81
|
+
invoke = invoke_xobject_op
|
|
82
|
+
return unless invoke
|
|
83
|
+
|
|
84
|
+
canvas.save_graphics_state do
|
|
85
|
+
canvas.concat(w, 0, 0, h, x, y)
|
|
86
|
+
canvas.emit_op(invoke, @logo_ref)
|
|
87
|
+
end
|
|
88
|
+
rescue StandardError => e
|
|
89
|
+
Arrolio::Logger.warn "logo render failed: #{e.class}: #{e.message[0, 80]}"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Renderer
|
|
5
|
+
class Pdf
|
|
6
|
+
# Extracted concern: Pdfrb coupling points kept out of the
|
|
7
|
+
# emission path.
|
|
8
|
+
module FontEmbedding
|
|
9
|
+
# Pre-scan all pages, collect codepoints per font family,
|
|
10
|
+
# then call Font::Embedder for each font_path.
|
|
11
|
+
def prepare_embedded_fonts(pages)
|
|
12
|
+
return if @font_paths.empty?
|
|
13
|
+
|
|
14
|
+
codepoints = Hash.new { |h, k| h[k] = [] }
|
|
15
|
+
pages.each { |page| collect_codepoints(page, codepoints) }
|
|
16
|
+
Arrolio::Logger.debug "collected codepoints for fonts: #{codepoints.keys.inspect}"
|
|
17
|
+
@font_paths.each do |font_name, path|
|
|
18
|
+
exists = File.exist?(path)
|
|
19
|
+
has_cp = codepoints.key?(font_name)
|
|
20
|
+
cp_count = codepoints[font_name]&.length || 0
|
|
21
|
+
Arrolio::Logger.debug "font #{font_name}: exists=#{exists} cp=#{cp_count}"
|
|
22
|
+
next unless exists
|
|
23
|
+
next unless has_cp
|
|
24
|
+
|
|
25
|
+
cps = codepoints[font_name].uniq
|
|
26
|
+
embedder = Font::Embedder.new(@document, path,
|
|
27
|
+
base_font_name: font_name)
|
|
28
|
+
ref = embedder.embed(cps)
|
|
29
|
+
attach_to_resources(font_name, ref)
|
|
30
|
+
@font_embedders[font_name] = embedder
|
|
31
|
+
@font_encoders[font_name] = Font::TextEncoder.new(embedder)
|
|
32
|
+
@font_refs[font_name] = ref
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
Arrolio::Logger.warn "embed failed for #{font_name}: #{e.class}: #{e.message[0,100]}"
|
|
35
|
+
Arrolio::Logger.debug e.backtrace.first(5).join("\n")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def attach_to_resources(font_name, ref)
|
|
40
|
+
catalog = @document.catalog
|
|
41
|
+
res = catalog.value[:Resources]
|
|
42
|
+
Arrolio::Logger.debug "attach_to_resources: Resources=#{res.class}"
|
|
43
|
+
unless res.is_a?(Hash) && res[:Font].is_a?(Hash)
|
|
44
|
+
catalog.value[:Resources] = { Font: {} }
|
|
45
|
+
end
|
|
46
|
+
font_hash = catalog.value[:Resources][:Font]
|
|
47
|
+
key = (format('EF%d', (font_hash.length + 1))).to_sym
|
|
48
|
+
font_hash[key] = ref
|
|
49
|
+
@embedded_resource_keys ||= {}
|
|
50
|
+
@embedded_resource_keys[font_name] = key
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def collect_codepoints(page, by_font)
|
|
54
|
+
(page.static_regions.values + page.regions.values).each do |region|
|
|
55
|
+
region.placed_boxes.each do |box|
|
|
56
|
+
next unless box.kind == :text
|
|
57
|
+
next unless box.data.is_a?(Hash) && box.data[:lines]
|
|
58
|
+
|
|
59
|
+
box.data[:lines].each do |line|
|
|
60
|
+
next unless line.is_a?(TextLayout::Line)
|
|
61
|
+
line.placed_runs.each do |pr|
|
|
62
|
+
next unless pr.run.is_a?(InlineRun)
|
|
63
|
+
# Collect under the RUN'S font name, not the
|
|
64
|
+
# paragraph's. Italic/bold runs have different
|
|
65
|
+
# font_names and need their own subsets.
|
|
66
|
+
run_font = pr.run.style.font_name
|
|
67
|
+
next unless @font_paths.key?(run_font)
|
|
68
|
+
pr.run.text.each_codepoint { |cp| by_font[run_font] << cp }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
module Renderer
|
|
5
|
+
class Pdf
|
|
6
|
+
# Extracted concern: Pdfrb coupling points kept out of the
|
|
7
|
+
# emission path.
|
|
8
|
+
module Metadata
|
|
9
|
+
def apply_metadata(metadata)
|
|
10
|
+
info = @document.catalog.value[:Info]
|
|
11
|
+
info ||= @document.add({})
|
|
12
|
+
@document.catalog.value[:Info] = info
|
|
13
|
+
info.value[:Title] = metadata[:title] if metadata[:title]
|
|
14
|
+
info.value[:Author] = metadata[:author] if metadata[:author]
|
|
15
|
+
info.value[:Creator] = 'Arrolio (Ruby)'
|
|
16
|
+
info.value[:Producer] = 'Arrolio + Pdfrb'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Emit an XMP metadata packet (RDF/XML) as a stream on the
|
|
20
|
+
# catalog's /Metadata entry. PDF readers use this for Dublin
|
|
21
|
+
# Core properties alongside the legacy /Info dict.
|
|
22
|
+
def attach_xmp_metadata(metadata)
|
|
23
|
+
xmp = XmpBuilder.new(metadata).build
|
|
24
|
+
stream = @document.add(
|
|
25
|
+
{ Type: :Metadata, Subtype: :XML, Length: xmp.bytesize },
|
|
26
|
+
type: Pdfrb::Model::Cos::Stream
|
|
27
|
+
)
|
|
28
|
+
stream.stream = xmp
|
|
29
|
+
@document.catalog.value[:Metadata] =
|
|
30
|
+
Pdfrb::Model::Reference.new(stream.oid, stream.gen)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
Arrolio::Logger.warn "XMP attach failed: #{e.class}: #{e.message[0, 80]}"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def build_outline(context, _pages)
|
|
36
|
+
entries = context.heading_entries
|
|
37
|
+
return unless entries&.any?
|
|
38
|
+
|
|
39
|
+
pdfrb_pages = @document.pages.to_a
|
|
40
|
+
ob = OutlineBuilder.new(document: @document,
|
|
41
|
+
entries: entries,
|
|
42
|
+
pdfrb_pages: pdfrb_pages)
|
|
43
|
+
result = ob.build
|
|
44
|
+
Arrolio::Logger.debug "outline build returned: #{result.class}"
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Arrolio::Logger.warn "outline build failed: #{e.class}: #{e.message[0,150]}"
|
|
47
|
+
Arrolio::Logger.debug e.backtrace.first(5).join("\n")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|