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.
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Carve
4
+ module Hexapdf
5
+ VERSION = "0.1.0"
6
+ end
7
+ 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: []