From 5fd93a66f47f39822261a96eb434a840be8fd40c Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Sat, 17 Oct 2026 12:50:49 +0300 Subject: [PATCH 1/3] Add flattened page export API Add EPDFDoc_ExportPagesFlattened to copy pages in a requested order as they draw for screen or print, optionally flattening annotations and form fields. Copy in-memory annotation appearances and their resources without modifying the source, while preserving optional-content visibility and omitting interactive page data. --- core/fpdfdoc/cpdf_annot.cpp | 6 + core/fpdfdoc/cpdf_annot.h | 7 + fpdfsdk/BUILD.gn | 3 + fpdfsdk/epdf_c_api_test.c | 1 + fpdfsdk/epdf_flatten.cpp | 265 +++++++++++++++-- fpdfsdk/epdf_flatten_embeddertest.cpp | 371 +++++++++++++++++++++++- fpdfsdk/epdf_object_copier.cpp | 158 ++++++++++ fpdfsdk/epdf_object_copier.h | 73 +++++ public/epdf_flatten.h | 47 ++- testing/embedpdf_layer_mode_writers.txt | 2 +- 10 files changed, 905 insertions(+), 28 deletions(-) create mode 100644 fpdfsdk/epdf_object_copier.cpp create mode 100644 fpdfsdk/epdf_object_copier.h diff --git a/core/fpdfdoc/cpdf_annot.cpp b/core/fpdfdoc/cpdf_annot.cpp index 55446c64c4..98b26698c4 100644 --- a/core/fpdfdoc/cpdf_annot.cpp +++ b/core/fpdfdoc/cpdf_annot.cpp @@ -307,6 +307,12 @@ std::optional CPDF_Annot::GetDrawingRect(AppearanceMode mode) { return GetRect(); } +// EmbedPDF(annot-appearance, reads-dont-write): what flattening paints for an +// annotation without a stored appearance. +RetainPtr CPDF_Annot::GetAppearanceDrawnInMemory() { + return GetOrBuildEphemeralAP(AppearanceMode::kNormal); +} + CPDF_Annot::Subtype CPDF_Annot::GetSubtype() const { return subtype_; } diff --git a/core/fpdfdoc/cpdf_annot.h b/core/fpdfdoc/cpdf_annot.h index 36139cc888..5609efde6b 100644 --- a/core/fpdfdoc/cpdf_annot.h +++ b/core/fpdfdoc/cpdf_annot.h @@ -238,6 +238,13 @@ class CPDF_Annot { // appearance into the file). std::optional GetDrawingRect(AppearanceMode mode); + // EmbedPDF(annot-appearance, reads-dont-write): what flattening paints for + // an annotation without a stored appearance. + // The normal appearance PDFium draws in memory when there is no stored one, + // built without writing anything. This object keeps it, and the fonts it + // uses, alive. Null when there is a stored appearance or nothing to draw. + RetainPtr GetAppearanceDrawnInMemory(); + bool DrawAppearance(CPDF_Page* pPage, CFX_RenderDevice* pDevice, const CFX_Matrix& mtUser2Device, diff --git a/fpdfsdk/BUILD.gn b/fpdfsdk/BUILD.gn index d6059f391c..9c5b7b4a19 100644 --- a/fpdfsdk/BUILD.gn +++ b/fpdfsdk/BUILD.gn @@ -28,6 +28,9 @@ source_set("fpdfsdk") { "epdf_form.cpp", # EmbedPDF: layer-safe selective page and annotation flattening. "epdf_flatten.cpp", + # EmbedPDF: copies objects across documents and in-memory stores. + "epdf_object_copier.cpp", + "epdf_object_copier.h", "epdf_layer.cpp", "epdf_outline.cpp", # EmbedPDF: the catalog's /Names /Pages and /Templates name trees. diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index 87f99e202c..401773be01 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -271,6 +271,7 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFText_RedactInRect); // epdf_flatten.h + CHK(EPDFDoc_ExportPagesFlattened); CHK(EPDFPage_ExportAnnotationsAsDocument); CHK(EPDFPage_Flatten); CHK(EPDFPage_FlattenAnnotations); diff --git a/fpdfsdk/epdf_flatten.cpp b/fpdfsdk/epdf_flatten.cpp index 7154b82a29..2525ca477b 100644 --- a/fpdfsdk/epdf_flatten.cpp +++ b/fpdfsdk/epdf_flatten.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -19,23 +20,27 @@ #include "constants/annotation_flags.h" #include "constants/font_encodings.h" #include "constants/page_object.h" -#include "core/fxcrt/stream_write_utils.h" #include "core/fpdfapi/page/cpdf_annotcontext.h" +#include "core/fpdfapi/page/cpdf_occontext.h" #include "core/fpdfapi/page/cpdf_page.h" #include "core/fpdfapi/parser/cpdf_array.h" #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_number.h" #include "core/fpdfapi/parser/cpdf_reference.h" #include "core/fpdfapi/parser/cpdf_stream.h" #include "core/fpdfapi/parser/fpdf_parser_utility.h" #include "core/fpdfdoc/cpdf_annot.h" +#include "core/fxcrt/compiler_specific.h" #include "core/fxcrt/fx_string_wrappers.h" +#include "core/fxcrt/numerics/safe_conversions.h" #include "core/fxcrt/span.h" +#include "core/fxcrt/stream_write_utils.h" #include "fpdfsdk/cpdfsdk_helpers.h" -#include "fpdfsdk/epdf_appearance_exporter.h" #include "fpdfsdk/epdf_form_helpers.h" +#include "fpdfsdk/epdf_object_copier.h" #include "fpdfsdk/epdf_object_helpers.h" #include "public/epdf_flatten.h" #include "public/fpdf_edit.h" @@ -47,6 +52,9 @@ struct FlattenCandidate { uint32_t annotation_object_number = 0; RetainPtr annotation; RetainPtr appearance; + // Set when the annotation has no stored appearance and |appearance| is the + // one the renderer draws in memory: it owns that stream and its fonts. + std::unique_ptr drawn; CFX_FloatRect annotation_rect; CFX_FloatRect appearance_rect; CFX_Matrix appearance_matrix; @@ -78,6 +86,17 @@ bool IsValidUsage(int usage) { return usage == FLAT_NORMALDISPLAY || usage == FLAT_PRINT; } +// Which annotations a plan paints: EPDF_EXPORT_FLATTENED_* bits. +constexpr int kAllKinds = + EPDF_EXPORT_FLATTENED_ANNOTATIONS | EPDF_EXPORT_FLATTENED_FORM_FIELDS; + +bool IsOfKinds(const CPDF_Dictionary* annotation, int kinds) { + const bool widget = + annotation->GetByteStringFor(pdfium::annotation::kSubtype) == "Widget"; + return !!(kinds & (widget ? EPDF_EXPORT_FLATTENED_FORM_FIELDS + : EPDF_EXPORT_FLATTENED_ANNOTATIONS)); +} + uint32_t GetObjectNumber(const CPDF_Object* entry, const CPDF_Dictionary* dictionary) { const CPDF_Reference* reference = ToReference(entry); @@ -160,19 +179,35 @@ std::optional MakeCandidate( size_t annotation_index, const CPDF_Object* entry, RetainPtr annotation, - int usage) { - if (!IsEligibleForUsage(annotation.Get(), usage)) { + int usage, + int kinds) { + if (!IsEligibleForUsage(annotation.Get(), usage) || + !IsOfKinds(annotation.Get(), kinds)) { return std::nullopt; } + // Flatten paints what the renderer draws: the stored appearance, or, when + // there is none, the one the renderer builds in memory, placed where the + // renderer places it (which can reach past /Rect: a cloudy border, a + // dimension label). RetainPtr appearance = GetNormalAppearance(document, annotation.Get()); - if (!appearance) { - return std::nullopt; + std::unique_ptr drawn; + CFX_FloatRect annotation_rect; + if (appearance) { + annotation_rect = annotation->GetRectFor(pdfium::annotation::kRect); + } else { + // As the annotation list does: drawing from memory writes nothing, so a + // read-only dictionary serves. + drawn = std::make_unique( + pdfium::WrapRetain(const_cast(annotation.Get())), + document, CPDF_Annot::MissingAppearance::kGenerateWhenDrawn); + appearance = drawn->GetAppearanceDrawnInMemory(); + if (!appearance) { + return std::nullopt; + } + annotation_rect = drawn->GetRect(); } - - CFX_FloatRect annotation_rect = - annotation->GetRectFor(pdfium::annotation::kRect); annotation_rect.Normalize(); if (annotation_rect.IsEmpty()) { return std::nullopt; @@ -202,6 +237,7 @@ std::optional MakeCandidate( candidate.annotation_object_number = GetObjectNumber(entry, annotation.Get()); candidate.annotation = std::move(annotation); candidate.appearance = std::move(appearance); + candidate.drawn = std::move(drawn); candidate.annotation_rect = annotation_rect; candidate.appearance_rect = appearance_rect; candidate.appearance_matrix = appearance_matrix; @@ -215,7 +251,8 @@ std::optional MakeCandidate( FlattenPlan BuildFlattenPlan(CPDF_Document* document, const CPDF_Dictionary* page, pdfium::span targets, - int usage) { + int usage, + int kinds = kAllKinds) { FlattenPlan plan; plan.target_outcomes.assign(targets.size(), kTargetNotOnPage); RetainPtr annotations_entry = @@ -259,8 +296,8 @@ FlattenPlan BuildFlattenPlan(CPDF_Document* document, } } - std::optional candidate = - MakeCandidate(document, i, entry.Get(), std::move(annotation), usage); + std::optional candidate = MakeCandidate( + document, i, entry.Get(), std::move(annotation), usage, kinds); if (candidate) { if (slot.has_value()) { plan.target_outcomes[*slot] = static_cast(plan.candidates.size()); @@ -702,9 +739,14 @@ int ApplyFlattenPlan(CPDF_Document* document, std::vector prepared; prepared.reserve(candidates.size()); + // An appearance drawn in memory keeps its fonts in a store of its own: + // copied into the document with everything it reaches. + epdf::ObjectCopier copier(document); for (FlattenCandidate& candidate : candidates) { RetainPtr stream = - ToStream(candidate.appearance->CloneForHolder(document)); + candidate.drawn + ? copier.CopyStream(candidate.appearance.Get()) + : ToStream(candidate.appearance->CloneForHolder(document)); if (!stream) { continue; } @@ -800,6 +842,9 @@ int FlattenPage(CPDF_Page* page, if (!document || !const_page || !IsValidUsage(usage)) { return FLATTEN_FAIL; } + // Reads the page's annotations and what their appearances need (fonts, + // the form's defaults) as this layer sees them. + CPDF_DocumentViewScope document_view(document); FlattenPlan plan = BuildFlattenPlan(document, const_page.Get(), targets, usage); @@ -826,6 +871,116 @@ int FlattenPage(CPDF_Page* page, return rc; } +// The keys of a page dictionary that say how the page draws: all a copy of +// the page as it draws keeps. Left out with the rest: /Annots, the page's +// actions (/AA), its article beads (/B), its structure and its thumbnail. +constexpr const char* kDrawingKeys[] = { + pdfium::page_object::kMediaBox, + pdfium::page_object::kCropBox, + pdfium::page_object::kBleedBox, + pdfium::page_object::kTrimBox, + pdfium::page_object::kArtBox, + "BoxColorInfo", + pdfium::page_object::kRotate, + "UserUnit", + pdfium::page_object::kResources, + pdfium::page_object::kContents, + "Group", +}; + +// Of those, the ones a page takes from the page tree when it has none. +bool IsInheritable(ByteStringView key) { + return key == pdfium::page_object::kMediaBox || + key == pdfium::page_object::kCropBox || + key == pdfium::page_object::kResources || + key == pdfium::page_object::kRotate; +} + +RetainPtr GetPageEntry(const CPDF_Dictionary* page, + ByteStringView key) { + std::set visited; + const CPDF_Dictionary* current = page; + while (current && !visited.contains(current)) { + RetainPtr value = current->GetObjectFor(key); + if (value || !IsInheritable(key)) { + return value; + } + visited.insert(current); + current = current->GetDictFor(pdfium::page_object::kParent).Get(); + } + return nullptr; +} + +// Makes |dest| draw as |source| does: its drawing keys copied, inherited ones +// included, and nothing interactive. +void CopyPageDrawing(epdf::ObjectCopier& copier, + const CPDF_Dictionary* source, + CPDF_Dictionary* dest) { + for (const char* key : kDrawingKeys) { + RetainPtr value = copier.Copy(GetPageEntry(source, key).Get()); + if (value) { + dest->SetFor(key, std::move(value)); + } + } + // As page import does without a media box: the crop box, else US Letter. + if (!dest->KeyExist(pdfium::page_object::kMediaBox)) { + CFX_FloatRect crop_box = dest->GetRectFor(pdfium::page_object::kCropBox); + crop_box.Normalize(); + dest->SetRectFor( + pdfium::page_object::kMediaBox, + crop_box.IsEmpty() ? CFX_FloatRect(0, 0, 612, 792) : crop_box); + } + if (!dest->KeyExist(pdfium::page_object::kResources)) { + dest->SetNewFor(pdfium::page_object::kResources); + } +} + +// Gives the copy's optional content the state the source's has for |usage|. +// The copy has no configuration of its own, and a viewer turns on a group it +// finds in none, so the groups that are off for |usage| are off in the copy's +// default configuration. A group's own /Usage travels with it. +void KeepOptionalContentState(CPDF_Document* source, + CPDF_Document* dest, + const epdf::ObjectCopier& copier, + int usage) { + auto context = pdfium::MakeRetain( + source, + usage == FLAT_PRINT ? CPDF_OCContext::kPrint : CPDF_OCContext::kView); + std::vector groups; + std::vector off; + for (const auto& [object, number] : copier.copied()) { + const CPDF_Dictionary* group = object->AsDictionary(); + if (!group || group->GetNameFor("Type") != "OCG") { + continue; + } + groups.push_back(number); + if (!context->CheckOCGDictVisible(group)) { + off.push_back(number); + } + } + if (groups.empty()) { + return; + } + // In object order, so the same pages make the same file. + std::sort(groups.begin(), groups.end()); + std::sort(off.begin(), off.end()); + + RetainPtr properties = + dest->GetMutableRoot()->SetNewFor("OCProperties"); + RetainPtr all = properties->SetNewFor("OCGs"); + for (uint32_t number : groups) { + all->AppendNew(dest, number); + } + RetainPtr config = + properties->SetNewFor("D"); + if (!off.empty()) { + RetainPtr off_array = config->SetNewFor("OFF"); + for (uint32_t number : off) { + off_array->AppendNew(dest, number); + } + } +} + } // namespace FPDF_EXPORT int FPDF_CALLCONV EPDFPage_Flatten(FPDF_PAGE page, int usage) { @@ -867,6 +1022,7 @@ EPDFPage_ExportAnnotationsAsDocument(FPDF_PAGE page, if (!src_doc || !src_page) { return nullptr; } + CPDF_DocumentViewScope document_view(src_doc); std::vector targets; targets.reserve(annot_count); @@ -912,15 +1068,15 @@ EPDFPage_ExportAnnotationsAsDocument(FPDF_PAGE page, return nullptr; } - // One exporter for the whole set: resources shared between appearances - // (a font used by three stamps) are cloned once. - AnnotAppearanceExporter exporter(dest_doc, src_doc); + // One copier for the whole set: resources shared between appearances (a + // font used by three stamps) are copied once. + epdf::ObjectCopier copier(dest_doc); std::vector prepared; prepared.reserve(plan.candidates.size()); bool ok = true; for (FlattenCandidate& candidate : plan.candidates) { RetainPtr stream = - exporter.ExportFormXObject(candidate.appearance); + copier.CopyStream(candidate.appearance.Get()); if (!stream) { ok = false; break; @@ -940,3 +1096,78 @@ EPDFPage_ExportAnnotationsAsDocument(FPDF_PAGE page, } return exported; } + +FPDF_EXPORT FPDF_DOCUMENT FPDF_CALLCONV +EPDFDoc_ExportPagesFlattened(FPDF_DOCUMENT document, + const int* page_indices, + unsigned long length, + int usage, + int flags) { + CPDF_Document* src_doc = CPDFDocumentFromFPDFDocument(document); + if (!src_doc || !page_indices || length == 0 || !IsValidUsage(usage) || + (flags & ~kAllKinds)) { + return nullptr; + } + CPDF_DocumentViewScope document_view(src_doc); + // SAFETY: required from caller. + auto indices = UNSAFE_BUFFERS( + pdfium::span(page_indices, pdfium::checked_cast(length))); + const int page_count = src_doc->GetPageCount(); + for (int index : indices) { + if (index < 0 || index >= page_count) { + return nullptr; + } + } + + FPDF_DOCUMENT exported = FPDF_CreateNewDocument(); + CPDF_Document* dest_doc = CPDFDocumentFromFPDFDocument(exported); + if (!dest_doc) { + if (exported) { + FPDF_CloseDocument(exported); + } + return nullptr; + } + + // One copier for every page: what pages and appearances share (a font, an + // image, a resource dictionary) is copied once. + epdf::ObjectCopier copier(dest_doc); + bool ok = true; + for (size_t i = 0; ok && i < indices.size(); ++i) { + RetainPtr src_page = + src_doc->GetPageDictionary(indices[i]); + RetainPtr dest_page = + dest_doc->CreateNewPage(pdfium::checked_cast(i)); + if (!src_page || !dest_page) { + ok = false; + break; + } + CopyPageDrawing(copier, src_page.Get(), dest_page.Get()); + if (!flags) { + continue; + } + + // The same plan, and the same placement, as flattening the page in place. + FlattenPlan plan = + BuildFlattenPlan(src_doc, src_page.Get(), {}, usage, flags); + std::vector prepared; + prepared.reserve(plan.candidates.size()); + for (FlattenCandidate& candidate : plan.candidates) { + RetainPtr stream = + copier.CopyStream(candidate.appearance.Get()); + if (!stream) { + ok = false; + break; + } + prepared.push_back({std::move(candidate), std::move(stream)}); + } + if (ok && !prepared.empty()) { + ok = PaintAppearances(dest_doc, dest_page.Get(), prepared, CFX_PointF()); + } + } + if (!ok) { + FPDF_CloseDocument(exported); + return nullptr; + } + KeepOptionalContentState(src_doc, dest_doc, copier, usage); + return exported; +} diff --git a/fpdfsdk/epdf_flatten_embeddertest.cpp b/fpdfsdk/epdf_flatten_embeddertest.cpp index 3d641eeb97..49b1f4c187 100644 --- a/fpdfsdk/epdf_flatten_embeddertest.cpp +++ b/fpdfsdk/epdf_flatten_embeddertest.cpp @@ -11,17 +11,23 @@ #include #include "core/fpdfapi/page/cpdf_annotcontext.h" +#include "core/fpdfapi/page/cpdf_page.h" +#include "core/fpdfapi/parser/cpdf_array.h" #include "core/fpdfapi/parser/cpdf_dictionary.h" #include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_indirect_object_holder.h" #include "core/fpdfapi/parser/cpdf_name.h" #include "core/fpdfapi/parser/cpdf_number.h" #include "core/fpdfapi/parser/cpdf_reference.h" #include "core/fpdfapi/parser/cpdf_stream.h" +#include "core/fpdfapi/parser/cpdf_string.h" #include "fpdfsdk/cpdfsdk_helpers.h" +#include "fpdfsdk/epdf_object_copier.h" #include "public/epdf_annot.h" #include "public/epdf_form.h" #include "public/epdf_layer.h" #include "public/epdf_save.h" +#include "public/epdf_view.h" #include "public/fpdf_annot.h" #include "public/fpdf_edit.h" #include "public/fpdf_flatten.h" @@ -77,6 +83,56 @@ ScopedFPDFBitmap WhiteBitmap(int width, int height) { return bitmap; } +// A yellow highlight over |rect| with no stored appearance: the renderer +// draws one in memory. +RetainPtr AddHighlightWithoutAppearance( + CPDF_Document* document, + CPDF_Dictionary* page, + const CFX_FloatRect& rect) { + auto annotation = document->NewIndirect(); + annotation->SetNewFor("Type", "Annot"); + annotation->SetNewFor("Subtype", "Highlight"); + annotation->SetRectFor("Rect", rect); + annotation->SetNewFor("F", FPDF_ANNOT_FLAG_PRINT); + RetainPtr quad = annotation->SetNewFor("QuadPoints"); + for (float value : {rect.left, rect.top, rect.right, rect.top, rect.left, + rect.bottom, rect.right, rect.bottom}) { + quad->AppendNew(value); + } + RetainPtr color = annotation->SetNewFor("C"); + for (float value : {1.0f, 1.0f, 0.0f}) { + color->AppendNew(value); + } + RetainPtr annots = page->GetMutableArrayFor("Annots"); + if (!annots) { + annots = page->SetNewFor("Annots"); + } + annots->AppendNew(document, annotation->GetObjNum()); + return annotation; +} + +constexpr int kAllKinds = + EPDF_EXPORT_FLATTENED_ANNOTATIONS | EPDF_EXPORT_FLATTENED_FORM_FIELDS; + +// The appearance forms the copy's page paints: the forms inside the wrapper +// flatten adds (0 when nothing was painted). +size_t CountPaintedAppearances(FPDF_DOCUMENT copy, int page_index) { + RetainPtr page = + CPDFDocumentFromFPDFDocument(copy)->GetPageDictionary(page_index); + RetainPtr resources = + page ? page->GetDictFor("Resources") : nullptr; + RetainPtr xobjects = + resources ? resources->GetDictFor("XObject") : nullptr; + RetainPtr wrapper = + xobjects ? xobjects->GetStreamFor("FFT0") : nullptr; + if (!wrapper) { + return 0; + } + RetainPtr forms = + wrapper->GetDict()->GetDictFor("Resources")->GetDictFor("XObject"); + return forms ? forms->size() : 0; +} + } // namespace class EPDFFlattenEmbedderTest : public EmbedderTest { @@ -157,14 +213,15 @@ TEST_F(EPDFFlattenEmbedderTest, ASSERT_TRUE(page); ASSERT_EQ(FLATTEN_SUCCESS, EPDFPage_Flatten(page.get(), FLAT_NORMALDISPLAY)); - // The Text annotation (object 6) has no appearance, and loading the page - // draws its icon in memory without writing one: like every annotation with - // nothing to paint, it stays. - EXPECT_EQ(3, FPDFPage_GetAnnotCount(page.get())); + // The Text annotation (object 6) has no appearance; the renderer draws its + // icon in memory, and flatten paints what the renderer draws. The hidden + // square (5) and the invisible one (9) stay. + EXPECT_EQ(2, FPDFPage_GetAnnotCount(page.get())); EXPECT_FALSE(EPDFPage_GetAnnotByObjectNumber(page.get(), 4u)); + EXPECT_FALSE(EPDFPage_GetAnnotByObjectNumber(page.get(), 6u)); EXPECT_FALSE(EPDFPage_GetAnnotByObjectNumber(page.get(), 13u)); EXPECT_FALSE(EPDFPage_GetAnnotByObjectNumber(page.get(), 16u)); - for (unsigned int object_number : {5u, 6u, 9u}) { + for (unsigned int object_number : {5u, 9u}) { ScopedFPDFAnnotation annotation( EPDFPage_GetAnnotByObjectNumber(page.get(), object_number)); EXPECT_TRUE(annotation) << object_number; @@ -244,7 +301,8 @@ TEST_F(EPDFFlattenEmbedderTest, FlattenPageIsLayerSafeAndDeltaDurable) { EXPECT_FALSE(EPDFLayer_IsObjectPromoted(document.layer, 15u)); EXPECT_FALSE(EPDFLayer_IsObjectPromoted(document.layer, 17u)); - EXPECT_EQ(3, FPDFPage_GetAnnotCount(page.get())); + // The Text annotation without an appearance is painted from memory too. + EXPECT_EQ(2, FPDFPage_GetAnnotCount(page.get())); ClearString(); EPDFLayerSaveStatus save_status = EPDFLayerSaveStatus_kSaveFailed; @@ -266,7 +324,7 @@ TEST_F(EPDFFlattenEmbedderTest, FlattenPageIsLayerSafeAndDeltaDurable) { ScopedFPDFPage replay_page(FPDF_LoadPage(replay.get(), 0)); ASSERT_TRUE(replay_page); - EXPECT_EQ(3, FPDFPage_GetAnnotCount(replay_page.get())); + EXPECT_EQ(2, FPDFPage_GetAnnotCount(replay_page.get())); EPDF_FORM_MODEL model = EPDFForm_LoadModel(replay.get()); ASSERT_TRUE(model); ASSERT_EQ(1, EPDFForm_CountFields(model)); @@ -562,3 +620,302 @@ TEST_F(EPDFFlattenEmbedderTest, ExportAnnotationsDeduplicatesSharedResources) { EXPECT_EQ(1u, font_objects.size()); EXPECT_FALSE(font_objects.contains(0u)); } + +TEST_F(EPDFFlattenEmbedderTest, FlattenPaintsTheAppearanceDrawnInMemory) { + ScopedFPDFDocument doc(FPDF_CreateNewDocument()); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + { + ScopedFPDFPage blank(FPDFPage_New(doc.get(), 0, 200, 200)); + ASSERT_TRUE(blank); + } + RetainPtr highlight = + AddHighlightWithoutAppearance(pdf, pdf->GetMutablePageDictionary(0).Get(), + CFX_FloatRect(20, 20, 120, 40)); + + ScopedFPDFPage page(FPDF_LoadPage(doc.get(), 0)); + ASSERT_TRUE(page); + ScopedFPDFBitmap drawn = RenderPageWithFlags(page.get(), nullptr, FPDF_ANNOT); + ScopedFPDFBitmap blank = WhiteBitmap(200, 200); + ASSERT_NE(HashBitmap(blank.get()), HashBitmap(drawn.get())); + + ASSERT_EQ(FLATTEN_SUCCESS, EPDFPage_Flatten(page.get(), FLAT_NORMALDISPLAY)); + EXPECT_EQ(0, FPDFPage_GetAnnotCount(page.get())); + // Painted into the page, not written onto the annotation. + EXPECT_FALSE(highlight->KeyExist("AP")); + + // Saved and opened again, the page shows what the renderer drew. + ASSERT_TRUE(FPDF_SaveAsCopy(doc.get(), this, 0)); + ScopedSavedDoc saved = OpenScopedSavedDocument(); + ASSERT_TRUE(saved); + ScopedSavedPage saved_page = LoadScopedSavedPage(0); + ASSERT_TRUE(saved_page); + EXPECT_EQ(0, FPDFPage_GetAnnotCount(saved_page.get())); + ScopedFPDFBitmap painted = RenderSavedPage(saved_page.get()); + EXPECT_EQ(HashBitmap(drawn.get()), HashBitmap(painted.get())); +} + +TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedPrintsAFieldDrawnInMemory) { + // Opened as the engine opens a document: no form-fill environment, which + // would write appearances onto the fields when a page loads. + const std::string path = PathService::GetTestFilePath("text_form.pdf"); + ASSERT_FALSE(path.empty()); + std::vector bytes = GetFileContents(path.c_str()); + ScopedFPDFDocument doc( + FPDF_LoadMemDocument64(bytes.data(), bytes.size(), nullptr)); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + // The text field has no appearance: the renderer draws its value in + // memory, in the form's default font. + RetainPtr field = + ToDictionary(pdf->GetMutableIndirectObject(4u)); + ASSERT_TRUE(field); + ASSERT_FALSE(field->KeyExist("AP")); + field->SetNewFor("V", "Printed"); + field->SetNewFor("F", FPDF_ANNOT_FLAG_PRINT); + + ScopedFPDFPage page(FPDF_LoadPage(doc.get(), 0)); + ASSERT_TRUE(page); + // Fields draw with EPDF_RENDER_WIDGETS, as the engine draws them. + ScopedFPDFBitmap source = RenderPageWithFlags( + page.get(), nullptr, EPDF_RENDER_WIDGETS | FPDF_PRINTING); + ScopedFPDFBitmap content_only = + RenderPageWithFlags(page.get(), nullptr, FPDF_PRINTING); + ASSERT_NE(HashBitmap(content_only.get()), HashBitmap(source.get())); + + const int index = 0; + ScopedFPDFDocument copy(EPDFDoc_ExportPagesFlattened(doc.get(), &index, 1, + FLAT_PRINT, kAllKinds)); + ASSERT_TRUE(copy); + ASSERT_EQ(1, FPDF_GetPageCount(copy.get())); + ScopedFPDFPage copy_page(FPDF_LoadPage(copy.get(), 0)); + ASSERT_TRUE(copy_page); + EXPECT_EQ(0, FPDFPage_GetAnnotCount(copy_page.get())); + EXPECT_EQ(1u, CountPaintedAppearances(copy.get(), 0)); + ScopedFPDFBitmap printed = + RenderPageWithFlags(copy_page.get(), nullptr, FPDF_PRINTING); + EXPECT_EQ(HashBitmap(source.get()), HashBitmap(printed.get())); + + // The copy is page content only, and the source is untouched. + EXPECT_FALSE(CPDFDocumentFromFPDFDocument(copy.get()) + ->GetRoot() + ->KeyExist("AcroForm")); + EXPECT_FALSE(field->KeyExist("AP")); + EXPECT_EQ(1, FPDFPage_GetAnnotCount(page.get())); +} + +TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedLeavesThePageActionsOut) { + ASSERT_TRUE(OpenDocument("get_page_aaction.pdf")); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(document()); + ASSERT_TRUE(pdf->GetPageDictionary(0)->KeyExist("AA")); + + const int index = 0; + ScopedFPDFDocument copy(EPDFDoc_ExportPagesFlattened(document(), &index, 1, + FLAT_PRINT, kAllKinds)); + ASSERT_TRUE(copy); + RetainPtr page = + CPDFDocumentFromFPDFDocument(copy.get())->GetPageDictionary(0); + ASSERT_TRUE(page); + EXPECT_FALSE(page->KeyExist("AA")); + EXPECT_FALSE(page->KeyExist("Annots")); + EXPECT_TRUE(page->KeyExist("MediaBox")); + EXPECT_TRUE(page->KeyExist("Resources")); + ScopedFPDFPage loaded(FPDF_LoadPage(copy.get(), 0)); + ASSERT_TRUE(loaded); + EXPECT_FALSE(FPDF_GetPageAAction(loaded.get(), FPDFPAGE_AACTION_OPEN)); + EXPECT_FALSE(FPDF_GetPageAAction(loaded.get(), FPDFPAGE_AACTION_CLOSE)); +} + +TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedKeepsTheLayersPrintState) { + ScopedFPDFDocument doc(FPDF_CreateNewDocument()); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + + // One layer on screen but never on paper, one on paper but never on + // screen. + auto make_layer = [&](const char* name, const char* view, const char* print) { + auto layer = pdf->NewIndirect(); + layer->SetNewFor("Type", "OCG"); + layer->SetNewFor("Name", ByteString(name)); + RetainPtr usage = + layer->SetNewFor("Usage"); + usage->SetNewFor("View")->SetNewFor("ViewState", + view); + usage->SetNewFor("Print")->SetNewFor( + "PrintState", print); + return layer; + }; + RetainPtr screen_only = make_layer("Screen", "ON", "OFF"); + RetainPtr paper_only = make_layer("Paper", "OFF", "ON"); + RetainPtr properties = + pdf->GetMutableRoot()->SetNewFor("OCProperties"); + RetainPtr groups = properties->SetNewFor("OCGs"); + groups->AppendNew(pdf, screen_only->GetObjNum()); + groups->AppendNew(pdf, paper_only->GetObjNum()); + properties->SetNewFor("D"); + + RetainPtr page_dict = pdf->CreateNewPage(0); + ASSERT_TRUE(page_dict); + page_dict->SetRectFor("MediaBox", CFX_FloatRect(0, 0, 100, 100)); + RetainPtr layers = + page_dict->SetNewFor("Resources") + ->SetNewFor("Properties"); + layers->SetNewFor("S", pdf, screen_only->GetObjNum()); + layers->SetNewFor("P", pdf, paper_only->GetObjNum()); + auto content = pdf->NewIndirect(pdf->New()); + const ByteString ops = + "/OC /S BDC 1 0 0 rg 10 10 30 30 re f EMC " + "/OC /P BDC 0 0 1 rg 60 60 30 30 re f EMC"; + content->SetData(ops.unsigned_span()); + page_dict->SetNewFor("Contents", pdf, content->GetObjNum()); + + ScopedFPDFPage page(FPDF_LoadPage(doc.get(), 0)); + ASSERT_TRUE(page); + ScopedFPDFBitmap on_screen = RenderPageWithFlags(page.get(), nullptr, 0); + ScopedFPDFBitmap on_paper = + RenderPageWithFlags(page.get(), nullptr, FPDF_PRINTING); + ASSERT_NE(HashBitmap(on_screen.get()), HashBitmap(on_paper.get())); + + const int index = 0; + ScopedFPDFDocument copy( + EPDFDoc_ExportPagesFlattened(doc.get(), &index, 1, FLAT_PRINT, 0)); + ASSERT_TRUE(copy); + CPDF_Document* copy_pdf = CPDFDocumentFromFPDFDocument(copy.get()); + RetainPtr copy_properties = + copy_pdf->GetRoot()->GetDictFor("OCProperties"); + ASSERT_TRUE(copy_properties); + EXPECT_EQ(2u, copy_properties->GetArrayFor("OCGs")->size()); + RetainPtr off = + copy_properties->GetDictFor("D")->GetArrayFor("OFF"); + ASSERT_TRUE(off); + ASSERT_EQ(1u, off->size()); + EXPECT_EQ("Screen", off->GetDictAt(0)->GetUnicodeTextFor("Name").ToUTF8()); + + // The copy prints as the source prints, and a viewer that reads only the + // configuration shows the same. + ScopedFPDFPage copy_page(FPDF_LoadPage(copy.get(), 0)); + ASSERT_TRUE(copy_page); + ScopedFPDFBitmap printed = + RenderPageWithFlags(copy_page.get(), nullptr, FPDF_PRINTING); + EXPECT_EQ(HashBitmap(on_paper.get()), HashBitmap(printed.get())); +} + +TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedPaintsTheChosenKinds) { + ASSERT_TRUE(OpenDocument("flatten_selective.pdf")); + // For print: the square (4) and the widget (13); the hidden (5), the + // invisible (9) and the unflagged (6, 16) annotations don't print. + const int index = 0; + struct { + int flags; + size_t painted; + } cases[] = { + {0, 0u}, + {EPDF_EXPORT_FLATTENED_ANNOTATIONS, 1u}, + {EPDF_EXPORT_FLATTENED_FORM_FIELDS, 1u}, + {kAllKinds, 2u}, + }; + for (const auto& test_case : cases) { + ScopedFPDFDocument copy(EPDFDoc_ExportPagesFlattened( + document(), &index, 1, FLAT_PRINT, test_case.flags)); + ASSERT_TRUE(copy) << test_case.flags; + EXPECT_EQ(test_case.painted, CountPaintedAppearances(copy.get(), 0)) + << test_case.flags; + } + ScopedPage page = LoadScopedPage(0); + ASSERT_TRUE(page); + EXPECT_EQ(6, FPDFPage_GetAnnotCount(page.get())); +} + +TEST_F(EPDFFlattenEmbedderTest, + ExportPagesFlattenedCopiesPagesInTheGivenOrder) { + ScopedFPDFDocument doc(FPDF_CreateNewDocument()); + ASSERT_TRUE(doc); + for (int i = 0; i < 3; ++i) { + ScopedFPDFPage page(FPDFPage_New(doc.get(), i, 100.0 + 10 * i, 200)); + ASSERT_TRUE(page); + } + const int indices[] = {2, 0}; + ScopedFPDFDocument copy(EPDFDoc_ExportPagesFlattened(doc.get(), indices, 2, + FLAT_PRINT, kAllKinds)); + ASSERT_TRUE(copy); + ASSERT_EQ(2, FPDF_GetPageCount(copy.get())); + ScopedFPDFPage first(FPDF_LoadPage(copy.get(), 0)); + ScopedFPDFPage second(FPDF_LoadPage(copy.get(), 1)); + EXPECT_FLOAT_EQ(120.0f, FPDF_GetPageWidthF(first.get())); + EXPECT_FLOAT_EQ(100.0f, FPDF_GetPageWidthF(second.get())); +} + +TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedRefusesWhatItCantCopy) { + ASSERT_TRUE(OpenDocument("flatten_selective.pdf")); + const int index = 0; + const int beyond = 1; + EXPECT_FALSE( + EPDFDoc_ExportPagesFlattened(nullptr, &index, 1, FLAT_PRINT, kAllKinds)); + EXPECT_FALSE(EPDFDoc_ExportPagesFlattened(document(), nullptr, 1, FLAT_PRINT, + kAllKinds)); + EXPECT_FALSE(EPDFDoc_ExportPagesFlattened(document(), &index, 0, FLAT_PRINT, + kAllKinds)); + EXPECT_FALSE(EPDFDoc_ExportPagesFlattened(document(), &beyond, 1, FLAT_PRINT, + kAllKinds)); + EXPECT_FALSE( + EPDFDoc_ExportPagesFlattened(document(), &index, 1, 99, kAllKinds)); + EXPECT_FALSE(EPDFDoc_ExportPagesFlattened(document(), &index, 1, FLAT_PRINT, + kAllKinds << 1)); +} + +TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedFromALayerWritesNothing) { + LayerDocument document; + ASSERT_TRUE(OpenLayer("flatten_selective.pdf", &document)); + // For display, the Text annotation without an appearance is painted from + // memory: drawing it there reads the layer and writes nothing to it. + const int index = 0; + ScopedFPDFDocument copy(EPDFDoc_ExportPagesFlattened( + document.layer, &index, 1, FLAT_NORMALDISPLAY, kAllKinds)); + ASSERT_TRUE(copy); + // The squares (4, 16), the widget (13) and the Text annotation (6). + EXPECT_EQ(4u, CountPaintedAppearances(copy.get(), 0)); + EXPECT_EQ(0ul, EPDFLayer_GetPromotedObjectCount(document.layer)); +} + +TEST_F(EPDFFlattenEmbedderTest, CopierFollowsReferencesByWhatTheyResolveTo) { + ScopedFPDFDocument doc(FPDF_CreateNewDocument()); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + // An appearance drawn in memory numbers its fonts from 1 in a store of its + // own, while the document has an object 1 of its own (its catalog). + CPDF_IndirectObjectHolder store; + auto font = store.NewIndirect(); + font->SetNewFor("Type", "Font"); + font->SetNewFor("BaseFont", "InMemory"); + ASSERT_EQ(1u, font->GetObjNum()); + ASSERT_TRUE(pdf->GetIndirectObject(1u)); + auto document_font = pdf->NewIndirect(); + document_font->SetNewFor("BaseFont", "InTheDocument"); + + auto appearance = + pdfium::MakeRetain(pdfium::MakeRetain()); + RetainPtr fonts = + appearance->GetMutableDict() + ->SetNewFor("Resources") + ->SetNewFor("Font"); + fonts->SetNewFor("F1", &store, 1u); + fonts->SetNewFor("F2", pdf, document_font->GetObjNum()); + fonts->SetNewFor("F3", &store, 1u); + + epdf::ObjectCopier copier(pdf); + RetainPtr copy = copier.CopyStream(appearance.Get()); + ASSERT_TRUE(copy); + ASSERT_NE(0u, copy->GetObjNum()); + RetainPtr copied_fonts = + copy->GetDict()->GetDictFor("Resources")->GetDictFor("Font"); + RetainPtr in_memory = copied_fonts->GetDictFor("F1"); + ASSERT_TRUE(in_memory); + EXPECT_EQ("InMemory", in_memory->GetNameFor("BaseFont")); + EXPECT_NE(1u, in_memory->GetObjNum()); + // Copied once, however many references reach it. + EXPECT_EQ(in_memory->GetObjNum(), + copied_fonts->GetDictFor("F3")->GetObjNum()); + // The document's own object is referenced, not copied. + EXPECT_EQ(document_font->GetObjNum(), + copied_fonts->GetDictFor("F2")->GetObjNum()); +} diff --git a/fpdfsdk/epdf_object_copier.cpp b/fpdfsdk/epdf_object_copier.cpp new file mode 100644 index 0000000000..ae67eb11ad --- /dev/null +++ b/fpdfsdk/epdf_object_copier.cpp @@ -0,0 +1,158 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "fpdfsdk/epdf_object_copier.h" + +#include + +#include "core/fpdfapi/parser/cpdf_array.h" +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_null.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_stream.h" +#include "core/fxcrt/bytestring.h" + +namespace epdf { + +namespace { + +bool IsPageTreeNode(const CPDF_Object* object) { + const CPDF_Dictionary* dictionary = object->AsDictionary(); + if (!dictionary) { + return false; + } + const ByteString type = dictionary->GetNameFor("Type"); + return type.EqualNoCase("Page") || type.EqualNoCase("Pages"); +} + +} // namespace + +ObjectCopier::ObjectCopier(CPDF_Document* dest) : dest_(dest) {} + +ObjectCopier::~ObjectCopier() = default; + +RetainPtr ObjectCopier::Copy(const CPDF_Object* object) { + if (!object) { + return nullptr; + } + if (const CPDF_Reference* reference = object->AsReference()) { + const uint32_t number = CopyReferenced(reference); + return number ? pdfium::MakeRetain(dest_.get(), number) + : nullptr; + } + // An indirect object handed over directly, or a stream (which the file + // only holds as an object of its own): referenced by its copy. + if (object->GetObjNum() != 0 || object->IsStream()) { + const uint32_t number = CopyIndirect(object); + return number ? pdfium::MakeRetain(dest_.get(), number) + : nullptr; + } + RetainPtr copy = object->Clone(); + if (copy) { + Retarget(copy.Get()); + } + return copy; +} + +RetainPtr ObjectCopier::CopyStream(const CPDF_Stream* stream) { + const uint32_t number = CopyIndirect(stream); + return number ? ToStream(dest_->GetMutableIndirectObject(number)) : nullptr; +} + +uint32_t ObjectCopier::CopyIndirect(const CPDF_Object* source) { + if (!source) { + return 0; + } + const uint32_t number = source->GetObjNum(); + if (number != 0 && dest_->GetIndirectObject(number).Get() == source) { + return number; // the destination's own object + } + const auto it = copied_.find(source); + if (it != copied_.end()) { + return it->second; + } + if (IsPageTreeNode(source)) { + return 0; + } + + RetainPtr copy = source->Clone(); + if (!copy) { + return 0; + } + const uint32_t copy_number = dest_->AddIndirectObject(copy); + // Recorded before its references are followed, so a cycle back to it ends + // here. + copied_.emplace(source, copy_number); + sources_.push_back(pdfium::WrapRetain(source)); + Retarget(copy.Get()); + return copy_number; +} + +uint32_t ObjectCopier::CopyReferenced(const CPDF_Reference* reference) { + // Resolved through the reference's own store, not by its number here. + RetainPtr target = reference->GetDirect(); + return target ? CopyIndirect(target.Get()) : 0; +} + +void ObjectCopier::Retarget(CPDF_Object* copy) { + if (CPDF_Stream* stream = copy->AsMutableStream()) { + Retarget(stream->GetMutableDict().Get()); + return; + } + + if (CPDF_Dictionary* dictionary = copy->AsMutableDictionary()) { + std::vector dropped; + std::vector> made_indirect; + { + CPDF_DictionaryLocker locker(dictionary); + for (const auto& item : locker) { + CPDF_Object* value = item.second.Get(); + if (CPDF_Reference* reference = value->AsMutableReference()) { + const uint32_t number = CopyReferenced(reference); + if (number) { + reference->SetRef(dest_.get(), number); + } else { + dropped.push_back(item.first); + } + } else if (value->IsStream()) { + made_indirect.emplace_back(item.first, + dest_->AddIndirectObject(item.second)); + Retarget(value); + } else { + Retarget(value); + } + } + } + for (const ByteString& key : dropped) { + dictionary->RemoveFor(key.AsStringView()); + } + for (const auto& [key, number] : made_indirect) { + dictionary->SetNewFor(key, dest_.get(), number); + } + return; + } + + if (CPDF_Array* array = copy->AsMutableArray()) { + for (size_t i = 0; i < array->size(); ++i) { + RetainPtr value = array->GetMutableObjectAt(i); + if (CPDF_Reference* reference = value->AsMutableReference()) { + const uint32_t number = CopyReferenced(reference); + if (number) { + reference->SetRef(dest_.get(), number); + } else { + // Kept as null, so the entries after it keep their places. + array->SetNewAt(i); + } + } else if (value->IsStream()) { + const uint32_t number = dest_->AddIndirectObject(value); + Retarget(value.Get()); + array->SetNewAt(i, dest_.get(), number); + } else { + Retarget(value.Get()); + } + } + } +} + +} // namespace epdf diff --git a/fpdfsdk/epdf_object_copier.h b/fpdfsdk/epdf_object_copier.h new file mode 100644 index 0000000000..9f73e9e05f --- /dev/null +++ b/fpdfsdk/epdf_object_copier.h @@ -0,0 +1,73 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#ifndef FPDFSDK_EPDF_OBJECT_COPIER_H_ +#define FPDFSDK_EPDF_OBJECT_COPIER_H_ + +#include + +#include +#include + +#include "core/fpdfapi/parser/cpdf_object.h" +#include "core/fxcrt/retain_ptr.h" +#include "core/fxcrt/unowned_ptr.h" + +class CPDF_Document; +class CPDF_Reference; +class CPDF_Stream; + +namespace epdf { + +// Copies PDF objects into one document, wherever they live: another +// document, or the store an appearance drawn in memory keeps its fonts in. +// +// A copy follows references by what they resolve to, never by object number +// alone: an appearance drawn in memory numbers its fonts from 1 in a store of +// its own, so the same number in its source document is another object. +// +// Every indirect object is copied once per copier, however many copies reach +// it. An object the destination already holds is referenced, not copied. A +// reference to a page or to the page tree is dropped: a copied resource never +// brings a document's pages with it. +class ObjectCopier { + public: + explicit ObjectCopier(CPDF_Document* dest); + ~ObjectCopier(); + + ObjectCopier(const ObjectCopier&) = delete; + ObjectCopier& operator=(const ObjectCopier&) = delete; + + // A value for the destination: a reference stays a reference, to the + // destination's copy of what it resolves to; any other object is copied + // with everything it reaches. Null when a reference leads nowhere. + RetainPtr Copy(const CPDF_Object* object); + + // The destination's indirect copy of |stream| (the stream itself when the + // destination holds it already). Null when it can't be copied. + RetainPtr CopyStream(const CPDF_Stream* stream); + + // The indirect object |source| was copied to, if it was: for a caller that + // looks at what a copy brought along (the optional content groups of the + // copied pages, say). + using CopiedObjects = std::map; + const CopiedObjects& copied() const { return copied_; } + + private: + // The destination's object number for the indirect object |source|; 0 when + // it is dropped or can't be copied. + uint32_t CopyIndirect(const CPDF_Object* source); + uint32_t CopyReferenced(const CPDF_Reference* reference); + // Points every reference inside |copy| (a fresh clone) at the + // destination's copies, and makes a direct stream inside it indirect. + void Retarget(CPDF_Object* copy); + + UnownedPtr const dest_; + CopiedObjects copied_; + // Keeps the sources alive while their addresses key |copied_|. + std::vector> sources_; +}; + +} // namespace epdf + +#endif // FPDFSDK_EPDF_OBJECT_COPIER_H_ diff --git a/public/epdf_flatten.h b/public/epdf_flatten.h index 9e1079a6b9..06a214273b 100644 --- a/public/epdf_flatten.h +++ b/public/epdf_flatten.h @@ -12,7 +12,8 @@ extern "C" { #endif // __cplusplus // EmbedPDF's additions to public/fpdf_flatten.h: flattening a page or its -// annotations, and exporting annotations as a document. +// annotations, exporting annotations as a document, and copying pages as they +// draw (a print copy). // Per-annotation outcome of EPDFPage_FlattenAnnotations(). // Painted into the page content and removed from /Annots. @@ -29,8 +30,11 @@ extern "C" { // promoted before any mutation. // // Only annotations whose normal appearance was successfully added to page -// content are removed. Hidden, usage-ineligible, popup, malformed, and -// appearance-less annotations remain in /Annots. Flattened widgets are also +// content are removed. Hidden, usage-ineligible, popup and malformed +// annotations remain in /Annots, and so does one without a stored appearance +// that the renderer doesn't draw from memory either. One the renderer draws +// from memory is painted with that appearance, written into the document +// with its fonts. Flattened widgets are also // detached from the AcroForm field tree; a separate logical field remains as // an unplaced field. // @@ -99,6 +103,43 @@ EPDFPage_ExportAnnotationsAsDocument(FPDF_PAGE page, FPDF_ANNOTATION* annots, int annot_count); +// Which annotations EPDFDoc_ExportPagesFlattened() paints into the pages. +// Every annotation that is not a form field: markup, links, stamps, ... +#define EPDF_EXPORT_FLATTENED_ANNOTATIONS 0x1 +// Form field widgets, as filled. +#define EPDF_EXPORT_FLATTENED_FORM_FIELDS 0x2 + +// Experimental EmbedPDF Extension API. +// Copy pages of |document| into a NEW document, each page as it draws for +// |usage|: with FLAT_PRINT, a print copy. +// +// The annotations |flags| chooses that draw for |usage| are painted into the +// page content, with the plan and placement of EPDFPage_Flatten(). Flattening +// paints what the renderer draws, so an annotation without a stored +// appearance is painted with the one the renderer builds in memory, from the +// source document's form defaults and fonts. +// +// Nothing interactive is copied: no /Annots, no page actions (/AA), no +// article beads, and none of the catalog (outline, form, document scripts). +// Optional content keeps the state it has for |usage|: the copy's default +// configuration turns off the groups that are off. The source is untouched. +// +// document - the source document. +// page_indices - the pages to copy, by index, in the copy's order. +// length - number of entries; > 0. +// usage - exactly one of the |FLAT_*| values. +// flags - EPDF_EXPORT_FLATTENED_* bits; 0 copies the page content +// alone. +// +// Returns a new document (the caller closes it with FPDF_CloseDocument()), +// or NULL for invalid arguments or a copy that failed. +FPDF_EXPORT FPDF_DOCUMENT FPDF_CALLCONV +EPDFDoc_ExportPagesFlattened(FPDF_DOCUMENT document, + const int* page_indices, + unsigned long length, + int usage, + int flags); + #ifdef __cplusplus } // extern "C" #endif // __cplusplus diff --git a/testing/embedpdf_layer_mode_writers.txt b/testing/embedpdf_layer_mode_writers.txt index aedb9a6406..bb9c99a473 100644 --- a/testing/embedpdf_layer_mode_writers.txt +++ b/testing/embedpdf_layer_mode_writers.txt @@ -74,7 +74,7 @@ EPDFAttachmentEmbedderTest.GetAttachmentIndexByKey 6 EPDFFlattenEmbedderTest.FlattenAnnotationSetReportsPerEntry 7 EPDFFlattenEmbedderTest.FlattenMergedWidgetRemovesFieldTreeEntry 12 EPDFFlattenEmbedderTest.FlattenPageHonorsPrintUsage 10 -EPDFFlattenEmbedderTest.FlattenPagePreservesUnpaintedAnnotationsAndDetachesWidget 11 +EPDFFlattenEmbedderTest.FlattenPagePreservesUnpaintedAnnotationsAndDetachesWidget 12 EPDFFlattenEmbedderTest.FlattenSpecificAnnotationByHandle 7 EPDFFormEmbedderTest.AttachToLegacyMergedFieldKeepsFieldId 6 EPDFFormEmbedderTest.AttachWidgetsFormsARadioGroup 11 From 2dc036e5b958fc2bb729aa478d28a50c59ab6486 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Sat, 17 Oct 2026 14:22:01 +0300 Subject: [PATCH 2/3] Leave no-view annotations out of a display flatten The renderer never shows a no-view annotation on screen (it may still print), so flattening for display leaves it in place, and a display copy doesn't paint it. --- fpdfsdk/epdf_flatten.cpp | 5 ++++- fpdfsdk/epdf_flatten_embeddertest.cpp | 31 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/fpdfsdk/epdf_flatten.cpp b/fpdfsdk/epdf_flatten.cpp index 2525ca477b..bcd9642508 100644 --- a/fpdfsdk/epdf_flatten.cpp +++ b/fpdfsdk/epdf_flatten.cpp @@ -114,8 +114,11 @@ bool IsEligibleForUsage(const CPDF_Dictionary* annotation, int usage) { if (flags & pdfium::annotation_flags::kHidden) { return false; } + // On screen the renderer skips a no-view annotation (ISO 32000-2 12.5.3: + // it may still print), on paper one without the print flag. return usage == FLAT_NORMALDISPLAY - ? !(flags & pdfium::annotation_flags::kInvisible) + ? !(flags & (pdfium::annotation_flags::kInvisible | + pdfium::annotation_flags::kNoView)) : !!(flags & pdfium::annotation_flags::kPrint); } diff --git a/fpdfsdk/epdf_flatten_embeddertest.cpp b/fpdfsdk/epdf_flatten_embeddertest.cpp index 49b1f4c187..80f5a1fa19 100644 --- a/fpdfsdk/epdf_flatten_embeddertest.cpp +++ b/fpdfsdk/epdf_flatten_embeddertest.cpp @@ -826,6 +826,37 @@ TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedPaintsTheChosenKinds) { EXPECT_EQ(6, FPDFPage_GetAnnotCount(page.get())); } +TEST_F(EPDFFlattenEmbedderTest, FlattenForDisplayLeavesANoViewAnnotation) { + ASSERT_TRUE(OpenDocument("flatten_selective.pdf")); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(document()); + // The visible square (4) becomes no-view: printed, never shown. + RetainPtr square = + ToDictionary(pdf->GetMutableIndirectObject(4u)); + ASSERT_TRUE(square); + square->SetNewFor( + "F", FPDF_ANNOT_FLAG_NOVIEW | FPDF_ANNOT_FLAG_PRINT); + + const int index = 0; + ScopedFPDFDocument shown( + EPDFDoc_ExportPagesFlattened(document(), &index, 1, FLAT_NORMALDISPLAY, + EPDF_EXPORT_FLATTENED_ANNOTATIONS)); + ScopedFPDFDocument printed(EPDFDoc_ExportPagesFlattened( + document(), &index, 1, FLAT_PRINT, EPDF_EXPORT_FLATTENED_ANNOTATIONS)); + ASSERT_TRUE(shown); + ASSERT_TRUE(printed); + // On screen: the Text annotation (6) and the unflagged square (16). + EXPECT_EQ(2u, CountPaintedAppearances(shown.get(), 0)); + // On paper: the no-view square. + EXPECT_EQ(1u, CountPaintedAppearances(printed.get(), 0)); + + ScopedPage page = LoadScopedPage(0); + ASSERT_TRUE(page); + ScopedFPDFAnnotation no_view(EPDFPage_GetAnnotByObjectNumber(page.get(), 4u)); + ASSERT_TRUE(no_view); + EXPECT_EQ(FLATTEN_NOTHINGTODO, + FlattenOne(page.get(), no_view.get(), FLAT_NORMALDISPLAY)); +} + TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedCopiesPagesInTheGivenOrder) { ScopedFPDFDocument doc(FPDF_CreateNewDocument()); From f8736b174a22de44bed7a8aac168e3aa5d9d2f62 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Sat, 17 Oct 2026 17:18:11 +0300 Subject: [PATCH 3/3] Follow the spec for the Invisible annotation flag ISO 32000-2 12.5.3 limits the Invisible flag to annotations of a non-standard type that no handler draws; on a standard annotation it means nothing. The renderer drew such unknown annotations anyway, and flatten left out every annotation with the flag, so a flattened copy lost standard annotations the viewer showed. Both now ask CPDF_Annot::IsHiddenByInvisibleFlag(). --- core/fpdfdoc/cpdf_annot.cpp | 14 +++++ core/fpdfdoc/cpdf_annot.h | 7 +++ core/fpdfdoc/cpdf_annotlist.cpp | 6 ++ fpdfsdk/epdf_flatten.cpp | 8 ++- fpdfsdk/epdf_flatten_embeddertest.cpp | 77 +++++++++++++++++++++--- testing/embedpdf_layer_mode_writers.txt | 1 + testing/resources/flatten_selective.in | 2 +- testing/resources/flatten_selective.pdf | Bin 2278 -> 2287 bytes 8 files changed, 101 insertions(+), 14 deletions(-) diff --git a/core/fpdfdoc/cpdf_annot.cpp b/core/fpdfdoc/cpdf_annot.cpp index 98b26698c4..e70067325e 100644 --- a/core/fpdfdoc/cpdf_annot.cpp +++ b/core/fpdfdoc/cpdf_annot.cpp @@ -346,6 +346,20 @@ bool CPDF_Annot::IsHidden() const { return !!(GetFlags() & pdfium::annotation_flags::kHidden); } +// EmbedPDF(bug-fix): the Invisible flag applies to unknown types only (header). +// static +bool CPDF_Annot::IsHiddenByInvisibleFlag(const CPDF_Dictionary* annot_dict) { + if (!(annot_dict->GetIntegerFor(pdfium::annotation::kF) & + pdfium::annotation_flags::kInvisible)) { + return false; + } + const ByteString subtype = + annot_dict->GetNameFor(pdfium::annotation::kSubtype); + // Projection is standard since PDF 2.0, though PDFium has no Subtype for it. + return StringToAnnotSubtype(subtype) == Subtype::UNKNOWN && + subtype != "Projection"; +} + // EmbedPDF(reads-dont-write): reads take a const dictionary. RetainPtr GetAnnotAP(const CPDF_Dictionary* pAnnotDict, CPDF_Annot::AppearanceMode eMode) { diff --git a/core/fpdfdoc/cpdf_annot.h b/core/fpdfdoc/cpdf_annot.h index 5609efde6b..cadbbfe703 100644 --- a/core/fpdfdoc/cpdf_annot.h +++ b/core/fpdfdoc/cpdf_annot.h @@ -222,6 +222,13 @@ class CPDF_Annot { bool IsHidden() const; + // EmbedPDF(bug-fix): whether the Invisible flag leaves |annot_dict| undrawn. + // ISO 32000-2 12.5.3 limits the flag to a type that isn't a standard one, + // which no handler draws; on a standard annotation it means nothing. Upstream + // drew such an unknown annotation anyway, and its flatten skipped every + // annotation with the flag. The renderer and flatten both ask here. + static bool IsHiddenByInvisibleFlag(const CPDF_Dictionary* annot_dict); + // EmbedPDF(annot-appearance): draw this state of an appearance subdictionary // (/AP /N /Yes), whatever /AS says. Only a stored state is drawn: a mode // whose entry is a single stream, a state the subdictionary lacks, or no diff --git a/core/fpdfdoc/cpdf_annotlist.cpp b/core/fpdfdoc/cpdf_annotlist.cpp index e1517299f1..84c997c93e 100644 --- a/core/fpdfdoc/cpdf_annotlist.cpp +++ b/core/fpdfdoc/cpdf_annotlist.cpp @@ -211,6 +211,12 @@ void CPDF_AnnotList::DisplayPass(CPDF_RenderContext* context, continue; } + // EmbedPDF(bug-fix): an unknown type with the Invisible flag isn't drawn; + // upstream drew it. + if (CPDF_Annot::IsHiddenByInvisibleFlag(pAnnot->GetAnnotDict())) { + continue; + } + if (bPrinting && (annot_flags & pdfium::annotation_flags::kPrint) == 0) { continue; } diff --git a/fpdfsdk/epdf_flatten.cpp b/fpdfsdk/epdf_flatten.cpp index bcd9642508..e6e8d93bae 100644 --- a/fpdfsdk/epdf_flatten.cpp +++ b/fpdfsdk/epdf_flatten.cpp @@ -110,15 +110,17 @@ bool IsEligibleForUsage(const CPDF_Dictionary* annotation, int usage) { return false; } + // The renderer's rules (CPDF_AnnotList::DisplayPass), so flatten paints what + // it draws. const int flags = annotation->GetIntegerFor("F"); - if (flags & pdfium::annotation_flags::kHidden) { + if ((flags & pdfium::annotation_flags::kHidden) || + CPDF_Annot::IsHiddenByInvisibleFlag(annotation)) { return false; } // On screen the renderer skips a no-view annotation (ISO 32000-2 12.5.3: // it may still print), on paper one without the print flag. return usage == FLAT_NORMALDISPLAY - ? !(flags & (pdfium::annotation_flags::kInvisible | - pdfium::annotation_flags::kNoView)) + ? !(flags & pdfium::annotation_flags::kNoView) : !!(flags & pdfium::annotation_flags::kPrint); } diff --git a/fpdfsdk/epdf_flatten_embeddertest.cpp b/fpdfsdk/epdf_flatten_embeddertest.cpp index 80f5a1fa19..808c6e3b2e 100644 --- a/fpdfsdk/epdf_flatten_embeddertest.cpp +++ b/fpdfsdk/epdf_flatten_embeddertest.cpp @@ -215,7 +215,7 @@ TEST_F(EPDFFlattenEmbedderTest, ASSERT_EQ(FLATTEN_SUCCESS, EPDFPage_Flatten(page.get(), FLAT_NORMALDISPLAY)); // The Text annotation (object 6) has no appearance; the renderer draws its // icon in memory, and flatten paints what the renderer draws. The hidden - // square (5) and the invisible one (9) stay. + // square (5) and the unknown type flagged Invisible (9) stay. EXPECT_EQ(2, FPDFPage_GetAnnotCount(page.get())); EXPECT_FALSE(EPDFPage_GetAnnotByObjectNumber(page.get(), 4u)); EXPECT_FALSE(EPDFPage_GetAnnotByObjectNumber(page.get(), 6u)); @@ -385,15 +385,15 @@ TEST_F(EPDFFlattenEmbedderTest, FlattenAnnotationSetReportsPerEntry) { ASSERT_TRUE(page); ASSERT_EQ(6, FPDFPage_GetAnnotCount(page.get())); - // 4: visible square; 5: hidden square; 9: print-only square (invisible - // for display, so skipped). + // 4: visible square; 5: hidden square; 9: an unknown type flagged + // Invisible, which nothing draws, so skipped. ScopedFPDFAnnotation visible(EPDFPage_GetAnnotByObjectNumber(page.get(), 4u)); ScopedFPDFAnnotation hidden(EPDFPage_GetAnnotByObjectNumber(page.get(), 5u)); - ScopedFPDFAnnotation print_only( + ScopedFPDFAnnotation invisible( EPDFPage_GetAnnotByObjectNumber(page.get(), 9u)); ASSERT_TRUE(visible); ASSERT_TRUE(hidden); - ASSERT_TRUE(print_only); + ASSERT_TRUE(invisible); int status = -1; EXPECT_EQ(FLATTEN_FAIL, @@ -412,7 +412,7 @@ TEST_F(EPDFFlattenEmbedderTest, FlattenAnnotationSetReportsPerEntry) { EXPECT_EQ(EPDF_FLATTEN_STATUS_NOT_ON_PAGE, status); EXPECT_EQ(6, FPDFPage_GetAnnotCount(page.get())); - FPDF_ANNOTATION set[] = {hidden.get(), visible.get(), print_only.get()}; + FPDF_ANNOTATION set[] = {hidden.get(), visible.get(), invisible.get()}; int statuses[3] = {-1, -1, -1}; ASSERT_EQ(FLATTEN_SUCCESS, EPDFPage_FlattenAnnotations(page.get(), set, 3, FLAT_NORMALDISPLAY, @@ -519,16 +519,16 @@ TEST_F(EPDFFlattenEmbedderTest, ExportAnnotationsIsAllOrNothing) { ASSERT_TRUE(page); ScopedFPDFAnnotation visible(EPDFPage_GetAnnotByObjectNumber(page.get(), 4u)); ScopedFPDFAnnotation hidden(EPDFPage_GetAnnotByObjectNumber(page.get(), 5u)); - ScopedFPDFAnnotation print_only( + ScopedFPDFAnnotation invisible( EPDFPage_GetAnnotByObjectNumber(page.get(), 9u)); ASSERT_TRUE(visible); ASSERT_TRUE(hidden); - ASSERT_TRUE(print_only); + ASSERT_TRUE(invisible); FPDF_ANNOTATION with_hidden[] = {visible.get(), hidden.get()}; EXPECT_FALSE( EPDFPage_ExportAnnotationsAsDocument(page.get(), with_hidden, 2)); - FPDF_ANNOTATION with_invisible[] = {visible.get(), print_only.get()}; + FPDF_ANNOTATION with_invisible[] = {visible.get(), invisible.get()}; EXPECT_FALSE( EPDFPage_ExportAnnotationsAsDocument(page.get(), with_invisible, 2)); FPDF_ANNOTATION null_entry[] = {nullptr}; @@ -803,7 +803,8 @@ TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedKeepsTheLayersPrintState) { TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedPaintsTheChosenKinds) { ASSERT_TRUE(OpenDocument("flatten_selective.pdf")); // For print: the square (4) and the widget (13); the hidden (5), the - // invisible (9) and the unflagged (6, 16) annotations don't print. + // invisible unknown type (9) and the unflagged (6, 16) annotations don't + // print. const int index = 0; struct { int flags; @@ -857,6 +858,62 @@ TEST_F(EPDFFlattenEmbedderTest, FlattenForDisplayLeavesANoViewAnnotation) { FlattenOne(page.get(), no_view.get(), FLAT_NORMALDISPLAY)); } +// ISO 32000-2 12.5.3: the Invisible flag leaves out only an annotation of a +// type that isn't standard. A standard one draws whatever the flag says, +// Projection too (standard since PDF 2.0; PDFium has no Subtype for it). The +// renderer and flatten agree, on screen and on paper. +TEST_F(EPDFFlattenEmbedderTest, InvisibleLeavesOutOnlyAnUnknownType) { + struct Case { + const char* subtype; + int flags; + bool drawn; + }; + constexpr int kInvisible = FPDF_ANNOT_FLAG_INVISIBLE | FPDF_ANNOT_FLAG_PRINT; + const Case cases[] = { + {"Square", kInvisible, true}, + {"Projection", kInvisible, true}, + {"MyCompanyMark", FPDF_ANNOT_FLAG_PRINT, true}, + {"MyCompanyMark", kInvisible, false}, + }; + for (const Case& test : cases) { + SCOPED_TRACE(testing::Message() << test.subtype << " /F " << test.flags); + ScopedFPDFDocument doc(FPDF_CreateNewDocument()); + ASSERT_TRUE(doc); + { + ScopedFPDFPage blank(FPDFPage_New(doc.get(), 0, 200, 200)); + ASSERT_TRUE(blank); + ScopedFPDFAnnotation annot = MakeSquareWithAppearance( + blank.get(), {20, 120, 120, 20}, L"0 0 10 10 re f", nullptr); + ASSERT_TRUE(annot); + RetainPtr dict = + CPDFAnnotContextFromFPDFAnnotation(annot.get()) + ->GetMutableAnnotDict(); + dict->SetNewFor("Subtype", test.subtype); + dict->SetNewFor("F", test.flags); + } + + ScopedFPDFPage page(FPDF_LoadPage(doc.get(), 0)); + ASSERT_TRUE(page); + const std::string blank = HashBitmap(WhiteBitmap(200, 200).get()); + ScopedFPDFBitmap on_screen = + RenderPageWithFlags(page.get(), nullptr, FPDF_ANNOT); + ScopedFPDFBitmap on_paper = + RenderPageWithFlags(page.get(), nullptr, FPDF_ANNOT | FPDF_PRINTING); + EXPECT_EQ(test.drawn, HashBitmap(on_screen.get()) != blank); + EXPECT_EQ(test.drawn, HashBitmap(on_paper.get()) != blank); + + const int index = 0; + for (int usage : {FLAT_NORMALDISPLAY, FLAT_PRINT}) { + ScopedFPDFDocument copy( + EPDFDoc_ExportPagesFlattened(doc.get(), &index, 1, usage, kAllKinds)); + ASSERT_TRUE(copy); + EXPECT_EQ(test.drawn ? 1u : 0u, CountPaintedAppearances(copy.get(), 0)); + } + EXPECT_EQ(test.drawn ? FLATTEN_SUCCESS : FLATTEN_NOTHINGTODO, + EPDFPage_Flatten(page.get(), FLAT_NORMALDISPLAY)); + } +} + TEST_F(EPDFFlattenEmbedderTest, ExportPagesFlattenedCopiesPagesInTheGivenOrder) { ScopedFPDFDocument doc(FPDF_CreateNewDocument()); diff --git a/testing/embedpdf_layer_mode_writers.txt b/testing/embedpdf_layer_mode_writers.txt index bb9c99a473..256e94d8e0 100644 --- a/testing/embedpdf_layer_mode_writers.txt +++ b/testing/embedpdf_layer_mode_writers.txt @@ -72,6 +72,7 @@ EPDFAttachmentEmbedderTest.ExtractFileEmptyAttachment 5 EPDFAttachmentEmbedderTest.ExtractFileLargeAttachment 5 EPDFAttachmentEmbedderTest.GetAttachmentIndexByKey 6 EPDFFlattenEmbedderTest.FlattenAnnotationSetReportsPerEntry 7 +EPDFFlattenEmbedderTest.FlattenForDisplayLeavesANoViewAnnotation 1 EPDFFlattenEmbedderTest.FlattenMergedWidgetRemovesFieldTreeEntry 12 EPDFFlattenEmbedderTest.FlattenPageHonorsPrintUsage 10 EPDFFlattenEmbedderTest.FlattenPagePreservesUnpaintedAnnotationsAndDetachesWidget 12 diff --git a/testing/resources/flatten_selective.in b/testing/resources/flatten_selective.in index 7787372371..4f75074a1e 100644 --- a/testing/resources/flatten_selective.in +++ b/testing/resources/flatten_selective.in @@ -59,7 +59,7 @@ endobj {{object 9 0}} << /Type /Annot - /Subtype /Square + /Subtype /EPDFUnknownMark /Rect [130 10 160 40] /F 1 /AP << /N 10 0 R >> diff --git a/testing/resources/flatten_selective.pdf b/testing/resources/flatten_selective.pdf index f53d181b1be2650c1c54c1f14fa1e56662f6ed80..9f69ad9ad69b978b5cd266efee235247b2d890c3 100644 GIT binary patch delta 186 zcmaDR_+D_sK4yN`02jB=yzIRE@;u+fqU_B_nEhDljm;GdKtLf+feXwqFf=qb#Sk+x uG{F=zx5N-LF)+puGd01q!_3eStd1YqVsk^w$qP7aIZe&ER8?L5-M9d$cqs4y delta 174 zcmaDa_)KuaKIX}NjDnkQFgvr<8(AnAfPg}t0vDKJU}$J&h9PETV2UYbZh#?XY-xfa jW@?OShnay9SRFsI#pZ?vlUH)sa++9hsj9mAyKw;khkqo4