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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d47fc98c98ecd21adba76ed8abb6150aabe535bcb99ad844b6f48705f115dce8
4
+ data.tar.gz: 3213a192f274a65f6e262ac73009dda8d33d88945406b20096120ce38d5a6158
5
+ SHA512:
6
+ metadata.gz: 84ad78a47f2cbb33608373ed0d05e3af1f7a14b2f250ed193ad42586bce5e3bae4bdd0b58659517d58e410e6d5f2d7d4a739d9aacd167597c2ff1ffc44a5834d
7
+ data.tar.gz: 8f88fe4e7cfb412788e3d4c045937db3a03707d891221561ab181485fc6026577cfbbc058c74334c1a5fb4cd07d38dce5d3a4b21bc1f98024142d8e42731ee89
data/CHANGELOG.md ADDED
@@ -0,0 +1,119 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [0.1.0] - 2026-08-19
9
+
10
+ ### Added
11
+
12
+ - Initial release: `Carve::Hexapdf.render`, `.render_ast`, and `.render_file`
13
+ to render Carve markup to PDF via the pure-Ruby HexaPDF engine.
14
+ - AST walker mapping Carve blocks to HexaPDF text/list/table/container/image
15
+ boxes and Carve inline nodes to styled text runs.
16
+ - All inline emphasis renders with its decoration: strong/italic/bold-italic
17
+ (font variants), underline, strikethrough, superscript, subscript, and
18
+ highlight; critic insert/delete map to underline/strikethrough; footnote
19
+ references render as superscript.
20
+ - Tables with header rows and full **row / column span** resolution (`^` / `<`
21
+ markers map to HexaPDF `row_span` / `col_span`).
22
+ - Math and diagram fences render as embedded raster images via optional
23
+ `renderers:` callables (`:math`, `:mermaid`, `:graphviz`, `:chart`), degrading
24
+ to monospace source when no renderer is supplied.
25
+ - Images (block and inline) embedded from local file paths or `data:` URIs.
26
+ - Footnotes (inline `^[..]` and referenced `[^id]`) render as superscript
27
+ `[n]` markers with their bodies collected into a numbered endnote section.
28
+ - Task-list checkboxes are drawn in the list marker column, so item text and
29
+ nested lists align like any other list.
30
+ - Renderer callables may return `{ bytes:, width:, height: }` to control the
31
+ drawn size, so high-DPI rasters embed crisply.
32
+ - Hierarchical `styles:` support through `Carve::Hexapdf::StyleMap` for
33
+ restyling headings, code, links, highlights, admonitions, tables, images,
34
+ math fallbacks, and other renderer surfaces; the `base_font:`, `code_font:`,
35
+ `link_color:`, and `highlight_color:` keyword options are convenience sugar
36
+ under the style map.
37
+ - Graceful degradation: unknown nodes fall back to text/children, remote image
38
+ URLs show alt text, raw HTML and comments are dropped; the renderer never
39
+ raises.
40
+
41
+ [Unreleased]: https://github.com/markup-carve/carve-hexapdf/compare/v0.1.0...HEAD
42
+ [0.1.0]: https://github.com/markup-carve/carve-hexapdf/releases/tag/v0.1.0
43
+
44
+ - **Composite figures render as one grouped float.** A bare `::: figure`
45
+ container is one numbered figure of ordered panels (Carve PART 9 section 4c,
46
+ AST node `figure_group`). Its panels, the content preserved between them and
47
+ the group caption are laid out as a single box a page break may not enter,
48
+ and each panel keeps its own caption the same way, so the caption that numbers
49
+ the figure cannot land on the page after it. A group too tall for a page
50
+ splits rather than failing the render, panels still in source order. A
51
+ `.columns-N` hint is honored when the page can give every column
52
+ `figure.group.min_column_width` points and is otherwise ignored in favor of a
53
+ stack - every panel is drawn either way. Two new style keys, `figure.group`
54
+ and `figure.group.caption`. The parser this gem consumes does not produce the
55
+ node yet; `render_ast` accepts one from any engine that does.
56
+
57
+ ### Fixed
58
+
59
+ - **The `carve-lang` floor moves to `>= 0.1.1`.** At `>= 0.1` a consumer could
60
+ resolve carve-lang 0.1.0, which predates the Carve 0.1.3 security release and
61
+ renders a list-valued URL attribute unsanitized - so this gem's own suite
62
+ could be green while every install of it carried a vulnerable engine. 0.1.1 is
63
+ the rebuild onto carve-rs 0.1.3.
64
+
65
+ - **A table's caption no longer loses the whole document.** `table.caption`
66
+ sits under `table` in the style chain, so it inherited `cell_padding` - a
67
+ property this renderer reads itself and HexaPDF has never heard of - and
68
+ splatting it into the caption's text box raised `NoMethodError`. Every
69
+ captioned table was affected; no fixture had captioned one.
70
+
71
+ - **A resolved no-break space renders instead of killing the render.** The
72
+ engines publish U+E000 in a text value to stand for a no-break space the parser
73
+ resolved - from an escaped space (`a\ b`) or a line block's preserved
74
+ indentation - and PART 12 says a consumer maps it to its target's no-break
75
+ space and must not emit it. It was drawn as-is, and the default Type1 font has
76
+ no glyph for a private-use codepoint, so an ordinary escaped space raised
77
+ `HexaPDF::MissingGlyphError` and produced no document at all. It now maps to
78
+ U+00A0 in text, inline code, code blocks and image alt text.
79
+
80
+ - **Four more inline nodes reach the page instead of vanishing.** `symbol`,
81
+ `literal_inline`, `caption_number` and `inline_extension` each carry their
82
+ content in `name`, `content` or `n` rather than in `children`, and the inline
83
+ fallback emits children only - so they contributed nothing at all: no error, no
84
+ placeholder, just missing text. A captioned figure rendered its caption without
85
+ its number. The `emoji` arm had also gone dead when that type was renamed to
86
+ `symbol` upstream; both names are accepted now. What each prints follows the
87
+ reference engine's plain-text target. `heading_ref` and `substitution` are still
88
+ dropped and are pinned as known gaps, since each needs a decision rather than an
89
+ arm.
90
+
91
+ - **Bold, italic, underline, strike, superscript and subscript reach the page
92
+ again.** Each emphasis sort is its own node type now, not one `emphasis` node
93
+ carrying a `kind` (carve-rb#32). The renderer matched only `emphasis`, so all
94
+ seven fell through to the default branch: the text still rendered, in the
95
+ regular face with no decoration, and the PDF stayed valid - which is why the
96
+ smoke test kept passing. `/*both*/` is a single `strong` carrying
97
+ `boldItalic`, so the italic is read off the flag rather than inferred from
98
+ nesting.
99
+
100
+ - **Footnote endnote numbers are back, and the body appears once.** Definitions
101
+ used to arrive as a `footnote_defs` map on the root; PART 12 fixes the root at
102
+ three fields, so they moved into the tree as `footnote` block nodes carrying a
103
+ `label` (carve-rb#19, #21), and the inline became `footnote_ref` /
104
+ `inline_footnote` rather than `footnote`. Reading the old root field found
105
+ nothing, every reference failed to resolve, and the `[1]` markers silently
106
+ vanished while the bodies still rendered. A definition is now relocated to the
107
+ endnote section wherever it sits, including inside a container.
108
+
109
+ Both old spellings are still accepted, so an AST stored by an earlier version
110
+ renders the same.
111
+
112
+ - **An escaped character no longer vanishes from the page.** `escaped_text` is
113
+ its own inline node (carve#350), and it carries no children - so without an
114
+ arm of its own it fell through to the branch that emits children and rendered
115
+ nothing at all. `a\-b` came out as `ab`. The same silent-drop shape as the
116
+ smart-punctuation node before it (carve#355).
117
+
118
+ Dormant until the engine behind `Carve.parse` emits the node; handled here
119
+ first so the bump lands into a renderer that already copes.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 markup-carve
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,230 @@
1
+ # carve-hexapdf
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.
18
+
19
+ ## Install
20
+
21
+ ```ruby
22
+ # Gemfile
23
+ gem "carve-hexapdf"
24
+ ```
25
+
26
+ ```sh
27
+ bundle install
28
+ ```
29
+
30
+ `carve-hexapdf` depends on `carve-lang` (a native gem that builds the Carve
31
+ engine via Rust) and on `hexapdf`.
32
+
33
+ ## Usage
34
+
35
+ ```ruby
36
+ 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
+ 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
+ ### Options
58
+
59
+ | Option | Default | Meaning |
60
+ | ------ | ------- | ------- |
61
+ | `page_size` | `:A4` | HexaPDF page size (e.g. `:A4`, `:Letter`) |
62
+ | `margin` | `45` | Page margin in points |
63
+ | `base_font` | `"Times"` | Proportional font family |
64
+ | `code_font` | `"Courier"` | Monospace font family |
65
+ | `link_color` | `"hp-blue"` | Fill color for links |
66
+ | `highlight_color` | `"fff3a3"` | Background color for `=highlight=` |
67
+ | `styles` | `nil` | Hierarchical style overrides (see below) |
68
+ | `renderers` | `nil` | Callables that turn math / diagram source into image bytes (see below) |
69
+
70
+ ## Styling
71
+
72
+ Pass `styles:` to restyle renderer output without patching the renderer. Keys are
73
+ hierarchical dotted names; more specific entries win before parent entries, and
74
+ user values win over defaults at the same key.
75
+
76
+ ```ruby
77
+ Carve::Hexapdf.render(source, styles: {
78
+ "heading" => { fill_color: "333333" },
79
+ "code.block" => { box: { background_color: "fff8dd", padding: 8 } },
80
+ "admonition.warning" => { box: { background_color: "fff0f0" } },
81
+ })
82
+ ```
83
+
84
+ Resolution examples:
85
+
86
+ - `heading.1` resolves through `heading` and then `base`.
87
+ - `code.inline` resolves through `code` and then `base`.
88
+ - `admonition.warning` resolves through `admonition` and then `base`.
89
+ - `box:` hashes deep-merge; other values, including margin arrays, replace as a
90
+ whole.
91
+ - `box:` only takes effect on keys that draw a surrounding box (`code.block`,
92
+ `quote`, `admonition`, `definition_list`, `math`); on text-only keys it is
93
+ ignored.
94
+ - `list` accepts only its structural properties (`item_spacing`,
95
+ `content_indentation`); item text styling flows through `paragraph`.
96
+
97
+ Specificity comes first: `"heading" => { font_size: 30 }` does not override the
98
+ default `heading.1` size of `22`, but `"heading" => { fill_color: "333333" }`
99
+ does apply to all heading levels. To change all heading sizes, set
100
+ `heading.1` through `heading.6` individually.
101
+
102
+ Existing keyword options are sugar under `styles:` and explicit style entries
103
+ win: `base_font:` maps to `base.font`, `code_font:` to `code.font`,
104
+ `link_color:` to `link.fill_color`, and `highlight_color:` to
105
+ `highlight.background_color`.
106
+
107
+ | Key | Defaults |
108
+ | --- | -------- |
109
+ | `base` | `{ font: "Times" }` |
110
+ | `heading` | `{ margin: [10, 0, 6] }` |
111
+ | `heading.1` ... `heading.6` | `{ font_size: 22 }`, `{ font_size: 18 }`, `{ font_size: 15 }`, `{ font_size: 13 }`, `{ font_size: 12 }`, `{ font_size: 11 }` |
112
+ | `paragraph` | `{ margin: [0, 0, 8] }` |
113
+ | `code` | `{ font: "Courier" }` |
114
+ | `code.block` | `{ font_size: 9, margin: [2, 0, 8], box: { background_color: "f2f2f2", padding: 6 } }` |
115
+ | `code.inline` | `{}` |
116
+ | `quote` | `{ box: { margin: [2, 0, 8], padding: [4, 10], background_color: "f7f7f7" } }` |
117
+ | `admonition` | `{ box: { margin: [2, 0, 8], padding: [6, 10], background_color: "eef3fb" }, title_margin: [0, 0, 4] }` |
118
+ | `admonition.<kind>` | No defaults; any kind the parser accepts works (including hyphenated ones) |
119
+ | `list` | `{ item_spacing: 3, content_indentation: 18 }` |
120
+ | `definition_list` | `{ box: { margin: [0, 0, 8] }, definition_indent: 16 }` |
121
+ | `table` | `{ font_size: 10, cell_padding: 4, margin: [2, 0, 8] }` |
122
+ | `table.header` | `{}` |
123
+ | `table.caption` | `{ font_size: 9, margin: [0, 0, 8] }` |
124
+ | `figure.caption` | `{ font_size: 9, margin: [2, 0, 8], text_align: :center }` (panel captions too) |
125
+ | `figure.group` | `{ box: { margin: [2, 0, 8] }, column_gap: 18, min_column_width: 90 }` |
126
+ | `figure.group.caption` | `{ font_size: 9, margin: [4, 0, 0], text_align: :center }` |
127
+ | `footnote` | `{ font_size: 9, margin: [0, 0, 3] }` (endnote section entries) |
128
+ | `link` | `{ fill_color: "hp-blue" }` |
129
+ | `highlight` | `{ background_color: "fff3a3" }` |
130
+ | `image` | `{ margin: [2, 0, 8] }` |
131
+ | `math` | `{ font_size: 11, margin: [4, 0, 8], box: { padding: 4 } }` |
132
+ | `thematic_break` | `{ height: 2, margin: [8, 0, 8], background_color: "cccccc" }` |
133
+
134
+ ## Supported constructs
135
+
136
+ Headings, paragraphs, all inline emphasis (strong / emphasis / bold-italic /
137
+ **underline** / **strikethrough** / **superscript** / **subscript** /
138
+ **highlight**), code, links, autolinks, soft & hard breaks, ordered / unordered
139
+ / task lists (nested), **tables with header rows and full row / column spans**,
140
+ block quotes (with attribution), fenced code blocks, divs, admonitions,
141
+ definition lists, figures, thematic breaks, critic markup (insert → underline,
142
+ delete → strikethrough), **footnotes** (superscript `[n]` markers with the
143
+ bodies collected into a numbered endnote section - inline `^[..]` and
144
+ referenced `[^id]` alike), and **images** - both block and inline, embedded
145
+ from a local file path or a `data:` URI. Task-list checkboxes are drawn in the
146
+ list marker column, so item text and nested lists align like any other list.
147
+
148
+ ### Composite figures
149
+
150
+ A bare `::: figure` container is one figure of ordered panels (Carve PART 9
151
+ section 4c):
152
+
153
+ ```
154
+ {.columns-2}
155
+ ::: figure
156
+ ![one](a.png)
157
+ ^ (a) One
158
+
159
+ ![two](b.png)
160
+ ^ (b) Two
161
+ :::
162
+ ^ Figure #: Both samples
163
+ ```
164
+
165
+ On a page that group is one float. Its panels, any content preserved between
166
+ them and the group caption are laid out as a single box a page break may not
167
+ enter, and each panel keeps its own caption the same way - so the caption that
168
+ numbers the figure never lands on the page after the panels it numbers. A
169
+ group too tall to fit a page splits instead of failing the render, with the
170
+ panels still in source order.
171
+
172
+ `.columns-N` on the attribute line is honored when the page is wide enough to
173
+ give every column `figure.group.min_column_width` points, and is otherwise
174
+ ignored in favor of a stack. Every panel is drawn either way: the hint decides
175
+ arrangement, never content.
176
+
177
+ An opener that carries a title or a label (`::: figure "T"`, `::: figure [g]`)
178
+ is deliberately NOT this construct - it stays a generic container and renders
179
+ as one.
180
+
181
+ > [!NOTE]
182
+ > The parser this gem consumes (`carve-lang`, over carve-rs) does not produce
183
+ > `figure_group` nodes yet. The renderer accepts them today - through
184
+ > `Carve.parse` once the engine ships the construct, and through
185
+ > `Carve::Hexapdf.render_ast` with an AST from any engine that already does.
186
+
187
+ ### Math and diagrams (renderer callables)
188
+
189
+ PDF has no client-side renderer, so math and diagram fences are turned into
190
+ embedded raster images through callables you supply in `renderers:`. Each
191
+ returns image bytes (PNG/JPG) as a String - or a Hash `{ bytes:, width:,
192
+ height: }` (points) to control the drawn size, so high-DPI rasters embed
193
+ crisply at their intended dimensions. A missing renderer, or one that returns
194
+ anything else or raises, degrades that construct to its monospace source.
195
+
196
+ ```ruby
197
+ Carve::Hexapdf.render(source, renderers: {
198
+ # inline `$`x`$` and display `$$`x`$$` math:
199
+ math: ->(tex, display) { my_tex_to_png(tex, display) }, # -> bytes | {bytes:, width:, height:} | nil
200
+ # fenced ```mermaid / ```dot|graphviz / ```chart|vega:
201
+ mermaid: ->(src) { my_mermaid_to_png(src) },
202
+ graphviz: ->(src) { my_dot_to_png(src) },
203
+ chart: ->(src) { my_chart_to_png(src) },
204
+ })
205
+ ```
206
+
207
+ ### Graceful degradation
208
+
209
+ The renderer never raises on an unsupported node - it degrades so a document
210
+ always produces a PDF:
211
+
212
+ - **Math / diagram** fences without a matching `renderers:` callable render
213
+ their source in a monospace run.
214
+ - **Remote image URLs** (`http(s)://`) are shown as alt text - no network
215
+ fetching. Local files and `data:` URIs are embedded.
216
+ - **Raw HTML** blocks/inlines and **comments** are dropped.
217
+
218
+ ## Licensing
219
+
220
+ This gem is **MIT** licensed. However, **HexaPDF is dual-licensed AGPL-3.0 /
221
+ commercial**. If you distribute software or offer it over a network while
222
+ depending on HexaPDF, you must comply with the AGPL (open-source your
223
+ application) or hold a
224
+ [HexaPDF commercial license](https://hexapdf.gettalong.org/#pricing). This gem
225
+ only bridges Carve to HexaPDF; your use of HexaPDF is governed by HexaPDF's own
226
+ terms.
227
+
228
+ ## License
229
+
230
+ MIT, markup-carve. See [LICENSE](LICENSE).