如何使用pdfbox或其他Java库减小合并的PDF/A-1b文件大小

6
输入: 一组(例如14)带有嵌入字体的PDF/A-1b文件。
处理: 使用Apache PDFBOX进行简单合并。
结果: 一个文件大小过大(太大了)的PDF/A-1b文件。(它几乎等于所有源文件大小的总和)。
问题: 有没有办法减小生成的PDF文件大小?
想法: 删除冗余的嵌入字体。但是如何删除?这是正确的方法吗?
不幸的是,以下代码没有完成任务,但是它强调了明显的问题。
try (PDDocument document = PDDocument.load(new File("E:/tmp/16189_ZU_20181121195111_5544_2008-12-31_Standardauswertung.pdf"))) {
    List<COSName> collectedFonts = new ArrayList<>();
    PDPageTree pages = document.getDocumentCatalog().getPages();
    int pageNr = 0;
    for (PDPage page : pages) {
        pageNr++;
        Iterable<COSName> names = page.getResources().getFontNames();
        System.out.println("Page " + pageNr);
        for (COSName name : names) {
            collectedFonts.add(name);
            System.out.print("\t" + name + " - ");
            PDFont font = page.getResources().getFont(name);
            System.out.println(font + ", embedded: " + font.isEmbedded());
            page.getCOSObject().removeItem(COSName.F);
            page.getResources().getCOSObject().removeItem(name);
        }
    }
    document.save("E:/tmp/output.pdf");
}

代码会生成以下输出:
Page 1
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 2
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F33} - PDTrueTypeFont ArialMT-BoldItalic, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 3
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 4
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 5
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F33} - PDTrueTypeFont ArialMT-BoldItalic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 6
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F33} - PDTrueTypeFont ArialMT-BoldItalic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 7
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F33} - PDTrueTypeFont ArialMT-BoldItalic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 8
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 9
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F33} - PDTrueTypeFont ArialMT-BoldItalic, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 10
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F33} - PDTrueTypeFont ArialMT-BoldItalic, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 11
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F33} - PDTrueTypeFont ArialMT-BoldItalic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 12
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 13
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true
Page 14
    COSName{F23} - PDTrueTypeFont ArialMT-Bold, embedded: true
    COSName{F25} - PDTrueTypeFont ArialMT-Italic, embedded: true
    COSName{F27} - PDTrueTypeFont ArialMT-Regular, embedded: true

感激不尽,任何帮助都会受到赞赏...


