carve-hexapdf 0.1.5 → 0.1.6

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: e542a29c024a850009d9f9fa32d6fe69ba870a97eab23f595522878d7da1c367
4
+ data.tar.gz: 05d84eaa30d5ad7c4d6f111dc442f88a113bf8a7aa817849df4ee7b58bb5942f
5
5
  SHA512:
6
- metadata.gz: 718e37565452624372860471353c73c80b26566e603d62b60912d2dba8ec5b99555c2671c7663ddc42dac18b88c620b091490908eb756f2c1277bf1a7f0ea731
7
- data.tar.gz: 11b5129dd95d3f1ffd4260a136f6c842a381d4286fcaedac400ff53ca25b49b084e3d6f58c725b8a56d74dc29343149307c42ae586308b91701b546250302ef0
6
+ metadata.gz: 1382a76d094f651b2cc1b6cbae0ce65409d27222e02f4355361fce93ef6c9cc873b1ebd58ea01b6f02003ebb112ee8228784bb4dddf0aea60ac7186fc4e48f74
7
+ data.tar.gz: 25cd7967c9fe80a6c18df8a2d261c6d27d041a874029c056b52f43f621af5e97f939e3a19f9c141eaa51a87292829445ac58b8074a09a477c8afd61cff31b149
data/CHANGELOG.md CHANGED
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.6] - 2026-11-12
11
+
12
+ 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.
13
+
14
+ ### Fixes
15
+
16
+ - Definition list terms and descriptions are drawn; they were dropped from the page on every engine (#66).
17
+ - `require "carve/hexapdf"` works on Ruby 3.4, where `base64` is no longer a default gem (#64).
18
+
19
+ ### Improvements
20
+
21
+ - Table cells keep their horizontal alignment, from the cell or its column's header (#67).
22
+ - 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).
23
+
24
+ **Full Changelog**: https://github.com/markup-carve/carve-hexapdf/compare/v0.1.5...v0.1.6
25
+
10
26
  ## [0.1.5] - 2026-10-24
11
27
 
12
28
  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
 
@@ -258,13 +257,15 @@ module Carve
258
257
  def definition_list(node, target)
259
258
  style = style_for("definition_list", with_box: true)
260
259
  target.container(style: style[:box]) do |cont|
260
+ # `items` is a flat run of definition_term / definition_description
261
+ # nodes, not term/definition pairs.
261
262
  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|
263
+ case item[:type]
264
+ when "definition_term"
265
+ cont.formatted_text(inline_runs(item[:children], bold: true), margin: [2, 0, 1])
266
+ when "definition_description"
266
267
  cont.container(style: { padding: [0, 0, 0, style[:definition_indent]] }) do |dcont|
267
- Array(defn).each { |ch| block(ch, dcont) }
268
+ Array(item[:children]).each { |ch| block(ch, dcont) }
268
269
  end
269
270
  end
270
271
  end
@@ -434,6 +435,7 @@ module Carve
434
435
  resolved.first.none? { |o| o[:row_span] > 1 } ? 1 : 0
435
436
  table_style = style_for("table")
436
437
  header_style = style_for("table.header")
438
+ columns = column_alignments(node)
437
439
 
438
440
  cell_boxes = resolved.map do |row|
439
441
  row.map do |o|
@@ -444,6 +446,9 @@ module Carve
444
446
  # :margin belongs to the table box, :cell_padding is our pseudo-prop.
445
447
  box_opts = cell_style.except(:margin, :cell_padding, :box)
446
448
  box_opts[:padding] = cell_style[:cell_padding]
449
+ column = columns[o[:col]] || {}
450
+ align = o[:cell][:align] || column[:align]
451
+ box_opts[:text_align] = CELL_ALIGN[align] if CELL_ALIGN.key?(align)
447
452
  box = @layout.formatted_text_box(runs, **box_opts)
448
453
  hash = { content: box }
449
454
  hash[:col_span] = o[:col_span] if o[:col_span] > 1
@@ -464,9 +469,37 @@ module Carve
464
469
  end
465
470
  end
466
471
 
472
+ CELL_ALIGN = { "left" => :left, "center" => :center, "right" => :right }.freeze
473
+
474
+ # A cell without its own alignment inherits its column's: the table's
475
+ # `columns` metadata, overridden by the head rows (the HTML `thead`), a
476
+ # later row winning. Any other cell's alignment is local. The head is
477
+ # `rowGroups.headRows` when published, else the leading header rows.
478
+ # Vertical alignment is not drawn: `text_valign` overflows a table cell.
479
+ def column_alignments(node)
480
+ columns = Array(node[:columns]).map { |c| (c || {}).slice(:align) }
481
+ rows = Array(node[:rows])
482
+ declared = node.dig(:rowGroups, :headRows)
483
+ head = declared.is_a?(Integer) ? rows.first(declared) : rows.take_while { |row| header_row?(row) }
484
+ head.each do |row|
485
+ Array(row[:cells]).each_with_index do |cell, col|
486
+ next if cell[:span]
487
+
488
+ (columns[col] ||= {})[:align] = cell[:align] if cell[:align]
489
+ end
490
+ end
491
+ columns
492
+ end
493
+
494
+ # A `^` / `<` marker is not a header cell, but leaves its row in the head.
495
+ def header_row?(row)
496
+ content = Array(row[:cells]).reject { |cell| cell[:span] }
497
+ !content.empty? && content.all? { |cell| cell[:header] }
498
+ end
499
+
467
500
  # Resolve Carve's explicit span markers (a `<` cell = merge left, a `^`
468
501
  # cell = merge up) into per-cell col_span / row_span counts, returning
469
- # rows of originator hashes {cell:, header:, col_span:, row_span:} with
502
+ # rows of originator hashes {cell:, header:, col:, col_span:, row_span:} with
470
503
  # marker cells dropped. Every covered grid position is explicit in Carve,
471
504
  # so a cell's column index equals its position in the row.
472
505
  def resolve_spans(rows)
@@ -486,7 +519,7 @@ module Carve
486
519
  else
487
520
  # A leading marker has no cell to extend. Keep its grid slot
488
521
  # so the table has a drawable cell in this row.
489
- o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
522
+ o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
490
523
  emitted << o
491
524
  last = o
492
525
  col_owner[col] = o
@@ -500,13 +533,13 @@ module Carve
500
533
  bumped[owner.object_id] = true
501
534
  end
502
535
  if owner.nil?
503
- o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
536
+ o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
504
537
  emitted << o
505
538
  col_owner[col] = o
506
539
  end
507
540
  # A valid owner's slot remains available for further `^` rows.
508
541
  else
509
- o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
542
+ o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
510
543
  emitted << o
511
544
  last = o
512
545
  col_owner[col] = o
@@ -905,7 +938,7 @@ module Carve
905
938
  meta, data = src.split(",", 2)
906
939
  return nil unless data
907
940
 
908
- bytes = meta.include?(";base64") ? Base64.decode64(data) : data
941
+ bytes = meta.include?(";base64") ? data.unpack1("m") : data
909
942
  return StringIO.new(bytes)
910
943
  end
911
944
  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.6"
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.6
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