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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c89e78922fbb1a3922b13c425086212a70454a28b6f17f9f75b8fe9ab772256
4
- data.tar.gz: 3144ff7e6bae54b2b71fe80bd1b22652b5466120d03cf2fa7f7dcccab15c9d7a
3
+ metadata.gz: 1031f5b376f46ea2e57b4a06692f10fe62f081bf010eff784e7e179c3dca83d1
4
+ data.tar.gz: aa3e9a16d357779d6b51c7d66c8736641a140a180844cd3f6c5bcbb22988ce32
5
5
  SHA512:
6
- metadata.gz: 2e6736a64f275a612a85727c94ebe7e5ccde471b5024f974611b6a43ce747ee317f0544769996816f943aff50071e9fcc5eb5e92b08316af921b29cc6dea9c1d
7
- data.tar.gz: 8941eff1af71bed2d082e4eb66256d9b284800aa9cfd95100fadc3eeb4888663f647d5f939816b82fbdd6e735b479aff0724ee6fc72ba8931456c6a64c67d822
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.2.0..HEAD
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.2.0..HEAD)
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
- | Inspect links, XMP, RoleMap | `doc.links`, `doc.metadata`, `doc.role_map` |
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` |
@@ -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
- 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);
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);
@@ -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;
@@ -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 = checked_optional_string(given(values[0]));
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 = checked_optional_string(given(values[1]));
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);
@@ -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
- bool rdf_root;
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
- if (stack_.back().rdf_root) out.rdf_close = lt;
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
- std::vector<std::pair<std::string, std::string>> attributes;
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.emplace_back(attribute, value);
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
- out.properties.insert({ns, local});
232
- for (auto const& [attribute, value] : attributes) {
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
- if (!attribute_ns.empty()) out.properties.insert({attribute_ns, attribute_local});
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) return true;
242
- stack_.push_back({qname, std::move(bindings), rdf_root});
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
@@ -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
- // What the identification step needs to know about an XMP packet, read with namespace scoping:
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 element and every namespaced attribute - XMP allows a
17
- // simple property in either form.
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module QpdfRuby
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
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: (
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: qpdf_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Dieter S.