diff --git a/core/fpdfapi/edit/cpdf_creator.cpp b/core/fpdfapi/edit/cpdf_creator.cpp index ab2693011..02ce2f1cf 100644 --- a/core/fpdfapi/edit/cpdf_creator.cpp +++ b/core/fpdfapi/edit/cpdf_creator.cpp @@ -256,7 +256,8 @@ bool CPDF_Creator::WriteIndirectObj(uint32_t objnum, const CPDF_Object* pObj) { } std::unique_ptr encryptor; - if (GetCryptoHandler() && pObj != encrypt_dict_) { + // EmbedPDF(bug-fix): see IsClearMetadata(). + if (GetCryptoHandler() && pObj != encrypt_dict_ && !IsClearMetadata(objnum)) { encryptor = std::make_unique(GetCryptoHandler(), objnum, generation); } @@ -268,6 +269,19 @@ bool CPDF_Creator::WriteIndirectObj(uint32_t objnum, const CPDF_Object* pObj) { return archive_->WriteString("\r\nendobj\r\n"); } +// EmbedPDF(bug-fix): the document's metadata stream stays in the clear when +// the encryption dictionary says /EncryptMetadata false, as the parser reads +// it. Every other stream is encrypted, other metadata streams included. +bool CPDF_Creator::IsClearMetadata(uint32_t objnum) const { + if (!security_handler_ || security_handler_->IsMetadataEncrypted()) { + return false; + } + const CPDF_Dictionary* root = document_->GetRoot(); + RetainPtr metadata = + root ? ToReference(root->GetObjectFor("Metadata")) : nullptr; + return metadata && metadata->GetRefObjNum() == objnum; +} + // EmbedPDF(save-generations): the generation an incremental save writes for an // object: the layer's version's, or the input file's. A full save writes 0. uint32_t CPDF_Creator::GetObjectGeneration(uint32_t object_number) const { diff --git a/core/fpdfapi/edit/cpdf_creator.h b/core/fpdfapi/edit/cpdf_creator.h index 1949ab13b..ddefca81f 100644 --- a/core/fpdfapi/edit/cpdf_creator.h +++ b/core/fpdfapi/edit/cpdf_creator.h @@ -141,6 +141,9 @@ class CPDF_Creator final : public CPDF_WriteContext { std::vector NewObjectNumbers() const; bool WriteNewObjs(); bool WriteIndirectObj(uint32_t objnum, const CPDF_Object* pObj); + // EmbedPDF(bug-fix): whether |objnum| is the document's metadata stream, + // kept in the clear by /EncryptMetadata false. + bool IsClearMetadata(uint32_t objnum) const; // EmbedPDF(layer-save): refuses an offset an entry can't hold. bool CheckEmittedOffset(FX_FILESIZE offset); // EmbedPDF(save-generations): the creator as CPDF_WriteContext, and diff --git a/core/fpdfapi/parser/cpdf_flateencoder.cpp b/core/fpdfapi/parser/cpdf_flateencoder.cpp index 098e71167..85fba3918 100644 --- a/core/fpdfapi/parser/cpdf_flateencoder.cpp +++ b/core/fpdfapi/parser/cpdf_flateencoder.cpp @@ -26,14 +26,25 @@ CPDF_FlateEncoder::CPDF_FlateEncoder(RetainPtr pStream, bool bHasFilter = pStream->HasFilter(); if (bHasFilter && !bFlateEncode) { - auto pDestAcc = pdfium::MakeRetain(pStream); - pDestAcc->LoadAllDataFiltered(); - - data_ = acc_->GetSpan(); - cloned_dict_ = ToDictionary(pStream->GetDict()->Clone()); - cloned_dict_->RemoveFor("Filter"); - DCHECK(!dict_); - return; + // EmbedPDF(bug-fix): a stream written unencoded (the metadata stream, + // which XMP readers look for in the file's bytes) is written decoded. + // Upstream decoded it, then wrote its encoded bytes without the filter + // that decodes them. Bytes that don't decode stay as they are, filter and + // all. + std::optional decoders = GetDecoderArray(pStream->GetDict()); + std::optional decoded; + if (decoders.has_value() && !decoders.value().empty()) { + decoded = PDF_DataDecode(acc_->GetSpan(), 0, false, decoders.value()); + } + if (decoded.has_value() && decoded.value().image_encoding.IsEmpty() && + !decoded.value().data.empty()) { + data_ = std::move(decoded.value().data); + cloned_dict_ = ToDictionary(pStream->GetDict()->Clone()); + cloned_dict_->RemoveFor("Filter"); + cloned_dict_->RemoveFor(pdfium::stream::kDecodeParms); + DCHECK(!dict_); + return; + } } if (bHasFilter || !bFlateEncode) { data_ = acc_->GetSpan(); diff --git a/core/fpdfapi/parser/cpdf_stream.cpp b/core/fpdfapi/parser/cpdf_stream.cpp index a1f8ec54a..93ee478c6 100644 --- a/core/fpdfapi/parser/cpdf_stream.cpp +++ b/core/fpdfapi/parser/cpdf_stream.cpp @@ -297,7 +297,12 @@ bool CPDF_Stream::WriteTo(IFX_ArchiveStream* archive, std::optional> encrypted_data; pdfium::span data = encoder.GetSpan(); - if (encryptor && !is_metadata) { + // EmbedPDF(bug-fix): a metadata stream is encrypted like any other stream; + // the creator gives the one a file keeps in the clear (/EncryptMetadata + // false) no encryptor. Upstream never encrypted one, so a save of an + // encrypted file wrote its XMP in the clear, and readers decrypted it into + // noise. + if (encryptor) { // EmbedPDF(save-security): a stream that can't be encrypted fails the save. encrypted_data = encryptor->Encrypt(data); if (!encrypted_data) { diff --git a/fpdfsdk/BUILD.gn b/fpdfsdk/BUILD.gn index cfd462ec4..e9a433962 100644 --- a/fpdfsdk/BUILD.gn +++ b/fpdfsdk/BUILD.gn @@ -22,6 +22,8 @@ source_set("fpdfsdk") { "epdf_base_document.cpp", # EmbedPDF: captures of what a layer write loses (EPDF*_Export*Raw). "epdf_capture.cpp", + # EmbedPDF: the catalog's metadata stream (public/epdf_catalog.h). + "epdf_catalog.cpp", # EmbedPDF: implements EPDFFont_* runtime font registration APIs. "epdf_font.cpp", # EmbedPDF: session-free AcroForm model snapshot (EPDFForm_* APIs). @@ -253,6 +255,8 @@ pdfium_embeddertest_source_set("embeddertests") { "epdf_edit_test_util.h", # EmbedPDF: covers captures and their imports (EPDF*_Export*Raw). "epdf_capture_embeddertest.cpp", + # EmbedPDF: covers the catalog's metadata stream (EPDFCatalog_*Metadata). + "epdf_catalog_embeddertest.cpp", # EmbedPDF: covers appearance states and modes, widgets in page renders, # and the check box and radio button symbols. "epdf_appearance_state_embeddertest.cpp", diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index 7c1c0fe1d..249c8d018 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -235,6 +235,8 @@ int CheckEmbedPDFCApi(void) { // epdf_catalog.h CHK(EPDFCatalog_GetLanguage); + CHK(EPDFCatalog_GetMetadata); + CHK(EPDFCatalog_SetMetadata); // epdf_digest.h CHK(EPDF_DigestBuffer); diff --git a/fpdfsdk/epdf_catalog.cpp b/fpdfsdk/epdf_catalog.cpp new file mode 100644 index 000000000..a155660fb --- /dev/null +++ b/fpdfsdk/epdf_catalog.cpp @@ -0,0 +1,96 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include + +#include + +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_document_view_scope.h" +#include "core/fpdfapi/parser/cpdf_name.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_stream.h" +#include "core/fxcrt/compiler_specific.h" +#include "core/fxcrt/retain_ptr.h" +#include "core/fxcrt/span.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "public/epdf_catalog.h" + +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFCatalog_GetMetadata(FPDF_DOCUMENT document, + void* buffer, + unsigned long length) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + if (!doc) { + return 0; + } + // References a base document's objects hold resolve through the layer. + CPDF_DocumentViewScope document_view(doc); + const CPDF_Dictionary* catalog = doc->GetRoot(); + RetainPtr stream = + catalog ? catalog->GetStreamFor("Metadata") : nullptr; + if (!stream) { + return 0; + } + if (!buffer) { + length = 0; + } + // SAFETY: required from caller. + return DecodeStreamMaybeCopyAndReturnLength( + std::move(stream), + UNSAFE_BUFFERS(pdfium::span(static_cast(buffer), + static_cast(length)))); +} + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFCatalog_SetMetadata(FPDF_DOCUMENT document, + const void* data, + unsigned long size) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + if (!doc || (!data && size > 0)) { + return false; + } + CPDF_DocumentViewScope document_view(doc); + const CPDF_Dictionary* catalog = doc->GetRoot(); + if (!catalog) { + return false; + } + if (!data) { + if (!catalog->KeyExist("Metadata")) { + return true; + } + RetainPtr writable = doc->GetMutableRoot(); + if (!writable) { + return false; + } + writable->RemoveFor("Metadata"); + return true; + } + + // The stream is written through its object number, so on a layer the + // write lands in the layer's copy and never in the base's. + RetainPtr reference = + ToReference(catalog->GetObjectFor("Metadata")); + RetainPtr stream = + reference + ? ToStream(doc->GetMutableIndirectObject(reference->GetRefObjNum())) + : nullptr; + if (!stream) { + RetainPtr writable = doc->GetMutableRoot(); + if (!writable) { + return false; + } + stream = doc->NewIndirect( + pdfium::MakeRetain()); + writable->SetNewFor("Metadata", doc, stream->GetObjNum()); + } + // SAFETY: required from caller. + stream->SetDataAndRemoveFilter(UNSAFE_BUFFERS( + pdfium::span(static_cast(data), + static_cast(size)))); + RetainPtr dict = stream->GetMutableDict(); + dict->SetNewFor("Type", "Metadata"); + dict->SetNewFor("Subtype", "XML"); + return true; +} diff --git a/fpdfsdk/epdf_catalog_embeddertest.cpp b/fpdfsdk/epdf_catalog_embeddertest.cpp new file mode 100644 index 000000000..e74f3e39b --- /dev/null +++ b/fpdfsdk/epdf_catalog_embeddertest.cpp @@ -0,0 +1,341 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "public/epdf_catalog.h" + +#include +#include +#include +#include + +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_document_view_scope.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_stream.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "fpdfsdk/epdf_edit_test_util.h" +#include "public/cpp/fpdf_scopers.h" +#include "public/epdf_layer.h" +#include "public/epdf_save.h" +#include "public/epdf_view.h" +#include "public/fpdf_save.h" +#include "public/fpdfview.h" +#include "testing/embedder_test.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +constexpr char kOldPacket[] = + "" + "" + "OLDPRODUCER" + ""; + +constexpr char kNewPacket[] = + "" + ""; + +std::string Hex(const std::string& bytes) { + static constexpr char kDigits[] = "0123456789ABCDEF"; + std::string hex; + for (unsigned char byte : bytes) { + hex += kDigits[byte >> 4]; + hex += kDigits[byte & 0xf]; + } + return hex + ">"; +} + +// A one-page PDF whose catalog has a /Metadata stream (object 4) holding +// kOldPacket hex-encoded, so reading it applies a filter. +std::string PdfWithPacket() { + const std::string encoded = Hex(kOldPacket); + return MakePdf({ + "<< /Type /Catalog /Pages 2 0 R /Metadata 4 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + "<< /Type /Metadata /Subtype /XML /Filter /ASCIIHexDecode /Length " + + std::to_string(encoded.size()) + " >>\nstream\n" + encoded + + "\nendstream", + }); +} + +std::string PdfWithoutPacket() { + return MakePdf({ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] >>", + }); +} + +// A PDF file opened as a document, or as a layer over it. Both read +// `input`, which must outlive them. +struct OpenedPdf { + std::unique_ptr, + decltype(&EPDF_ReleaseBaseDocument)> + base{nullptr, EPDF_ReleaseBaseDocument}; + ScopedFPDFDocument doc; +}; + +OpenedPdf OpenPdf(const std::string& input, bool layer) { + OpenedPdf opened; + if (layer) { + opened.base.reset( + EPDF_LoadMemBaseDocument64(input.data(), input.size(), nullptr)); + if (opened.base) { + opened.doc.reset( + EPDFLayer_OpenLayer(opened.base.get(), nullptr, nullptr, nullptr)); + } + } else { + opened.doc.reset( + FPDF_LoadMemDocument64(input.data(), input.size(), nullptr)); + } + return opened; +} + +std::string MetadataOf(FPDF_DOCUMENT doc) { + const unsigned long size = EPDFCatalog_GetMetadata(doc, nullptr, 0); + std::string bytes(size, '\0'); + if (size > 0) { + EXPECT_EQ(size, EPDFCatalog_GetMetadata(doc, bytes.data(), size)); + } + return bytes; +} + +// Sets the packet; on a layer in a transaction, as the engine runs every +// write. +void SetPacket(FPDF_DOCUMENT doc, bool layer, const char* packet) { + if (layer) { + ASSERT_TRUE(EPDFLayer_BeginTransaction(doc)); + } + ASSERT_TRUE(EPDFCatalog_SetMetadata( + doc, packet, packet ? std::char_traits::length(packet) : 0)); + if (layer) { + ASSERT_TRUE(EPDFLayer_CommitTransaction(doc)); + } +} + +// The catalog's /Metadata entry: its object number, and its stream. +struct MetadataEntry { + uint32_t objnum = 0; + RetainPtr stream; +}; + +MetadataEntry EntryOf(FPDF_DOCUMENT doc) { + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc); + CPDF_DocumentViewScope document_view(pdf); + MetadataEntry entry; + RetainPtr reference = + ToReference(pdf->GetRoot()->GetObjectFor("Metadata")); + if (reference) { + entry.objnum = reference->GetRefObjNum(); + } + entry.stream = pdf->GetRoot()->GetStreamFor("Metadata"); + return entry; +} + +} // namespace + +class EPDFCatalogEmbedderTest : public EmbedderTest {}; + +TEST_F(EPDFCatalogEmbedderTest, GetMetadataReadsThePacketDecoded) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + + const unsigned long size = std::char_traits::length(kOldPacket); + EXPECT_EQ(size, EPDFCatalog_GetMetadata(opened.doc.get(), nullptr, 0)); + // A buffer that can't hold every byte gets none. + std::string small(size - 1, '-'); + EXPECT_EQ(size, EPDFCatalog_GetMetadata(opened.doc.get(), small.data(), + small.size())); + EXPECT_EQ(std::string(size - 1, '-'), small); + EXPECT_EQ(kOldPacket, MetadataOf(opened.doc.get())); + } + + const std::string empty = PdfWithoutPacket(); + OpenedPdf without = OpenPdf(empty, false); + ASSERT_TRUE(without.doc); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(without.doc.get(), nullptr, 0)); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(nullptr, nullptr, 0)); +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataReplacesThePacketInPlace) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + for (FPDF_DWORD flags : {FPDF_NO_INCREMENTAL, FPDF_INCREMENTAL}) { + SCOPED_TRACE(layer ? "layer" : "document"); + SCOPED_TRACE(flags == FPDF_INCREMENTAL ? "incremental" : "rewrite"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, kNewPacket)); + + EXPECT_EQ(kNewPacket, MetadataOf(opened.doc.get())); + MetadataEntry entry = EntryOf(opened.doc.get()); + EXPECT_EQ(4u, entry.objnum); + ASSERT_TRUE(entry.stream); + EXPECT_FALSE(entry.stream->HasFilter()); + EXPECT_EQ("Metadata", entry.stream->GetDict()->GetNameFor("Type")); + EXPECT_EQ("XML", entry.stream->GetDict()->GetNameFor("Subtype")); + + ClearString(); + ASSERT_TRUE(FPDF_SaveAsCopy(opened.doc.get(), this, flags)); + const std::string saved = GetString(); + // Written as it is, so XMP readers that scan the file find it. + EXPECT_NE(std::string::npos, saved.find(kNewPacket)); + if (flags == FPDF_NO_INCREMENTAL) { + EXPECT_EQ(std::string::npos, saved.find(Hex(kOldPacket))); + } + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kNewPacket, MetadataOf(reopened.get())); + } + } +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataOnALayerLeavesItsBaseAlone) { + const std::string input = PdfWithPacket(); + std::unique_ptr, + decltype(&EPDF_ReleaseBaseDocument)> + base(EPDF_LoadMemBaseDocument64(input.data(), input.size(), nullptr), + EPDF_ReleaseBaseDocument); + ASSERT_TRUE(base); + ScopedFPDFDocument edited( + EPDFLayer_OpenLayer(base.get(), nullptr, nullptr, nullptr)); + ScopedFPDFDocument sibling( + EPDFLayer_OpenLayer(base.get(), nullptr, nullptr, nullptr)); + ASSERT_TRUE(edited); + ASSERT_TRUE(sibling); + ASSERT_NO_FATAL_FAILURE(SetPacket(edited.get(), true, kNewPacket)); + EXPECT_EQ(kNewPacket, MetadataOf(edited.get())); + EXPECT_EQ(kOldPacket, MetadataOf(sibling.get())); + + // The delta carries the new packet back onto the base. + ClearString(); + EPDFLayerSaveStatus status = EPDFLayerSaveStatus_kSaveFailed; + ASSERT_TRUE(EPDFLayer_SaveDelta(edited.get(), this, &status)); + std::string delta = GetString(); + FPDF_FILEACCESS delta_access = {}; + delta_access.m_FileLen = delta.size(); + delta_access.m_GetBlock = GetBlockFromString; + delta_access.m_Param = δ + ScopedFPDFDocument replayed( + EPDFLayer_OpenLayer(base.get(), &delta_access, nullptr, nullptr)); + ASSERT_TRUE(replayed); + EXPECT_EQ(kNewPacket, MetadataOf(replayed.get())); +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataMakesAStreamWhenThereIsNone) { + const std::string input = PdfWithoutPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, kNewPacket)); + EXPECT_EQ(kNewPacket, MetadataOf(opened.doc.get())); + MetadataEntry entry = EntryOf(opened.doc.get()); + EXPECT_GT(entry.objnum, 3u); + + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + EXPECT_NE(std::string::npos, GetString().find(kNewPacket)); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kNewPacket, MetadataOf(reopened.get())); + } +} + +// A save writes the packet decoded, without its filter, where XMP readers +// find it in the file's bytes. +TEST_F(EPDFCatalogEmbedderTest, SavesWriteAFilteredPacketDecoded) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + EXPECT_NE(std::string::npos, GetString().find(kOldPacket)); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kOldPacket, MetadataOf(reopened.get())); + } +} + +// A save of an encrypted file encrypts its packet with everything else +// (/EncryptMetadata defaults to true), so readers decrypt it back. +TEST_F(EPDFCatalogEmbedderTest, EncryptedSavesKeepThePacketReadable) { + const std::string input = PdfWithPacket(); + std::string encrypted; + { + OpenedPdf opened = OpenPdf(input, false); + ASSERT_TRUE(opened.doc); + ASSERT_TRUE(EPDF_SetEncryption(opened.doc.get(), "", "owner", + EPDF_PERM_PRINT)); + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + encrypted = GetString(); + EXPECT_EQ(std::string::npos, encrypted.find("OLDPRODUCER")); + } + for (bool layer : {false, true}) { + for (FPDF_DWORD flags : {FPDF_NO_INCREMENTAL, FPDF_INCREMENTAL}) { + SCOPED_TRACE(layer ? "layer" : "document"); + SCOPED_TRACE(flags == FPDF_INCREMENTAL ? "incremental" : "rewrite"); + OpenedPdf opened = OpenPdf(encrypted, layer); + ASSERT_TRUE(opened.doc); + ASSERT_TRUE(EPDF_IsEncrypted(opened.doc.get())); + EXPECT_EQ(kOldPacket, MetadataOf(opened.doc.get())); + + // Saved again as it is. + ClearString(); + ASSERT_TRUE(FPDF_SaveAsCopy(opened.doc.get(), this, flags)); + { + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kOldPacket, MetadataOf(reopened.get())); + } + + // And with a new packet. + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, kNewPacket)); + ClearString(); + ASSERT_TRUE(FPDF_SaveAsCopy(opened.doc.get(), this, flags)); + EXPECT_EQ(std::string::npos, GetString().find(kNewPacket)); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(kNewPacket, MetadataOf(reopened.get())); + } + } +} + +TEST_F(EPDFCatalogEmbedderTest, SetMetadataWithoutDataRemovesThePacket) { + const std::string input = PdfWithPacket(); + for (bool layer : {false, true}) { + SCOPED_TRACE(layer ? "layer" : "document"); + OpenedPdf opened = OpenPdf(input, layer); + ASSERT_TRUE(opened.doc); + EXPECT_FALSE(EPDFCatalog_SetMetadata(opened.doc.get(), nullptr, 5)); + EXPECT_EQ(kOldPacket, MetadataOf(opened.doc.get())); + + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, nullptr)); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(opened.doc.get(), nullptr, 0)); + EXPECT_EQ(0u, EntryOf(opened.doc.get()).objnum); + // Removing what isn't there is no failure. + ASSERT_NO_FATAL_FAILURE(SetPacket(opened.doc.get(), layer, nullptr)); + + ClearString(); + ASSERT_TRUE( + FPDF_SaveAsCopy(opened.doc.get(), this, FPDF_NO_INCREMENTAL)); + EXPECT_EQ(std::string::npos, GetString().find(Hex(kOldPacket))); + ScopedSavedDoc reopened = OpenScopedSavedDocument(); + ASSERT_TRUE(reopened); + EXPECT_EQ(0u, EPDFCatalog_GetMetadata(reopened.get(), nullptr, 0)); + } +} diff --git a/public/epdf_catalog.h b/public/epdf_catalog.h index fc32813b3..a889621c1 100644 --- a/public/epdf_catalog.h +++ b/public/epdf_catalog.h @@ -11,7 +11,8 @@ extern "C" { #endif // __cplusplus -// EmbedPDF's additions to public/fpdf_catalog.h: the catalog's language. +// EmbedPDF's additions to public/fpdf_catalog.h: the catalog's language and +// its metadata stream. // Experimental EmbedPDF API. // Gets the Catalog /Lang value as a UTF-16LE string with trailing NUL. @@ -31,6 +32,32 @@ extern "C" { FPDF_EXPORT unsigned long FPDF_CALLCONV EPDFCatalog_GetLanguage(FPDF_DOCUMENT document, void* buffer, unsigned long length); +// Experimental EmbedPDF API. +// The catalog's /Metadata stream (the document's XMP packet) as it reads: its +// bytes with every filter applied. What they say is the caller's to read. +// +// document - handle to a document. +// buffer - destination for the bytes (can be NULL to probe). +// length - size of |buffer| in bytes. +// +// Returns the number of bytes, copying them only when |buffer| holds them +// all. 0 when the catalog has no /Metadata stream, or an empty one. +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFCatalog_GetMetadata(FPDF_DOCUMENT document, void* buffer, unsigned long length); + +// Experimental EmbedPDF API. +// Replaces the bytes of the catalog's /Metadata stream with the |size| bytes +// at |data|, unfiltered, in a stream marked /Type /Metadata /Subtype /XML (so +// a save leaves them uncompressed, as XMP readers expect). The stream keeps +// its object number; a catalog without one gets a new stream. NULL |data| +// removes /Metadata from the catalog. The bytes are not checked: what they +// say is the caller's. +// +// Returns false for an invalid |document|, or for NULL |data| with a +// non-zero |size|. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFCatalog_SetMetadata(FPDF_DOCUMENT document, const void* data, unsigned long size); + #ifdef __cplusplus } // extern "C" #endif // __cplusplus