carve-hexapdf 0.1.4 → 0.1.6
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 +28 -0
- data/README.md +9 -256
- data/lib/carve/hexapdf/renderer.rb +69 -14
- data/lib/carve/hexapdf/version.rb +1 -1
- 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: e542a29c024a850009d9f9fa32d6fe69ba870a97eab23f595522878d7da1c367
|
|
4
|
+
data.tar.gz: 05d84eaa30d5ad7c4d6f111dc442f88a113bf8a7aa817849df4ee7b58bb5942f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1382a76d094f651b2cc1b6cbae0ce65409d27222e02f4355361fce93ef6c9cc873b1ebd58ea01b6f02003ebb112ee8228784bb4dddf0aea60ac7186fc4e48f74
|
|
7
|
+
data.tar.gz: 25cd7967c9fe80a6c18df8a2d261c6d27d041a874029c056b52f43f621af5e97f939e3a19f9c141eaa51a87292829445ac58b8074a09a477c8afd61cff31b149
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.6] - 2026-11-12
|
|
11
|
+
|
|
12
|
+
Definition lists and table cell alignment now reach the PDF, the gem loads on Ruby 3.4, and the engine moves to carve-lang 0.1.14.
|
|
13
|
+
|
|
14
|
+
### Fixes
|
|
15
|
+
|
|
16
|
+
- Definition list terms and descriptions are drawn; they were dropped from the page on every engine (#66).
|
|
17
|
+
- `require "carve/hexapdf"` works on Ruby 3.4, where `base64` is no longer a default gem (#64).
|
|
18
|
+
|
|
19
|
+
### Improvements
|
|
20
|
+
|
|
21
|
+
- Table cells keep their horizontal alignment, from the cell or its column's header (#67).
|
|
22
|
+
- Developed and tested against carve-lang 0.1.14. Its braced alignment spelling (`|={>}`) right-aligns the column; a first-line `---` now opens frontmatter that runs to the end of an unclosed document, so nothing below it is drawn (#68).
|
|
23
|
+
|
|
24
|
+
**Full Changelog**: https://github.com/markup-carve/carve-hexapdf/compare/v0.1.5...v0.1.6
|
|
25
|
+
|
|
26
|
+
## [0.1.5] - 2026-10-24
|
|
27
|
+
|
|
28
|
+
Ruby interchange text now appears in rendered PDFs, and tables with span markers render without a PDF layout error.
|
|
29
|
+
|
|
30
|
+
### Fixes
|
|
31
|
+
|
|
32
|
+
- Ruby base text and annotations render together instead of losing the annotation (#58).
|
|
33
|
+
- Tables with ownerless span markers or a header rowspan crossing into body rows render without a PDF layout error (#60).
|
|
34
|
+
|
|
35
|
+
**Full Changelog**: https://github.com/markup-carve/carve-hexapdf/compare/v0.1.4...v0.1.5
|
|
36
|
+
|
|
37
|
+
|
|
10
38
|
## [0.1.4] - 2026-10-23
|
|
11
39
|
|
|
12
40
|
### Fixed
|
data/README.md
CHANGED
|
@@ -1,274 +1,27 @@
|
|
|
1
1
|
# carve-hexapdf
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
**PDF** from Ruby, using the pure-Ruby [HexaPDF](https://hexapdf.gettalong.org)
|
|
5
|
-
document composition engine.
|
|
6
|
-
|
|
7
|
-
Carve source is parsed with [`Carve.parse`](https://github.com/markup-carve/carve-rb)
|
|
8
|
-
(from the `carve-lang` gem) and the resulting AST is walked onto a
|
|
9
|
-
`HexaPDF::Composer`:
|
|
10
|
-
|
|
11
|
-
- **Inline** nodes become HexaPDF styled text runs: `*strong*` and `/emphasis/`
|
|
12
|
-
map to bold / italic font variants, `` `code` `` to a monospace font, links to
|
|
13
|
-
a colored run with a clickable URI overlay.
|
|
14
|
-
- **Block** nodes map to HexaPDF boxes: headings and paragraphs to text boxes,
|
|
15
|
-
lists to list boxes (ordered / unordered / task), tables to table boxes,
|
|
16
|
-
block quotes / divs / admonitions to styled containers, and images to image
|
|
17
|
-
boxes.
|
|
3
|
+
Ruby PDF renderer for Carve documents. It supports `carve-lang` 0.1.1 or later and uses the HexaPDF composition engine. See the [Carve documentation](https://markup-carve.github.io/carve/).
|
|
18
4
|
|
|
19
5
|
## Install
|
|
20
6
|
|
|
21
|
-
```ruby
|
|
22
|
-
# Gemfile
|
|
23
|
-
gem "carve-hexapdf"
|
|
24
|
-
```
|
|
25
|
-
|
|
26
7
|
```sh
|
|
27
|
-
|
|
8
|
+
gem install carve-hexapdf
|
|
28
9
|
```
|
|
29
10
|
|
|
30
|
-
`carve-hexapdf` depends on `carve-lang` (a native gem that builds the Carve
|
|
31
|
-
engine via Rust) and on `hexapdf`.
|
|
32
|
-
|
|
33
11
|
## Usage
|
|
34
12
|
|
|
35
13
|
```ruby
|
|
36
14
|
require "carve/hexapdf"
|
|
37
|
-
|
|
38
|
-
# Carve syntax note: *...* is STRONG (bold), /.../ is EMPHASIS (italic).
|
|
39
|
-
pdf_bytes = Carve::Hexapdf.render(<<~CRV)
|
|
40
|
-
# Report
|
|
41
|
-
|
|
42
|
-
A paragraph with *bold*, /italic/, `code`, and a [link](https://example.com).
|
|
43
|
-
|
|
44
|
-
|= Name |= Score |
|
|
45
|
-
| Ann | 42 |
|
|
46
|
-
| Bob | 7 |
|
|
47
|
-
CRV
|
|
48
|
-
|
|
49
|
-
# Write straight to a file:
|
|
50
15
|
Carve::Hexapdf.render_file("# Hello", "hello.pdf")
|
|
51
|
-
|
|
52
|
-
# Render an already-parsed / transformed AST:
|
|
53
|
-
ast = Carve.parse("# From AST")
|
|
54
|
-
pdf_bytes = Carve::Hexapdf.render_ast(ast)
|
|
55
|
-
```
|
|
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
16
|
```
|
|
77
17
|
|
|
78
|
-
|
|
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
|
-
|
|
101
|
-
### Options
|
|
102
|
-
|
|
103
|
-
| Option | Default | Meaning |
|
|
104
|
-
| ------ | ------- | ------- |
|
|
105
|
-
| `page_size` | `:A4` | HexaPDF page size (e.g. `:A4`, `:Letter`) |
|
|
106
|
-
| `margin` | `45` | Page margin in points |
|
|
107
|
-
| `base_font` | `"Times"` | Proportional font family |
|
|
108
|
-
| `code_font` | `"Courier"` | Monospace font family |
|
|
109
|
-
| `link_color` | `"hp-blue"` | Fill color for links |
|
|
110
|
-
| `highlight_color` | `"fff3a3"` | Background color for `=highlight=` |
|
|
111
|
-
| `styles` | `nil` | Hierarchical style overrides (see below) |
|
|
112
|
-
| `renderers` | `nil` | Callables that turn math / diagram source into image bytes (see below) |
|
|
113
|
-
|
|
114
|
-
## Styling
|
|
115
|
-
|
|
116
|
-
Pass `styles:` to restyle renderer output without patching the renderer. Keys are
|
|
117
|
-
hierarchical dotted names; more specific entries win before parent entries, and
|
|
118
|
-
user values win over defaults at the same key.
|
|
119
|
-
|
|
120
|
-
```ruby
|
|
121
|
-
Carve::Hexapdf.render(source, styles: {
|
|
122
|
-
"heading" => { fill_color: "333333" },
|
|
123
|
-
"code.block" => { box: { background_color: "fff8dd", padding: 8 } },
|
|
124
|
-
"admonition.warning" => { box: { background_color: "fff0f0" } },
|
|
125
|
-
})
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
Resolution examples:
|
|
129
|
-
|
|
130
|
-
- `heading.1` resolves through `heading` and then `base`.
|
|
131
|
-
- `code.inline` resolves through `code` and then `base`.
|
|
132
|
-
- `admonition.warning` resolves through `admonition` and then `base`.
|
|
133
|
-
- `box:` hashes deep-merge; other values, including margin arrays, replace as a
|
|
134
|
-
whole.
|
|
135
|
-
- `box:` only takes effect on keys that draw a surrounding box (`code.block`,
|
|
136
|
-
`quote`, `admonition`, `definition_list`, `math`); on text-only keys it is
|
|
137
|
-
ignored.
|
|
138
|
-
- `list` accepts only its structural properties (`item_spacing`,
|
|
139
|
-
`content_indentation`); item text styling flows through `paragraph`.
|
|
140
|
-
|
|
141
|
-
Specificity comes first: `"heading" => { font_size: 30 }` does not override the
|
|
142
|
-
default `heading.1` size of `22`, but `"heading" => { fill_color: "333333" }`
|
|
143
|
-
does apply to all heading levels. To change all heading sizes, set
|
|
144
|
-
`heading.1` through `heading.6` individually.
|
|
145
|
-
|
|
146
|
-
Existing keyword options are sugar under `styles:` and explicit style entries
|
|
147
|
-
win: `base_font:` maps to `base.font`, `code_font:` to `code.font`,
|
|
148
|
-
`link_color:` to `link.fill_color`, and `highlight_color:` to
|
|
149
|
-
`highlight.background_color`.
|
|
150
|
-
|
|
151
|
-
| Key | Defaults |
|
|
152
|
-
| --- | -------- |
|
|
153
|
-
| `base` | `{ font: "Times" }` |
|
|
154
|
-
| `heading` | `{ margin: [10, 0, 6] }` |
|
|
155
|
-
| `heading.1` ... `heading.6` | `{ font_size: 22 }`, `{ font_size: 18 }`, `{ font_size: 15 }`, `{ font_size: 13 }`, `{ font_size: 12 }`, `{ font_size: 11 }` |
|
|
156
|
-
| `paragraph` | `{ margin: [0, 0, 8] }` |
|
|
157
|
-
| `code` | `{ font: "Courier" }` |
|
|
158
|
-
| `code.block` | `{ font_size: 9, margin: [2, 0, 8], box: { background_color: "f2f2f2", padding: 6 } }` |
|
|
159
|
-
| `code.inline` | `{}` |
|
|
160
|
-
| `quote` | `{ box: { margin: [2, 0, 8], padding: [4, 10], background_color: "f7f7f7" } }` |
|
|
161
|
-
| `admonition` | `{ box: { margin: [2, 0, 8], padding: [6, 10], background_color: "eef3fb" }, title_margin: [0, 0, 4] }` |
|
|
162
|
-
| `admonition.<kind>` | No defaults; any kind the parser accepts works (including hyphenated ones) |
|
|
163
|
-
| `list` | `{ item_spacing: 3, content_indentation: 18 }` |
|
|
164
|
-
| `definition_list` | `{ box: { margin: [0, 0, 8] }, definition_indent: 16 }` |
|
|
165
|
-
| `table` | `{ font_size: 10, cell_padding: 4, margin: [2, 0, 8] }` |
|
|
166
|
-
| `table.header` | `{}` |
|
|
167
|
-
| `table.caption` | `{ font_size: 9, margin: [0, 0, 8] }` |
|
|
168
|
-
| `figure.caption` | `{ font_size: 9, margin: [2, 0, 8], text_align: :center }` (panel captions too) |
|
|
169
|
-
| `figure.group` | `{ box: { margin: [2, 0, 8] }, column_gap: 18, min_column_width: 90 }` |
|
|
170
|
-
| `figure.group.caption` | `{ font_size: 9, margin: [4, 0, 0], text_align: :center }` |
|
|
171
|
-
| `footnote` | `{ font_size: 9, margin: [0, 0, 3] }` (endnote section entries) |
|
|
172
|
-
| `link` | `{ fill_color: "hp-blue" }` |
|
|
173
|
-
| `highlight` | `{ background_color: "fff3a3" }` |
|
|
174
|
-
| `image` | `{ margin: [2, 0, 8] }` |
|
|
175
|
-
| `math` | `{ font_size: 11, margin: [4, 0, 8], box: { padding: 4 } }` |
|
|
176
|
-
| `thematic_break` | `{ height: 2, margin: [8, 0, 8], background_color: "cccccc" }` |
|
|
177
|
-
|
|
178
|
-
## Supported constructs
|
|
179
|
-
|
|
180
|
-
Headings, paragraphs, all inline emphasis (strong / emphasis / bold-italic /
|
|
181
|
-
**underline** / **strikethrough** / **superscript** / **subscript** /
|
|
182
|
-
**highlight**), code, links, autolinks, soft & hard breaks, ordered / unordered
|
|
183
|
-
/ task lists (nested), **tables with header rows and full row / column spans**,
|
|
184
|
-
block quotes (with attribution), fenced code blocks, divs, admonitions,
|
|
185
|
-
definition lists, figures, thematic breaks, critic markup (insert → underline,
|
|
186
|
-
delete → strikethrough), **footnotes** (superscript `[n]` markers with the
|
|
187
|
-
bodies collected into a numbered endnote section - inline `^[..]` and
|
|
188
|
-
referenced `[^id]` alike), and **images** - both block and inline, embedded
|
|
189
|
-
from a local file path or a `data:` URI. Task-list checkboxes are drawn in the
|
|
190
|
-
list marker column, so item text and nested lists align like any other list.
|
|
191
|
-
|
|
192
|
-
### Composite figures
|
|
193
|
-
|
|
194
|
-
A bare `::: figure` container is one figure of ordered panels (Carve PART 9
|
|
195
|
-
section 4c):
|
|
196
|
-
|
|
197
|
-
```
|
|
198
|
-
{.columns-2}
|
|
199
|
-
::: figure
|
|
200
|
-

|
|
201
|
-
^ (a) One
|
|
202
|
-
|
|
203
|
-

|
|
204
|
-
^ (b) Two
|
|
205
|
-
:::
|
|
206
|
-
^ Figure #: Both samples
|
|
207
|
-
```
|
|
208
|
-
|
|
209
|
-
On a page that group is one float. Its panels, any content preserved between
|
|
210
|
-
them and the group caption are laid out as a single box a page break may not
|
|
211
|
-
enter, and each panel keeps its own caption the same way - so the caption that
|
|
212
|
-
numbers the figure never lands on the page after the panels it numbers. A
|
|
213
|
-
group too tall to fit a page splits instead of failing the render, with the
|
|
214
|
-
panels still in source order.
|
|
215
|
-
|
|
216
|
-
`.columns-N` on the attribute line is honored when the page is wide enough to
|
|
217
|
-
give every column `figure.group.min_column_width` points, and is otherwise
|
|
218
|
-
ignored in favor of a stack. Every panel is drawn either way: the hint decides
|
|
219
|
-
arrangement, never content.
|
|
220
|
-
|
|
221
|
-
An opener that carries a title or a label (`::: figure "T"`, `::: figure [g]`)
|
|
222
|
-
is deliberately NOT this construct - it stays a generic container and renders
|
|
223
|
-
as one.
|
|
224
|
-
|
|
225
|
-
> [!NOTE]
|
|
226
|
-
> The parser this gem consumes (`carve-lang`, over carve-rs) does not produce
|
|
227
|
-
> `figure_group` nodes yet. The renderer accepts them today - through
|
|
228
|
-
> `Carve.parse` once the engine ships the construct, and through
|
|
229
|
-
> `Carve::Hexapdf.render_ast` with an AST from any engine that already does.
|
|
230
|
-
|
|
231
|
-
### Math and diagrams (renderer callables)
|
|
232
|
-
|
|
233
|
-
PDF has no client-side renderer, so math and diagram fences are turned into
|
|
234
|
-
embedded raster images through callables you supply in `renderers:`. Each
|
|
235
|
-
returns image bytes (PNG/JPG) as a String - or a Hash `{ bytes:, width:,
|
|
236
|
-
height: }` (points) to control the drawn size, so high-DPI rasters embed
|
|
237
|
-
crisply at their intended dimensions. A missing renderer, or one that returns
|
|
238
|
-
anything else or raises, degrades that construct to its monospace source.
|
|
239
|
-
|
|
240
|
-
```ruby
|
|
241
|
-
Carve::Hexapdf.render(source, renderers: {
|
|
242
|
-
# inline `$`x`$` and display `$$`x`$$` math:
|
|
243
|
-
math: ->(tex, display) { my_tex_to_png(tex, display) }, # -> bytes | {bytes:, width:, height:} | nil
|
|
244
|
-
# fenced ```mermaid / ```dot|graphviz / ```chart|vega:
|
|
245
|
-
mermaid: ->(src) { my_mermaid_to_png(src) },
|
|
246
|
-
graphviz: ->(src) { my_dot_to_png(src) },
|
|
247
|
-
chart: ->(src) { my_chart_to_png(src) },
|
|
248
|
-
})
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
### Graceful degradation
|
|
252
|
-
|
|
253
|
-
The renderer never raises on an unsupported node - it degrades so a document
|
|
254
|
-
always produces a PDF:
|
|
255
|
-
|
|
256
|
-
- **Math / diagram** fences without a matching `renderers:` callable render
|
|
257
|
-
their source in a monospace run.
|
|
258
|
-
- **Remote image URLs** (`http(s)://`) are shown as alt text - no network
|
|
259
|
-
fetching. Local files and `data:` URIs are embedded.
|
|
260
|
-
- **Raw HTML** blocks/inlines and **comments** are dropped.
|
|
261
|
-
|
|
262
|
-
## Development
|
|
18
|
+
## Documentation
|
|
263
19
|
|
|
264
|
-
|
|
20
|
+
- [Carve documentation](https://markup-carve.github.io/carve/)
|
|
21
|
+
- [Guide](docs/guide.md)
|
|
22
|
+
- [Documentation index](docs/README.md)
|
|
23
|
+
- [Contributing and development](docs/development.md)
|
|
265
24
|
|
|
266
|
-
##
|
|
25
|
+
## License
|
|
267
26
|
|
|
268
|
-
|
|
269
|
-
commercial**. If you distribute software or offer it over a network while
|
|
270
|
-
depending on HexaPDF, you must comply with the AGPL (open-source your
|
|
271
|
-
application) or hold a
|
|
272
|
-
[HexaPDF commercial license](https://hexapdf.gettalong.org/#pricing). This gem
|
|
273
|
-
only bridges Carve to HexaPDF; your use of HexaPDF is governed by HexaPDF's own
|
|
274
|
-
terms.
|
|
27
|
+
[MIT](LICENSE). HexaPDF has separate licensing terms; see the [guide](docs/guide.md#licensing).
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "stringio"
|
|
4
|
-
require "base64"
|
|
5
4
|
|
|
6
5
|
require_relative "style_map"
|
|
7
6
|
|
|
@@ -258,13 +257,15 @@ module Carve
|
|
|
258
257
|
def definition_list(node, target)
|
|
259
258
|
style = style_for("definition_list", with_box: true)
|
|
260
259
|
target.container(style: style[:box]) do |cont|
|
|
260
|
+
# `items` is a flat run of definition_term / definition_description
|
|
261
|
+
# nodes, not term/definition pairs.
|
|
261
262
|
Array(node[:items]).each do |item|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
263
|
+
case item[:type]
|
|
264
|
+
when "definition_term"
|
|
265
|
+
cont.formatted_text(inline_runs(item[:children], bold: true), margin: [2, 0, 1])
|
|
266
|
+
when "definition_description"
|
|
266
267
|
cont.container(style: { padding: [0, 0, 0, style[:definition_indent]] }) do |dcont|
|
|
267
|
-
Array(
|
|
268
|
+
Array(item[:children]).each { |ch| block(ch, dcont) }
|
|
268
269
|
end
|
|
269
270
|
end
|
|
270
271
|
end
|
|
@@ -429,9 +430,12 @@ module Carve
|
|
|
429
430
|
resolved = resolve_spans(Array(node[:rows]))
|
|
430
431
|
return if resolved.empty?
|
|
431
432
|
|
|
432
|
-
|
|
433
|
+
# HexaPDF cannot repeat a header whose cell spans into the body.
|
|
434
|
+
header_count = resolved.first.any? { |o| o[:header] } &&
|
|
435
|
+
resolved.first.none? { |o| o[:row_span] > 1 } ? 1 : 0
|
|
433
436
|
table_style = style_for("table")
|
|
434
437
|
header_style = style_for("table.header")
|
|
438
|
+
columns = column_alignments(node)
|
|
435
439
|
|
|
436
440
|
cell_boxes = resolved.map do |row|
|
|
437
441
|
row.map do |o|
|
|
@@ -442,6 +446,9 @@ module Carve
|
|
|
442
446
|
# :margin belongs to the table box, :cell_padding is our pseudo-prop.
|
|
443
447
|
box_opts = cell_style.except(:margin, :cell_padding, :box)
|
|
444
448
|
box_opts[:padding] = cell_style[:cell_padding]
|
|
449
|
+
column = columns[o[:col]] || {}
|
|
450
|
+
align = o[:cell][:align] || column[:align]
|
|
451
|
+
box_opts[:text_align] = CELL_ALIGN[align] if CELL_ALIGN.key?(align)
|
|
445
452
|
box = @layout.formatted_text_box(runs, **box_opts)
|
|
446
453
|
hash = { content: box }
|
|
447
454
|
hash[:col_span] = o[:col_span] if o[:col_span] > 1
|
|
@@ -462,9 +469,37 @@ module Carve
|
|
|
462
469
|
end
|
|
463
470
|
end
|
|
464
471
|
|
|
472
|
+
CELL_ALIGN = { "left" => :left, "center" => :center, "right" => :right }.freeze
|
|
473
|
+
|
|
474
|
+
# A cell without its own alignment inherits its column's: the table's
|
|
475
|
+
# `columns` metadata, overridden by the head rows (the HTML `thead`), a
|
|
476
|
+
# later row winning. Any other cell's alignment is local. The head is
|
|
477
|
+
# `rowGroups.headRows` when published, else the leading header rows.
|
|
478
|
+
# Vertical alignment is not drawn: `text_valign` overflows a table cell.
|
|
479
|
+
def column_alignments(node)
|
|
480
|
+
columns = Array(node[:columns]).map { |c| (c || {}).slice(:align) }
|
|
481
|
+
rows = Array(node[:rows])
|
|
482
|
+
declared = node.dig(:rowGroups, :headRows)
|
|
483
|
+
head = declared.is_a?(Integer) ? rows.first(declared) : rows.take_while { |row| header_row?(row) }
|
|
484
|
+
head.each do |row|
|
|
485
|
+
Array(row[:cells]).each_with_index do |cell, col|
|
|
486
|
+
next if cell[:span]
|
|
487
|
+
|
|
488
|
+
(columns[col] ||= {})[:align] = cell[:align] if cell[:align]
|
|
489
|
+
end
|
|
490
|
+
end
|
|
491
|
+
columns
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
# A `^` / `<` marker is not a header cell, but leaves its row in the head.
|
|
495
|
+
def header_row?(row)
|
|
496
|
+
content = Array(row[:cells]).reject { |cell| cell[:span] }
|
|
497
|
+
!content.empty? && content.all? { |cell| cell[:header] }
|
|
498
|
+
end
|
|
499
|
+
|
|
465
500
|
# Resolve Carve's explicit span markers (a `<` cell = merge left, a `^`
|
|
466
501
|
# cell = merge up) into per-cell col_span / row_span counts, returning
|
|
467
|
-
# rows of originator hashes {cell:, header:, col_span:, row_span:} with
|
|
502
|
+
# rows of originator hashes {cell:, header:, col:, col_span:, row_span:} with
|
|
468
503
|
# marker cells dropped. Every covered grid position is explicit in Carve,
|
|
469
504
|
# so a cell's column index equals its position in the row.
|
|
470
505
|
def resolve_spans(rows)
|
|
@@ -478,8 +513,17 @@ module Carve
|
|
|
478
513
|
case cell[:span]
|
|
479
514
|
when "colspan"
|
|
480
515
|
owner = last || col_owner[col - 1]
|
|
481
|
-
|
|
482
|
-
|
|
516
|
+
if owner
|
|
517
|
+
owner[:col_span] += 1
|
|
518
|
+
col_owner[col] = owner
|
|
519
|
+
else
|
|
520
|
+
# A leading marker has no cell to extend. Keep its grid slot
|
|
521
|
+
# so the table has a drawable cell in this row.
|
|
522
|
+
o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
|
|
523
|
+
emitted << o
|
|
524
|
+
last = o
|
|
525
|
+
col_owner[col] = o
|
|
526
|
+
end
|
|
483
527
|
when "rowspan"
|
|
484
528
|
owner = col_owner[col]
|
|
485
529
|
# A multi-column cell has one `^` per covered column on the next
|
|
@@ -488,10 +532,14 @@ module Carve
|
|
|
488
532
|
owner[:row_span] += 1
|
|
489
533
|
bumped[owner.object_id] = true
|
|
490
534
|
end
|
|
491
|
-
|
|
492
|
-
|
|
535
|
+
if owner.nil?
|
|
536
|
+
o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
|
|
537
|
+
emitted << o
|
|
538
|
+
col_owner[col] = o
|
|
539
|
+
end
|
|
540
|
+
# A valid owner's slot remains available for further `^` rows.
|
|
493
541
|
else
|
|
494
|
-
o = { cell: cell, header: cell[:header], col_span: 1, row_span: 1 }
|
|
542
|
+
o = { cell: cell, header: cell[:header], col: col, col_span: 1, row_span: 1 }
|
|
495
543
|
emitted << o
|
|
496
544
|
last = o
|
|
497
545
|
col_owner[col] = o
|
|
@@ -594,6 +642,13 @@ module Carve
|
|
|
594
642
|
when "substitution"
|
|
595
643
|
emit_half(node[:old], node[:oldText], ctx.merge(strike: true), out)
|
|
596
644
|
emit_half(node[:new], node[:newText], ctx.merge(underline: true), out)
|
|
645
|
+
when "ruby"
|
|
646
|
+
Array(node[:pairs]).each do |pair|
|
|
647
|
+
Array(pair[:base]).each { |part| emit_inline(part, ctx, out) }
|
|
648
|
+
out << run("(", ctx)
|
|
649
|
+
Array(pair[:annotation]).each { |part| emit_inline(part, ctx, out) }
|
|
650
|
+
out << run(")", ctx)
|
|
651
|
+
end
|
|
597
652
|
when "smart_punctuation" then out << run(smart_punctuation_text(node), ctx)
|
|
598
653
|
# A character the author escaped. The backslash is authoring syntax, so
|
|
599
654
|
# the page shows the character - but the node has no children, so
|
|
@@ -883,7 +938,7 @@ module Carve
|
|
|
883
938
|
meta, data = src.split(",", 2)
|
|
884
939
|
return nil unless data
|
|
885
940
|
|
|
886
|
-
bytes = meta.include?(";base64") ?
|
|
941
|
+
bytes = meta.include?(";base64") ? data.unpack1("m") : data
|
|
887
942
|
return StringIO.new(bytes)
|
|
888
943
|
end
|
|
889
944
|
return src if File.file?(src)
|
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.6
|
|
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-11-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: carve-lang
|