qpdf-compress 0.6.0 → 0.6.2

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.
package/src/qpdf_addon.cc CHANGED
@@ -1,12 +1,19 @@
1
1
  #include <napi.h>
2
2
 
3
+ #include <atomic>
3
4
  #include <cerrno>
4
5
  #include <cstdio>
5
6
  #include <cstdlib>
6
7
  #include <cstring>
7
- #include <filesystem>
8
8
  #include <memory>
9
9
  #include <string>
10
+ #ifdef _WIN32
11
+ #include <io.h>
12
+ #define F_OK 0
13
+ #define access _access
14
+ #else
15
+ #include <unistd.h>
16
+ #endif
10
17
  #include <vector>
11
18
 
12
19
  #include <qpdf/Pl_Flate.hh>
@@ -23,27 +30,45 @@
23
30
 
24
31
  static std::string writeToFile(const std::string &path, const uint8_t *data,
25
32
  size_t size) {
26
- auto closer = [](FILE *fp) {
27
- if (fp)
28
- fclose(fp);
29
- };
30
- std::unique_ptr<FILE, decltype(closer)> f(fopen(path.c_str(), "wb"), closer);
33
+ FILE *f = fopen(path.c_str(), "wb");
31
34
  if (!f) {
32
- auto parentDir = std::filesystem::path(path).parent_path();
33
- if (!parentDir.empty() && !std::filesystem::is_directory(parentDir))
34
- return "Parent directory does not exist: " + parentDir.string();
35
+ auto slash = path.rfind('/');
36
+ if (slash != std::string::npos) {
37
+ auto parentDir = path.substr(0, slash);
38
+ if (access(parentDir.c_str(), F_OK) != 0)
39
+ return "Parent directory does not exist: " + parentDir;
40
+ }
35
41
  return "Failed to open output file: " + path + " (" +
36
42
  std::string(std::strerror(errno)) + ")";
37
43
  }
38
- if (fwrite(data, 1, size, f.get()) != size)
44
+ size_t written = fwrite(data, 1, size, f);
45
+ int closeErr = fclose(f);
46
+ if (written != size)
39
47
  return "Failed to write output file: " + path + " (" +
40
48
  std::string(std::strerror(errno)) + ")";
41
- if (fflush(f.get()) != 0)
42
- return "Failed to flush output file: " + path + " (" +
49
+ if (closeErr != 0)
50
+ return "Failed to close output file: " + path + " (" +
43
51
  std::string(std::strerror(errno)) + ")";
44
52
  return {};
45
53
  }
46
54
 
55
+ // ---------------------------------------------------------------------------
56
+ // Per-environment alive flag for worker thread safety.
57
+ // When a Node.js worker thread is terminated, the V8 isolate tears down but
58
+ // libuv async callbacks (OnOK/OnError) may still fire. This flag lets workers
59
+ // bail out before touching V8 handles on a torn-down isolate.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ struct AddonData {
63
+ std::shared_ptr<std::atomic<bool>> envAlive =
64
+ std::make_shared<std::atomic<bool>>(true);
65
+ };
66
+
67
+ static std::shared_ptr<std::atomic<bool>> GetEnvAlive(Napi::Env env) {
68
+ auto *data = env.GetInstanceData<AddonData>();
69
+ return data ? data->envAlive : nullptr;
70
+ }
71
+
47
72
  // ---------------------------------------------------------------------------
48
73
  // CompressWorker — async PDF compression
49
74
  // ---------------------------------------------------------------------------
@@ -54,74 +79,98 @@ public:
54
79
  CompressWorker(Napi::Env env, std::vector<uint8_t> data, bool lossy,
55
80
  bool stripMeta, std::string outputPath)
56
81
  : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)),
57
- bufferData_(std::move(data)), lossy_(lossy), stripMeta_(stripMeta),
58
- useFile_(false), outputPath_(std::move(outputPath)) {}
82
+ envAlive_(GetEnvAlive(env)), bufferData_(std::move(data)),
83
+ lossy_(lossy), stripMeta_(stripMeta), useFile_(false),
84
+ outputPath_(std::move(outputPath)) {}
59
85
 
