qpdf_ruby 0.1.0 → 0.2.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 +69 -1
- data/README.md +94 -54
- data/ext/qpdf_ruby/document_handle.cpp +77 -25
- data/ext/qpdf_ruby/document_handle.hpp +32 -2
- 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 +7 -3
- data/ext/qpdf_ruby/pdfua.cpp +909 -0
- data/ext/qpdf_ruby/pdfua.hpp +102 -0
- data/ext/qpdf_ruby/qpdf_ruby.cpp +384 -138
- data/ext/qpdf_ruby/qpdf_ruby.hpp +1 -3
- 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/struct_node.hpp +2 -0
- data/ext/qpdf_ruby/xmp_scan.cpp +257 -0
- data/ext/qpdf_ruby/xmp_scan.hpp +27 -0
- data/lib/qpdf_ruby/version.rb +1 -1
- data/lib/qpdf_ruby.rb +7 -2
- data/sig/qpdf_ruby.rbs +78 -1
- metadata +11 -11
- 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/docker/Dockerfile +0 -46
- data/ext/qpdf_ruby/pdf_image_mapper.cpp +0 -294
- data/ext/qpdf_ruby/pdf_image_mapper.hpp +0 -66
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
#include "pdfua.hpp"
|
|
2
|
+
#include "xmp_scan.hpp"
|
|
3
|
+
|
|
4
|
+
#include <qpdf/Pl_Buffer.hh>
|
|
5
|
+
#include <qpdf/QPDFNameTreeObjectHelper.hh>
|
|
6
|
+
#include <qpdf/QPDFNumberTreeObjectHelper.hh>
|
|
7
|
+
#include <qpdf/QPDFObjectHandle.hh>
|
|
8
|
+
#include <qpdf/QPDFPageObjectHelper.hh>
|
|
9
|
+
#include <qpdf/QPDFTokenizer.hh>
|
|
10
|
+
|
|
11
|
+
#include <climits>
|
|
12
|
+
#include <cstdlib>
|
|
13
|
+
#include <functional>
|
|
14
|
+
#include <memory>
|
|
15
|
+
#include <set>
|
|
16
|
+
#include <vector>
|
|
17
|
+
|
|
18
|
+
namespace qpdf_ruby::pdfua {
|
|
19
|
+
|
|
20
|
+
namespace {
|
|
21
|
+
|
|
22
|
+
const std::set<std::string> kPathConstruction = {"m", "l", "c", "v", "y", "h", "re"};
|
|
23
|
+
const std::set<std::string> kClip = {"W", "W*"};
|
|
24
|
+
const std::set<std::string> kPathEnd = {"S", "s", "f", "F", "f*", "B", "B*", "b", "b*", "n"};
|
|
25
|
+
const std::set<std::string> kTextShow = {"Tj", "TJ", "'", "\""};
|
|
26
|
+
// Graphics state and colour operators producers emit between a path's construction and its
|
|
27
|
+
// painting operator (Chromium: `... l 2 w S`). They change no geometry, so they stay part of the
|
|
28
|
+
// path object instead of ending it.
|
|
29
|
+
const std::set<std::string> kPathState = {"w", "J", "j", "M", "d", "ri", "i", "gs", "CS", "cs",
|
|
30
|
+
"SC", "SCN", "sc", "scn", "G", "g", "RG", "rg", "K", "k"};
|
|
31
|
+
|
|
32
|
+
// Notes whether a content stream uses a marked-content operator - as a token, so "BDC" inside a
|
|
33
|
+
// string or an inline image's data does not count.
|
|
34
|
+
class MarkedContentProbe : public QPDFObjectHandle::TokenFilter {
|
|
35
|
+
public:
|
|
36
|
+
void handleToken(QPDFTokenizer::Token const& token) override {
|
|
37
|
+
if (token.getType() == QPDFTokenizer::tt_word && (token.getValue() == "BDC" || token.getValue() == "BMC")) {
|
|
38
|
+
found = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
bool found = false;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// True if a Form XObject (or anything it draws) carries marked content of its own.
|
|
45
|
+
bool form_has_marked_content(QPDFObjectHandle xobject, std::set<QPDFObjGen>& seen) {
|
|
46
|
+
if (!xobject.isStream() || !seen.insert(xobject.getObjGen()).second) return false;
|
|
47
|
+
QPDFObjectHandle dict = xobject.getDict();
|
|
48
|
+
if (!dict.getKey("/Subtype").isNameAndEquals("/Form")) return false;
|
|
49
|
+
if (dict.hasKey("/StructParents") || dict.hasKey("/StructParent")) return true;
|
|
50
|
+
MarkedContentProbe probe;
|
|
51
|
+
xobject.filterAsContents(&probe, nullptr);
|
|
52
|
+
if (probe.found) return true;
|
|
53
|
+
QPDFObjectHandle resources = dict.getKey("/Resources");
|
|
54
|
+
QPDFObjectHandle xobjects = resources.isDictionary() ? resources.getKey("/XObject") : QPDFObjectHandle::newNull();
|
|
55
|
+
if (xobjects.isDictionary()) {
|
|
56
|
+
for (auto [name, child] : xobjects.ditems()) {
|
|
57
|
+
if (form_has_marked_content(child, seen)) return true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Token filter behind mark_untagged_content_as_artifacts / count_untagged_content.
|
|
64
|
+
class UntaggedFilter : public QPDFObjectHandle::TokenFilter {
|
|
65
|
+
public:
|
|
66
|
+
UntaggedFilter(QPDFObjectHandle resources, bool rewrite) : resources_(std::move(resources)), rewrite_(rewrite) {}
|
|
67
|
+
|
|
68
|
+
void handleToken(QPDFTokenizer::Token const& token) override {
|
|
69
|
+
if (in_inline_image_) {
|
|
70
|
+
object_.push_back(token);
|
|
71
|
+
if (token.getType() == QPDFTokenizer::tt_word && token.getValue() == "EI") {
|
|
72
|
+
emit_object(true);
|
|
73
|
+
++counts.inline_images;
|
|
74
|
+
in_inline_image_ = false;
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (token.getType() != QPDFTokenizer::tt_word) {
|
|
79
|
+
pending_.push_back(token);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
std::string const& op = token.getValue();
|
|
83
|
+
|
|
84
|
+
if (op == "BMC" || op == "BDC" || op == "EMC") {
|
|
85
|
+
flush_object_unwrapped();
|
|
86
|
+
flush_pending();
|
|
87
|
+
writeToken(token);
|
|
88
|
+
if (op == "EMC") {
|
|
89
|
+
if (depth_ > 0) --depth_;
|
|
90
|
+
} else {
|
|
91
|
+
++depth_;
|
|
92
|
+
}
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (depth_ > 0) {
|
|
96
|
+
flush_pending();
|
|
97
|
+
writeToken(token);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (op == "BI") {
|
|
102
|
+
flush_object_unwrapped();
|
|
103
|
+
take_pending();
|
|
104
|
+
object_.push_back(token);
|
|
105
|
+
in_inline_image_ = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (kPathConstruction.count(op) || (in_path_ && kClip.count(op))) {
|
|
109
|
+
take_pending();
|
|
110
|
+
object_.push_back(token);
|
|
111
|
+
in_path_ = true;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (in_path_ && kPathState.count(op)) {
|
|
115
|
+
take_pending();
|
|
116
|
+
object_.push_back(token);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (in_path_ && kPathEnd.count(op)) {
|
|
120
|
+
take_pending();
|
|
121
|
+
object_.push_back(token);
|
|
122
|
+
bool painted = op != "n";
|
|
123
|
+
emit_object(painted);
|
|
124
|
+
if (painted) ++counts.paths;
|
|
125
|
+
in_path_ = false;
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (kTextShow.count(op) || op == "sh" || (op == "Do" && !draws_marked_form())) {
|
|
129
|
+
take_pending();
|
|
130
|
+
object_.push_back(token);
|
|
131
|
+
emit_object(true);
|
|
132
|
+
if (kTextShow.count(op)) ++counts.texts;
|
|
133
|
+
else if (op == "sh") ++counts.shadings;
|
|
134
|
+
else ++counts.xobjects;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
flush_object_unwrapped();
|
|
139
|
+
flush_pending();
|
|
140
|
+
writeToken(token);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
void handleEOF() override {
|
|
144
|
+
flush_object_unwrapped();
|
|
145
|
+
flush_pending();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
UntaggedCounts counts;
|
|
149
|
+
|
|
150
|
+
private:
|
|
151
|
+
// The operand of the Do being handled is the last name among the pending tokens.
|
|
152
|
+
bool draws_marked_form() {
|
|
153
|
+
for (auto it = pending_.rbegin(); it != pending_.rend(); ++it) {
|
|
154
|
+
if (it->getType() == QPDFTokenizer::tt_name) {
|
|
155
|
+
QPDFObjectHandle xobjects =
|
|
156
|
+
resources_.isDictionary() ? resources_.getKey("/XObject") : QPDFObjectHandle::newNull();
|
|
157
|
+
if (!xobjects.isDictionary()) return false;
|
|
158
|
+
std::set<QPDFObjGen> seen;
|
|
159
|
+
return form_has_marked_content(xobjects.getKey(it->getValue()), seen);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
void take_pending() {
|
|
166
|
+
object_.insert(object_.end(), pending_.begin(), pending_.end());
|
|
167
|
+
pending_.clear();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
void flush_pending() {
|
|
171
|
+
for (auto const& t : pending_) writeToken(t);
|
|
172
|
+
pending_.clear();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
void flush_object_unwrapped() {
|
|
176
|
+
for (auto const& t : object_) writeToken(t);
|
|
177
|
+
object_.clear();
|
|
178
|
+
in_path_ = false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
void emit_object(bool wrap) {
|
|
182
|
+
bool artifact = wrap && rewrite_;
|
|
183
|
+
if (artifact) write("/Artifact BMC\n");
|
|
184
|
+
for (auto const& t : object_) writeToken(t);
|
|
185
|
+
if (artifact) write("\nEMC\n");
|
|
186
|
+
object_.clear();
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
QPDFObjectHandle resources_;
|
|
190
|
+
bool rewrite_;
|
|
191
|
+
std::vector<QPDFTokenizer::Token> pending_;
|
|
192
|
+
std::vector<QPDFTokenizer::Token> object_;
|
|
193
|
+
bool in_path_ = false;
|
|
194
|
+
bool in_inline_image_ = false;
|
|
195
|
+
int depth_ = 0;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
void add_counts(UntaggedCounts& into, UntaggedCounts const& from) {
|
|
199
|
+
into.paths += from.paths;
|
|
200
|
+
into.texts += from.texts;
|
|
201
|
+
into.xobjects += from.xobjects;
|
|
202
|
+
into.shadings += from.shadings;
|
|
203
|
+
into.inline_images += from.inline_images;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Runs the filter over a page or Form XObject; when rewriting, replaces its content with the
|
|
207
|
+
// result. Form XObjects with marked content of their own are walked too (once each).
|
|
208
|
+
UntaggedCounts filter_contents(QPDF& pdf, QPDFObjectHandle owner, bool rewrite, std::set<QPDFObjGen>& visited) {
|
|
209
|
+
UntaggedCounts counts;
|
|
210
|
+
if (!visited.insert(owner.getObjGen()).second) return counts;
|
|
211
|
+
|
|
212
|
+
QPDFPageObjectHelper helper(owner);
|
|
213
|
+
QPDFObjectHandle resources = helper.getAttribute("/Resources", false);
|
|
214
|
+
UntaggedFilter filter(resources, rewrite);
|
|
215
|
+
Pl_Buffer buffer("pdfua artifacts");
|
|
216
|
+
helper.filterContents(&filter, rewrite ? &buffer : nullptr);
|
|
217
|
+
add_counts(counts, filter.counts);
|
|
218
|
+
|
|
219
|
+
if (rewrite) {
|
|
220
|
+
auto data = buffer.getBufferSharedPointer();
|
|
221
|
+
if (owner.isStream()) {
|
|
222
|
+
owner.replaceStreamData(data, QPDFObjectHandle::newNull(), QPDFObjectHandle::newNull());
|
|
223
|
+
} else {
|
|
224
|
+
owner.replaceKey("/Contents", QPDFObjectHandle::newStream(&pdf, data));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
QPDFObjectHandle xobjects = resources.isDictionary() ? resources.getKey("/XObject") : QPDFObjectHandle::newNull();
|
|
229
|
+
if (xobjects.isDictionary()) {
|
|
230
|
+
for (auto [name, xobject] : xobjects.ditems()) {
|
|
231
|
+
std::set<QPDFObjGen> seen;
|
|
232
|
+
if (form_has_marked_content(xobject, seen)) add_counts(counts, filter_contents(pdf, xobject, rewrite, visited));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return counts;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
UntaggedCounts walk_untagged(QPDF& pdf, bool rewrite) {
|
|
239
|
+
UntaggedCounts counts;
|
|
240
|
+
std::set<QPDFObjGen> visited;
|
|
241
|
+
for (QPDFObjectHandle page : pdf.getAllPages()) add_counts(counts, filter_contents(pdf, page, rewrite, visited));
|
|
242
|
+
return counts;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
QPDFObjectHandle struct_tree(QPDF& pdf) { return pdf.getRoot().getKey("/StructTreeRoot"); }
|
|
246
|
+
|
|
247
|
+
// The page a structure element's marked content lives on: its own /Pg or an ancestor's.
|
|
248
|
+
QPDFObjectHandle page_of(QPDFObjectHandle elem) {
|
|
249
|
+
for (int guard = 0; elem.isDictionary() && guard < 256; ++guard) {
|
|
250
|
+
if (elem.getKey("/Pg").isDictionary()) return elem.getKey("/Pg");
|
|
251
|
+
elem = elem.getKey("/P");
|
|
252
|
+
}
|
|
253
|
+
return QPDFObjectHandle::newNull();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
std::vector<QPDFObjectHandle> kids_of(QPDFObjectHandle elem) {
|
|
257
|
+
std::vector<QPDFObjectHandle> kids;
|
|
258
|
+
QPDFObjectHandle k = elem.getKey("/K");
|
|
259
|
+
if (k.isArray()) {
|
|
260
|
+
for (auto kid : k.aitems()) kids.push_back(kid);
|
|
261
|
+
} else if (!k.isNull()) {
|
|
262
|
+
kids.push_back(k);
|
|
263
|
+
}
|
|
264
|
+
return kids;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
void each_struct_elem(QPDFObjectHandle node, std::function<void(QPDFObjectHandle)> const& fn,
|
|
268
|
+
std::set<QPDFObjGen>& seen, int depth = 0) {
|
|
269
|
+
if (!node.isDictionary() || depth > 512) return;
|
|
270
|
+
if (node.isIndirect() && !seen.insert(node.getObjGen()).second) return;
|
|
271
|
+
if (node.hasKey("/S")) fn(node);
|
|
272
|
+
for (auto& kid : kids_of(node)) each_struct_elem(kid, fn, seen, depth + 1);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
void each_struct_elem(QPDF& pdf, std::function<void(QPDFObjectHandle)> const& fn) {
|
|
276
|
+
std::set<QPDFObjGen> seen;
|
|
277
|
+
QPDFObjectHandle tree = struct_tree(pdf);
|
|
278
|
+
if (tree.isDictionary()) each_struct_elem(tree, fn, seen);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// The content stream an MCID is numbered in: a marked-content reference's /Stm (a Form XObject),
|
|
282
|
+
// else its /Pg, else the page of the structure element holding it. Null when /Stm is not a stream.
|
|
283
|
+
QPDFObjectHandle mcid_owner(QPDFObjectHandle kid, QPDFObjectHandle page) {
|
|
284
|
+
if (kid.isDictionary() && kid.hasKey("/Stm")) {
|
|
285
|
+
QPDFObjectHandle stm = kid.getKey("/Stm");
|
|
286
|
+
return stm.isStream() ? stm : QPDFObjectHandle::newNull();
|
|
287
|
+
}
|
|
288
|
+
if (kid.isDictionary() && kid.getKey("/Pg").isDictionary()) return kid.getKey("/Pg");
|
|
289
|
+
return page;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// The MCID a structure element's kid names: the kid itself, or a marked-content reference's /MCID.
|
|
293
|
+
QPDFObjectHandle mcid_value(QPDFObjectHandle kid) {
|
|
294
|
+
if (kid.isInteger()) return kid;
|
|
295
|
+
return kid.isDictionary() ? kid.getKey("/MCID") : QPDFObjectHandle::newNull();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// The ParentTree array of an MCID owner (page or Form XObject), found through its /StructParents.
|
|
299
|
+
QPDFObjectHandle parent_tree_entries(QPDF& pdf, QPDFObjectHandle parent_tree, QPDFObjectHandle owner) {
|
|
300
|
+
QPDFObjectHandle dict = owner.isStream() ? owner.getDict() : owner;
|
|
301
|
+
QPDFObjectHandle key = dict.isDictionary() ? dict.getKey("/StructParents") : QPDFObjectHandle::newNull();
|
|
302
|
+
QPDFObjectHandle entries;
|
|
303
|
+
if (!parent_tree.isDictionary() || !key.isInteger() ||
|
|
304
|
+
!QPDFNumberTreeObjectHelper(parent_tree, pdf).findObject(key.getIntValue(), entries) || !entries.isArray()) {
|
|
305
|
+
return QPDFObjectHandle::newNull();
|
|
306
|
+
}
|
|
307
|
+
return entries;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
std::string xml_escape(std::string const& s) {
|
|
311
|
+
std::string out;
|
|
312
|
+
for (char c : s) {
|
|
313
|
+
switch (c) {
|
|
314
|
+
case '&': out += "&"; break;
|
|
315
|
+
case '<': out += "<"; break;
|
|
316
|
+
case '>': out += ">"; break;
|
|
317
|
+
case '"': out += """; break;
|
|
318
|
+
default: out += c;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
} // namespace
|
|
325
|
+
|
|
326
|
+
UntaggedCounts mark_untagged_content_as_artifacts(QPDF& pdf) { return walk_untagged(pdf, true); }
|
|
327
|
+
|
|
328
|
+
UntaggedCounts count_untagged_content(QPDF& pdf) { return walk_untagged(pdf, false); }
|
|
329
|
+
|
|
330
|
+
namespace {
|
|
331
|
+
|
|
332
|
+
// An internal link's destination: its name (a named destination) and the 1-based page it opens.
|
|
333
|
+
std::pair<std::string, int> link_destination(QPDF& pdf, QPDFObjectHandle annot) {
|
|
334
|
+
QPDFObjectHandle dest = annot.getKey("/Dest");
|
|
335
|
+
QPDFObjectHandle action = annot.getKey("/A");
|
|
336
|
+
if (dest.isNull() && action.isDictionary() && action.getKey("/S").isNameAndEquals("/GoTo")) {
|
|
337
|
+
dest = action.getKey("/D");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// A name is looked up in the catalog's /Dests dictionary (PDF 1.1), a string in the /Names /Dests
|
|
341
|
+
// name tree (PDF 1.2+, possibly split into /Kids). Either value is the array or a dict with /D.
|
|
342
|
+
std::string name;
|
|
343
|
+
QPDFObjectHandle named;
|
|
344
|
+
if (dest.isName()) {
|
|
345
|
+
name = dest.getName().substr(1);
|
|
346
|
+
QPDFObjectHandle dests = pdf.getRoot().getKey("/Dests");
|
|
347
|
+
if (dests.isDictionary()) named = dests.getKey(dest.getName());
|
|
348
|
+
} else if (dest.isString()) {
|
|
349
|
+
name = dest.getUTF8Value();
|
|
350
|
+
QPDFObjectHandle names = pdf.getRoot().getKey("/Names");
|
|
351
|
+
QPDFObjectHandle tree = names.isDictionary() ? names.getKey("/Dests") : QPDFObjectHandle::newNull();
|
|
352
|
+
if (tree.isDictionary() && !QPDFNameTreeObjectHelper(tree, pdf).findObject(name, named)) {
|
|
353
|
+
named = QPDFObjectHandle::newNull();
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (named.isDictionary()) named = named.getKey("/D");
|
|
357
|
+
if (named.isArray()) dest = named;
|
|
358
|
+
|
|
359
|
+
int page_number = 0;
|
|
360
|
+
if (dest.isArray() && dest.getArrayNItems() > 0) {
|
|
361
|
+
QPDFObjectHandle target = dest.getArrayItem(0);
|
|
362
|
+
auto pages = pdf.getAllPages();
|
|
363
|
+
for (size_t i = 0; i < pages.size(); ++i) {
|
|
364
|
+
if (target.isIndirect() && pages[i].getObjGen() == target.getObjGen()) page_number = static_cast<int>(i) + 1;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return {name, page_number};
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
} // namespace
|
|
371
|
+
|
|
372
|
+
long describe_links(QPDF& pdf, std::map<std::string, std::string> const& texts) {
|
|
373
|
+
QPDFObjectHandle tree = struct_tree(pdf);
|
|
374
|
+
QPDFObjectHandle parent_tree = tree.isDictionary() ? tree.getKey("/ParentTree") : QPDFObjectHandle::newNull();
|
|
375
|
+
long described = 0;
|
|
376
|
+
|
|
377
|
+
for (QPDFObjectHandle page : pdf.getAllPages()) {
|
|
378
|
+
QPDFObjectHandle annots = page.getKey("/Annots");
|
|
379
|
+
if (!annots.isArray()) continue;
|
|
380
|
+
for (auto annot : annots.aitems()) {
|
|
381
|
+
if (!annot.isDictionary() || !annot.getKey("/Subtype").isNameAndEquals("/Link")) continue;
|
|
382
|
+
QPDFObjectHandle contents = annot.getKey("/Contents");
|
|
383
|
+
if (contents.isString() && !contents.getUTF8Value().empty()) continue;
|
|
384
|
+
|
|
385
|
+
std::string uri;
|
|
386
|
+
QPDFObjectHandle action = annot.getKey("/A");
|
|
387
|
+
if (action.isDictionary() && action.getKey("/URI").isString()) uri = action.getKey("/URI").getUTF8Value();
|
|
388
|
+
|
|
389
|
+
std::string text;
|
|
390
|
+
if (auto it = texts.find(uri); !uri.empty() && it != texts.end()) text = it->second;
|
|
391
|
+
|
|
392
|
+
if (text.empty() && parent_tree.isDictionary() && annot.getKey("/StructParent").isInteger()) {
|
|
393
|
+
QPDFObjectHandle elem;
|
|
394
|
+
QPDFNumberTreeObjectHelper numbers(parent_tree, pdf);
|
|
395
|
+
if (numbers.findObject(annot.getKey("/StructParent").getIntValue(), elem) && elem.isDictionary()) {
|
|
396
|
+
for (char const* key : {"/Alt", "/ActualText"}) {
|
|
397
|
+
if (text.empty() && elem.getKey(key).isString()) text = elem.getKey(key).getUTF8Value();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
if (text.empty() && uri.empty()) {
|
|
402
|
+
auto [name, page_number] = link_destination(pdf, annot);
|
|
403
|
+
if (auto it = texts.find("#" + name); !name.empty() && it != texts.end()) text = it->second;
|
|
404
|
+
if (text.empty() && page_number > 0) text = "Page " + std::to_string(page_number);
|
|
405
|
+
}
|
|
406
|
+
if (text.empty()) text = uri.rfind("mailto:", 0) == 0 ? uri.substr(7) : uri;
|
|
407
|
+
if (text.empty()) continue;
|
|
408
|
+
|
|
409
|
+
annot.replaceKey("/Contents", QPDFObjectHandle::newUnicodeString(text));
|
|
410
|
+
++described;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return described;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
namespace {
|
|
417
|
+
|
|
418
|
+
// Structure types that group other elements and must not hold content of their own (ISO 32000-1
|
|
419
|
+
// 14.8.4.2, and the table and list containers).
|
|
420
|
+
const std::set<std::string> kGrouping = {"/Document", "/Part", "/Art", "/Sect", "/Div", "/BlockQuote",
|
|
421
|
+
"/TOC", "/TOCI", "/Index", "/Table", "/THead", "/TBody",
|
|
422
|
+
"/TFoot", "/TR", "/L", "/LI"};
|
|
423
|
+
|
|
424
|
+
// Operators whose output is more than a shape: text, XObjects (an image, or a form that may hold
|
|
425
|
+
// text), inline images.
|
|
426
|
+
const std::set<std::string> kNotDecorative = {"Tj", "TJ", "'", "\"", "Do", "BI"};
|
|
427
|
+
|
|
428
|
+
// A structure type after following the RoleMap (bounded, so a cycle cannot hang the walk).
|
|
429
|
+
std::string standard_type(QPDFObjectHandle elem, QPDFObjectHandle role_map) {
|
|
430
|
+
QPDFObjectHandle s = elem.getKey("/S");
|
|
431
|
+
if (!s.isName()) return "";
|
|
432
|
+
std::string type = s.getName();
|
|
433
|
+
for (int guard = 0; guard < 16 && !kGrouping.count(type) && role_map.isDictionary(); ++guard) {
|
|
434
|
+
QPDFObjectHandle mapped = role_map.getKey(type);
|
|
435
|
+
if (!mapped.isName() || mapped.getName() == type) break;
|
|
436
|
+
type = mapped.getName();
|
|
437
|
+
}
|
|
438
|
+
return type;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Reads which marked-content sequences of one content stream paint only shapes, and - when
|
|
442
|
+
// rewriting - turns the sequences of `targets` into /Artifact BMC ... EMC.
|
|
443
|
+
class DecorationFilter : public QPDFObjectHandle::TokenFilter {
|
|
444
|
+
public:
|
|
445
|
+
DecorationFilter(QPDFObjectHandle resources, std::set<int> targets = {})
|
|
446
|
+
: resources_(std::move(resources)), targets_(std::move(targets)) {}
|
|
447
|
+
|
|
448
|
+
void handleToken(QPDFTokenizer::Token const& token) override {
|
|
449
|
+
if (token.getType() != QPDFTokenizer::tt_word) {
|
|
450
|
+
pending_.push_back(token);
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
std::string const& op = token.getValue();
|
|
454
|
+
if (op == "BDC") {
|
|
455
|
+
int mcid = pending_mcid();
|
|
456
|
+
if (mcid >= 0) {
|
|
457
|
+
if (int outer = innermost(); outer >= 0) decorative[outer] = false; // shapes around tagged content
|
|
458
|
+
decorative.try_emplace(mcid, true);
|
|
459
|
+
}
|
|
460
|
+
stack_.push_back(mcid);
|
|
461
|
+
if (mcid >= 0 && targets_.count(mcid)) {
|
|
462
|
+
pending_.clear();
|
|
463
|
+
write(" /Artifact BMC");
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
} else if (op == "BMC") {
|
|
467
|
+
stack_.push_back(-1);
|
|
468
|
+
} else if (op == "EMC") {
|
|
469
|
+
if (!stack_.empty()) stack_.pop_back();
|
|
470
|
+
} else if (kNotDecorative.count(op)) {
|
|
471
|
+
if (int mcid = innermost(); mcid >= 0) decorative[mcid] = false;
|
|
472
|
+
}
|
|
473
|
+
flush();
|
|
474
|
+
writeToken(token);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
void handleEOF() override { flush(); }
|
|
478
|
+
|
|
479
|
+
std::map<int, bool> decorative; // MCID -> paints only shapes
|
|
480
|
+
|
|
481
|
+
private:
|
|
482
|
+
int innermost() const {
|
|
483
|
+
for (auto it = stack_.rbegin(); it != stack_.rend(); ++it) {
|
|
484
|
+
if (*it >= 0) return *it;
|
|
485
|
+
}
|
|
486
|
+
return -1;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// The MCID of the BDC being read: from its inline property list, or from the named entry in the
|
|
490
|
+
// resources' /Properties.
|
|
491
|
+
int pending_mcid() {
|
|
492
|
+
std::vector<QPDFTokenizer::Token const*> meaningful;
|
|
493
|
+
for (auto const& t : pending_) {
|
|
494
|
+
if (t.getType() != QPDFTokenizer::tt_space && t.getType() != QPDFTokenizer::tt_comment) meaningful.push_back(&t);
|
|
495
|
+
}
|
|
496
|
+
for (size_t i = 0; i + 1 < meaningful.size(); ++i) {
|
|
497
|
+
if (meaningful[i]->getType() == QPDFTokenizer::tt_name && meaningful[i]->getValue() == "/MCID" &&
|
|
498
|
+
meaningful[i + 1]->getType() == QPDFTokenizer::tt_integer) {
|
|
499
|
+
// An MCID out of int range names nothing a structure element can hold: not a candidate.
|
|
500
|
+
char* end = nullptr;
|
|
501
|
+
long value = std::strtol(meaningful[i + 1]->getValue().c_str(), &end, 10);
|
|
502
|
+
return *end == '\0' && value >= 0 && value <= INT_MAX ? static_cast<int>(value) : -1;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (meaningful.size() == 2 && meaningful[1]->getType() == QPDFTokenizer::tt_name && resources_.isDictionary()) {
|
|
506
|
+
QPDFObjectHandle properties = resources_.getKey("/Properties");
|
|
507
|
+
QPDFObjectHandle list = properties.isDictionary() ? properties.getKey(meaningful[1]->getValue())
|
|
508
|
+
: QPDFObjectHandle::newNull();
|
|
509
|
+
if (list.isDictionary() && list.getKey("/MCID").isInteger()) return list.getKey("/MCID").getIntValueAsInt();
|
|
510
|
+
}
|
|
511
|
+
return -1;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
void flush() {
|
|
515
|
+
for (auto const& t : pending_) writeToken(t);
|
|
516
|
+
pending_.clear();
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
QPDFObjectHandle resources_;
|
|
520
|
+
std::set<int> targets_;
|
|
521
|
+
std::vector<QPDFTokenizer::Token> pending_;
|
|
522
|
+
std::vector<int> stack_;
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
// Runs the filter over a page or Form XObject; with a pipeline, replaces its content with the output.
|
|
526
|
+
void run_decoration_filter(QPDF& pdf, QPDFObjectHandle owner, DecorationFilter& filter, bool rewrite) {
|
|
527
|
+
QPDFPageObjectHelper helper(owner);
|
|
528
|
+
Pl_Buffer buffer("pdfua decorations");
|
|
529
|
+
helper.filterContents(&filter, rewrite ? &buffer : nullptr);
|
|
530
|
+
if (!rewrite) return;
|
|
531
|
+
auto data = buffer.getBufferSharedPointer();
|
|
532
|
+
if (owner.isStream()) {
|
|
533
|
+
owner.replaceStreamData(data, QPDFObjectHandle::newNull(), QPDFObjectHandle::newNull());
|
|
534
|
+
} else {
|
|
535
|
+
owner.replaceKey("/Contents", QPDFObjectHandle::newStream(&pdf, data));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
} // namespace
|
|
540
|
+
|
|
541
|
+
long artifact_tagged_decorations(QPDF& pdf) {
|
|
542
|
+
QPDFObjectHandle tree = struct_tree(pdf);
|
|
543
|
+
if (!tree.isDictionary()) return 0;
|
|
544
|
+
QPDFObjectHandle parent_tree = tree.getKey("/ParentTree");
|
|
545
|
+
QPDFObjectHandle role_map = tree.getKey("/RoleMap");
|
|
546
|
+
|
|
547
|
+
// Content held directly by a grouping element, per content stream that numbers it.
|
|
548
|
+
struct Candidate {
|
|
549
|
+
QPDFObjectHandle elem;
|
|
550
|
+
int mcid;
|
|
551
|
+
};
|
|
552
|
+
std::map<QPDFObjGen, QPDFObjectHandle> owners;
|
|
553
|
+
std::map<QPDFObjGen, std::vector<Candidate>> candidates;
|
|
554
|
+
each_struct_elem(pdf, [&](QPDFObjectHandle elem) {
|
|
555
|
+
if (!kGrouping.count(standard_type(elem, role_map))) return;
|
|
556
|
+
QPDFObjectHandle page = page_of(elem);
|
|
557
|
+
for (auto& kid : kids_of(elem)) {
|
|
558
|
+
if (kid.isDictionary() && kid.hasKey("/S")) continue;
|
|
559
|
+
QPDFObjectHandle mcid = mcid_value(kid);
|
|
560
|
+
QPDFObjectHandle owner = mcid_owner(kid, page);
|
|
561
|
+
if (!mcid.isInteger() || mcid.getIntValue() < 0 || !(owner.isStream() || owner.isDictionary())) continue;
|
|
562
|
+
owners[owner.getObjGen()] = owner;
|
|
563
|
+
candidates[owner.getObjGen()].push_back({elem, mcid.getIntValueAsInt()});
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
long changed = 0;
|
|
568
|
+
for (auto& [og, list] : candidates) {
|
|
569
|
+
QPDFObjectHandle owner = owners[og];
|
|
570
|
+
QPDFObjectHandle resources = QPDFPageObjectHelper(owner).getAttribute("/Resources", false);
|
|
571
|
+
DecorationFilter reader(resources);
|
|
572
|
+
run_decoration_filter(pdf, owner, reader, false);
|
|
573
|
+
|
|
574
|
+
std::set<int> targets;
|
|
575
|
+
for (auto const& c : list) {
|
|
576
|
+
auto it = reader.decorative.find(c.mcid);
|
|
577
|
+
if (it != reader.decorative.end() && it->second) targets.insert(c.mcid);
|
|
578
|
+
}
|
|
579
|
+
if (targets.empty()) continue;
|
|
580
|
+
DecorationFilter writer(resources, targets);
|
|
581
|
+
run_decoration_filter(pdf, owner, writer, true);
|
|
582
|
+
|
|
583
|
+
// The structure no longer refers to the artifacts: off the element's kids, out of the ParentTree.
|
|
584
|
+
QPDFObjectHandle entries = parent_tree_entries(pdf, parent_tree, owner);
|
|
585
|
+
for (Candidate c : list) { // a copy: the handle is modified, and handles share their object
|
|
586
|
+
if (!targets.count(c.mcid)) continue;
|
|
587
|
+
QPDFObjectHandle page = page_of(c.elem);
|
|
588
|
+
std::vector<QPDFObjectHandle> keep;
|
|
589
|
+
for (auto& kid : kids_of(c.elem)) {
|
|
590
|
+
bool is_target = !(kid.isDictionary() && kid.hasKey("/S")) && mcid_value(kid).isInteger() &&
|
|
591
|
+
mcid_value(kid).getIntValue() == c.mcid && mcid_owner(kid, page).getObjGen() == og;
|
|
592
|
+
if (!is_target) keep.push_back(kid);
|
|
593
|
+
}
|
|
594
|
+
c.elem.replaceKey("/K", QPDFObjectHandle::newArray(keep));
|
|
595
|
+
if (entries.isArray() && c.mcid < entries.getArrayNItems()) {
|
|
596
|
+
entries.setArrayItem(c.mcid, QPDFObjectHandle::newNull());
|
|
597
|
+
}
|
|
598
|
+
++changed;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return changed;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
long wrap_list_bodies(QPDF& pdf) {
|
|
605
|
+
QPDFObjectHandle tree = struct_tree(pdf);
|
|
606
|
+
if (!tree.isDictionary()) return 0;
|
|
607
|
+
QPDFObjectHandle parent_tree = tree.getKey("/ParentTree");
|
|
608
|
+
long created = 0;
|
|
609
|
+
|
|
610
|
+
std::vector<QPDFObjectHandle> items;
|
|
611
|
+
each_struct_elem(pdf, [&](QPDFObjectHandle elem) {
|
|
612
|
+
if (elem.getKey("/S").isNameAndEquals("/LI")) items.push_back(elem);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
for (auto& item : items) {
|
|
616
|
+
std::vector<QPDFObjectHandle> keep, body;
|
|
617
|
+
for (auto& kid : kids_of(item)) {
|
|
618
|
+
QPDFObjectHandle s = kid.isDictionary() ? kid.getKey("/S") : QPDFObjectHandle::newNull();
|
|
619
|
+
if (s.isNameAndEquals("/Lbl") || s.isNameAndEquals("/LBody")) {
|
|
620
|
+
keep.push_back(kid);
|
|
621
|
+
} else {
|
|
622
|
+
body.push_back(kid);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (body.empty()) continue;
|
|
626
|
+
|
|
627
|
+
QPDFObjectHandle page = page_of(item);
|
|
628
|
+
QPDFObjectHandle lbody = pdf.makeIndirectObject(QPDFObjectHandle::newDictionary());
|
|
629
|
+
lbody.replaceKey("/Type", QPDFObjectHandle::newName("/StructElem"));
|
|
630
|
+
lbody.replaceKey("/S", QPDFObjectHandle::newName("/LBody"));
|
|
631
|
+
lbody.replaceKey("/P", item);
|
|
632
|
+
if (page.isDictionary()) lbody.replaceKey("/Pg", page);
|
|
633
|
+
|
|
634
|
+
// Marked content and annotations moved into the LBody must name it as their parent.
|
|
635
|
+
bool ok = true;
|
|
636
|
+
std::vector<std::function<void()>> reparent;
|
|
637
|
+
for (auto& kid : body) {
|
|
638
|
+
if (kid.isDictionary() && kid.hasKey("/S")) {
|
|
639
|
+
reparent.push_back([kid, lbody]() mutable { kid.replaceKey("/P", lbody); });
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
if (!parent_tree.isDictionary()) {
|
|
643
|
+
ok = false;
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
bool is_objr = kid.isDictionary() && kid.getKey("/Type").isNameAndEquals("/OBJR");
|
|
647
|
+
if (is_objr) {
|
|
648
|
+
QPDFObjectHandle object = kid.getKey("/Obj");
|
|
649
|
+
QPDFObjectHandle key = object.isDictionary() ? object.getKey("/StructParent") : QPDFObjectHandle::newNull();
|
|
650
|
+
if (!key.isInteger()) {
|
|
651
|
+
ok = false;
|
|
652
|
+
break;
|
|
653
|
+
}
|
|
654
|
+
reparent.push_back([&pdf, parent_tree, key, lbody]() mutable {
|
|
655
|
+
QPDFNumberTreeObjectHelper(parent_tree, pdf).insert(key.getIntValue(), lbody);
|
|
656
|
+
});
|
|
657
|
+
continue;
|
|
658
|
+
}
|
|
659
|
+
// An MCID is numbered in its own content stream: a Form XObject's (/Stm) has its own
|
|
660
|
+
// ParentTree entry, so the page's must not be touched for it.
|
|
661
|
+
QPDFObjectHandle mcid = mcid_value(kid);
|
|
662
|
+
QPDFObjectHandle entries = parent_tree_entries(pdf, parent_tree, mcid_owner(kid, page));
|
|
663
|
+
if (!mcid.isInteger() || !entries.isArray() || mcid.getIntValue() < 0 ||
|
|
664
|
+
mcid.getIntValue() >= entries.getArrayNItems()) {
|
|
665
|
+
ok = false;
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
int index = mcid.getIntValue();
|
|
669
|
+
reparent.push_back([entries, index, lbody]() mutable { entries.setArrayItem(index, lbody); });
|
|
670
|
+
}
|
|
671
|
+
if (!ok) continue; // leave an LI we cannot rewrite consistently as it is
|
|
672
|
+
|
|
673
|
+
for (auto& apply : reparent) apply();
|
|
674
|
+
lbody.replaceKey("/K", QPDFObjectHandle::newArray(body));
|
|
675
|
+
keep.push_back(lbody);
|
|
676
|
+
item.replaceKey("/K", QPDFObjectHandle::newArray(keep));
|
|
677
|
+
++created;
|
|
678
|
+
}
|
|
679
|
+
return created;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
long count_parent_tree_mismatches(QPDF& pdf) {
|
|
683
|
+
QPDFObjectHandle tree = struct_tree(pdf);
|
|
684
|
+
if (!tree.isDictionary()) return 0;
|
|
685
|
+
QPDFObjectHandle parent_tree = tree.getKey("/ParentTree");
|
|
686
|
+
long mismatches = 0;
|
|
687
|
+
|
|
688
|
+
each_struct_elem(pdf, [&](QPDFObjectHandle elem) {
|
|
689
|
+
QPDFObjectHandle page = page_of(elem);
|
|
690
|
+
for (auto& kid : kids_of(elem)) {
|
|
691
|
+
if (kid.isDictionary() && kid.hasKey("/S")) continue;
|
|
692
|
+
QPDFObjectHandle entry;
|
|
693
|
+
if (kid.isDictionary() && kid.getKey("/Type").isNameAndEquals("/OBJR")) {
|
|
694
|
+
QPDFObjectHandle object = kid.getKey("/Obj");
|
|
695
|
+
QPDFObjectHandle key = object.isDictionary() ? object.getKey("/StructParent") : QPDFObjectHandle::newNull();
|
|
696
|
+
if (!parent_tree.isDictionary() || !key.isInteger() ||
|
|
697
|
+
!QPDFNumberTreeObjectHelper(parent_tree, pdf).findObject(key.getIntValue(), entry)) {
|
|
698
|
+
entry = QPDFObjectHandle::newNull();
|
|
699
|
+
}
|
|
700
|
+
} else {
|
|
701
|
+
QPDFObjectHandle mcid = mcid_value(kid);
|
|
702
|
+
if (!mcid.isInteger()) continue;
|
|
703
|
+
QPDFObjectHandle entries = parent_tree_entries(pdf, parent_tree, mcid_owner(kid, page));
|
|
704
|
+
long long index = mcid.getIntValue();
|
|
705
|
+
entry = entries.isArray() && index >= 0 && index < entries.getArrayNItems()
|
|
706
|
+
? entries.getArrayItem(static_cast<int>(index))
|
|
707
|
+
: QPDFObjectHandle::newNull();
|
|
708
|
+
}
|
|
709
|
+
if (!entry.isIndirect() || entry.getObjGen() != elem.getObjGen()) ++mismatches;
|
|
710
|
+
}
|
|
711
|
+
});
|
|
712
|
+
return mismatches;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
long map_nonstandard_roles(QPDF& pdf) {
|
|
716
|
+
static const std::set<std::string> standard = {
|
|
717
|
+
"/Document", "/Part", "/Art", "/Sect", "/Div", "/BlockQuote", "/Caption", "/TOC", "/TOCI",
|
|
718
|
+
"/Index", "/NonStruct", "/Private", "/P", "/H", "/H1", "/H2", "/H3", "/H4",
|
|
719
|
+
"/H5", "/H6", "/L", "/LI", "/Lbl", "/LBody", "/Table", "/TR", "/TH",
|
|
720
|
+
"/TD", "/THead", "/TBody", "/TFoot", "/Span", "/Quote", "/Note", "/Reference", "/BibEntry",
|
|
721
|
+
"/Code", "/Link", "/Annot", "/Ruby", "/RB", "/RT", "/RP", "/Warichu", "/WT",
|
|
722
|
+
"/WP", "/Figure", "/Formula", "/Form"};
|
|
723
|
+
// PDF 2.0 types and their nearest PDF 1.7 equivalent.
|
|
724
|
+
static const std::map<std::string, std::string> pdf2 = {
|
|
725
|
+
{"/Aside", "/Sect"}, {"/Strong", "/Span"}, {"/Em", "/Span"}, {"/Title", "/P"},
|
|
726
|
+
{"/Sub", "/Span"}, {"/FENote", "/Note"}, {"/DocumentFragment", "/Part"}};
|
|
727
|
+
|
|
728
|
+
QPDFObjectHandle tree = struct_tree(pdf);
|
|
729
|
+
if (!tree.isDictionary()) return 0;
|
|
730
|
+
QPDFObjectHandle role_map = tree.getKey("/RoleMap");
|
|
731
|
+
if (!role_map.isDictionary()) {
|
|
732
|
+
role_map = QPDFObjectHandle::newDictionary();
|
|
733
|
+
tree.replaceKey("/RoleMap", role_map);
|
|
734
|
+
role_map = tree.getKey("/RoleMap");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
long added = 0;
|
|
738
|
+
each_struct_elem(pdf, [&](QPDFObjectHandle elem) {
|
|
739
|
+
QPDFObjectHandle s = elem.getKey("/S");
|
|
740
|
+
if (!s.isName()) return;
|
|
741
|
+
std::string name = s.getName();
|
|
742
|
+
if (standard.count(name) || role_map.hasKey(name)) return;
|
|
743
|
+
auto it = pdf2.find(name);
|
|
744
|
+
if (it == pdf2.end()) return;
|
|
745
|
+
role_map.replaceKey(name, QPDFObjectHandle::newName(it->second));
|
|
746
|
+
++added;
|
|
747
|
+
});
|
|
748
|
+
return added;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
namespace {
|
|
752
|
+
|
|
753
|
+
bool has_alternative(QPDFObjectHandle elem) {
|
|
754
|
+
for (char const* key : {"/Alt", "/ActualText"}) {
|
|
755
|
+
QPDFObjectHandle value = elem.getKey(key);
|
|
756
|
+
if (value.isString() && !value.getUTF8Value().empty()) return true;
|
|
757
|
+
}
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
bool has_figure_below(QPDFObjectHandle elem, int depth = 0) {
|
|
762
|
+
if (depth > 256) return false;
|
|
763
|
+
for (auto& kid : kids_of(elem)) {
|
|
764
|
+
if (!kid.isDictionary() || !kid.hasKey("/S")) continue;
|
|
765
|
+
if (kid.getKey("/S").isNameAndEquals("/Figure") || has_figure_below(kid, depth + 1)) return true;
|
|
766
|
+
}
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
} // namespace
|
|
771
|
+
|
|
772
|
+
long retag_grouping_figures(QPDF& pdf) {
|
|
773
|
+
std::vector<QPDFObjectHandle> groups;
|
|
774
|
+
each_struct_elem(pdf, [&](QPDFObjectHandle elem) {
|
|
775
|
+
if (elem.getKey("/S").isNameAndEquals("/Figure") && !has_alternative(elem) && has_figure_below(elem)) {
|
|
776
|
+
groups.push_back(elem);
|
|
777
|
+
}
|
|
778
|
+
});
|
|
779
|
+
for (auto& elem : groups) elem.replaceKey("/S", QPDFObjectHandle::newName("/Div"));
|
|
780
|
+
return static_cast<long>(groups.size());
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
long count_figures_without_alt(QPDF& pdf) {
|
|
784
|
+
long count = 0;
|
|
785
|
+
each_struct_elem(pdf, [&](QPDFObjectHandle elem) {
|
|
786
|
+
if (elem.getKey("/S").isNameAndEquals("/Figure") && !has_alternative(elem)) ++count;
|
|
787
|
+
});
|
|
788
|
+
return count;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
namespace {
|
|
792
|
+
|
|
793
|
+
constexpr char kPdfuaNs[] = "http://www.aiim.org/pdfua/ns/id/";
|
|
794
|
+
constexpr char kDcNs[] = "http://purl.org/dc/elements/1.1/";
|
|
795
|
+
|
|
796
|
+
// Sets a boolean key unless it already has that value; true if it changed anything.
|
|
797
|
+
bool ensure_true(QPDFObjectHandle dict, std::string const& key) {
|
|
798
|
+
QPDFObjectHandle value = dict.getKey(key);
|
|
799
|
+
if (value.isBool() && value.getBoolValue()) return false;
|
|
800
|
+
dict.replaceKey(key, QPDFObjectHandle::newBool(true));
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// The catalog's dictionary under `key`, created if missing.
|
|
805
|
+
QPDFObjectHandle catalog_dict(QPDFObjectHandle root, std::string const& key) {
|
|
806
|
+
if (!root.getKey(key).isDictionary()) root.replaceKey(key, QPDFObjectHandle::newDictionary());
|
|
807
|
+
return root.getKey(key);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
} // namespace
|
|
811
|
+
|
|
812
|
+
namespace {
|
|
813
|
+
|
|
814
|
+
constexpr char kRdfNs[] = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
|
|
815
|
+
|
|
816
|
+
// The identification step, with the reason when the file could not be identified.
|
|
817
|
+
bool identify(QPDF& pdf, std::optional<std::string> const& title, std::optional<std::string>& unidentified) {
|
|
818
|
+
QPDFObjectHandle root = pdf.getRoot();
|
|
819
|
+
bool changed = ensure_true(catalog_dict(root, "/ViewerPreferences"), "/DisplayDocTitle");
|
|
820
|
+
|
|
821
|
+
std::string doc_title;
|
|
822
|
+
QPDFObjectHandle info = pdf.getTrailer().getKey("/Info");
|
|
823
|
+
if (title) {
|
|
824
|
+
doc_title = *title;
|
|
825
|
+
if (!info.isDictionary()) {
|
|
826
|
+
info = pdf.makeIndirectObject(QPDFObjectHandle::newDictionary());
|
|
827
|
+
pdf.getTrailer().replaceKey("/Info", info);
|
|
828
|
+
}
|
|
829
|
+
QPDFObjectHandle current = info.getKey("/Title");
|
|
830
|
+
if (!current.isString() || current.getUTF8Value() != doc_title) {
|
|
831
|
+
info.replaceKey("/Title", QPDFObjectHandle::newUnicodeString(doc_title));
|
|
832
|
+
changed = true;
|
|
833
|
+
}
|
|
834
|
+
} else if (info.isDictionary() && info.getKey("/Title").isString()) {
|
|
835
|
+
doc_title = info.getKey("/Title").getUTF8Value();
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Marked and pdfuaid both claim a tagged file - false without a structure tree.
|
|
839
|
+
if (!struct_tree(pdf).isDictionary()) {
|
|
840
|
+
unidentified = "no structure tree: the file is not tagged";
|
|
841
|
+
return changed;
|
|
842
|
+
}
|
|
843
|
+
changed = ensure_true(catalog_dict(root, "/MarkInfo"), "/Marked") || changed;
|
|
844
|
+
|
|
845
|
+
// The new Description declares every prefix it uses itself, so it reads the same whatever
|
|
846
|
+
// prefixes the packet around it binds.
|
|
847
|
+
auto description = [&](bool part, bool with_title) {
|
|
848
|
+
return std::string("<rdf:Description xmlns:rdf=\"") + kRdfNs + "\" rdf:about=\"\"" +
|
|
849
|
+
(part ? std::string(" xmlns:pdfuaid=\"") + kPdfuaNs + "\"" : "") +
|
|
850
|
+
(with_title ? std::string(" xmlns:dc=\"") + kDcNs + "\"" : "") + ">" +
|
|
851
|
+
(part ? "<pdfuaid:part>1</pdfuaid:part>" : "") +
|
|
852
|
+
(with_title ? "<dc:title><rdf:Alt><rdf:li xml:lang=\"x-default\">" + xml_escape(doc_title) +
|
|
853
|
+
"</rdf:li></rdf:Alt></dc:title>"
|
|
854
|
+
: "") +
|
|
855
|
+
"</rdf:Description>\n";
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// An existing packet keeps everything it has; only what is missing goes into one new
|
|
859
|
+
// Description, the identification and the title each checked on their own.
|
|
860
|
+
QPDFObjectHandle metadata = root.getKey("/Metadata");
|
|
861
|
+
if (metadata.isStream()) {
|
|
862
|
+
auto data = metadata.getStreamData(qpdf_dl_generalized);
|
|
863
|
+
std::string xmp(reinterpret_cast<char const*>(data->getBuffer()), data->getSize());
|
|
864
|
+
xmp::Scan packet = xmp::scan(xmp);
|
|
865
|
+
if (!packet.well_formed || packet.rdf_close == std::string::npos) {
|
|
866
|
+
// Replacing the packet would lose what it holds; leave it, and say so.
|
|
867
|
+
unidentified = "existing XMP metadata is not well-formed XML with an rdf:RDF element";
|
|
868
|
+
return changed;
|
|
869
|
+
}
|
|
870
|
+
bool add_part = !packet.has(kPdfuaNs, "part");
|
|
871
|
+
bool add_title = !doc_title.empty() && !packet.has(kDcNs, "title");
|
|
872
|
+
if (!add_part && !add_title) return changed;
|
|
873
|
+
xmp.insert(packet.rdf_close, description(add_part, add_title));
|
|
874
|
+
metadata.replaceStreamData(xmp, QPDFObjectHandle::newNull(), QPDFObjectHandle::newNull());
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
std::string xmp = "<?xpacket begin=\"\xEF\xBB\xBF\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n"
|
|
879
|
+
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"" +
|
|
880
|
+
std::string(kRdfNs) + "\">\n" + description(true, !doc_title.empty()) +
|
|
881
|
+
"</rdf:RDF></x:xmpmeta>\n<?xpacket end=\"w\"?>";
|
|
882
|
+
QPDFObjectHandle stream = QPDFObjectHandle::newStream(&pdf, xmp);
|
|
883
|
+
stream.getDict().replaceKey("/Type", QPDFObjectHandle::newName("/Metadata"));
|
|
884
|
+
stream.getDict().replaceKey("/Subtype", QPDFObjectHandle::newName("/XML"));
|
|
885
|
+
root.replaceKey("/Metadata", stream);
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
} // namespace
|
|
890
|
+
|
|
891
|
+
bool add_pdfua_identification(QPDF& pdf, std::optional<std::string> const& title) {
|
|
892
|
+
std::optional<std::string> unidentified;
|
|
893
|
+
return identify(pdf, title, unidentified);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
Report apply(QPDF& pdf, std::map<std::string, std::string> const& link_texts, std::optional<std::string> const& title) {
|
|
897
|
+
Report report;
|
|
898
|
+
report.decorations = artifact_tagged_decorations(pdf);
|
|
899
|
+
report.artifacts = mark_untagged_content_as_artifacts(pdf);
|
|
900
|
+
report.links = describe_links(pdf, link_texts);
|
|
901
|
+
report.list_bodies = wrap_list_bodies(pdf);
|
|
902
|
+
report.roles = map_nonstandard_roles(pdf);
|
|
903
|
+
report.figure_groups = retag_grouping_figures(pdf);
|
|
904
|
+
report.figures_without_alt = count_figures_without_alt(pdf);
|
|
905
|
+
report.identified = identify(pdf, title, report.unidentified_reason);
|
|
906
|
+
return report;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
} // namespace qpdf_ruby::pdfua
|