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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d9b8c7c6b8002f3587048d1711e0901a180e4f41a85778a2e4654f7daaeed9da
4
- data.tar.gz: 6da20e9768485a0afe3aa2b71a73bb670a155a7adfb9e30d593bedead5db5632
3
+ metadata.gz: 20e7254d5697ad9d0e7b04769f516ce56b892d09c5407fe6b6cbd3e1ed8eed7e
4
+ data.tar.gz: 24af38395418395895e2eb639d8f6dd1e8ce3827135e532338fd670df2b8befb
5
5
  SHA512:
6
- metadata.gz: 41deae43d68f4996a26a2e2ccae432550bb730877b82a164b5203fe3020e14e515e85d0261ff25a603c92bc7cc1957c2494c8ac7cf9b2c4174eed686f8b639c3
7
- data.tar.gz: 3256a05ce59a8b4071df645e1df3690d3d9ee56c00d4f588f543330cf756c50e689c97aa39026db77e0caa1ac8f095b993fc57990dea20fecbb04d98fa838438
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
- ```sh
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
- when "cross_ref" then out << run(node[:target].to_s, ctx)
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 "critic_insert" then emit_children(node, ctx.merge(underline: true), out)
570
- when "critic_delete" then emit_children(node, ctx.merge(strike: true), out)
571
- when "critic_substitute" then out << run(node[:new_text].to_s, ctx.merge(underline: true))
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 = node[: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 critic_insert critic_delete
881
- critic_substitute critic_comment
906
+ abbreviation footnote soft_break hard_break insert delete
907
+ substitution critic_comment
882
908
  ].freeze
883
909
  end
884
910
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Carve
4
4
  module Hexapdf
5
- VERSION = "0.1.1"
5
+ VERSION = "0.1.3"
6
6
  end
7
7
  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, **opts)
49
- render_ast(::Carve.parse(source), **opts)
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.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-09-09 00:00:00.000000000 Z
11
+ date: 2026-10-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: carve-lang