60
86
  // file path variant
61
87
  CompressWorker(Napi::Env env, std::string path, bool lossy, bool stripMeta,
62
88
  std::string outputPath)
63
89
  : Napi::AsyncWorker(env), deferred_(Napi::Promise::Deferred::New(env)),
64
- filePath_(std::move(path)), lossy_(lossy), stripMeta_(stripMeta),
65
- useFile_(true), outputPath_(std::move(outputPath)) {}
90
+ envAlive_(GetEnvAlive(env)), filePath_(std::move(path)), lossy_(lossy),
91
+ stripMeta_(stripMeta), useFile_(true),
92
+ outputPath_(std::move(outputPath)) {}
66
93
 
67
94
  Napi::Promise Promise() { return deferred_.Promise(); }
68
95
 
96
+ // guard against worker-thread teardown: if the env is being destroyed
97
+ // (e.g. worker.terminate()), skip all V8 API calls — the JS promise will
98
+ // never settle, but there is no listener for the result anyway.
99
+ void OnWorkComplete(Napi::Env env, napi_status status) override {
100
+ if (envAlive_ && !envAlive_->load())
101
+ return;
102
+
103
+ // probe whether V8 is still accessible (raw C call, no throw on failure)
104
+ napi_handle_scope scope = nullptr;
105
+ if (napi_open_handle_scope(env, &scope) != napi_ok)
106
+ return;
107
+ napi_close_handle_scope(env, scope);
108
+
109
+ try {
110
+ Napi::AsyncWorker::OnWorkComplete(env, status);
111
+ } catch (const Napi::Error &) {
112
+ // env tore down between probe and base class call
113
+ }
114
+ }
115
+
69
116
  protected:
