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/strip.h
ADDED
package/src/structure.cc
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#include "structure.h"
|
|
2
|
+
#include "hash_utils.h"
|
|
3
|
+
#include "images.h"
|
|
4
|
+
|
|
5
|
+
#include <algorithm>
|
|
6
|
+
#include <cmath>
|
|
7
|
+
#include <cstdint>
|
|
8
|
+
#include <cstdio>
|
|
9
|
+
#include <cstring>
|
|
10
|
+
#include <map>
|
|
11
|
+
#include <set>
|
|
12
|
+
#include <string>
|
|
13
|
+
#include <unordered_map>
|
|
14
|
+
#include <vector>
|
|
15
|
+
|
|
16
|
+
#include <qpdf/QPDFObjectHandle.hh>
|
|
17
|
+
#include <qpdf/QPDFPageDocumentHelper.hh>
|
|
18
|
+
#include <qpdf/QPDFPageObjectHelper.hh>
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Deduplicate identical non-image streams (fonts, ICC profiles, etc.)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
void deduplicateStreams(QPDF &qpdf) {
|
|
25
|
+
// collect all stream objects and their raw data hashes
|
|
26
|
+
struct StreamEntry {
|
|
27
|
+
QPDFObjGen og;
|
|
28
|
+
size_t dataSize;
|
|
29
|
+
QPDFObjectHandle handle;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
std::unordered_map<uint64_t, std::vector<StreamEntry>> hashGroups;
|
|
33
|
+
std::set<QPDFObjGen> imageObjGens;
|
|
34
|
+
|
|
35
|
+
// collect image object IDs to skip them (already handled by
|
|
36
|
+
// deduplicateImages)
|
|
37
|
+
forEachImage(qpdf, [&](const std::string &, QPDFObjectHandle xobj,
|
|
38
|
+
QPDFObjectHandle, QPDFPageObjectHelper &) {
|
|
39
|
+
imageObjGens.insert(xobj.getObjGen());
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
for (auto &obj : qpdf.getAllObjects()) {
|
|
43
|
+
if (!obj.isStream())
|
|
44
|
+
continue;
|
|
45
|
+
|
|
46
|
+
auto og = obj.getObjGen();
|
|
47
|
+
|
|
48
|
+
// skip images
|
|
49
|
+
if (imageObjGens.count(og))
|
|
50
|
+
continue;
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
auto rawData = obj.getRawStreamData();
|
|
54
|
+
size_t size = rawData->getSize();
|
|
55
|
+
if (size == 0)
|
|
56
|
+
continue;
|
|
57
|
+
|
|
58
|
+
uint64_t hash = fnv1aHash(rawData->getBuffer(), size);
|
|
59
|
+
|
|
60
|
+
hashGroups[hash].push_back({og, size, obj});
|
|
61
|
+
} catch (...) {
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// find duplicates via full byte comparison
|
|
66
|
+
std::map<QPDFObjGen, QPDFObjectHandle> replacements;
|
|
67
|
+
|
|
68
|
+
for (auto &[hash, group] : hashGroups) {
|
|
69
|
+
if (group.size() < 2)
|
|
70
|
+
continue;
|
|
71
|
+
|
|
72
|
+
for (size_t i = 0; i < group.size(); ++i) {
|
|
73
|
+
if (replacements.count(group[i].og))
|
|
74
|
+
continue;
|
|
75
|
+
|
|
76
|
+
auto rawI = group[i].handle.getRawStreamData();
|
|
77
|
+
for (size_t j = i + 1; j < group.size(); ++j) {
|
|
78
|
+
if (replacements.count(group[j].og))
|
|
79
|
+
continue;
|
|
80
|
+
|
|
81
|
+
auto rawJ = group[j].handle.getRawStreamData();
|
|
82
|
+
if (rawI->getSize() != rawJ->getSize())
|
|
83
|
+
continue;
|
|
84
|
+
|
|
85
|
+
if (memcmp(rawI->getBuffer(), rawJ->getBuffer(), rawI->getSize()) ==
|
|
86
|
+
0) {
|
|
87
|
+
// verify stream dictionaries are compatible
|
|
88
|
+
auto dictI = group[i].handle.getDict();
|
|
89
|
+
auto dictJ = group[j].handle.getDict();
|
|
90
|
+
|
|
91
|
+
auto filterI = dictI.getKey("/Filter");
|
|
92
|
+
auto filterJ = dictJ.getKey("/Filter");
|
|
93
|
+
bool filtersMatch = (filterI.isName() && filterJ.isName() &&
|
|
94
|
+
filterI.getName() == filterJ.getName()) ||
|
|
95
|
+
(!filterI.isName() && !filterJ.isName());
|
|
96
|
+
|
|
97
|
+
// also verify DecodeParms match — different predictors on
|
|
98
|
+
// identical raw bytes would produce different decoded content
|
|
99
|
+
auto dpI = dictI.getKey("/DecodeParms");
|
|
100
|
+
auto dpJ = dictJ.getKey("/DecodeParms");
|
|
101
|
+
bool paramsMatch = (dpI.isNull() && dpJ.isNull()) ||
|
|
102
|
+
(!dpI.isNull() && !dpJ.isNull() &&
|
|
103
|
+
dpI.unparse() == dpJ.unparse());
|
|
104
|
+
|
|
105
|
+
if (filtersMatch && paramsMatch)
|
|
106
|
+
replacements[group[j].og] = group[i].handle;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (replacements.empty())
|
|
113
|
+
return;
|
|
114
|
+
|
|
115
|
+
// rewrite references: scan all objects for indirect references to duplicates
|
|
116
|
+
for (auto &obj : qpdf.getAllObjects()) {
|
|
117
|
+
if (!obj.isDictionary() && !obj.isStream())
|
|
118
|
+
continue;
|
|
119
|
+
|
|
120
|
+
auto dict = obj.isStream() ? obj.getDict() : obj;
|
|
121
|
+
for (auto &key : dict.getKeys()) {
|
|
122
|
+
auto val = dict.getKey(key);
|
|
123
|
+
if (val.isIndirect()) {
|
|
124
|
+
auto it = replacements.find(val.getObjGen());
|
|
125
|
+
if (it != replacements.end())
|
|
126
|
+
dict.replaceKey(key, it->second);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Form flattening — merge interactive form field appearances into page
|
|
134
|
+
// content and remove the /AcroForm dictionary
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
void flattenForms(QPDF &qpdf) {
|
|
138
|
+
auto root = qpdf.getRoot();
|
|
139
|
+
if (!root.hasKey("/AcroForm"))
|
|
140
|
+
return;
|
|
141
|
+
|
|
142
|
+
// stamp each widget annotation's appearance into the page content,
|
|
143
|
+
// then remove the annotation
|
|
144
|
+
for (auto &page : QPDFPageDocumentHelper(qpdf).getAllPages()) {
|
|
145
|
+
auto pageObj = page.getObjectHandle();
|
|
146
|
+
if (!pageObj.hasKey("/Annots"))
|
|
147
|
+
continue;
|
|
148
|
+
|
|
149
|
+
auto annots = pageObj.getKey("/Annots");
|
|
150
|
+
if (!annots.isArray())
|
|
151
|
+
continue;
|
|
152
|
+
|
|
153
|
+
std::vector<int> widgetIndices;
|
|
154
|
+
std::string allSnippets;
|
|
155
|
+
for (int i = 0; i < annots.getArrayNItems(); ++i) {
|
|
156
|
+
auto annot = annots.getArrayItem(i);
|
|
157
|
+
if (!annot.isDictionary())
|
|
158
|
+
continue;
|
|
159
|
+
|
|
160
|
+
auto subtype = annot.getKey("/Subtype");
|
|
161
|
+
if (!subtype.isName() || subtype.getName() != "/Widget")
|
|
162
|
+
continue;
|
|
163
|
+
|
|
164
|
+
// skip hidden, invisible, or no-view annotations
|
|
165
|
+
if (annot.hasKey("/F")) {
|
|
166
|
+
auto flags = annot.getKey("/F");
|
|
167
|
+
if (flags.isInteger()) {
|
|
168
|
+
int f = static_cast<int>(flags.getIntValue());
|
|
169
|
+
// bit 1 = Invisible, bit 2 = Hidden, bit 6 (32) = NoView
|
|
170
|
+
if (f & (1 | 2 | 32))
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// check if there's a normal appearance to flatten
|
|
176
|
+
// skip signature fields — their appearance should not be flattened
|
|
177
|
+
if (annot.hasKey("/FT")) {
|
|
178
|
+
auto ft = annot.getKey("/FT");
|
|
179
|
+
if (ft.isName() && ft.getName() == "/Sig")
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
auto ap = annot.getKey("/AP");
|
|
184
|
+
if (!ap.isDictionary())
|
|
185
|
+
continue;
|
|
186
|
+
auto nAppearance = ap.getKey("/N");
|
|
187
|
+
if (!nAppearance.isStream())
|
|
188
|
+
continue;
|
|
189
|
+
|
|
190
|
+
// get widget rectangle
|
|
191
|
+
auto rect = annot.getKey("/Rect");
|
|
192
|
+
if (!rect.isArray() || rect.getArrayNItems() < 4)
|
|
193
|
+
continue;
|
|
194
|
+
|
|
195
|
+
try {
|
|
196
|
+
double rx1 = rect.getArrayItem(0).getNumericValue();
|
|
197
|
+
double ry1 = rect.getArrayItem(1).getNumericValue();
|
|
198
|
+
double rx2 = rect.getArrayItem(2).getNumericValue();
|
|
199
|
+
double ry2 = rect.getArrayItem(3).getNumericValue();
|
|
200
|
+
|
|
201
|
+
// normalize rect (some generators produce inverted coordinates)
|
|
202
|
+
if (rx1 > rx2)
|
|
203
|
+
std::swap(rx1, rx2);
|
|
204
|
+
if (ry1 > ry2)
|
|
205
|
+
std::swap(ry1, ry2);
|
|
206
|
+
|
|
207
|
+
double rw = rx2 - rx1;
|
|
208
|
+
double rh = ry2 - ry1;
|
|
209
|
+
if (rw <= 0 || rh <= 0)
|
|
210
|
+
continue;
|
|
211
|
+
|
|
212
|
+
// get appearance stream bounding box and matrix
|
|
213
|
+
auto apDict = nAppearance.getDict();
|
|
214
|
+
double bx1 = 0, by1 = 0, bx2 = 1, by2 = 1;
|
|
215
|
+
if (apDict.hasKey("/BBox")) {
|
|
216
|
+
auto bbox = apDict.getKey("/BBox");
|
|
217
|
+
if (bbox.isArray() && bbox.getArrayNItems() >= 4) {
|
|
218
|
+
bx1 = bbox.getArrayItem(0).getNumericValue();
|
|
219
|
+
by1 = bbox.getArrayItem(1).getNumericValue();
|
|
220
|
+
bx2 = bbox.getArrayItem(2).getNumericValue();
|
|
221
|
+
by2 = bbox.getArrayItem(3).getNumericValue();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
double bw = bx2 - bx1;
|
|
226
|
+
double bh = by2 - by1;
|
|
227
|
+
if (std::abs(bw) < 1e-10 || std::abs(bh) < 1e-10)
|
|
228
|
+
continue;
|
|
229
|
+
|
|
230
|
+
// BBox→Rect mapping: scale then translate
|
|
231
|
+
double sx = rw / bw;
|
|
232
|
+
double sy = rh / bh;
|
|
233
|
+
double stx = rx1 - bx1 * sx;
|
|
234
|
+
double sty = ry1 - by1 * sy;
|
|
235
|
+
// S = [sx, 0, 0, sy, stx, sty]
|
|
236
|
+
|
|
237
|
+
// read the form's /Matrix (default identity). the Do operator
|
|
238
|
+
// applies this matrix, so our CTM must compensate:
|
|
239
|
+
// rendered = FormMatrix × OurCTM, we want rendered = S,
|
|
240
|
+
// so OurCTM = FormMatrix⁻¹ × S.
|
|
241
|
+
double ma = 1, mb = 0, mc = 0, md = 1, me = 0, mf = 0;
|
|
242
|
+
if (apDict.hasKey("/Matrix")) {
|
|
243
|
+
auto matrix = apDict.getKey("/Matrix");
|
|
244
|
+
if (matrix.isArray() && matrix.getArrayNItems() >= 6) {
|
|
245
|
+
ma = matrix.getArrayItem(0).getNumericValue();
|
|
246
|
+
mb = matrix.getArrayItem(1).getNumericValue();
|
|
247
|
+
mc = matrix.getArrayItem(2).getNumericValue();
|
|
248
|
+
md = matrix.getArrayItem(3).getNumericValue();
|
|
249
|
+
me = matrix.getArrayItem(4).getNumericValue();
|
|
250
|
+
mf = matrix.getArrayItem(5).getNumericValue();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// compute CTM = Matrix⁻¹ × S
|
|
255
|
+
// PDF matrix [a b c d e f] represents:
|
|
256
|
+
// | a b 0 |
|
|
257
|
+
// | c d 0 |
|
|
258
|
+
// | e f 1 |
|
|
259
|
+
double ca, cb, cc, cd, ce, cf;
|
|
260
|
+
double det = ma * md - mb * mc;
|
|
261
|
+
if (std::abs(det) < 1e-10) {
|
|
262
|
+
// degenerate matrix — use BBox→Rect directly
|
|
263
|
+
ca = sx;
|
|
264
|
+
cb = 0;
|
|
265
|
+
cc = 0;
|
|
266
|
+
cd = sy;
|
|
267
|
+
ce = stx;
|
|
268
|
+
cf = sty;
|
|
269
|
+
} else {
|
|
270
|
+
// inverse of Matrix
|
|
271
|
+
double ia = md / det;
|
|
272
|
+
double ib = -mb / det;
|
|
273
|
+
double ic = -mc / det;
|
|
274
|
+
double id = ma / det;
|
|
275
|
+
double ie = (mc * mf - md * me) / det;
|
|
276
|
+
double iif = (mb * me - ma * mf) / det;
|
|
277
|
+
|
|
278
|
+
// multiply M⁻¹ × S (M⁻¹ is left, S is right):
|
|
279
|
+
// [ia, ib, ic, id, ie, iif] × [sx, 0, 0, sy, stx, sty]
|
|
280
|
+
ca = ia * sx;
|
|
281
|
+
cb = ib * sy;
|
|
282
|
+
cc = ic * sx;
|
|
283
|
+
cd = id * sy;
|
|
284
|
+
ce = ie * sx + stx;
|
|
285
|
+
cf = iif * sy + sty;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// register appearance as a form XObject on the page
|
|
289
|
+
auto resources = pageObj.getKey("/Resources");
|
|
290
|
+
if (!resources.isDictionary()) {
|
|
291
|
+
resources = QPDFObjectHandle::newDictionary();
|
|
292
|
+
pageObj.replaceKey("/Resources", resources);
|
|
293
|
+
}
|
|
294
|
+
auto xobjects = resources.getKey("/XObject");
|
|
295
|
+
if (!xobjects.isDictionary()) {
|
|
296
|
+
xobjects = QPDFObjectHandle::newDictionary();
|
|
297
|
+
resources.replaceKey("/XObject", xobjects);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
std::string xobjName = "/FlatForm" + std::to_string(i);
|
|
301
|
+
xobjects.replaceKey(xobjName, nAppearance);
|
|
302
|
+
|
|
303
|
+
// ensure the appearance stream has /Type /XObject /Subtype /Form
|
|
304
|
+
if (!apDict.hasKey("/Type"))
|
|
305
|
+
apDict.replaceKey("/Type", QPDFObjectHandle::newName("/XObject"));
|
|
306
|
+
if (!apDict.hasKey("/Subtype"))
|
|
307
|
+
apDict.replaceKey("/Subtype", QPDFObjectHandle::newName("/Form"));
|
|
308
|
+
|
|
309
|
+
// build content stream snippet with the full affine CTM
|
|
310
|
+
// use snprintf for locale-safe decimal formatting
|
|
311
|
+
char buf[384];
|
|
312
|
+
std::snprintf(buf, sizeof(buf),
|
|
313
|
+
"q %.6g %.6g %.6g %.6g %.6g %.6g cm %s Do Q\n", ca, cb,
|
|
314
|
+
cc, cd, ce, cf, xobjName.c_str());
|
|
315
|
+
allSnippets += buf;
|
|
316
|
+
|
|
317
|
+
widgetIndices.push_back(i);
|
|
318
|
+
} catch (...) {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!widgetIndices.empty()) {
|
|
324
|
+
// wrap existing page content in q/Q so the flattened form snippets
|
|
325
|
+
// run with the default (identity) CTM, regardless of any active
|
|
326
|
+
// transforms in the page content (e.g. top-down Y-flip)
|
|
327
|
+
page.addPageContents(QPDFObjectHandle::newStream(&qpdf, "q\n"), true);
|
|
328
|
+
page.addPageContents(
|
|
329
|
+
QPDFObjectHandle::newStream(&qpdf, "Q\n" + allSnippets), false);
|
|
330
|
+
|
|
331
|
+
// remove widget annotations (reverse order to preserve indices)
|
|
332
|
+
for (auto it = widgetIndices.rbegin(); it != widgetIndices.rend(); ++it)
|
|
333
|
+
annots.eraseItem(*it);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// remove the /AcroForm dictionary
|
|
338
|
+
root.removeKey("/AcroForm");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Page tree flattening — push inherited attributes to pages so QPDFWriter
|
|
343
|
+
// can generate a flat single-level page tree
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
void flattenPageTree(QPDF &qpdf) { qpdf.pushInheritedAttributesToPage(); }
|