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 +7 -0
- data/CHANGELOG.md +119 -0
- data/LICENSE +21 -0
- data/README.md +230 -0
- data/lib/carve/hexapdf/renderer.rb +885 -0
- data/lib/carve/hexapdf/style_map.rb +159 -0
- data/lib/carve/hexapdf/version.rb +7 -0
- data/lib/carve/hexapdf.rb +71 -0
- metadata +114 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Carve
|
|
4
|
+
module Hexapdf
|
|
5
|
+
# Hierarchical style resolver for renderer properties.
|
|
6
|
+
class StyleMap
|
|
7
|
+
def self.deep_freeze(value)
|
|
8
|
+
case value
|
|
9
|
+
when Hash
|
|
10
|
+
value.each_value { |entry| deep_freeze(entry) }
|
|
11
|
+
when Array
|
|
12
|
+
value.each { |entry| deep_freeze(entry) }
|
|
13
|
+
end
|
|
14
|
+
value.freeze
|
|
15
|
+
end
|
|
16
|
+
private_class_method :deep_freeze
|
|
17
|
+
|
|
18
|
+
DEFAULTS = deep_freeze({
|
|
19
|
+
"base" => { font: "Times" },
|
|
20
|
+
"heading" => { margin: [10, 0, 6] },
|
|
21
|
+
"heading.1" => { font_size: 22 },
|
|
22
|
+
"heading.2" => { font_size: 18 },
|
|
23
|
+
"heading.3" => { font_size: 15 },
|
|
24
|
+
"heading.4" => { font_size: 13 },
|
|
25
|
+
"heading.5" => { font_size: 12 },
|
|
26
|
+
"heading.6" => { font_size: 11 },
|
|
27
|
+
"paragraph" => { margin: [0, 0, 8] },
|
|
28
|
+
"code" => { font: "Courier" },
|
|
29
|
+
"code.block" => {
|
|
30
|
+
font_size: 9,
|
|
31
|
+
margin: [2, 0, 8],
|
|
32
|
+
box: { background_color: "f2f2f2", padding: 6 },
|
|
33
|
+
},
|
|
34
|
+
"code.inline" => {},
|
|
35
|
+
"quote" => {
|
|
36
|
+
box: { margin: [2, 0, 8], padding: [4, 10], background_color: "f7f7f7" },
|
|
37
|
+
},
|
|
38
|
+
"admonition" => {
|
|
39
|
+
box: { margin: [2, 0, 8], padding: [6, 10], background_color: "eef3fb" },
|
|
40
|
+
title_margin: [0, 0, 4],
|
|
41
|
+
},
|
|
42
|
+
"list" => { item_spacing: 3, content_indentation: 18 },
|
|
43
|
+
"definition_list" => { box: { margin: [0, 0, 8] }, definition_indent: 16 },
|
|
44
|
+
"table" => { font_size: 10, cell_padding: 4, margin: [2, 0, 8] },
|
|
45
|
+
"table.header" => {},
|
|
46
|
+
"table.caption" => { font_size: 9, margin: [0, 0, 8] },
|
|
47
|
+
"figure.caption" => { font_size: 9, margin: [2, 0, 8], text_align: :center },
|
|
48
|
+
# A composite figure (PART 9 section 4c). :column_gap and
|
|
49
|
+
# :min_column_width are read by the renderer rather than by HexaPDF:
|
|
50
|
+
# they decide whether a `.columns-N` hint is honored or the panels
|
|
51
|
+
# stack.
|
|
52
|
+
"figure.group" => {
|
|
53
|
+
box: { margin: [2, 0, 8] },
|
|
54
|
+
column_gap: 18,
|
|
55
|
+
min_column_width: 90,
|
|
56
|
+
},
|
|
57
|
+
"figure.group.caption" => { font_size: 9, margin: [4, 0, 0], text_align: :center },
|
|
58
|
+
"footnote" => { font_size: 9, margin: [0, 0, 3] },
|
|
59
|
+
"link" => { fill_color: "hp-blue" },
|
|
60
|
+
"highlight" => { background_color: "fff3a3" },
|
|
61
|
+
"image" => { margin: [2, 0, 8] },
|
|
62
|
+
"math" => { font_size: 11, margin: [4, 0, 8], box: { padding: 4 } },
|
|
63
|
+
"thematic_break" => { height: 2, margin: [8, 0, 8], background_color: "cccccc" },
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
def initialize(styles = nil)
|
|
67
|
+
@user = normalize(styles || {})
|
|
68
|
+
@memo = {}
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def resolve(key)
|
|
72
|
+
normalized = normalize_key(key)
|
|
73
|
+
validate_key!(normalized)
|
|
74
|
+
@memo[normalized] ||= chain_for(normalized).each_with_object({}) do |part, resolved|
|
|
75
|
+
merge_entry!(resolved, DEFAULTS[part]) if DEFAULTS.key?(part)
|
|
76
|
+
merge_entry!(resolved, @user[part]) if @user.key?(part)
|
|
77
|
+
end.freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def user_set?(key, property = nil)
|
|
81
|
+
normalized = normalize_key(key)
|
|
82
|
+
return false unless @user.key?(normalized)
|
|
83
|
+
return true if property.nil?
|
|
84
|
+
|
|
85
|
+
@user[normalized].key?(property)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def user_set_in_chain?(key, property)
|
|
89
|
+
chain_for(normalize_key(key)).any? { |part| user_set?(part, property) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def normalize(styles)
|
|
95
|
+
unless styles.respond_to?(:each_pair)
|
|
96
|
+
raise ArgumentError, "styles must be a Hash-like object"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
styles.each_pair.with_object({}) do |(key, value), out|
|
|
100
|
+
normalized = normalize_key(key)
|
|
101
|
+
validate_key!(normalized)
|
|
102
|
+
out[normalized] = normalize_entry(value, normalized)
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_key(key)
|
|
107
|
+
key.to_s
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_entry(value, key)
|
|
111
|
+
unless value.respond_to?(:each_pair)
|
|
112
|
+
raise ArgumentError, "style entry #{key.inspect} must be a Hash-like object"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
value.each_pair.with_object({}) do |(property, property_value), out|
|
|
116
|
+
out[property.to_sym] = property_value.is_a?(Hash) ? normalize_property_hash(property_value) : property_value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def normalize_property_hash(hash)
|
|
121
|
+
hash.each_pair.with_object({}) { |(key, value), out| out[key.to_sym] = value }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def validate_key!(key)
|
|
125
|
+
return if DEFAULTS.key?(key)
|
|
126
|
+
return if key.match?(/\Aheading\.\d+\z/)
|
|
127
|
+
# Admonition kinds are an open vocabulary (any word the parser accepts,
|
|
128
|
+
# including hyphenated ones), so accept any dot-free suffix.
|
|
129
|
+
return if key.match?(/\Aadmonition\.[^\s.]+\z/)
|
|
130
|
+
|
|
131
|
+
raise ArgumentError, "unknown style key: #{key.inspect}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def chain_for(key)
|
|
135
|
+
chain = []
|
|
136
|
+
current = key
|
|
137
|
+
loop do
|
|
138
|
+
chain << current
|
|
139
|
+
break if current == "base"
|
|
140
|
+
|
|
141
|
+
current = current.include?(".") ? current.rpartition(".").first : "base"
|
|
142
|
+
end
|
|
143
|
+
chain.reverse
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def merge_entry!(target, source)
|
|
147
|
+
return unless source
|
|
148
|
+
|
|
149
|
+
source.each_pair do |key, value|
|
|
150
|
+
target[key] = if key == :box && value.is_a?(Hash)
|
|
151
|
+
(target[key] || {}).merge(value)
|
|
152
|
+
else
|
|
153
|
+
value
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "hexapdf"
|
|
4
|
+
require "carve"
|
|
5
|
+
|
|
6
|
+
require_relative "hexapdf/version"
|
|
7
|
+
require_relative "hexapdf/style_map"
|
|
8
|
+
require_relative "hexapdf/renderer"
|
|
9
|
+
|
|
10
|
+
module Carve
|
|
11
|
+
# Render Carve markup to PDF using the pure-Ruby HexaPDF layout engine.
|
|
12
|
+
#
|
|
13
|
+
# pdf_bytes = Carve::Hexapdf.render("# Hello *world*")
|
|
14
|
+
# Carve::Hexapdf.render_file("# Report", "out.pdf")
|
|
15
|
+
#
|
|
16
|
+
# The Carve source is parsed with +Carve.parse+ (from the carve-lang gem) and
|
|
17
|
+
# the resulting AST is walked by {Renderer}, which drives a
|
|
18
|
+
# HexaPDF::Composer. Bold/italic map to font variants, inline code to a
|
|
19
|
+
# monospace font, links to colored runs with URI overlays; block nodes map to
|
|
20
|
+
# HexaPDF text/list/table/container/image boxes.
|
|
21
|
+
#
|
|
22
|
+
# NOTE ON LICENSING: HexaPDF is dual-licensed AGPL-3.0 / commercial. Software
|
|
23
|
+
# that is distributed or offered over a network while depending on HexaPDF
|
|
24
|
+
# must comply with the AGPL or hold a HexaPDF commercial license. This gem
|
|
25
|
+
# (MIT) only bridges to it; your use of HexaPDF is governed by HexaPDF's own
|
|
26
|
+
# terms.
|
|
27
|
+
module Hexapdf
|
|
28
|
+
class << self
|
|
29
|
+
# Render Carve +source+ to a PDF and return the document as a binary
|
|
30
|
+
# String.
|
|
31
|
+
#
|
|
32
|
+
# Options:
|
|
33
|
+
# page_size:: HexaPDF page size (default +:A4+).
|
|
34
|
+
# margin:: page margin in points (default 45).
|
|
35
|
+
# base_font:: proportional font family (default "Times").
|
|
36
|
+
# code_font:: monospace font family (default "Courier").
|
|
37
|
+
# link_color:: fill color for links (default "hp-blue").
|
|
38
|
+
# styles:: hierarchical style map for PDF output.
|
|
39
|
+
# renderers:: Hash of callables that turn math / diagram source into
|
|
40
|
+
# raster images, so those constructs render as images
|
|
41
|
+
# instead of degrading to source. Keys:
|
|
42
|
+
# +:math+ -> callable(tex, display_bool);
|
|
43
|
+
# +:mermaid+ / +:graphviz+ / +:chart+ -> callable(source).
|
|
44
|
+
# Return PNG/JPG bytes as a String, or a Hash with
|
|
45
|
+
# +:bytes+ and optional +:width+/+:height+ (points) to
|
|
46
|
+
# control the drawn size; anything else degrades the
|
|
47
|
+
# construct to its monospace source.
|
|
48
|
+
def render(source, **opts)
|
|
49
|
+
render_ast(::Carve.parse(source), **opts)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Render an already-parsed Carve AST Hash (see +Carve.parse+) to PDF
|
|
53
|
+
# bytes. Useful when the AST is inspected or transformed before render.
|
|
54
|
+
def render_ast(ast, page_size: :A4, margin: 45, base_font: nil,
|
|
55
|
+
code_font: nil, link_color: nil,
|
|
56
|
+
highlight_color: nil, styles: nil, renderers: nil)
|
|
57
|
+
composer = ::HexaPDF::Composer.new(page_size: page_size, margin: margin)
|
|
58
|
+
Renderer.new(composer, base_font: base_font, code_font: code_font,
|
|
59
|
+
link_color: link_color, highlight_color: highlight_color,
|
|
60
|
+
styles: styles, renderers: renderers).render_document(ast)
|
|
61
|
+
composer.write_to_string
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Render Carve +source+ and write the PDF to +path+. Returns +path+.
|
|
65
|
+
def render_file(source, path, **opts)
|
|
66
|
+
File.binwrite(path, render(source, **opts))
|
|
67
|
+
path
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: carve-hexapdf
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- markup-carve
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-08-19 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: carve-lang
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 0.1.1
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 0.1.1
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: hexapdf
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '1.0'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '1.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: minitest
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '5.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '5.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: rake
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '13.0'
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '13.0'
|
|
69
|
+
description: |-
|
|
70
|
+
Parse Carve markup (via the carve-lang gem) and render it to a laid-out PDF
|
|
71
|
+
using HexaPDF's document composition engine. Carve block nodes map to
|
|
72
|
+
HexaPDF text/list/table/container/image boxes; inline nodes map to styled
|
|
73
|
+
text runs (bold/italic font variants, monospace code, colored links).
|
|
74
|
+
email:
|
|
75
|
+
executables: []
|
|
76
|
+
extensions: []
|
|
77
|
+
extra_rdoc_files: []
|
|
78
|
+
files:
|
|
79
|
+
- CHANGELOG.md
|
|
80
|
+
- LICENSE
|
|
81
|
+
- README.md
|
|
82
|
+
- lib/carve/hexapdf.rb
|
|
83
|
+
- lib/carve/hexapdf/renderer.rb
|
|
84
|
+
- lib/carve/hexapdf/style_map.rb
|
|
85
|
+
- lib/carve/hexapdf/version.rb
|
|
86
|
+
homepage: https://github.com/markup-carve/carve-hexapdf
|
|
87
|
+
licenses:
|
|
88
|
+
- MIT
|
|
89
|
+
metadata:
|
|
90
|
+
homepage_uri: https://github.com/markup-carve/carve-hexapdf
|
|
91
|
+
source_code_uri: https://github.com/markup-carve/carve-hexapdf
|
|
92
|
+
bug_tracker_uri: https://github.com/markup-carve/carve-hexapdf/issues
|
|
93
|
+
changelog_uri: https://github.com/markup-carve/carve-hexapdf/blob/main/CHANGELOG.md
|
|
94
|
+
rubygems_mfa_required: 'true'
|
|
95
|
+
post_install_message:
|
|
96
|
+
rdoc_options: []
|
|
97
|
+
require_paths:
|
|
98
|
+
- lib
|
|
99
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - ">="
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: 3.0.0
|
|
104
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
105
|
+
requirements:
|
|
106
|
+
- - ">="
|
|
107
|
+
- !ruby/object:Gem::Version
|
|
108
|
+
version: '0'
|
|
109
|
+
requirements: []
|
|
110
|
+
rubygems_version: 3.4.20
|
|
111
|
+
signing_key:
|
|
112
|
+
specification_version: 4
|
|
113
|
+
summary: Render the Carve markup language to PDF via the pure-Ruby HexaPDF engine.
|
|
114
|
+
test_files: []
|