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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e9d301e46536064070bccbf04046e06cdb94b4eab0aba48480c1d8206ab067e9
|
|
4
|
+
data.tar.gz: f6f3d63a924dbba3cb2af4d0b818e9b26516a1e7feb315e9c9730cc79d680e7f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d4e6680ffacf7bbbd4712464d491d2a25113af8042ab21dae643b96b1c7c24d50b57f0133a782f29516baf5040fd3ee7af5e6a4206695b5f74e301145708c6d8
|
|
7
|
+
data.tar.gz: 481a67ecf12a9875149753453985ce2ab458717b7a05097581fc30723ef831ac3b17e113915611c50ba4a95672c29c47829eee39ec9bf5b798a408b1c3ef8cd5
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class GenericFlowBuilder
|
|
5
|
+
# Extracted emission seam: one module per content family, the
|
|
6
|
+
# same pattern as GenericAdapter's decomposition. The class
|
|
7
|
+
# keeps build(), dispatch, and shared helpers.
|
|
8
|
+
module Bibliography
|
|
9
|
+
def bibliography_item_flowable(item, out)
|
|
10
|
+
tag = item.tag.to_s
|
|
11
|
+
body_para = bibliography_body_paragraph(item)
|
|
12
|
+
if tag.empty?
|
|
13
|
+
out << paragraph_flowable(body_para)
|
|
14
|
+
return
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
body = paragraph_flowable(body_para)
|
|
18
|
+
out << Flowables::NoteFlowable.new(
|
|
19
|
+
tag,
|
|
20
|
+
[body],
|
|
21
|
+
style: resolve(item.style_id || :bibitem),
|
|
22
|
+
label_style: resolve(:bibitem_marker),
|
|
23
|
+
marker_width: 24.0
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def bibliography_body_paragraph(item)
|
|
28
|
+
runs = []
|
|
29
|
+
if item.formattedref
|
|
30
|
+
runs.concat(item.formattedref.inline_runs.map do |r|
|
|
31
|
+
Content::InlineRun.new(r.text, style_id: r.style_id)
|
|
32
|
+
end)
|
|
33
|
+
end
|
|
34
|
+
Content::Paragraph.new(runs, style_id: item.style_id || :bibitem,
|
|
35
|
+
id: item.id)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def marker_width_of(tag)
|
|
39
|
+
return 0.0 if tag.to_s.empty?
|
|
40
|
+
|
|
41
|
+
style = resolve(:bibitem_marker)
|
|
42
|
+
GlyphMeasurer.new(font_name: style.font_name)
|
|
43
|
+
.width_of_string("#{tag} ", font_size: style.font_size)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class GenericFlowBuilder
|
|
5
|
+
# Extracted emission seam: one module per content family, the
|
|
6
|
+
# same pattern as GenericAdapter's decomposition. The class
|
|
7
|
+
# keeps build(), dispatch, and shared helpers.
|
|
8
|
+
module Figures
|
|
9
|
+
INLINE_SVG_PREFIX = 'inline-svg:'
|
|
10
|
+
# Figures are atomic: image + caption render as ONE flowable
|
|
11
|
+
# so the caption can never be orphaned onto the next page.
|
|
12
|
+
# Caption gap is flavor geometry (ref: image bottom to caption
|
|
13
|
+
# ~28pt; figure blocks separated by ~24pt).
|
|
14
|
+
def figure_group_flowable(group, out)
|
|
15
|
+
figure_config = @rules['figure'] || {}
|
|
16
|
+
caption_gap = figure_config.fetch('caption_gap', 0.0).to_f
|
|
17
|
+
block_gap = figure_config.fetch('block_gap', 0.0).to_f
|
|
18
|
+
|
|
19
|
+
image = image_flowable(group.image) if group.image
|
|
20
|
+
if group.caption
|
|
21
|
+
caption_style = resolve(:figure_caption).with(margin_top: caption_gap)
|
|
22
|
+
runs = group.caption.inline_runs.map do |run|
|
|
23
|
+
InlineRun.new(run.text, style: caption_style)
|
|
24
|
+
end
|
|
25
|
+
caption = Flowables::TextFlowable.new(runs, style: caption_style)
|
|
26
|
+
end
|
|
27
|
+
return out << caption if image.nil?
|
|
28
|
+
|
|
29
|
+
image = with_block_gap(image, block_gap)
|
|
30
|
+
out << if caption
|
|
31
|
+
Flowables::FigureFlowable.new(image, caption)
|
|
32
|
+
else
|
|
33
|
+
image
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def with_block_gap(flowable, gap)
|
|
38
|
+
return flowable if gap.zero?
|
|
39
|
+
|
|
40
|
+
style = flowable.style.with(margin_top: flowable.style.margin_top + gap)
|
|
41
|
+
flowable.class.new(flowable.src,
|
|
42
|
+
natural_width: flowable.natural_width,
|
|
43
|
+
natural_height: flowable.natural_height,
|
|
44
|
+
display_width: flowable.display_width,
|
|
45
|
+
alt: flowable.alt,
|
|
46
|
+
style: style)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def image_flowable(image)
|
|
50
|
+
source = resolve_image_source(image)
|
|
51
|
+
image_rules = @rules['image'] || {}
|
|
52
|
+
default_width = (image_rules['default_natural_width'] || 400).to_f
|
|
53
|
+
default_height = (image_rules['default_natural_height'] || 300).to_f
|
|
54
|
+
max_width = (image_rules['max_display_width'] || 106).to_f
|
|
55
|
+
natural_width = image.width || svg_dimension(source, 'width') || default_width
|
|
56
|
+
natural_height = image.height || svg_dimension(source, 'height') || default_height
|
|
57
|
+
display_width = [image.width || natural_width, max_width].min
|
|
58
|
+
Flowables::ImageFlowable.new(
|
|
59
|
+
source,
|
|
60
|
+
natural_width: natural_width,
|
|
61
|
+
natural_height: natural_height,
|
|
62
|
+
display_width: display_width,
|
|
63
|
+
alt: image.alt,
|
|
64
|
+
style: resolve(image.style_id)
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def resolve_image_source(image)
|
|
69
|
+
return asset_resolver.resolve(image.src) unless image.src.is_a?(String)
|
|
70
|
+
return write_inline_svg(image.src) if image.src.start_with?(INLINE_SVG_PREFIX)
|
|
71
|
+
|
|
72
|
+
asset_resolver.resolve(image.src)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def write_inline_svg(prefixed)
|
|
76
|
+
svg_xml = prefixed[INLINE_SVG_PREFIX.length..]
|
|
77
|
+
path = File.join(Dir.mktmpdir('arrolio-svg'), 'figure.svg')
|
|
78
|
+
File.write(path, svg_xml)
|
|
79
|
+
path
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def svg_dimension(source, name)
|
|
83
|
+
return nil unless source && File.exist?(source) && source.match?(/\.svg\z/i)
|
|
84
|
+
|
|
85
|
+
value = File.read(source)[/(?:#{name})=["']([\d.]+)/, 1]
|
|
86
|
+
value && (value.to_f * SVG_PX_TO_PT)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class GenericFlowBuilder
|
|
5
|
+
# Extracted emission seam: one module per content family, the
|
|
6
|
+
# same pattern as GenericAdapter's decomposition. The class
|
|
7
|
+
# keeps build(), dispatch, and shared helpers.
|
|
8
|
+
module Lists
|
|
9
|
+
def list_flowable(list, container: nil)
|
|
10
|
+
items = list.items.each_with_index.map do |item, index|
|
|
11
|
+
marker = item.marker.nil? ? default_marker(list, index, container: container) : item.marker
|
|
12
|
+
body = item.content.flat_map do |content|
|
|
13
|
+
flowable_for_list_content(content)
|
|
14
|
+
end
|
|
15
|
+
[marker, body]
|
|
16
|
+
end
|
|
17
|
+
Flowables::ListFlowable.new(items, kind: list.kind,
|
|
18
|
+
style: resolve(list.style_id),
|
|
19
|
+
**list_geometry(list.kind, container: container))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# List marker geometry (indent, marker column, inter-item
|
|
23
|
+
# spacing) is flavor configuration extracted from the flavor's
|
|
24
|
+
# XSL list styles — not engine policy.
|
|
25
|
+
# A kind-specific map (e.g. geometry.ordered) overrides the
|
|
26
|
+
# shared defaults — flavors whose bullets indent but whose
|
|
27
|
+
# ordered markers start at the margin (which also restores
|
|
28
|
+
# nested lists' depth).
|
|
29
|
+
def list_geometry(kind, container: nil)
|
|
30
|
+
config = @rules.dig('list', 'geometry') || {}
|
|
31
|
+
config = config.merge(config[kind.to_s] || {})
|
|
32
|
+
# Lists nested in note bodies sit deeper: the reference puts
|
|
33
|
+
# 3.1.3.1's note bullets at +43.7pt from the note column
|
|
34
|
+
# (marker x=143 from the page), pitch 17pt.
|
|
35
|
+
config = config.merge(@rules.dig('note', 'list') || {}) if container == :note
|
|
36
|
+
{
|
|
37
|
+
marker_indent: config.fetch('marker_indent', 0.0).to_f,
|
|
38
|
+
marker_width: config.fetch('marker_width', 18.0).to_f,
|
|
39
|
+
body_indent: config.fetch('body_indent', 6.0).to_f,
|
|
40
|
+
item_spacing: config.fetch('item_spacing', 0.0).to_f
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def flowable_for_list_content(content)
|
|
45
|
+
case content
|
|
46
|
+
when Content::Paragraph then [paragraph_flowable(content)]
|
|
47
|
+
when Content::List then [list_flowable(content)]
|
|
48
|
+
else []
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def default_marker(list, index, container: nil)
|
|
53
|
+
defaults = @rules.dig('list', 'defaults') || {}
|
|
54
|
+
if list.ordered?
|
|
55
|
+
format = defaults['ordered_marker'] || '%d.'
|
|
56
|
+
format.sub('%d', (index + 1).to_s)
|
|
57
|
+
else
|
|
58
|
+
nested = @rules.dig('note', 'list') || {}
|
|
59
|
+
return nested['bullet_marker'] if container == :note && nested['bullet_marker']
|
|
60
|
+
|
|
61
|
+
defaults['bullet_marker'] || '■ '
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class GenericFlowBuilder
|
|
5
|
+
# Extracted emission seam: one module per content family, the
|
|
6
|
+
# same pattern as GenericAdapter's decomposition. The class
|
|
7
|
+
# keeps build(), dispatch, and shared helpers.
|
|
8
|
+
module Notes
|
|
9
|
+
def note_flowable(note)
|
|
10
|
+
body = note.body.map { |node| note_body_flowable(node) }
|
|
11
|
+
body = with_note_body_spacing(body)
|
|
12
|
+
Flowables::NoteFlowable.new(
|
|
13
|
+
formatted_note_label(note.label),
|
|
14
|
+
body,
|
|
15
|
+
style: resolve(note.style_id),
|
|
16
|
+
label_style: resolve(:note_label)
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# A note's trailing list sits ~7pt below its text in the
|
|
21
|
+
# reference (3.1.3.1: text -> list 20pt vs a plain 13pt pitch).
|
|
22
|
+
def with_note_body_spacing(body)
|
|
23
|
+
spacing = (@rules.dig('note', 'body_spacing') || 0.0).to_f
|
|
24
|
+
return body if spacing.zero? || body.length < 2
|
|
25
|
+
|
|
26
|
+
body.flat_map.with_index do |flowable, index|
|
|
27
|
+
index < body.length - 1 ? [flowable, Flowables::Spacer.new(spacing)] : [flowable]
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def note_body_flowable(node)
|
|
32
|
+
return list_flowable(node, container: :note) if node.is_a?(Content::List)
|
|
33
|
+
|
|
34
|
+
paragraph_flowable(node)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def formatted_note_label(label)
|
|
38
|
+
return '' if label.nil? || label.empty?
|
|
39
|
+
|
|
40
|
+
suffix = @rules.dig('note', 'label_suffix') || ':'
|
|
41
|
+
stripped = label.strip.chomp(':').strip
|
|
42
|
+
return '' if stripped.empty?
|
|
43
|
+
|
|
44
|
+
suffix.start_with?(':') ? "#{stripped}#{suffix} " : "#{stripped} #{suffix} "
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Examples render the label as a block heading with the body
|
|
48
|
+
# indented 35.4pt — the FOP example layout.
|
|
49
|
+
def example_flowable(example)
|
|
50
|
+
body = example.body.map { |paragraph| paragraph_flowable(paragraph) }
|
|
51
|
+
Flowables::NoteFlowable.new(
|
|
52
|
+
example.label,
|
|
53
|
+
body,
|
|
54
|
+
style: resolve(example.style_id),
|
|
55
|
+
body_indent: 35.4,
|
|
56
|
+
label_mode: :block
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class GenericFlowBuilder
|
|
5
|
+
# Extracted emission seam: one module per content family, the
|
|
6
|
+
# same pattern as GenericAdapter's decomposition. The class
|
|
7
|
+
# keeps build(), dispatch, and shared helpers.
|
|
8
|
+
module Sequences
|
|
9
|
+
def page_sequences
|
|
10
|
+
Array(@rules['page_sequences'])
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def sequence_start(document, sequence)
|
|
14
|
+
title = sequence['role'].to_s == 'body' ? title_text(document) : nil
|
|
15
|
+
Flowables::PageSequenceStart.new(
|
|
16
|
+
role: sequence.fetch('role'),
|
|
17
|
+
header_template: interpolate(sequence['header'], document),
|
|
18
|
+
footer_template: sequence['footer'],
|
|
19
|
+
header_align: sequence['header_align'],
|
|
20
|
+
footer_align: sequence['footer_align'] || :center,
|
|
21
|
+
initial_page_number: sequence['initial_page_number'],
|
|
22
|
+
title_template: title
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_sequence_content(document, source, sequence, out)
|
|
27
|
+
if sequence['build_content'] == 'cover_content'
|
|
28
|
+
build_cover_content(document, out)
|
|
29
|
+
elsif source.is_a?(Array)
|
|
30
|
+
build_sections(source, out,
|
|
31
|
+
between_breaks: preface_clause_breaks?(sequence))
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# mn2pdf gives each preface clause its own page sequence (ToC
|
|
36
|
+
# page, then Foreword on a fresh page) when the flavor opts in.
|
|
37
|
+
def preface_clause_breaks?(sequence)
|
|
38
|
+
sequence['role'].to_s == 'preface' &&
|
|
39
|
+
@rules.dig('preface', 'page_break_between_clauses')
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The first body page opens with the part title ("Part 1 -
|
|
43
|
+
# {part title}") - the docidentifier's trailing part number
|
|
44
|
+
# supplies the prefix.
|
|
45
|
+
def title_text(document)
|
|
46
|
+
return nil unless document.title_block
|
|
47
|
+
|
|
48
|
+
text = document.title_block.inline_runs.map(&:text).join.strip
|
|
49
|
+
return nil if text.empty?
|
|
50
|
+
|
|
51
|
+
part = document.metadata[:docidentifier].to_s[/-(\d+)\z/, 1]
|
|
52
|
+
part ? "Part #{part} - #{text}" : text
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def content_for(document, role)
|
|
56
|
+
case role.to_s
|
|
57
|
+
when 'preface' then document.preface
|
|
58
|
+
when 'body' then document.sections
|
|
59
|
+
when 'bibliography' then document.bibliography
|
|
60
|
+
else []
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def build_cover_content(document, out)
|
|
65
|
+
Array(@rules['cover_content']).each do |entry|
|
|
66
|
+
next unless condition_matches?(entry['when'], document)
|
|
67
|
+
|
|
68
|
+
case entry['type'].to_s
|
|
69
|
+
when 'spacer'
|
|
70
|
+
out << Flowables::Spacer.new(entry.fetch('size').to_f)
|
|
71
|
+
when 'text'
|
|
72
|
+
style = resolve(entry.fetch('style'))
|
|
73
|
+
style = style.with(align: :center) if entry.fetch('align', 'center').to_sym == :center
|
|
74
|
+
text = interpolate(entry.fetch('source'), document)
|
|
75
|
+
out << Flowables::TextFlowable.new([InlineRun.new(text, style: style)], style: style)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def append_endnotes(document, flowables)
|
|
81
|
+
return if document.footnotes.empty?
|
|
82
|
+
return unless @rules['endnotes']
|
|
83
|
+
|
|
84
|
+
flowables << Flowables::PageSequenceStart.new(
|
|
85
|
+
role: :endnotes,
|
|
86
|
+
header_template: nil,
|
|
87
|
+
footer_template: nil
|
|
88
|
+
)
|
|
89
|
+
document.footnotes.each do |footnote|
|
|
90
|
+
marker_text = footnote.marker.to_s
|
|
91
|
+
body = footnote.body.map { |paragraph| paragraph_flowable(paragraph) }
|
|
92
|
+
flowables << Flowables::NoteFlowable.new(
|
|
93
|
+
marker_text,
|
|
94
|
+
body,
|
|
95
|
+
style: resolve(footnote.style_id),
|
|
96
|
+
marker_width: 22.0
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class GenericFlowBuilder
|
|
5
|
+
# Extracted emission seam: one module per content family, the
|
|
6
|
+
# same pattern as GenericAdapter's decomposition. The class
|
|
7
|
+
# keeps build(), dispatch, and shared helpers.
|
|
8
|
+
module Tables
|
|
9
|
+
def caption_runs_for(table)
|
|
10
|
+
return nil if table.caption.nil?
|
|
11
|
+
|
|
12
|
+
paragraph_flowable(table.caption, standalone: false).runs
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Table geometry (minimum row height, cell padding, footnote
|
|
16
|
+
# size) is flavor configuration — extracted from the flavor's
|
|
17
|
+
# XSL row/cell styles — not engine policy.
|
|
18
|
+
def table_geometry
|
|
19
|
+
config = @rules['table'] || {}
|
|
20
|
+
{
|
|
21
|
+
min_row_height: config.fetch('min_row_height', 0.0).to_f,
|
|
22
|
+
cell_padding: config.fetch('cell_padding', 2.0).to_f,
|
|
23
|
+
footnote_font_size: config['footnote_font_size']&.to_f
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Arrolio
|
|
4
|
+
class GenericFlowBuilder
|
|
5
|
+
# Extracted emission seam: one module per content family, the
|
|
6
|
+
# same pattern as GenericAdapter's decomposition. The class
|
|
7
|
+
# keeps build(), dispatch, and shared helpers.
|
|
8
|
+
module Terms
|
|
9
|
+
def term_config
|
|
10
|
+
@rules['term'] || {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def term_entry_flowable(entry, out)
|
|
14
|
+
if entry.number
|
|
15
|
+
number_style = resolve(:term).with(margin_top: 12.0, margin_bottom: 0.0)
|
|
16
|
+
out << Flowables::TextFlowable.new(
|
|
17
|
+
[InlineRun.new(entry.number, style: number_style)],
|
|
18
|
+
style: number_style
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
if entry.preferred
|
|
22
|
+
preferred_style = resolve(:term).with(margin_top: 0.0, margin_bottom: 6.0)
|
|
23
|
+
out << Flowables::TextFlowable.new(
|
|
24
|
+
entry.preferred.inline_runs.map do |run|
|
|
25
|
+
InlineRun.new(run.text, style: resolve(run.style_id),
|
|
26
|
+
baseline_shift: run.baseline_shift,
|
|
27
|
+
font_size_scale: run.font_size_scale,
|
|
28
|
+
href: run.href)
|
|
29
|
+
end,
|
|
30
|
+
style: preferred_style
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
# FOP applies space-before BETWEEN sibling blocks (not before
|
|
34
|
+
# the first) — term definitions' second+ paragraphs (the
|
|
35
|
+
# "(For notes...)" line) and the SOURCE line each carry it.
|
|
36
|
+
entry.definition.each_with_index do |item, index|
|
|
37
|
+
append_child(item, out, standalone: false, in_term: true)
|
|
38
|
+
if index.zero?
|
|
39
|
+
widen_first_definition_widows(out)
|
|
40
|
+
else
|
|
41
|
+
apply_sibling_spacing(item, out)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
return unless entry.source
|
|
45
|
+
|
|
46
|
+
source_style = resolve(entry.source.style_id)
|
|
47
|
+
.with(margin_top: term_config.fetch('source_space_before', 2.0).to_f,
|
|
48
|
+
margin_bottom: 2.0)
|
|
49
|
+
source_runs = entry.source.inline_runs.map do |run|
|
|
50
|
+
InlineRun.new(run.text, style: resolve(run.style_id),
|
|
51
|
+
baseline_shift: run.baseline_shift,
|
|
52
|
+
font_size_scale: run.font_size_scale,
|
|
53
|
+
href: run.href)
|
|
54
|
+
end
|
|
55
|
+
out << Flowables::TextFlowable.new(source_runs, style: source_style)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# An entry's head must keep number + preferred + two
|
|
59
|
+
# definition lines together (the reference moves whole entry
|
|
60
|
+
# heads when those don't fit, leaving its characteristic
|
|
61
|
+
# 50-90pt terms-region page-end gaps).
|
|
62
|
+
def widen_first_definition_widows(out)
|
|
63
|
+
widows = term_config.fetch('definition_widows', 1).to_i
|
|
64
|
+
last = out.last
|
|
65
|
+
return unless widows > 1 && last.is_a?(Flowables::TextFlowable)
|
|
66
|
+
|
|
67
|
+
out[-1] = Flowables::TextFlowable.new(
|
|
68
|
+
last.runs,
|
|
69
|
+
style: last.style.with(widows: widows),
|
|
70
|
+
measurer: last.measurer
|
|
71
|
+
)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Re-emits the last paragraph flowable with the configured
|
|
75
|
+
# between-sibling space (XSL-FO space-before semantics).
|
|
76
|
+
def apply_sibling_spacing(_item, out)
|
|
77
|
+
spacing = term_config.fetch('definition_paragraph_space_before', 0.0).to_f
|
|
78
|
+
return if spacing.zero? || out.empty?
|
|
79
|
+
|
|
80
|
+
last = out.last
|
|
81
|
+
return unless last.is_a?(Flowables::TextFlowable)
|
|
82
|
+
|
|
83
|
+
out[-1] = Flowables::TextFlowable.new(last.runs,
|
|
84
|
+
style: last.style.with(margin_top: spacing),
|
|
85
|
+
measurer: last.measurer)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|