qpdf_ruby 0.2.0 โ 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 +22 -2
- data/README.md +2 -1
- data/ext/qpdf_ruby/pdfua.cpp +104 -8
- data/ext/qpdf_ruby/pdfua.hpp +16 -0
- data/ext/qpdf_ruby/qpdf_ruby.cpp +59 -2
- data/ext/qpdf_ruby/xmp_scan.cpp +85 -13
- data/ext/qpdf_ruby/xmp_scan.hpp +26 -4
- data/lib/qpdf_ruby/version.rb +1 -1
- data/sig/qpdf_ruby.rbs +4 -0
- metadata +1 -1
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,10 +6,29 @@ 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/dieter-medium/qpdf_ruby/compare/v0.
|
|
9
|
+
[unreleased]: https://github.com/dieter-medium/qpdf_ruby/compare/v0.3.0..HEAD
|
|
10
10
|
|
|
11
11
|
<!-- generated by git-cliff end -->
|
|
12
12
|
|
|
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
|
+
|
|
13
32
|
## [0.2.0] - 2026-10-02
|
|
14
33
|
|
|
15
34
|
Makes the tagged PDFs Chromium prints (`Page.printToPDF` with `generateTaggedPDF`) PDF/UA-1
|
|
@@ -68,6 +87,7 @@ as PDF/UA-1 and PAC 2024 passes without errors.
|
|
|
68
87
|
|
|
69
88
|
### ๐ Released
|
|
70
89
|
|
|
71
|
-
- [unreleased](https://github.com/dieter-medium/qpdf_ruby/compare/v0.
|
|
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)
|
|
72
92
|
- [0.2.0](https://github.com/dieter-medium/qpdf_ruby/compare/v0.1.1..v0.2.0)
|
|
73
93
|
- [0.1.1](https://github.com/dieter-medium/qpdf_ruby/compare/v0.1.0..v0.1.1)
|
data/README.md
CHANGED
|
@@ -46,7 +46,8 @@ Also:
|
|
|
46
46
|
| Count untagged content (dry run, per kind) | `doc.untagged_content` |
|
|
47
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
48
|
| Dump the structure tree as XML | `doc.show_structure` |
|
|
49
|
-
|
|
|
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` |
|
|
50
51
|
| Count ParentTree entries that do not name their content's structure element (0 = consistent) | `doc.parent_tree_mismatches` |
|
|
51
52
|
| Encrypt | `doc.encrypt(user_pw:, owner_pw:, โฆ)` |
|
|
52
53
|
| Read/write files or memory | `Document.new(path, password = nil)`, `Document.from_memory(bytes, password = nil)`, `#write(path)`, `#to_memory` |
|
data/ext/qpdf_ruby/pdfua.cpp
CHANGED
|
@@ -8,11 +8,13 @@
|
|
|
8
8
|
#include <qpdf/QPDFPageObjectHelper.hh>
|
|
9
9
|
#include <qpdf/QPDFTokenizer.hh>
|
|
10
10
|
|
|
11
|
+
#include <algorithm>
|
|
11
12
|
#include <climits>
|
|
12
13
|
#include <cstdlib>
|
|
13
14
|
#include <functional>
|
|
14
15
|
#include <memory>
|
|
15
16
|
#include <set>
|
|
17
|
+
#include <stdexcept>
|
|
16
18
|
#include <vector>
|
|
17
19
|
|
|
18
20
|
namespace qpdf_ruby::pdfua {
|
|
@@ -813,6 +815,17 @@ namespace {
|
|
|
813
815
|
|
|
814
816
|
constexpr char kRdfNs[] = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
|
|
815
817
|
|
|
818
|
+
// A new XMP packet holding `description` as the catalog's metadata stream.
|
|
819
|
+
void add_packet(QPDF& pdf, std::string const& description) {
|
|
820
|
+
std::string xmp = "<?xpacket begin=\"\xEF\xBB\xBF\" id=\"W5M0MpCehiHzreSzNTczkc9d\"?>\n"
|
|
821
|
+
"<x:xmpmeta xmlns:x=\"adobe:ns:meta/\"><rdf:RDF xmlns:rdf=\"" +
|
|
822
|
+
std::string(kRdfNs) + "\">\n" + description + "</rdf:RDF></x:xmpmeta>\n<?xpacket end=\"w\"?>";
|
|
823
|
+
QPDFObjectHandle stream = QPDFObjectHandle::newStream(&pdf, xmp);
|
|
824
|
+
stream.getDict().replaceKey("/Type", QPDFObjectHandle::newName("/Metadata"));
|
|
825
|
+
stream.getDict().replaceKey("/Subtype", QPDFObjectHandle::newName("/XML"));
|
|
826
|
+
pdf.getRoot().replaceKey("/Metadata", stream);
|
|
827
|
+
}
|
|
828
|
+
|
|
816
829
|
// The identification step, with the reason when the file could not be identified.
|
|
817
830
|
bool identify(QPDF& pdf, std::optional<std::string> const& title, std::optional<std::string>& unidentified) {
|
|
818
831
|
QPDFObjectHandle root = pdf.getRoot();
|
|
@@ -875,14 +888,7 @@ bool identify(QPDF& pdf, std::optional<std::string> const& title, std::optional<
|
|
|
875
888
|
return true;
|
|
876
889
|
}
|
|
877
890
|
|
|
878
|
-
|
|
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);
|
|
891
|
+
add_packet(pdf, description(true, !doc_title.empty()));
|
|
886
892
|
return true;
|
|
887
893
|
}
|
|
888
894
|
|
|
@@ -893,6 +899,96 @@ bool add_pdfua_identification(QPDF& pdf, std::optional<std::string> const& title
|
|
|
893
899
|
return identify(pdf, title, unidentified);
|
|
894
900
|
}
|
|
895
901
|
|
|
902
|
+
namespace {
|
|
903
|
+
|
|
904
|
+
constexpr char kXmpNs[] = "http://ns.adobe.com/xap/1.0/";
|
|
905
|
+
constexpr char kPdfNs[] = "http://ns.adobe.com/pdf/1.3/";
|
|
906
|
+
|
|
907
|
+
// Sets the information dictionary's `key` unless it already holds `value`; true if it changed.
|
|
908
|
+
bool set_info(QPDF& pdf, std::string const& key, std::string const& value) {
|
|
909
|
+
QPDFObjectHandle info = pdf.getTrailer().getKey("/Info");
|
|
910
|
+
if (!info.isDictionary()) {
|
|
911
|
+
info = pdf.makeIndirectObject(QPDFObjectHandle::newDictionary());
|
|
912
|
+
pdf.getTrailer().replaceKey("/Info", info);
|
|
913
|
+
}
|
|
914
|
+
QPDFObjectHandle current = info.getKey(key);
|
|
915
|
+
if (current.isString() && current.getUTF8Value() == value) return false;
|
|
916
|
+
info.replaceKey(key, QPDFObjectHandle::newUnicodeString(value));
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
} // namespace
|
|
921
|
+
|
|
922
|
+
DocumentInfoResult set_document_info(QPDF& pdf, std::optional<std::string> const& creator,
|
|
923
|
+
std::optional<std::string> const& producer) {
|
|
924
|
+
DocumentInfoResult result;
|
|
925
|
+
struct Wanted {
|
|
926
|
+
char const* ns;
|
|
927
|
+
char const* local;
|
|
928
|
+
char const* prefix;
|
|
929
|
+
std::string value;
|
|
930
|
+
};
|
|
931
|
+
std::vector<Wanted> wanted;
|
|
932
|
+
if (creator && !creator->empty()) {
|
|
933
|
+
result.changed = set_info(pdf, "/Creator", *creator) || result.changed;
|
|
934
|
+
wanted.push_back({kXmpNs, "CreatorTool", "xmp", *creator});
|
|
935
|
+
}
|
|
936
|
+
if (producer && !producer->empty()) {
|
|
937
|
+
result.changed = set_info(pdf, "/Producer", *producer) || result.changed;
|
|
938
|
+
wanted.push_back({kPdfNs, "Producer", "pdf", *producer});
|
|
939
|
+
}
|
|
940
|
+
if (wanted.empty()) return result;
|
|
941
|
+
|
|
942
|
+
auto description = [&](std::vector<Wanted> const& properties) {
|
|
943
|
+
std::string out = std::string("<rdf:Description xmlns:rdf=\"") + kRdfNs + "\" rdf:about=\"\"";
|
|
944
|
+
for (auto const& p : properties) out += std::string(" xmlns:") + p.prefix + "=\"" + p.ns + "\"";
|
|
945
|
+
out += ">";
|
|
946
|
+
for (auto const& p : properties) {
|
|
947
|
+
out += std::string("<") + p.prefix + ":" + p.local + ">" + xml_escape(p.value) + "</" + p.prefix + ":" + p.local + ">";
|
|
948
|
+
}
|
|
949
|
+
return out + "</rdf:Description>\n";
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
QPDFObjectHandle metadata = pdf.getRoot().getKey("/Metadata");
|
|
953
|
+
if (!metadata.isStream()) {
|
|
954
|
+
add_packet(pdf, description(wanted));
|
|
955
|
+
result.changed = true;
|
|
956
|
+
return result;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
auto data = metadata.getStreamData(qpdf_dl_generalized);
|
|
960
|
+
std::string xmp(reinterpret_cast<char const*>(data->getBuffer()), data->getSize());
|
|
961
|
+
xmp::Scan packet = xmp::scan(xmp);
|
|
962
|
+
if (!packet.well_formed || packet.rdf_close == std::string::npos) {
|
|
963
|
+
result.xmp_error = "existing XMP metadata is not well-formed XML with an rdf:RDF element";
|
|
964
|
+
return result;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// A property that already says exactly this, once, stays; any other occurrence of it goes, so
|
|
968
|
+
// the packet and the information dictionary never disagree.
|
|
969
|
+
std::vector<Wanted> missing;
|
|
970
|
+
std::vector<std::pair<size_t, size_t>> removals;
|
|
971
|
+
for (auto const& w : wanted) {
|
|
972
|
+
std::vector<xmp::Property const*> found;
|
|
973
|
+
for (auto const& p : packet.located) {
|
|
974
|
+
if (p.ns == w.ns && p.local == w.local) found.push_back(&p);
|
|
975
|
+
}
|
|
976
|
+
if (found.size() == 1 && found[0]->has_value && found[0]->value == w.value) continue;
|
|
977
|
+
for (auto const* p : found) removals.emplace_back(p->begin, p->end);
|
|
978
|
+
missing.push_back(w);
|
|
979
|
+
}
|
|
980
|
+
if (missing.empty()) return result;
|
|
981
|
+
|
|
982
|
+
std::sort(removals.begin(), removals.end(), [](auto const& a, auto const& b) { return a.first > b.first; });
|
|
983
|
+
for (auto const& [begin, end] : removals) xmp.erase(begin, end - begin);
|
|
984
|
+
size_t close = xmp::scan(xmp).rdf_close;
|
|
985
|
+
if (close == std::string::npos) throw std::logic_error("XMP packet lost its rdf:RDF element");
|
|
986
|
+
xmp.insert(close, description(missing));
|
|
987
|
+
metadata.replaceStreamData(xmp, QPDFObjectHandle::newNull(), QPDFObjectHandle::newNull());
|
|
988
|
+
result.changed = true;
|
|
989
|
+
return result;
|
|
990
|
+
}
|
|
991
|
+
|
|
896
992
|
Report apply(QPDF& pdf, std::map<std::string, std::string> const& link_texts, std::optional<std::string> const& title) {
|
|
897
993
|
Report report;
|
|
898
994
|
report.decorations = artifact_tagged_decorations(pdf);
|
data/ext/qpdf_ruby/pdfua.hpp
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
#include <map>
|
|
7
7
|
#include <optional>
|
|
8
8
|
#include <string>
|
|
9
|
+
#include <vector>
|
|
9
10
|
|
|
10
11
|
// Fixes for the PDF/UA-1 gaps of Chromium's tagged PDF (print-to-PDF with generateTaggedPDF): it
|
|
11
12
|
// writes no /Artifact sections, no LBody, no link descriptions, no XMP identification, and uses
|
|
@@ -84,6 +85,21 @@ long count_figures_without_alt(QPDF& pdf);
|
|
|
84
85
|
// unidentified_reason. Returns true if anything changed.
|
|
85
86
|
bool add_pdfua_identification(QPDF& pdf, std::optional<std::string> const& title);
|
|
86
87
|
|
|
88
|
+
// Who made the file: the information dictionary's /Creator and /Producer, and their XMP twins
|
|
89
|
+
// xmp:CreatorTool and pdf:Producer, kept in agreement. A value that is not given, or empty, is left
|
|
90
|
+
// alone. The information dictionary is always set. In XMP a property that already holds exactly
|
|
91
|
+
// that value once stays; every other occurrence of it is removed and the value written in a new
|
|
92
|
+
// rdf:Description - a packet that is not well-formed XML with an rdf:RDF element is left alone and
|
|
93
|
+
// xmp_error says so. Without a packet a new one is written. The values must be valid XML text
|
|
94
|
+
// (xmp::valid_text) - the Ruby binding checks that.
|
|
95
|
+
struct DocumentInfoResult {
|
|
96
|
+
bool changed = false;
|
|
97
|
+
std::optional<std::string> xmp_error; // why the XMP packet was not updated, if it was not
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
DocumentInfoResult set_document_info(QPDF& pdf, std::optional<std::string> const& creator,
|
|
101
|
+
std::optional<std::string> const& producer);
|
|
102
|
+
|
|
87
103
|
struct Report {
|
|
88
104
|
long decorations = 0;
|
|
89
105
|
UntaggedCounts artifacts;
|
data/ext/qpdf_ruby/qpdf_ruby.cpp
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
#include "pdfua.hpp"
|
|
6
6
|
#include "ruby_guard.hpp"
|
|
7
7
|
#include "struct_node.hpp"
|
|
8
|
+
#include "xmp_scan.hpp"
|
|
8
9
|
|
|
9
10
|
#include <qpdf/QPDF.hh>
|
|
10
11
|
#include <qpdf/QPDFObjectHandle.hh>
|
|
@@ -50,6 +51,16 @@ static VALUE checked_string(VALUE value) {
|
|
|
50
51
|
|
|
51
52
|
static VALUE checked_optional_string(VALUE value) { return NIL_P(value) ? Qnil : checked_string(value); }
|
|
52
53
|
|
|
54
|
+
// A string that goes into XMP: nil, or UTF-8 made of XML 1.0 characters - a NUL or a control
|
|
55
|
+
// character would make the packet invalid XML.
|
|
56
|
+
static VALUE checked_xmp_string(VALUE value, char const* keyword) {
|
|
57
|
+
value = checked_optional_string(value);
|
|
58
|
+
if (!NIL_P(value) && !xmp::valid_text(RSTRING_PTR(value), static_cast<size_t>(RSTRING_LEN(value)))) {
|
|
59
|
+
rb_raise(rb_eArgError, "%s must be UTF-8 text without control characters (XML 1.0)", keyword);
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
53
64
|
static int collect_link_text(VALUE key, VALUE value, VALUE pairs) {
|
|
54
65
|
rb_ary_push(pairs, rb_assoc_new(rb_obj_as_string(key), rb_obj_as_string(value)));
|
|
55
66
|
return ST_CONTINUE;
|
|
@@ -302,6 +313,27 @@ static VALUE doc_metadata(VALUE self) {
|
|
|
302
313
|
[](std::optional<std::string> const& xmp) { return xmp ? ruby_string(*xmp) : Qnil; });
|
|
303
314
|
}
|
|
304
315
|
|
|
316
|
+
// The information dictionary's string entries as {"Creator" => "...", ...}; empty without one.
|
|
317
|
+
static VALUE doc_document_info(VALUE self) {
|
|
318
|
+
DocumentHandle* h = handle_of(self);
|
|
319
|
+
return guarded_to_ruby(
|
|
320
|
+
[&] {
|
|
321
|
+
std::map<std::string, std::string> out;
|
|
322
|
+
QPDFObjectHandle info = h->qpdf().getTrailer().getKey("/Info");
|
|
323
|
+
if (info.isDictionary()) {
|
|
324
|
+
for (auto [key, value] : info.ditems()) {
|
|
325
|
+
if (value.isString()) out[key.substr(1)] = value.getUTF8Value();
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
},
|
|
330
|
+
[](std::map<std::string, std::string> const& entries) {
|
|
331
|
+
VALUE hash = rb_hash_new();
|
|
332
|
+
for (auto const& [key, value] : entries) rb_hash_aset(hash, ruby_string(key), ruby_string(value));
|
|
333
|
+
return hash;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
305
337
|
// The structure tree's RoleMap as {"Aside" => "Sect", ...}.
|
|
306
338
|
static VALUE doc_role_map(VALUE self) {
|
|
307
339
|
DocumentHandle* h = handle_of(self);
|
|
@@ -334,13 +366,36 @@ static VALUE doc_add_pdfua_identification(int argc, VALUE* argv, VALUE self) {
|
|
|
334
366
|
ID keys[1] = {rb_intern("title")};
|
|
335
367
|
VALUE values[1];
|
|
336
368
|
rb_get_kwargs(kwargs, keys, 0, 1, values);
|
|
337
|
-
VALUE title =
|
|
369
|
+
VALUE title = checked_xmp_string(given(values[0]), "title");
|
|
338
370
|
DocumentHandle* h = handle_of(self);
|
|
339
371
|
bool changed = guarded([&] { return pdfua::add_pdfua_identification(h->qpdf(), cpp_optional_string(title)); });
|
|
340
372
|
RB_GC_GUARD(title);
|
|
341
373
|
return changed ? Qtrue : Qfalse;
|
|
342
374
|
}
|
|
343
375
|
|
|
376
|
+
static VALUE doc_set_document_info(int argc, VALUE* argv, VALUE self) {
|
|
377
|
+
VALUE kwargs = Qnil;
|
|
378
|
+
rb_scan_args(argc, argv, ":", &kwargs);
|
|
379
|
+
ID keys[2] = {rb_intern("creator"), rb_intern("producer")};
|
|
380
|
+
VALUE values[2];
|
|
381
|
+
rb_get_kwargs(kwargs, keys, 0, 2, values);
|
|
382
|
+
VALUE creator = checked_xmp_string(given(values[0]), "creator");
|
|
383
|
+
VALUE producer = checked_xmp_string(given(values[1]), "producer");
|
|
384
|
+
DocumentHandle* h = handle_of(self);
|
|
385
|
+
|
|
386
|
+
VALUE out = guarded_to_ruby(
|
|
387
|
+
[&] { return pdfua::set_document_info(h->qpdf(), cpp_optional_string(creator), cpp_optional_string(producer)); },
|
|
388
|
+
[](pdfua::DocumentInfoResult const& result) {
|
|
389
|
+
VALUE hash = rb_hash_new();
|
|
390
|
+
rb_hash_aset(hash, ID2SYM(rb_intern("changed")), result.changed ? Qtrue : Qfalse);
|
|
391
|
+
rb_hash_aset(hash, ID2SYM(rb_intern("xmp_error")), result.xmp_error ? ruby_string(*result.xmp_error) : Qnil);
|
|
392
|
+
return hash;
|
|
393
|
+
});
|
|
394
|
+
RB_GC_GUARD(creator);
|
|
395
|
+
RB_GC_GUARD(producer);
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
344
399
|
static VALUE report_hash(pdfua::Report const& report);
|
|
345
400
|
|
|
346
401
|
static VALUE doc_apply_pdfua_fixes(int argc, VALUE* argv, VALUE self) {
|
|
@@ -350,7 +405,7 @@ static VALUE doc_apply_pdfua_fixes(int argc, VALUE* argv, VALUE self) {
|
|
|
350
405
|
VALUE values[2];
|
|
351
406
|
rb_get_kwargs(kwargs, keys, 0, 2, values);
|
|
352
407
|
VALUE pairs = checked_link_texts(given(values[0]));
|
|
353
|
-
VALUE title =
|
|
408
|
+
VALUE title = checked_xmp_string(given(values[1]), "title");
|
|
354
409
|
DocumentHandle* h = handle_of(self);
|
|
355
410
|
|
|
356
411
|
VALUE out = guarded_to_ruby(
|
|
@@ -436,9 +491,11 @@ RUBY_FUNC_EXPORTED void Init_qpdf_ruby(void) {
|
|
|
436
491
|
rb_define_method(rb_cDocument, "figures_without_alt", RUBY_METHOD_FUNC(doc_figures_without_alt), 0);
|
|
437
492
|
rb_define_method(rb_cDocument, "add_pdfua_identification", RUBY_METHOD_FUNC(doc_add_pdfua_identification), -1);
|
|
438
493
|
rb_define_method(rb_cDocument, "apply_pdfua_fixes", RUBY_METHOD_FUNC(doc_apply_pdfua_fixes), -1);
|
|
494
|
+
rb_define_method(rb_cDocument, "set_document_info", RUBY_METHOD_FUNC(doc_set_document_info), -1);
|
|
439
495
|
|
|
440
496
|
rb_define_method(rb_cDocument, "links", RUBY_METHOD_FUNC(doc_links), 0);
|
|
441
497
|
rb_define_method(rb_cDocument, "metadata", RUBY_METHOD_FUNC(doc_metadata), 0);
|
|
498
|
+
rb_define_method(rb_cDocument, "document_info", RUBY_METHOD_FUNC(doc_document_info), 0);
|
|
442
499
|
rb_define_method(rb_cDocument, "role_map", RUBY_METHOD_FUNC(doc_role_map), 0);
|
|
443
500
|
|
|
444
501
|
rb_define_method(rb_cDocument, "encrypt", RUBY_METHOD_FUNC(doc_encrypt), -1);
|
data/ext/qpdf_ruby/xmp_scan.cpp
CHANGED
|
@@ -73,10 +73,18 @@ bool decode(std::string const& raw, std::string& out) {
|
|
|
73
73
|
|
|
74
74
|
using Bindings = std::map<std::string, std::string>; // prefix ("" = default) -> namespace URI
|
|
75
75
|
|
|
76
|
+
// Where an element sits: the first rdf:RDF, an rdf:Description directly under it, a property of
|
|
77
|
+
// that Description, or anything else.
|
|
78
|
+
enum class Kind { Other, RdfRoot, Description, Property };
|
|
79
|
+
|
|
76
80
|
struct Element {
|
|
77
81
|
std::string qname;
|
|
78
82
|
Bindings bindings;
|
|
79
|
-
|
|
83
|
+
Kind kind;
|
|
84
|
+
size_t start = 0; // the "<" of the start tag
|
|
85
|
+
size_t content_start = 0; // just after its ">"
|
|
86
|
+
bool simple = true; // no child element or CDATA inside
|
|
87
|
+
std::string ns, local;
|
|
80
88
|
};
|
|
81
89
|
|
|
82
90
|
// The namespace of a qualified name under `bindings`; false for an unbound prefix.
|
|
@@ -143,7 +151,7 @@ class Scanner {
|
|
|
143
151
|
if (lt == std::string::npos) return true;
|
|
144
152
|
pos_ = lt;
|
|
145
153
|
bool ok = starts("<!--") ? skip_past("-->")
|
|
146
|
-
: starts("<![CDATA[") ? !stack_.empty() && skip_past("]]>")
|
|
154
|
+
: starts("<![CDATA[") ? !stack_.empty() && (stack_.back().simple = false, skip_past("]]>"))
|
|
147
155
|
: starts("<?") ? skip_past("?>")
|
|
148
156
|
: starts("<!DOCTYPE") ? doctype()
|
|
149
157
|
: starts("</") ? end_tag(out)
|
|
@@ -175,18 +183,31 @@ class Scanner {
|
|
|
175
183
|
spaces();
|
|
176
184
|
if (pos_ >= xml_.size() || xml_[pos_] != '>' || stack_.empty() || stack_.back().qname != qname) return false;
|
|
177
185
|
++pos_;
|
|
178
|
-
|
|
186
|
+
Element const& closed = stack_.back();
|
|
187
|
+
if (closed.kind == Kind::RdfRoot) out.rdf_close = lt;
|
|
188
|
+
if (closed.kind == Kind::Property) {
|
|
189
|
+
Property property{closed.ns, closed.local, closed.start, pos_, "", false};
|
|
190
|
+
if (closed.simple) property.has_value = decode(xml_.substr(closed.content_start, lt - closed.content_start), property.value);
|
|
191
|
+
if (!property.has_value) property.value.clear();
|
|
192
|
+
out.located.push_back(std::move(property));
|
|
193
|
+
}
|
|
179
194
|
stack_.pop_back();
|
|
180
195
|
return true;
|
|
181
196
|
}
|
|
182
197
|
|
|
183
198
|
bool start_tag(Scan& out) {
|
|
199
|
+
size_t lt = pos_;
|
|
200
|
+
if (!stack_.empty()) stack_.back().simple = false;
|
|
184
201
|
++pos_;
|
|
185
202
|
std::string qname = name();
|
|
186
203
|
if (qname.empty()) return false;
|
|
187
204
|
if (stack_.empty() && seen_root_) return false; // a second root element
|
|
188
205
|
|
|
189
|
-
|
|
206
|
+
struct Attribute {
|
|
207
|
+
std::string qname, value;
|
|
208
|
+
size_t begin, end;
|
|
209
|
+
};
|
|
210
|
+
std::vector<Attribute> attributes;
|
|
190
211
|
Bindings bindings = stack_.empty() ? Bindings{} : stack_.back().bindings;
|
|
191
212
|
bool self_closing = false;
|
|
192
213
|
while (true) {
|
|
@@ -203,6 +224,7 @@ class Scanner {
|
|
|
203
224
|
break;
|
|
204
225
|
}
|
|
205
226
|
if (pos_ == before) return false; // attributes must be separated by white space
|
|
227
|
+
size_t attribute_begin = before;
|
|
206
228
|
std::string attribute = name();
|
|
207
229
|
spaces();
|
|
208
230
|
if (attribute.empty() || pos_ >= xml_.size() || xml_[pos_] != '=') return false;
|
|
@@ -222,24 +244,38 @@ class Scanner {
|
|
|
222
244
|
if (attribute.size() == 6 || value.empty()) return false;
|
|
223
245
|
bindings[attribute.substr(6)] = value;
|
|
224
246
|
} else {
|
|
225
|
-
attributes.
|
|
247
|
+
attributes.push_back({attribute, value, attribute_begin, pos_});
|
|
226
248
|
}
|
|
227
249
|
}
|
|
228
250
|
|
|
229
251
|
std::string ns, local;
|
|
230
252
|
if (!resolve(qname, bindings, false, ns, local)) return false;
|
|
231
|
-
|
|
232
|
-
|
|
253
|
+
Kind parent = stack_.empty() ? Kind::Other : stack_.back().kind;
|
|
254
|
+
Kind kind = Kind::Other;
|
|
255
|
+
if (ns == kRdfNs && local == "RDF" && !seen_rdf_) {
|
|
256
|
+
kind = Kind::RdfRoot;
|
|
257
|
+
seen_rdf_ = true;
|
|
258
|
+
} else if (parent == Kind::RdfRoot && ns == kRdfNs && local == "Description") {
|
|
259
|
+
kind = Kind::Description;
|
|
260
|
+
} else if (parent == Kind::Description) {
|
|
261
|
+
kind = Kind::Property;
|
|
262
|
+
out.properties.insert({ns, local});
|
|
263
|
+
}
|
|
264
|
+
for (auto const& attribute : attributes) {
|
|
233
265
|
std::string attribute_ns, attribute_local;
|
|
234
|
-
if (!resolve(attribute, bindings, true, attribute_ns, attribute_local)) return false;
|
|
235
|
-
|
|
266
|
+
if (!resolve(attribute.qname, bindings, true, attribute_ns, attribute_local)) return false;
|
|
267
|
+
bool property = kind == Kind::Description && !attribute_ns.empty() && attribute_ns != kRdfNs && attribute_ns != kXmlNs;
|
|
268
|
+
if (!property) continue;
|
|
269
|
+
out.properties.insert({attribute_ns, attribute_local});
|
|
270
|
+
out.located.push_back({attribute_ns, attribute_local, attribute.begin, attribute.end, attribute.value, true});
|
|
236
271
|
}
|
|
237
272
|
|
|
238
|
-
bool rdf_root = ns == kRdfNs && local == "RDF" && !seen_rdf_;
|
|
239
|
-
if (rdf_root) seen_rdf_ = true;
|
|
240
273
|
if (stack_.empty()) seen_root_ = true;
|
|
241
|
-
if (self_closing)
|
|
242
|
-
|
|
274
|
+
if (self_closing) {
|
|
275
|
+
if (kind == Kind::Property) out.located.push_back({ns, local, lt, pos_, "", true});
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
stack_.push_back({qname, std::move(bindings), kind, lt, pos_, true, ns, local});
|
|
243
279
|
return true;
|
|
244
280
|
}
|
|
245
281
|
|
|
@@ -254,4 +290,40 @@ class Scanner {
|
|
|
254
290
|
|
|
255
291
|
Scan scan(std::string const& xml) { return Scanner(xml).run(); }
|
|
256
292
|
|
|
293
|
+
bool valid_text(char const* text, size_t size) noexcept {
|
|
294
|
+
auto const* bytes = reinterpret_cast<unsigned char const*>(text);
|
|
295
|
+
for (size_t i = 0; i < size;) {
|
|
296
|
+
unsigned char lead = bytes[i];
|
|
297
|
+
unsigned long cp;
|
|
298
|
+
size_t length;
|
|
299
|
+
if (lead < 0x80) {
|
|
300
|
+
cp = lead;
|
|
301
|
+
length = 1;
|
|
302
|
+
} else if ((lead & 0xE0) == 0xC0) {
|
|
303
|
+
cp = lead & 0x1F;
|
|
304
|
+
length = 2;
|
|
305
|
+
} else if ((lead & 0xF0) == 0xE0) {
|
|
306
|
+
cp = lead & 0x0F;
|
|
307
|
+
length = 3;
|
|
308
|
+
} else if ((lead & 0xF8) == 0xF0) {
|
|
309
|
+
cp = lead & 0x07;
|
|
310
|
+
length = 4;
|
|
311
|
+
} else {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
if (i + length > size) return false;
|
|
315
|
+
for (size_t k = 1; k < length; ++k) {
|
|
316
|
+
if ((bytes[i + k] & 0xC0) != 0x80) return false;
|
|
317
|
+
cp = (cp << 6) | (bytes[i + k] & 0x3F);
|
|
318
|
+
}
|
|
319
|
+
static constexpr unsigned long kMinimum[] = {0, 0, 0x80, 0x800, 0x10000};
|
|
320
|
+
if (cp < kMinimum[length]) return false; // an overlong form
|
|
321
|
+
bool allowed = cp == 0x9 || cp == 0xA || cp == 0xD || (cp >= 0x20 && cp <= 0xD7FF) ||
|
|
322
|
+
(cp >= 0xE000 && cp <= 0xFFFD) || (cp >= 0x10000 && cp <= 0x10FFFF);
|
|
323
|
+
if (!allowed) return false;
|
|
324
|
+
i += length;
|
|
325
|
+
}
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
|
|
257
329
|
} // namespace qpdf_ruby::xmp
|
data/ext/qpdf_ruby/xmp_scan.hpp
CHANGED
|
@@ -3,19 +3,36 @@
|
|
|
3
3
|
#include <set>
|
|
4
4
|
#include <string>
|
|
5
5
|
#include <utility>
|
|
6
|
+
#include <vector>
|
|
6
7
|
|
|
7
8
|
namespace qpdf_ruby::xmp {
|
|
8
9
|
|
|
9
|
-
//
|
|
10
|
+
// A property of the document - a child element or a namespaced attribute of an rdf:Description
|
|
11
|
+
// directly under the packet's first rdf:RDF - with where it sits in the packet.
|
|
12
|
+
struct Property {
|
|
13
|
+
std::string ns;
|
|
14
|
+
std::string local;
|
|
15
|
+
// [begin, end) of the whole element, or of the attribute with the white space before it.
|
|
16
|
+
size_t begin = 0;
|
|
17
|
+
size_t end = 0;
|
|
18
|
+
// The text value, references resolved; empty for an element with child elements, CDATA or a
|
|
19
|
+
// reference the scanner does not know (has_value false).
|
|
20
|
+
std::string value;
|
|
21
|
+
bool has_value = false;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// What the metadata steps need to know about an XMP packet, read with namespace scoping:
|
|
10
25
|
// comments, CDATA sections, processing instructions and a DOCTYPE are skipped, so a property that
|
|
11
|
-
// is only mentioned in one of them does not count
|
|
26
|
+
// is only mentioned in one of them does not count, and neither does an element of the same name
|
|
27
|
+
// anywhere but on the document's own rdf:Description.
|
|
12
28
|
struct Scan {
|
|
13
29
|
// Every tag closed in order, one root element, every prefix bound, attributes quoted and
|
|
14
30
|
// separated. Not a validating parser: names and text outside the root are not checked.
|
|
15
31
|
bool well_formed = false;
|
|
16
|
-
// (namespace URI, local name) of every
|
|
17
|
-
//
|
|
32
|
+
// (namespace URI, local name) of every property of the document - XMP allows a simple property
|
|
33
|
+
// as an element or as an attribute.
|
|
18
34
|
std::set<std::pair<std::string, std::string>> properties;
|
|
35
|
+
std::vector<Property> located;
|
|
19
36
|
// Offset of the "<" of the end tag of the outermost rdf:RDF element; npos without one.
|
|
20
37
|
size_t rdf_close = std::string::npos;
|
|
21
38
|
|
|
@@ -24,4 +41,9 @@ struct Scan {
|
|
|
24
41
|
|
|
25
42
|
Scan scan(std::string const& xml);
|
|
26
43
|
|
|
44
|
+
// True if `size` bytes at `text` are UTF-8 made only of XML 1.0 characters (no NUL, no control
|
|
45
|
+
// character but tab, line feed and carriage return, no surrogate, no U+FFFE/U+FFFF) - what may go
|
|
46
|
+
// into an XMP packet. Allocates nothing and never throws.
|
|
47
|
+
bool valid_text(char const* text, size_t size) noexcept;
|
|
48
|
+
|
|
27
49
|
} // namespace qpdf_ruby::xmp
|
data/lib/qpdf_ruby/version.rb
CHANGED
data/sig/qpdf_ruby.rbs
CHANGED
|
@@ -33,6 +33,8 @@ module QpdfRuby
|
|
|
33
33
|
unidentified_reason: String?
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
type document_info_result = { changed: bool, xmp_error: String? }
|
|
37
|
+
|
|
36
38
|
type link = { page: Integer, uri: String?, contents: String? }
|
|
37
39
|
|
|
38
40
|
def self.from_memory: (String data, ?String? password) -> Document
|
|
@@ -58,9 +60,11 @@ module QpdfRuby
|
|
|
58
60
|
def retag_grouping_figures: () -> Integer
|
|
59
61
|
def figures_without_alt: () -> Integer
|
|
60
62
|
def add_pdfua_identification: (?title: String?) -> bool
|
|
63
|
+
def set_document_info: (?creator: String?, ?producer: String?) -> document_info_result
|
|
61
64
|
|
|
62
65
|
def links: () -> Array[link]
|
|
63
66
|
def metadata: () -> String?
|
|
67
|
+
def document_info: () -> Hash[String, String]
|
|
64
68
|
def role_map: () -> Hash[String, String]
|
|
65
69
|
|
|
66
70
|
def encrypt: (
|