carve-hexapdf
Render the Carve markup language to PDF from Ruby, using the pure-Ruby HexaPDF document composition engine.
Carve source is parsed with Carve.parse
(from the carve-lang gem) and the resulting AST is walked onto a
HexaPDF::Composer:
- Inline nodes become HexaPDF styled text runs:
*strong*and/emphasis/map to bold / italic font variants,`code`to a monospace font, links to a colored run with a clickable URI overlay. - Block nodes map to HexaPDF boxes: headings and paragraphs to text boxes, lists to list boxes (ordered / unordered / task), tables to table boxes, block quotes / divs / admonitions to styled containers, and images to image boxes.
Install
# Gemfile
gem "carve-hexapdf"
bundle install
carve-hexapdf depends on carve-lang (a native gem that builds the Carve
engine via Rust) and on hexapdf.
Usage
require "carve/hexapdf"
# Carve syntax note: *...* is STRONG (bold), /.../ is EMPHASIS (italic).
pdf_bytes = Carve::Hexapdf.render(<<~CRV)
# Report
A paragraph with *bold*, /italic/, `code`, and a [link](https://example.com).
|= Name |= Score |
| Ann | 42 |
| Bob | 7 |
CRV
# Write straight to a file:
Carve::Hexapdf.render_file("# Hello", "hello.pdf")
# Render an already-parsed / transformed AST:
ast = Carve.parse("# From AST")
pdf_bytes = Carve::Hexapdf.render_ast(ast)
Options
| Option | Default | Meaning |
|---|---|---|
page_size |
:A4 |
HexaPDF page size (e.g. :A4, :Letter) |
margin |
45 |
Page margin in points |
base_font |
"Times" |
Proportional font family |
code_font |
"Courier" |
Monospace font family |
link_color |
"hp-blue" |
Fill color for links |
highlight_color |
"fff3a3" |
Background color for =highlight= |
styles |
nil |
Hierarchical style overrides (see below) |
renderers |
nil |
Callables that turn math / diagram source into image bytes (see below) |
Styling
Pass styles: to restyle renderer output without patching the renderer. Keys are
hierarchical dotted names; more specific entries win before parent entries, and
user values win over defaults at the same key.
Carve::Hexapdf.render(source, styles: {
"heading" => { fill_color: "333333" },
"code.block" => { box: { background_color: "fff8dd", padding: 8 } },
"admonition.warning" => { box: { background_color: "fff0f0" } },
})
Resolution examples:
heading.1resolves throughheadingand thenbase.code.inlineresolves throughcodeand thenbase.admonition.warningresolves throughadmonitionand thenbase.box:hashes deep-merge; other values, including margin arrays, replace as a whole.box:only takes effect on keys that draw a surrounding box (code.block,quote,admonition,definition_list,math); on text-only keys it is ignored.listaccepts only its structural properties (item_spacing,content_indentation); item text styling flows throughparagraph.
Specificity comes first: "heading" => { font_size: 30 } does not override the
default heading.1 size of 22, but "heading" => { fill_color: "333333" }
does apply to all heading levels. To change all heading sizes, set
heading.1 through heading.6 individually.
Existing keyword options are sugar under styles: and explicit style entries
win: base_font: maps to base.font, code_font: to code.font,
link_color: to link.fill_color, and highlight_color: to
highlight.background_color.
| Key | Defaults |
|---|---|
base |
{ font: "Times" } |
heading |
{ margin: [10, 0, 6] } |
heading.1 ... heading.6 |
{ font_size: 22 }, { font_size: 18 }, { font_size: 15 }, { font_size: 13 }, { font_size: 12 }, { font_size: 11 } |
paragraph |
{ margin: [0, 0, 8] } |
code |
{ font: "Courier" } |
code.block |
{ font_size: 9, margin: [2, 0, 8], box: { background_color: "f2f2f2", padding: 6 } } |
code.inline |
{} |
quote |
{ box: { margin: [2, 0, 8], padding: [4, 10], background_color: "f7f7f7" } } |
admonition |
{ box: { margin: [2, 0, 8], padding: [6, 10], background_color: "eef3fb" }, title_margin: [0, 0, 4] } |
admonition.<kind> |
No defaults; any kind the parser accepts works (including hyphenated ones) |
list |
{ item_spacing: 3, content_indentation: 18 } |
definition_list |
{ box: { margin: [0, 0, 8] }, definition_indent: 16 } |
table |
{ font_size: 10, cell_padding: 4, margin: [2, 0, 8] } |
table.header |
{} |
table.caption |
{ font_size: 9, margin: [0, 0, 8] } |
figure.caption |
{ font_size: 9, margin: [2, 0, 8], text_align: :center } (panel captions too) |
figure.group |
{ box: { margin: [2, 0, 8] }, column_gap: 18, min_column_width: 90 } |
figure.group.caption |
{ font_size: 9, margin: [4, 0, 0], text_align: :center } |
footnote |
{ font_size: 9, margin: [0, 0, 3] } (endnote section entries) |
link |
{ fill_color: "hp-blue" } |
highlight |
{ background_color: "fff3a3" } |
image |
{ margin: [2, 0, 8] } |
math |
{ font_size: 11, margin: [4, 0, 8], box: { padding: 4 } } |
thematic_break |
{ height: 2, margin: [8, 0, 8], background_color: "cccccc" } |
Supported constructs
Headings, paragraphs, all inline emphasis (strong / emphasis / bold-italic /
underline / strikethrough / superscript / subscript /
highlight), code, links, autolinks, soft & hard breaks, ordered / unordered
/ task lists (nested), tables with header rows and full row / column spans,
block quotes (with attribution), fenced code blocks, divs, admonitions,
definition lists, figures, thematic breaks, critic markup (insert → underline,
delete → strikethrough), footnotes (superscript [n] markers with the
bodies collected into a numbered endnote section - inline ^[..] and
referenced [^id] alike), and images - both block and inline, embedded
from a local file path or a data: URI. Task-list checkboxes are drawn in the
list marker column, so item text and nested lists align like any other list.
Composite figures
A bare ::: figure container is one figure of ordered panels (Carve PART 9
section 4c):
{.columns-2}
::: figure

^ (a) One

^ (b) Two
:::
^ Figure #: Both samples
On a page that group is one float. Its panels, any content preserved between them and the group caption are laid out as a single box a page break may not enter, and each panel keeps its own caption the same way - so the caption that numbers the figure never lands on the page after the panels it numbers. A group too tall to fit a page splits instead of failing the render, with the panels still in source order.
.columns-N on the attribute line is honored when the page is wide enough to
give every column figure.group.min_column_width points, and is otherwise
ignored in favor of a stack. Every panel is drawn either way: the hint decides
arrangement, never content.
An opener that carries a title or a label (::: figure "T", ::: figure [g])
is deliberately NOT this construct - it stays a generic container and renders
as one.
[!NOTE] The parser this gem consumes (
carve-lang, over carve-rs) does not producefigure_groupnodes yet. The renderer accepts them today - throughCarve.parseonce the engine ships the construct, and throughCarve::Hexapdf.render_astwith an AST from any engine that already does.
Math and diagrams (renderer callables)
PDF has no client-side renderer, so math and diagram fences are turned into
embedded raster images through callables you supply in renderers:. Each
returns image bytes (PNG/JPG) as a String - or a Hash { bytes:, width:, height: } (points) to control the drawn size, so high-DPI rasters embed
crisply at their intended dimensions. A missing renderer, or one that returns
anything else or raises, degrades that construct to its monospace source.
Carve::Hexapdf.render(source, renderers: {
# inline `$`x`$` and display `$$`x`$$` math:
math: ->(tex, display) { my_tex_to_png(tex, display) }, # -> bytes | {bytes:, width:, height:} | nil
# fenced ```mermaid / ```dot|graphviz / ```chart|vega:
mermaid: ->(src) { my_mermaid_to_png(src) },
graphviz: ->(src) { my_dot_to_png(src) },
chart: ->(src) { my_chart_to_png(src) },
})
Graceful degradation
The renderer never raises on an unsupported node - it degrades so a document always produces a PDF:
- Math / diagram fences without a matching
renderers:callable render their source in a monospace run. - Remote image URLs (
http(s)://) are shown as alt text - no network fetching. Local files anddata:URIs are embedded. - Raw HTML blocks/inlines and comments are dropped.
Licensing
This gem is MIT licensed. However, HexaPDF is dual-licensed AGPL-3.0 / commercial. If you distribute software or offer it over a network while depending on HexaPDF, you must comply with the AGPL (open-source your application) or hold a HexaPDF commercial license. This gem only bridges Carve to HexaPDF; your use of HexaPDF is governed by HexaPDF's own terms.
License
MIT, markup-carve. See LICENSE.