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 installcarve-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)Includes
A Carve document can pull another file in with {{ path }}. A String has no
identity of its own, so a directive in one stays literal; name the file instead
and it expands:
pdf_bytes = Carve::Hexapdf.render_from_file("report/index.crv")render_from_file READS Carve from a path and hands back PDF bytes.
render_file is its opposite pair: it takes Carve source and WRITES the PDF out,
and a directive in that source stays literal.
Containment defaults to the input file's own directory, so a sibling or a file
below it resolves and nothing above it does. include_root: moves that root:
Carve::Hexapdf.render_from_file("report/index.crv", include_root: "/srv/docs")The root must be absolute. A relative one is refused rather than resolved, because resolving it lands on whatever directory the process happens to run in, which is not a root anyone chose. The named document has to sit inside the root.
A target that cannot be read leaves the directive drawn as written, and the
reason goes to stderr. The message does not say whether the file was missing or
refused by containment: both report include-unresolved, so a document cannot
be used to probe the filesystem. Pass on_includes: to take reporting over and
get the dependency identities with it:
Carve::Hexapdf.render_from_file(
"report/index.crv",
on_includes: lambda { |warnings:, dependencies:, suppressed_warnings:|
dependencies.each { |d| puts "#{d[:path]} #{d[:resolved] ? 'read' : d[:denial]}" }
}
)extensions: and profile: reach the engine on the include path, and a child
file is parsed with the same ones as its parent. Without a root they raise,
rather than being dropped: Carve.parse accepts neither.
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 produce
figure_group nodes yet. The renderer accepts them today - through
Carve.parse once the engine ships the construct, and through
Carve::Hexapdf.render_ast with 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.
Development
Contributor setup, testing, and maintenance notes are in the development guide.
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.