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/CHANGELOG.md +15 -0
- package/binding.gyp +24 -9
- package/package.json +3 -2
- package/scripts/download-harfbuzz.mjs +166 -0
- package/scripts/download-mozjpeg.mjs +1 -0
- package/scripts/download-qpdf.mjs +8 -1
- package/scripts/install.mjs +1 -0
- package/src/content.cc +291 -0
- package/src/content.h +6 -0
- package/src/encoding_tables.h +42 -0
- package/src/font_subset.cc +128 -425
- package/src/font_subset.h +17 -7
- package/src/fonts.cc +1209 -0
- package/src/fonts.h +5 -0
- package/src/hash_utils.h +15 -0
- package/src/images.cc +156 -132
- package/src/images.h +20 -2
- package/src/jpeg.cc +6 -0
- package/src/jpeg.h +1 -0
- package/src/optimize.h +5 -13
- package/src/qpdf_addon.cc +137 -55
- package/src/strip.cc +391 -0
- package/src/strip.h +9 -0
- package/src/structure.cc +346 -0
- package/src/structure.h +7 -0
- package/src/optimize.cc +0 -1048
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
|
-
|
|
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
|
|
33
|
-
if (
|
|
34
|
-
|
|
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
|
-
|
|
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 (
|
|
42
|
-
return "Failed to
|
|
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)),
|
|
58
|
-
|
|
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),
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
qpdf
|
|
123
|
+
qpdf->setAttemptRecovery(true);
|
|
124
|
+
qpdf->setSuppressWarnings(true);
|
|
74
125
|
|
|
75
126
|
if (useFile_) {
|
|
76
|
-
if (
|
|
127
|
+
if (access(filePath_.c_str(), F_OK) != 0) {
|
|
77
128
|
SetError("Input file not found: " + filePath_);
|
|
78
129
|
return;
|
|
79
130
|
}
|
|
80
|
-
qpdf
|
|
131
|
+
qpdf->processFile(filePath_.c_str());
|
|
81
132
|
} else {
|
|
82
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
138
|
-
|
|
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_,
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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::
|
|
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
|
+
}
|