qpdf_ruby 0.1.1 → 0.3.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 +4 -4
- data/CHANGELOG.md +70 -5
- data/README.md +95 -54
- data/ext/qpdf_ruby/document_handle.cpp +1 -1
- data/ext/qpdf_ruby/figure_node.cpp +112 -87
- data/ext/qpdf_ruby/mcid_bounds.cpp +317 -0
- data/ext/qpdf_ruby/mcid_bounds.hpp +33 -0
- data/ext/qpdf_ruby/pdf_struct_walker.cpp +11 -18
- data/ext/qpdf_ruby/pdf_struct_walker.hpp +5 -3
- data/ext/qpdf_ruby/pdfua.cpp +1005 -0
- data/ext/qpdf_ruby/pdfua.hpp +118 -0
- data/ext/qpdf_ruby/qpdf_ruby.cpp +424 -212
- data/ext/qpdf_ruby/qpdf_ruby.hpp +1 -4
- data/ext/qpdf_ruby/ruby_guard.hpp +71 -0
- data/ext/qpdf_ruby/stroke_bounds.cpp +155 -0
- data/ext/qpdf_ruby/stroke_bounds.hpp +49 -0
- data/ext/qpdf_ruby/xmp_scan.cpp +329 -0
- data/ext/qpdf_ruby/xmp_scan.hpp +49 -0
- data/lib/qpdf_ruby/version.rb +1 -1
- data/lib/qpdf_ruby.rb +3 -2
- data/sig/qpdf_ruby.rbs +82 -1
- metadata +11 -12
- data/.clang-format +0 -4
- data/.rspec +0 -3
- data/.rubocop.yml +0 -88
- data/.ruby-gemset +0 -1
- data/.ruby-version +0 -1
- data/Rakefile +0 -25
- data/cliff.toml +0 -138
- data/docker/Dockerfile +0 -46
- data/ext/qpdf_ruby/pdf_image_mapper.cpp +0 -294
- data/ext/qpdf_ruby/pdf_image_mapper.hpp +0 -68
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1031f5b376f46ea2e57b4a06692f10fe62f081bf010eff784e7e179c3dca83d1
|
|
4
|
+
data.tar.gz: aa3e9a16d357779d6b51c7d66c8736641a140a180844cd3f6c5bcbb22988ce32
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: baaebb9aa86b819c652f7e2b9f8b593df9419c75d4cf9279e1301b6d0302b43c4aa1c629f2f8e65582ca07dd659889c9397d9286179b627442f29605e6356673
|
|
7
|
+
data.tar.gz: 6f31a3e8543f7bc23561ef74ad445e9a32e136fcbb0d57721d6058cc158441d4069af1d1ef50480494617d2e367a445316beb3100bfb25e1089dfb8d9eff23cc
|
data/CHANGELOG.md
CHANGED
|
@@ -6,11 +6,74 @@ All notable changes to this project will be documented in this file.
|
|
|
6
6
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
7
7
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
8
8
|
|
|
9
|
-
[unreleased]: https://github.com
|
|
9
|
+
[unreleased]: https://github.com/dieter-medium/qpdf_ruby/compare/v0.3.0..HEAD
|
|
10
10
|
|
|
11
|
-
<!-- generated by git-cliff -->
|
|
11
|
+
<!-- generated by git-cliff end -->
|
|
12
12
|
|
|
13
|
-
##
|
|
13
|
+
## [0.3.0] - 2026-10-08
|
|
14
|
+
|
|
15
|
+
Records who made a file: `set_document_info` writes the creator and producer to the information
|
|
16
|
+
dictionary and to XMP, and keeps the two in agreement.
|
|
17
|
+
|
|
18
|
+
### 🚀 Added
|
|
19
|
+
|
|
20
|
+
- `set_document_info(creator:, producer:)`: the information dictionary's `/Creator` and
|
|
21
|
+
`/Producer`, and XMP `xmp:CreatorTool` and `pdf:Producer`, replacing any other value there so the
|
|
22
|
+
two always agree; `xmp_error` says why a packet that is not well-formed was left alone.
|
|
23
|
+
- `document_info`: the information dictionary's string entries, for inspection.
|
|
24
|
+
|
|
25
|
+
### 🐛 Fixed
|
|
26
|
+
|
|
27
|
+
- A `title:` with a NUL, a control character or bytes that are not UTF-8 raises `ArgumentError`
|
|
28
|
+
instead of writing invalid XMP (`add_pdfua_identification`, `apply_pdfua_fixes`).
|
|
29
|
+
- An XMP property counts only on the document's own `rdf:Description`: a `dc:title` or
|
|
30
|
+
`pdfuaid:part` nested inside another property no longer suppresses the identification.
|
|
31
|
+
|
|
32
|
+
## [0.2.0] - 2026-10-02
|
|
33
|
+
|
|
34
|
+
Makes the tagged PDFs Chromium prints (`Page.printToPDF` with `generateTaggedPDF`) PDF/UA-1
|
|
35
|
+
conformant: `apply_pdfua_fixes` plus `ensure_bbox` take Chromium's output to a file veraPDF accepts
|
|
36
|
+
as PDF/UA-1 and PAC 2024 passes without errors.
|
|
37
|
+
|
|
38
|
+
### 🚀 Added
|
|
39
|
+
|
|
40
|
+
- `apply_pdfua_fixes(link_texts:, title:)` runs every step below and returns a report.
|
|
41
|
+
- `artifact_tagged_decorations`: CSS backgrounds and borders Chromium tags as content of a
|
|
42
|
+
grouping element (a `Table`'s cell backgrounds, an `<article>`'s card) become artifacts.
|
|
43
|
+
- `mark_untagged_content_as_artifacts` and `untagged_content`: untagged paths, text, XObjects,
|
|
44
|
+
shadings and inline images are wrapped in `/Artifact` (7.1-3), never inside tagged content.
|
|
45
|
+
- `describe_links`: Link annotations get `/Contents` (7.18), from given texts, the structure
|
|
46
|
+
element's `/Alt`, named and string destinations ("Page N"), or the URI.
|
|
47
|
+
- `wrap_list_bodies`: list item content moves into `LBody` (7.2-20), ParentTree updated.
|
|
48
|
+
- `map_nonstandard_roles`: PDF 2.0 structure types get a RoleMap entry (7.1-5).
|
|
49
|
+
- `retag_grouping_figures`: the `Figure` Chromium wraps around an HTML `<figure>` becomes a `Div`
|
|
50
|
+
(7.3-1); `figures_without_alt` counts what only a person can fix.
|
|
51
|
+
- `add_pdfua_identification(title:)`: XMP `pdfuaid:part` and `dc:title`, `DisplayDocTitle`,
|
|
52
|
+
`Marked` (7.1-8).
|
|
53
|
+
- `ensure_bbox` computes Figure bounds from the painted content (images, forms, strokes with line
|
|
54
|
+
width, caps and joins) and sets `Placement /Block` outside a line of text.
|
|
55
|
+
- `parent_tree_mismatches`, `links`, `metadata` and `role_map` for inspection.
|
|
56
|
+
|
|
57
|
+
### 🐛 Fixed
|
|
58
|
+
|
|
59
|
+
- `ensure_bbox`: an uninitialized crop box crashed it, the CTM was replaced instead of
|
|
60
|
+
concatenated, MCIDs were matched across pages and Form XObjects, and `/A` was overwritten.
|
|
61
|
+
- `to_memory` leaked the QPDF buffer behind every call.
|
|
62
|
+
- Ruby exceptions no longer skip C++ destructors, and a `#to_s` callback that reopens the document
|
|
63
|
+
can no longer leave a freed handle in use.
|
|
64
|
+
- Builds against QPDF 11.9 as well as QPDF 12, and runs on Ruby 4.
|
|
65
|
+
|
|
66
|
+
### 🛠️ Development
|
|
67
|
+
|
|
68
|
+
- veraPDF runs in CI (`bin/install-verapdf.sh`); `bin/asan-rspec.sh` runs the specs under
|
|
69
|
+
AddressSanitizer and LeakSanitizer.
|
|
70
|
+
- `rake release` checks the push credential first, following RubyGems' own order
|
|
71
|
+
(`GEM_HOST_API_KEY`, `gem.push_key`, push-host key, `rubygems_api_key`).
|
|
72
|
+
- The Docker image is built in two stages and pushed for linux/amd64 and linux/arm64 from `main`
|
|
73
|
+
and `v*` tags.
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## [0.1.1] - 2025-06-30
|
|
14
77
|
|
|
15
78
|
### 🚀 Features
|
|
16
79
|
|
|
@@ -24,5 +87,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
24
87
|
|
|
25
88
|
### 🔄 Released
|
|
26
89
|
|
|
27
|
-
- [unreleased](https://github.com/dieter-medium/
|
|
28
|
-
- [0.
|
|
90
|
+
- [unreleased](https://github.com/dieter-medium/qpdf_ruby/compare/v0.3.0..HEAD)
|
|
91
|
+
- [0.3.0](https://github.com/dieter-medium/qpdf_ruby/compare/v0.2.0..v0.3.0)
|
|
92
|
+
- [0.2.0](https://github.com/dieter-medium/qpdf_ruby/compare/v0.1.1..v0.2.0)
|
|
93
|
+
- [0.1.1](https://github.com/dieter-medium/qpdf_ruby/compare/v0.1.0..v0.1.1)
|
data/README.md
CHANGED
|
@@ -1,37 +1,74 @@
|
|
|
1
1
|
# QpdfRuby
|
|
2
2
|
|
|
3
|
-
> **
|
|
4
|
-
|
|
5
|
-
QpdfRuby is a
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
3
|
+
> **Make Chromium's tagged PDFs PDF/UA-1 conformant.**
|
|
4
|
+
|
|
5
|
+
QpdfRuby is a small Ruby wrapper around the [QPDF](https://qpdf.sourceforge.net/) C++ library,
|
|
6
|
+
made for PDFs printed by Chromium (`Page.printToPDF` with `generateTaggedPDF`) and checked with
|
|
7
|
+
veraPDF or PAC 2024. Chromium tags the text of a page but leaves gaps that PDF/UA-1 rejects; the
|
|
8
|
+
gem closes them without touching the content that is tagged:
|
|
9
|
+
|
|
10
|
+
| PDF/UA-1 rule (veraPDF) | What Chromium does | What QpdfRuby does | Ruby API |
|
|
11
|
+
| --- | --- | --- | --- |
|
|
12
|
+
| 7.1-3 untagged content | backgrounds, borders, bars, `aria-hidden` and SVG decorations end up outside any marked content; it never writes `/Artifact` | wraps every painting operation outside marked content in `/Artifact BMC … EMC` (not a Form XObject that carries tagged content) | `doc.mark_untagged_content_as_artifacts` |
|
|
13
|
+
| PAC: content in an inadmissible location, invalid TR | CSS backgrounds and borders are tagged as content of the grouping element they belong to (a `Table`'s cell backgrounds, an `<article>`'s card) | turns such shape-only content into artifacts and takes it out of the structure tree; content with text stays tagged | `doc.artifact_tagged_decorations` |
|
|
14
|
+
| 7.18.1-2, 7.18.5-2 link descriptions | no `/Contents` on Link annotations | your text per URI, else the structure element's `/Alt`/`/ActualText`, else the URI (internal links: your text for `"#<destination>"`, else "Page N" - named destinations from `/Dests`, string destinations from the `/Names` tree) | `doc.describe_links(texts = {})` |
|
|
15
|
+
| 7.2-20 list items | `LI` holds `Lbl` and the content directly, never `LBody` | moves the content into an `LBody`, ParentTree entries updated (a Form XObject's MCIDs in its own entry) | `doc.wrap_list_bodies` |
|
|
16
|
+
| 7.1-5 non-standard types | PDF 2.0 types (`Strong`, `Em`, `Aside`, …) without a RoleMap | maps them to PDF 1.7 types in the RoleMap (table below) | `doc.map_nonstandard_roles` |
|
|
17
|
+
| 7.3-1 figure alternative | an HTML `<figure>` becomes a `Figure` without alt text around the image's own `Figure` | retags that grouping `Figure` as `Div` | `doc.retag_grouping_figures` |
|
|
18
|
+
| 7.1-8 metadata | no XMP `pdfuaid` identification | adds `pdfuaid:part 1` and `dc:title`, each only if missing (read namespace-aware - a commented-out one does not count, an empty one does; extends existing XMP, never replaces a title), sets `DisplayDocTitle` and `Marked` - the identification only for a file with a structure tree | `doc.add_pdfua_identification(title: nil)` |
|
|
19
|
+
|
|
20
|
+
`doc.apply_pdfua_fixes(link_texts: {}, title: nil)` runs all of them and returns a report.
|
|
21
|
+
Each step only adds what is missing, so running it twice changes nothing (the report's
|
|
22
|
+
`identified` is true only when the identification step changed something). When the file cannot
|
|
23
|
+
be identified - it has no structure tree, or its existing XMP has no RDF element the gem can find -
|
|
24
|
+
the other steps still run and `unidentified_reason` says why; it is `nil` otherwise. Figures that
|
|
25
|
+
have no alternative text at all cannot be fixed by a tool - `doc.figures_without_alt` counts them.
|
|
26
|
+
|
|
27
|
+
The RoleMap entries `map_nonstandard_roles` adds, each only for a type the file uses and does not
|
|
28
|
+
map yet. Some are lossy - an assistive technology reads `Title` as a paragraph, `Aside` as a
|
|
29
|
+
section:
|
|
30
|
+
|
|
31
|
+
| PDF 2.0 type | mapped to |
|
|
32
|
+
| --- | --- |
|
|
33
|
+
| `Aside` | `Sect` |
|
|
34
|
+
| `DocumentFragment` | `Part` |
|
|
35
|
+
| `Em`, `Strong`, `Sub` | `Span` |
|
|
36
|
+
| `FENote` | `Note` |
|
|
37
|
+
| `Title` | `P` |
|
|
38
|
+
|
|
39
|
+
Untagged content is looked for in page content and the Form XObjects it draws - not in annotation
|
|
40
|
+
appearance streams, tiling patterns or Type 3 glyph procedures, which Chromium does not emit.
|
|
41
|
+
|
|
42
|
+
Also:
|
|
43
|
+
|
|
44
|
+
| Feature | Ruby API |
|
|
45
|
+
| --- | --- |
|
|
46
|
+
| Count untagged content (dry run, per kind) | `doc.untagged_content` |
|
|
47
|
+
| Add a layout `/BBox` to every `/Figure`¹, and `/Placement /Block` to one outside a line of text (PAC 2024 warns of a "possibly inappropriate use" of a Figure without it) | `doc.ensure_bbox` |
|
|
48
|
+
| Dump the structure tree as XML | `doc.show_structure` |
|
|
49
|
+
| Set who made the file: Info `/Creator` and `/Producer`, XMP `xmp:CreatorTool` and `pdf:Producer`, kept in agreement (another XMP value is replaced; `xmp_error` says why a packet that is not well-formed was left alone) | `doc.set_document_info(creator:, producer:)` |
|
|
50
|
+
| Inspect links, XMP, RoleMap, the information dictionary | `doc.links`, `doc.metadata`, `doc.role_map`, `doc.document_info` |
|
|
51
|
+
| Count ParentTree entries that do not name their content's structure element (0 = consistent) | `doc.parent_tree_mismatches` |
|
|
52
|
+
| Encrypt | `doc.encrypt(user_pw:, owner_pw:, …)` |
|
|
53
|
+
| Read/write files or memory | `Document.new(path, password = nil)`, `Document.from_memory(bytes, password = nil)`, `#write(path)`, `#to_memory` |
|
|
54
|
+
|
|
55
|
+
_¹The gem parses each page's content stream, and the content of the Form XObjects it draws, under
|
|
56
|
+
the full transformation matrix and unites what each piece of marked content paints - images, Form
|
|
57
|
+
XObjects, paths - per page, content stream and MCID (a marked-content reference's `/Stm` names the
|
|
58
|
+
form whose MCIDs it means). A stroked path counts with its line width, caps and joins (from `w`, `J`,
|
|
59
|
+
`j`, `M` or an ExtGState); a curve's stroke is bounded a little generously. Text adds nothing (its extent needs font metrics), and content outside
|
|
60
|
+
the crop box is ignored; a Figure with no other content gets the crop box._
|
|
61
|
+
|
|
62
|
+
`#mark_paths_as_artifacts` is deprecated: it now does what `#mark_untagged_content_as_artifacts`
|
|
63
|
+
does. Its old behaviour - wrapping rectangles anywhere, also inside tagged content - fixed few
|
|
64
|
+
Chromium decorations and broke rules 7.1-1/7.1-2.
|
|
65
|
+
|
|
66
|
+
Every error raises `QpdfRuby::Error` (a `RuntimeError`); an argument of the wrong type raises
|
|
67
|
+
`TypeError`, an unknown keyword (`tittle:`) `ArgumentError`.
|
|
68
|
+
|
|
69
|
+
Checked against veraPDF 1.30.2 on Chromium 154 output (`spec/fixtures/chromium/`): both
|
|
70
|
+
fixtures are PDF/UA-1 compliant after `apply_pdfua_fixes`. A machine check covers the machine
|
|
71
|
+
checkable part only - alternative texts, headings and reading order still need a person.
|
|
35
72
|
|
|
36
73
|
---
|
|
37
74
|
|
|
@@ -39,8 +76,8 @@ the structure tree._
|
|
|
39
76
|
|
|
40
77
|
### Requirements
|
|
41
78
|
|
|
42
|
-
* **Ruby** \>= 3.
|
|
43
|
-
* **QPDF** \>=
|
|
79
|
+
* **Ruby** \>= 3.3 (tested with 3.3, 3.4 and 4.0)
|
|
80
|
+
* **QPDF** \>= 11.9 (headers & libs; tested with 11.9.1 and 12.2)
|
|
44
81
|
|
|
45
82
|
### macOS
|
|
46
83
|
```bash
|
|
@@ -50,12 +87,11 @@ bundle config set --local build.qpdf_ruby "--with-qpdf-dir=$(brew --prefix qpdf)
|
|
|
50
87
|
|
|
51
88
|
### Debian/Ubuntu
|
|
52
89
|
```bash
|
|
53
|
-
#
|
|
90
|
+
# Debian 13 (trixie) and newer ship QPDF 12
|
|
54
91
|
sudo apt-get update && sudo apt-get install -y libqpdf-dev qpdf
|
|
55
92
|
```
|
|
56
|
-
If `apt` cannot provide QPDF ≥
|
|
57
|
-
|
|
58
|
-
`apt preferences` snippet.
|
|
93
|
+
If `apt` cannot provide QPDF ≥ 11.9, compile it yourself or use Debian trixie (see the
|
|
94
|
+
[Dockerfile](./docker/Dockerfile)).
|
|
59
95
|
|
|
60
96
|
### Add the gem
|
|
61
97
|
```bash
|
|
@@ -70,24 +106,19 @@ bundle add qpdf_ruby
|
|
|
70
106
|
```ruby
|
|
71
107
|
require "qpdf_ruby"
|
|
72
108
|
|
|
73
|
-
pdf = QpdfRuby::Document.new("
|
|
74
|
-
|
|
75
|
-
# 1. tag decorative paths
|
|
76
|
-
pdf.mark_paths_as_artifacts
|
|
109
|
+
pdf = QpdfRuby::Document.new("chromium-output.pdf")
|
|
77
110
|
|
|
78
|
-
|
|
79
|
-
|
|
111
|
+
report = pdf.apply_pdfua_fixes(
|
|
112
|
+
link_texts: { "mailto:jana@example.com" => "E-mail Jana" }, # optional, per URI or "#destination"
|
|
113
|
+
title: "Curriculum vitae - Jana Example" # optional, else the PDF's /Title
|
|
114
|
+
)
|
|
115
|
+
# => { decorations: 3, artifacts: { paths: 75, texts: 101, …, total: 176 }, links: 2, list_bodies: 34,
|
|
116
|
+
# roles: 0, figure_groups: 0, figures_without_alt: 0, identified: true, unidentified_reason: nil }
|
|
80
117
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
# 4. save 🎉
|
|
85
|
-
pdf.write("fixed.pdf")
|
|
118
|
+
pdf.ensure_bbox # layout BBoxes for figures (PAC 2024 asks for them)
|
|
119
|
+
pdf.write("accessible.pdf") # or pdf.to_memory
|
|
86
120
|
```
|
|
87
121
|
|
|
88
|
-
Run PAC 2024 on `fixed.pdf` – it should report far fewer (or zero!)
|
|
89
|
-
errors compared to the original browser output.
|
|
90
|
-
|
|
91
122
|
---
|
|
92
123
|
|
|
93
124
|
## Development
|
|
@@ -97,7 +128,17 @@ cd qpdf_ruby
|
|
|
97
128
|
bin/setup # install gem + test deps
|
|
98
129
|
autotest # guard & RSpec
|
|
99
130
|
```
|
|
100
|
-
* Bump **version.rb** → `bundle exec rake release` to push a new gem.
|
|
131
|
+
* Bump **version.rb** → `bundle exec rake release` to push a new gem. It first runs
|
|
132
|
+
`rake release_credentials_check`, which shows where the push credential comes from: a set
|
|
133
|
+
`GEM_HOST_API_KEY` wins (as in RubyGems), else `gem.push_key` in the credentials file RubyGems uses,
|
|
134
|
+
else that file's key for the push host, else its `rubygems_api_key`.
|
|
135
|
+
* `bundle exec rspec` runs the specs; the veraPDF examples run only where `verapdf` is on the
|
|
136
|
+
`PATH` (`bin/install-verapdf.sh [DIR]` installs the pinned CLI; needs Java). They are skipped
|
|
137
|
+
otherwise - `REQUIRE_VERAPDF=1`, set in CI, makes them fail instead.
|
|
138
|
+
* `bin/asan-rspec.sh [RSPEC_ARGS]` builds the extension with AddressSanitizer in a scratch copy and
|
|
139
|
+
runs the specs under it, with LeakSanitizer (`bin/lsan.supp` filters Ruby's own heap) - the way to
|
|
140
|
+
see a use-after-free a plain run survives by luck. A destructor skipped by a Ruby raise is the one
|
|
141
|
+
leak it cannot see; `spec/qpdf_ruby/document_error_path_memory_spec.rb` watches RSS for that.
|
|
101
142
|
|
|
102
143
|
### Testing with local QPDF builds
|
|
103
144
|
If you tinker with QPDF itself, point Bundler to your custom prefix:
|
|
@@ -119,7 +160,7 @@ Bug reports & pull requests are welcome at
|
|
|
119
160
|
|
|
120
161
|
### Code Style
|
|
121
162
|
* C++ 17, clang‑format enforced
|
|
122
|
-
* Ruby 3.
|
|
163
|
+
* Ruby 3.3, rubocop default rules
|
|
123
164
|
|
|
124
165
|
---
|
|
125
166
|
|
|
@@ -62,7 +62,7 @@ std::string DocumentHandle::write_to_memory() {
|
|
|
62
62
|
w.setOutputMemory();
|
|
63
63
|
w.write();
|
|
64
64
|
|
|
65
|
-
auto b = w.getBuffer()
|
|
65
|
+
auto b = w.getBufferSharedPointer(); // getBuffer() hands over a Buffer the caller must delete
|
|
66
66
|
return std::string(reinterpret_cast<char const*>(b->getBuffer()), b->getSize());
|
|
67
67
|
} catch (std::exception const& ex) {
|
|
68
68
|
throw std::runtime_error("qpdf_ruby: write_to_memory failed: " + std::string(ex.what()));
|
|
@@ -1,109 +1,134 @@
|
|
|
1
1
|
#include "struct_node.hpp"
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
#include <optional>
|
|
4
|
+
#include <set>
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
mcid = node.getKey("/K").getIntValue();
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
// Get the bbox from the walker or use defaults
|
|
11
|
-
|
|
12
|
-
QPDFObjectHandle pageObj;
|
|
13
|
-
QPDFObjectHandle currentNode = node;
|
|
14
|
-
bool found = false;
|
|
6
|
+
namespace {
|
|
15
7
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
break;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
if (currentNode.hasKey("/P")) {
|
|
25
|
-
currentNode = currentNode.getKey("/P");
|
|
26
|
-
} else {
|
|
27
|
-
break; // No more parents to check
|
|
28
|
-
}
|
|
8
|
+
// The page a structure element's content lives on: its own /Pg or the nearest ancestor's.
|
|
9
|
+
QPDFObjectHandle page_of(QPDFObjectHandle elem) {
|
|
10
|
+
for (int guard = 0; elem.isDictionary() && guard < 256; ++guard) {
|
|
11
|
+
if (elem.getKey("/Pg").isDictionary()) return elem.getKey("/Pg");
|
|
12
|
+
elem = elem.getKey("/P");
|
|
29
13
|
}
|
|
14
|
+
return QPDFObjectHandle::newNull();
|
|
15
|
+
}
|
|
30
16
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
17
|
+
// Unites the bounds of every piece of marked content below `elem`: direct MCIDs (in the content of
|
|
18
|
+
// the element's page), marked-content references (in their /Stm if given, else their own /Pg's
|
|
19
|
+
// content) and nested structure elements.
|
|
20
|
+
void collect_bounds(QPDFObjectHandle elem, qpdf_ruby::McidBounds const& bounds, std::optional<qpdf_ruby::Box>& out,
|
|
21
|
+
std::set<QPDFObjGen>& seen, int depth = 0) {
|
|
22
|
+
if (depth > 256 || (elem.isIndirect() && !seen.insert(elem.getObjGen()).second)) return;
|
|
23
|
+
QPDFObjectHandle page = page_of(elem);
|
|
24
|
+
|
|
25
|
+
auto add = [&](QPDFObjectHandle on_page, QPDFObjectHandle owner, int mcid) {
|
|
26
|
+
if (!on_page.isDictionary() || !(owner.isDictionary() || owner.isStream())) return;
|
|
27
|
+
auto it = bounds.find({on_page.getObjGen(), owner.getObjGen(), mcid});
|
|
28
|
+
if (it == bounds.end()) return;
|
|
29
|
+
out = out ? qpdf_ruby::unite(*out, it->second) : it->second;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
QPDFObjectHandle kids = elem.getKey("/K");
|
|
33
|
+
std::vector<QPDFObjectHandle> list;
|
|
34
|
+
if (kids.isArray()) {
|
|
35
|
+
for (auto kid : kids.aitems()) list.push_back(kid);
|
|
36
|
+
} else if (!kids.isNull()) {
|
|
37
|
+
list.push_back(kids);
|
|
41
38
|
}
|
|
42
39
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
for (auto& kid : list) {
|
|
41
|
+
if (kid.isInteger()) {
|
|
42
|
+
add(page, page, kid.getIntValue());
|
|
43
|
+
} else if (kid.isDictionary() && kid.getKey("/Type").isNameAndEquals("/MCR") && kid.getKey("/MCID").isInteger()) {
|
|
44
|
+
QPDFObjectHandle on_page = kid.getKey("/Pg").isDictionary() ? kid.getKey("/Pg") : page;
|
|
45
|
+
// A /Stm that is not a stream names nothing we can look up: no bounds rather than the page's.
|
|
46
|
+
QPDFObjectHandle owner = kid.hasKey("/Stm") ? kid.getKey("/Stm") : on_page;
|
|
47
|
+
if (kid.hasKey("/Stm") && !owner.isStream()) continue;
|
|
48
|
+
add(on_page, owner, kid.getKey("/MCID").getIntValue());
|
|
49
|
+
} else if (kid.isDictionary() && kid.hasKey("/S")) {
|
|
50
|
+
collect_bounds(kid, bounds, out, seen, depth + 1);
|
|
51
|
+
}
|
|
46
52
|
}
|
|
53
|
+
}
|
|
47
54
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
auto const& bbox_map = walker.getMcidBboxMap();
|
|
55
|
+
bool is_layout(QPDFObjectHandle attrs) {
|
|
56
|
+
return attrs.isDictionary() && attrs.getKey("/O").isNameAndEquals("/Layout");
|
|
57
|
+
}
|
|
52
58
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
urx = b[2];
|
|
60
|
-
ury = b[3];
|
|
59
|
+
// The element's Layout attribute dictionary - /A itself, or the first one in an /A array - or null.
|
|
60
|
+
QPDFObjectHandle layout_of(QPDFObjectHandle attrs) {
|
|
61
|
+
if (is_layout(attrs)) return attrs;
|
|
62
|
+
if (attrs.isArray()) {
|
|
63
|
+
for (auto item : attrs.aitems()) {
|
|
64
|
+
if (is_layout(item)) return item;
|
|
61
65
|
}
|
|
62
|
-
} catch (const std::exception& e) {
|
|
63
|
-
std::cerr << "Error accessing mcid " << mcid << " in bbox map: " << e.what() << std::endl;
|
|
64
|
-
// Continue with default values
|
|
65
66
|
}
|
|
67
|
+
return QPDFObjectHandle::newNull();
|
|
68
|
+
}
|
|
66
69
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
attrs.replaceKey("/BBox", arr);
|
|
70
|
+
// Structure types whose content is a line of text: a Figure among them is part of that line.
|
|
71
|
+
const std::set<std::string> kInlineParents = {"/P", "/H", "/H1", "/H2", "/H3", "/H4", "/H5",
|
|
72
|
+
"/H6", "/Lbl", "/Span", "/Quote", "/Note", "/Reference",
|
|
73
|
+
"/BibEntry", "/Code", "/Link", "/Annot", "/Ruby", "/Warichu",
|
|
74
|
+
"/Strong", "/Em"};
|
|
75
|
+
|
|
76
|
+
// True if the Figure sits in a line of text - its nearest parent that is not a NonStruct (which
|
|
77
|
+
// only groups, ISO 32000-1 14.8.4.2) is one of kInlineParents.
|
|
78
|
+
bool in_inline_context(QPDFObjectHandle elem) {
|
|
79
|
+
QPDFObjectHandle parent = elem.getKey("/P");
|
|
80
|
+
for (int guard = 0; guard < 64 && parent.isDictionary(); ++guard) {
|
|
81
|
+
if (!parent.getKey("/S").isNameAndEquals("/NonStruct")) break;
|
|
82
|
+
parent = parent.getKey("/P");
|
|
81
83
|
}
|
|
84
|
+
QPDFObjectHandle type = parent.isDictionary() ? parent.getKey("/S") : QPDFObjectHandle::newNull();
|
|
85
|
+
return type.isName() && kInlineParents.count(type.getName()) > 0;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
QPDFObjectHandle box_array(qpdf_ruby::Box const& box) {
|
|
89
|
+
QPDFObjectHandle array = QPDFObjectHandle::newArray();
|
|
90
|
+
for (double value : box) array.appendItem(QPDFObjectHandle::newReal(value, 4));
|
|
91
|
+
return array;
|
|
82
92
|
}
|
|
83
93
|
|
|
94
|
+
} // namespace
|
|
95
|
+
|
|
96
|
+
// Gives the Figure a Layout attribute with a /BBox (its painted bounds) and, outside a line of text,
|
|
97
|
+
// /Placement /Block - without the placement PAC 2024 warns of a "possibly inappropriate use" of the
|
|
98
|
+
// Figure (checked 2026-10-02). Existing values are kept; an existing attribute object is extended.
|
|
84
99
|
void FigureNode::ensureLayoutBBox(PDFStructWalker& walker) {
|
|
85
100
|
StructElemNode::ensureLayoutBBox(walker);
|
|
86
101
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
return false;
|
|
103
|
-
}())) {
|
|
104
|
-
return; // nothing to do
|
|
105
|
-
}
|
|
102
|
+
QPDFObjectHandle attrs = node.getKey("/A");
|
|
103
|
+
QPDFObjectHandle layout = layout_of(attrs);
|
|
104
|
+
bool needs_bbox = !(layout.isDictionary() && layout.hasKey("/BBox"));
|
|
105
|
+
bool needs_placement = !(layout.isDictionary() && layout.hasKey("/Placement")) && !in_inline_context(node);
|
|
106
|
+
if (!needs_bbox && !needs_placement) return;
|
|
107
|
+
|
|
108
|
+
std::optional<qpdf_ruby::Box> box;
|
|
109
|
+
if (needs_bbox) {
|
|
110
|
+
std::set<QPDFObjGen> seen;
|
|
111
|
+
collect_bounds(node, walker.getMcidBounds(), box, seen);
|
|
112
|
+
QPDFObjectHandle page = page_of(node);
|
|
113
|
+
if (!box && page.isDictionary()) box = walker.getPageCropBoxFor(page); // else nothing to anchor it to
|
|
106
114
|
}
|
|
115
|
+
if (!box && !needs_placement) return;
|
|
107
116
|
|
|
108
|
-
|
|
109
|
-
|
|
117
|
+
bool attached = layout.isDictionary();
|
|
118
|
+
if (!attached) {
|
|
119
|
+
layout = QPDFObjectHandle::newDictionary();
|
|
120
|
+
layout.replaceKey("/O", QPDFObjectHandle::newName("/Layout"));
|
|
121
|
+
}
|
|
122
|
+
if (box) layout.replaceKey("/BBox", box_array(*box));
|
|
123
|
+
if (needs_placement) layout.replaceKey("/Placement", QPDFObjectHandle::newName("/Block"));
|
|
124
|
+
if (attached) return;
|
|
125
|
+
|
|
126
|
+
// Keep every existing attribute object: append the new Layout dictionary to them.
|
|
127
|
+
if (attrs.isArray()) {
|
|
128
|
+
attrs.appendItem(layout);
|
|
129
|
+
} else if (attrs.isDictionary()) {
|
|
130
|
+
node.replaceKey("/A", QPDFObjectHandle::newArray({attrs, layout}));
|
|
131
|
+
} else {
|
|
132
|
+
node.replaceKey("/A", layout);
|
|
133
|
+
}
|
|
134
|
+
}
|