dummy.pdf(https://datentransfer.sparkassenverlag.de/my/transfers/5q8eskgne52npemx8kid7728zk1hq3f993dfat8h)是一个示例文件。 - hab
1
好的,字体确实完全嵌入了。而且是一模一样的。像这样的文件可以轻松优化。我会尽力找时间给出一个可行的答案。 - mkl
@Lonzak 我尝试使用itext 7,但不起作用:File outPdf = new File("D:/unfortunatelyNoSmallerPdf.pdf");PdfWriter pdfWriter = new PdfWriter(outPdf); pdfWriter.setSmartMode(true); pdfWriter.setCompressionLevel(5);PdfDocument pdfDoc = new PdfDocument(new PdfReader(bigPdf), pdfWriter); pdfDoc.close(); - hab
@hab 还没有使用过 iText 7(只用过 iText 2 和 5)。看了一下描述 http://itextsupport.com/apidocs/itext5/5.5.9/com/itextpdf/text/pdf/PdfSmartCopy.html,应该可以工作。 - Lonzak
@hab iText(5和7)应该在合并期间删除重复的字体流,但它没有明确的API方法来优化具有内部重复的单个文件。 - mkl
显示剩余8条评论
3个回答

15

这个答案中的代码旨在优化类似于OP示例文档的文档,即包含完全相同、完全嵌入字体的副本。它不会合并仅几乎相同的对象,例如将同一字体的多个子集合并为一个联合子集。

在回答的评论过程中,很明显OP的PDF文件中的重复字体实际上是源字体文件的完全副本。要合并这样的重复对象,必须收集文档的复杂对象(数组、字典、流),将它们彼此比较,然后合并重复项。

由于实际上对文档的所有复杂对象进行成对比较可能需要太长时间(特别是在大型文档的情况下),因此以下代码计算了这些对象的哈希值,并仅比较具有相同哈希值的对象。

为了合并重复项,该代码选择其中一个重复项,并用所选择的引用替换任何其他重复项的引用,从文档对象池中删除其他重复项。为了更有效地执行此操作,该代码最初不仅收集所有复杂对象,而且还收集每个对象的所有引用。

优化代码

这是调用以优化PDDocument的方法:

public void optimize(PDDocument pdDocument) throws IOException {
    Map<COSBase, Collection<Reference>> complexObjects = findComplexObjects(pdDocument);
    for (int pass = 0; ; pass++) {
        int merges = mergeDuplicates(complexObjects);
        if (merges <= 0) {
            System.out.printf("Pass %d - No merged objects\n\n", pass);
            break;
        }
        System.out.printf("Pass %d - Merged objects: %d\n\n", pass, merges);
    }
}

(正在测试的OptimizeAfterMerge方法)

优化需要多次循环,因为某些对象的相等性只有在它们所引用的重复对象已合并之后才能被识别。

以下辅助方法和类收集PDF的复杂对象及其各自的引用:

Map<COSBase, Collection<Reference>> findComplexObjects(PDDocument pdDocument) {
    COSDictionary catalogDictionary = pdDocument.getDocumentCatalog().getCOSObject();
    Map<COSBase, Collection<Reference>> incomingReferences = new HashMap<>();
    incomingReferences.put(catalogDictionary, new ArrayList<>());

    Set<COSBase> lastPass = Collections.<COSBase>singleton(catalogDictionary);
    Set<COSBase> thisPass = new HashSet<>();
    while(!lastPass.isEmpty()) {
        for (COSBase object : lastPass) {
            if (object instanceof COSArray) {
                COSArray array = (COSArray) object;
                for (int i = 0; i < array.size(); i++) {
                    addTarget(new ArrayReference(array, i), incomingReferences, thisPass);
                }
            } else if (object instanceof COSDictionary) {
                COSDictionary dictionary = (COSDictionary) object;
                for (COSName key : dictionary.keySet()) {
                    addTarget(new DictionaryReference(dictionary, key), incomingReferences, thisPass);
                }
            }
        }
        lastPass = thisPass;
        thisPass = new HashSet<>();
    }
    return incomingReferences;
}

void addTarget(Reference reference, Map<COSBase, Collection<Reference>> incomingReferences, Set<COSBase> thisPass) {
    COSBase object = reference.getTo();
    if (object instanceof COSArray || object instanceof COSDictionary) {
        Collection<Reference> incoming = incomingReferences.get(object);
        if (incoming == null) {
            incoming = new ArrayList<>();
            incomingReferences.put(object, incoming);
            thisPass.add(object);
        }
        incoming.add(reference);
    }
}

(OptimizeAfterMerge辅助方法findComplexObjectsaddTarget)

interface Reference {
    public COSBase getFrom();

    public COSBase getTo();
    public void setTo(COSBase to);
}

static class ArrayReference implements Reference {
    public ArrayReference(COSArray array, int index) {
        this.from = array;
        this.index = index;
    }

    @Override
    public COSBase getFrom() {
        return from;
    }

    @Override
    public COSBase getTo() {
        return resolve(from.get(index));
    }

    @Override
    public void setTo(COSBase to) {
        from.set(index, to);
    }

    final COSArray from;
    final int index;
}

static class DictionaryReference implements Reference {
    public DictionaryReference(COSDictionary dictionary, COSName key) {
        this.from = dictionary;
        this.key = key;
    }

    @Override
    public COSBase getFrom() {
        return from;
    }

    @Override
    public COSBase getTo() {
        return resolve(from.getDictionaryObject(key));
    }

    @Override
    public void setTo(COSBase to) {
        from.setItem(key, to);
    }

    final COSDictionary from;
    final COSName key;
}

(OptimizeAfterMerge 辅助接口 Reference,实现为 ArrayReferenceDictionaryReference)

以下辅助方法和类最终确定并合并了重复项:

int mergeDuplicates(Map<COSBase, Collection<Reference>> complexObjects) throws IOException {
    List<HashOfCOSBase> hashes = new ArrayList<>(complexObjects.size());
    for (COSBase object : complexObjects.keySet()) {
        hashes.add(new HashOfCOSBase(object));
    }
    Collections.sort(hashes);

    int removedDuplicates = 0;
    if (!hashes.isEmpty()) {
        int runStart = 0;
        int runHash = hashes.get(0).hash;
        for (int i = 1; i < hashes.size(); i++) {
            int hash = hashes.get(i).hash;
            if (hash != runHash) {
                int runSize = i - runStart;
                if (runSize != 1) {
                    System.out.printf("Equal hash %d for %d elements.\n", runHash, runSize);
                    removedDuplicates += mergeRun(complexObjects, hashes.subList(runStart, i));
                }
                runHash = hash;
                runStart = i;
            }
        }
        int runSize = hashes.size() - runStart;
        if (runSize != 1) {
            System.out.printf("Equal hash %d for %d elements.\n", runHash, runSize);
            removedDuplicates += mergeRun(complexObjects, hashes.subList(runStart, hashes.size()));
        }
    }
    return removedDuplicates;
}

int mergeRun(Map<COSBase, Collection<Reference>> complexObjects, List<HashOfCOSBase> run) {
    int removedDuplicates = 0;

    List<List<COSBase>> duplicateSets = new ArrayList<>();
    for (HashOfCOSBase entry : run) {
        COSBase element = entry.object;
        for (List<COSBase> duplicateSet : duplicateSets) {
            if (equals(element, duplicateSet.get(0))) {
                duplicateSet.add(element);
                element = null;
                break;
            }
        }
        if (element != null) {
            List<COSBase> duplicateSet = new ArrayList<>();
            duplicateSet.add(element);
            duplicateSets.add(duplicateSet);
        }
    }

    System.out.printf("Identified %d set(s) of identical objects in run.\n", duplicateSets.size());

    for (List<COSBase> duplicateSet : duplicateSets) {
        if (duplicateSet.size() > 1) {
            COSBase surviver = duplicateSet.remove(0);
            Collection<Reference> surviverReferences = complexObjects.get(surviver);
            for (COSBase object : duplicateSet) {
                Collection<Reference> references = complexObjects.get(object);
                for (Reference reference : references) {
                    reference.setTo(surviver);
                    surviverReferences.add(reference);
                }
                complexObjects.remove(object);
                removedDuplicates++;
            }
            surviver.setDirect(false);
        }
    }

    return removedDuplicates;
}

boolean equals(COSBase a, COSBase b) {
    if (a instanceof COSArray) {
        if (b instanceof COSArray) {
            COSArray aArray = (COSArray) a;
            COSArray bArray = (COSArray) b;
            if (aArray.size() == bArray.size()) {
                for (int i=0; i < aArray.size(); i++) {
                    if (!resolve(aArray.get(i)).equals(resolve(bArray.get(i))))
                        return false;
                }
                return true;
            }
        }
    } else if (a instanceof COSDictionary) {
        if (b instanceof COSDictionary) {
            COSDictionary aDict = (COSDictionary) a;
            COSDictionary bDict = (COSDictionary) b;
            Set<COSName> keys = aDict.keySet();
            if (keys.equals(bDict.keySet())) {
                for (COSName key : keys) {
                    if (!resolve(aDict.getItem(key)).equals(bDict.getItem(key)))
                        return false;
                }
                // In case of COSStreams we strictly speaking should
                // also compare the stream contents here. But apparently
                // their hashes coincide well enough for the original
                // hashing equality, so let's just assume...
                return true;
            }
        }
    }
    return false;
}

static COSBase resolve(COSBase object) {
    while (object instanceof COSObject)
        object = ((COSObject)object).getObject();
    return object;
}

(OptimizeAfterMerge 辅助方法包括 mergeDuplicatesmergeRunequalsresolve)

static class HashOfCOSBase implements Comparable<HashOfCOSBase> {
    public HashOfCOSBase(COSBase object) throws IOException {
        this.object = object;
        this.hash = calculateHash(object);
    }

    int calculateHash(COSBase object) throws IOException {
        if (object instanceof COSArray) {
            int result = 1;
            for (COSBase member : (COSArray)object)
                result = 31 * result + member.hashCode();
            return result;
        } else if (object instanceof COSDictionary) {
            int result = 3;
            for (Map.Entry<COSName, COSBase> entry : ((COSDictionary)object).entrySet())
                result += entry.hashCode();
            if (object instanceof COSStream) {
                try (   InputStream data = ((COSStream)object).createRawInputStream()   ) {
                    MessageDigest md = MessageDigest.getInstance("MD5");
                    byte[] buffer = new byte[8192];
                    int bytesRead = 0;
                    while((bytesRead = data.read(buffer)) >= 0)
                        md.update(buffer, 0, bytesRead);
                    result = 31 * result + Arrays.hashCode(md.digest());
                } catch (NoSuchAlgorithmException e) {
                    throw new IOException(e);
                }
            }
            return result;
        } else {
            throw new IllegalArgumentException(String.format("Unknown complex COSBase type %s", object.getClass().getName()));
        }
    }

    final COSBase object;
    final int hash;

    @Override
    public int compareTo(HashOfCOSBase o) {
        int result = Integer.compare(hash,  o.hash);
        if (result == 0)
            result = Integer.compare(hashCode(), o.hashCode());
        return result;
    }
}

(OptimizeAfterMerge类中的HashOfCOSBase辅助类)

将代码应用于原帖示例文档

原帖示例文档大小约为6.5MB。可以按照以下方式应用上述代码:

PDDocument pdDocument = PDDocument.load(SOURCE);

optimize(pdDocument);

pdDocument.save(RESULT);

结果是一个小于 700 KB 的 PDF 文档且看起来是完整的。(如果有什么遗漏,请告诉我,我会尝试修复。)

警告

一方面,该优化程序将无法识别所有相同的重复项。特别是在出现循环引用的情况下,对象的重复圆圈不会被识别,因为代码仅在它们的内容相同的情况下才会识别重复项,而这通常在重复对象的圆圈中不会发生。

另一方面,在某些情况下,该优化程序可能已经过于热心,因为某些重复项可能需要作为单独的对象存在,以便 PDF 查看器将每个实例视为单独的实体。

此外,该程序会触及文件中的各种对象,甚至包括定义 PDF 内部结构的那些对象,但它不会尝试更新任何管理此结构的 PDFBox 类(PDDocumentPDDocumentCatalogPDAcroForm 等)。因此,请仅将此程序应用于刚加载的未修改的 PDDocument 实例,并立即保存文档,以免出现任何未完成的更改损坏整个文档。


谢谢,代码似乎做得不错。上周我被迫很快提供一个解决方案,所以选择采用schowave的解决方案,因为他的视图行解决方案对我和我的PDF文件有效。顺便说一下,在此期间,我找到了一个使用ITEXT 7的可行解决方案,明天我会发布一个代码示例。 - hab
我使用这种技术从另一个pdf创建了这个AcroForm only pdf,但是源文件中的acroform包含了我在新pdf中不再需要的所有这些XObject。你知道如何在pdfbox中完全删除XObject吗?否则,更好的方法是pdfbox中是否有任何机会知道此xobject是否在页面中使用。感谢您的考虑/调查。 - ebeg
1
请将此作为一个独立的 Stack Overflow 问题提出。Stack Overflow 旨在成为易于查找的问答资源。因此,一般性的问题应该被提出为问题,而不是评论。 - mkl
1
https://www.calcresult.com/transfer/CEC_BEN_LLDNOTIF_K0J000.MAILED_LANDLD.PDF 只能打开到第6页... - MikeB
1
@SanketGupta 当然,你可以尝试将PDFBox合并工具与上述优化结合起来,识别重复对象并在从合并文件导入时替换它们。不过,正确地执行这样的操作是非常困难的。我今年肯定没有时间去研究这个问题。 - mkl
显示剩余14条评论

6

当在文件中调试时,我发现相同字体的字体文件被多次引用。因此,通过用已经查看过的字体文件项替换字典中的实际字体文件项,可以删除引用并进行压缩。通过这样做,我能够将一个30 MB的文件压缩到约6 MB左右。

    File file = new File("test.pdf");

    PDDocument doc = PDDocument.load(file);
    Map<String, COSBase> fontFileCache = new HashMap<>();
    for (int pageNumber = 0; pageNumber < doc.getNumberOfPages(); pageNumber++) {
        final PDPage page = doc.getPage(pageNumber);
        COSDictionary pageDictionary = (COSDictionary) page.getResources().getCOSObject().getDictionaryObject(COSName.FONT);
        for (COSName currentFont : pageDictionary.keySet()) {
            COSDictionary fontDictionary = (COSDictionary) pageDictionary.getDictionaryObject(currentFont);
            for (COSName actualFont : fontDictionary.keySet()) {
                COSBase actualFontDictionaryObject = fontDictionary.getDictionaryObject(actualFont);
                if (actualFontDictionaryObject instanceof COSDictionary) {
                    COSDictionary fontFile = (COSDictionary) actualFontDictionaryObject;
                    if (fontFile.getItem(COSName.FONT_NAME) instanceof COSName) {
                        COSName fontName = (COSName) fontFile.getItem(COSName.FONT_NAME);
                        fontFileCache.computeIfAbsent(fontName.getName(), key -> fontFile.getItem(COSName.FONT_FILE2));
                        fontFile.setItem(COSName.FONT_FILE2, fontFileCache.get(fontName.getName()));
                    }
                }
            }
        }
    }

    final ByteArrayOutputStream baos = new ByteArrayOutputStream();
    doc.save(baos);
    final File compressed = new File("test_compressed.pdf");
    baos.writeTo(new FileOutputStream(compressed));

也许这不是最优雅的方法,但它能保持PDF/A-1b兼容性。

1
只有在以下两个条件都满足的情况下,此方法才有效:a. 所有嵌入同一名称字体程序的确是相同的;b. 要考虑的所有字体都在直接页面资源中,而不是某些引用的XObject或Pattern的资源中。如果这些条件得到满足,那么它很可能比我答案中的方法快得多。 - mkl
2
我刚刚比较了你分享的示例PDF的结果。原始PDF大小:6561805。你的代码结果大小为788470,我的代码结果大小为691147。因此,尽管仍有一些优化潜力,但你更短更快的代码确实也去除了主要的重复内容。 - mkl
感谢您的调查!您的代码文件仍然符合PDF/A-1b兼容性吗? - schowave
你的代码文件还是PDF/A-1b兼容格式吗?就你的示例文件而言,Adobe Acrobat 9.5 Preflight显示它是兼容的。 - mkl

2
我发现另一种方法是使用ITEXT 7(pdfWriter.setSmartMode):
    try (PdfWriter pdfWriter = new PdfWriter(out)) {
        pdfWriter.setSmartMode(true); // Here happens the optimation, e.g. reducing redundantly embedded fonts
        pdfWriter.setCompressionLevel(Deflater.BEST_COMPRESSION);
        try (PdfDocument pdfDoc = new PdfADocument(pdfWriter, PdfAConformanceLevel.PDF_A_1B,
                new PdfOutputIntent("Custom", "", "http://www.color.org", "sRGB IEC61966-2.1", colorProfile))) {
            PdfMerger merger = new PdfMerger(pdfDoc);
            merger.setCloseSourceDocuments(true);
            try {
                for (InputStream pdf : pdfs) {
                    try (PdfDocument doc = new PdfDocument(new PdfReader(pdf))) {
                        merger.merge(doc, createPageList(doc.getNumberOfPages()));
                    }
                }
                merger.close();
            }
            catch (com.itextpdf.kernel.crypto.BadPasswordException e) {
                throw new BieneException("Konkatenierung eines passwortgeschützten PDF-Dokumentes nicht möglich: " + e.getMessage(),
                        e);
            }
            catch (com.itextpdf.io.IOException | PdfException e) {
                throw new BieneException(e.getMessage(), e);
            }
        }
    }

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接