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.
@@ -5,7 +5,22 @@ require 'yaml'
5
5
 
6
6
  module Arrolio
7
7
  class GenericFlowBuilder
8
- INLINE_SVG_PREFIX = 'inline-svg:'
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