0.0
The project is in a healthy, maintained state
Parse Carve markup (via the carve-lang gem) and render it to a laid-out PDF using HexaPDF's document composition engine. Carve block nodes map to HexaPDF text/list/table/container/image boxes; inline nodes map to styled text runs (bold/italic font variants, monospace code, colored links).
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

>= 5.0
~> 13.0

Runtime

>= 0.1.1, < 0.2.0
>= 1.0
 Project Readme

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)

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.1 resolves through heading and then base.
  • code.inline resolves through code and then base.
  • admonition.warning resolves through admonition and then base.
  • 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.
  • list accepts only its structural properties (item_spacing, content_indentation); item text styling flows through paragraph.

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
![one](a.png)
^ (a) One

![two](b.png)
^ (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 and data: 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.