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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5e34005af4a00f153fe6597426be27d03e047c9fce46af4e669c22138e0b726
4
- data.tar.gz: 95fb970cb38489fd18b2d8aee4e0f48e843f9b14f45a4806ea7b5b1e6f79045c
3
+ metadata.gz: 1031f5b376f46ea2e57b4a06692f10fe62f081bf010eff784e7e179c3dca83d1
4
+ data.tar.gz: aa3e9a16d357779d6b51c7d66c8736641a140a180844cd3f6c5bcbb22988ce32
5
5
  SHA512:
6
- metadata.gz: 6f1e65790f45ab85cb633883c4f7d8597d8271e15aedf6ef670e58491f0658a0bf53370a232abdda370088bc13594445e292d7e73fda1b34e9fa460a15ae0eef
7
- data.tar.gz: be16ec8f1fde0eb5c022a39b29588429319d6aa553fdc368ee90f6a409659a0d7d0a30eba212683e4f5640eb961c127bef46d2f7f4dded9879ab58195c0df67e
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///compare/v0.1.1..HEAD
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
- ## [0.1.1] - 2025-06-30
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/bidi2pdf/compare/v0.1.1..HEAD)
28
- - [0.1.1](https://github.com/dieter-medium/bidi2pdf/compare/v0.1.0..v0.1.1)
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
- > **Patch & polish PDFs so that PAC 2024 finally turns green.**
4
-
5
- QpdfRuby is a very small Ruby wrapper around the battle‑tested
6
- [QPDF \>= 12](https://qpdf.sourceforge.net/) C++ library. Right now the
7
- library focuses on only **three specialised tasks** that are needed when
8
- PDFs are printed from Chromium‑based browsers and subsequently audited
9
- with the PAC 2024 accessibility checker:
10
-
11
- 1. **Export the structure tree as XML** – handy for debugging.
12
- 2. **Mark vector path objects as `/Artifact`** so that decorative lines,
13
- boxes, &c. are ignored by assistive technologies.
14
- 3. **Add missing `/BBox` entries to every `/Figure` element** (derived
15
- from the page’s graphic operators) so that screen readers know the
16
- physical extent of each image.
17
-
18
- Together these tweaks eliminate the most common complaints PAC 2024 has
19
- about browser‑generated PDFs.
20
-
21
- ---
22
-
23
- ## Features in Detail
24
-
25
- | Feature | Ruby API |
26
- | ---------------------------------------------- | ---------------------------------------- |
27
- | Dump structure tree as XML | `doc.show_structure` |
28
- | Mark path objects ( `re … S/s/f/F/B/b` ) | `doc.mark_paths_as_artifacts` |
29
- | Ensure `/Figure` elements have a layout BBox¹ | `doc.ensure_bbox` |
30
-
31
- _¹Internally the gem parses each page’s content stream, maps image
32
- `/MCID`s to their transformation matrix, computes the bounding box
33
- (courtesy of a little linear algebra) and finally writes the result into
34
- the structure tree._
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.1
43
- * **QPDF** \>= 12.0.0 (headers & libs)
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
- # on Debian 11/Ubuntu 20.04 you may need newer packages from testing
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 ≥ 12 you can compile it yourself or pull the
57
- package from *testing/unstable* – see the [Dockerfile](./docker/Dockerfile) for a working
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("input.pdf")
74
-
75
- # 1. tag decorative paths
76
- pdf.mark_paths_as_artifacts
109
+ pdf = QpdfRuby::Document.new("chromium-output.pdf")
77
110
 
78
- # 2. add BBox to every <Figure>
79
- pdf.ensure_bbox
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
- # 3. introspect structure tree (optional)
82
- File.write("structure.xml", pdf.show_structure)
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.2, rubocop default rules
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
- static void add_bbox(PDFStructWalker& walker, QPDFObjectHandle node) {
4
- int mcid = -std::numeric_limits<int>::infinity();
3
+ #include <optional>
4
+ #include <set>
5
5
 
6
- if (node.getKey("/K").isInteger()) {
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
- // Try to find /Pg by walking up the parent chain
17
- while (currentNode.isDictionary()) {
18
- if (currentNode.hasKey("/Pg")) {
19
- pageObj = currentNode.getKey("/Pg");
20
- found = true;
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
- if (!found && node.hasKey("/K") && node.getKey("/K").isArray()) {
32
- QPDFObjectHandle kids = node.getKey("/K");
33
- for (int i = 0; i < kids.getArrayNItems(); ++i) {
34
- QPDFObjectHandle kid = kids.getArrayItem(i);
35
- if (kid.isDictionary() && kid.hasKey("/Pg")) {
36
- pageObj = kid.getKey("/Pg");
37
- found = true;
38
- break;
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
- if (!found || !pageObj.isIndirect()) {
44
- std::cerr << "No /Pg key found for MCID " << mcid << ", cannot add BBox." << std::endl;
45
- return;
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
- auto media_box = walker.getPageCropBoxFor(pageObj);
49
-
50
- double llx = media_box[0], lly = media_box[1], urx = media_box[2], ury = media_box[3];
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
- // Use a try/catch to prevent failures when accessing the map
54
- try {
55
- if (bbox_map.count(mcid) > 0) {
56
- auto const& b = bbox_map.at(mcid);
57
- llx = b[0];
58
- lly = b[1];
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
- QPDFObjectHandle attrs;
68
- attrs = QPDFObjectHandle::newDictionary();
69
- attrs.replaceKey("/O", QPDFObjectHandle::newName("/Layout"));
70
-
71
- node.replaceKey("/A", attrs);
72
-
73
- if (!attrs.hasKey("/BBox")) {
74
- QPDFObjectHandle arr = QPDFObjectHandle::newArray();
75
- arr.appendItem(QPDFObjectHandle::newReal(llx));
76
- arr.appendItem(QPDFObjectHandle::newReal(lly));
77
- arr.appendItem(QPDFObjectHandle::newReal(urx));
78
- arr.appendItem(QPDFObjectHandle::newReal(ury));
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
- // Bail if a BBox is already present *anywhere* in /A
88
- if (node.hasKey("/A")) {
89
- QPDFObjectHandle A = node.getKey("/A");
90
- auto has_bbox = [](QPDFObjectHandle const& dict) {
91
- QPDFObjectHandle non_const_dict = dict;
92
- return non_const_dict.isDictionary() && non_const_dict.hasKey("/O") &&
93
- non_const_dict.getKey("/O").getName() == "/Layout" && non_const_dict.hasKey("/BBox");
94
- };
95
-
96
- if ((A.isDictionary() && has_bbox(A)) || (A.isArray() && [&]() -> bool {
97
- for (int i = 0; i < A.getArrayNItems(); ++i) {
98
- if (has_bbox(A.getArrayItem(i))) {
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
- add_bbox(walker, node);
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
+ }