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
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'mml'
|
|
4
|
+
|
|
5
|
+
module Arrolio
|
|
6
|
+
module MathML
|
|
7
|
+
# Walks a parsed Mml tree (from +Mml.parse(xml_string, version: 3)+)
|
|
8
|
+
# and emits an Array of +Content::InlineRun+ values, applying
|
|
9
|
+
# +baseline_shift+ and +font_size_scale+ to match the math
|
|
10
|
+
# layout semantics of each MathML element.
|
|
11
|
+
#
|
|
12
|
+
# The extractor relies on the canonical Mml gem (plurimath/mml)
|
|
13
|
+
# for parsing, keeping Arrolio free of its own MathML grammar.
|
|
14
|
+
# MathML presentation elements map as follows:
|
|
15
|
+
#
|
|
16
|
+
# <mi> → italic math run (:math_identifier)
|
|
17
|
+
# <mn>, <mo>, <mtext> → regular math run (parent style)
|
|
18
|
+
# <ms> → quoted string run (parent style)
|
|
19
|
+
# <msub> → base + subscript run
|
|
20
|
+
# <msup> → base + superscript run
|
|
21
|
+
# <msubsup> → base + sub + sup
|
|
22
|
+
# <munder>/<mover> → rendered as sub/sup for visual fidelity
|
|
23
|
+
# <mfrac> → numerator + '/' + denominator (inline)
|
|
24
|
+
# <mrow>, <mstyle> → transparent group, recurse children
|
|
25
|
+
#
|
|
26
|
+
# Anything else recurses transparently.
|
|
27
|
+
class InlineRunExtractor
|
|
28
|
+
|
|
29
|
+
# Each entry is [value_attribute, child_collection_attribute, kind]
|
|
30
|
+
# - value_attribute: node method returning the text content (or nil)
|
|
31
|
+
# - child_collection_attribute: node method returning child
|
|
32
|
+
# elements of that type (Array)
|
|
33
|
+
# - kind: :text | :identifier | :transparent | :subscript_container
|
|
34
|
+
# | :superscript_container | :fraction | :ignored
|
|
35
|
+
ELEMENT_HANDLERS = {
|
|
36
|
+
'Mml::V3::Mi' => [:value, :mi_value, :identifier],
|
|
37
|
+
'Mml::V3::Mn' => [:value, :mn_value, :text],
|
|
38
|
+
'Mml::V3::Mo' => [:value, :mo_value, :text],
|
|
39
|
+
'Mml::V3::Mtext' => [:value, :mtext_value, :text],
|
|
40
|
+
'Mml::V3::Ms' => [:value, :ms_value, :text],
|
|
41
|
+
'Mml::V3::Mrow' => [nil, nil, :transparent_all],
|
|
42
|
+
'Mml::V3::Mstyle' => [nil, nil, :transparent_all],
|
|
43
|
+
'Mml::V3::Msub' => [nil, :msub_value, :subscript_container],
|
|
44
|
+
'Mml::V3::Msup' => [nil, :msup_value, :superscript_container],
|
|
45
|
+
'Mml::V3::Msubsup' => [nil, :msubsup_value, :subsup_container],
|
|
46
|
+
'Mml::V3::Munder' => [nil, :munder_value, :subscript_container],
|
|
47
|
+
'Mml::V3::Mover' => [nil, :mover_value, :superscript_container],
|
|
48
|
+
'Mml::V3::Munderover' => [nil, :munderover_value, :subsup_container],
|
|
49
|
+
'Mml::V3::Mfrac' => [nil, :mfrac_value, :fraction],
|
|
50
|
+
'Mml::V3::Menclose' => [nil, :menclose_value, :transparent],
|
|
51
|
+
'Mml::V3::Mphantom' => [nil, :mphantom_value, :transparent],
|
|
52
|
+
'Mml::V3::Mpadded' => [nil, :mpadded_value, :transparent],
|
|
53
|
+
'Mml::V3::Merror' => [nil, :merror_value, :transparent],
|
|
54
|
+
'Mml::V3::Semantics' => [nil, :semantics_value, :transparent],
|
|
55
|
+
'Mml::V3::Math' => [nil, nil, :transparent_all]
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
attr_reader :base_style
|
|
59
|
+
|
|
60
|
+
def sub_sup_scale = 0.7
|
|
61
|
+
|
|
62
|
+
def default_baseline
|
|
63
|
+
::Arrolio::Content::InlineRun::BASELINE_NORMAL
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def sub_baseline
|
|
67
|
+
::Arrolio::Content::InlineRun::BASELINE_SUB
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def sup_baseline
|
|
71
|
+
::Arrolio::Content::InlineRun::BASELINE_SUP
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def initialize(base_style: :math)
|
|
75
|
+
@base_style = base_style.to_sym
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def extract(math_node)
|
|
79
|
+
runs = []
|
|
80
|
+
walk(math_node, runs, baseline: default_baseline, scale: 1.0,
|
|
81
|
+
style_id: base_style)
|
|
82
|
+
runs
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Parse +xml_string+ as MathML and extract the runs. Returns
|
|
86
|
+
# an empty Array if parsing fails.
|
|
87
|
+
def self.extract_from_xml(xml_string, base_style: :math)
|
|
88
|
+
return [] if xml_string.nil? || xml_string.to_s.empty?
|
|
89
|
+
|
|
90
|
+
parsed = ::Mml.parse(xml_string.to_s, version: 3)
|
|
91
|
+
new(base_style: base_style).extract(parsed)
|
|
92
|
+
rescue StandardError => e
|
|
93
|
+
Arrolio::Logger.warn "Mml parse failed: #{e.class}: #{e.message[0, 80]}"
|
|
94
|
+
[]
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def walk(node, runs, baseline:, scale:, style_id:)
|
|
100
|
+
return unless node
|
|
101
|
+
|
|
102
|
+
handler = ELEMENT_HANDLERS[node.class.name]
|
|
103
|
+
if handler
|
|
104
|
+
dispatch_handler(node, handler, runs, baseline: baseline,
|
|
105
|
+
scale: scale, style_id: style_id)
|
|
106
|
+
else
|
|
107
|
+
# Unknown element: recurse transparently into all child collections.
|
|
108
|
+
walk_all_children(node, runs, baseline: baseline, scale: scale,
|
|
109
|
+
style_id: style_id)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def dispatch_handler(node, handler, runs, baseline:, scale:, style_id:)
|
|
114
|
+
_value_attr, child_attr, kind = handler
|
|
115
|
+
effective_style = kind == :identifier ? :math_identifier : style_id
|
|
116
|
+
if handler.first
|
|
117
|
+
emit_text_value(node, runs, baseline: baseline, scale: scale,
|
|
118
|
+
style_id: effective_style)
|
|
119
|
+
end
|
|
120
|
+
case kind
|
|
121
|
+
when :identifier
|
|
122
|
+
emit_collection_as(node, child_attr, runs, baseline: baseline, scale: scale,
|
|
123
|
+
style_id: :math_identifier)
|
|
124
|
+
when :text
|
|
125
|
+
emit_collection_as(node, child_attr, runs, baseline: baseline, scale: scale,
|
|
126
|
+
style_id: style_id)
|
|
127
|
+
when :transparent
|
|
128
|
+
walk_collection(node, child_attr, runs, baseline: baseline, scale: scale,
|
|
129
|
+
style_id: style_id)
|
|
130
|
+
when :transparent_all
|
|
131
|
+
walk_all_children(node, runs, baseline: baseline, scale: scale,
|
|
132
|
+
style_id: style_id)
|
|
133
|
+
when :subscript_container
|
|
134
|
+
process_script_container(node, runs, baseline: baseline, scale: scale,
|
|
135
|
+
style_id: style_id,
|
|
136
|
+
script_baseline: sub_baseline,
|
|
137
|
+
script_position: 1)
|
|
138
|
+
when :superscript_container
|
|
139
|
+
process_script_container(node, runs, baseline: baseline, scale: scale,
|
|
140
|
+
style_id: style_id,
|
|
141
|
+
script_baseline: sup_baseline,
|
|
142
|
+
script_position: 1)
|
|
143
|
+
when :subsup_container
|
|
144
|
+
process_subsup_container(node, runs, baseline: baseline, scale: scale,
|
|
145
|
+
style_id: style_id)
|
|
146
|
+
when :fraction
|
|
147
|
+
process_fraction_container(node, runs, baseline: baseline, scale: scale,
|
|
148
|
+
style_id: style_id)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def emit_text_value(node, runs, baseline:, scale:, style_id:)
|
|
153
|
+
value = safe_value(node)
|
|
154
|
+
return if value.nil? || value.empty?
|
|
155
|
+
|
|
156
|
+
runs << ::Arrolio::Content::InlineRun.new(value,
|
|
157
|
+
style_id: style_id,
|
|
158
|
+
baseline_shift: baseline,
|
|
159
|
+
font_size_scale: scale)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def emit_collection_as(node, attr, runs, baseline:, scale:, style_id:)
|
|
163
|
+
children = safe_collection(node, attr)
|
|
164
|
+
children.each { |c| walk(c, runs, baseline: baseline, scale: scale, style_id: style_id) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def walk_collection(node, attr, runs, baseline:, scale:, style_id:)
|
|
168
|
+
safe_collection(node, attr).each do |child|
|
|
169
|
+
walk(child, runs, baseline: baseline, scale: scale, style_id: style_id)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# For <msub>/<msup>: the node IS the container. Its children
|
|
174
|
+
# are the base (first) and the script (second).
|
|
175
|
+
def process_script_container(node, runs, baseline:, scale:, style_id:,
|
|
176
|
+
script_baseline:, script_position:)
|
|
177
|
+
children = all_child_elements(node)
|
|
178
|
+
base = children.first
|
|
179
|
+
script = children[script_position]
|
|
180
|
+
if base
|
|
181
|
+
walk(base, runs, baseline: baseline, scale: scale,
|
|
182
|
+
style_id: style_id)
|
|
183
|
+
end
|
|
184
|
+
return unless script
|
|
185
|
+
|
|
186
|
+
walk(script, runs, baseline: script_baseline,
|
|
187
|
+
scale: reduce_scale(scale),
|
|
188
|
+
style_id: style_id)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# For <msubsup>: base + sub + sup
|
|
192
|
+
def process_subsup_container(node, runs, baseline:, scale:, style_id:)
|
|
193
|
+
children = all_child_elements(node)
|
|
194
|
+
base = children[0]
|
|
195
|
+
sub = children[1]
|
|
196
|
+
sup = children[2]
|
|
197
|
+
if base
|
|
198
|
+
walk(base, runs, baseline: baseline, scale: scale,
|
|
199
|
+
style_id: style_id)
|
|
200
|
+
end
|
|
201
|
+
if sub
|
|
202
|
+
walk(sub, runs, baseline: sub_baseline,
|
|
203
|
+
scale: reduce_scale(scale), style_id: style_id)
|
|
204
|
+
end
|
|
205
|
+
return unless sup
|
|
206
|
+
|
|
207
|
+
walk(sup, runs, baseline: sup_baseline,
|
|
208
|
+
scale: reduce_scale(scale), style_id: style_id)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# For <mfrac>: numerator + '/' + denominator, all at reduced scale.
|
|
212
|
+
def process_fraction_container(node, runs, baseline:, scale:, style_id:)
|
|
213
|
+
children = all_child_elements(node)
|
|
214
|
+
numerator = children[0]
|
|
215
|
+
denominator = children[1]
|
|
216
|
+
child_scale = reduce_scale(scale)
|
|
217
|
+
if numerator
|
|
218
|
+
walk(numerator, runs, baseline: sup_baseline, scale: child_scale,
|
|
219
|
+
style_id: style_id)
|
|
220
|
+
end
|
|
221
|
+
runs << ::Arrolio::Content::InlineRun.new('/', style_id: style_id,
|
|
222
|
+
baseline_shift: baseline,
|
|
223
|
+
font_size_scale: scale)
|
|
224
|
+
return unless denominator
|
|
225
|
+
|
|
226
|
+
walk(denominator, runs, baseline: sub_baseline, scale: child_scale,
|
|
227
|
+
style_id: style_id)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def walk_all_children(node, runs, baseline:, scale:, style_id:)
|
|
231
|
+
%i[mi_value mn_value mo_value mtext_value ms_value mrow_value
|
|
232
|
+
mstyle_value msub_value msup_value msubsup_value mfrac_value
|
|
233
|
+
menclose_value mphantom_value mpadded_value merror_value
|
|
234
|
+
mover_value munder_value munderover_value semantics_value
|
|
235
|
+
msqrt_value mroot_value mtable_value math_value].each do |attr|
|
|
236
|
+
safe_collection(node, attr).each do |child|
|
|
237
|
+
walk(child, runs, baseline: baseline, scale: scale, style_id: style_id)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def all_child_elements(node)
|
|
243
|
+
%i[mi_value mn_value mo_value mtext_value ms_value mrow_value
|
|
244
|
+
mstyle_value msub_value msup_value msubsup_value mfrac_value
|
|
245
|
+
menclose_value mphantom_value mpadded_value merror_value
|
|
246
|
+
mover_value munder_value munderover_value semantics_value
|
|
247
|
+
msqrt_value mroot_value mtable_value].flat_map do |attr|
|
|
248
|
+
safe_collection(node, attr)
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def safe_collection(node, attr)
|
|
253
|
+
return [] unless node.is_a?(::Mml::CommonElements)
|
|
254
|
+
|
|
255
|
+
node.public_send(attr)
|
|
256
|
+
rescue StandardError
|
|
257
|
+
[]
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def safe_value(node)
|
|
261
|
+
value = node.value
|
|
262
|
+
value.is_a?(Array) ? value.join : value.to_s
|
|
263
|
+
rescue StandardError
|
|
264
|
+
nil
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def reduce_scale(current_scale)
|
|
268
|
+
(current_scale * sub_sup_scale).round(4)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
data/lib/arrolio/output/page.rb
CHANGED
|
@@ -5,13 +5,13 @@ module Arrolio
|
|
|
5
5
|
class Page
|
|
6
6
|
attr_reader :number, :template_name, :template_role, :page_size,
|
|
7
7
|
:regions, :static_regions, :header_text, :footer_text,
|
|
8
|
-
:header_align, :footer_align, :footnotes
|
|
8
|
+
:header_align, :footer_align, :footnotes, :title_text
|
|
9
9
|
|
|
10
10
|
def initialize(number:, template_name:, page_size:,
|
|
11
11
|
regions:, static_regions: {}, template_role: :body,
|
|
12
12
|
header_text: nil, footer_text: nil,
|
|
13
|
-
header_align:
|
|
14
|
-
footnotes: [])
|
|
13
|
+
header_align: nil, footer_align: :center,
|
|
14
|
+
footnotes: [], title_text: nil)
|
|
15
15
|
@number = number.to_i
|
|
16
16
|
@template_name = template_name.to_sym
|
|
17
17
|
@template_role = template_role.to_sym
|
|
@@ -20,9 +20,10 @@ module Arrolio
|
|
|
20
20
|
@static_regions = static_regions.transform_keys(&:to_sym).freeze
|
|
21
21
|
@header_text = header_text
|
|
22
22
|
@footer_text = footer_text
|
|
23
|
-
@header_align = header_align.to_sym
|
|
23
|
+
@header_align = header_align ? header_align.to_sym : :right
|
|
24
24
|
@footer_align = footer_align.to_sym
|
|
25
25
|
@footnotes = Array(footnotes).freeze
|
|
26
|
+
@title_text = title_text
|
|
26
27
|
freeze
|
|
27
28
|
end
|
|
28
29
|
|
|
@@ -17,11 +17,14 @@ module Arrolio
|
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
class << self
|
|
20
|
-
def text(x:, y:, width:, height:, lines:, line_height:, style:,
|
|
20
|
+
def text(x:, y:, width:, height:, lines:, line_height:, style:,
|
|
21
|
+
hanging_indent: 0.0, line_heights: nil, link_dest: nil)
|
|
21
22
|
new(x: x, y: y, width: width, height: height, kind: :text,
|
|
22
23
|
data: { lines: Array(lines).freeze,
|
|
23
24
|
line_height: line_height.to_f,
|
|
24
|
-
|
|
25
|
+
line_heights: line_heights&.map(&:to_f)&.freeze,
|
|
26
|
+
style: style,
|
|
27
|
+
hanging_indent: hanging_indent.to_f },
|
|
25
28
|
link_dest: link_dest)
|
|
26
29
|
end
|
|
27
30
|
|
data/lib/arrolio/renderer/pdf.rb
CHANGED
|
@@ -240,6 +240,7 @@ module Arrolio
|
|
|
240
240
|
pdfrb_page.value[:Resources] = catalog.value[:Resources]
|
|
241
241
|
canvas = pdfrb_page.canvas
|
|
242
242
|
render_cover_logo(canvas, output_page) if output_page.template_role == :cover && @logo_ref
|
|
243
|
+
render_page_title(canvas, output_page) if output_page.title_text
|
|
243
244
|
render_header_footer(canvas, output_page) unless output_page.template_role == :cover
|
|
244
245
|
render_page_footnotes(canvas, output_page) unless output_page.template_role == :cover
|
|
245
246
|
(output_page.static_regions.values + output_page.regions.values).each do |region|
|
|
@@ -248,28 +249,37 @@ module Arrolio
|
|
|
248
249
|
@link_annotator.flush(pdfrb_page)
|
|
249
250
|
end
|
|
250
251
|
|
|
252
|
+
# Footnote bodies occupy the zone the engine reserved at the
|
|
253
|
+
# page bottom. The layout comes from
|
|
254
|
+
# FootnoteMarkerFlowable.body_flowable - the same policy the
|
|
255
|
+
# engine used to reserve the space - so the drawn block fits
|
|
256
|
+
# the reserved zone exactly.
|
|
251
257
|
def render_page_footnotes(canvas, page)
|
|
252
|
-
return
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
page.
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
y -= 9.0
|
|
258
|
+
return if page.footnotes.nil? || page.footnotes.empty?
|
|
259
|
+
|
|
260
|
+
style = footnote_style
|
|
261
|
+
body = page.body_region
|
|
262
|
+
width = body ? body.width : page.width * 0.85
|
|
263
|
+
x = body ? body.x : page.width * 0.075
|
|
264
|
+
flowables = page.footnotes.map do |footnote|
|
|
265
|
+
Flowables::FootnoteMarkerFlowable.body_flowable(footnote, style)
|
|
266
|
+
end
|
|
267
|
+
total = flowables.sum { |flow| flow.height(width) }
|
|
268
|
+
y = header_footer_style[:margin_bottom] + 20 + total
|
|
269
|
+
flowables.each do |flow|
|
|
270
|
+
boxes, = flow.emit(x, y, width, nil)
|
|
271
|
+
boxes.each { |box| render_box(canvas, box) }
|
|
272
|
+
y -= flow.height(width)
|
|
268
273
|
end
|
|
269
274
|
rescue StandardError => e
|
|
270
275
|
Arrolio::Logger.warn "footnote render failed: #{e.class}: #{e.message[0, 80]}"
|
|
271
276
|
end
|
|
272
277
|
|
|
278
|
+
def footnote_style
|
|
279
|
+
style = @layout_spec&.resolve_style(:footnote)
|
|
280
|
+
style.nil? || style.font_size.nil? ? Style::Definition.new(font_size: 9.0) : style
|
|
281
|
+
end
|
|
282
|
+
|
|
273
283
|
def render_cover_logo(canvas, page)
|
|
274
284
|
logo_style = cover_logo_style
|
|
275
285
|
w = logo_style[:width]
|
|
@@ -296,6 +306,24 @@ module Arrolio
|
|
|
296
306
|
end
|
|
297
307
|
end
|
|
298
308
|
|
|
309
|
+
def render_page_title(canvas, page)
|
|
310
|
+
header_footer_style
|
|
311
|
+
body = page.regions[:body]
|
|
312
|
+
return unless body
|
|
313
|
+
|
|
314
|
+
font_name = 'Times New Roman Bold'
|
|
315
|
+
size = 18.0
|
|
316
|
+
measurer = GlyphMeasurer.new(font_name: font_name)
|
|
317
|
+
tw = measurer.width_of_string(page.title_text, font_size: size)
|
|
318
|
+
x = (page.width - tw) / 2.0
|
|
319
|
+
baseline = body.y + body.height - 9.0
|
|
320
|
+
ref = embedded_or_standard(font_name)
|
|
321
|
+
payload = encoded_or_text(font_name, page.title_text)
|
|
322
|
+
canvas.text(payload, at: [x, baseline], font: ref, size: size)
|
|
323
|
+
rescue StandardError => e
|
|
324
|
+
Arrolio::Logger.warn "render_page_title failed: #{e.message[0, 80]}"
|
|
325
|
+
end
|
|
326
|
+
|
|
299
327
|
def render_header_footer(canvas, page)
|
|
300
328
|
hf_style = header_footer_style
|
|
301
329
|
margin_top = hf_style[:margin_top]
|
|
@@ -344,48 +372,119 @@ module Arrolio
|
|
|
344
372
|
when :rect then render_rect(canvas, box)
|
|
345
373
|
when :line then render_line(canvas, box)
|
|
346
374
|
when :image then render_image(canvas, box)
|
|
375
|
+
when :rotated_text then render_rotated_text(canvas, box)
|
|
347
376
|
end
|
|
348
377
|
end
|
|
349
378
|
|
|
379
|
+
def render_rotated_text(canvas, box)
|
|
380
|
+
text = box.data[:text]
|
|
381
|
+
style = box.data[:style]
|
|
382
|
+
angle = box.data[:angle].to_f
|
|
383
|
+
line_height = box.data[:line_height].to_f
|
|
384
|
+
font_name = style.font_name
|
|
385
|
+
ref = embedded_or_standard(font_name)
|
|
386
|
+
payload = encoded_or_text(font_name, text)
|
|
387
|
+
rad = angle * Math::PI / 180.0
|
|
388
|
+
cos = Math.cos(rad)
|
|
389
|
+
sin = Math.sin(rad)
|
|
390
|
+
baseline_y = box.y + line_height - GlyphMeasurer.new(font_name: font_name)
|
|
391
|
+
.ascender(font_size: style.font_size)
|
|
392
|
+
# PDF text matrix is [a b c d e f] where (a,d) scale, (b,c) rotate,
|
|
393
|
+
# (e,f) translate. We rotate around the baseline starting point.
|
|
394
|
+
canvas.text(payload, at: [box.x, baseline_y], font: ref, size: style.font_size,
|
|
395
|
+
tm: [cos, sin, -sin, cos, box.x, baseline_y])
|
|
396
|
+
rescue StandardError => e
|
|
397
|
+
Arrolio::Logger.warn "render_rotated_text failed: #{e.message[0, 80]}"
|
|
398
|
+
end
|
|
399
|
+
|
|
350
400
|
def render_text(canvas, box)
|
|
351
401
|
lines = box.data[:lines]
|
|
352
402
|
line_height = box.data[:line_height]
|
|
353
403
|
style = box.data[:style]
|
|
404
|
+
hanging_indent = box.data[:hanging_indent] || 0.0
|
|
354
405
|
measurer = GlyphMeasurer.new(font_name: style.font_name)
|
|
355
406
|
ascender = measurer.ascender(font_size: style.font_size)
|
|
356
407
|
x_origin = box.x
|
|
357
408
|
y_top = box.y + box.height
|
|
358
409
|
|
|
410
|
+
heights = box.data[:line_heights] ||
|
|
411
|
+
Array.new(lines.length, line_height)
|
|
412
|
+
offset = 0.0
|
|
359
413
|
lines.each_with_index do |line, idx|
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
414
|
+
# A math line's slot is taller than a text line; the
|
|
415
|
+
# reference draws the stacked expression at the BOTTOM of
|
|
416
|
+
# its slot (the text-level parens stay at the line top).
|
|
417
|
+
drop = heights[idx] - line_height
|
|
418
|
+
baseline_y = y_top - offset - ascender - drop
|
|
419
|
+
indent = idx.zero? ? 0 : hanging_indent
|
|
420
|
+
line_x = x_origin + indent + line.x_offset
|
|
421
|
+
render_line_runs(canvas, line, line_x, baseline_y, style,
|
|
422
|
+
last_line: idx == lines.length - 1)
|
|
423
|
+
offset += heights[idx]
|
|
363
424
|
end
|
|
364
425
|
end
|
|
365
426
|
|
|
366
|
-
def render_line_runs(canvas, line, line_x, baseline_y, _paragraph_style)
|
|
367
|
-
justify = line.justified? ? line.justify_stretch : 0.0
|
|
427
|
+
def render_line_runs(canvas, line, line_x, baseline_y, _paragraph_style, last_line: false)
|
|
428
|
+
justify = line.justified? && !last_line ? line.justify_stretch : 0.0
|
|
368
429
|
extra_offset = 0.0
|
|
369
430
|
line.placed_runs.each do |pr|
|
|
370
431
|
run = pr.run
|
|
371
|
-
|
|
432
|
+
run_x = line_x + pr.x_offset
|
|
372
433
|
font_name = run.style.font_name
|
|
373
434
|
ref = embedded_or_standard(font_name)
|
|
374
|
-
payload = encoded_or_text(font_name, run.text)
|
|
375
435
|
y, size = baseline_position_for(run, baseline_y)
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
436
|
+
measurer = GlyphMeasurer.new(font_name: font_name)
|
|
437
|
+
cursor = 0.0
|
|
438
|
+
run_start = nil
|
|
439
|
+
text_chunks(run.text, justify).each do |chunk|
|
|
440
|
+
x = run_x + extra_offset + cursor
|
|
441
|
+
run_start ||= x
|
|
442
|
+
payload = encoded_or_text(font_name, chunk)
|
|
443
|
+
opts = { at: [x, y], font: ref, size: size }
|
|
444
|
+
if run.style.character_spacing && !run.style.character_spacing.zero?
|
|
445
|
+
opts[:char_spacing] = run.style.character_spacing
|
|
446
|
+
end
|
|
447
|
+
if run.style.word_spacing && !run.style.word_spacing.zero?
|
|
448
|
+
opts[:word_spacing] = run.style.word_spacing
|
|
449
|
+
end
|
|
450
|
+
record_link_if_needed(run, x, y, size) if cursor.zero?
|
|
451
|
+
canvas.text(payload, **opts)
|
|
452
|
+
cursor += measurer.width_of_string(chunk, font_size: size)
|
|
453
|
+
# Justify must SHRINK as well as stretch: a compressed
|
|
454
|
+
# line (ratio > -1) relies on negative space adjustment —
|
|
455
|
+
# skipping it draws the line at natural width, past the
|
|
456
|
+
# measure.
|
|
457
|
+
extra_offset += (chunk.count(' ') * justify) unless justify.zero?
|
|
382
458
|
end
|
|
383
|
-
|
|
384
|
-
canvas.text(payload, **opts)
|
|
385
|
-
extra_offset += (run.text.count(' ') * justify) if justify.positive?
|
|
459
|
+
draw_underline(canvas, run, run_start, y, size, cursor) if run.style.underline
|
|
386
460
|
end
|
|
387
461
|
end
|
|
388
462
|
|
|
463
|
+
# PDF word spacing (Tw) does not apply to two-byte CID-encoded
|
|
464
|
+
# embedded fonts, so justification cannot rely on it. Instead a
|
|
465
|
+
# justified line is drawn as one positioned text operation per
|
|
466
|
+
# word — FOP's model — with each word's x carrying the stretch
|
|
467
|
+
# accumulated so far. The separating space stays attached to
|
|
468
|
+
# the preceding word so text extraction still sees word gaps.
|
|
469
|
+
def text_chunks(text, justify)
|
|
470
|
+
return [text] if justify.zero?
|
|
471
|
+
|
|
472
|
+
text.split(/(?<=\s)(?=\S)/)
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# Draws a thin horizontal rule below a run's baseline when the
|
|
476
|
+
# style's `underline` flag is set. Matches FOP/HTML convention:
|
|
477
|
+
# the rule sits ~1pt below baseline, thickness ~5% of font size.
|
|
478
|
+
def draw_underline(canvas, _run, x, baseline_y, size, width)
|
|
479
|
+
weight = [size * 0.05, 0.5].max
|
|
480
|
+
underline_y = baseline_y - (size * 0.18)
|
|
481
|
+
canvas.line(x, underline_y, x + width, underline_y)
|
|
482
|
+
canvas.line_width = weight
|
|
483
|
+
canvas.stroke
|
|
484
|
+
rescue StandardError => e
|
|
485
|
+
Arrolio::Logger.warn "draw_underline failed: #{e.message[0, 60]}"
|
|
486
|
+
end
|
|
487
|
+
|
|
389
488
|
# Returns [y, font_size] for a run, applying baseline shift and
|
|
390
489
|
# font size scaling for subscript/superscript runs.
|
|
391
490
|
# Subscript: shift down 0.2em, scale 0.7x.
|
|
@@ -9,7 +9,8 @@ module Arrolio
|
|
|
9
9
|
:padding_top, :padding_right, :padding_bottom, :padding_left,
|
|
10
10
|
:character_spacing, :word_spacing, :line_break,
|
|
11
11
|
:keep_together, :page_break_before, :page_break_after,
|
|
12
|
-
:keep_with_next, :widows, :orphans, :parent
|
|
12
|
+
:keep_with_next, :widows, :orphans, :parent,
|
|
13
|
+
:underline, :text_transform
|
|
13
14
|
|
|
14
15
|
DEFAULTS = {
|
|
15
16
|
font_name: 'Helvetica', font_size: 12.0,
|
|
@@ -20,7 +21,8 @@ module Arrolio
|
|
|
20
21
|
character_spacing: 0.0, word_spacing: 0.0,
|
|
21
22
|
line_break: :greedy,
|
|
22
23
|
keep_together: false, page_break_before: false, page_break_after: false,
|
|
23
|
-
keep_with_next: false, widows:
|
|
24
|
+
keep_with_next: false, widows: 1, orphans: 1, parent: nil,
|
|
25
|
+
underline: false, text_transform: nil
|
|
24
26
|
}.freeze
|
|
25
27
|
|
|
26
28
|
def initialize(**opts)
|
|
@@ -50,6 +52,8 @@ module Arrolio
|
|
|
50
52
|
@widows = merged.fetch(:widows).to_i
|
|
51
53
|
@orphans = merged.fetch(:orphans).to_i
|
|
52
54
|
@parent = merged.fetch(:parent)
|
|
55
|
+
@underline = truthy?(merged.fetch(:underline))
|
|
56
|
+
@text_transform = merged.fetch(:text_transform)
|
|
53
57
|
freeze
|
|
54
58
|
end
|
|
55
59
|
|
|
@@ -80,7 +84,7 @@ module Arrolio
|
|
|
80
84
|
line_break: @line_break, keep_together: @keep_together,
|
|
81
85
|
page_break_before: @page_break_before, page_break_after: @page_break_after,
|
|
82
86
|
keep_with_next: @keep_with_next, widows: @widows, orphans: @orphans,
|
|
83
|
-
parent: @parent
|
|
87
|
+
parent: @parent, underline: @underline, text_transform: @text_transform
|
|
84
88
|
}
|
|
85
89
|
end
|
|
86
90
|
|
|
@@ -9,18 +9,21 @@ module Arrolio
|
|
|
9
9
|
|
|
10
10
|
def initialize(overrides = {})
|
|
11
11
|
@overrides = {}
|
|
12
|
+
@resolution_cache = {}
|
|
12
13
|
overrides.each { |name, attrs| register(name, **attrs) }
|
|
13
14
|
freeze
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def register(name, **opts)
|
|
17
18
|
@overrides[name.to_sym] = opts.transform_keys(&:to_sym)
|
|
19
|
+
@resolution_cache.clear
|
|
18
20
|
name.to_sym
|
|
19
21
|
end
|
|
20
22
|
|
|
21
23
|
def resolve(name, fallback: Definition.new)
|
|
22
24
|
key = name.to_sym
|
|
23
25
|
return fallback unless @overrides.key?(key)
|
|
26
|
+
return @resolution_cache[key] if @resolution_cache.key?(key)
|
|
24
27
|
|
|
25
28
|
chain = []
|
|
26
29
|
cursor = key
|
|
@@ -32,7 +35,9 @@ module Arrolio
|
|
|
32
35
|
end
|
|
33
36
|
merged = chain.reverse_each.reduce({}) { |acc, h| acc.merge(h) }
|
|
34
37
|
merged.delete(:parent)
|
|
35
|
-
Definition.new(**merged)
|
|
38
|
+
result = Definition.new(**merged)
|
|
39
|
+
@resolution_cache[key] = result
|
|
40
|
+
result
|
|
36
41
|
end
|
|
37
42
|
|
|
38
43
|
def names = @overrides.keys
|