carve-hexapdf 0.1.0
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 +7 -0
- data/CHANGELOG.md +119 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/lib/carve/hexapdf/renderer.rb +885 -0
- data/lib/carve/hexapdf/style_map.rb +159 -0
- data/lib/carve/hexapdf/version.rb +7 -0
- data/lib/carve/hexapdf.rb +71 -0
- metadata +114 -0
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require "base64"
|
|
5
|
+
|
|
6
|
+
require_relative "style_map"
|
|
7
|
+
|
|
8
|
+
module Carve
|
|
9
|
+
module Hexapdf
|
|
10
|
+
# Walks a Carve AST (as produced by +Carve.parse+) and draws it onto a
|
|
11
|
+
# HexaPDF::Composer, producing a laid-out PDF document.
|
|
12
|
+
#
|
|
13
|
+
# Block nodes become HexaPDF boxes (text, list, table, container, image);
|
|
14
|
+
# inline nodes become the multi-part styled "runs" that
|
|
15
|
+
# HexaPDF::Composer#formatted_text consumes. Emphasis maps to font variants
|
|
16
|
+
# and text decorations, inline code to a monospace font, links to a colored
|
|
17
|
+
# run with a URI overlay.
|
|
18
|
+
#
|
|
19
|
+
# Math and diagram fences are rendered through optional +renderers:+
|
|
20
|
+
# callables (which return image bytes); without a matching renderer they
|
|
21
|
+
# degrade to their monospace source. The renderer never raises on an
|
|
22
|
+
# unknown or unsupported node - it degrades to text/children so a document
|
|
23
|
+
# always renders.
|
|
24
|
+
class Renderer
|
|
25
|
+
BLOCK_GAP = 8
|
|
26
|
+
|
|
27
|
+
# Style properties this renderer consumes itself. They are not HexaPDF
|
|
28
|
+
# style properties, and the style chain hands each of them down to its
|
|
29
|
+
# nested keys: `table.caption` inherits `table`'s :cell_padding,
|
|
30
|
+
# `figure.group.caption` inherits `figure.group`'s column properties.
|
|
31
|
+
# Splatting one into a text box raises NoMethodError, which loses the
|
|
32
|
+
# whole document, so a style destined for a text box drops them
|
|
33
|
+
# (+text_style+). :box is dropped by +style_for+ already.
|
|
34
|
+
RENDERER_PROPS = %i[
|
|
35
|
+
cell_padding column_gap min_column_width title_margin
|
|
36
|
+
definition_indent item_spacing content_indentation
|
|
37
|
+
].freeze
|
|
38
|
+
|
|
39
|
+
# Code-fence languages that map to a diagram renderer key.
|
|
40
|
+
DIAGRAM_LANGS = {
|
|
41
|
+
"mermaid" => :mermaid,
|
|
42
|
+
"dot" => :graphviz,
|
|
43
|
+
"graphviz" => :graphviz,
|
|
44
|
+
"chart" => :chart,
|
|
45
|
+
"vega" => :chart,
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# @param renderers [Hash] optional callables that turn a construct's
|
|
49
|
+
# source into raster image bytes (PNG/JPG). Keys:
|
|
50
|
+
# +:math+ -> callable(tex_string, display_bool);
|
|
51
|
+
# +:mermaid+/+:graphviz+/+:chart+ -> callable(source_string).
|
|
52
|
+
# Each callable returns the image bytes as a String, or a Hash with
|
|
53
|
+
# +:bytes+ and optional +:width+/+:height+ (points) to control the
|
|
54
|
+
# drawn size of high-DPI rasters. Any other return, or a missing key,
|
|
55
|
+
# degrades the construct to its source.
|
|
56
|
+
def initialize(composer, base_font: nil, code_font: nil,
|
|
57
|
+
link_color: nil, highlight_color: nil, styles: nil, renderers: nil)
|
|
58
|
+
@c = composer
|
|
59
|
+
@layout = composer.document.layout
|
|
60
|
+
@styles = StyleMap.new(style_sugar(base_font: base_font, code_font: code_font,
|
|
61
|
+
link_color: link_color,
|
|
62
|
+
highlight_color: highlight_color,
|
|
63
|
+
styles: styles))
|
|
64
|
+
@renderers = renderers || {}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def render_document(doc)
|
|
68
|
+
# Keys arrive as Symbols (symbolized JSON) while node :id is a String.
|
|
69
|
+
@footnote_defs = collect_footnote_defs(doc)
|
|
70
|
+
@footnotes = []
|
|
71
|
+
@footnote_numbers = {}
|
|
72
|
+
Array(doc[:children]).each { |node| block(node, @c) }
|
|
73
|
+
render_footnotes(@c)
|
|
74
|
+
@c
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# ---- block dispatch ------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def block(node, target)
|
|
80
|
+
case node[:type]
|
|
81
|
+
# A definition is RELOCATED, not rendered in place - it belongs in the
|
|
82
|
+
# endnote section, which is what the HTML renderer does with it too.
|
|
83
|
+
# Handled HERE rather than by filtering the root's children, because a
|
|
84
|
+
# definition written inside a container stays inside it (the engines
|
|
85
|
+
# differ on hoisting), and a root-level filter would render that one on
|
|
86
|
+
# the page and again in the endnotes.
|
|
87
|
+
when "footnote" then nil
|
|
88
|
+
when "heading" then heading(node, target)
|
|
89
|
+
when "paragraph" then paragraph(node, target)
|
|
90
|
+
when "code_block" then code_block(node, target)
|
|
91
|
+
when "list" then list(node, target)
|
|
92
|
+
when "block_quote" then block_quote(node, target)
|
|
93
|
+
when "table" then table(node, target)
|
|
94
|
+
when "thematic_break" then thematic_break(target)
|
|
95
|
+
when "div" then container_of(node[:children], target)
|
|
96
|
+
when "admonition" then admonition(node, target)
|
|
97
|
+
when "definition_list" then definition_list(node, target)
|
|
98
|
+
when "figure" then figure(node, target)
|
|
99
|
+
when "figure_group" then figure_group(node, target)
|
|
100
|
+
when "block_image", "image" then image_block(node, target)
|
|
101
|
+
when "block_extension" then container_of(node[:children], target)
|
|
102
|
+
when "raw_block", "comment", "abbreviation_def"
|
|
103
|
+
# No meaningful PDF form - drop.
|
|
104
|
+
else
|
|
105
|
+
if inline_children?(node[:children])
|
|
106
|
+
emit_paragraph(node[:children], target)
|
|
107
|
+
elsif node[:children]
|
|
108
|
+
Array(node[:children]).each { |ch| block(ch, target) }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def style_sugar(base_font:, code_font:, link_color:, highlight_color:, styles:)
|
|
116
|
+
sugar = {}
|
|
117
|
+
sugar["base"] = { font: base_font } unless base_font.nil?
|
|
118
|
+
sugar["code"] = { font: code_font } unless code_font.nil?
|
|
119
|
+
sugar["link"] = { fill_color: link_color } unless link_color.nil?
|
|
120
|
+
sugar["highlight"] = { background_color: highlight_color } unless highlight_color.nil?
|
|
121
|
+
return sugar if styles.nil?
|
|
122
|
+
unless styles.respond_to?(:each_pair)
|
|
123
|
+
raise ArgumentError, "styles must be a Hash-like object"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
styles.each_pair.with_object(sugar) do |(key, value), out|
|
|
127
|
+
key = key.to_s
|
|
128
|
+
out[key] = (out[key] || {}).merge(value)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# The default base font ("Times") matches HexaPDF's own default, so it is
|
|
133
|
+
# stripped from block styles to keep default output identical to what the
|
|
134
|
+
# composer would produce anyway; a USER-set font at any chain level
|
|
135
|
+
# (including base) must survive into the block style.
|
|
136
|
+
#
|
|
137
|
+
# :box is a pseudo-property consumed only by sites that draw a surrounding
|
|
138
|
+
# box (code blocks, quotes, admonitions, ...); everywhere else it would
|
|
139
|
+
# crash HexaPDF's style handling, so it is stripped unless requested.
|
|
140
|
+
def style_for(key, inherited_font: false, with_box: false)
|
|
141
|
+
style = @styles.resolve(key).dup
|
|
142
|
+
style.delete(:font) unless inherited_font || @styles.user_set_in_chain?(key, :font)
|
|
143
|
+
style.delete(:box) unless with_box
|
|
144
|
+
style
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# A resolved style safe to splat into a HexaPDF text box: the renderer's
|
|
148
|
+
# own properties are dropped, whether they were set on this key or
|
|
149
|
+
# inherited from an ancestor of it (see RENDERER_PROPS).
|
|
150
|
+
def text_style(key, inherited_font: false)
|
|
151
|
+
style_for(key, inherited_font: inherited_font)
|
|
152
|
+
.reject { |prop, _| RENDERER_PROPS.include?(prop) }
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def heading(node, target)
|
|
156
|
+
style = style_for("heading.#{node[:level]}")
|
|
157
|
+
runs = inline_runs(node[:children], bold: true, font_family: style[:font])
|
|
158
|
+
return if runs.empty?
|
|
159
|
+
|
|
160
|
+
target.formatted_text(runs, **style)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def paragraph(node, target)
|
|
164
|
+
# A paragraph that is only a display-math node becomes a centered image.
|
|
165
|
+
children = Array(node[:children])
|
|
166
|
+
if children.size == 1 && children.first[:type] == "math" && children.first[:display]
|
|
167
|
+
return display_math(children.first, target)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
emit_paragraph(children, target)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def emit_paragraph(children, target, **style)
|
|
174
|
+
merged = style_for("paragraph").merge(style)
|
|
175
|
+
runs = inline_runs(children, font_family: merged[:font])
|
|
176
|
+
return if runs.empty?
|
|
177
|
+
|
|
178
|
+
target.formatted_text(runs, **merged)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def code_block(node, target)
|
|
182
|
+
lang = node[:lang].to_s.downcase
|
|
183
|
+
if (key = DIAGRAM_LANGS[lang]) && (bytes = call_renderer(key, node[:content].to_s))
|
|
184
|
+
return image_bytes(bytes, target)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
content = resolve_nbsp(node[:content]).chomp
|
|
188
|
+
style = style_for("code.block", inherited_font: true, with_box: true)
|
|
189
|
+
box = style.delete(:box)
|
|
190
|
+
target.text(content, **style, box_style: box)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def list(node, target)
|
|
194
|
+
# The list box is structural: item text styling flows through the
|
|
195
|
+
# paragraph chain, and Composer#list rejects text keywords (:font,
|
|
196
|
+
# :font_size, ...) that the chain inherits from base - whitelist.
|
|
197
|
+
style = style_for("list").slice(:item_spacing, :content_indentation)
|
|
198
|
+
items = Array(node[:items])
|
|
199
|
+
ordered = node[:ordered]
|
|
200
|
+
task = items.any? { |it| !it[:checked].nil? }
|
|
201
|
+
start = node[:start] || 1
|
|
202
|
+
marker = if task
|
|
203
|
+
# Standard PDF fonts have no checkbox glyphs; draw an ASCII
|
|
204
|
+
# checkbox in the marker column so item text (and nested
|
|
205
|
+
# lists) align like any other list.
|
|
206
|
+
checked = items.map { |it| it[:checked] }
|
|
207
|
+
font = style_for("paragraph", inherited_font: true)[:font] || "Times"
|
|
208
|
+
->(doc, list_box, index) do
|
|
209
|
+
# A list split across pages continues in a box whose
|
|
210
|
+
# start_number is advanced by the items already drawn;
|
|
211
|
+
# index alone is relative to that split remainder.
|
|
212
|
+
absolute = list_box.start_number - start + index
|
|
213
|
+
doc.layout.text_box(checked[absolute] ? "[x]" : "[ ]",
|
|
214
|
+
font: font, font_size: 10)
|
|
215
|
+
end
|
|
216
|
+
elsif ordered
|
|
217
|
+
:decimal
|
|
218
|
+
else
|
|
219
|
+
:disc
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
target.list(**style, marker_type: marker, start_number: start) do |list_box|
|
|
223
|
+
items.each do |item|
|
|
224
|
+
list_box.container do |cell|
|
|
225
|
+
Array(item[:children]).each { |ch| block(ch, cell) }
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def block_quote(node, target)
|
|
232
|
+
target.container(style: style_for("quote", with_box: true)[:box]) do |cont|
|
|
233
|
+
Array(node[:children]).each { |ch| block(ch, cont) }
|
|
234
|
+
if node[:attribution]
|
|
235
|
+
emit_paragraph(node[:attribution], cont, margin: [2, 0, 0])
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def container_of(children, target)
|
|
241
|
+
target.container(style: { margin: [0, 0, BLOCK_GAP] }) do |cont|
|
|
242
|
+
Array(children).each { |ch| block(ch, cont) }
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def admonition(node, target)
|
|
247
|
+
kind = node[:kind].to_s
|
|
248
|
+
key = kind.empty? || kind.include?(".") ? "admonition" : "admonition.#{kind}"
|
|
249
|
+
style = style_for(key, with_box: true)
|
|
250
|
+
target.container(style: style[:box]) do |cont|
|
|
251
|
+
title = node[:title] && !node[:title].empty? ? node[:title] : [{ type: "text", value: node[:kind].to_s.capitalize }]
|
|
252
|
+
cont.formatted_text(inline_runs(title, bold: true, font_family: style[:font]),
|
|
253
|
+
margin: style[:title_margin])
|
|
254
|
+
Array(node[:children]).each { |ch| block(ch, cont) }
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def definition_list(node, target)
|
|
259
|
+
style = style_for("definition_list", with_box: true)
|
|
260
|
+
target.container(style: style[:box]) do |cont|
|
|
261
|
+
Array(node[:items]).each do |item|
|
|
262
|
+
Array(item[:terms]).each do |term|
|
|
263
|
+
cont.formatted_text(inline_runs(term, bold: true), margin: [2, 0, 1])
|
|
264
|
+
end
|
|
265
|
+
Array(item[:definitions]).each do |defn|
|
|
266
|
+
cont.container(style: { padding: [0, 0, 0, style[:definition_indent]] }) do |dcont|
|
|
267
|
+
Array(defn).each { |ch| block(ch, dcont) }
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def figure(node, target)
|
|
275
|
+
block(node[:target], target) if node[:target]
|
|
276
|
+
if node[:caption] && !node[:caption].empty?
|
|
277
|
+
style = text_style("figure.caption")
|
|
278
|
+
target.formatted_text(inline_runs(node[:caption], italic: true, font_family: style[:font]),
|
|
279
|
+
**style)
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
# A composite figure (PART 9 section 4c) is ONE float. Its panels, the
|
|
284
|
+
# stray content preserved between them and the group caption are laid out
|
|
285
|
+
# as a single box that a page break may not enter, so the caption cannot
|
|
286
|
+
# be stranded from the panels it numbers, nor a panel from its own
|
|
287
|
+
# caption.
|
|
288
|
+
#
|
|
289
|
+
# PANELS ARE THE `figure` AND `table` CHILDREN, in source order; every
|
|
290
|
+
# other child is plain group content and is drawn IN PLACE between them.
|
|
291
|
+
# Nothing here re-attaches or drops it.
|
|
292
|
+
#
|
|
293
|
+
# THE GROUP CAPTION IS ALREADY NUMBERED when it arrives: the number is a
|
|
294
|
+
# `caption_number` inline the engine resolved, and a `#` placeholder in a
|
|
295
|
+
# PANEL caption stayed literal because a panel draws nothing from the
|
|
296
|
+
# document sequence. Neither is this renderer's decision.
|
|
297
|
+
def figure_group(node, target)
|
|
298
|
+
style = style_for("figure.group", with_box: true)
|
|
299
|
+
panels = ::HexaPDF::Document::Layout::ChildrenCollector.collect(@layout) do |cont|
|
|
300
|
+
Array(node[:children]).each do |child|
|
|
301
|
+
case child[:type]
|
|
302
|
+
when "figure", "table" then panel(child, cont)
|
|
303
|
+
else block(child, cont)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
columns = column_count(node, style)
|
|
308
|
+
body = if columns > 1
|
|
309
|
+
[@layout.box(:column, children: panels, columns: columns, gaps: style[:column_gap])]
|
|
310
|
+
else
|
|
311
|
+
panels
|
|
312
|
+
end
|
|
313
|
+
body += [group_caption_box(node[:caption])] if node[:caption] && !node[:caption].empty?
|
|
314
|
+
emit_box(keep_together(body, style: style[:box]), target)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# A panel keeps its host and its own caption on one page too - a caption
|
|
318
|
+
# that says "(a)" is worth nothing on the page after its image. A `table`
|
|
319
|
+
# panel keeps the table's own caption, which the table renderer draws;
|
|
320
|
+
# the panel wrapper adds nothing to it.
|
|
321
|
+
def panel(node, target)
|
|
322
|
+
children = ::HexaPDF::Document::Layout::ChildrenCollector.collect(@layout) do |cont|
|
|
323
|
+
node[:type] == "table" ? table(node, cont) : figure(node, cont)
|
|
324
|
+
end
|
|
325
|
+
emit_box(keep_together(children), target)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def group_caption_box(caption)
|
|
329
|
+
style = text_style("figure.group.caption")
|
|
330
|
+
@layout.formatted_text_box(inline_runs(caption, bold: true, font_family: style[:font]),
|
|
331
|
+
**style)
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
# `.columns-N` from the attribute line is a LAYOUT HINT, not content: it
|
|
335
|
+
# is honored when the page is wide enough to give every column
|
|
336
|
+
# +min_column_width+, and ignored in favor of a stack when it is not. The
|
|
337
|
+
# panels render either way, in source order, so no hint can cost the
|
|
338
|
+
# document a panel.
|
|
339
|
+
def column_count(node, style)
|
|
340
|
+
attrs = node[:attrs]
|
|
341
|
+
classes = attrs.is_a?(Hash) ? Array(attrs[:classes]) : []
|
|
342
|
+
hint = classes.filter_map { |c| c.to_s[/\Acolumns-(\d+)\z/, 1] }.last
|
|
343
|
+
return 1 if hint.nil?
|
|
344
|
+
|
|
345
|
+
count = hint.to_i
|
|
346
|
+
return 1 if count < 2
|
|
347
|
+
|
|
348
|
+
gaps = style[:column_gap].to_f * (count - 1)
|
|
349
|
+
return 1 if (@c.frame.width - gaps) / count < style[:min_column_width].to_f
|
|
350
|
+
|
|
351
|
+
count
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# A non-splitable box that does not fit even an empty page does not
|
|
355
|
+
# degrade - HexaPDF raises "Box didn't fit multiple times", which loses
|
|
356
|
+
# the WHOLE document over one oversized figure. So the box is fitted
|
|
357
|
+
# against a full page first, and a group that cannot be kept together is
|
|
358
|
+
# allowed to split instead: page breaks inside it, panels still in source
|
|
359
|
+
# order, which is what splitting a container box preserves.
|
|
360
|
+
def keep_together(children, style: nil)
|
|
361
|
+
box = @layout.box(:container, children: children, splitable: false, style: style || {})
|
|
362
|
+
return box if fits_on_a_page?(box)
|
|
363
|
+
|
|
364
|
+
@layout.box(:container, children: children, splitable: true, style: style || {})
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def fits_on_a_page?(box)
|
|
368
|
+
width = @c.frame.width
|
|
369
|
+
height = @c.frame.height
|
|
370
|
+
box.fit(width, height, ::HexaPDF::Layout::Frame.new(0, 0, width, height)).success?
|
|
371
|
+
rescue StandardError
|
|
372
|
+
false
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# +target+ is the composer at the top level and a children collector
|
|
376
|
+
# inside any container; only the former draws.
|
|
377
|
+
def emit_box(box, target)
|
|
378
|
+
target.respond_to?(:draw_box) ? target.draw_box(box) : target << box
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def image_block(node, target)
|
|
382
|
+
io = resolve_image(node[:src].to_s)
|
|
383
|
+
if io
|
|
384
|
+
target.image(io, **style_for("image"))
|
|
385
|
+
else
|
|
386
|
+
alt = resolve_nbsp(node[:alt])
|
|
387
|
+
alt = "[image: #{node[:src]}]" if alt.empty?
|
|
388
|
+
target.formatted_text([{ text: alt, font: [base_font, { variant: :italic }] }],
|
|
389
|
+
margin: [0, 0, BLOCK_GAP])
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def display_math(node, target)
|
|
394
|
+
if (bytes = call_renderer(:math, node[:content].to_s, true))
|
|
395
|
+
return image_bytes(bytes, target, align: :center)
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
style = style_for("math", with_box: true)
|
|
399
|
+
box = style.delete(:box)
|
|
400
|
+
style[:font] ||= code_font
|
|
401
|
+
target.text(node[:content].to_s, **style, text_align: :center, box_style: box)
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def thematic_break(target)
|
|
405
|
+
style = style_for("thematic_break")
|
|
406
|
+
height = style.delete(:height)
|
|
407
|
+
target.box(:base, height: height, style: style)
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Draw a rendered image ({bytes:, width:, height:} or raw bytes) as a
|
|
411
|
+
# block image. :width/:height are box constructor arguments in HexaPDF,
|
|
412
|
+
# not style properties.
|
|
413
|
+
def image_bytes(img, target, align: nil)
|
|
414
|
+
img = { bytes: img } if img.is_a?(String)
|
|
415
|
+
style = style_for("image")
|
|
416
|
+
style[:align] = align if align
|
|
417
|
+
opts = { style: style }
|
|
418
|
+
opts[:width] = img[:width] if img[:width]
|
|
419
|
+
opts[:height] = img[:height] if img[:height]
|
|
420
|
+
target.image(StringIO.new(img[:bytes]), **opts)
|
|
421
|
+
rescue StandardError
|
|
422
|
+
# A malformed image must not abort the whole document.
|
|
423
|
+
nil
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# ---- tables (with row/col spans) -----------------------------------
|
|
427
|
+
|
|
428
|
+
def table(node, target)
|
|
429
|
+
resolved = resolve_spans(Array(node[:rows]))
|
|
430
|
+
return if resolved.empty?
|
|
431
|
+
|
|
432
|
+
header_count = resolved.first.any? { |o| o[:header] } ? 1 : 0
|
|
433
|
+
table_style = style_for("table")
|
|
434
|
+
header_style = style_for("table.header")
|
|
435
|
+
|
|
436
|
+
cell_boxes = resolved.map do |row|
|
|
437
|
+
row.map do |o|
|
|
438
|
+
cell_style = o[:header] ? table_style.merge(header_style) : table_style
|
|
439
|
+
runs = inline_runs(o[:cell][:children], bold: o[:header],
|
|
440
|
+
font_family: cell_style[:font])
|
|
441
|
+
runs = [{ text: "" }] if runs.empty?
|
|
442
|
+
# :margin belongs to the table box, :cell_padding is our pseudo-prop.
|
|
443
|
+
box_opts = cell_style.except(:margin, :cell_padding, :box)
|
|
444
|
+
box_opts[:padding] = cell_style[:cell_padding]
|
|
445
|
+
box = @layout.formatted_text_box(runs, **box_opts)
|
|
446
|
+
hash = { content: box }
|
|
447
|
+
hash[:col_span] = o[:col_span] if o[:col_span] > 1
|
|
448
|
+
hash[:row_span] = o[:row_span] if o[:row_span] > 1
|
|
449
|
+
hash
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
header = header_count.positive? ? ->(_t) { [cell_boxes.first] } : nil
|
|
454
|
+
body = header_count.positive? ? cell_boxes[1..] : cell_boxes
|
|
455
|
+
body = [[{ content: @layout.text_box("") }]] if body.nil? || body.empty?
|
|
456
|
+
|
|
457
|
+
target.table(body, header: header, margin: table_style[:margin])
|
|
458
|
+
if node[:caption] && !node[:caption].empty?
|
|
459
|
+
style = text_style("table.caption")
|
|
460
|
+
target.formatted_text(inline_runs(node[:caption], italic: true, font_family: style[:font]),
|
|
461
|
+
**style)
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Resolve Carve's explicit span markers (a `<` cell = merge left, a `^`
|
|
466
|
+
# cell = merge up) into per-cell col_span / row_span counts, returning
|
|
467
|
+
# rows of originator hashes {cell:, header:, col_span:, row_span:} with
|
|
468
|
+
# marker cells dropped. Every covered grid position is explicit in Carve,
|
|
469
|
+
# so a cell's column index equals its position in the row.
|
|
470
|
+
def resolve_spans(rows)
|
|
471
|
+
col_owner = {} # column index => originator hash currently owning it
|
|
472
|
+
out = []
|
|
473
|
+
rows.each do |row|
|
|
474
|
+
emitted = []
|
|
475
|
+
last = nil
|
|
476
|
+
bumped = {} # originators already row-extended in THIS row (by object id)
|
|
477
|
+
Array(row[:cells]).each_with_index do |cell, col|
|
|
478
|
+
case cell[:span]
|
|
479
|
+
when "colspan"
|
|
480
|
+
owner = last || col_owner[col - 1]
|
|
481
|
+
owner[:col_span] += 1 if owner
|
|
482
|
+
col_owner[col] = owner if owner
|
|
483
|
+
when "rowspan"
|
|
484
|
+
owner = col_owner[col]
|
|
485
|
+
# A multi-column cell has one `^` per covered column on the next
|
|
486
|
+
# row; count the downward extension only once per originator.
|
|
487
|
+
if owner && !bumped[owner.object_id]
|
|
488
|
+
owner[:row_span] += 1
|
|
489
|
+
bumped[owner.object_id] = true
|
|
490
|
+
end
|
|
491
|
+
# col_owner[col] stays pointing at the same originator so a
|
|
492
|
+
# further `^` in the next row chains onto it.
|
|
493
|
+
else
|
|
494
|
+
o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
|
|
495
|
+
emitted << o
|
|
496
|
+
last = o
|
|
497
|
+
col_owner[col] = o
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
out << emitted
|
|
501
|
+
end
|
|
502
|
+
out
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
# ---- inline flattening ---------------------------------------------
|
|
506
|
+
|
|
507
|
+
def inline_runs(nodes, **ctx)
|
|
508
|
+
out = []
|
|
509
|
+
Array(nodes).each { |n| emit_inline(n, ctx, out) }
|
|
510
|
+
out
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def emit_inline(node, ctx, out)
|
|
514
|
+
case node[:type]
|
|
515
|
+
when "text" then out << run(node[:value].to_s, ctx)
|
|
516
|
+
when "soft_break" then out << run(" ", ctx)
|
|
517
|
+
when "hard_break" then out << { text: "\n" }
|
|
518
|
+
# Each emphasis sort is its OWN node type. They used to be one
|
|
519
|
+
# `emphasis` node carrying a `kind`, so a profile could not deny bold
|
|
520
|
+
# while allowing italic and nothing could name them apart (carve-rb#32).
|
|
521
|
+
# Matching only `emphasis` meant `*bold*` fell through to the default
|
|
522
|
+
# branch: the text still rendered, in the regular face, with nothing to
|
|
523
|
+
# report.
|
|
524
|
+
when "strong", "emphasis", "underline", "strike",
|
|
525
|
+
"superscript", "subscript", "highlight"
|
|
526
|
+
emit_children(node, emphasis_ctx(ctx, node), out)
|
|
527
|
+
when "span" then emit_children(node, ctx, out)
|
|
528
|
+
when "code" then out << run(node[:value].to_s, ctx.merge(code: true))
|
|
529
|
+
when "math" then inline_math(node, ctx, out)
|
|
530
|
+
when "link"
|
|
531
|
+
lctx = ctx.merge(link: node[:href].to_s)
|
|
532
|
+
if node[:children] && !node[:children].empty?
|
|
533
|
+
node[:children].each { |c| emit_inline(c, lctx, out) }
|
|
534
|
+
else
|
|
535
|
+
out << run(node[:href].to_s, lctx)
|
|
536
|
+
end
|
|
537
|
+
when "autolink" then out << run(node[:href].to_s, ctx.merge(link: node[:href].to_s))
|
|
538
|
+
when "image" then inline_image(node, ctx, out)
|
|
539
|
+
# `symbol` is what `:name:` publishes now; `emoji` was its name before
|
|
540
|
+
# the rename and is still accepted, the same way the footnote arm below
|
|
541
|
+
# still accepts `footnote`. Both print the shortcode, which is what the
|
|
542
|
+
# reference engine's plain-text target does with an unresolved one.
|
|
543
|
+
when "symbol", "emoji" then out << run(":#{node[:name]}:", ctx)
|
|
544
|
+
# The inline literal of PART 9 section 27 - a code span with the wrapper
|
|
545
|
+
# dropped. There is no wrapper in a PDF text run, so what is left is the
|
|
546
|
+
# content, unstyled: carve-js renders `A !`raw span` B` as `A raw span B`
|
|
547
|
+
# in plain text.
|
|
548
|
+
when "literal_inline" then out << run(node[:content].to_s, ctx)
|
|
549
|
+
# The number in "Figure 1:". Without this the caption renders without it.
|
|
550
|
+
when "caption_number" then out << run(node[:n].to_s, ctx)
|
|
551
|
+
# `:name[content]` - the content is an inline array, and the extension's
|
|
552
|
+
# own presentation has no PDF form, so emit what it wraps. carve-js
|
|
553
|
+
# renders `:kbd[Ctrl]` as `Ctrl` in plain text.
|
|
554
|
+
when "inline_extension" then inline_extension(node, ctx, out)
|
|
555
|
+
when "mention" then out << run("@#{node[:user]}", ctx)
|
|
556
|
+
when "tag" then out << run("##{node[:name]}", ctx)
|
|
557
|
+
# `footnote_ref` is `[^label]` and `inline_footnote` is `^[body]`.
|
|
558
|
+
# Both used to publish as `footnote`, which is the BLOCK definition's
|
|
559
|
+
# type - one identifier for three constructs, so nothing could tell them
|
|
560
|
+
# apart (carve-rb#19). The old name is still accepted: a tree stored by
|
|
561
|
+
# an older version can still be rendered.
|
|
562
|
+
when "footnote_ref", "inline_footnote", "footnote"
|
|
563
|
+
number = register_footnote(node)
|
|
564
|
+
out << run("[#{number}]", ctx.merge(super: true)) if number
|
|
565
|
+
when "citation_group" then out << run(node[:raw].to_s, ctx)
|
|
566
|
+
when "abbreviation" then out << run(node[:abbr].to_s, ctx)
|
|
567
|
+
when "cross_ref" then out << run(node[:target].to_s, ctx)
|
|
568
|
+
when "caption_number" then (out << run(node[:number].to_s, ctx) if node[:number])
|
|
569
|
+
when "critic_insert" then emit_children(node, ctx.merge(underline: true), out)
|
|
570
|
+
when "critic_delete" then emit_children(node, ctx.merge(strike: true), out)
|
|
571
|
+
when "critic_substitute" then out << run(node[:new_text].to_s, ctx.merge(underline: true))
|
|
572
|
+
when "smart_punctuation" then out << run(smart_punctuation_text(node), ctx)
|
|
573
|
+
# A character the author escaped. The backslash is authoring syntax, so
|
|
574
|
+
# the page shows the character - but the node has no children, so
|
|
575
|
+
# without this arm it falls through to the branch that emits children
|
|
576
|
+
# and renders nothing at all (carve#350, carve#355).
|
|
577
|
+
when "escaped_text" then out << run(node[:value].to_s, ctx)
|
|
578
|
+
when "raw_inline", "critic_comment"
|
|
579
|
+
# No safe PDF form - drop.
|
|
580
|
+
else
|
|
581
|
+
emit_children(node, ctx, out) if node[:children]
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
# Canonical glyph per smart-typography kind (Carve spec PART 9 section 8).
|
|
586
|
+
# Quote kinds are deliberately absent: their glyph is locale-dependent and
|
|
587
|
+
# is resolved during parsing, so the node carries it.
|
|
588
|
+
SMART_PUNCTUATION_GLYPHS = {
|
|
589
|
+
"ellipsis" => "\u{2026}",
|
|
590
|
+
"em_dash" => "\u{2014}",
|
|
591
|
+
"en_dash" => "\u{2013}",
|
|
592
|
+
"left_right_arrow" => "\u{2194}",
|
|
593
|
+
"rightwards_arrow" => "\u{2192}",
|
|
594
|
+
"leftwards_arrow" => "\u{2190}",
|
|
595
|
+
"rightwards_double_arrow" => "\u{21D2}",
|
|
596
|
+
"less_than_or_equal" => "\u{2264}",
|
|
597
|
+
"greater_than_or_equal" => "\u{2265}",
|
|
598
|
+
"not_equal" => "\u{2260}",
|
|
599
|
+
"plus_minus" => "\u{00B1}",
|
|
600
|
+
"copyright" => "\u{00A9}",
|
|
601
|
+
"registered" => "\u{00AE}",
|
|
602
|
+
"trademark" => "\u{2122}"
|
|
603
|
+
}.freeze
|
|
604
|
+
|
|
605
|
+
# A typographic substitution is its own node rather than text (spec PART 9
|
|
606
|
+
# section 8), carrying the resolved kind AND the author's source run.
|
|
607
|
+
#
|
|
608
|
+
# Before this existed here, the node fell through to the else branch,
|
|
609
|
+
# which emits children - and this node has none - so every quote,
|
|
610
|
+
# apostrophe, dash and ellipsis vanished from the rendered PDF with no
|
|
611
|
+
# error. That is why the resolution order matters: prefer the glyph the
|
|
612
|
+
# parser fixed (quotes are locale-dependent), then the kind table, and
|
|
613
|
+
# only fall back to the author's source run if a future kind arrives that
|
|
614
|
+
# this table does not know. Dropping to source text renders three dots
|
|
615
|
+
# instead of an ellipsis, which is wrong but visible; dropping the node
|
|
616
|
+
# renders nothing, which is not.
|
|
617
|
+
def smart_punctuation_text(node)
|
|
618
|
+
glyph = node[:glyph]
|
|
619
|
+
return glyph.to_s unless glyph.nil? || glyph.to_s.empty?
|
|
620
|
+
|
|
621
|
+
SMART_PUNCTUATION_GLYPHS.fetch(node[:kind].to_s) { node[:value].to_s }
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
def emit_children(node, ctx, out)
|
|
625
|
+
Array(node[:children]).each { |c| emit_inline(c, ctx, out) }
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# Styling for one emphasis node.
|
|
629
|
+
#
|
|
630
|
+
# `/*both*/` is a single `strong` node carrying `boldItalic`, not a
|
|
631
|
+
# `strong` wrapping an `emphasis`, so the italic has to be read off the
|
|
632
|
+
# flag rather than inferred from nesting.
|
|
633
|
+
#
|
|
634
|
+
# The legacy `kind` spelling is still honoured, so a tree stored by an
|
|
635
|
+
# older version renders the same.
|
|
636
|
+
def emphasis_ctx(ctx, node)
|
|
637
|
+
case node[:kind] || node[:type]
|
|
638
|
+
when "strong"
|
|
639
|
+
styled = ctx.merge(bold: true)
|
|
640
|
+
node[:boldItalic] ? styled.merge(italic: true) : styled
|
|
641
|
+
when "emphasis", "italic" then ctx.merge(italic: true)
|
|
642
|
+
when "bold-italic" then ctx.merge(bold: true, italic: true)
|
|
643
|
+
when "underline" then ctx.merge(underline: true)
|
|
644
|
+
when "strike" then ctx.merge(strike: true)
|
|
645
|
+
when "superscript", "super" then ctx.merge(super: true)
|
|
646
|
+
when "subscript", "sub" then ctx.merge(sub: true)
|
|
647
|
+
when "highlight" then ctx.merge(highlight: true)
|
|
648
|
+
else ctx
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
|
|
652
|
+
# Build one formatted-text run hash for +text+ under styling +ctx+.
|
|
653
|
+
# `:name[content]` - the extension's own presentation has no PDF form, so
|
|
654
|
+
# emit what it wraps. `content` is an inline ARRAY in a parsed tree; a
|
|
655
|
+
# hand-built one may hold a bare string, and `render_ast` is public, so
|
|
656
|
+
# take both rather than raising on the second.
|
|
657
|
+
def inline_extension(node, ctx, out)
|
|
658
|
+
content = node[:content]
|
|
659
|
+
case content
|
|
660
|
+
when Array then emit_children({ children: content }, ctx, out)
|
|
661
|
+
when String then out << run(content, ctx)
|
|
662
|
+
end
|
|
663
|
+
end
|
|
664
|
+
|
|
665
|
+
# U+E000 STANDS FOR a no-break space the parser resolved - from an escaped
|
|
666
|
+
# space (`a\ b`) or from a line block's preserved indentation (PART 9
|
|
667
|
+
# section 23). PART 12 is explicit that a consumer maps it to its target's
|
|
668
|
+
# no-break space, or to an ordinary space where the target has none, and
|
|
669
|
+
# MUST NOT emit it.
|
|
670
|
+
#
|
|
671
|
+
# A PDF has one, so it maps to U+00A0. Emitting the sentinel is not merely
|
|
672
|
+
# wrong here: the default Type1 font has no glyph for a private-use
|
|
673
|
+
# codepoint, so `a\ b` raised HexaPDF::MissingGlyphError and rendered
|
|
674
|
+
# nothing at all (carve-hexapdf#14).
|
|
675
|
+
RESOLVED_NBSP = "\u{E000}"
|
|
676
|
+
NBSP = "\u{00A0}"
|
|
677
|
+
|
|
678
|
+
def resolve_nbsp(text)
|
|
679
|
+
text.to_s.gsub(RESOLVED_NBSP, NBSP)
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
def run(text, ctx)
|
|
683
|
+
item = { text: resolve_nbsp(text) }
|
|
684
|
+
if ctx[:code]
|
|
685
|
+
# :box is a block-level pseudo-property (e.g. inherited from a user
|
|
686
|
+
# "code" entry meant for code.block); HexaPDF would read a run :box
|
|
687
|
+
# as an inline box spec and crash.
|
|
688
|
+
item.merge!(@styles.resolve("code.inline").except(:box))
|
|
689
|
+
elsif ctx[:bold] || ctx[:italic]
|
|
690
|
+
variant = if ctx[:bold] && ctx[:italic]
|
|
691
|
+
:bold_italic
|
|
692
|
+
elsif ctx[:bold]
|
|
693
|
+
:bold
|
|
694
|
+
else
|
|
695
|
+
:italic
|
|
696
|
+
end
|
|
697
|
+
item[:font] = [ctx[:font_family] || base_font, { variant: variant }]
|
|
698
|
+
end
|
|
699
|
+
item[:underline] = true if ctx[:underline]
|
|
700
|
+
item[:strikeout] = true if ctx[:strike]
|
|
701
|
+
item[:superscript] = true if ctx[:super]
|
|
702
|
+
item[:subscript] = true if ctx[:sub]
|
|
703
|
+
item.merge!(run_style("highlight")) if ctx[:highlight]
|
|
704
|
+
if ctx[:link] && !ctx[:link].empty?
|
|
705
|
+
item[:link] = ctx[:link]
|
|
706
|
+
item.merge!(run_style("link"))
|
|
707
|
+
end
|
|
708
|
+
item
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# Link/highlight styles are decoration overlays on a run that may already
|
|
712
|
+
# carry a variant or code font; the font they inherit from +base+ must not
|
|
713
|
+
# clobber it (only a font set explicitly on the key itself wins), and a
|
|
714
|
+
# block-level :box pseudo-property must never reach a run.
|
|
715
|
+
def run_style(key)
|
|
716
|
+
style = @styles.resolve(key).except(:box)
|
|
717
|
+
return style if @styles.user_set?(key, :font)
|
|
718
|
+
|
|
719
|
+
style.except(:font)
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Footnote definitions, keyed by label.
|
|
723
|
+
#
|
|
724
|
+
# They used to arrive as a `footnote_defs` MAP ON THE ROOT. PART 12 fixes
|
|
725
|
+
# the root at `type`, `children` and `srcByteLength`, so carve-rb moved
|
|
726
|
+
# them into the tree as `footnote` BLOCK nodes carrying a `label`
|
|
727
|
+
# (carve-rb#19, #21). Reading the old root field found nothing, every
|
|
728
|
+
# reference failed to resolve, and the endnote numbers silently vanished
|
|
729
|
+
# from the PDF - the bodies still rendered, so the page looked plausible.
|
|
730
|
+
#
|
|
731
|
+
# They are collected from anywhere in the tree, not just the top level: a
|
|
732
|
+
# definition written inside a container is still a definition.
|
|
733
|
+
def collect_footnote_defs(doc)
|
|
734
|
+
# The old root map first, so an AST stored by an earlier version still
|
|
735
|
+
# renders - `render_ast` takes whatever the caller kept.
|
|
736
|
+
defs = (doc[:footnote_defs] || {}).transform_keys(&:to_s)
|
|
737
|
+
walk_footnote_defs(doc, defs)
|
|
738
|
+
defs
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
def walk_footnote_defs(node, defs)
|
|
742
|
+
return unless node.is_a?(Hash)
|
|
743
|
+
|
|
744
|
+
if node[:type] == "footnote" && node[:label]
|
|
745
|
+
defs[node[:label].to_s] ||= Array(node[:children])
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
node.each_value do |value|
|
|
749
|
+
case value
|
|
750
|
+
when Array then value.each { |child| walk_footnote_defs(child, defs) }
|
|
751
|
+
when Hash then walk_footnote_defs(value, defs)
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Register a footnote occurrence and return its sequential number, or nil
|
|
757
|
+
# when there is nothing to number (unresolvable reference). Repeated
|
|
758
|
+
# references to the same definition share one number and one endnote.
|
|
759
|
+
def register_footnote(node)
|
|
760
|
+
if node[:inline]
|
|
761
|
+
@footnotes << { number: @footnotes.size + 1, inline: node[:inline] }
|
|
762
|
+
return @footnotes.last[:number]
|
|
763
|
+
end
|
|
764
|
+
|
|
765
|
+
id = node[:id]
|
|
766
|
+
if id && @footnote_defs.key?(id)
|
|
767
|
+
return @footnote_numbers[id] ||= begin
|
|
768
|
+
@footnotes << { number: @footnotes.size + 1, blocks: @footnote_defs[id] }
|
|
769
|
+
@footnotes.last[:number]
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
node[:number]
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
# Emit collected footnotes as a numbered endnote section under a short
|
|
777
|
+
# separator rule, mirroring how the HTML renderer relocates footnotes.
|
|
778
|
+
def render_footnotes(target)
|
|
779
|
+
return if @footnotes.empty?
|
|
780
|
+
|
|
781
|
+
style = style_for("footnote")
|
|
782
|
+
target.box(:base, height: 1,
|
|
783
|
+
style: { margin: [14, 380, 6, 0], background_color: "bbbbbb" })
|
|
784
|
+
@footnotes.each do |fn|
|
|
785
|
+
if fn[:inline]
|
|
786
|
+
runs = [{ text: "#{fn[:number]}. " }] +
|
|
787
|
+
inline_runs(fn[:inline], font_family: style[:font])
|
|
788
|
+
target.formatted_text(runs, **style)
|
|
789
|
+
else
|
|
790
|
+
first_para = true
|
|
791
|
+
Array(fn[:blocks]).each do |blk|
|
|
792
|
+
if first_para && blk[:type] == "paragraph"
|
|
793
|
+
runs = [{ text: "#{fn[:number]}. " }] +
|
|
794
|
+
inline_runs(blk[:children], font_family: style[:font])
|
|
795
|
+
target.formatted_text(runs, **style)
|
|
796
|
+
else
|
|
797
|
+
block(blk, target)
|
|
798
|
+
end
|
|
799
|
+
first_para = false
|
|
800
|
+
end
|
|
801
|
+
end
|
|
802
|
+
end
|
|
803
|
+
end
|
|
804
|
+
|
|
805
|
+
def base_font
|
|
806
|
+
@styles.resolve("base")[:font]
|
|
807
|
+
end
|
|
808
|
+
|
|
809
|
+
def code_font
|
|
810
|
+
@styles.resolve("code.inline")[:font]
|
|
811
|
+
end
|
|
812
|
+
|
|
813
|
+
def inline_math(node, ctx, out)
|
|
814
|
+
if (img = call_renderer(:math, node[:content].to_s, !!node[:display]))
|
|
815
|
+
spec = { box: [:image, StringIO.new(img[:bytes])],
|
|
816
|
+
height: img[:height] || 11, valign: :baseline }
|
|
817
|
+
spec[:width] = img[:width] if img[:width]
|
|
818
|
+
out << spec
|
|
819
|
+
else
|
|
820
|
+
out << run(node[:content].to_s, ctx.merge(code: true))
|
|
821
|
+
end
|
|
822
|
+
rescue StandardError
|
|
823
|
+
out << run(node[:content].to_s, ctx.merge(code: true))
|
|
824
|
+
end
|
|
825
|
+
|
|
826
|
+
def inline_image(node, ctx, out)
|
|
827
|
+
io = resolve_image(node[:src].to_s)
|
|
828
|
+
if io
|
|
829
|
+
out << { box: [:image, io], height: 12, valign: :baseline }
|
|
830
|
+
else
|
|
831
|
+
alt = node[:alt].to_s
|
|
832
|
+
out << run(alt.empty? ? "[image]" : alt, ctx.merge(italic: true))
|
|
833
|
+
end
|
|
834
|
+
rescue StandardError
|
|
835
|
+
out << run(node[:alt].to_s.empty? ? "[image]" : node[:alt].to_s, ctx.merge(italic: true))
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
# Resolve an image source to something HexaPDF can load: a local file
|
|
839
|
+
# path, or a decoded +data:+ URI (as a StringIO). Returns nil for remote
|
|
840
|
+
# URLs or unreadable sources (no network fetching).
|
|
841
|
+
def resolve_image(src)
|
|
842
|
+
return nil if src.empty?
|
|
843
|
+
|
|
844
|
+
if src.start_with?("data:")
|
|
845
|
+
meta, data = src.split(",", 2)
|
|
846
|
+
return nil unless data
|
|
847
|
+
|
|
848
|
+
bytes = meta.include?(";base64") ? Base64.decode64(data) : data
|
|
849
|
+
return StringIO.new(bytes)
|
|
850
|
+
end
|
|
851
|
+
return src if File.file?(src)
|
|
852
|
+
|
|
853
|
+
nil
|
|
854
|
+
end
|
|
855
|
+
|
|
856
|
+
# Invoke a renderer callable; returns {bytes:, width:, height:} or nil.
|
|
857
|
+
# Callables may return raw image bytes (String) or a Hash with :bytes
|
|
858
|
+
# plus optional :width / :height (in points) controlling the drawn size,
|
|
859
|
+
# so high-DPI rasters can embed at their intended dimensions.
|
|
860
|
+
def call_renderer(key, *args)
|
|
861
|
+
callable = @renderers[key] || @renderers[key.to_s]
|
|
862
|
+
return nil unless callable
|
|
863
|
+
|
|
864
|
+
result = callable.call(*args)
|
|
865
|
+
result = { bytes: result } if result.is_a?(String)
|
|
866
|
+
return nil unless result.is_a?(Hash) && result[:bytes].is_a?(String)
|
|
867
|
+
|
|
868
|
+
result
|
|
869
|
+
rescue StandardError
|
|
870
|
+
nil
|
|
871
|
+
end
|
|
872
|
+
|
|
873
|
+
def inline_children?(children)
|
|
874
|
+
Array(children).any? { |c| INLINE_TYPES.include?(c[:type]) }
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
INLINE_TYPES = %w[
|
|
878
|
+
text emphasis code link image span math raw_inline emoji autolink
|
|
879
|
+
cross_ref caption_number mention tag citation_group inline_extension
|
|
880
|
+
abbreviation footnote soft_break hard_break critic_insert critic_delete
|
|
881
|
+
critic_substitute critic_comment
|
|
882
|
+
].freeze
|
|
883
|
+
end
|
|
884
|
+
end
|
|
885
|
+
end
|