From 0f98071b1b8723f3ffe10a4eee3294a7ee38082b Mon Sep 17 00:00:00 2001 From: KleiKodesh <211884665+KleiKodesh@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:27:17 +0200 Subject: [PATCH] Count only defined objects in an incremental save's /Size An incremental save wrote /Size from the document's last object number, which includes numbers an editing session reserved with EPDFLayer_RaiseLastObjectNumber() and never used. qpdf reports such a /Size as larger than the highest object number plus one. The /Size and the cross-reference stream's number now follow the highest object number the file defines: the input's objects and /Size, and the objects the save wrote. --- core/fpdfapi/edit/cpdf_creator.cpp | 18 +++++++++++++++ docs/embedpdf/fork-ledger.md | 4 +++- fpdfsdk/epdf_layer_embeddertest.cpp | 34 +++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/core/fpdfapi/edit/cpdf_creator.cpp b/core/fpdfapi/edit/cpdf_creator.cpp index e10c219db..615976b98 100644 --- a/core/fpdfapi/edit/cpdf_creator.cpp +++ b/core/fpdfapi/edit/cpdf_creator.cpp @@ -608,6 +608,24 @@ CPDF_Creator::Stage CPDF_Creator::WriteDoc_Stage3() { DCHECK(stage_ >= Stage::kInitWriteXRefs80 || stage_ < Stage::kWriteTrailerAndFinish90); + if (stage_ == Stage::kInitWriteXRefs80 && is_incremental_) { + // EmbedPDF(save-traversal): an incremental save's /Size and cross-reference + // stream number follow the highest object number the file defines (the + // input's objects and /Size, and the objects this save wrote), not + // numbers the document reserved and never used. A layer delta is + // cumulative from its base, so it writes every object the base lacks. + last_obj_num_ = + object_offsets_.empty() ? 0 : object_offsets_.rbegin()->first; + if (parser_) { + last_obj_num_ = std::max(last_obj_num_, parser_->GetLastObjNum()); + const int original_size = + parser_->GetCombinedTrailer()->GetIntegerFor("Size"); + if (original_size > 0) { + last_obj_num_ = + std::max(last_obj_num_, static_cast(original_size) - 1); + } + } + } uint32_t dwLastObjNum = last_obj_num_; if (stage_ == Stage::kInitWriteXRefs80) { if (is_incremental_ && skip_empty_revision_ && diff --git a/docs/embedpdf/fork-ledger.md b/docs/embedpdf/fork-ledger.md index e50c001d0..10b8bef17 100644 --- a/docs/embedpdf/fork-ledger.md +++ b/docs/embedpdf/fork-ledger.md @@ -185,7 +185,9 @@ generations. ### save-traversal **Rule.** A save picks one trailer and root, walks live objects with a bounded reference index, and reads objects through `CPDF_SaveObjectReader` and a byte-budgeted object stream cache, without -filling the live document's caches. +filling the live document's caches. An incremental save's `/Size` is the highest object number +the file defines (the input's, its `/Size` and the objects the save wrote) plus one; numbers an +editing session reserved and never used don't count. **Why.** Full rewrites of large files kept every object parsed in memory, and stale cross-reference and encryption objects leaked into saves. **Where.** `cpdf_save_trailer`, `cpdf_reference_index`, `cpdf_save_object_reader`, diff --git a/fpdfsdk/epdf_layer_embeddertest.cpp b/fpdfsdk/epdf_layer_embeddertest.cpp index 23d1ff5b4..22d5f5f6f 100644 --- a/fpdfsdk/epdf_layer_embeddertest.cpp +++ b/fpdfsdk/epdf_layer_embeddertest.cpp @@ -1481,6 +1481,40 @@ TEST_F(EPDFLayerEmbedderTest, SaveLayerDeltaMaterializesWithBaseBytes) { EPDF_ReleaseBaseDocument(base); } +// Object numbers reserved with EPDFLayer_RaiseLastObjectNumber() and never +// used don't count in the delta's /Size: it is the highest object number the +// file defines plus one. +TEST_F(EPDFLayerEmbedderTest, SaveLayerDeltaSizeIgnoresUnusedReservedNumbers) { + FileAccessForTesting base_access("rectangles.pdf"); + EPDF_BASE_DOCUMENT base = EPDF_LoadBaseDocument(&base_access, nullptr); + ASSERT_TRUE(base); + { + ScopedFPDFDocument layer( + EPDFLayer_OpenLayer(base, nullptr, nullptr, nullptr)); + ASSERT_TRUE(layer); + const unsigned long last = EPDFLayer_GetLastObjectNumber(layer.get()); + ASSERT_GT(last, 0u); + ASSERT_TRUE(EPDFLayer_RaiseLastObjectNumber(layer.get(), last + 64)); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + { + ScopedFPDFAnnotation annot(EPDFPage_CreateAnnotRaw( + layer.get(), 0, FPDF_ANNOT_SQUARE, last + 1)); + ASSERT_TRUE(annot); + } + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + + ClearString(); + EPDFLayerSaveStatus save_status = EPDFLayerSaveStatus_kSaveFailed; + ASSERT_TRUE(EPDFLayer_SaveDelta(layer.get(), this, &save_status)); + EXPECT_EQ(EPDFLayerSaveStatus_kSuccess, save_status); + const std::string delta = GetString(); + const size_t at = delta.rfind("/Size "); + ASSERT_NE(std::string::npos, at); + EXPECT_EQ(last + 2, std::stoul(delta.substr(at + 6))); + } + EPDF_ReleaseBaseDocument(base); +} + TEST_F(EPDFLayerEmbedderTest, LayerDeltaReplayWithLeadingBytesInBase) { const std::string pdf_path = PathService::GetTestFilePath("rectangles.pdf"); ASSERT_FALSE(pdf_path.empty());