arrolio 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.rubocop.yml +2 -2
- data/Rakefile +15 -0
- data/TODO.complete/56-generic-adapter-mece-decomposition.md +20 -0
- data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +92 -55
- data/TODO.complete/70-ttf-font-metrics-parity.md +68 -0
- data/TODO.complete/71-cover-layout-parity.md +45 -0
- data/TODO.complete/72-table-layout-parity.md +49 -0
- data/TODO.complete/73-toc-leaders-parity.md +55 -0
- data/TODO.complete/74-svg-image-rendering-parity.md +76 -0
- data/TODO.complete/75-mathml-formula-rendering-parity.md +52 -0
- data/TODO.complete/76-odd-even-page-templates-parity.md +50 -0
- data/TODO.complete/77-text-formatting-parity.md +55 -0
- data/TODO.complete/78-bibliography-formatting-parity.md +62 -0
- data/TODO.complete/79-line-breaking-parity.md +80 -0
- data/TODO.complete/80-parity-diff-harness-in-ci.md +68 -0
- data/TODO.complete/81-conditional-style-refinements.md +70 -0
- data/TODO.complete/82-semantic-presentation-dedup.md +65 -0
- data/TODO.complete/83-nested-list-support.md +74 -0
- data/TODO.complete/84-figure-svg-rendering.md +70 -0
- data/TODO.complete/85-locality-reference-formatting.md +48 -0
- data/TODO.complete/86-list-item-text-overflow.md +80 -0
- data/TODO.complete/87-odd-even-page-templates.md +55 -0
- data/TODO.complete/88-hyphenation-support.md +52 -0
- data/TODO.complete/89-table-rowspan.md +49 -0
- data/TODO.complete/90-mathml-stacked-layout.md +65 -0
- data/TODO.complete/91-parity-diff-diagnostics.md +50 -0
- data/TODO.complete/92-table-cell-height-parity.md +66 -0
- data/TODO.complete/93-figure-text-searchability.md +49 -0
- data/TODO.complete/94-fo-space-resolution.md +63 -0
- data/TODO.complete/95-title-header-placement.md +55 -0
- data/TODO.complete/96-pagination-drift-parity.md +517 -0
- data/TODO.complete/README.md +58 -38
- data/lib/arrolio/config_driven_pipeline.rb +45 -1
- data/lib/arrolio/content/document.rb +4 -3
- data/lib/arrolio/content/section.rb +3 -0
- data/lib/arrolio/content/table.rb +25 -14
- data/lib/arrolio/embedded_block_extractor.rb +54 -0
- data/lib/arrolio/engine/paged.rb +109 -28
- data/lib/arrolio/flavor/manifest.rb +0 -1
- data/lib/arrolio/flavor/registry.rb +1 -1
- data/lib/arrolio/flowable.rb +21 -0
- data/lib/arrolio/flowables/figure_flowable.rb +17 -0
- data/lib/arrolio/flowables/footnote_marker_flowable.rb +32 -13
- data/lib/arrolio/flowables/group_flowable.rb +57 -0
- data/lib/arrolio/flowables/list_flowable.rb +46 -18
- data/lib/arrolio/flowables/note_flowable.rb +84 -9
- data/lib/arrolio/flowables/page_sequence_start.rb +8 -4
- data/lib/arrolio/flowables/positioned_block.rb +55 -0
- data/lib/arrolio/flowables/rotated_text.rb +51 -0
- data/lib/arrolio/flowables/table_flowable.rb +303 -71
- data/lib/arrolio/flowables/text_flowable.rb +67 -11
- data/lib/arrolio/flowables/toc_line_flowable.rb +70 -7
- data/lib/arrolio/flowables.rb +4 -0
- data/lib/arrolio/font/embedder.rb +22 -3
- data/lib/arrolio/font_scanner.rb +133 -0
- data/lib/arrolio/generic_adapter/document_extraction.rb +101 -0
- data/lib/arrolio/generic_adapter/footnote_extraction.rb +81 -0
- data/lib/arrolio/generic_adapter/heading_extraction.rb +92 -0
- data/lib/arrolio/generic_adapter/inline_run_collection.rb +136 -0
- data/lib/arrolio/generic_adapter/list_conversion.rb +70 -0
- data/lib/arrolio/generic_adapter/metadata_extraction.rb +46 -0
- data/lib/arrolio/generic_adapter/table_conversion.rb +105 -0
- data/lib/arrolio/generic_adapter.rb +263 -317
- data/lib/arrolio/generic_flow_builder.rb +362 -62
- data/lib/arrolio/glyph_measurer.rb +16 -5
- data/lib/arrolio/inline_run.rb +2 -1
- data/lib/arrolio/layout_spec/page_template.rb +14 -8
- data/lib/arrolio/math_ml/inline_run_extractor.rb +272 -0
- data/lib/arrolio/math_ml.rb +7 -0
- data/lib/arrolio/output/page.rb +5 -4
- data/lib/arrolio/output/placed_box.rb +5 -2
- data/lib/arrolio/renderer/pdf.rb +131 -32
- data/lib/arrolio/style/definition.rb +7 -3
- data/lib/arrolio/style/registry.rb +6 -1
- data/lib/arrolio/table/auto_layout.rb +19 -10
- data/lib/arrolio/table/grid.rb +105 -0
- data/lib/arrolio/table.rb +1 -0
- data/lib/arrolio/text_layout/knuth_plass/breaker.rb +194 -92
- data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +31 -17
- data/lib/arrolio/text_layout/knuth_plass.rb +3 -0
- data/lib/arrolio/text_layout/line.rb +3 -1
- data/lib/arrolio/version.rb +1 -1
- data/lib/arrolio.rb +8 -4
- data/scripts/parity_check.rb +52 -0
- data/scripts/parity_diff.rb +89 -0
- data/scripts/xsl_profiles/standoc.yml +8 -2
- data/scripts/xsl_to_config.rb +28 -1
- metadata +81 -5
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'tmpdir'
|
|
3
4
|
require 'yaml'
|
|
4
5
|
|
|
5
6
|
module Arrolio
|
|
6
7
|
class GenericFlowBuilder
|
|
8
|
+
INLINE_SVG_PREFIX = 'inline-svg:'
|
|
9
|
+
|
|
7
10
|
attr_reader :layout_spec, :rules, :asset_resolver
|
|
8
11
|
|
|
9
12
|
def initialize(layout_spec:, rules:, asset_resolver: nil)
|
|
@@ -19,32 +22,20 @@ module Arrolio
|
|
|
19
22
|
source = content_for(document, sequence['role'])
|
|
20
23
|
next if sequence['role'].to_s != 'cover' && sequence['role'].to_s != 'back_of_cover' && source.empty?
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
start = sequence_start(document, sequence)
|
|
26
|
+
flowables << start
|
|
27
|
+
# The part-title overlay consumes layout space in the
|
|
28
|
+
# reference (title glyph top to the first heading: 58pt on
|
|
29
|
+
# the first body page); reserve it so following content
|
|
30
|
+
# lands where the reference puts it.
|
|
31
|
+
flowables << Flowables::Spacer.new(title_block_space) if start.title_template
|
|
23
32
|
build_sequence_content(document, source, sequence, flowables)
|
|
24
33
|
flowables << Flowables::PageBreak.new if sequence['page_break_after']
|
|
25
34
|
end
|
|
26
|
-
append_inline_footnote_markers(document, flowables)
|
|
27
35
|
append_endnotes(document, flowables)
|
|
28
36
|
flowables
|
|
29
37
|
end
|
|
30
38
|
|
|
31
|
-
# Emits a FootnoteMarkerFlowable for each Document#footnote when the
|
|
32
|
-
# flavor opts in via `flow_rules.page_bottom_footnotes: true`. The
|
|
33
|
-
# engine collects these onto the page where they appear (the last
|
|
34
|
-
# body page by default) and the renderer draws them at the bottom.
|
|
35
|
-
#
|
|
36
|
-
# This is a pragmatic bridge until the adapter can emit markers
|
|
37
|
-
# inline at `<fn>` reference sites (TODO 60). Opt-in keeps it
|
|
38
|
-
# disabled by default so existing flavors are unaffected.
|
|
39
|
-
def append_inline_footnote_markers(document, flowables)
|
|
40
|
-
return if document.footnotes.empty?
|
|
41
|
-
return unless @rules['page_bottom_footnotes']
|
|
42
|
-
|
|
43
|
-
document.footnotes.each do |footnote|
|
|
44
|
-
flowables << Flowables::FootnoteMarkerFlowable.new(footnote)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
39
|
def append_endnotes(document, flowables)
|
|
49
40
|
return if document.footnotes.empty?
|
|
50
41
|
return unless @rules['endnotes']
|
|
@@ -72,14 +63,20 @@ module Arrolio
|
|
|
72
63
|
Array(@rules['page_sequences'])
|
|
73
64
|
end
|
|
74
65
|
|
|
66
|
+
def title_block_space
|
|
67
|
+
(@rules.dig('title', 'block_space') || 0.0).to_f
|
|
68
|
+
end
|
|
69
|
+
|
|
75
70
|
def sequence_start(document, sequence)
|
|
71
|
+
title = sequence['role'].to_s == 'body' ? title_text(document) : nil
|
|
76
72
|
Flowables::PageSequenceStart.new(
|
|
77
73
|
role: sequence.fetch('role'),
|
|
78
74
|
header_template: interpolate(sequence['header'], document),
|
|
79
75
|
footer_template: sequence['footer'],
|
|
80
|
-
header_align: sequence['header_align']
|
|
76
|
+
header_align: sequence['header_align'],
|
|
81
77
|
footer_align: sequence['footer_align'] || :center,
|
|
82
|
-
initial_page_number: sequence['initial_page_number']
|
|
78
|
+
initial_page_number: sequence['initial_page_number'],
|
|
79
|
+
title_template: title
|
|
83
80
|
)
|
|
84
81
|
end
|
|
85
82
|
|
|
@@ -87,10 +84,31 @@ module Arrolio
|
|
|
87
84
|
if sequence['build_content'] == 'cover_content'
|
|
88
85
|
build_cover_content(document, out)
|
|
89
86
|
elsif source.is_a?(Array)
|
|
90
|
-
build_sections(source, out
|
|
87
|
+
build_sections(source, out,
|
|
88
|
+
between_breaks: preface_clause_breaks?(sequence))
|
|
91
89
|
end
|
|
92
90
|
end
|
|
93
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
|
+
|
|
94
112
|
def content_for(document, role)
|
|
95
113
|
case role.to_s
|
|
96
114
|
when 'preface' then document.preface
|
|
@@ -116,15 +134,23 @@ module Arrolio
|
|
|
116
134
|
end
|
|
117
135
|
end
|
|
118
136
|
|
|
119
|
-
def build_sections(sections, out)
|
|
137
|
+
def build_sections(sections, out, between_breaks: false)
|
|
120
138
|
section_rules = @rules.fetch('section', {})
|
|
121
139
|
break_between = section_rules.fetch('insert_page_break_before', false)
|
|
140
|
+
break_numbers = section_rules.fetch('page_break_before_numbers', [])
|
|
122
141
|
sections.each_with_index do |section, index|
|
|
123
|
-
|
|
142
|
+
forced = break_numbers.include?(section.number.to_s)
|
|
143
|
+
needs_break = (break_between || forced || between_breaks) &&
|
|
144
|
+
index.positive? && !bibliography_section?(section)
|
|
145
|
+
out << Flowables::PageBreak.new if needs_break
|
|
124
146
|
build_section(section, out)
|
|
125
147
|
end
|
|
126
148
|
end
|
|
127
149
|
|
|
150
|
+
def bibliography_section?(section)
|
|
151
|
+
section.children.any?(Content::BibliographyItem)
|
|
152
|
+
end
|
|
153
|
+
|
|
128
154
|
def build_section(section, out)
|
|
129
155
|
if section.heading?
|
|
130
156
|
out << Flowables::HeadingFlowable.new(
|
|
@@ -138,14 +164,24 @@ module Arrolio
|
|
|
138
164
|
|
|
139
165
|
children = section.children
|
|
140
166
|
children = prefix_number(section.number, children) if !section.heading? && section.number
|
|
141
|
-
children.
|
|
167
|
+
children.each_with_index do |child, index|
|
|
168
|
+
append_child(child, out, followed_by_keep: keeps_with_previous?(children[index + 1]))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Tables and lists pull their preceding paragraph with them
|
|
173
|
+
# across page boundaries (XSL-FO keep-with-next on the lead-in;
|
|
174
|
+
# the reference breaks freely between text and figures — 2.3's
|
|
175
|
+
# paragraph ends p5 with Figure 1 opening p6).
|
|
176
|
+
def keeps_with_previous?(child)
|
|
177
|
+
child.is_a?(Content::Table) || child.is_a?(Content::List)
|
|
142
178
|
end
|
|
143
179
|
|
|
144
180
|
def prefix_number(number, children)
|
|
145
181
|
return children if children.empty? || !children.first.is_a?(Content::Paragraph)
|
|
146
182
|
|
|
147
183
|
first = children.first
|
|
148
|
-
prefix = Content::InlineRun.new("#{number}
|
|
184
|
+
prefix = Content::InlineRun.new("#{number} ", style_id: :caption_label)
|
|
149
185
|
updated = Content::Paragraph.new(
|
|
150
186
|
[prefix] + first.inline_runs,
|
|
151
187
|
style_id: first.style_id,
|
|
@@ -157,23 +193,53 @@ module Arrolio
|
|
|
157
193
|
|
|
158
194
|
def emit_footnote_markers_for(paragraph, out)
|
|
159
195
|
return if paragraph.footnote_refs.empty?
|
|
196
|
+
return unless @rules['page_bottom_footnotes']
|
|
160
197
|
return unless @document
|
|
161
198
|
|
|
162
199
|
paragraph.footnote_refs.each do |ref_id|
|
|
163
200
|
footnote = @document.footnotes.find { |fn| fn.id == ref_id || fn.marker == ref_id }
|
|
164
201
|
next unless footnote
|
|
165
202
|
|
|
166
|
-
out << Flowables::FootnoteMarkerFlowable.new(
|
|
203
|
+
out << Flowables::FootnoteMarkerFlowable.new(
|
|
204
|
+
footnote, style: resolve(:footnote)
|
|
205
|
+
)
|
|
167
206
|
end
|
|
168
207
|
end
|
|
169
208
|
|
|
170
|
-
def append_child(child, out
|
|
209
|
+
def append_child(child, out, standalone: true, followed_by_keep: false,
|
|
210
|
+
in_term: false)
|
|
171
211
|
case child
|
|
172
212
|
when Content::Paragraph
|
|
173
|
-
|
|
213
|
+
flowable = paragraph_flowable(child, standalone: standalone)
|
|
214
|
+
# mn2pdf keeps a paragraph with the table/figure that
|
|
215
|
+
# follows it, so the caption is never orphaned at a page
|
|
216
|
+
# bottom and the reference's page-end whitespace before
|
|
217
|
+
# table sections is reproduced.
|
|
218
|
+
if followed_by_keep
|
|
219
|
+
flowable = Flowables::TextFlowable.new(
|
|
220
|
+
flowable.runs,
|
|
221
|
+
style: flowable.style.with(keep_with_next: true),
|
|
222
|
+
measurer: flowable.measurer
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
out << flowable
|
|
174
226
|
emit_footnote_markers_for(child, out)
|
|
175
227
|
when Content::Note
|
|
176
|
-
|
|
228
|
+
note = note_flowable(child)
|
|
229
|
+
before = in_term ? term_config.fetch('note_spacing', 0.0).to_f : 0.0
|
|
230
|
+
after = in_term ? term_config.fetch('note_spacing_after', before) : 0.0
|
|
231
|
+
parts = []
|
|
232
|
+
parts << Flowables::Spacer.new(before) if before.positive?
|
|
233
|
+
parts << note
|
|
234
|
+
parts << Flowables::Spacer.new(after) if after.positive?
|
|
235
|
+
out << if parts.length == 1
|
|
236
|
+
note
|
|
237
|
+
else
|
|
238
|
+
Flowables::GroupFlowable.new(parts,
|
|
239
|
+
style: Style::Definition.new(keep_together: false))
|
|
240
|
+
end
|
|
241
|
+
when Content::Example
|
|
242
|
+
out << example_flowable(child)
|
|
177
243
|
when Content::FigureGroup
|
|
178
244
|
figure_group_flowable(child, out)
|
|
179
245
|
when Content::TermEntry
|
|
@@ -183,7 +249,11 @@ module Arrolio
|
|
|
183
249
|
when Content::Section
|
|
184
250
|
build_section(child, out)
|
|
185
251
|
when Content::Table
|
|
186
|
-
out << Flowables::TableFlowable.new(child,
|
|
252
|
+
out << Flowables::TableFlowable.new(child,
|
|
253
|
+
style: resolve(child.style_id),
|
|
254
|
+
caption_runs: caption_runs_for(child),
|
|
255
|
+
caption_style: resolve(:figure_caption).with(align: :left),
|
|
256
|
+
**table_geometry)
|
|
187
257
|
when Content::List
|
|
188
258
|
out << list_flowable(child)
|
|
189
259
|
when Content::Image
|
|
@@ -196,51 +266,230 @@ module Arrolio
|
|
|
196
266
|
end
|
|
197
267
|
|
|
198
268
|
def note_flowable(note)
|
|
199
|
-
body = note.body.map { |
|
|
269
|
+
body = note.body.map { |node| note_body_flowable(node) }
|
|
270
|
+
body = with_note_body_spacing(body)
|
|
200
271
|
Flowables::NoteFlowable.new(
|
|
201
|
-
note.label,
|
|
272
|
+
formatted_note_label(note.label),
|
|
202
273
|
body,
|
|
203
|
-
style: resolve(note.style_id)
|
|
274
|
+
style: resolve(note.style_id),
|
|
275
|
+
label_style: resolve(:note_label)
|
|
204
276
|
)
|
|
205
277
|
end
|
|
206
278
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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?
|
|
210
302
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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)
|
|
214
335
|
end
|
|
215
|
-
out <<
|
|
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'] || {}
|
|
216
390
|
end
|
|
217
391
|
|
|
218
392
|
def term_entry_flowable(entry, out)
|
|
219
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)
|
|
220
402
|
out << Flowables::TextFlowable.new(
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
223
410
|
)
|
|
224
411
|
end
|
|
225
|
-
|
|
226
|
-
|
|
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
|
|
227
423
|
return unless entry.source
|
|
228
424
|
|
|
229
|
-
|
|
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)
|
|
230
435
|
end
|
|
231
436
|
|
|
232
437
|
def bibliography_item_flowable(item, out)
|
|
233
|
-
|
|
234
|
-
|
|
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)
|
|
235
446
|
out << Flowables::NoteFlowable.new(
|
|
236
|
-
|
|
237
|
-
body,
|
|
238
|
-
style: resolve(item.style_id),
|
|
239
|
-
|
|
447
|
+
tag,
|
|
448
|
+
[body],
|
|
449
|
+
style: resolve(item.style_id || :bibitem),
|
|
450
|
+
label_style: resolve(:bibitem_marker),
|
|
451
|
+
marker_width: 24.0
|
|
240
452
|
)
|
|
241
453
|
end
|
|
242
454
|
|
|
243
|
-
def
|
|
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
|
+
def paragraph_flowable(paragraph, standalone: false)
|
|
244
493
|
runs = paragraph.inline_runs.map do |run|
|
|
245
494
|
InlineRun.new(
|
|
246
495
|
run.text,
|
|
@@ -250,7 +499,9 @@ module Arrolio
|
|
|
250
499
|
href: run.href
|
|
251
500
|
)
|
|
252
501
|
end
|
|
253
|
-
|
|
502
|
+
style = resolve(paragraph.style_id)
|
|
503
|
+
style = style.with(margin_bottom: 10.0) if standalone && paragraph.style_id == :body
|
|
504
|
+
Flowables::TextFlowable.new(runs, style: style)
|
|
254
505
|
end
|
|
255
506
|
|
|
256
507
|
def preformatted_flowable(preformatted)
|
|
@@ -263,29 +514,64 @@ module Arrolio
|
|
|
263
514
|
Flowables::TextFlowable.new(runs, style: style)
|
|
264
515
|
end
|
|
265
516
|
|
|
266
|
-
def list_flowable(list)
|
|
517
|
+
def list_flowable(list, container: nil)
|
|
267
518
|
items = list.items.each_with_index.map do |item, index|
|
|
268
|
-
marker = item.marker.nil? ? default_marker(list, index) : item.marker
|
|
269
|
-
body = item.content.
|
|
270
|
-
|
|
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)
|
|
271
522
|
end
|
|
272
523
|
[marker, body]
|
|
273
524
|
end
|
|
274
|
-
Flowables::ListFlowable.new(items, kind: list.kind,
|
|
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
|
+
}
|
|
275
550
|
end
|
|
276
551
|
|
|
277
|
-
def
|
|
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)
|
|
278
561
|
defaults = @rules.dig('list', 'defaults') || {}
|
|
279
562
|
if list.ordered?
|
|
280
563
|
format = defaults['ordered_marker'] || '%d.'
|
|
281
564
|
format.sub('%d', (index + 1).to_s)
|
|
282
565
|
else
|
|
566
|
+
nested = @rules.dig('note', 'list') || {}
|
|
567
|
+
return nested['bullet_marker'] if container == :note && nested['bullet_marker']
|
|
568
|
+
|
|
283
569
|
defaults['bullet_marker'] || '■ '
|
|
284
570
|
end
|
|
285
571
|
end
|
|
286
572
|
|
|
287
573
|
def image_flowable(image)
|
|
288
|
-
source =
|
|
574
|
+
source = resolve_image_source(image)
|
|
289
575
|
image_rules = @rules['image'] || {}
|
|
290
576
|
default_width = (image_rules['default_natural_width'] || 400).to_f
|
|
291
577
|
default_height = (image_rules['default_natural_height'] || 300).to_f
|
|
@@ -303,11 +589,25 @@ module Arrolio
|
|
|
303
589
|
)
|
|
304
590
|
end
|
|
305
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
|
+
|
|
306
606
|
def svg_dimension(source, name)
|
|
307
607
|
return nil unless source && File.exist?(source) && source.match?(/\.svg\z/i)
|
|
308
608
|
|
|
309
609
|
value = File.read(source)[/(?:#{name})=["']([\d.]+)/, 1]
|
|
310
|
-
value
|
|
610
|
+
value && (value.to_f * SVG_PX_TO_PT)
|
|
311
611
|
end
|
|
312
612
|
|
|
313
613
|
def resolve(style_id)
|
|
@@ -21,17 +21,28 @@ module Arrolio
|
|
|
21
21
|
!@metrics.nil?
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
return
|
|
24
|
+
def metrics_for(font_name)
|
|
25
|
+
return @metrics if font_name.nil? || font_name.to_s == @font_name
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
::Arrolio::FontMetrics::Registry[font_name.to_s]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# +font_name+ lets a paragraph-level measurer price a run that
|
|
31
|
+
# carries a different font (e.g. a bold run inside a regular
|
|
32
|
+
# paragraph). Falls back to the instance font when nil.
|
|
33
|
+
def width_of_string(str, font_size:, font_name: nil)
|
|
34
|
+
metrics = metrics_for(font_name)
|
|
35
|
+
return estimate(str, font_size) if metrics.nil?
|
|
36
|
+
|
|
37
|
+
metrics.width_of_string(str, font_size: font_size)
|
|
28
38
|
end
|
|
29
39
|
|
|
30
40
|
# Width of a run including character_spacing (Tc — extra space
|
|
31
41
|
# added after every glyph) and word_spacing (Tw — extra space
|
|
32
42
|
# added after every space glyph). Matches PDF rendering rules.
|
|
33
|
-
def width_of_run(str, font_size:, character_spacing: 0, word_spacing: 0
|
|
34
|
-
|
|
43
|
+
def width_of_run(str, font_size:, character_spacing: 0, word_spacing: 0,
|
|
44
|
+
font_name: nil)
|
|
45
|
+
base = width_of_string(str, font_size: font_size, font_name: font_name)
|
|
35
46
|
char_count = str.length
|
|
36
47
|
word_count = str.count(' ')
|
|
37
48
|
extra = (character_spacing.to_f * [char_count - 1, 0].max) +
|
data/lib/arrolio/inline_run.rb
CHANGED
|
@@ -87,27 +87,33 @@ module Arrolio
|
|
|
87
87
|
{ before: 0.0, after: 0.0, start: 0.0, end: 0.0 }
|
|
88
88
|
end
|
|
89
89
|
|
|
90
|
+
# XSL-FO geometry: the body region is the page content
|
|
91
|
+
# rectangle (page minus page margins). The before/after/start/
|
|
92
|
+
# end extents size the auxiliary regions that sit INSIDE the
|
|
93
|
+
# margins — they never displace the body. Shrinking the body
|
|
94
|
+
# by the extents wasted the whole header/footer strip on
|
|
95
|
+
# every page.
|
|
90
96
|
def compute_regions
|
|
91
97
|
pw = width
|
|
92
98
|
ph = height
|
|
93
99
|
m = @margins
|
|
94
100
|
e = @region_extents
|
|
95
|
-
inner_w = pw - m[:left] - m[:right]
|
|
96
|
-
inner_h = ph - m[:top] - m[:bottom]
|
|
101
|
+
inner_w = pw - m[:left] - m[:right]
|
|
102
|
+
inner_h = ph - m[:top] - m[:bottom]
|
|
97
103
|
{
|
|
98
104
|
body: Region.new(name: :body,
|
|
99
|
-
x: m[:left]
|
|
100
|
-
y: m[:bottom]
|
|
105
|
+
x: m[:left],
|
|
106
|
+
y: m[:bottom],
|
|
101
107
|
width: inner_w,
|
|
102
108
|
height: inner_h),
|
|
103
109
|
before: Region.new(name: :before,
|
|
104
|
-
x: m[:left]
|
|
105
|
-
y: ph - m[:top]
|
|
110
|
+
x: m[:left],
|
|
111
|
+
y: ph - m[:top],
|
|
106
112
|
width: inner_w,
|
|
107
113
|
height: e[:before]),
|
|
108
114
|
after: Region.new(name: :after,
|
|
109
|
-
x: m[:left]
|
|
110
|
-
y: m[:bottom],
|
|
115
|
+
x: m[:left],
|
|
116
|
+
y: m[:bottom] - e[:after],
|
|
111
117
|
width: inner_w,
|
|
112
118
|
height: e[:after]),
|
|
113
119
|
start: Region.new(name: :start,
|