arrolio 0.1.0 → 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/.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/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 +92 -187
- 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/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 +140 -215
- 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/greedy.rb +15 -1
- data/lib/arrolio/text_layout/knuth_plass/breaker.rb +216 -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 +10 -2
- 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 +91 -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
|
|
|
@@ -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
|