70
117
  void Execute() override {
118
+ // heap-allocate QPDF so we control destruction order — if processFile/
119
+ // processMemoryFile throws, the QPDF destructor may also throw (broken
120
+ // internal state), which during stack unwinding would call std::terminate.
121
+ auto qpdf = std::make_unique<QPDF>();
71
122
  try {
72
- QPDF qpdf;
73
- qpdf.setAttemptRecovery(true);
123
+ qpdf->setAttemptRecovery(true);
124
+ qpdf->setSuppressWarnings(true);
74
125
 
75
126
  if (useFile_) {
76
- if (!std::filesystem::exists(filePath_)) {
127
+ if (access(filePath_.c_str(), F_OK) != 0) {
77
128
  SetError("Input file not found: " + filePath_);
78
129
  return;
79
130
  }
80
- qpdf.processFile(filePath_.c_str());
131
+ qpdf->processFile(filePath_.c_str());
81
132
  } else {
82
- // validate PDF header to prevent QPDF from aborting on garbage input
83
- if (bufferData_.size() < 5 ||
84
- memcmp(bufferData_.data(), "%PDF-", 5) != 0) {
85
- SetError("Input is not a valid PDF (missing %PDF- header)");
86
- return;
87
- }
88
- qpdf.processMemoryFile(
133
+ qpdf->processMemoryFile(
89
134
  "input.pdf", reinterpret_cast<const char *>(bufferData_.data()),
90
135
  bufferData_.size());
91
136
  }
92
137
 
93
- deduplicateImages(qpdf);
94
- convertGrayscaleImages(qpdf);
95
- convertBitonalImages(qpdf);
96
- flattenPageTree(qpdf);
138
+ // --- structural simplification ---
139
+ flattenPageTree(*qpdf);
140
+ flattenForms(*qpdf);
141
+ stripDocumentOverhead(*qpdf);
142
+
143
+ // --- image optimization ---
144
+ deduplicateImages(*qpdf);
145
+ optimizeColorSpaces(*qpdf);
97
146
 
98
147
  if (lossy_) {
99
- // lossy: re-encode high-quality JPEGs at lower quality
100
148
  CompressOptions opts;
101
149
  opts.skipThreshold = 65;
102
150
  opts.targetQuality = 75;
103
- optimizeImages(qpdf, opts);
104
- downscaleImages(qpdf, 72, 75);
151
+ optimizeImages(*qpdf, opts);
152
+ downscaleImages(*qpdf, 72, 75);
105
153
  }
106
154
 
107
- // lossless Huffman optimization for all existing JPEGs
108
- optimizeExistingJpegs(qpdf);
109
- optimizeSoftMasks(qpdf);
110
- removeUnusedFonts(qpdf);
111
- subsetFonts(qpdf);
112
- stripIccProfiles(qpdf);
113
- flattenForms(qpdf);
114
- coalesceContentStreams(qpdf);
115
- minifyContentStreams(qpdf);
116
- deduplicateStreams(qpdf);
117
- stripEmbeddedFiles(qpdf);
118
- stripJavaScript(qpdf);
155
+ optimizeExistingJpegs(*qpdf);
156
+ optimizeSoftMasks(*qpdf);
157
+
158
+ // --- font optimization ---
159
+ optimizeFonts(*qpdf);
160
+
161
+ // --- cleanup ---
162
+ removeUnusedResources(*qpdf);
163
+ coalesceContentStreams(*qpdf);
164
+ minifyContentStreams(*qpdf);
165
+ deduplicateStreams(*qpdf);
166
+ stripEmbeddedFiles(*qpdf);
167
+ stripJavaScript(*qpdf);
119
168
  if (stripMeta_)
120
- stripMetadata(qpdf);
169
+ stripMetadata(*qpdf);
121
170
 
122
171
  Pl_Flate::setCompressionLevel(9);
123
172
 
124
- QPDFWriter writer(qpdf);
173
+ QPDFWriter writer(*qpdf);
125
174
  writer.setOutputMemory();
126
175
  writer.setLinearization(false);
127
176
  writer.setStreamDataMode(qpdf_s_compress);
@@ -134,26 +183,50 @@ protected:
134
183
  writer.setPreserveUnreferencedObjects(false);
135
184
  writer.write();
136
185
 
137
- auto buf = writer.getBufferSharedPointer();
138
- result_.assign(buf->getBuffer(), buf->getBuffer() + buf->getSize());
186
+ writerBuf_ = writer.getBufferSharedPointer();
187
+
188
+ // release QPDF before file I/O — no longer needed
189
+ qpdf.reset();
139
190
 
140
191
  if (!outputPath_.empty()) {
141
- auto err = writeToFile(outputPath_, result_.data(), result_.size());
192
+ auto err = writeToFile(outputPath_, writerBuf_->getBuffer(),
193
+ writerBuf_->getSize());
142
194
  if (!err.empty()) {
143
195
  SetError(err);
144
196
  return;
145
197
  }
146
- result_.clear();
198
+ writerBuf_.reset();
147
199
  }
148
200
  } catch (std::exception &e) {
201
+ // destroy QPDF inside the catch so its destructor can't cause
202
+ // std::terminate via a double exception during stack unwinding
203
+ try {
204
+ qpdf.reset();
205
+ } catch (...) {
206
+ }
149
207
  SetError(e.what());
208
+ } catch (...) {
209
+ // on macOS/libc++, catch(std::exception&) may fail to match QPDFExc
210
+ // due to typeinfo visibility mismatch between the static library and
211
+ // our addon compiled with -fvisibility=hidden
212
+ try {
213
+ qpdf.reset();
214
+ } catch (...) {
215
+ }
216
+ SetError("PDF compression failed");
150
217
  }
151
218
  }
152
219
 
153
220
  void OnOK() override {
154
221
  if (outputPath_.empty()) {
155
- deferred_.Resolve(
156
- Napi::Buffer<uint8_t>::Copy(Env(), result_.data(), result_.size()));
222
+ // zero-copy: transfer QPDFWriter's buffer directly to JS, preventing
223
+ // a redundant full-copy of the compressed PDF
224
+ auto *prevent_copy = new std::shared_ptr<Buffer>(std::move(writerBuf_));
225
+ deferred_.Resolve(Napi::Buffer<uint8_t>::New(
226
+ Env(), prevent_copy->get()->getBuffer(),
227
+ prevent_copy->get()->getSize(),
228
+ [](Napi::Env, uint8_t *, std::shared_ptr<Buffer> *p) { delete p; },
229
+ prevent_copy));
157
230
  } else {
158
231
  deferred_.Resolve(Env().Undefined());
159
232
  }
@@ -165,13 +238,14 @@ protected:
165
238
 
166
239
  private:
167
240
  Napi::Promise::Deferred deferred_;
241
+ std::shared_ptr<std::atomic<bool>> envAlive_;
168
242
  std::vector<uint8_t> bufferData_;
169
243
  std::string filePath_;
170
244
  bool lossy_;
171
245
  bool stripMeta_;
172
246
  bool useFile_;
173
247
  std::string outputPath_;
174
- std::vector<uint8_t> result_;
248
+ std::shared_ptr<Buffer> writerBuf_;
175
249
  };
176
250
 
177
251
  // ---------------------------------------------------------------------------
@@ -231,6 +305,14 @@ static Napi::Value Compress(const Napi::CallbackInfo &info) {
231
305
  // ---------------------------------------------------------------------------
232
306
 
233
307
  static Napi::Object Init(Napi::Env env, Napi::Object exports) {
308
+ auto *addonData = new AddonData();
309
+ env.SetInstanceData(addonData);
310
+ auto envAlive = addonData->envAlive;
311
+ env.AddCleanupHook([addonData]() {
312
+ addonData->envAlive->store(false);
313
+ delete addonData;
314
+ });
315
+
234
316
  exports.Set("compress", Napi::Function::New(env, Compress));
235
317
  return exports;
236
318
  }
package/src/strip.cc ADDED
@@ -0,0 +1,391 @@
1
+ #include "strip.h"
2
+
3
+ #include <set>
4
+ #include <string>
5
+
6
+ #include <qpdf/QPDFObjectHandle.hh>
7
+ #include <qpdf/QPDFPageDocumentHelper.hh>
8
+ #include <qpdf/QPDFPageObjectHelper.hh>
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Metadata stripping
12
+ // ---------------------------------------------------------------------------
13
+
14
+ void stripMetadata(QPDF &qpdf) {
15
+ auto root = qpdf.getRoot();
16
+
17
+ // remove XMP metadata stream
18
+ if (root.hasKey("/Metadata"))
19
+ root.removeKey("/Metadata");
20
+
21
+ // remove document info dictionary
22
+ auto trailer = qpdf.getTrailer();
23
+ if (trailer.hasKey("/Info"))
24
+ trailer.removeKey("/Info");
25
+
26
+ // remove page-level metadata and PieceInfo
27
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
28
+ auto pageObj = page.getObjectHandle();
29
+ if (pageObj.hasKey("/Metadata"))
30
+ pageObj.removeKey("/Metadata");
31
+ if (pageObj.hasKey("/PieceInfo"))
32
+ pageObj.removeKey("/PieceInfo");
33
+ }
34
+
35
+ // remove embedded thumbnails
36
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
37
+ auto pageObj = page.getObjectHandle();
38
+ if (pageObj.hasKey("/Thumb"))
39
+ pageObj.removeKey("/Thumb");
40
+ }
41
+
42
+ // remove MarkInfo and page labels (optional metadata)
43
+ if (root.hasKey("/MarkInfo"))
44
+ root.removeKey("/MarkInfo");
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Embedded file stripping — remove /EmbeddedFiles from the name tree
49
+ // ---------------------------------------------------------------------------
50
+
51
+ void stripEmbeddedFiles(QPDF &qpdf) {
52
+ auto root = qpdf.getRoot();
53
+ if (!root.hasKey("/Names"))
54
+ return;
55
+
56
+ auto names = root.getKey("/Names");
57
+ if (!names.isDictionary())
58
+ return;
59
+
60
+ if (names.hasKey("/EmbeddedFiles"))
61
+ names.removeKey("/EmbeddedFiles");
62
+
63
+ // if /Names is now empty, remove it too
64
+ if (names.getKeys().empty())
65
+ root.removeKey("/Names");
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // JavaScript and action removal — strip JS, open actions, and additional
70
+ // actions from the catalog and all pages
71
+ // ---------------------------------------------------------------------------
72
+
73
+ void stripJavaScript(QPDF &qpdf) {
74
+ auto root = qpdf.getRoot();
75
+
76
+ // remove document-level open action only if it is JavaScript
77
+ if (root.hasKey("/OpenAction")) {
78
+ auto action = root.getKey("/OpenAction");
79
+ if (action.isDictionary()) {
80
+ auto s = action.getKey("/S");
81
+ if (s.isName() && s.getName() == "/JavaScript")
82
+ root.removeKey("/OpenAction");
83
+ }
84
+ }
85
+
86
+ // remove document-level additional actions
87
+ if (root.hasKey("/AA"))
88
+ root.removeKey("/AA");
89
+
90
+ // remove /JavaScript name tree
91
+ if (root.hasKey("/Names")) {
92
+ auto names = root.getKey("/Names");
93
+ if (names.isDictionary() && names.hasKey("/JavaScript"))
94
+ names.removeKey("/JavaScript");
95
+ }
96
+
97
+ // remove page-level actions and annotations with JS
98
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
99
+ auto pageObj = page.getObjectHandle();
100
+
101
+ if (pageObj.hasKey("/AA"))
102
+ pageObj.removeKey("/AA");
103
+
104
+ // strip JS actions from annotations
105
+ if (!pageObj.hasKey("/Annots"))
106
+ continue;
107
+
108
+ auto annots = pageObj.getKey("/Annots");
109
+ if (!annots.isArray())
110
+ continue;
111
+
112
+ for (int i = 0; i < annots.getArrayNItems(); ++i) {
113
+ auto annot = annots.getArrayItem(i);
114
+ if (!annot.isDictionary())
115
+ continue;
116
+ if (annot.hasKey("/AA"))
117
+ annot.removeKey("/AA");
118
+ if (annot.hasKey("/A")) {
119
+ auto action = annot.getKey("/A");
120
+ if (action.isDictionary()) {
121
+ auto s = action.getKey("/S");
122
+ if (s.isName() && s.getName() == "/JavaScript")
123
+ annot.removeKey("/A");
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // Document overhead stripping — remove structural and navigational data
132
+ // that does not affect visual rendering
133
+ // ---------------------------------------------------------------------------
134
+
135
+ void stripDocumentOverhead(QPDF &qpdf) {
136
+ auto root = qpdf.getRoot();
137
+
138
+ // remove structure tree (tagged PDF accessibility info)
139
+ if (root.hasKey("/StructTreeRoot"))
140
+ root.removeKey("/StructTreeRoot");
141
+
142
+ // remove output intents (PDF/A color management metadata)
143
+ if (root.hasKey("/OutputIntents"))
144
+ root.removeKey("/OutputIntents");
145
+
146
+ // remove viewer preferences
147
+ if (root.hasKey("/ViewerPreferences"))
148
+ root.removeKey("/ViewerPreferences");
149
+
150
+ // remove article threads
151
+ if (root.hasKey("/Threads"))
152
+ root.removeKey("/Threads");
153
+
154
+ // remove web capture info
155
+ if (root.hasKey("/SpiderInfo"))
156
+ root.removeKey("/SpiderInfo");
157
+
158
+ // remove page display mode (single page, outlines, etc.)
159
+ if (root.hasKey("/PageMode"))
160
+ root.removeKey("/PageMode");
161
+
162
+ // remove page layout preference
163
+ if (root.hasKey("/PageLayout"))
164
+ root.removeKey("/PageLayout");
165
+
166
+ // remove named destinations
167
+ if (root.hasKey("/Dests"))
168
+ root.removeKey("/Dests");
169
+
170
+ // remove page-level structure keys and obsolete /ProcSet
171
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
172
+ auto pageObj = page.getObjectHandle();
173
+
174
+ // structure tree references
175
+ if (pageObj.hasKey("/StructParents"))
176
+ pageObj.removeKey("/StructParents");
177
+ if (pageObj.hasKey("/StructParent"))
178
+ pageObj.removeKey("/StructParent");
179
+
180
+ // tab order (tied to structure tree)
181
+ if (pageObj.hasKey("/Tabs"))
182
+ pageObj.removeKey("/Tabs");
183
+
184
+ // presentation steps
185
+ if (pageObj.hasKey("/PresSteps"))
186
+ pageObj.removeKey("/PresSteps");
187
+
188
+ // remove /ProcSet from resources (obsolete since PDF 1.4)
189
+ auto resources = pageObj.getKey("/Resources");
190
+ if (resources.isDictionary() && resources.hasKey("/ProcSet"))
191
+ resources.removeKey("/ProcSet");
192
+ }
193
+
194
+ // remove /Metadata from non-root objects (XObjects, fonts, etc.)
195
+ // root /Metadata is handled separately by stripMetadata()
196
+ for (auto &obj : qpdf.getAllObjects()) {
197
+ if (!obj.isDictionary())
198
+ continue;
199
+ if (!obj.hasKey("/Metadata"))
200
+ continue;
201
+ // skip the document catalog itself
202
+ if (obj.getObjGen() == root.getObjGen())
203
+ continue;
204
+ obj.removeKey("/Metadata");
205
+ }
206
+ }
207
+
208
+ // ---------------------------------------------------------------------------
209
+ // Unused resource removal — remove XObject, ExtGState, ColorSpace, Pattern,
210
+ // Shading, and Properties entries not referenced by any content stream
211
+ // ---------------------------------------------------------------------------
212
+
213
+ class ResourceCollector : public QPDFObjectHandle::ParserCallbacks {
214
+ public:
215
+ std::set<std::string> usedNames;
216
+
217
+ void handleObject(QPDFObjectHandle obj) override {
218
+ if (obj.isOperator()) {
219
+ std::string op = obj.getOperatorValue();
220
+
221
+ // Do (XObject), gs (ExtGState), sh (Shading)
222
+ if ((op == "Do" || op == "gs" || op == "sh") && !operands.empty() &&
223
+ operands.back().isName())
224
+ usedNames.insert(operands.back().getName());
225
+
226
+ // cs/CS (set color space)
227
+ if ((op == "cs" || op == "CS") && !operands.empty() &&
228
+ operands.back().isName())
229
+ usedNames.insert(operands.back().getName());
230
+
231
+ // scn/SCN (set color with pattern)
232
+ if (op == "scn" || op == "SCN") {
233
+ for (auto &operand : operands) {
234
+ if (operand.isName())
235
+ usedNames.insert(operand.getName());
236
+ }
237
+ }
238
+
239
+ // Tf (font — for completeness, though fonts are handled separately)
240
+ if (op == "Tf" && operands.size() >= 2 &&
241
+ operands[operands.size() - 2].isName())
242
+ usedNames.insert(operands[operands.size() - 2].getName());
243
+
244
+ // BDC (marked content with properties)
245
+ if (op == "BDC") {
246
+ for (auto &operand : operands) {
247
+ if (operand.isName())
248
+ usedNames.insert(operand.getName());
249
+ }
250
+ }
251
+
252
+ operands.clear();
253
+ } else {
254
+ operands.push_back(obj);
255
+ }
256
+ }
257
+ void handleEOF() override {}
258
+
259
+ private:
260
+ std::vector<QPDFObjectHandle> operands;
261
+ };
262
+
263
+ void removeUnusedResources(QPDF &qpdf) {
264
+ for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
265
+ auto pageObj = page.getObjectHandle();
266
+ auto inheritedRes = pageObj.getKey("/Resources");
267
+ if (!inheritedRes.isDictionary())
268
+ continue;
269
+
270
+ // make a page-local copy so we don't modify shared (inherited) dicts
271
+ auto resources = inheritedRes.shallowCopy();
272
+ pageObj.replaceKey("/Resources", resources);
273
+
274
+ // collect all resource names used in content streams
275
+ ResourceCollector collector;
276
+
277
+ // scan page content
278
+ try {
279
+ auto contents = pageObj.getKey("/Contents");
280
+ QPDFObjectHandle::parseContentStream(contents, &collector);
281
+ } catch (...) {
282
+ continue;
283
+ }
284
+
285
+ // scan Form XObjects
286
+ auto xobjects = resources.getKey("/XObject");
287
+ if (xobjects.isDictionary()) {
288
+ for (auto &key : xobjects.getKeys()) {
289
+ auto xobj = xobjects.getKey(key);
290
+ if (!xobj.isStream())
291
+ continue;
292
+ auto xobjDict = xobj.getDict();
293
+ auto st = xobjDict.getKey("/Subtype");
294
+ if (!st.isName() || st.getName() != "/Form")
295
+ continue;
296
+ try {
297
+ QPDFObjectHandle::parseContentStream(xobj, &collector);
298
+ } catch (...) {
299
+ }
300
+ }
301
+ }
302
+
303
+ // remove unused entries from resource categories
304
+ // shallow-copy sub-dicts before modifying to avoid corrupting shared
305
+ // (inherited) resources that may be referenced by other pages
306
+ static const char *categories[] = {"/ExtGState", "/ColorSpace", "/Pattern",
307
+ "/Shading", "/Properties"};
308
+
309
+ for (auto category : categories) {
310
+ auto dict = resources.getKey(category);
311
+ if (!dict.isDictionary())
312
+ continue;
313
+
314
+ std::vector<std::string> toRemove;
315
+ for (auto &key : dict.getKeys()) {
316
+ if (collector.usedNames.find(key) == collector.usedNames.end())
317
+ toRemove.push_back(key);
318
+ }
319
+
320
+ if (toRemove.empty())
321
+ continue;
322
+
323
+ // copy the sub-dict so we don't modify a shared parent
324
+ auto copy = dict.shallowCopy();
325
+ for (auto &key : toRemove)
326
+ copy.removeKey(key);
327
+
328
+ if (copy.getKeys().empty())
329
+ resources.removeKey(category);
330
+ else
331
+ resources.replaceKey(category, copy);
332
+ }
333
+
334
+ // remove unused XObjects, preserving indirect dependencies (e.g. SMask)
335
+ if (xobjects.isDictionary()) {
336
+ // build a map from object ID to XObject name for dependency resolution
337
+ std::map<QPDFObjGen, std::string> objToName;
338
+ for (auto &key : xobjects.getKeys()) {
339
+ auto xobj = xobjects.getKey(key);
340
+ if (xobj.isIndirect())
341
+ objToName[xobj.getObjGen()] = key;
342
+ }
343
+
344
+ // mark XObjects that are dependencies of used XObjects (e.g. SMask)
345
+ bool changed = true;
346
+ while (changed) {
347
+ changed = false;
348
+ for (auto &key : xobjects.getKeys()) {
349
+ if (collector.usedNames.find(key) == collector.usedNames.end())
350
+ continue;
351
+ auto xobj = xobjects.getKey(key);
352
+ if (!xobj.isStream())
353
+ continue;
354
+ auto dict = xobj.getDict();
355
+ // check /SMask and /Mask references
356
+ for (auto &depKey : {"/SMask", "/Mask"}) {
357
+ auto dep = dict.getKey(depKey);
358
+ if (!dep.isIndirect())
359
+ continue;
360
+ auto it = objToName.find(dep.getObjGen());
361
+ if (it != objToName.end() &&
362
+ collector.usedNames.find(it->second) ==
363
+ collector.usedNames.end()) {
364
+ collector.usedNames.insert(it->second);
365
+ changed = true;
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ auto keys = xobjects.getKeys();
372
+ std::vector<std::string> toRemove;
373
+ for (auto &key : keys) {
374
+ if (collector.usedNames.find(key) == collector.usedNames.end())
375
+ toRemove.push_back(key);
376
+ }
377
+
378
+ if (!toRemove.empty()) {
379
+ // copy the sub-dict so we don't modify a shared parent
380
+ auto copy = xobjects.shallowCopy();
381
+ for (auto &key : toRemove)
382
+ copy.removeKey(key);
383
+
384
+ if (copy.getKeys().empty())
385
+ resources.removeKey("/XObject");
386
+ else
387
+ resources.replaceKey("/XObject", copy);
388
+ }
389
+ }
390
+ }
391
+ }