carve-hexapdf 0.1.5 → 0.1.7

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 52d77d202d7f1390346e49475aa665563e045e3bcf0c411bfb10a0d99c800070
4
- data.tar.gz: 74687ef32c19f1969dff555ace0311b406c65aecb2b7ea0fa6c5eeced9977b3c
3
+ metadata.gz: 998af42c6a5a9753c0874cbdd003da94d3433303de6051f1a784a7beb3f3d2a1
4
+ data.tar.gz: dfe6a046b7e1026ebe8c9ce6c1364872d58f9e407441049cba2a7dd9c63fce66
5
5
  SHA512:
6
- metadata.gz: 718e37565452624372860471353c73c80b26566e603d62b60912d2dba8ec5b99555c2671c7663ddc42dac18b88c620b091490908eb756f2c1277bf1a7f0ea731
7
- data.tar.gz: 11b5129dd95d3f1ffd4260a136f6c842a381d4286fcaedac400ff53ca25b49b084e3d6f58c725b8a56d74dc29343149307c42ae586308b91701b546250302ef0
6
+ metadata.gz: c9a88ff35c2faaa48db5a26344b49837d53b00212db3bd9f3ba44a743d32a212c16cbabe1b8fe7c2a0469882a93c7bd3684faaeddf91c3329af371440783c7de
7
+ data.tar.gz: 203abfd74c94db24dc267e14c8ab93dce5acc49e6ff98312fa17fb0c211b40f4c87dea3eafd62f9e9c8b78e06cf2025ae8f38a9a01ad849a072eac5aa3deef32
data/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.7] - 2026-11-12
11
+
12
+ The default fonts no longer crash the render: `x <= 10` and CJK text now produce a PDF.
13
+
14
+ ### Fixes
15
+
16
+ - Text the font cannot draw no longer aborts the render. With the default Type1 fonts, `<=`, `>=` and `!=` print as written instead of `≤ ≥ ≠`, and other characters without a glyph print as `?` with a warning; a TrueType `base_font:` draws them (#70, #71).
17
+
18
+ **Full Changelog**: https://github.com/markup-carve/carve-hexapdf/compare/v0.1.6...v0.1.7
19
+
20
+ ## [0.1.6] - 2026-11-12
21
+
22
+ Definition lists and table cell alignment now reach the PDF, the gem loads on Ruby 3.4, and the engine moves to carve-lang 0.1.14.
23
+
24
+ ### Fixes
25
+
26
+ - Definition list terms and descriptions are drawn; they were dropped from the page on every engine (#66).
27
+ - `require "carve/hexapdf"` works on Ruby 3.4, where `base64` is no longer a default gem (#64).
28
+
29
+ ### Improvements
30
+
31
+ - Table cells keep their horizontal alignment, from the cell or its column's header (#67).
32
+ - Developed and tested against carve-lang 0.1.14. Its braced alignment spelling (`|={>}`) right-aligns the column; a first-line `---` now opens frontmatter that runs to the end of an unclosed document, so nothing below it is drawn (#68).
33
+
34
+ **Full Changelog**: https://github.com/markup-carve/carve-hexapdf/compare/v0.1.5...v0.1.6
35
+
10
36
  ## [0.1.5] - 2026-10-24
11
37
 
12
38
  Ruby interchange text now appears in rendered PDFs, and tables with span markers render without a PDF layout error.
data/README.md CHANGED
@@ -1,277 +1,27 @@
1
1
  # carve-hexapdf
2
2
 
3
- Render the [Carve](https://github.com/markup-carve/carve) markup language to
4
- **PDF** from Ruby, using the pure-Ruby [HexaPDF](https://hexapdf.gettalong.org)
5
- document composition engine.
6
-
7
- Carve source is parsed with [`Carve.parse`](https://github.com/markup-carve/carve-rb)
8
- (from the `carve-lang` gem) and the resulting AST is walked onto a
9
- `HexaPDF::Composer`:
10
-
11
- - **Inline** nodes become HexaPDF styled text runs: `*strong*` and `/emphasis/`
12
- map to bold / italic font variants, `` `code` `` to a monospace font, links to
13
- a colored run with a clickable URI overlay.
14
- - **Block** nodes map to HexaPDF boxes: headings and paragraphs to text boxes,
15
- lists to list boxes (ordered / unordered / task), tables to table boxes,
16
- block quotes / divs / admonitions to styled containers, and images to image
17
- boxes.
3
+ Ruby PDF renderer for Carve documents. It supports `carve-lang` 0.1.1 or later and uses the HexaPDF composition engine. See the [Carve documentation](https://markup-carve.github.io/carve/).
18
4
 
19
5
  ## Install
20
6
 
21
- ```ruby
22
- # Gemfile
23
- gem "carve-hexapdf"
24
- ```
25
-
26
7
  ```sh
27
- bundle install
8
+ gem install carve-hexapdf
28
9
  ```
29
10
 
30
- `carve-hexapdf` depends on `carve-lang` (a native gem that builds the Carve
31
- engine via Rust) and on `hexapdf`.
32
-
33
11
  ## Usage
34
12
 
35
13
  ```ruby
36
14
  require "carve/hexapdf"
37
-
38
- # Carve syntax note: *...* is STRONG (bold), /.../ is EMPHASIS (italic).
39
- pdf_bytes = Carve::Hexapdf.render(<<~CRV)
40
- # Report
41
-
42
- A paragraph with *bold*, /italic/, `code`, and a [link](https://example.com).
43
-
44
- |= Name |= Score |
45
- | Ann | 42 |
46
- | Bob | 7 |
47
- CRV
48
-
49
- # Write straight to a file:
50
15
  Carve::Hexapdf.render_file("# Hello", "hello.pdf")
51
-
52
- # Render an already-parsed / transformed AST:
53
- ast = Carve.parse("# From AST")
54
- pdf_bytes = Carve::Hexapdf.render_ast(ast)
55
- ```
56
-
57
- For interchange `ruby` nodes, PDF text shows each pair as
58
- `base(annotation)`, including `()` when the annotation is empty.
59
-
60
- ### Includes
61
-
62
- A Carve document can pull another file in with `{{ path }}`. A String has no
63
- identity of its own, so a directive in one stays literal; name the file instead
64
- and it expands:
65
-
66
- ```ruby
67
- pdf_bytes = Carve::Hexapdf.render_from_file("report/index.crv")
68
- ```
69
-
70
- `render_from_file` READS Carve from a path and hands back PDF bytes.
71
- `render_file` is its opposite pair: it takes Carve source and WRITES the PDF out,
72
- and a directive in that source stays literal.
73
-
74
- Containment defaults to the input file's own directory, so a sibling or a file
75
- below it resolves and nothing above it does. `include_root:` moves that root:
76
-
77
- ```ruby
78
- Carve::Hexapdf.render_from_file("report/index.crv", include_root: "/srv/docs")
79
- ```
80
-
81
- The root must be **absolute**. A relative one is refused rather than resolved,
82
- because resolving it lands on whatever directory the process happens to run in,
83
- which is not a root anyone chose. The named document has to sit inside the root.
84
-
85
- A target that cannot be read leaves the directive drawn as written, and the
86
- reason goes to stderr. The message does not say whether the file was missing or
87
- refused by containment: both report `include-unresolved`, so a document cannot
88
- be used to probe the filesystem. Pass `on_includes:` to take reporting over and
89
- get the dependency identities with it:
90
-
91
- ```ruby
92
- Carve::Hexapdf.render_from_file(
93
- "report/index.crv",
94
- on_includes: lambda { |warnings:, dependencies:, suppressed_warnings:|
95
- dependencies.each { |d| puts "#{d[:path]} #{d[:resolved] ? 'read' : d[:denial]}" }
96
- }
97
- )
98
- ```
99
-
100
- `extensions:` and `profile:` reach the engine on the include path, and a child
101
- file is parsed with the same ones as its parent. Without a root they raise,
102
- rather than being dropped: `Carve.parse` accepts neither.
103
-
104
- ### Options
105
-
106
- | Option | Default | Meaning |
107
- | ------ | ------- | ------- |
108
- | `page_size` | `:A4` | HexaPDF page size (e.g. `:A4`, `:Letter`) |
109
- | `margin` | `45` | Page margin in points |
110
- | `base_font` | `"Times"` | Proportional font family |
111
- | `code_font` | `"Courier"` | Monospace font family |
112
- | `link_color` | `"hp-blue"` | Fill color for links |
113
- | `highlight_color` | `"fff3a3"` | Background color for `=highlight=` |
114
- | `styles` | `nil` | Hierarchical style overrides (see below) |
115
- | `renderers` | `nil` | Callables that turn math / diagram source into image bytes (see below) |
116
-
117
- ## Styling
118
-
119
- Pass `styles:` to restyle renderer output without patching the renderer. Keys are
120
- hierarchical dotted names; more specific entries win before parent entries, and
121
- user values win over defaults at the same key.
122
-
123
- ```ruby
124
- Carve::Hexapdf.render(source, styles: {
125
- "heading" => { fill_color: "333333" },
126
- "code.block" => { box: { background_color: "fff8dd", padding: 8 } },
127
- "admonition.warning" => { box: { background_color: "fff0f0" } },
128
- })
129
- ```
130
-
131
- Resolution examples:
132
-
133
- - `heading.1` resolves through `heading` and then `base`.
134
- - `code.inline` resolves through `code` and then `base`.
135
- - `admonition.warning` resolves through `admonition` and then `base`.
136
- - `box:` hashes deep-merge; other values, including margin arrays, replace as a
137
- whole.
138
- - `box:` only takes effect on keys that draw a surrounding box (`code.block`,
139
- `quote`, `admonition`, `definition_list`, `math`); on text-only keys it is
140
- ignored.
141
- - `list` accepts only its structural properties (`item_spacing`,
142
- `content_indentation`); item text styling flows through `paragraph`.
143
-
144
- Specificity comes first: `"heading" => { font_size: 30 }` does not override the
145
- default `heading.1` size of `22`, but `"heading" => { fill_color: "333333" }`
146
- does apply to all heading levels. To change all heading sizes, set
147
- `heading.1` through `heading.6` individually.
148
-
149
- Existing keyword options are sugar under `styles:` and explicit style entries
150
- win: `base_font:` maps to `base.font`, `code_font:` to `code.font`,
151
- `link_color:` to `link.fill_color`, and `highlight_color:` to
152
- `highlight.background_color`.
153
-
154
- | Key | Defaults |
155
- | --- | -------- |
156
- | `base` | `{ font: "Times" }` |
157
- | `heading` | `{ margin: [10, 0, 6] }` |
158
- | `heading.1` ... `heading.6` | `{ font_size: 22 }`, `{ font_size: 18 }`, `{ font_size: 15 }`, `{ font_size: 13 }`, `{ font_size: 12 }`, `{ font_size: 11 }` |
159
- | `paragraph` | `{ margin: [0, 0, 8] }` |
160
- | `code` | `{ font: "Courier" }` |
161
- | `code.block` | `{ font_size: 9, margin: [2, 0, 8], box: { background_color: "f2f2f2", padding: 6 } }` |
162
- | `code.inline` | `{}` |
163
- | `quote` | `{ box: { margin: [2, 0, 8], padding: [4, 10], background_color: "f7f7f7" } }` |
164
- | `admonition` | `{ box: { margin: [2, 0, 8], padding: [6, 10], background_color: "eef3fb" }, title_margin: [0, 0, 4] }` |
165
- | `admonition.<kind>` | No defaults; any kind the parser accepts works (including hyphenated ones) |
166
- | `list` | `{ item_spacing: 3, content_indentation: 18 }` |
167
- | `definition_list` | `{ box: { margin: [0, 0, 8] }, definition_indent: 16 }` |
168
- | `table` | `{ font_size: 10, cell_padding: 4, margin: [2, 0, 8] }` |
169
- | `table.header` | `{}` |
170
- | `table.caption` | `{ font_size: 9, margin: [0, 0, 8] }` |
171
- | `figure.caption` | `{ font_size: 9, margin: [2, 0, 8], text_align: :center }` (panel captions too) |
172
- | `figure.group` | `{ box: { margin: [2, 0, 8] }, column_gap: 18, min_column_width: 90 }` |
173
- | `figure.group.caption` | `{ font_size: 9, margin: [4, 0, 0], text_align: :center }` |
174
- | `footnote` | `{ font_size: 9, margin: [0, 0, 3] }` (endnote section entries) |
175
- | `link` | `{ fill_color: "hp-blue" }` |
176
- | `highlight` | `{ background_color: "fff3a3" }` |
177
- | `image` | `{ margin: [2, 0, 8] }` |
178
- | `math` | `{ font_size: 11, margin: [4, 0, 8], box: { padding: 4 } }` |
179
- | `thematic_break` | `{ height: 2, margin: [8, 0, 8], background_color: "cccccc" }` |
180
-
181
- ## Supported constructs
182
-
183
- Headings, paragraphs, all inline emphasis (strong / emphasis / bold-italic /
184
- **underline** / **strikethrough** / **superscript** / **subscript** /
185
- **highlight**), code, links, autolinks, soft & hard breaks, ordered / unordered
186
- / task lists (nested), **tables with header rows and full row / column spans**,
187
- block quotes (with attribution), fenced code blocks, divs, admonitions,
188
- definition lists, figures, thematic breaks, critic markup (insert → underline,
189
- delete → strikethrough), **footnotes** (superscript `[n]` markers with the
190
- bodies collected into a numbered endnote section - inline `^[..]` and
191
- referenced `[^id]` alike), and **images** - both block and inline, embedded
192
- from a local file path or a `data:` URI. Task-list checkboxes are drawn in the
193
- list marker column, so item text and nested lists align like any other list.
194
-
195
- ### Composite figures
196
-
197
- A bare `::: figure` container is one figure of ordered panels (Carve PART 9
198
- section 4c):
199
-
200
- ```
201
- {.columns-2}
202
- ::: figure
203
- ![one](a.png)
204
- ^ (a) One
205
-
206
- ![two](b.png)
207
- ^ (b) Two
208
- :::
209
- ^ Figure #: Both samples
210
16
  ```
211
17
 
212
- On a page that group is one float. Its panels, any content preserved between
213
- them and the group caption are laid out as a single box a page break may not
214
- enter, and each panel keeps its own caption the same way - so the caption that
215
- numbers the figure never lands on the page after the panels it numbers. A
216
- group too tall to fit a page splits instead of failing the render, with the
217
- panels still in source order.
218
-
219
- `.columns-N` on the attribute line is honored when the page is wide enough to
220
- give every column `figure.group.min_column_width` points, and is otherwise
221
- ignored in favor of a stack. Every panel is drawn either way: the hint decides
222
- arrangement, never content.
223
-
224
- An opener that carries a title or a label (`::: figure "T"`, `::: figure [g]`)
225
- is deliberately NOT this construct - it stays a generic container and renders
226
- as one.
227
-
228
- > [!NOTE]
229
- > The parser this gem consumes (`carve-lang`, over carve-rs) does not produce
230
- > `figure_group` nodes yet. The renderer accepts them today - through
231
- > `Carve.parse` once the engine ships the construct, and through
232
- > `Carve::Hexapdf.render_ast` with an AST from any engine that already does.
233
-
234
- ### Math and diagrams (renderer callables)
235
-
236
- PDF has no client-side renderer, so math and diagram fences are turned into
237
- embedded raster images through callables you supply in `renderers:`. Each
238
- returns image bytes (PNG/JPG) as a String - or a Hash `{ bytes:, width:,
239
- height: }` (points) to control the drawn size, so high-DPI rasters embed
240
- crisply at their intended dimensions. A missing renderer, or one that returns
241
- anything else or raises, degrades that construct to its monospace source.
242
-
243
- ```ruby
244
- Carve::Hexapdf.render(source, renderers: {
245
- # inline `$`x`$` and display `$$`x`$$` math:
246
- math: ->(tex, display) { my_tex_to_png(tex, display) }, # -> bytes | {bytes:, width:, height:} | nil
247
- # fenced ```mermaid / ```dot|graphviz / ```chart|vega:
248
- mermaid: ->(src) { my_mermaid_to_png(src) },
249
- graphviz: ->(src) { my_dot_to_png(src) },
250
- chart: ->(src) { my_chart_to_png(src) },
251
- })
252
- ```
253
-
254
- ### Graceful degradation
255
-
256
- The renderer never raises on an unsupported node - it degrades so a document
257
- always produces a PDF:
258
-
259
- - **Math / diagram** fences without a matching `renderers:` callable render
260
- their source in a monospace run.
261
- - **Remote image URLs** (`http(s)://`) are shown as alt text - no network
262
- fetching. Local files and `data:` URIs are embedded.
263
- - **Raw HTML** blocks/inlines and **comments** are dropped.
264
-
265
- ## Development
18
+ ## Documentation
266
19
 
267
- Contributor setup, testing, and maintenance notes are in the [development guide](docs/development.md).
20
+ - [Carve documentation](https://markup-carve.github.io/carve/)
21
+ - [Guide](docs/guide.md)
22
+ - [Documentation index](docs/README.md)
23
+ - [Contributing and development](docs/development.md)
268
24
 
269
- ## Licensing
25
+ ## License
270
26
 
271
- This gem is **MIT** licensed. However, **HexaPDF is dual-licensed AGPL-3.0 /
272
- commercial**. If you distribute software or offer it over a network while
273
- depending on HexaPDF, you must comply with the AGPL (open-source your
274
- application) or hold a
275
- [HexaPDF commercial license](https://hexapdf.gettalong.org/#pricing). This gem
276
- only bridges Carve to HexaPDF; your use of HexaPDF is governed by HexaPDF's own
277
- terms.
27
+ [MIT](LICENSE). HexaPDF has separate licensing terms; see the [guide](docs/guide.md#licensing).
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "stringio"
4
- require "base64"
5
4
 
6
5
  require_relative "style_map"
7
6
 
@@ -62,6 +61,8 @@ module Carve
62
61
  highlight_color: highlight_color,
63
62
  styles: styles))
64
63
  @renderers = renderers || {}
64
+ @drawable = {}
65
+ @warned = {}
65
66
  end
66
67
 
67
68
  def render_document(doc)
@@ -187,7 +188,7 @@ module Carve
187
188
  content = resolve_nbsp(node[:content]).chomp
188
189
  style = style_for("code.block", inherited_font: true, with_box: true)
189
190
  box = style.delete(:box)
190
- target.text(content, **style, box_style: box)
191
+ target.text(drawable_text(content, style[:font] || code_font), **style, box_style: box)
191
192
  end
192
193
 
193
194
  def list(node, target)
@@ -258,13 +259,15 @@ module Carve
258
259
  def definition_list(node, target)
259
260
  style = style_for("definition_list", with_box: true)
260
261
  target.container(style: style[:box]) do |cont|
262
+ # `items` is a flat run of definition_term / definition_description
263
+ # nodes, not term/definition pairs.
261
264
  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|
265
+ case item[:type]
266
+ when "definition_term"
267
+ cont.formatted_text(inline_runs(item[:children], bold: true), margin: [2, 0, 1])
268
+ when "definition_description"
266
269
  cont.container(style: { padding: [0, 0, 0, style[:definition_indent]] }) do |dcont|
267
- Array(defn).each { |ch| block(ch, dcont) }
270
+ Array(item[:children]).each { |ch| block(ch, dcont) }
268
271
  end
269
272
  end
270
273
  end
@@ -385,7 +388,8 @@ module Carve
385
388
  else
386
389
  alt = resolve_nbsp(node[:alt])
387
390
  alt = "[image: #{node[:src]}]" if alt.empty?
388
- target.formatted_text([{ text: alt, font: [base_font, { variant: :italic }] }],
391
+ font = [base_font, { variant: :italic }]
392
+ target.formatted_text([{ text: drawable_text(alt, font), font: font }],
389
393
  margin: [0, 0, BLOCK_GAP])
390
394
  end
391
395
  end
@@ -398,7 +402,8 @@ module Carve
398
402
  style = style_for("math", with_box: true)
399
403
  box = style.delete(:box)
400
404
  style[:font] ||= code_font
401
- target.text(node[:content].to_s, **style, text_align: :center, box_style: box)
405
+ target.text(drawable_text(node[:content].to_s, style[:font]), **style,
406
+ text_align: :center, box_style: box)
402
407
  end
403
408
 
404
409
  def thematic_break(target)
@@ -434,6 +439,7 @@ module Carve
434
439
  resolved.first.none? { |o| o[:row_span] > 1 } ? 1 : 0
435
440
  table_style = style_for("table")
436
441
  header_style = style_for("table.header")
442
+ columns = column_alignments(node)
437
443
 
438
444
  cell_boxes = resolved.map do |row|
439
445
  row.map do |o|
@@ -444,6 +450,9 @@ module Carve
444
450
  # :margin belongs to the table box, :cell_padding is our pseudo-prop.
445
451
  box_opts = cell_style.except(:margin, :cell_padding, :box)
446
452
  box_opts[:padding] = cell_style[:cell_padding]
453
+ column = columns[o[:col]] || {}
454
+ align = o[:cell][:align] || column[:align]
455
+ box_opts[:text_align] = CELL_ALIGN[align] if CELL_ALIGN.key?(align)
447
456
  box = @layout.formatted_text_box(runs, **box_opts)
448
457
  hash = { content: box }
449
458
  hash[:col_span] = o[:col_span] if o[:col_span] > 1
@@ -464,9 +473,37 @@ module Carve
464
473
  end
465
474
  end
466
475
 
476
+ CELL_ALIGN = { "left" => :left, "center" => :center, "right" => :right }.freeze
477
+
478
+ # A cell without its own alignment inherits its column's: the table's
479
+ # `columns` metadata, overridden by the head rows (the HTML `thead`), a
480
+ # later row winning. Any other cell's alignment is local. The head is
481
+ # `rowGroups.headRows` when published, else the leading header rows.
482
+ # Vertical alignment is not drawn: `text_valign` overflows a table cell.
483
+ def column_alignments(node)
484
+ columns = Array(node[:columns]).map { |c| (c || {}).slice(:align) }
485
+ rows = Array(node[:rows])
486
+ declared = node.dig(:rowGroups, :headRows)
487
+ head = declared.is_a?(Integer) ? rows.first(declared) : rows.take_while { |row| header_row?(row) }
488
+ head.each do |row|
489
+ Array(row[:cells]).each_with_index do |cell, col|
490
+ next if cell[:span]
491
+
492
+ (columns[col] ||= {})[:align] = cell[:align] if cell[:align]
493
+ end
494
+ end
495
+ columns
496
+ end
497
+
498
+ # A `^` / `<` marker is not a header cell, but leaves its row in the head.
499
+ def header_row?(row)
500
+ content = Array(row[:cells]).reject { |cell| cell[:span] }
501
+ !content.empty? && content.all? { |cell| cell[:header] }
502
+ end
503
+
467
504
  # Resolve Carve's explicit span markers (a `<` cell = merge left, a `^`
468
505
  # cell = merge up) into per-cell col_span / row_span counts, returning
469
- # rows of originator hashes {cell:, header:, col_span:, row_span:} with
506
+ # rows of originator hashes {cell:, header:, col:, col_span:, row_span:} with
470
507
  # marker cells dropped. Every covered grid position is explicit in Carve,
471
508
  # so a cell's column index equals its position in the row.
472
509
  def resolve_spans(rows)
@@ -486,7 +523,7 @@ module Carve
486
523
  else
487
524
  # A leading marker has no cell to extend. Keep its grid slot
488
525
  # so the table has a drawable cell in this row.
489
- o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
526
+ o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
490
527
  emitted << o
491
528
  last = o
492
529
  col_owner[col] = o
@@ -500,13 +537,13 @@ module Carve
500
537
  bumped[owner.object_id] = true
501
538
  end
502
539
  if owner.nil?
503
- o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
540
+ o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
504
541
  emitted << o
505
542
  col_owner[col] = o
506
543
  end
507
544
  # A valid owner's slot remains available for further `^` rows.
508
545
  else
509
- o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
546
+ o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
510
547
  emitted << o
511
548
  last = o
512
549
  col_owner[col] = o
@@ -616,7 +653,8 @@ module Carve
616
653
  Array(pair[:annotation]).each { |part| emit_inline(part, ctx, out) }
617
654
  out << run(")", ctx)
618
655
  end
619
- when "smart_punctuation" then out << run(smart_punctuation_text(node), ctx)
656
+ when "smart_punctuation"
657
+ out << run(smart_punctuation_text(node), ctx, fallback: node[:value])
620
658
  # A character the author escaped. The backslash is authoring syntax, so
621
659
  # the page shows the character - but the node has no children, so
622
660
  # without this arm it falls through to the branch that emits children
@@ -734,7 +772,9 @@ module Carve
734
772
  text.to_s.gsub(RESOLVED_NBSP, NBSP)
735
773
  end
736
774
 
737
- def run(text, ctx)
775
+ # +fallback+ is the author's spelling of the same text, drawn instead when
776
+ # the run font cannot draw +text+ (a smart-typography glyph).
777
+ def run(text, ctx, fallback: nil)
738
778
  item = { text: resolve_nbsp(text) }
739
779
  if ctx[:code]
740
780
  # :box is a block-level pseudo-property (e.g. inherited from a user
@@ -760,9 +800,77 @@ module Carve
760
800
  item[:link] = ctx[:link]
761
801
  item.merge!(run_style("link"))
762
802
  end
803
+ font = item[:font] || ctx[:font_family] || base_font
804
+ item[:text] = drawable_text(item[:text], font, fallback: fallback)
763
805
  item
764
806
  end
765
807
 
808
+ # A character the font cannot encode raises inside HexaPDF and loses the
809
+ # whole document (carve-hexapdf#70). That is either a missing glyph (CJK
810
+ # in Times) or a glyph the font's encoding cannot reach (standard Type1
811
+ # Times has `lessequal`, WinAnsi has no slot for it).
812
+ def drawable_text(text, font, fallback: nil)
813
+ wrapper = font_wrapper(font)
814
+ return text if wrapper.nil? || text.each_char.all? { |ch| drawable?(wrapper, ch) }
815
+
816
+ if fallback && fallback.to_s.each_char.all? { |ch| drawable?(wrapper, ch) }
817
+ return fallback.to_s
818
+ end
819
+
820
+ text.each_char.map do |ch|
821
+ next ch if drawable?(wrapper, ch)
822
+
823
+ warn_missing_glyph(wrapper, ch)
824
+ drawable?(wrapper, REPLACEMENT) ? REPLACEMENT : ""
825
+ end.join
826
+ end
827
+
828
+ REPLACEMENT = "?"
829
+
830
+ def font_wrapper(font)
831
+ return font if font.respond_to?(:decode_codepoint)
832
+
833
+ name, opts = font.is_a?(Array) ? font : [font, {}]
834
+ return name if name.respond_to?(:decode_codepoint)
835
+
836
+ document = @c.document
837
+ name ||= document.config["font.default"]
838
+ document.fonts.add(name, variant: (opts || {})[:variant] || :none)
839
+ rescue StandardError
840
+ nil
841
+ end
842
+
843
+ # Mirrors HexaPDF: decode, let `font.on_invalid_glyph` (and so any
844
+ # configured `font.fallback`) supply a missing glyph, then encode. Tabs,
845
+ # line breaks and the like are layout controls, never drawn.
846
+ def drawable?(wrapper, char)
847
+ key = [wrapper, char]
848
+ return @drawable[key] if @drawable.key?(key)
849
+
850
+ @drawable[key] = encodable?(wrapper, char)
851
+ end
852
+
853
+ def encodable?(wrapper, char)
854
+ glyphs = [wrapper.decode_codepoint(char.ord)]
855
+ if glyphs.first.is_a?(::HexaPDF::Font::InvalidGlyph)
856
+ return true if glyphs.first.control_char?
857
+
858
+ glyphs = @c.document.config["font.on_invalid_glyph"].call(char.ord, glyphs.first)
859
+ end
860
+ glyphs.all? { |g| !g.is_a?(::HexaPDF::Font::InvalidGlyph) && g.font_wrapper.encode(g) }
861
+ rescue ::HexaPDF::Error
862
+ false
863
+ end
864
+
865
+ def warn_missing_glyph(wrapper, char)
866
+ return if @warned[[wrapper, char]]
867
+
868
+ @warned[[wrapper, char]] = true
869
+ name = wrapper.wrapped_font.full_name rescue wrapper.to_s
870
+ warn format("carve-hexapdf: font '%s' has no glyph for U+%04X %p; drawing %p instead",
871
+ name, char.ord, char, REPLACEMENT)
872
+ end
873
+
766
874
  # Link/highlight styles are decoration overlays on a run that may already
767
875
  # carry a variant or code font; the font they inherit from +base+ must not
768
876
  # clobber it (only a font set explicitly on the key itself wins), and a
@@ -905,7 +1013,7 @@ module Carve
905
1013
  meta, data = src.split(",", 2)
906
1014
  return nil unless data
907
1015
 
908
- bytes = meta.include?(";base64") ? Base64.decode64(data) : data
1016
+ bytes = meta.include?(";base64") ? data.unpack1("m") : data
909
1017
  return StringIO.new(bytes)
910
1018
  end
911
1019
  return src if File.file?(src)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Carve
4
4
  module Hexapdf
5
- VERSION = "0.1.5"
5
+ VERSION = "0.1.7"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carve-hexapdf
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - markup-carve
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-24 00:00:00.000000000 Z
11
+ date: 2026-11-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: carve-lang