carve-hexapdf 0.1.1 → 0.1.3
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 +4 -4
- data/CHANGELOG.md +73 -1
- data/README.md +45 -37
- data/lib/carve/hexapdf/renderer.rb +33 -7
- data/lib/carve/hexapdf/version.rb +1 -1
- data/lib/carve/hexapdf.rb +79 -2
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 20e7254d5697ad9d0e7b04769f516ce56b892d09c5407fe6b6cbd3e1ed8eed7e
|
|
4
|
+
data.tar.gz: 24af38395418395895e2eb639d8f6dd1e8ce3827135e532338fd670df2b8befb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 813e1c949beb5f2047b406fa06bc1580da7e5e163d6f67b95e15ca17d06fbc037585b8435f49e60c14cfba117f74d17d5184deeb5be7f6b023071759fd2b48eb
|
|
7
|
+
data.tar.gz: 7616495f87f40f73f2edbb423fea6c7d43b9443f930cb8372542225c682383913455879045f5ed7e5fc25bf0317e4a7976f77b8c39afe9ba4e9422c937f7703d
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,79 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## Unreleased
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.3] - 2026-10-08
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **A referenced footnote's body no longer vanishes from the page.**
|
|
15
|
+
`register_footnote` matched a reference to its definition through
|
|
16
|
+
`footnote_ref[:id]`; the engine renamed that field to `label`, so no
|
|
17
|
+
definition resolved and the note text never reached the endnote section. The
|
|
18
|
+
marker still numbered itself, so a page kept `[2]` and silently lost what it
|
|
19
|
+
pointed at. Both spellings are accepted now.
|
|
20
|
+
markup-carve/carve-hexapdf#47
|
|
21
|
+
- **Cross-references reach the page.** The inline dispatcher had an arm for
|
|
22
|
+
`cross_ref` while the engine publishes `heading_ref`, so every `</#id>`
|
|
23
|
+
contributed nothing at all: `See </#Plan>.` drew `See .`. A resolved
|
|
24
|
+
reference is drawn as a link over the target heading's text, an unresolved
|
|
25
|
+
one back in its `</#id>` source spelling, which is how the HTML target
|
|
26
|
+
renders it. markup-carve/carve-hexapdf#48
|
|
27
|
+
- **The example PDFs match the engine the gemspec supports.** They had been
|
|
28
|
+
generated by carve-lang 0.1.0, the version the floor excludes, and
|
|
29
|
+
`examples/generate.rb` would silently use a `carve-rb` checkout that merely
|
|
30
|
+
happened to sit beside this one. The sibling is opt-in through
|
|
31
|
+
`CARVE_RB_PATH` now, the same variable the `Gemfile` reads.
|
|
32
|
+
markup-carve/carve-hexapdf#49
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- Requires carve-rb v0.1.7 (carve-lang 0.1.7), up from an unreleased revision
|
|
37
|
+
32 commits and two releases behind it. A document containing a single `|`
|
|
38
|
+
followed by an attribute block used to abort the native extension with a
|
|
39
|
+
fatal `byte range starts at 1 but ends at 0`, killing the process rather than
|
|
40
|
+
raising, so a batch render died on five bytes of valid input; it now renders
|
|
41
|
+
as paragraph text. Cross-references, glossary terms and numbered captions
|
|
42
|
+
also compare case exactly on this engine.
|
|
43
|
+
|
|
44
|
+
## [0.1.2] - 2026-09-19
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **A substitution no longer vanishes from the page.** The inline renderer
|
|
49
|
+
dispatched on `critic_substitute` and read `new_text`; the engine publishes
|
|
50
|
+
`substitution`, whose halves are the inline arrays `old` and `new`
|
|
51
|
+
(markup-carve/carve-js#1827). The arm never fired, and the node has no
|
|
52
|
+
`children`, so the fallback rendered nothing at all. Both halves now reach the
|
|
53
|
+
page, struck for the replaced run and underlined for the replacement, matching
|
|
54
|
+
the reference ANSI and HTML renderers.
|
|
55
|
+
markup-carve/carve-hexapdf#39
|
|
56
|
+
- **Inserted and deleted runs are decorated again.** The same mismatch on two
|
|
57
|
+
more arms: the dispatch read `critic_insert` and `critic_delete` where the
|
|
58
|
+
engine publishes `insert` and `delete`. Their text still reached the page
|
|
59
|
+
through the fallback, so what was lost was the underline and the
|
|
60
|
+
strikethrough, leaving an editorial mark indistinguishable from body text.
|
|
61
|
+
markup-carve/carve-hexapdf#41
|
|
62
|
+
|
|
63
|
+
### Added
|
|
64
|
+
|
|
65
|
+
- `Carve::Hexapdf.render_from_file(path)` renders the Carve document at `path`
|
|
66
|
+
with its `{{ path }}` includes expanded, contained to the file's own directory
|
|
67
|
+
by default and to `include_root:` when one is named. String input stays
|
|
68
|
+
literal. `on_includes:` hands the caller the warnings and the dependency
|
|
69
|
+
identities; without it the sanitized warnings go to stderr. `extensions:` and
|
|
70
|
+
`profile:` are accepted on that path and reach an included child.
|
|
71
|
+
markup-carve/carve-hexapdf#36
|
|
72
|
+
|
|
73
|
+
### Changed
|
|
74
|
+
|
|
75
|
+
- The development engine pin moves to carve-rb `main`, past
|
|
76
|
+
markup-carve/carve-rb#130, which exposes `Carve.parse_with_includes`, carries
|
|
77
|
+
the render options into a child, and publishes the substitution halves as
|
|
78
|
+
inline arrays. The supported consumer range is unchanged: no released
|
|
79
|
+
`carve-lang` carries the new halves yet, so a consumer resolving through that
|
|
80
|
+
range still sees a substitution render as nothing until carve-rb publishes.
|
|
9
81
|
|
|
10
82
|
## [0.1.1] - 2026-08-27
|
|
11
83
|
|
data/README.md
CHANGED
|
@@ -54,6 +54,50 @@ ast = Carve.parse("# From AST")
|
|
|
54
54
|
pdf_bytes = Carve::Hexapdf.render_ast(ast)
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
### Includes
|
|
58
|
+
|
|
59
|
+
A Carve document can pull another file in with `{{ path }}`. A String has no
|
|
60
|
+
identity of its own, so a directive in one stays literal; name the file instead
|
|
61
|
+
and it expands:
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
pdf_bytes = Carve::Hexapdf.render_from_file("report/index.crv")
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`render_from_file` READS Carve from a path and hands back PDF bytes.
|
|
68
|
+
`render_file` is its opposite pair: it takes Carve source and WRITES the PDF out,
|
|
69
|
+
and a directive in that source stays literal.
|
|
70
|
+
|
|
71
|
+
Containment defaults to the input file's own directory, so a sibling or a file
|
|
72
|
+
below it resolves and nothing above it does. `include_root:` moves that root:
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
Carve::Hexapdf.render_from_file("report/index.crv", include_root: "/srv/docs")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The root must be **absolute**. A relative one is refused rather than resolved,
|
|
79
|
+
because resolving it lands on whatever directory the process happens to run in,
|
|
80
|
+
which is not a root anyone chose. The named document has to sit inside the root.
|
|
81
|
+
|
|
82
|
+
A target that cannot be read leaves the directive drawn as written, and the
|
|
83
|
+
reason goes to stderr. The message does not say whether the file was missing or
|
|
84
|
+
refused by containment: both report `include-unresolved`, so a document cannot
|
|
85
|
+
be used to probe the filesystem. Pass `on_includes:` to take reporting over and
|
|
86
|
+
get the dependency identities with it:
|
|
87
|
+
|
|
88
|
+
```ruby
|
|
89
|
+
Carve::Hexapdf.render_from_file(
|
|
90
|
+
"report/index.crv",
|
|
91
|
+
on_includes: lambda { |warnings:, dependencies:, suppressed_warnings:|
|
|
92
|
+
dependencies.each { |d| puts "#{d[:path]} #{d[:resolved] ? 'read' : d[:denial]}" }
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`extensions:` and `profile:` reach the engine on the include path, and a child
|
|
98
|
+
file is parsed with the same ones as its parent. Without a root they raise,
|
|
99
|
+
rather than being dropped: `Carve.parse` accepts neither.
|
|
100
|
+
|
|
57
101
|
### Options
|
|
58
102
|
|
|
59
103
|
| Option | Default | Meaning |
|
|
@@ -217,43 +261,7 @@ always produces a PDF:
|
|
|
217
261
|
|
|
218
262
|
## Development
|
|
219
263
|
|
|
220
|
-
|
|
221
|
-
bundle install
|
|
222
|
-
bundle exec rake test
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
### Which engine you are testing against
|
|
226
|
-
|
|
227
|
-
`bundle install` here does **not** resolve `carve-lang` from RubyGems. `Gemfile`
|
|
228
|
-
pins the engine to a carve-rb revision, so a development run measures one exact
|
|
229
|
-
engine build rather than whatever the registry serves that day:
|
|
230
|
-
|
|
231
|
-
```sh
|
|
232
|
-
bundle exec ruby script/verify_engine_pin.rb
|
|
233
|
-
# carve-lang 0.1.2 from carve-rb b7f3a91a... (Gemfile pins b7f3a91a4192)
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
CI runs that before the suite and refuses when the Gemfile, the resolved bundle
|
|
237
|
-
and the loaded library disagree. To develop against a local carve-rb checkout,
|
|
238
|
-
point `CARVE_RB_PATH` at it - it has to be set on purpose, so the surprising
|
|
239
|
-
resolution is the one you asked for:
|
|
240
|
-
|
|
241
|
-
```sh
|
|
242
|
-
CARVE_RB_PATH=../carve-rb bundle install
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
An installed copy of this gem resolves differently, through the range
|
|
246
|
-
`carve-hexapdf.gemspec` declares - today `>= 0.1.1, < 0.2.0`. The two are
|
|
247
|
-
deliberately not the same engine, and the difference is not academic: this gem
|
|
248
|
-
reads an AST whose node vocabulary was renamed once already inside 0.1.
|
|
249
|
-
`test/engine_floor_test.rb` asks whether the engine under `bundle exec rake
|
|
250
|
-
test` still publishes the vocabulary the renderer maps, and
|
|
251
|
-
`script/consumer_engine_probe.rb` asks the same of an actual `gem install` -
|
|
252
|
-
daily in `Engine drift`, and again before every release.
|
|
253
|
-
|
|
254
|
-
There is no committed `Gemfile.lock`, and that is deliberate; the reasoning,
|
|
255
|
-
including why a lockfile would be a weaker pin here and what it would cost the
|
|
256
|
-
Ruby 3.1 job, is at the top of `Gemfile`.
|
|
264
|
+
Contributor setup, testing, and maintenance notes are in the [development guide](docs/development.md).
|
|
257
265
|
|
|
258
266
|
## Licensing
|
|
259
267
|
|
|
@@ -564,11 +564,32 @@ module Carve
|
|
|
564
564
|
out << run("[#{number}]", ctx.merge(super: true)) if number
|
|
565
565
|
when "citation_group" then out << run(node[:raw].to_s, ctx)
|
|
566
566
|
when "abbreviation" then out << run(node[:abbr].to_s, ctx)
|
|
567
|
-
|
|
567
|
+
# `heading_ref` is what `</#id>` publishes; `cross_ref` was its name
|
|
568
|
+
# before the rename and is still accepted, like the footnote arm above.
|
|
569
|
+
# Reading only `cross_ref` dropped every cross-reference from the page
|
|
570
|
+
# with no marker left behind: `See </#Plan>.` drew "See .".
|
|
571
|
+
#
|
|
572
|
+
# A resolved reference carries `href` and a `target` that is the target
|
|
573
|
+
# heading's TEXT, so it draws as a link over that text. An unresolved
|
|
574
|
+
# one carries no `href` and a `target` that is the selector as authored,
|
|
575
|
+
# which the HTML target renders as literal source - so it is drawn back
|
|
576
|
+
# in its source spelling rather than as a bare word that reads like
|
|
577
|
+
# prose.
|
|
578
|
+
when "heading_ref", "cross_ref"
|
|
579
|
+
if node[:href].to_s.empty?
|
|
580
|
+
out << run("</##{node[:target]}>", ctx)
|
|
581
|
+
else
|
|
582
|
+
out << run(node[:target].to_s, ctx.merge(link: node[:href].to_s))
|
|
583
|
+
end
|
|
568
584
|
when "caption_number" then (out << run(node[:number].to_s, ctx) if node[:number])
|
|
569
|
-
when "
|
|
570
|
-
when "
|
|
571
|
-
|
|
585
|
+
when "insert" then emit_children(node, ctx.merge(underline: true), out)
|
|
586
|
+
when "delete" then emit_children(node, ctx.merge(strike: true), out)
|
|
587
|
+
# Both halves are inline arrays, and both reach the page: struck for the
|
|
588
|
+
# replaced run, underlined for the replacement, which is what the
|
|
589
|
+
# reference ANSI and HTML renderers show.
|
|
590
|
+
when "substitution"
|
|
591
|
+
emit_children({ children: node[:old] }, ctx.merge(strike: true), out)
|
|
592
|
+
emit_children({ children: node[:new] }, ctx.merge(underline: true), out)
|
|
572
593
|
when "smart_punctuation" then out << run(smart_punctuation_text(node), ctx)
|
|
573
594
|
# A character the author escaped. The backslash is authoring syntax, so
|
|
574
595
|
# the page shows the character - but the node has no children, so
|
|
@@ -762,7 +783,12 @@ module Carve
|
|
|
762
783
|
return @footnotes.last[:number]
|
|
763
784
|
end
|
|
764
785
|
|
|
765
|
-
id
|
|
786
|
+
# `label` since carve-rb 0.1.4; `id` is the pre-rename spelling and is
|
|
787
|
+
# still accepted, the same way the node-type arms above accept both
|
|
788
|
+
# vocabularies. Reading only `id` silently dropped the body: the marker
|
|
789
|
+
# still numbered itself from `node[:number]`, so the page kept `[2]`
|
|
790
|
+
# and lost "2. Referenced note body." entirely.
|
|
791
|
+
id = node[:label] || node[:id]
|
|
766
792
|
if id && @footnote_defs.key?(id)
|
|
767
793
|
return @footnote_numbers[id] ||= begin
|
|
768
794
|
@footnotes << { number: @footnotes.size + 1, blocks: @footnote_defs[id] }
|
|
@@ -877,8 +903,8 @@ module Carve
|
|
|
877
903
|
INLINE_TYPES = %w[
|
|
878
904
|
text emphasis code link image span math raw_inline emoji autolink
|
|
879
905
|
cross_ref caption_number mention tag citation_group inline_extension
|
|
880
|
-
abbreviation footnote soft_break hard_break
|
|
881
|
-
|
|
906
|
+
abbreviation footnote soft_break hard_break insert delete
|
|
907
|
+
substitution critic_comment
|
|
882
908
|
].freeze
|
|
883
909
|
end
|
|
884
910
|
end
|
data/lib/carve/hexapdf.rb
CHANGED
|
@@ -45,8 +45,31 @@ module Carve
|
|
|
45
45
|
# +:bytes+ and optional +:width+/+:height+ (points) to
|
|
46
46
|
# control the drawn size; anything else degrades the
|
|
47
47
|
# construct to its monospace source.
|
|
48
|
-
def render(source,
|
|
49
|
-
|
|
48
|
+
def render(source, include_root: nil, source_path: nil, extensions: nil,
|
|
49
|
+
profile: nil, on_includes: nil, **opts)
|
|
50
|
+
ast = parse_source(source, include_root: include_root, source_path: source_path,
|
|
51
|
+
extensions: extensions, profile: profile,
|
|
52
|
+
on_includes: on_includes)
|
|
53
|
+
render_ast(ast, **opts)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Render the Carve document at +path+, with its <tt>{{ path }}</tt>
|
|
57
|
+
# includes expanded, and return the PDF bytes.
|
|
58
|
+
#
|
|
59
|
+
# Carve::Hexapdf.render_from_file("report/index.crv")
|
|
60
|
+
#
|
|
61
|
+
# NOT to be confused with {render_file}, which takes Carve SOURCE and
|
|
62
|
+
# writes the PDF out. This one READS the Carve and hands the bytes back.
|
|
63
|
+
#
|
|
64
|
+
# Containment defaults to the input file's own directory, so a sibling or
|
|
65
|
+
# a file below it resolves and nothing above it does. +include_root:+
|
|
66
|
+
# widens or moves that root, and reaches the engine as the caller wrote
|
|
67
|
+
# it: the engine refuses a relative root, which is what keeps containment
|
|
68
|
+
# off whatever directory the process happens to run in.
|
|
69
|
+
def render_from_file(path, include_root: nil, **opts)
|
|
70
|
+
absolute = File.expand_path(path)
|
|
71
|
+
root = include_root || File.dirname(absolute)
|
|
72
|
+
render(File.read(absolute), include_root: root, source_path: absolute, **opts)
|
|
50
73
|
end
|
|
51
74
|
|
|
52
75
|
# Render an already-parsed Carve AST Hash (see +Carve.parse+) to PDF
|
|
@@ -62,10 +85,64 @@ module Carve
|
|
|
62
85
|
end
|
|
63
86
|
|
|
64
87
|
# Render Carve +source+ and write the PDF to +path+. Returns +path+.
|
|
88
|
+
#
|
|
89
|
+
# +source+ is a String with no identity of its own, so a directive stays
|
|
90
|
+
# literal unless the caller names both +include_root:+ and +source_path:+.
|
|
65
91
|
def render_file(source, path, **opts)
|
|
66
92
|
File.binwrite(path, render(source, **opts))
|
|
67
93
|
path
|
|
68
94
|
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# The AST to draw: expanded when the caller named a root and a document.
|
|
99
|
+
#
|
|
100
|
+
# A String reaching this with neither is parsed as it stands, so a
|
|
101
|
+
# directive in it is text. That is the only thing a caller with no file
|
|
102
|
+
# can be given: a relative include has nothing to resolve against.
|
|
103
|
+
def parse_source(source, include_root:, source_path:, extensions:, profile:,
|
|
104
|
+
on_includes:)
|
|
105
|
+
if include_root.nil? || source_path.nil?
|
|
106
|
+
unless extensions.nil? && profile.nil?
|
|
107
|
+
raise ArgumentError,
|
|
108
|
+
"extensions: and profile: reach the engine on the include path only; " \
|
|
109
|
+
"pass include_root: and source_path:, or drop them"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
return ::Carve.parse(source)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
result = ::Carve.parse_with_includes(source, root: include_root,
|
|
116
|
+
source_path: source_path,
|
|
117
|
+
extensions: extensions,
|
|
118
|
+
profile: profile)
|
|
119
|
+
report_includes(result, on_includes)
|
|
120
|
+
result[:value]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Hand the caller everything expansion found, or say what degraded.
|
|
124
|
+
#
|
|
125
|
+
# With +on_includes:+ the caller owns reporting and gets the dependency
|
|
126
|
+
# identities with it. Without it the sanitized warnings go to stderr,
|
|
127
|
+
# because a document that silently lost half its content is the failure
|
|
128
|
+
# this exists to prevent.
|
|
129
|
+
def report_includes(result, on_includes)
|
|
130
|
+
if on_includes
|
|
131
|
+
on_includes.call(warnings: Array(result[:warnings]),
|
|
132
|
+
dependencies: Array(result[:dependencies]),
|
|
133
|
+
suppressed_warnings: result[:suppressedWarnings].to_i)
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
Array(result[:warnings]).each do |warning|
|
|
138
|
+
where = warning[:file] ? "#{warning[:file]}: " : ""
|
|
139
|
+
$stderr.puts "carve-hexapdf: #{where}#{warning[:rule]}: #{warning[:message]}"
|
|
140
|
+
end
|
|
141
|
+
suppressed = result[:suppressedWarnings].to_i
|
|
142
|
+
return unless suppressed.positive?
|
|
143
|
+
|
|
144
|
+
$stderr.puts "carve-hexapdf: #{suppressed} further include warnings suppressed"
|
|
145
|
+
end
|
|
69
146
|
end
|
|
70
147
|
end
|
|
71
148
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: carve-hexapdf
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.3
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- markup-carve
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-10-08 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: carve-lang
|