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.
@@ -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