diff --git a/BUILD.gn b/BUILD.gn index a3ecb2e1b..81827f26a 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -229,6 +229,8 @@ source_set("pdfium_public_headers_impl") { "public/epdf_form.h", # EmbedPDF: the catalog's /Names /Pages and /Templates name trees. "public/epdf_named_pages.h", + # EmbedPDF: the document outline, addressed by object number. + "public/epdf_outline.h", # EmbedPDF: digital signatures - revisions, the signature model. "public/epdf_signature.h", # EmbedPDF: generic namespace-scoped document/page /PieceInfo metadata. diff --git a/fpdfsdk/BUILD.gn b/fpdfsdk/BUILD.gn index 9c5b7b4a1..39f6de523 100644 --- a/fpdfsdk/BUILD.gn +++ b/fpdfsdk/BUILD.gn @@ -32,7 +32,14 @@ source_set("fpdfsdk") { "epdf_object_copier.cpp", "epdf_object_copier.h", "epdf_layer.cpp", + # EmbedPDF: destination, named destination and action creators + # (public/epdf_doc.h). + "epdf_doc.cpp", + # EmbedPDF: the document outline's writers (public/epdf_outline.h) and + # the mechanics its captures share. "epdf_outline.cpp", + "epdf_outline_helpers.cpp", + "epdf_outline_helpers.h", # EmbedPDF: the catalog's /Names /Pages and /Templates name trees. "epdf_named_pages.cpp", # EmbedPDF: digests (EPDF_DigestBuffer) and the hasher signatures share. @@ -245,6 +252,8 @@ pdfium_embeddertest_source_set("embeddertests") { # EmbedPDF: covers appearance states and modes, widgets in page renders, # and the check box and radio button symbols. "epdf_appearance_state_embeddertest.cpp", + # EmbedPDF: covers the document outline's writers and captures. + "epdf_outline_embeddertest.cpp", # EmbedPDF: covers layer transactions (EPDFLayer_BeginTransaction). "epdf_transaction_embeddertest.cpp", # EmbedPDF: covers annotation identity: handle lifetimes, promotion and diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index 401773be0..60458bab5 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -24,6 +24,7 @@ #include "public/epdf_layer.h" #include "public/epdf_measure.h" #include "public/epdf_named_pages.h" +#include "public/epdf_outline.h" #include "public/epdf_pieceinfo.h" #include "public/epdf_png.h" #include "public/epdf_progressive.h" @@ -220,6 +221,8 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFDoc_ImportDictRaw); CHK(EPDFForm_ExportFieldRawToOwnedBuffer); CHK(EPDFForm_ImportFieldRaw); + CHK(EPDFOutline_ExportItemRawToOwnedBuffer); + CHK(EPDFOutline_ImportItemRaw); CHK(EPDFPage_ExportAnnotsRawToOwnedBuffer); CHK(EPDFPage_ImportAnnotsRaw); @@ -237,15 +240,6 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFAction_CreateRemoteGoToByName); CHK(EPDFAction_CreateRemoteGoToDest); CHK(EPDFAction_CreateURI); - CHK(EPDFBookmark_AppendChild); - CHK(EPDFBookmark_Clear); - CHK(EPDFBookmark_ClearTarget); - CHK(EPDFBookmark_Create); - CHK(EPDFBookmark_Delete); - CHK(EPDFBookmark_InsertAfter); - CHK(EPDFBookmark_SetAction); - CHK(EPDFBookmark_SetDest); - CHK(EPDFBookmark_SetTitle); CHK(EPDFDest_CreateRemoteView); CHK(EPDFDest_CreateRemoteXYZ); CHK(EPDFDest_CreateView); @@ -443,6 +437,18 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFDoc_RemoveNamedPagesForPage); CHK(EPDFDoc_SetNamedPage); + // epdf_outline.h + CHK(EPDFOutline_CreateItem); + CHK(EPDFOutline_DeleteItem); + CHK(EPDFOutline_GetItem); + CHK(EPDFOutline_GetObjectNumber); + CHK(EPDFOutline_MoveItem); + CHK(EPDFOutline_SetAction); + CHK(EPDFOutline_SetColor); + CHK(EPDFOutline_SetOpen); + CHK(EPDFOutline_SetStyle); + CHK(EPDFOutline_SetTitle); + // epdf_pieceinfo.h CHK(EPDFDoc_ClearPagePieceInfoEntry); CHK(EPDFDoc_ClearPagePieceInfoKey); diff --git a/fpdfsdk/epdf_capture.cpp b/fpdfsdk/epdf_capture.cpp index 0549e09ea..d503a96e2 100644 --- a/fpdfsdk/epdf_capture.cpp +++ b/fpdfsdk/epdf_capture.cpp @@ -32,6 +32,7 @@ #include "fpdfsdk/cpdfsdk_helpers.h" #include "fpdfsdk/epdf_form_helpers.h" #include "fpdfsdk/epdf_object_helpers.h" +#include "fpdfsdk/epdf_outline_helpers.h" #include "fpdfsdk/epdf_owned_buffer.h" namespace { @@ -48,6 +49,14 @@ bool IsBoundaryKey(ByteStringView key) { return key == "P" || key == "Parent" || key == "IRT" || key == "OC"; } +// Keys of an outline item a capture doesn't follow: its links to its +// siblings and children (a capture adds the items it holds one by one) and +// its structure element. +bool IsOutlineLinkKey(ByteStringView key) { + return key == "Prev" || key == "Next" || key == "First" || key == "Last" || + key == "SE"; +} + // Objects a capture never holds: the catalog, the page tree and its pages. bool IsBoundaryObject(const CPDF_Object* object) { RetainPtr dict = object->GetDict(); @@ -117,6 +126,13 @@ class CaptureWriter { Drain(); } + // Captures the outline item |objnum| as AddObject() does, without following + // its outline links: the caller adds the items it means one by one. + void AddOutlineItem(uint32_t objnum) { + outline_items_.insert(objnum); + AddObject(objnum); + } + // The header, |manifest|, then every captured object in number order. DataVector Write(const CPDF_Dictionary* manifest) const { fxcrt::ostringstream text; @@ -188,7 +204,26 @@ class CaptureWriter { if (RetainPtr version = layer_->FindLayerVersion(objnum)) { objects_[objnum] = std::move(version); } - CollectReferences(object.Get()); + if (outline_items_.contains(objnum)) { + CollectItemReferences(object->AsDictionary()); + } else { + CollectReferences(object.Get()); + } + } + } + + // What an outline item's own keys reach, past its outline links. Only its + // own: an action it holds directly follows its /Next as any action does. + void CollectItemReferences(const CPDF_Dictionary* item) { + if (!item) { + return; + } + CPDF_DictionaryLocker locker(item); + for (const auto& [key, entry] : locker) { + if (!IsBoundaryKey(key.AsStringView()) && + !IsOutlineLinkKey(key.AsStringView())) { + CollectReferences(entry.Get()); + } } } @@ -215,6 +250,7 @@ class CaptureWriter { } UnownedPtr const layer_; + std::set outline_items_; std::set visited_; std::deque pending_; std::map> objects_; @@ -718,3 +754,116 @@ EPDFForm_ImportFieldRaw(FPDF_DOCUMENT document, } return true; } + +FPDF_EXPORT void* FPDF_CALLCONV +EPDFOutline_ExportItemRawToOwnedBuffer(FPDF_DOCUMENT document, + uint32_t objnum, + unsigned long* out_size) { + if (out_size) { + *out_size = 0; + } + CPDF_LayerDocument* layer = LayerOf(document); + if (!layer || !objnum) { + return nullptr; + } + CPDF_DocumentViewScope view(layer); + std::optional root = epdf::OutlineRootOf(layer); + if (!root || !epdf::IsOutlineItem(layer, *root, objnum)) { + return nullptr; + } + const uint32_t parent = epdf::OutlineParentOf(layer, objnum); + std::optional> siblings = + epdf::OutlineChildNumbers(layer, parent); + if (!siblings) { + return nullptr; + } + auto at = std::ranges::find(*siblings, objnum); + const uint32_t after = at == siblings->begin() ? 0 : *(at - 1); + + // The item, where it was (In: its parent, 0 for the top level; After: the + // sibling it followed, 0 when it was the first), and the items under it. + auto manifest = pdfium::MakeRetain(); + manifest->SetNewFor("Kind", "OutlineItem"); + manifest->SetNewFor("Item", pdfium::checked_cast(objnum)); + manifest->SetNewFor( + "In", parent == *root ? 0 : pdfium::checked_cast(parent)); + manifest->SetNewFor("After", pdfium::checked_cast(after)); + auto items = manifest->SetNewFor("Items"); + CaptureWriter writer(layer); + for (uint32_t item : epdf::OutlineSubtreeOf(layer, objnum)) { + items->AppendNew(pdfium::checked_cast(item)); + writer.AddOutlineItem(item); + } + return ToOwnedBuffer(writer.Write(manifest.Get()), out_size); +} + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFOutline_ImportItemRaw(FPDF_DOCUMENT document, + const void* capture, + unsigned long size) { + CPDF_LayerDocument* layer = LayerOf(document); + if (!layer || !layer->InTransaction() || !capture || !size) { + return false; + } + CPDF_DocumentViewScope view(layer); + // SAFETY: required from caller. + std::optional read = ReadCapture( + layer, + UNSAFE_BUFFERS(pdfium::span(static_cast(capture), size)), + "OutlineItem"); + if (!read) { + return false; + } + const int item = read->manifest->GetIntegerFor("Item"); + const int in = read->manifest->GetIntegerFor("In"); + const int after = read->manifest->GetIntegerFor("After"); + std::optional root = epdf::OutlineRootOf(layer); + if (item <= 0 || in < 0 || after < 0 || !root) { + return false; + } + + // Where it goes must still be there, and it must not be back already. + if (in > 0 && !epdf::IsOutlineItem(layer, *root, in)) { + return false; + } + if (epdf::IsOutlineItem(layer, *root, item)) { + return false; + } + // It comes back from the capture, or as the version the layer reads now + // (the file's original, when the layer never edited it). + if (!read->objects.contains(item) && + !ToDictionary(layer->GetOrParseIndirectObject(item))) { + return false; + } + std::vector children; + if (const uint32_t node = in > 0 ? in : *root) { + std::optional> numbers = + epdf::OutlineChildNumbers(layer, node); + if (!numbers || std::ranges::find(*numbers, static_cast(item)) != + numbers->end()) { + return false; + } + children = std::move(*numbers); + } + // After the sibling it followed while that is still a child here, else + // first. + size_t slot = 0; + if (auto at = std::ranges::find(children, static_cast(after)); + after > 0 && at != children.end()) { + slot = static_cast(at - children.begin()) + 1; + } + + if (!InstallObjects(layer, *read)) { + return false; + } + if (!*root) { + root = epdf::CreateOutlineRoot(layer); + if (!*root) { + return false; + } + } + const uint32_t node = in > 0 ? static_cast(in) : *root; + children.insert(children.begin() + slot, static_cast(item)); + return epdf::WriteOutlineChildren(layer, node, children) && + epdf::RecountOutline(layer, *root, item); +} diff --git a/fpdfsdk/epdf_doc.cpp b/fpdfsdk/epdf_doc.cpp new file mode 100644 index 000000000..20f8e27de --- /dev/null +++ b/fpdfsdk/epdf_doc.cpp @@ -0,0 +1,487 @@ +// Copyright 2025 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +// Experimental EmbedPDF destination, named destination and action creators +// (public/epdf_doc.h). They are factored out of fpdf_doc.cpp to keep that +// file small; the outline's writers are in epdf_outline.cpp. + +#include "public/fpdf_doc.h" + +#include + +#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_name.h" +#include "core/fpdfapi/parser/cpdf_null.h" +#include "core/fpdfapi/parser/cpdf_number.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_string.h" +#include "core/fpdfdoc/cpdf_dest.h" +#include "core/fxcrt/compiler_specific.h" +#include "core/fxcrt/retain_ptr.h" +#include "core/fxcrt/widestring.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "fpdfsdk/epdf_object_helpers.h" +#include "public/epdf_doc.h" + +namespace { + // Appends the page dictionary reference as the first element of |arr|. + bool AppendPageDict(CPDF_Array* arr, CPDF_Page* page) { + if (!arr || !page) + return false; + CPDF_Document* doc = page->GetDocument(); + if (!doc) + return false; + RetainPtr dict = page->GetDict(); + if (!dict) + return false; + arr->AppendNew(doc, dict->GetObjNum()); + return true; + } + + constexpr const char* kViewNames[] = { + "Unknown", "XYZ", "Fit", "FitH", "FitV", "FitR", "FitB", "FitBH", "FitBV"}; + constexpr uint8_t kViewMaxParams[] = {0, 3, 0, 1, 1, 4, 0, 1, 1}; + + // Non-XYZ: write N numeric params, padding with null up to |need|: a + // missing /FitH top keeps the viewer's current one. + static inline void AppendNonXYZParams(CPDF_Array* arr, + const FS_FLOAT* params, + unsigned long num_params, + uint8_t need) { + const unsigned long use = std::min(num_params, need); + for (unsigned long i = 0; i < need; ++i) { + if (i < use && params) { + arr->AppendNew(static_cast(params[i])); + } else { + arr->AppendNew(); + } + } + } + + // XYZ: each of {left, top, zoom} may be null; zoom==0 means "unspecified". + static inline void AppendXYZParams(CPDF_Array* arr, + FPDF_BOOL has_left, FS_FLOAT left, + FPDF_BOOL has_top, FS_FLOAT top, + FPDF_BOOL has_zoom, FS_FLOAT zoom) { + if (has_left) { + arr->AppendNew(static_cast(left)); + } else { + arr->AppendNew(); + } + + if (has_top) { + arr->AppendNew(static_cast(top)); + } else { + arr->AppendNew(); + } + + if (has_zoom && zoom != 0.0f) { + arr->AppendNew(static_cast(zoom)); + } else { + arr->AppendNew(); + } + } + + static RetainPtr GetOrCreateDestsNamesArray(CPDF_Document* doc) { + CPDF_Dictionary* root = doc->GetMutableRoot(); + if (!root) + return nullptr; + + RetainPtr names = root->GetMutableDictFor("Names"); + if (!names) { + names = doc->NewIndirect(); + root->SetNewFor("Names", doc, names->GetObjNum()); + } + + RetainPtr dests = names->GetMutableDictFor("Dests"); + if (!dests) { + dests = doc->NewIndirect(); + names->SetNewFor("Dests", doc, dests->GetObjNum()); + } + + RetainPtr arr = dests->GetMutableArrayFor("Names"); + if (!arr) { + arr = doc->NewIndirect(); + dests->SetNewFor("Names", doc, arr->GetObjNum()); + } + return arr; + } + + // Insert/replace a (name, value) pair in the flat /Names array, keeping it sorted. + // `value` may be DIRECT (that’s what we want). + static bool SetNameTreePairSorted(CPDF_Document* doc, + CPDF_Array* names_arr, + const ByteString& key, + RetainPtr value) { + if (!names_arr) + return false; + + // Default insertion point is "append to end". + int insert_at = static_cast(names_arr->size()); + + // Entries are (key, value) pairs at indices (0,1), (2,3), ... + for (size_t i = 0; i + 1 < names_arr->size(); i += 2) { + RetainPtr key_obj = names_arr->GetDirectObjectAt(i); + ByteString cur = key_obj ? key_obj->GetString() : ByteString(); + + const int cmp = key.Compare(cur.AsStringView()); + if (cmp == 0) { + // Replace existing value at i+1. + names_arr->SetAt(i + 1, std::move(value)); + return true; + } + if (cmp < 0) { + insert_at = static_cast(i); + break; + } + } + + // Insert new (key, value) pair at `insert_at`. + names_arr->InsertAt(insert_at, doc->New(key)); + names_arr->InsertAt(insert_at + 1, std::move(value)); + return true; + } + + // Remove a (name, value) pair if present. + static bool RemoveNameTreePair(CPDF_Array* names_arr, const ByteString& key) { + if (!names_arr) + return false; + + for (size_t i = 0; i + 1 < names_arr->size(); i += 2) { + RetainPtr key_obj = names_arr->GetDirectObjectAt(i); + ByteString cur = key_obj ? key_obj->GetString() : ByteString(); + if (cur == key.AsStringView()) { + // Remove value then key. + names_arr->RemoveAt(i + 1); + names_arr->RemoveAt(i); + return true; + } + } + return false; + } +} // namespace + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFNamedDest_SetDest(FPDF_DOCUMENT fdoc, + FPDF_BYTESTRING name, + FPDF_DEST fdest) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + CPDF_Array* dest = CPDFArrayFromFPDFDest(fdest); + if (!doc || !name || !dest) + return false; + + // Must be indirect in this document. + const uint32_t objnum = dest->GetObjNum(); + if (objnum == 0 || doc->GetIndirectObject(objnum) != dest) + return false; + + RetainPtr first = dest->GetDirectObjectAt(0); + RetainPtr page_dict = ToDictionary(first); + if (!page_dict) + return false; + if (!epdf::IsIndirectObjectOf(doc, page_dict.Get())) + return false; + + RetainPtr names_arr = GetOrCreateDestsNamesArray(doc); + if (!names_arr) + return false; + + // Value is a reference to the dest array object. + auto ref = pdfium::MakeRetain(doc, objnum); + return SetNameTreePairSorted(doc, names_arr.Get(), ByteString(name), std::move(ref)); +} + +// Remove a named destination mapping (no orphaning; the value was DIRECT). +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFNamedDest_Remove(FPDF_DOCUMENT fdoc, FPDF_BYTESTRING name) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + if (!doc || !name) + return false; + + RetainPtr names_arr = GetOrCreateDestsNamesArray(doc); + if (!names_arr) + return false; + + return RemoveNameTreePair(names_arr.Get(), ByteString(name)); +} + +FPDF_EXPORT FPDF_DEST FPDF_CALLCONV +EPDFDest_CreateView(FPDF_PAGE fpage, + unsigned long view, + const FS_FLOAT* params, + unsigned long num_params) { + CPDF_Page* page = CPDFPageFromFPDFPage(fpage); + if (!page) + return nullptr; + CPDF_Document* doc = page->GetDocument(); + if (!doc) + return nullptr; + + // Validate view (must be a non-XYZ mode we know). + if (view == PDFDEST_VIEW_XYZ || view < PDFDEST_VIEW_FIT || view > PDFDEST_VIEW_FITBV) + return nullptr; + + const uint8_t need = kViewMaxParams[view]; + + // INDIRECT array: [ pageRef /Fit* ... ] + auto arr = doc->NewIndirect(); + if (!AppendPageDict(arr.Get(), page)) + return nullptr; + + arr->AppendNew(kViewNames[view]); + AppendNonXYZParams(arr.Get(), params, num_params, need); + return FPDFDestFromCPDFArray(arr.Get()); +} + +FPDF_EXPORT FPDF_DEST FPDF_CALLCONV +EPDFDest_CreateXYZ(FPDF_PAGE fpage, + FPDF_BOOL has_left, FS_FLOAT left, + FPDF_BOOL has_top, FS_FLOAT top, + FPDF_BOOL has_zoom, FS_FLOAT zoom) { + CPDF_Page* page = CPDFPageFromFPDFPage(fpage); + if (!page) + return nullptr; + CPDF_Document* doc = page->GetDocument(); + if (!doc) + return nullptr; + + // INDIRECT array: [ pageRef /XYZ left? top? zoom? ] + auto arr = doc->NewIndirect(); + if (!AppendPageDict(arr.Get(), page)) + return nullptr; + + arr->AppendNew("XYZ"); + AppendXYZParams(arr.Get(), has_left, left, has_top, top, has_zoom, zoom); + return FPDFDestFromCPDFArray(arr.Get()); +} + +FPDF_EXPORT FPDF_DEST FPDF_CALLCONV +EPDFDest_CreateRemoteView(FPDF_DOCUMENT fdoc, + int page_index, + unsigned long view, + const FS_FLOAT* params, + unsigned long num_params) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + if (!doc || page_index < 0) + return nullptr; + if (view == PDFDEST_VIEW_XYZ || view < PDFDEST_VIEW_FIT || view > PDFDEST_VIEW_FITBV) + return nullptr; + + const uint8_t need = kViewMaxParams[view]; + + // Create an INDIRECT array: [ pageIndex /Fit* ... ] + auto arr = doc->NewIndirect(); + arr->AppendNew(page_index); + arr->AppendNew(kViewNames[view]); + AppendNonXYZParams(arr.Get(), params, num_params, need); + return FPDFDestFromCPDFArray(arr.Get()); +} + +FPDF_EXPORT FPDF_DEST FPDF_CALLCONV +EPDFDest_CreateRemoteXYZ(FPDF_DOCUMENT fdoc, + int page_index, + FPDF_BOOL has_left, FS_FLOAT left, + FPDF_BOOL has_top, FS_FLOAT top, + FPDF_BOOL has_zoom, FS_FLOAT zoom) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + if (!doc || page_index < 0) + return nullptr; + + // INDIRECT array: [ pageIndex /XYZ left? top? zoom? ] + auto arr = doc->NewIndirect(); + arr->AppendNew(page_index); + arr->AppendNew("XYZ"); + AppendXYZParams(arr.Get(), has_left, left, has_top, top, has_zoom, zoom); + return FPDFDestFromCPDFArray(arr.Get()); +} + +FPDF_EXPORT FPDF_DEST FPDF_CALLCONV +EPDFDest_CreateXYZByObjectNumber(FPDF_DOCUMENT fdoc, + unsigned int page_obj_num, + FPDF_BOOL has_left, FS_FLOAT left, + FPDF_BOOL has_top, FS_FLOAT top, + FPDF_BOOL has_zoom, FS_FLOAT zoom) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + if (!doc || page_obj_num == 0 || doc->GetPageIndex(page_obj_num) < 0) + return nullptr; + + // INDIRECT array: [ pageRef /XYZ left? top? zoom? ] + auto arr = doc->NewIndirect(); + arr->AppendNew(doc, page_obj_num); + arr->AppendNew("XYZ"); + AppendXYZParams(arr.Get(), has_left, left, has_top, top, has_zoom, zoom); + return FPDFDestFromCPDFArray(arr.Get()); +} + +FPDF_EXPORT FPDF_DEST FPDF_CALLCONV +EPDFDest_CreateViewByObjectNumber(FPDF_DOCUMENT fdoc, + unsigned int page_obj_num, + unsigned long view, + const FS_FLOAT* params, + unsigned long num_params) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + if (!doc || page_obj_num == 0 || doc->GetPageIndex(page_obj_num) < 0) + return nullptr; + if (view == PDFDEST_VIEW_XYZ || view < PDFDEST_VIEW_FIT || view > PDFDEST_VIEW_FITBV) + return nullptr; + + // INDIRECT array: [ pageRef /Fit* ... ] + auto arr = doc->NewIndirect(); + arr->AppendNew(doc, page_obj_num); + arr->AppendNew(kViewNames[view]); + AppendNonXYZParams(arr.Get(), params, num_params, kViewMaxParams[view]); + return FPDFDestFromCPDFArray(arr.Get()); +} + +FPDF_EXPORT unsigned int FPDF_CALLCONV +EPDFDest_GetViewNullParams(FPDF_DEST dest) { + const CPDF_Array* arr = CPDFArrayFromFPDFDest(dest); + if (!arr) + return 0; + + CPDF_Dest destination(pdfium::WrapRetain(arr)); + const size_t count = destination.GetNumParams(); + unsigned int mask = 0; + for (size_t i = 0; i < count; ++i) { + RetainPtr param = arr->GetDirectObjectAt(2 + i); + if (!param || param->IsNull()) + mask |= 1u << i; + } + return mask; +} + +FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV +EPDFAction_CreateGoTo(FPDF_DOCUMENT document, FPDF_DEST dest) { + CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); + if (!pDoc || !dest) + return nullptr; + + CPDF_Array* pDestArray = CPDFArrayFromFPDFDest(dest); + if (!pDestArray) + return nullptr; + + // We expect |dest| to belong to |document| and to be indirect (created by EPDFDest_*()). + // Bail out if it's not indirect to avoid cross-doc or inline ownership issues. + if (pDestArray->GetObjNum() == 0) + return nullptr; + + RetainPtr first = pDestArray->GetDirectObjectAt(0); + RetainPtr page_dict = ToDictionary(first); + if (!page_dict) + return nullptr; + if (!epdf::IsIndirectObjectOf(pDoc, page_dict.Get())) + return nullptr; + + RetainPtr action = pDoc->NewIndirect(); + action->SetNewFor("S", "GoTo"); + action->SetNewFor("D", pDoc, pDestArray->GetObjNum()); + + return FPDFActionFromCPDFDictionary(action.Get()); +} + +FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV +EPDFAction_CreateGoToNamed(FPDF_DOCUMENT fdoc, FPDF_BYTESTRING name) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + if (!doc || !name) + return nullptr; + RetainPtr action = doc->NewIndirect(); + action->SetNewFor("S", "GoTo"); + action->SetNewFor("D", ByteString(name)); // name-as-string + return FPDFActionFromCPDFDictionary(action.Get()); +} + +FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV +EPDFAction_CreateLaunch(FPDF_DOCUMENT document, FPDF_WIDESTRING file_path) { + CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); + if (!pDoc || !file_path) + return nullptr; + + WideString wpath = UNSAFE_BUFFERS(WideStringFromFPDFWideString(file_path)); + + RetainPtr action = pDoc->NewIndirect(); + action->SetNewFor("S", "Launch"); + // Simple FileSpec-as-string. Callers can upgrade to a full FileSpec later. + action->SetNewFor("F", wpath.AsStringView()); + return FPDFActionFromCPDFDictionary(action.Get()); +} + +FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV +EPDFAction_CreateRemoteGoToByName(FPDF_DOCUMENT document, + FPDF_WIDESTRING file_path, + FPDF_WIDESTRING named_dest) { + CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); + if (!pDoc || !file_path || !named_dest) + return nullptr; + + WideString wpath = UNSAFE_BUFFERS(WideStringFromFPDFWideString(file_path)); + WideString wname = UNSAFE_BUFFERS(WideStringFromFPDFWideString(named_dest)); + + RetainPtr action = pDoc->NewIndirect(); + action->SetNewFor("S", "GoToR"); + action->SetNewFor("F", wpath.AsStringView()); + // Named destination in the *target* doc, stored as a text string. + action->SetNewFor("D", wname.AsStringView()); + return FPDFActionFromCPDFDictionary(action.Get()); +} + +FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV +EPDFAction_CreateRemoteGoToDest(FPDF_DOCUMENT fdoc, + FPDF_WIDESTRING file_path, + FPDF_DEST fdest) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); + if (!doc || !file_path || !fdest) + return nullptr; + + CPDF_Array* dest = CPDFArrayFromFPDFDest(fdest); + if (!dest) + return nullptr; + + // The dest must be an INDIRECT object in this doc. + const uint32_t objnum = dest->GetObjNum(); + if (objnum == 0 || doc->GetIndirectObject(objnum) != dest) + return nullptr; + + // Basic shape check for remote explicit destinations: + // First element must be a page index (number). + RetainPtr first = dest->GetDirectObjectAt(0); + if (!first || !first->IsNumber()) + return nullptr; + + WideString wpath = UNSAFE_BUFFERS(WideStringFromFPDFWideString(file_path)); + + auto action = doc->NewIndirect(); + action->SetNewFor("S", "GoToR"); + action->SetNewFor("F", wpath.AsStringView()); + action->SetNewFor("D", doc, objnum); + return FPDFActionFromCPDFDictionary(action.Get()); +} + +FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV +EPDFAction_CreateURI(FPDF_DOCUMENT document, FPDF_BYTESTRING uri) { + CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); + if (!pDoc || !uri) + return nullptr; + + RetainPtr action = pDoc->NewIndirect(); + action->SetNewFor("S", "URI"); + // Store as a byte string (UTF-8 as provided by caller). + action->SetNewFor("URI", ByteString(uri)); + + return FPDFActionFromCPDFDictionary(action.Get()); +} + +FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV +EPDFAction_CreateNamed(FPDF_DOCUMENT document, FPDF_BYTESTRING name) { + CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); + if (!pDoc || !name || !name[0]) + return nullptr; + + RetainPtr action = pDoc->NewIndirect(); + action->SetNewFor("S", "Named"); + action->SetNewFor("N", ByteString(name)); + + return FPDFActionFromCPDFDictionary(action.Get()); +} diff --git a/fpdfsdk/epdf_object_helpers.cpp b/fpdfsdk/epdf_object_helpers.cpp index 62b28ad20..8115cfafc 100644 --- a/fpdfsdk/epdf_object_helpers.cpp +++ b/fpdfsdk/epdf_object_helpers.cpp @@ -8,6 +8,7 @@ #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_layer_document.h" #include "core/fpdfapi/parser/cpdf_reference.h" namespace epdf { @@ -54,4 +55,18 @@ bool IsAnnotationOfPage(CPDF_Page* page, CPDF_AnnotContext* annotation) { : page_dictionary.Get() == annotation_page_dictionary.Get(); } +bool IsIndirectObjectOf(const CPDF_Document* doc, const CPDF_Object* object) { + if (!doc || !object) { + return false; + } + const uint32_t objnum = object->GetObjNum(); + if (objnum == 0) { + return false; + } + if (const CPDF_LayerDocument* layer = CPDF_LayerDocument::FromDocument(doc)) { + return layer->IsVersionOf(objnum, object); + } + return doc->GetIndirectObject(objnum) == object; +} + } // namespace epdf diff --git a/fpdfsdk/epdf_object_helpers.h b/fpdfsdk/epdf_object_helpers.h index 9d0dc6e54..5204b2aef 100644 --- a/fpdfsdk/epdf_object_helpers.h +++ b/fpdfsdk/epdf_object_helpers.h @@ -11,6 +11,7 @@ class CPDF_AnnotContext; class CPDF_Array; class CPDF_Dictionary; class CPDF_Document; +class CPDF_Object; class CPDF_Page; namespace epdf { @@ -30,6 +31,13 @@ RetainPtr GetMutableArrayMember(CPDF_Document* doc, // say it: another document numbers its own objects. bool IsAnnotationOfPage(CPDF_Page* page, CPDF_AnnotContext* annotation); +// Whether |object| is an indirect object of |doc|: the one |doc| holds under +// its number, or on a layer any version of it. A handle read before a layer +// transaction holds the committed (or base) version, which a write in the +// transaction copies: still this document's. Another document's object, or a +// direct one, never is. +bool IsIndirectObjectOf(const CPDF_Document* doc, const CPDF_Object* object); + } // namespace epdf #endif // FPDFSDK_EPDF_OBJECT_HELPERS_H_ diff --git a/fpdfsdk/epdf_outline.cpp b/fpdfsdk/epdf_outline.cpp index 278e1d99e..4eff87a6c 100644 --- a/fpdfsdk/epdf_outline.cpp +++ b/fpdfsdk/epdf_outline.cpp @@ -1,925 +1,342 @@ -// Copyright 2025 CloudPDF LTD +// Copyright 2026 CloudPDF LTD // SPDX-License-Identifier: Apache-2.0 -// Experimental EmbedPDF outline/destination/action helpers and APIs. -// These functions are factored out of fpdf_doc.cpp to keep that file small. +#include "public/epdf_outline.h" -#include "public/fpdf_doc.h" +#include +#include +#include +#include +#include -#include - -#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_layer_document.h" -#include "core/fpdfapi/parser/cpdf_name.h" -#include "core/fpdfapi/parser/cpdf_null.h" #include "core/fpdfapi/parser/cpdf_number.h" #include "core/fpdfapi/parser/cpdf_reference.h" #include "core/fpdfapi/parser/cpdf_string.h" -#include "core/fpdfdoc/cpdf_dest.h" #include "core/fxcrt/compiler_specific.h" -#include "core/fxcrt/retain_ptr.h" #include "core/fxcrt/widestring.h" #include "fpdfsdk/cpdfsdk_helpers.h" -#include "public/epdf_doc.h" - -namespace { - // Appends the page dictionary reference as the first element of |arr|. - bool AppendPageDict(CPDF_Array* arr, CPDF_Page* page) { - if (!arr || !page) - return false; - CPDF_Document* doc = page->GetDocument(); - if (!doc) - return false; - RetainPtr dict = page->GetDict(); - if (!dict) - return false; - arr->AppendNew(doc, dict->GetObjNum()); - return true; - } - - // Returns the document root's /Outlines dict, creating one if needed. - RetainPtr GetOrCreateOutlines(CPDF_Document* doc) { - CPDF_Dictionary* root = doc->GetMutableRoot(); - if (!root) - return nullptr; - - // We plan to write under /Outlines, so use the mutable getter. - RetainPtr outlines = root->GetMutableDictFor("Outlines"); - if (outlines) - return outlines; - - outlines = doc->NewIndirect(); - outlines->SetNewFor("Type", "Outlines"); - root->SetNewFor("Outlines", doc, outlines->GetObjNum()); - return outlines; - } - - // Does |obj| belong to |doc| as an indirect object? - bool BelongsTo(const CPDF_Document* doc, const CPDF_Object* obj) { - if (!doc || !obj) - return false; - const uint32_t num = obj->GetObjNum(); - if (num == 0) { - return false; - } - // EmbedPDF: on a layer, a handle read before a transaction holds the - // committed (or base) version, which a write in the transaction copies: - // still this document's. - if (const CPDF_LayerDocument* layer = - CPDF_LayerDocument::FromDocument(doc)) { - return layer->IsVersionOf(num, obj); - } - return doc->GetIndirectObject(num) == obj; - } - - // EmbedPDF: the bookmark dictionary a write may change. With |doc|, the - // document's writable version of the same object (a layer transaction - // copies it up); the caller has checked BelongsTo() first. Without |doc|, - // |bookmark| itself, and only while it may be written: one read before a - // layer transaction belongs to a committed version. - RetainPtr WritableBookmark(CPDF_Document* doc, - CPDF_Dictionary* bookmark) { - if (!bookmark) { - return nullptr; - } - if (doc && bookmark->GetObjNum() != 0) { - return ToDictionary(doc->GetMutableIndirectObject(bookmark->GetObjNum())); - } - return bookmark->IsWritable() ? pdfium::WrapRetain(bookmark) : nullptr; +#include "fpdfsdk/epdf_object_helpers.h" +#include "fpdfsdk/epdf_outline_helpers.h" + +namespace { + +// The outline of the document a call works on: the document, and the number +// of its /Root /Outlines (0 when it has none). Empty when the document isn't +// one, or its /Outlines is a direct dictionary. +struct Outline { + CPDF_Document* doc = nullptr; + uint32_t root = 0; +}; + +std::optional OutlineOf(CPDF_Document* doc) { + if (!doc || !doc->GetRoot()) { + return std::nullopt; } - - // Create a new indirect bookmark dictionary with a UTF-16 title. - RetainPtr CreateBookmarkDict(CPDF_Document* doc, - const WideString& title) { - RetainPtr node = doc->NewIndirect(); - if (!title.IsEmpty()) - node->SetNewFor("Title", title.AsStringView()); - return node; - } - - // Unlink |node| from its current parent/sibling list. Clears node's /Prev,/Next. - // Leaves /Parent in place (caller will overwrite on insert or may inspect it). - void UnlinkFromParent(CPDF_Document* doc, CPDF_Dictionary* node) { - if (!node) - return; - - RetainPtr parent = node->GetMutableDictFor("Parent"); - RetainPtr prev = node->GetMutableDictFor("Prev"); - RetainPtr next = node->GetMutableDictFor("Next"); - - // Fix sibling pointers. - if (prev) { - if (next) - prev->SetNewFor("Next", doc, next->GetObjNum()); - else - prev->RemoveFor("Next"); - } - if (next) { - if (prev) - next->SetNewFor("Prev", doc, prev->GetObjNum()); - else - next->RemoveFor("Prev"); - } - - // Fix parent's /First and /Last. - if (parent) { - RetainPtr first = parent->GetMutableDictFor("First"); - RetainPtr last = parent->GetMutableDictFor("Last"); - if (first && first.Get() == node) { - if (next) - parent->SetNewFor("First", doc, next->GetObjNum()); - else - parent->RemoveFor("First"); - } - if (last && last.Get() == node) { - if (prev) - parent->SetNewFor("Last", doc, prev->GetObjNum()); - else - parent->RemoveFor("Last"); - } - } - - node->RemoveFor("Prev"); - node->RemoveFor("Next"); - } - - // Insert |child| as the last child of |parent_like|. - void AppendChild(CPDF_Document* doc, - CPDF_Dictionary* parent_like, - CPDF_Dictionary* child) { - child->SetNewFor("Parent", doc, parent_like->GetObjNum()); - - RetainPtr last = parent_like->GetMutableDictFor("Last"); - if (!last) { - // First child. - parent_like->SetNewFor("First", doc, child->GetObjNum()); - parent_like->SetNewFor("Last", doc, child->GetObjNum()); - child->RemoveFor("Prev"); - child->RemoveFor("Next"); - return; - } - - last->SetNewFor("Next", doc, child->GetObjNum()); - child->SetNewFor("Prev", doc, last->GetObjNum()); - child->RemoveFor("Next"); - parent_like->SetNewFor("Last", doc, child->GetObjNum()); - } - - // Insert |child| under |parent_like| right after |after_sibling|. - // If |after_sibling| is null, insert as the first child. - bool InsertAfter(CPDF_Document* doc, - CPDF_Dictionary* parent_like, - CPDF_Dictionary* after_sibling, // may be null - CPDF_Dictionary* child) { - child->SetNewFor("Parent", doc, parent_like->GetObjNum()); - - if (!after_sibling) { - // Insert as first child. - RetainPtr first = parent_like->GetMutableDictFor("First"); - if (first) { - first->SetNewFor("Prev", doc, child->GetObjNum()); - child->SetNewFor("Next", doc, first->GetObjNum()); - } else { - // No children previously. - parent_like->SetNewFor("Last", doc, child->GetObjNum()); - child->RemoveFor("Next"); - } - parent_like->SetNewFor("First", doc, child->GetObjNum()); - child->RemoveFor("Prev"); - return true; - } - - // Validate parent of after_sibling. - RetainPtr sib_parent = - after_sibling->GetDictFor("Parent"); - if (!sib_parent || sib_parent.Get() != parent_like) - return false; - - RetainPtr next = after_sibling->GetMutableDictFor("Next"); - - after_sibling->SetNewFor("Next", doc, child->GetObjNum()); - child->SetNewFor("Prev", doc, after_sibling->GetObjNum()); - - if (next) { - next->SetNewFor("Prev", doc, child->GetObjNum()); - child->SetNewFor("Next", doc, next->GetObjNum()); - } else { - parent_like->SetNewFor("Last", doc, child->GetObjNum()); - child->RemoveFor("Next"); - } - return true; - } - - // Recursively delete the subtree rooted at |node|. - void DeleteSubtree(CPDF_Document* doc, CPDF_Dictionary* node) { - // Delete children (depth-first). - for (RetainPtr cur = node->GetMutableDictFor("First"); cur; ) { - RetainPtr next = cur->GetMutableDictFor("Next"); - DeleteSubtree(doc, cur.Get()); - cur = std::move(next); - } - - // Unlink self and delete indirect object. - UnlinkFromParent(doc, node); - node->RemoveFor("First"); - node->RemoveFor("Last"); - node->RemoveFor("Parent"); - - const uint32_t num = node->GetObjNum(); - if (num) - doc->DeleteIndirectObject(num); - } - - - constexpr const char* kViewNames[] = { - "Unknown", "XYZ", "Fit", "FitH", "FitV", "FitR", "FitB", "FitBH", "FitBV"}; - constexpr uint8_t kViewMaxParams[] = {0, 3, 0, 1, 1, 4, 0, 1, 1}; - - // Non-XYZ: write N numeric params, padding with null up to |need|: a - // missing /FitH top keeps the viewer's current one. - static inline void AppendNonXYZParams(CPDF_Array* arr, - const FS_FLOAT* params, - unsigned long num_params, - uint8_t need) { - const unsigned long use = std::min(num_params, need); - for (unsigned long i = 0; i < need; ++i) { - if (i < use && params) { - arr->AppendNew(static_cast(params[i])); - } else { - arr->AppendNew(); - } - } - } - - // XYZ: each of {left, top, zoom} may be null; zoom==0 means "unspecified". - static inline void AppendXYZParams(CPDF_Array* arr, - FPDF_BOOL has_left, FS_FLOAT left, - FPDF_BOOL has_top, FS_FLOAT top, - FPDF_BOOL has_zoom, FS_FLOAT zoom) { - if (has_left) { - arr->AppendNew(static_cast(left)); - } else { - arr->AppendNew(); - } - - if (has_top) { - arr->AppendNew(static_cast(top)); - } else { - arr->AppendNew(); - } - - if (has_zoom && zoom != 0.0f) { - arr->AppendNew(static_cast(zoom)); - } else { - arr->AppendNew(); - } + std::optional root = epdf::OutlineRootOf(doc); + if (!root) { + return std::nullopt; } - - static RetainPtr GetOrCreateDestsNamesArray(CPDF_Document* doc) { - CPDF_Dictionary* root = doc->GetMutableRoot(); - if (!root) - return nullptr; - - RetainPtr names = root->GetMutableDictFor("Names"); - if (!names) { - names = doc->NewIndirect(); - root->SetNewFor("Names", doc, names->GetObjNum()); - } - - RetainPtr dests = names->GetMutableDictFor("Dests"); - if (!dests) { - dests = doc->NewIndirect(); - names->SetNewFor("Dests", doc, dests->GetObjNum()); - } - - RetainPtr arr = dests->GetMutableArrayFor("Names"); - if (!arr) { - arr = doc->NewIndirect(); - dests->SetNewFor("Names", doc, arr->GetObjNum()); - } - return arr; - } - - // Insert/replace a (name, value) pair in the flat /Names array, keeping it sorted. - // `value` may be DIRECT (that’s what we want). - static bool SetNameTreePairSorted(CPDF_Document* doc, - CPDF_Array* names_arr, - const ByteString& key, - RetainPtr value) { - if (!names_arr) - return false; - - // Default insertion point is "append to end". - int insert_at = static_cast(names_arr->size()); - - // Entries are (key, value) pairs at indices (0,1), (2,3), ... - for (size_t i = 0; i + 1 < names_arr->size(); i += 2) { - RetainPtr key_obj = names_arr->GetDirectObjectAt(i); - ByteString cur = key_obj ? key_obj->GetString() : ByteString(); - - const int cmp = key.Compare(cur.AsStringView()); - if (cmp == 0) { - // Replace existing value at i+1. - names_arr->SetAt(i + 1, std::move(value)); - return true; - } - if (cmp < 0) { - insert_at = static_cast(i); - break; - } - } - - // Insert new (key, value) pair at `insert_at`. - names_arr->InsertAt(insert_at, doc->New(key)); - names_arr->InsertAt(insert_at + 1, std::move(value)); - return true; - } - - // Remove a (name, value) pair if present. - static bool RemoveNameTreePair(CPDF_Array* names_arr, const ByteString& key) { - if (!names_arr) - return false; - - for (size_t i = 0; i + 1 < names_arr->size(); i += 2) { - RetainPtr key_obj = names_arr->GetDirectObjectAt(i); - ByteString cur = key_obj ? key_obj->GetString() : ByteString(); - if (cur == key.AsStringView()) { - // Remove value then key. - names_arr->RemoveAt(i + 1); - names_arr->RemoveAt(i); - return true; - } - } - return false; - } -} // namespace - -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFNamedDest_SetDest(FPDF_DOCUMENT fdoc, - FPDF_BYTESTRING name, - FPDF_DEST fdest) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - CPDF_Array* dest = CPDFArrayFromFPDFDest(fdest); - if (!doc || !name || !dest) - return false; - - // Must be indirect in this document. - const uint32_t objnum = dest->GetObjNum(); - if (objnum == 0 || doc->GetIndirectObject(objnum) != dest) - return false; - - RetainPtr first = dest->GetDirectObjectAt(0); - RetainPtr page_dict = ToDictionary(first); - if (!page_dict) - return false; - if (!BelongsTo(doc, page_dict.Get())) - return false; - - RetainPtr names_arr = GetOrCreateDestsNamesArray(doc); - if (!names_arr) - return false; - - // Value is a reference to the dest array object. - auto ref = pdfium::MakeRetain(doc, objnum); - return SetNameTreePairSorted(doc, names_arr.Get(), ByteString(name), std::move(ref)); -} - -// Remove a named destination mapping (no orphaning; the value was DIRECT). -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFNamedDest_Remove(FPDF_DOCUMENT fdoc, FPDF_BYTESTRING name) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc || !name) - return false; - - RetainPtr names_arr = GetOrCreateDestsNamesArray(doc); - if (!names_arr) - return false; - - return RemoveNameTreePair(names_arr.Get(), ByteString(name)); -} - -FPDF_EXPORT FPDF_DEST FPDF_CALLCONV -EPDFDest_CreateView(FPDF_PAGE fpage, - unsigned long view, - const FS_FLOAT* params, - unsigned long num_params) { - CPDF_Page* page = CPDFPageFromFPDFPage(fpage); - if (!page) - return nullptr; - CPDF_Document* doc = page->GetDocument(); - if (!doc) - return nullptr; - - // Validate view (must be a non-XYZ mode we know). - if (view == PDFDEST_VIEW_XYZ || view < PDFDEST_VIEW_FIT || view > PDFDEST_VIEW_FITBV) - return nullptr; - - const uint8_t need = kViewMaxParams[view]; - - // INDIRECT array: [ pageRef /Fit* ... ] - auto arr = doc->NewIndirect(); - if (!AppendPageDict(arr.Get(), page)) - return nullptr; - - arr->AppendNew(kViewNames[view]); - AppendNonXYZParams(arr.Get(), params, num_params, need); - return FPDFDestFromCPDFArray(arr.Get()); + return Outline{doc, *root}; } -FPDF_EXPORT FPDF_DEST FPDF_CALLCONV -EPDFDest_CreateXYZ(FPDF_PAGE fpage, - FPDF_BOOL has_left, FS_FLOAT left, - FPDF_BOOL has_top, FS_FLOAT top, - FPDF_BOOL has_zoom, FS_FLOAT zoom) { - CPDF_Page* page = CPDFPageFromFPDFPage(fpage); - if (!page) - return nullptr; - CPDF_Document* doc = page->GetDocument(); - if (!doc) +// The item |objnum| of |outline|, ready to write; null when it isn't one. +RetainPtr WritableItem(const Outline& outline, + uint32_t objnum) { + if (!epdf::IsOutlineItem(outline.doc, outline.root, objnum)) { return nullptr; - - // INDIRECT array: [ pageRef /XYZ left? top? zoom? ] - auto arr = doc->NewIndirect(); - if (!AppendPageDict(arr.Get(), page)) - return nullptr; - - arr->AppendNew("XYZ"); - AppendXYZParams(arr.Get(), has_left, left, has_top, top, has_zoom, zoom); - return FPDFDestFromCPDFArray(arr.Get()); -} - -FPDF_EXPORT FPDF_DEST FPDF_CALLCONV -EPDFDest_CreateRemoteView(FPDF_DOCUMENT fdoc, - int page_index, - unsigned long view, - const FS_FLOAT* params, - unsigned long num_params) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc || page_index < 0) - return nullptr; - if (view == PDFDEST_VIEW_XYZ || view < PDFDEST_VIEW_FIT || view > PDFDEST_VIEW_FITBV) - return nullptr; - - const uint8_t need = kViewMaxParams[view]; - - // Create an INDIRECT array: [ pageIndex /Fit* ... ] - auto arr = doc->NewIndirect(); - arr->AppendNew(page_index); - arr->AppendNew(kViewNames[view]); - AppendNonXYZParams(arr.Get(), params, num_params, need); - return FPDFDestFromCPDFArray(arr.Get()); -} - -FPDF_EXPORT FPDF_DEST FPDF_CALLCONV -EPDFDest_CreateRemoteXYZ(FPDF_DOCUMENT fdoc, - int page_index, - FPDF_BOOL has_left, FS_FLOAT left, - FPDF_BOOL has_top, FS_FLOAT top, - FPDF_BOOL has_zoom, FS_FLOAT zoom) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc || page_index < 0) - return nullptr; - - // INDIRECT array: [ pageIndex /XYZ left? top? zoom? ] - auto arr = doc->NewIndirect(); - arr->AppendNew(page_index); - arr->AppendNew("XYZ"); - AppendXYZParams(arr.Get(), has_left, left, has_top, top, has_zoom, zoom); - return FPDFDestFromCPDFArray(arr.Get()); -} - -FPDF_EXPORT FPDF_DEST FPDF_CALLCONV -EPDFDest_CreateXYZByObjectNumber(FPDF_DOCUMENT fdoc, - unsigned int page_obj_num, - FPDF_BOOL has_left, FS_FLOAT left, - FPDF_BOOL has_top, FS_FLOAT top, - FPDF_BOOL has_zoom, FS_FLOAT zoom) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc || page_obj_num == 0 || doc->GetPageIndex(page_obj_num) < 0) - return nullptr; - - // INDIRECT array: [ pageRef /XYZ left? top? zoom? ] - auto arr = doc->NewIndirect(); - arr->AppendNew(doc, page_obj_num); - arr->AppendNew("XYZ"); - AppendXYZParams(arr.Get(), has_left, left, has_top, top, has_zoom, zoom); - return FPDFDestFromCPDFArray(arr.Get()); -} - -FPDF_EXPORT FPDF_DEST FPDF_CALLCONV -EPDFDest_CreateViewByObjectNumber(FPDF_DOCUMENT fdoc, - unsigned int page_obj_num, - unsigned long view, - const FS_FLOAT* params, - unsigned long num_params) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc || page_obj_num == 0 || doc->GetPageIndex(page_obj_num) < 0) - return nullptr; - if (view == PDFDEST_VIEW_XYZ || view < PDFDEST_VIEW_FIT || view > PDFDEST_VIEW_FITBV) - return nullptr; - - // INDIRECT array: [ pageRef /Fit* ... ] - auto arr = doc->NewIndirect(); - arr->AppendNew(doc, page_obj_num); - arr->AppendNew(kViewNames[view]); - AppendNonXYZParams(arr.Get(), params, num_params, kViewMaxParams[view]); - return FPDFDestFromCPDFArray(arr.Get()); + } + return ToDictionary(outline.doc->GetMutableIndirectObject(objnum)); } -FPDF_EXPORT unsigned int FPDF_CALLCONV -EPDFDest_GetViewNullParams(FPDF_DEST dest) { - const CPDF_Array* arr = CPDFArrayFromFPDFDest(dest); - if (!arr) +// Where |after| is among |children|: the index an item put right after it +// takes, 0 for |after| 0, or nullopt when it isn't one of them. +std::optional SlotAfter(const std::vector& children, + uint32_t after) { + if (!after) { return 0; - - CPDF_Dest destination(pdfium::WrapRetain(arr)); - const size_t count = destination.GetNumParams(); - unsigned int mask = 0; - for (size_t i = 0; i < count; ++i) { - RetainPtr param = arr->GetDirectObjectAt(2 + i); - if (!param || param->IsNull()) - mask |= 1u << i; - } - return mask; -} - -FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV -EPDFAction_CreateGoTo(FPDF_DOCUMENT document, FPDF_DEST dest) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); - if (!pDoc || !dest) - return nullptr; - - CPDF_Array* pDestArray = CPDFArrayFromFPDFDest(dest); - if (!pDestArray) - return nullptr; - - // We expect |dest| to belong to |document| and to be indirect (created by EPDFDest_*()). - // Bail out if it's not indirect to avoid cross-doc or inline ownership issues. - if (pDestArray->GetObjNum() == 0) - return nullptr; - - RetainPtr first = pDestArray->GetDirectObjectAt(0); - RetainPtr page_dict = ToDictionary(first); - if (!page_dict) - return nullptr; - if (!BelongsTo(pDoc, page_dict.Get())) - return nullptr; - - RetainPtr action = pDoc->NewIndirect(); - action->SetNewFor("S", "GoTo"); - action->SetNewFor("D", pDoc, pDestArray->GetObjNum()); - - return FPDFActionFromCPDFDictionary(action.Get()); -} - -FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV -EPDFAction_CreateGoToNamed(FPDF_DOCUMENT fdoc, FPDF_BYTESTRING name) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc || !name) - return nullptr; - RetainPtr action = doc->NewIndirect(); - action->SetNewFor("S", "GoTo"); - action->SetNewFor("D", ByteString(name)); // name-as-string - return FPDFActionFromCPDFDictionary(action.Get()); -} - -FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV -EPDFAction_CreateLaunch(FPDF_DOCUMENT document, FPDF_WIDESTRING file_path) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); - if (!pDoc || !file_path) - return nullptr; - - WideString wpath = UNSAFE_BUFFERS(WideStringFromFPDFWideString(file_path)); - - RetainPtr action = pDoc->NewIndirect(); - action->SetNewFor("S", "Launch"); - // Simple FileSpec-as-string. Callers can upgrade to a full FileSpec later. - action->SetNewFor("F", wpath.AsStringView()); - return FPDFActionFromCPDFDictionary(action.Get()); + } + auto it = std::ranges::find(children, after); + if (it == children.end()) { + return std::nullopt; + } + return static_cast(it - children.begin()) + 1; } -FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV -EPDFAction_CreateRemoteGoToByName(FPDF_DOCUMENT document, - FPDF_WIDESTRING file_path, - FPDF_WIDESTRING named_dest) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); - if (!pDoc || !file_path || !named_dest) - return nullptr; - - WideString wpath = UNSAFE_BUFFERS(WideStringFromFPDFWideString(file_path)); - WideString wname = UNSAFE_BUFFERS(WideStringFromFPDFWideString(named_dest)); - - RetainPtr action = pDoc->NewIndirect(); - action->SetNewFor("S", "GoToR"); - action->SetNewFor("F", wpath.AsStringView()); - // Named destination in the *target* doc, stored as a text string. - action->SetNewFor("D", wname.AsStringView()); - return FPDFActionFromCPDFDictionary(action.Get()); +// Whether |node| is |item| or under it: |item| is on |node|'s /Parent chain. +bool IsAtOrUnder(const Outline& outline, uint32_t node, uint32_t item) { + std::set seen; + for (uint32_t current = node; + current && current != outline.root && seen.insert(current).second; + current = epdf::OutlineParentOf(outline.doc, current)) { + if (current == item) { + return true; + } + } + return false; } -FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV -EPDFAction_CreateRemoteGoToDest(FPDF_DOCUMENT fdoc, - FPDF_WIDESTRING file_path, - FPDF_DEST fdest) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc || !file_path || !fdest) - return nullptr; - - CPDF_Array* dest = CPDFArrayFromFPDFDest(fdest); - if (!dest) - return nullptr; - - // The dest must be an INDIRECT object in this doc. - const uint32_t objnum = dest->GetObjNum(); - if (objnum == 0 || doc->GetIndirectObject(objnum) != dest) - return nullptr; - - // Basic shape check for remote explicit destinations: - // First element must be a page index (number). - RetainPtr first = dest->GetDirectObjectAt(0); - if (!first || !first->IsNumber()) - return nullptr; - - WideString wpath = UNSAFE_BUFFERS(WideStringFromFPDFWideString(file_path)); - - auto action = doc->NewIndirect(); - action->SetNewFor("S", "GoToR"); - action->SetNewFor("F", wpath.AsStringView()); - action->SetNewFor("D", doc, objnum); - return FPDFActionFromCPDFDictionary(action.Get()); +WideString TitleOf(FPDF_WIDESTRING title) { + // SAFETY: required from caller. + return title ? UNSAFE_BUFFERS(WideStringFromFPDFWideString(title)) + : WideString(); } -FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV -EPDFAction_CreateURI(FPDF_DOCUMENT document, FPDF_BYTESTRING uri) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); - if (!pDoc || !uri) - return nullptr; - - RetainPtr action = pDoc->NewIndirect(); - action->SetNewFor("S", "URI"); - // Store as a byte string (UTF-8 as provided by caller). - action->SetNewFor("URI", ByteString(uri)); - - return FPDFActionFromCPDFDictionary(action.Get()); +bool IsColorComponent(float value) { + return std::isfinite(value) && value >= 0 && value <= 1; } -FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV -EPDFAction_CreateNamed(FPDF_DOCUMENT document, FPDF_BYTESTRING name) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); - if (!pDoc || !name || !name[0]) - return nullptr; - - RetainPtr action = pDoc->NewIndirect(); - action->SetNewFor("S", "Named"); - action->SetNewFor("N", ByteString(name)); +} // namespace - return FPDFActionFromCPDFDictionary(action.Get()); +FPDF_EXPORT uint32_t FPDF_CALLCONV +EPDFOutline_GetObjectNumber(FPDF_BOOKMARK bookmark) { + const CPDF_Dictionary* dict = CPDFDictionaryFromFPDFBookmark(bookmark); + return dict ? dict->GetObjNum() : 0; } FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV -EPDFBookmark_Create(FPDF_DOCUMENT doc, FPDF_WIDESTRING title) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(doc); - if (!pDoc) - return nullptr; - - RetainPtr outlines = GetOrCreateOutlines(pDoc); - if (!outlines) +EPDFOutline_GetItem(FPDF_DOCUMENT document, uint32_t objnum) { + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + if (!outline || !epdf::IsOutlineItem(outline->doc, outline->root, objnum)) { return nullptr; - - WideString wtitle = - title ? UNSAFE_BUFFERS(WideStringFromFPDFWideString(title)) : WideString(); - - RetainPtr node = CreateBookmarkDict(pDoc, wtitle); - AppendChild(pDoc, outlines.Get(), node.Get()); - return FPDFBookmarkFromCPDFDictionary(node.Get()); + } + return FPDFBookmarkFromCPDFDictionary( + ToDictionary(outline->doc->GetOrParseIndirectObject(objnum)).Get()); } -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_Delete(FPDF_DOCUMENT doc, FPDF_BOOKMARK bookmark) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(doc); - CPDF_Dictionary* handle = CPDFDictionaryFromFPDFBookmark(bookmark); - if (!pDoc || !handle || !BelongsTo(pDoc, handle)) { - return false; +FPDF_EXPORT uint32_t FPDF_CALLCONV +EPDFOutline_CreateItem(FPDF_DOCUMENT document, + uint32_t objnum, + uint32_t parent, + uint32_t after, + FPDF_WIDESTRING title) { + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + if (!outline) { + return 0; } - RetainPtr bm = WritableBookmark(pDoc, handle); - if (!bm) { - return false; + CPDF_Document* doc = outline->doc; + // Everything is checked before anything is written: a refusal changes + // nothing. + if (objnum && !doc->CanAddIndirectObjectAt(objnum)) { + return 0; } - - DeleteSubtree(pDoc, bm.Get()); - return true; -} - -FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV -EPDFBookmark_AppendChild(FPDF_DOCUMENT doc, - FPDF_BOOKMARK parent, - FPDF_WIDESTRING title) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(doc); - if (!pDoc) - return nullptr; - - RetainPtr parent_like = - parent ? pdfium::WrapRetain(CPDFDictionaryFromFPDFBookmark(parent)) - : GetOrCreateOutlines(pDoc); - if (!parent_like || !BelongsTo(pDoc, parent_like.Get())) - return nullptr; - parent_like = WritableBookmark(pDoc, parent_like.Get()); - if (!parent_like) { - return nullptr; + if (parent && !epdf::IsOutlineItem(doc, outline->root, parent)) { + return 0; } - - WideString wtitle = - title ? UNSAFE_BUFFERS(WideStringFromFPDFWideString(title)) : WideString(); - RetainPtr child = CreateBookmarkDict(pDoc, wtitle); - AppendChild(pDoc, parent_like.Get(), child.Get()); - return FPDFBookmarkFromCPDFDictionary(child.Get()); -} - -FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV -EPDFBookmark_InsertAfter(FPDF_DOCUMENT doc, - FPDF_BOOKMARK parent, - FPDF_BOOKMARK after_sibling, - FPDF_WIDESTRING title) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(doc); - if (!pDoc) - return nullptr; - - RetainPtr parent_like = - parent ? pdfium::WrapRetain(CPDFDictionaryFromFPDFBookmark(parent)) - : GetOrCreateOutlines(pDoc); - if (!parent_like || !BelongsTo(pDoc, parent_like.Get())) - return nullptr; - parent_like = WritableBookmark(pDoc, parent_like.Get()); - if (!parent_like) { - return nullptr; + std::vector children; + if (const uint32_t node = parent ? parent : outline->root) { + std::optional> numbers = + epdf::OutlineChildNumbers(doc, node); + if (!numbers) { + return 0; + } + children = std::move(*numbers); + } + std::optional slot = SlotAfter(children, after); + if (!slot) { + return 0; } - CPDF_Dictionary* after_handle = - after_sibling ? CPDFDictionaryFromFPDFBookmark(after_sibling) : nullptr; - if (after_handle && !BelongsTo(pDoc, after_handle)) { - return nullptr; + if (!outline->root) { + outline->root = epdf::CreateOutlineRoot(doc); + if (!outline->root) { + return 0; + } } - RetainPtr after = - after_handle ? WritableBookmark(pDoc, after_handle) : nullptr; - if (after_handle && !after) { - return nullptr; + const uint32_t node = parent ? parent : outline->root; + RetainPtr item = + objnum ? doc->NewIndirectAt(objnum) + : doc->NewIndirect(); + if (!item) { + return 0; } - CPDF_Dictionary* after_dict = after.Get(); - - WideString wtitle = - title ? UNSAFE_BUFFERS(WideStringFromFPDFWideString(title)) : WideString(); - RetainPtr child = CreateBookmarkDict(pDoc, wtitle); - - if (!InsertAfter(pDoc, parent_like.Get(), after_dict, child.Get())) - return nullptr; - - return FPDFBookmarkFromCPDFDictionary(child.Get()); -} - -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_Clear(FPDF_DOCUMENT fdoc) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(fdoc); - if (!doc) - return false; - - CPDF_Dictionary* root = doc->GetMutableRoot(); - if (!root) - return false; - - RetainPtr outlines = root->GetMutableDictFor("Outlines"); - if (!outlines) - return true; // nothing to clear - - // Delete all top-level nodes. - for (RetainPtr cur = outlines->GetMutableDictFor("First"); cur; ) { - RetainPtr next = cur->GetMutableDictFor("Next"); - DeleteSubtree(doc, cur.Get()); - cur = std::move(next); + item->SetNewFor("Title", TitleOf(title).AsStringView()); + children.insert(children.begin() + *slot, item->GetObjNum()); + if (!epdf::WriteOutlineChildren(doc, node, children) || + !epdf::RecountOutline(doc, outline->root, node)) { + return 0; } - - // Remove /Outlines from root and delete the dict object. - root->RemoveFor("Outlines"); - const uint32_t num = outlines->GetObjNum(); - if (num) - doc->DeleteIndirectObject(num); - - return true; + return item->GetObjNum(); } - -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_SetTitle(FPDF_BOOKMARK bookmark, FPDF_WIDESTRING title) { - if (!bookmark) +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_MoveItem(FPDF_DOCUMENT document, + uint32_t objnum, + uint32_t parent, + uint32_t after) { + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + if (!outline || after == objnum || + !epdf::IsOutlineItem(outline->doc, outline->root, objnum)) { return false; - - RetainPtr bm_dict = - WritableBookmark(nullptr, CPDFDictionaryFromFPDFBookmark(bookmark)); - if (!bm_dict) + } + CPDF_Document* doc = outline->doc; + if (parent && (!epdf::IsOutlineItem(doc, outline->root, parent) || + IsAtOrUnder(*outline, parent, objnum))) { return false; - - WideString wide = - title ? UNSAFE_BUFFERS(WideStringFromFPDFWideString(title)) : WideString(); - - // Store as Unicode CPDF_String (matches existing patterns, e.g., Info keys). - bm_dict->SetNewFor("Title", wide.AsStringView()); - return true; -} - -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_SetDest(FPDF_DOCUMENT doc, FPDF_BOOKMARK bookmark, FPDF_DEST dest) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(doc); - if (!pDoc || !bookmark || !dest) + } + const uint32_t from = epdf::OutlineParentOf(doc, objnum); + const uint32_t to = parent ? parent : outline->root; + std::optional> old_children = + epdf::OutlineChildNumbers(doc, from); + if (!old_children) { return false; - - CPDF_Dictionary* handle = CPDFDictionaryFromFPDFBookmark(bookmark); - if (!handle || !BelongsTo(pDoc, handle)) { + } + std::erase(*old_children, objnum); + std::optional> new_children = + from == to ? old_children : epdf::OutlineChildNumbers(doc, to); + if (!new_children) { return false; } - RetainPtr bm_dict = WritableBookmark(pDoc, handle); - if (!bm_dict) + std::optional slot = SlotAfter(*new_children, after); + if (!slot) { return false; + } + new_children->insert(new_children->begin() + *slot, objnum); - CPDF_Array* dest_arr = CPDFArrayFromFPDFDest(dest); - if (!dest_arr) + if (from != to && !epdf::WriteOutlineChildren(doc, from, *old_children)) { return false; + } + return epdf::WriteOutlineChildren(doc, to, *new_children) && + epdf::RecountOutline(doc, outline->root, from) && + epdf::RecountOutline(doc, outline->root, to); +} - // Require |dest| to be indirect to avoid cross-doc or inline ownership issues. - if (dest_arr->GetObjNum() == 0) +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFOutline_DeleteItem(FPDF_DOCUMENT document, uint32_t objnum) { + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + if (!outline || !epdf::IsOutlineItem(outline->doc, outline->root, objnum)) { return false; - - RetainPtr first = dest_arr->GetDirectObjectAt(0); - RetainPtr page_dict = ToDictionary(first); - if (!page_dict) + } + CPDF_Document* doc = outline->doc; + const uint32_t parent = epdf::OutlineParentOf(doc, objnum); + std::optional> siblings = + epdf::OutlineChildNumbers(doc, parent); + if (!siblings) { return false; - if (!BelongsTo(pDoc, page_dict.Get())) + } + std::erase(*siblings, objnum); + const std::vector subtree = epdf::OutlineSubtreeOf(doc, objnum); + if (!epdf::WriteOutlineChildren(doc, parent, *siblings)) { return false; - - // Clear any action. - bm_dict->RemoveFor("A"); - - // Set /Dest as an indirect reference into this document. - bm_dict->SetNewFor("Dest", pDoc, dest_arr->GetObjNum()); - return true; + } + for (uint32_t item : subtree) { + doc->DeleteIndirectObject(item); + } + return epdf::RecountOutline(doc, outline->root, parent); } FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_SetAction(FPDF_DOCUMENT document, - FPDF_BOOKMARK bookmark, - FPDF_ACTION action) { - CPDF_Document* pDoc = CPDFDocumentFromFPDFDocument(document); - if (!pDoc || !bookmark || !action) +EPDFOutline_SetTitle(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_WIDESTRING title) { + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + RetainPtr item = + outline ? WritableItem(*outline, objnum) : nullptr; + if (!item) { return false; + } + item->SetNewFor("Title", TitleOf(title).AsStringView()); + return true; +} - CPDF_Dictionary* handle = CPDFDictionaryFromFPDFBookmark(bookmark); - if (!handle || !BelongsTo(pDoc, handle)) { +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_SetOpen(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_BOOL open) { + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + if (!outline || !epdf::IsOutlineItem(outline->doc, outline->root, objnum)) { return false; } - RetainPtr bm_dict = WritableBookmark(pDoc, handle); - if (!bm_dict) + if (epdf::OutlineChildrenOf(outline->doc, objnum).empty()) { + return true; // a leaf has no open state + } + RetainPtr item = WritableItem(*outline, objnum); + if (!item) { return false; + } + // Only the sign matters here: the count below makes it right. + item->SetNewFor("Count", open ? 1 : -1); + return epdf::RecountOutline(outline->doc, outline->root, objnum); +} - CPDF_Dictionary* act_dict = CPDFDictionaryFromFPDFAction(action); - if (!act_dict) +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_SetColor(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_BOOL has_color, + float r, + float g, + float b) { + if (has_color && + (!IsColorComponent(r) || !IsColorComponent(g) || !IsColorComponent(b))) { return false; - - // Require the action to be indirect so we can reference it. - if (act_dict->GetObjNum() == 0) + } + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + RetainPtr item = + outline ? WritableItem(*outline, objnum) : nullptr; + if (!item) { return false; - - // Clear any /Dest when setting /A, per PDF spec. - bm_dict->RemoveFor("Dest"); - - // Set /A as an indirect reference to |action|. - bm_dict->SetNewFor("A", pDoc, act_dict->GetObjNum()); + } + if (!has_color) { + item->RemoveFor("C"); + return true; + } + RetainPtr color = item->SetNewFor("C"); + color->AppendNew(r); + color->AppendNew(g); + color->AppendNew(b); return true; } -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_ClearTarget(FPDF_BOOKMARK bookmark) { - if (!bookmark) +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_SetStyle(FPDF_DOCUMENT document, + uint32_t objnum, + int flags) { + if (flags < 0) { return false; - RetainPtr bm_dict = - WritableBookmark(nullptr, CPDFDictionaryFromFPDFBookmark(bookmark)); - if (!bm_dict) + } + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + RetainPtr item = + outline ? WritableItem(*outline, objnum) : nullptr; + if (!item) { return false; - - bm_dict->RemoveFor("Dest"); - bm_dict->RemoveFor("A"); + } + if (flags) { + item->SetNewFor("F", flags); + } else { + item->RemoveFor("F"); + } return true; } FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_SetNamedDest(FPDF_BOOKMARK bookmark, FPDF_BYTESTRING name) { - if (!bookmark || !name) +EPDFOutline_SetAction(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_ACTION action) { + ScopedFPDFDocumentView document_view(document); + std::optional outline = OutlineOf(document_view.Get()); + if (!outline) { return false; - RetainPtr bm = - WritableBookmark(nullptr, CPDFDictionaryFromFPDFBookmark(bookmark)); - if (!bm) + } + const CPDF_Dictionary* action_dict = CPDFDictionaryFromFPDFAction(action); + if (action && !epdf::IsIndirectObjectOf(outline->doc, action_dict)) { return false; - bm->RemoveFor("A"); - bm->SetNewFor("Dest", ByteString(name)); // name-as-string + } + RetainPtr item = WritableItem(*outline, objnum); + if (!item) { + return false; + } + item->RemoveFor("Dest"); + if (action_dict) { + item->SetNewFor("A", outline->doc, + action_dict->GetObjNum()); + } else { + item->RemoveFor("A"); + } return true; } diff --git a/fpdfsdk/epdf_outline_embeddertest.cpp b/fpdfsdk/epdf_outline_embeddertest.cpp new file mode 100644 index 000000000..58ed2ad4c --- /dev/null +++ b/fpdfsdk/epdf_outline_embeddertest.cpp @@ -0,0 +1,854 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +// The document outline's writers (public/epdf_outline.h) and its captures +// (EPDFOutline_ExportItemRawToOwnedBuffer and EPDFOutline_ImportItemRaw): +// items named by number, lists and counts kept right, refusals that change +// nothing, and layers. + +#include + +#include +#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_layer_document.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fxcrt/compiler_specific.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "fpdfsdk/epdf_edit_test_util.h" +#include "public/cpp/fpdf_scopers.h" +#include "public/epdf_capture.h" +#include "public/epdf_doc.h" +#include "public/epdf_layer.h" +#include "public/epdf_outline.h" +#include "public/epdf_save.h" +#include "public/fpdf_doc.h" +#include "public/fpdfview.h" +#include "testing/embedder_test.h" +#include "testing/fx_string_testhelpers.h" +#include "testing/gtest/include/gtest/gtest.h" + +namespace { + +// The objects of the document below. +constexpr uint32_t kFirstPage = 3; +constexpr uint32_t kOutlines = 5; +constexpr uint32_t kA = 6; +constexpr uint32_t kB = 7; +constexpr uint32_t kC = 8; +constexpr uint32_t kA1 = 9; +constexpr uint32_t kC1 = 10; + +// Two pages and an outline: A (open, its child A1, a destination), B (red, +// bold) and C (closed, its child C1). Four items show when it opens. +std::string MakeDocument() { + return MakePdf({ + "<< /Type /Catalog /Pages 2 0 R /Outlines 5 0 R >>", + "<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Type /Outlines /First 6 0 R /Last 8 0 R /Count 4 >>", + "<< /Title (A) /Parent 5 0 R /Next 7 0 R /First 9 0 R /Last 9 0 R " + "/Count 1 /Dest [3 0 R /Fit] >>", + "<< /Title (B) /Parent 5 0 R /Prev 6 0 R /Next 8 0 R /C [1 0 0] /F 2 >>", + "<< /Title (C) /Parent 5 0 R /Prev 7 0 R /First 10 0 R /Last 10 0 R " + "/Count -1 >>", + "<< /Title (A1) /Parent 6 0 R >>", + "<< /Title (C1) /Parent 8 0 R >>", + }); +} + +// A document with |outline| as object 3 and the items after it, its page 2. +std::string MakeDocumentWith(const std::vector& outline) { + std::vector objects = { + "<< /Type /Catalog /Pages 2 0 R /Outlines 3 0 R >>", + "<< /Type /Pages /Kids [4 0 R] /Count 1 >>", + }; + objects.insert(objects.end(), outline.begin(), outline.end()); + return MakePdf(objects); +} + +RetainPtr DictOf(FPDF_DOCUMENT doc, uint32_t objnum) { + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc); + CPDF_DocumentViewScope view(pdf); + return ToDictionary(pdf->GetOrParseIndirectObject(objnum)); +} + +uint32_t RefOf(const CPDF_Dictionary* dict, const char* key) { + RetainPtr value = dict ? dict->GetObjectFor(key) : nullptr; + const CPDF_Reference* ref = ToReference(value.Get()); + return ref ? ref->GetRefObjNum() : 0; +} + +uint32_t OutlinesOf(FPDF_DOCUMENT doc) { + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc); + CPDF_DocumentViewScope view(pdf); + return RefOf(pdf->GetRoot(), "Outlines"); +} + +std::string TitleOf(FPDF_BOOKMARK bookmark) { + unsigned long length = FPDFBookmark_GetTitle(bookmark, nullptr, 0); + std::vector buffer(length / sizeof(FPDF_WCHAR) + 1); + FPDFBookmark_GetTitle(bookmark, buffer.data(), length); + return GetPlatformString(buffer.data()); +} + +std::string TitleOf(FPDF_DOCUMENT doc, uint32_t objnum) { + FPDF_BOOKMARK item = EPDFOutline_GetItem(doc, objnum); + return item ? TitleOf(item) : "(none)"; +} + +// The outline as the readers of fpdf_doc.h see it: each title, its /Count +// when it has one, and its children in brackets; the outline's own /Count +// first. Only for outlines without loops: the readers don't stop at them. +std::string Tree(FPDF_DOCUMENT doc, FPDF_BOOKMARK parent = nullptr) { + std::string out; + if (!parent) { + RetainPtr outlines = DictOf(doc, OutlinesOf(doc)); + out = outlines && outlines->KeyExist("Count") + ? std::to_string(outlines->GetIntegerFor("Count")) + ":" + : "-:"; + } + bool first = true; + for (FPDF_BOOKMARK child = FPDFBookmark_GetFirstChild(doc, parent); child; + child = FPDFBookmark_GetNextSibling(doc, child)) { + out += first ? "" : " "; + first = false; + out += TitleOf(child); + if (DictOf(doc, EPDFOutline_GetObjectNumber(child))->KeyExist("Count")) { + const int count = FPDFBookmark_GetCount(child); + out += (count > 0 ? "+" : "") + std::to_string(count); + } + const std::string under = Tree(doc, child); + if (!under.empty()) { + out += "[" + under + "]"; + } + } + return out; +} + +// The numbers of every item, depth first, through the readers. +std::vector Items(FPDF_DOCUMENT doc, FPDF_BOOKMARK parent = nullptr) { + std::vector items; + for (FPDF_BOOKMARK child = FPDFBookmark_GetFirstChild(doc, parent); child; + child = FPDFBookmark_GetNextSibling(doc, child)) { + items.push_back(EPDFOutline_GetObjectNumber(child)); + for (uint32_t under : Items(doc, child)) { + items.push_back(under); + } + } + return items; +} + +// What is wrong with the outline's links and counts, worked out here from +// the dictionaries and not by the code under test; empty when nothing is. +class OutlineChecker { + public: + explicit OutlineChecker(FPDF_DOCUMENT doc) + : pdf_(CPDFDocumentFromFPDFDocument(doc)) {} + + std::string Problems() { + CPDF_DocumentViewScope view(pdf_); + const uint32_t root = RefOf(pdf_->GetRoot(), "Outlines"); + RetainPtr outlines = Dict(root); + if (!outlines) { + return problems_; + } + const int visible = VisibleUnder(outlines.Get(), root); + if (visible ? outlines->GetIntegerFor("Count") != visible + : outlines->KeyExist("Count")) { + Report(root, "/Count " + + std::to_string(outlines->GetIntegerFor("Count")) + ", " + + std::to_string(visible) + " visible"); + } + return problems_; + } + + private: + RetainPtr Dict(uint32_t objnum) { + return ToDictionary(pdf_->GetOrParseIndirectObject(objnum)); + } + + void Report(uint32_t objnum, const std::string& what) { + problems_ += std::to_string(objnum) + ": " + what + "; "; + } + + void ExpectRef(uint32_t objnum, + const CPDF_Dictionary* dict, + const char* key, + uint32_t expected) { + const uint32_t actual = RefOf(dict, key); + if (actual != expected || (!expected && dict->KeyExist(key))) { + Report(objnum, std::string("/") + key + " " + std::to_string(actual) + + ", expected " + std::to_string(expected)); + } + } + + // Checks |node|'s list and everything under it; returns how many items + // show under |node| when it is open. + int VisibleUnder(const CPDF_Dictionary* node, uint32_t objnum) { + std::vector numbers; + std::vector> kids; + RetainPtr link = node->GetObjectFor("First"); + while (link) { + const CPDF_Reference* ref = ToReference(link.Get()); + if (!ref) { + Report(objnum, "a child that isn't indirect"); + break; + } + if (!seen_.insert(ref->GetRefObjNum()).second) { + Report(ref->GetRefObjNum(), "reached twice"); + break; + } + numbers.push_back(ref->GetRefObjNum()); + kids.push_back(Dict(ref->GetRefObjNum())); + link = kids.back()->GetObjectFor("Next"); + } + ExpectRef(objnum, node, "First", numbers.empty() ? 0 : numbers.front()); + ExpectRef(objnum, node, "Last", numbers.empty() ? 0 : numbers.back()); + int visible = 0; + for (size_t i = 0; i < kids.size(); ++i) { + const CPDF_Dictionary* kid = kids[i].Get(); + ExpectRef(numbers[i], kid, "Parent", objnum); + ExpectRef(numbers[i], kid, "Prev", i ? numbers[i - 1] : 0); + ExpectRef(numbers[i], kid, "Next", + i + 1 < kids.size() ? numbers[i + 1] : 0); + if (!kid->KeyExist("Title")) { + Report(numbers[i], "no /Title"); + } + const int under = VisibleUnder(kid, numbers[i]); + const int count = kid->GetIntegerFor("Count"); + if (under == 0 && kid->KeyExist("Count")) { + Report(numbers[i], "/Count without children"); + } else if (under > 0 && count != under && count != -under) { + Report(numbers[i], "/Count " + std::to_string(count) + ", " + + std::to_string(under) + " under it"); + } + visible += 1 + (count > 0 ? under : 0); + } + return visible; + } + + CPDF_Document* const pdf_; + std::set seen_; + std::string problems_; +}; + +std::string Problems(FPDF_DOCUMENT doc) { + return OutlineChecker(doc).Problems(); +} + +// A GoTo action of |doc| to the page |page| (an object number). +FPDF_ACTION GoTo(FPDF_DOCUMENT doc, uint32_t page) { + FPDF_DEST dest = + EPDFDest_CreateXYZByObjectNumber(doc, page, true, 10, true, 20, false, 0); + return dest ? EPDFAction_CreateGoTo(doc, dest) : nullptr; +} + +uint32_t Create(FPDF_DOCUMENT doc, + uint32_t parent, + uint32_t after, + const wchar_t* title, + uint32_t objnum = 0) { + ScopedFPDFWideString text = GetFPDFWideString(title); + return EPDFOutline_CreateItem(doc, objnum, parent, after, text.get()); +} + +std::vector Take(void* buffer, unsigned long size) { + std::vector bytes; + if (buffer) { + const uint8_t* data = static_cast(buffer); + // SAFETY: the export reported |size| bytes. + bytes.assign(data, UNSAFE_BUFFERS(data + size)); + EPDF_FreeBuffer(buffer); + } + return bytes; +} + +std::vector ExportItem(FPDF_DOCUMENT doc, uint32_t objnum) { + unsigned long size = 0; + void* buffer = EPDFOutline_ExportItemRawToOwnedBuffer(doc, objnum, &size); + return Take(buffer, size); +} + +bool ImportItem(FPDF_DOCUMENT doc, const std::vector& capture) { + return EPDFOutline_ImportItemRaw(doc, capture.data(), + static_cast(capture.size())); +} + +} // namespace + +class EPDFOutlineEmbedderTest : public EmbedderTest { + protected: + void TearDown() override { + if (base_) { + EPDF_ReleaseBaseDocument(base_); + } + EmbedderTest::TearDown(); + } + + // A plain document of |bytes|. + ScopedFPDFDocument OpenPlain(std::string bytes = MakeDocument()) { + plain_ = std::move(bytes); + return ScopedFPDFDocument( + FPDF_LoadMemDocument(plain_.data(), plain_.size(), nullptr)); + } + + // A layer over a base of |bytes|, with numbers up to 40 handed out. + ScopedFPDFDocument OpenLayer(std::string bytes = MakeDocument()) { + input_ = std::move(bytes); + base_ = EPDF_LoadMemBaseDocument(input_.data(), input_.size(), nullptr); + if (!base_) { + return nullptr; + } + ScopedFPDFDocument layer( + EPDFLayer_OpenLayer(base_, nullptr, nullptr, nullptr)); + if (layer && !EPDFLayer_RaiseLastObjectNumber(layer.get(), 40)) { + return nullptr; + } + return layer; + } + + // The layer saved and opened again: what nothing reaches is gone. + ScopedFPDFDocument Reopen(FPDF_DOCUMENT layer) { + ClearString(); + EXPECT_TRUE(EPDFLayer_SaveLayerArtifact(layer, this, nullptr)); + artifact_ = GetString(); + FPDF_FILEACCESS access = {}; + access.m_FileLen = artifact_.size(); + access.m_GetBlock = GetBlockFromString; + access.m_Param = &artifact_; + EPDFLayerOpenStatus status = EPDFLayerOpenStatus_kOpenFailed; + ScopedFPDFDocument reopened( + EPDFLayer_OpenLayerArtifact(base_, &access, nullptr, &status)); + EXPECT_EQ(EPDFLayerOpenStatus_kSuccess, status); + return reopened; + } + + std::string plain_; + std::string input_; + std::string artifact_; + EPDF_BASE_DOCUMENT base_ = nullptr; +}; + +TEST_F(EPDFOutlineEmbedderTest, ItemsAreNamedByNumber) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + EXPECT_EQ("4:A+1[A1] B C-1[C1]", Tree(doc.get())); + EXPECT_EQ("", Problems(doc.get())); + FPDF_BOOKMARK first = FPDFBookmark_GetFirstChild(doc.get(), nullptr); + EXPECT_EQ(kA, EPDFOutline_GetObjectNumber(first)); + EXPECT_EQ(0u, EPDFOutline_GetObjectNumber(nullptr)); + EXPECT_EQ("C1", TitleOf(doc.get(), kC1)); + EXPECT_EQ(first, EPDFOutline_GetItem(doc.get(), kA)); + // The outline itself, a page and a number nothing has are no items. + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), kOutlines)); + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), kFirstPage)); + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), 99)); + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), 0)); + EXPECT_FALSE(EPDFOutline_GetItem(nullptr, kA)); +} + +TEST_F(EPDFOutlineEmbedderTest, CreatePlacesTheItemAndCounts) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + const uint32_t top = Create(doc.get(), 0, 0, L"Top"); + ASSERT_TRUE(top); + EXPECT_EQ("5:Top A+1[A1] B C-1[C1]", Tree(doc.get())); + ASSERT_TRUE(Create(doc.get(), 0, kB, L"AfterB")); + ASSERT_TRUE(Create(doc.get(), 0, kC, L"End")); + EXPECT_EQ("7:Top A+1[A1] B AfterB C-1[C1] End", Tree(doc.get())); + + // Under an open item the outline shows one more; under a closed one, none. + ASSERT_TRUE(Create(doc.get(), kA, kA1, L"A2")); + ASSERT_TRUE(Create(doc.get(), kC, 0, L"C0")); + EXPECT_EQ("8:Top A+2[A1 A2] B AfterB C-2[C0 C1] End", Tree(doc.get())); + + // A leaf that gets a child starts closed. + ASSERT_TRUE(Create(doc.get(), kB, 0, L"B1")); + EXPECT_EQ("8:Top A+2[A1 A2] B-1[B1] AfterB C-2[C0 C1] End", Tree(doc.get())); + EXPECT_EQ("", Problems(doc.get())); + + // No title is an empty one: /Title is always written. + const uint32_t untitled = EPDFOutline_CreateItem(doc.get(), 0, 0, 0, nullptr); + ASSERT_TRUE(untitled); + EXPECT_TRUE(DictOf(doc.get(), untitled)->KeyExist("Title")); + EXPECT_EQ("", TitleOf(doc.get(), untitled)); + EXPECT_EQ("", Problems(doc.get())); +} + +TEST_F(EPDFOutlineEmbedderTest, CreateRefusesAndChangesNothing) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + const std::map before = ObjectGraph(pdf); + EXPECT_FALSE(Create(doc.get(), kFirstPage, 0, L"x")); // a page + EXPECT_FALSE(Create(doc.get(), 99, 0, L"x")); // nothing + EXPECT_FALSE(Create(doc.get(), kOutlines, 0, L"x")); // the outline itself + EXPECT_FALSE(Create(doc.get(), 0, kA1, L"x")); // not a top child + EXPECT_FALSE(Create(doc.get(), kA, kB, L"x")); // not a child of A + EXPECT_FALSE(Create(doc.get(), 0, 0, L"x", 30)); // a number, no layer + EXPECT_EQ(before, ObjectGraph(pdf)); +} + +TEST_F(EPDFOutlineEmbedderTest, CreateAtANumberOnALayer) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + EXPECT_FALSE(Create(layer.get(), 0, 0, L"x", 30)); // outside a transaction + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + EXPECT_EQ(30u, Create(layer.get(), 0, kC, L"Thirty", 30)); + EXPECT_FALSE(Create(layer.get(), 0, 0, L"again", 30)); // taken + EXPECT_FALSE(Create(layer.get(), 0, 0, L"base", kB)); // the file's + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ("5:A+1[A1] B C-1[C1] Thirty", Tree(layer.get())); + EXPECT_EQ("", Problems(layer.get())); +} + +TEST_F(EPDFOutlineEmbedderTest, CreateMakesTheOutline) { + ScopedFPDFDocument doc = OpenPlain(MakePdf({ + "<< /Type /Catalog /Pages 2 0 R >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + })); + ASSERT_TRUE(doc); + EXPECT_FALSE(Create(doc.get(), 4, 0, L"x")); // no outline, no items + EXPECT_FALSE(OutlinesOf(doc.get())); + const uint32_t first = Create(doc.get(), 0, 0, L"First"); + ASSERT_TRUE(first); + const uint32_t outlines = OutlinesOf(doc.get()); + ASSERT_TRUE(outlines); + EXPECT_EQ("Outlines", DictOf(doc.get(), outlines)->GetNameFor("Type")); + EXPECT_EQ("1:First", Tree(doc.get())); + EXPECT_EQ("", Problems(doc.get())); +} + +TEST_F(EPDFOutlineEmbedderTest, MoveKeepsNumbersKeysAndState) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + // C goes under A, closed, with its child and its keys. + ASSERT_TRUE(EPDFOutline_MoveItem(doc.get(), kC, kA, kA1)); + EXPECT_EQ("4:A+2[A1 C-1[C1]] B", Tree(doc.get())); + EXPECT_EQ(kC, RefOf(DictOf(doc.get(), kC1).Get(), "Parent")); + EXPECT_EQ("", Problems(doc.get())); + // To the top, first; then along its own list. + ASSERT_TRUE(EPDFOutline_MoveItem(doc.get(), kC, 0, 0)); + EXPECT_EQ("4:C-1[C1] A+1[A1] B", Tree(doc.get())); + ASSERT_TRUE(EPDFOutline_MoveItem(doc.get(), kC, 0, kB)); + EXPECT_EQ("4:A+1[A1] B C-1[C1]", Tree(doc.get())); + ASSERT_TRUE(EPDFOutline_MoveItem(doc.get(), kA, 0, kB)); + EXPECT_EQ("4:B A+1[A1] C-1[C1]", Tree(doc.get())); + // A's subtree under B, which was a leaf: B starts closed. + ASSERT_TRUE(EPDFOutline_MoveItem(doc.get(), kA, kB, 0)); + EXPECT_EQ("2:B-2[A+1[A1]] C-1[C1]", Tree(doc.get())); + // A last child leaving takes its parent's /Count with it. + ASSERT_TRUE(EPDFOutline_MoveItem(doc.get(), kC1, 0, 0)); + EXPECT_EQ("3:C1 B-2[A+1[A1]] C", Tree(doc.get())); + EXPECT_EQ("", Problems(doc.get())); +} + +TEST_F(EPDFOutlineEmbedderTest, MoveRefusesAndChangesNothing) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + const std::map before = ObjectGraph(pdf); + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), kA, kA, 0)); // itself + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), kA, kA1, 0)); // under it + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), kA, 0, kA)); // after itself + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), kA, 0, kA1)); // not a top child + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), kA, kFirstPage, 0)); + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), kOutlines, 0, 0)); + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), 99, 0, 0)); + EXPECT_EQ(before, ObjectGraph(pdf)); +} + +TEST_F(EPDFOutlineEmbedderTest, DeleteTakesTheItemsUnderIt) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + ASSERT_TRUE(EPDFOutline_DeleteItem(doc.get(), kA)); + EXPECT_EQ("2:B C-1[C1]", Tree(doc.get())); + // The file's dictionaries can still be read; they are no items. + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), kA)); + EXPECT_FALSE(EPDFOutline_GetItem(doc.get(), kA1)); + EXPECT_FALSE(EPDFOutline_DeleteItem(doc.get(), kA)); + ASSERT_TRUE(EPDFOutline_DeleteItem(doc.get(), kC1)); + EXPECT_EQ("2:B C", Tree(doc.get())); + EXPECT_EQ("", Problems(doc.get())); + // The last ones: the outline stays, empty. + ASSERT_TRUE(EPDFOutline_DeleteItem(doc.get(), kB)); + ASSERT_TRUE(EPDFOutline_DeleteItem(doc.get(), kC)); + RetainPtr outlines = DictOf(doc.get(), kOutlines); + ASSERT_TRUE(outlines); + EXPECT_FALSE(outlines->KeyExist("First")); + EXPECT_FALSE(outlines->KeyExist("Last")); + EXPECT_FALSE(outlines->KeyExist("Count")); + EXPECT_EQ(kOutlines, OutlinesOf(doc.get())); + ASSERT_TRUE(Create(doc.get(), 0, 0, L"Again")); + EXPECT_EQ("1:Again", Tree(doc.get())); +} + +TEST_F(EPDFOutlineEmbedderTest, SetOpenFlipsTheSignAndCountsAgain) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + ASSERT_TRUE(EPDFOutline_SetOpen(doc.get(), kC, true)); + EXPECT_EQ("5:A+1[A1] B C+1[C1]", Tree(doc.get())); + ASSERT_TRUE(EPDFOutline_SetOpen(doc.get(), kA, false)); + EXPECT_EQ("4:A-1[A1] B C+1[C1]", Tree(doc.get())); + // A leaf has no open state: nothing is written. + ASSERT_TRUE(EPDFOutline_SetOpen(doc.get(), kB, true)); + EXPECT_FALSE(DictOf(doc.get(), kB)->KeyExist("Count")); + EXPECT_FALSE(EPDFOutline_SetOpen(doc.get(), 99, true)); + EXPECT_EQ("", Problems(doc.get())); +} + +TEST_F(EPDFOutlineEmbedderTest, TitleColorAndStyle) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + ScopedFPDFWideString renamed = GetFPDFWideString(L"Ölçü"); + ASSERT_TRUE(EPDFOutline_SetTitle(doc.get(), kA1, renamed.get())); + EXPECT_EQ("Ölçü", TitleOf(doc.get(), kA1)); + EXPECT_FALSE(EPDFOutline_SetTitle(doc.get(), kFirstPage, renamed.get())); + + FPDF_BOOKMARK a = EPDFOutline_GetItem(doc.get(), kA); + float r = 0; + float g = 0; + float b = 0; + EXPECT_FALSE(FPDFBookmark_GetColor(a, &r, &g, &b)); + ASSERT_TRUE(EPDFOutline_SetColor(doc.get(), kA, true, 0, 0.5f, 1)); + a = EPDFOutline_GetItem(doc.get(), kA); + ASSERT_TRUE(FPDFBookmark_GetColor(a, &r, &g, &b)); + EXPECT_FLOAT_EQ(0, r); + EXPECT_FLOAT_EQ(0.5f, g); + EXPECT_FLOAT_EQ(1, b); + EXPECT_FALSE(EPDFOutline_SetColor(doc.get(), kA, true, 0, 2, 0)); + EXPECT_FALSE(EPDFOutline_SetColor(doc.get(), kA, true, -1, 0, 0)); + ASSERT_TRUE(EPDFOutline_SetColor(doc.get(), kB, false, 0, 0, 0)); + EXPECT_FALSE(DictOf(doc.get(), kB)->KeyExist("C")); + + ASSERT_TRUE(EPDFOutline_SetStyle(doc.get(), kA, 3)); + EXPECT_EQ(3, FPDFBookmark_GetStyle(EPDFOutline_GetItem(doc.get(), kA))); + ASSERT_TRUE(EPDFOutline_SetStyle(doc.get(), kB, 0)); + EXPECT_FALSE(DictOf(doc.get(), kB)->KeyExist("F")); + EXPECT_FALSE(EPDFOutline_SetStyle(doc.get(), kA, -1)); +} + +TEST_F(EPDFOutlineEmbedderTest, SetActionReplacesTheDestination) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + ASSERT_TRUE(DictOf(doc.get(), kA)->KeyExist("Dest")); + FPDF_ACTION action = GoTo(doc.get(), kFirstPage); + ASSERT_TRUE(action); + ASSERT_TRUE(EPDFOutline_SetAction(doc.get(), kA, action)); + RetainPtr a = DictOf(doc.get(), kA); + EXPECT_FALSE(a->KeyExist("Dest")); + EXPECT_TRUE(RefOf(a.Get(), "A")); + EXPECT_TRUE(FPDFBookmark_GetAction(EPDFOutline_GetItem(doc.get(), kA))); + + // Another document's action is refused; none removes the target. + ScopedFPDFDocument other = OpenLayer(); + ASSERT_TRUE(other); + FPDF_ACTION foreign = GoTo(other.get(), kFirstPage); + ASSERT_TRUE(foreign); + EXPECT_FALSE(EPDFOutline_SetAction(doc.get(), kB, foreign)); + ASSERT_TRUE(EPDFOutline_SetAction(doc.get(), kA, nullptr)); + a = DictOf(doc.get(), kA); + EXPECT_FALSE(a->KeyExist("A")); + EXPECT_FALSE(a->KeyExist("Dest")); +} + +// A new target, or none, replaces the item's own /A: an action object it +// shares with another item is never written, on a plain document or a layer. +TEST_F(EPDFOutlineEmbedderTest, SetActionLeavesASharedActionAlone) { + const std::string bytes = MakeDocumentWith({ + "<< /Type /Outlines /First 5 0 R /Last 6 0 R /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Title (One) /Parent 3 0 R /Next 6 0 R /A 7 0 R >>", + "<< /Title (Two) /Parent 3 0 R /Prev 5 0 R /A 7 0 R >>", + "<< /S /GoTo /D [4 0 R /Fit] >>", + }); + { + ScopedFPDFDocument doc = OpenPlain(bytes); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + const std::string shared = ObjectGraph(pdf).at(7); + ASSERT_TRUE(EPDFOutline_SetAction(doc.get(), 5, GoTo(doc.get(), 4))); + const uint32_t replaced = RefOf(DictOf(doc.get(), 5).Get(), "A"); + EXPECT_TRUE(replaced); + EXPECT_NE(7u, replaced); + EXPECT_EQ(7u, RefOf(DictOf(doc.get(), 6).Get(), "A")); + ASSERT_TRUE(EPDFOutline_SetAction(doc.get(), 6, nullptr)); + EXPECT_FALSE(DictOf(doc.get(), 6)->KeyExist("A")); + EXPECT_EQ(shared, ObjectGraph(pdf).at(7)); + } + ScopedFPDFDocument layer = OpenLayer(bytes); + ASSERT_TRUE(layer); + CPDF_LayerDocument* pdf = CPDF_LayerDocument::FromDocument( + CPDFDocumentFromFPDFDocument(layer.get())); + ASSERT_TRUE(EPDFOutline_SetAction(layer.get(), 5, GoTo(layer.get(), 4))); + EXPECT_TRUE(pdf->FindLayerVersion(5)); + EXPECT_FALSE(pdf->FindLayerVersion(6)); + EXPECT_FALSE(pdf->FindLayerVersion(7)); + EXPECT_EQ(7u, RefOf(DictOf(layer.get(), 6).Get(), "A")); +} + +// A /Next that comes back ends the list; the next write of that list +// straightens it. +TEST_F(EPDFOutlineEmbedderTest, ALoopEndsTheList) { + ScopedFPDFDocument doc = OpenPlain(MakeDocumentWith({ + "<< /Type /Outlines /First 5 0 R /Last 6 0 R /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Title (One) /Parent 3 0 R /Next 6 0 R >>", + "<< /Title (Two) /Parent 3 0 R /Prev 5 0 R /Next 5 0 R >>", + })); + ASSERT_TRUE(doc); + EXPECT_TRUE(EPDFOutline_GetItem(doc.get(), 6)); + ASSERT_TRUE(Create(doc.get(), 0, 6, L"Three")); + EXPECT_EQ("", Problems(doc.get())); + EXPECT_EQ("3:One Two Three", Tree(doc.get())); +} + +// A wrong /Last, /Prev or /Count never misleads a write. A write puts right +// the lists it changes and the counts above them, and leaves the rest. +TEST_F(EPDFOutlineEmbedderTest, WrongLinksAndCountsAreWrittenRight) { + ScopedFPDFDocument doc = OpenPlain(MakeDocumentWith({ + "<< /Type /Outlines /First 5 0 R /Last 5 0 R /Count 9 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Title (One) /Parent 3 0 R /Next 6 0 R /Prev 6 0 R >>", + "<< /Title (Two) /Parent 3 0 R /First 7 0 R /Count 5 >>", + "<< /Title (Two.1) /Parent 6 0 R >>", + })); + ASSERT_TRUE(doc); + ASSERT_TRUE(Create(doc.get(), 0, 6, L"Three")); + // The top list and the outline's count are right, counted from the tree; + // Two's own list wasn't written. + EXPECT_EQ("6: /Last 0, expected 7; 6: /Count 5, 1 under it; ", + Problems(doc.get())); + EXPECT_EQ("4:One Two+5[Two.1] Three", Tree(doc.get())); + ASSERT_TRUE(Create(doc.get(), 6, 7, L"Two.2")); + EXPECT_EQ("", Problems(doc.get())); + EXPECT_EQ("5:One Two+2[Two.1 Two.2] Three", Tree(doc.get())); +} + +// A list holding an item that isn't an indirect object is never written; +// what doesn't change the list still works. +TEST_F(EPDFOutlineEmbedderTest, ADirectItemKeepsItsListAsItIs) { + ScopedFPDFDocument doc = OpenPlain(MakeDocumentWith({ + "<< /Type /Outlines /First 5 0 R /Count 2 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + "<< /Title (One) /Parent 3 0 R /Next << /Title (Direct) >> >>", + })); + ASSERT_TRUE(doc); + CPDF_Document* pdf = CPDFDocumentFromFPDFDocument(doc.get()); + const std::map before = ObjectGraph(pdf); + EXPECT_FALSE(Create(doc.get(), 0, 0, L"x")); + EXPECT_FALSE(EPDFOutline_DeleteItem(doc.get(), 5)); + EXPECT_FALSE(EPDFOutline_MoveItem(doc.get(), 5, 0, 0)); + EXPECT_EQ(before, ObjectGraph(pdf)); + ScopedFPDFWideString renamed = GetFPDFWideString(L"Renamed"); + EXPECT_TRUE(EPDFOutline_SetTitle(doc.get(), 5, renamed.get())); + EXPECT_EQ("Renamed", TitleOf(doc.get(), 5)); +} + +TEST_F(EPDFOutlineEmbedderTest, ADirectOutlineDictionaryIsNeverWritten) { + ScopedFPDFDocument doc = OpenPlain(MakePdf({ + "<< /Type /Catalog /Pages 2 0 R /Outlines << /Type /Outlines >> >>", + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>", + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] >>", + })); + ASSERT_TRUE(doc); + EXPECT_FALSE(Create(doc.get(), 0, 0, L"x")); +} + +// Random writes, checked after each one: the links and the counts are what +// the tree says. +TEST_F(EPDFOutlineEmbedderTest, RandomWritesKeepTheOutlineRight) { + ScopedFPDFDocument doc = OpenPlain(); + ASSERT_TRUE(doc); + std::mt19937 random(20261018); + auto pick = [&](const std::vector& from) -> uint32_t { + if (from.empty()) { + return 0; + } + return from[std::uniform_int_distribution(0, + from.size() - 1)(random)]; + }; + for (int step = 0; step < 300; ++step) { + SCOPED_TRACE(step); + const std::vector items = Items(doc.get()); + const uint32_t item = pick(items); + // A parent and a neighbour that may or may not fit: refusals must also + // leave the outline right. + const uint32_t parent = random() % 4 ? pick(items) : 0; + std::vector siblings; + for (FPDF_BOOKMARK child = FPDFBookmark_GetFirstChild( + doc.get(), + parent ? EPDFOutline_GetItem(doc.get(), parent) : nullptr); + child; child = FPDFBookmark_GetNextSibling(doc.get(), child)) { + siblings.push_back(EPDFOutline_GetObjectNumber(child)); + } + const uint32_t after = random() % 3 ? pick(siblings) : 0; + switch (random() % 5) { + case 0: + case 1: + Create(doc.get(), parent, after, L"new"); + break; + case 2: + if (item) { + EPDFOutline_MoveItem(doc.get(), item, parent, after); + } + break; + case 3: + if (item && items.size() > 3) { + EPDFOutline_DeleteItem(doc.get(), item); + } + break; + case 4: + if (item) { + EPDFOutline_SetOpen(doc.get(), item, random() % 2); + } + break; + } + ASSERT_EQ("", Problems(doc.get())); + } +} + +// A write changes only what it must: appending an item touches the item +// before it and the outline, never the items further up the list. +TEST_F(EPDFOutlineEmbedderTest, WritesCopyOnlyWhatChanges) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + CPDF_LayerDocument* pdf = CPDF_LayerDocument::FromDocument( + CPDFDocumentFromFPDFDocument(layer.get())); + ASSERT_TRUE(Create(layer.get(), 0, kC, L"End")); + EXPECT_TRUE(pdf->FindLayerVersion(kC)); + EXPECT_TRUE(pdf->FindLayerVersion(kOutlines)); + EXPECT_FALSE(pdf->FindLayerVersion(kA)); + EXPECT_FALSE(pdf->FindLayerVersion(kB)); + EXPECT_FALSE(pdf->FindLayerVersion(kA1)); + EXPECT_FALSE(pdf->FindLayerVersion(kC1)); +} + +// The readers see the layer's own versions of the file's items. +TEST_F(EPDFOutlineEmbedderTest, ReadsSeeWhatTheLayerWrote) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ScopedFPDFWideString renamed = GetFPDFWideString(L"B renamed"); + ASSERT_TRUE(EPDFOutline_SetTitle(layer.get(), kB, renamed.get())); + ASSERT_TRUE(EPDFOutline_SetOpen(layer.get(), kC, true)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ("5:A+1[A1] B renamed C+1[C1]", Tree(layer.get())); + EXPECT_EQ("", Problems(layer.get())); + ScopedFPDFDocument reopened = Reopen(layer.get()); + ASSERT_TRUE(reopened); + EXPECT_EQ("5:A+1[A1] B renamed C+1[C1]", Tree(reopened.get())); +} + +// An item the layer made comes back at its number and place, with the items +// under it and its action, also when a save and reopen dropped what the +// delete left behind. +TEST_F(EPDFOutlineEmbedderTest, DeletedLayerItemComesBack) { + for (bool reopen : {false, true}) { + SCOPED_TRACE(reopen ? "reopened between delete and import" + : "same session"); + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + ASSERT_EQ(20u, Create(layer.get(), 0, kA, L"New", 20)); + ASSERT_EQ(21u, Create(layer.get(), 20, 0, L"New.1", 21)); + ASSERT_TRUE(EPDFOutline_SetOpen(layer.get(), 20, true)); + FPDF_ACTION action = GoTo(layer.get(), kFirstPage); + ASSERT_TRUE(EPDFOutline_SetAction(layer.get(), 20, action)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + const uint32_t action_objnum = RefOf(DictOf(layer.get(), 20).Get(), "A"); + ASSERT_TRUE(action_objnum); + EXPECT_EQ("6:A+1[A1] New+1[New.1] B C-1[C1]", Tree(layer.get())); + + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportItem(layer.get(), 20); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(EPDFOutline_DeleteItem(layer.get(), 20)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ("4:A+1[A1] B C-1[C1]", Tree(layer.get())); + EXPECT_FALSE(EPDFOutline_GetItem(layer.get(), 20)); + + ScopedFPDFDocument target = reopen ? Reopen(layer.get()) : std::move(layer); + ASSERT_TRUE(target); + if (reopen) { + EXPECT_FALSE(DictOf(target.get(), action_objnum)); + } + ASSERT_TRUE(EPDFLayer_BeginTransaction(target.get())); + ASSERT_TRUE(ImportItem(target.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(target.get())); + EXPECT_EQ("6:A+1[A1] New+1[New.1] B C-1[C1]", Tree(target.get())); + EXPECT_EQ(action_objnum, RefOf(DictOf(target.get(), 20).Get(), "A")); + EXPECT_TRUE(DictOf(target.get(), action_objnum)); + EXPECT_EQ("", Problems(target.get())); + } +} + +// An item of the file comes back as the file's original, the items under it +// with it; the layer edited it, so the capture holds that edit. +TEST_F(EPDFOutlineEmbedderTest, DeletedFileItemComesBack) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ScopedFPDFWideString renamed = GetFPDFWideString(L"A1 renamed"); + ASSERT_TRUE(EPDFOutline_SetTitle(layer.get(), kA1, renamed.get())); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector capture = ExportItem(layer.get(), kA); + ASSERT_FALSE(capture.empty()); + ASSERT_TRUE(EPDFOutline_DeleteItem(layer.get(), kA)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ("2:B C-1[C1]", Tree(layer.get())); + + ScopedFPDFDocument reopened = Reopen(layer.get()); + ASSERT_TRUE(reopened); + ASSERT_TRUE(EPDFLayer_BeginTransaction(reopened.get())); + ASSERT_TRUE(ImportItem(reopened.get(), capture)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(reopened.get())); + EXPECT_EQ("4:A+1[A1 renamed] B C-1[C1]", Tree(reopened.get())); + EXPECT_TRUE(DictOf(reopened.get(), kA)->KeyExist("Dest")); + EXPECT_EQ("", Problems(reopened.get())); +} + +// Its place: after the sibling it followed while that is still there, else +// first; never twice, and never under a parent that is gone. +TEST_F(EPDFOutlineEmbedderTest, ImportFindsItsPlace) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + ASSERT_TRUE(EPDFLayer_BeginTransaction(layer.get())); + const std::vector b = ExportItem(layer.get(), kB); + const std::vector c1 = ExportItem(layer.get(), kC1); + ASSERT_FALSE(b.empty()); + ASSERT_FALSE(c1.empty()); + EXPECT_FALSE(ImportItem(layer.get(), b)); // it's there + ASSERT_TRUE(EPDFOutline_DeleteItem(layer.get(), kB)); + ASSERT_TRUE(EPDFOutline_DeleteItem(layer.get(), kA)); + ASSERT_TRUE(EPDFOutline_DeleteItem(layer.get(), kC)); + EXPECT_FALSE(ImportItem(layer.get(), c1)); // its parent is gone + ASSERT_TRUE(Create(layer.get(), 0, 0, L"Other")); + ASSERT_TRUE(ImportItem(layer.get(), b)); // A is gone: first + EXPECT_FALSE(ImportItem(layer.get(), b)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(layer.get())); + EXPECT_EQ("2:B Other", Tree(layer.get())); + EXPECT_EQ("", Problems(layer.get())); +} + +TEST_F(EPDFOutlineEmbedderTest, CapturesNeedAnItemAndALayer) { + ScopedFPDFDocument layer = OpenLayer(); + ASSERT_TRUE(layer); + EXPECT_TRUE(ExportItem(layer.get(), kOutlines).empty()); + EXPECT_TRUE(ExportItem(layer.get(), kFirstPage).empty()); + EXPECT_TRUE(ExportItem(layer.get(), 99).empty()); + const std::vector capture = ExportItem(layer.get(), kB); + ASSERT_FALSE(capture.empty()); + EXPECT_FALSE(ImportItem(layer.get(), capture)); // outside a transaction + ScopedFPDFDocument plain = OpenPlain(); + ASSERT_TRUE(plain); + EXPECT_TRUE(ExportItem(plain.get(), kB).empty()); +} diff --git a/fpdfsdk/epdf_outline_helpers.cpp b/fpdfsdk/epdf_outline_helpers.cpp new file mode 100644 index 000000000..5c65d51a6 --- /dev/null +++ b/fpdfsdk/epdf_outline_helpers.cpp @@ -0,0 +1,282 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "fpdfsdk/epdf_outline_helpers.h" + +#include +#include +#include +#include + +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_name.h" +#include "core/fpdfapi/parser/cpdf_number.h" +#include "core/fpdfapi/parser/cpdf_reference.h" + +namespace epdf { + +namespace { + +RetainPtr DictOf(CPDF_Document* doc, uint32_t objnum) { + return objnum ? ToDictionary(doc->GetOrParseIndirectObject(objnum)) : nullptr; +} + +// The object number |dict| refers to under |key|, or 0. +uint32_t LinkOf(const CPDF_Dictionary* dict, ByteStringView key) { + RetainPtr value = dict ? dict->GetObjectFor(key) : nullptr; + const CPDF_Reference* ref = value ? value->AsReference() : nullptr; + return ref ? ref->GetRefObjNum() : 0; +} + +bool IsOpen(const CPDF_Dictionary* item) { + return item->GetIntegerFor("Count") > 0; +} + +// The children of |dict|, whose own number is |self| (0 when it has none). +std::vector ChildrenOfDict(CPDF_Document* doc, + const CPDF_Dictionary* dict, + uint32_t self) { + std::vector children; + if (!dict) { + return children; + } + std::set seen; + if (self) { + seen.insert(self); + } + RetainPtr link = dict->GetObjectFor("First"); + while (link) { + OutlineChild child; + if (const CPDF_Reference* ref = link->AsReference()) { + child.objnum = ref->GetRefObjNum(); + if (!seen.insert(child.objnum).second) { + break; // a loop + } + child.dict = DictOf(doc, child.objnum); + } else { + child.dict = ToDictionary(link); + } + if (!child.dict) { + break; // a reference to nothing, or not a dictionary + } + link = child.dict->GetObjectFor("Next"); + children.push_back(std::move(child)); + } + return children; +} + +// How many items show under |node| when it is open: each child, and what +// shows under each open child, read from the tree. |known| holds what was +// worked out already, by object number. +int VisibleUnder(CPDF_Document* doc, + uint32_t node, + const std::map& known) { + int total = 0; + std::set expanded = {node}; + std::vector pending = + ChildrenOfDict(doc, DictOf(doc, node).Get(), node); + while (!pending.empty()) { + OutlineChild child = std::move(pending.back()); + pending.pop_back(); + ++total; + if (!IsOpen(child.dict.Get())) { + continue; + } + if (child.objnum) { + if (auto it = known.find(child.objnum); it != known.end()) { + total += it->second; + continue; + } + if (!expanded.insert(child.objnum).second) { + continue; // a loop + } + } + for (OutlineChild& grandchild : + ChildrenOfDict(doc, child.dict.Get(), child.objnum)) { + pending.push_back(std::move(grandchild)); + } + } + return total; +} + +RetainPtr WritableDictOf(CPDF_Document* doc, uint32_t objnum) { + return ToDictionary(doc->GetMutableIndirectObject(objnum)); +} + +// Makes |objnum|'s |key| refer to |target| (0 removes it), unless it does. +bool SetLink(CPDF_Document* doc, + uint32_t objnum, + const char* key, + uint32_t target) { + RetainPtr dict = DictOf(doc, objnum); + if (!dict) { + return false; + } + const bool present = dict->KeyExist(key); + if (target ? present && LinkOf(dict.Get(), key) == target : !present) { + return true; + } + RetainPtr writable = WritableDictOf(doc, objnum); + if (!writable) { + return false; + } + if (target) { + writable->SetNewFor(key, doc, target); + } else { + writable->RemoveFor(key); + } + return true; +} + +// Makes |objnum|'s /Count |count| (0 removes it), unless it is. +bool SetCount(CPDF_Document* doc, uint32_t objnum, int count) { + RetainPtr dict = DictOf(doc, objnum); + if (!dict) { + return false; + } + RetainPtr value = dict->GetObjectFor("Count"); + if (count ? value && value->IsNumber() && value->GetInteger() == count + : !value) { + return true; + } + RetainPtr writable = WritableDictOf(doc, objnum); + if (!writable) { + return false; + } + if (count) { + writable->SetNewFor("Count", count); + } else { + writable->RemoveFor("Count"); + } + return true; +} + +} // namespace + +std::optional OutlineRootOf(CPDF_Document* doc) { + const CPDF_Dictionary* catalog = doc->GetRoot(); + RetainPtr value = + catalog ? catalog->GetObjectFor("Outlines") : nullptr; + if (!value) { + return 0; + } + const CPDF_Reference* ref = value->AsReference(); + if (!ref) { + return value->IsDictionary() ? std::nullopt : std::optional(0); + } + return DictOf(doc, ref->GetRefObjNum()) ? ref->GetRefObjNum() : 0; +} + +uint32_t CreateOutlineRoot(CPDF_Document* doc) { + RetainPtr catalog = doc->GetMutableRoot(); + if (!catalog) { + return 0; + } + RetainPtr outlines = doc->NewIndirect(); + outlines->SetNewFor("Type", "Outlines"); + catalog->SetNewFor("Outlines", doc, outlines->GetObjNum()); + return outlines->GetObjNum(); +} + +std::vector OutlineChildrenOf(CPDF_Document* doc, uint32_t node) { + return ChildrenOfDict(doc, DictOf(doc, node).Get(), node); +} + +std::optional> OutlineChildNumbers(CPDF_Document* doc, + uint32_t node) { + std::vector numbers; + for (const OutlineChild& child : OutlineChildrenOf(doc, node)) { + if (!child.objnum) { + return std::nullopt; + } + numbers.push_back(child.objnum); + } + return numbers; +} + +bool IsOutlineItem(CPDF_Document* doc, uint32_t root, uint32_t objnum) { + if (!root || !objnum || objnum == root) { + return false; + } + std::set seen; + for (uint32_t node = objnum; node != root;) { + if (!seen.insert(node).second) { + return false; // a loop in the /Parent chain + } + const uint32_t parent = OutlineParentOf(doc, node); + if (!parent) { + return false; + } + const std::vector siblings = OutlineChildrenOf(doc, parent); + if (std::ranges::none_of(siblings, [node](const OutlineChild& child) { + return child.objnum == node; + })) { + return false; + } + node = parent; + } + return true; +} + +uint32_t OutlineParentOf(CPDF_Document* doc, uint32_t objnum) { + return LinkOf(DictOf(doc, objnum).Get(), "Parent"); +} + +std::vector OutlineSubtreeOf(CPDF_Document* doc, uint32_t objnum) { + std::vector items = {objnum}; + std::set seen = {objnum}; + for (size_t i = 0; i < items.size(); ++i) { + const uint32_t parent = items[i]; + for (const OutlineChild& child : OutlineChildrenOf(doc, parent)) { + if (child.objnum && LinkOf(child.dict.Get(), "Parent") == parent && + seen.insert(child.objnum).second) { + items.push_back(child.objnum); + } + } + } + return items; +} + +bool WriteOutlineChildren(CPDF_Document* doc, + uint32_t node, + const std::vector& children) { + for (size_t i = 0; i < children.size(); ++i) { + const uint32_t prev = i > 0 ? children[i - 1] : 0; + const uint32_t next = i + 1 < children.size() ? children[i + 1] : 0; + if (!SetLink(doc, children[i], "Parent", node) || + !SetLink(doc, children[i], "Prev", prev) || + !SetLink(doc, children[i], "Next", next)) { + return false; + } + } + return SetLink(doc, node, "First", children.empty() ? 0 : children.front()) && + SetLink(doc, node, "Last", children.empty() ? 0 : children.back()); +} + +bool RecountOutline(CPDF_Document* doc, uint32_t root, uint32_t start) { + std::map known; + std::set seen; + for (uint32_t node = start; node && seen.insert(node).second;) { + RetainPtr dict = DictOf(doc, node); + if (!dict) { + return false; + } + const int visible = VisibleUnder(doc, node, known); + known[node] = visible; + int count = 0; + if (visible > 0) { + count = node == root || IsOpen(dict.Get()) ? visible : -visible; + } + if (!SetCount(doc, node, count)) { + return false; + } + if (node == root) { + break; + } + node = LinkOf(dict.Get(), "Parent"); + } + return true; +} + +} // namespace epdf diff --git a/fpdfsdk/epdf_outline_helpers.h b/fpdfsdk/epdf_outline_helpers.h new file mode 100644 index 000000000..cc0c12c3b --- /dev/null +++ b/fpdfsdk/epdf_outline_helpers.h @@ -0,0 +1,76 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +// The mechanics of the document outline that its writers (epdf_outline.cpp) +// and its captures (epdf_capture.cpp) share: reading its lists, naming its +// items, and writing lists and counts the way public/epdf_outline.h promises. +// Callers open the document's view first (ScopedFPDFDocumentView). + +#ifndef FPDFSDK_EPDF_OUTLINE_HELPERS_H_ +#define FPDFSDK_EPDF_OUTLINE_HELPERS_H_ + +#include + +#include +#include + +#include "core/fxcrt/retain_ptr.h" + +class CPDF_Dictionary; +class CPDF_Document; + +namespace epdf { + +// One entry of a parent's list of children. +struct OutlineChild { + // Its object number; 0 when it isn't an indirect object. + uint32_t objnum = 0; + RetainPtr dict; +}; + +// The outline's own dictionary, /Root /Outlines: its object number, 0 when +// the document has none, or nullopt when it is a direct dictionary, which no +// item's /Parent can refer to. +std::optional OutlineRootOf(CPDF_Document* doc); + +// Makes a new /Root /Outlines and returns its object number, or 0. +uint32_t CreateOutlineRoot(CPDF_Document* doc); + +// The children of |node| (the outline's dictionary or an item): its /First +// and the /Next that follow. A repeat, or a reference to nothing, ends the +// list. +std::vector OutlineChildrenOf(CPDF_Document* doc, uint32_t node); + +// The object numbers of |node|'s children, or nullopt when one of them isn't +// an indirect object: a list no write may change. +std::optional> OutlineChildNumbers(CPDF_Document* doc, + uint32_t node); + +// Whether |objnum| is an item of the outline whose dictionary is |root|: +// each step up its /Parent chain is among the parent's children, up to +// |root|. +bool IsOutlineItem(CPDF_Document* doc, uint32_t root, uint32_t objnum); + +// The object number the item's /Parent refers to, or 0. +uint32_t OutlineParentOf(CPDF_Document* doc, uint32_t objnum); + +// The item |objnum| and the items under it, the item first. A child goes +// with its parent only when its /Parent says so, so a list that wandered +// into another part of the tree never takes that part along. +std::vector OutlineSubtreeOf(CPDF_Document* doc, uint32_t objnum); + +// Makes |children| the children of |node|, in order: their /Parent, /Prev +// and /Next, and |node|'s /First and /Last (both removed when there are +// none). Only the keys whose value changes are written. +bool WriteOutlineChildren(CPDF_Document* doc, + uint32_t node, + const std::vector& children); + +// Counts again from |start| up to |root|: each one's /Count from the tree, +// with its sign kept; |root|'s is never negative. An item without children +// loses its /Count. +bool RecountOutline(CPDF_Document* doc, uint32_t root, uint32_t start); + +} // namespace epdf + +#endif // FPDFSDK_EPDF_OUTLINE_HELPERS_H_ diff --git a/fpdfsdk/epdf_transaction_embeddertest.cpp b/fpdfsdk/epdf_transaction_embeddertest.cpp index 0ca127711..94b64be43 100644 --- a/fpdfsdk/epdf_transaction_embeddertest.cpp +++ b/fpdfsdk/epdf_transaction_embeddertest.cpp @@ -34,6 +34,7 @@ #include "public/epdf_attachment.h" #include "public/epdf_doc.h" #include "public/epdf_layer.h" +#include "public/epdf_outline.h" #include "public/epdf_save.h" #include "public/fpdf_annot.h" #include "public/fpdf_attachment.h" @@ -576,10 +577,10 @@ TEST_F(EPDFTransactionEmbedderTest, AttachmentWritesNeedAWriteHandle) { } } -// Bookmarks: a handle read before a transaction belongs to a committed -// version. Writers that take the document write the document's version of -// it (a copy, in the transaction); those that don't refuse it. -TEST_F(EPDFTransactionEmbedderTest, BookmarkWritesGoThroughTheDoor) { +// The outline: its writers name an item by number, so an item made before a +// transaction is written as the document's version of it (a copy, in the +// transaction), and an abort drops what the transaction wrote. +TEST_F(EPDFTransactionEmbedderTest, OutlineWritesGoThroughTheDoor) { auto title = [](FPDF_BOOKMARK bookmark) { unsigned long length = FPDFBookmark_GetTitle(bookmark, nullptr, 0); std::vector buffer(length / sizeof(FPDF_WCHAR) + 1); @@ -599,22 +600,24 @@ TEST_F(EPDFTransactionEmbedderTest, BookmarkWritesGoThroughTheDoor) { ScopedFPDFDocument doc = Open(); ASSERT_TRUE(doc); ScopedFPDFWideString chapter = GetFPDFWideString(L"Chapter"); - ASSERT_TRUE(EPDFBookmark_AppendChild(doc.get(), nullptr, chapter.get())); - FPDF_BOOKMARK read = FPDFBookmark_GetFirstChild(doc.get(), nullptr); - ASSERT_EQ("Chapter", title(read)); + const uint32_t item = + EPDFOutline_CreateItem(doc.get(), 0, 0, 0, chapter.get()); + ASSERT_TRUE(item); ASSERT_TRUE(EPDFLayer_BeginTransaction(doc.get())); ScopedFPDFWideString renamed = GetFPDFWideString(L"Renamed"); - EXPECT_FALSE(EPDFBookmark_SetTitle(read, renamed.get())); // no document + EXPECT_TRUE(EPDFOutline_SetTitle(doc.get(), item, renamed.get())); ScopedFPDFWideString section = GetFPDFWideString(L"Section"); - EXPECT_TRUE(EPDFBookmark_AppendChild(doc.get(), read, section.get())); - EXPECT_EQ( - 1, children(doc.get(), FPDFBookmark_GetFirstChild(doc.get(), nullptr))); + EXPECT_TRUE(EPDFOutline_CreateItem(doc.get(), 0, item, 0, section.get())); + FPDF_BOOKMARK during = FPDFBookmark_GetFirstChild(doc.get(), nullptr); + EXPECT_EQ("Renamed", title(during)); + EXPECT_EQ(1, children(doc.get(), during)); ASSERT_TRUE(commit ? EPDFLayer_CommitTransaction(doc.get()) : EPDFLayer_AbortTransaction(doc.get())); FPDF_BOOKMARK after = FPDFBookmark_GetFirstChild(doc.get(), nullptr); - EXPECT_EQ("Chapter", title(after)); + EXPECT_EQ(item, EPDFOutline_GetObjectNumber(after)); + EXPECT_EQ(commit ? "Renamed" : "Chapter", title(after)); EXPECT_EQ(commit ? 1 : 0, children(doc.get(), after)); } } diff --git a/fpdfsdk/fpdf_doc.cpp b/fpdfsdk/fpdf_doc.cpp index 0dfb517a2..c6ad47ef1 100644 --- a/fpdfsdk/fpdf_doc.cpp +++ b/fpdfsdk/fpdf_doc.cpp @@ -143,7 +143,9 @@ inline ByteStringView StatusToTrappedName(FPDF_TRAPPED_STATUS status) { FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV FPDFBookmark_GetFirstChild(FPDF_DOCUMENT document, FPDF_BOOKMARK bookmark) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + // EmbedPDF(view-scope): read through the document's view scope. + ScopedFPDFDocumentView document_view(document); + CPDF_Document* doc = document_view.Get(); if (!doc) { return nullptr; } @@ -156,7 +158,9 @@ FPDFBookmark_GetFirstChild(FPDF_DOCUMENT document, FPDF_BOOKMARK bookmark) { FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV FPDFBookmark_GetNextSibling(FPDF_DOCUMENT document, FPDF_BOOKMARK bookmark) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + // EmbedPDF(view-scope): read through the document's view scope. + ScopedFPDFDocumentView document_view(document); + CPDF_Document* doc = document_view.Get(); if (!doc) { return nullptr; } @@ -198,7 +202,9 @@ FPDF_EXPORT int FPDF_CALLCONV FPDFBookmark_GetCount(FPDF_BOOKMARK bookmark) { FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV FPDFBookmark_Find(FPDF_DOCUMENT document, FPDF_WIDESTRING title) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + // EmbedPDF(view-scope): read through the document's view scope. + ScopedFPDFDocumentView document_view(document); + CPDF_Document* doc = document_view.Get(); if (!doc) { return nullptr; } @@ -217,7 +223,9 @@ FPDFBookmark_Find(FPDF_DOCUMENT document, FPDF_WIDESTRING title) { FPDF_EXPORT FPDF_DEST FPDF_CALLCONV FPDFBookmark_GetDest(FPDF_DOCUMENT document, FPDF_BOOKMARK bookmark) { - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + // EmbedPDF(view-scope): read through the document's view scope. + ScopedFPDFDocumentView document_view(document); + CPDF_Document* doc = document_view.Get(); if (!doc) { return nullptr; } diff --git a/public/epdf_capture.h b/public/epdf_capture.h index 7b1d1a481..097b8be46 100644 --- a/public/epdf_capture.h +++ b/public/epdf_capture.h @@ -107,6 +107,30 @@ EPDFForm_ImportFieldRaw(FPDF_DOCUMENT document, const void* capture, unsigned long size); +// Experimental EmbedPDF Extension API. +// The outline item |objnum| as EPDFOutline_DeleteItem() removes it: the item +// and the items under it, with every object the layer holds a version of +// that they reach (their actions and destinations, not their structure +// elements), and the item's place: its parent, and the sibling it followed. +// NULL when |objnum| is not an item in the outline (public/epdf_outline.h), +// or a list it is in holds an item that isn't an indirect object. +FPDF_EXPORT void* FPDF_CALLCONV +EPDFOutline_ExportItemRawToOwnedBuffer(FPDF_DOCUMENT document, + uint32_t objnum, + unsigned long* out_size); + +// Experimental EmbedPDF Extension API. +// Puts the outline item of |capture| back with the items under it: their +// objects where the layer holds no version, then the item among its parent's +// children, right after the sibling it followed while that is still one of +// them, else first. Counts are kept, as every outline write keeps them. +// False outside a transaction, for a malformed capture, when its parent is no +// longer an item in the outline, or when the item is in the outline already. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFOutline_ImportItemRaw(FPDF_DOCUMENT document, + const void* capture, + unsigned long size); + #ifdef __cplusplus } // extern "C" #endif diff --git a/public/epdf_doc.h b/public/epdf_doc.h index 70ae0814f..6dff288f3 100644 --- a/public/epdf_doc.h +++ b/public/epdf_doc.h @@ -12,7 +12,8 @@ extern "C" { #endif // __cplusplus // EmbedPDF's additions to public/fpdf_doc.h: document metadata and its trapped -// status, destinations, named destinations, actions and bookmarks. +// status, destinations, named destinations and actions. The outline's +// writers are in public/epdf_outline.h. // The trapped status of the document. See section 14.10.2.4 "Trapped" of the // ISO 32000-1:2008 spec. @@ -131,7 +132,7 @@ EPDF_GetMetaKeyName(FPDF_DOCUMENT document, // // Notes: // * The returned object is an INDIRECT array suitable for use by -// EPDFBookmark_SetDest() or EPDFAction_CreateGoTo(). +// EPDFAction_CreateGoTo(). // * Unspecified fields are encoded as PDF nulls. FPDF_EXPORT FPDF_DEST FPDF_CALLCONV EPDFDest_CreateXYZ(FPDF_PAGE page, FPDF_BOOL has_left, @@ -390,144 +391,6 @@ EPDFAction_CreateURI(FPDF_DOCUMENT document, FPDF_BYTESTRING uri); FPDF_EXPORT FPDF_ACTION FPDF_CALLCONV EPDFAction_CreateNamed(FPDF_DOCUMENT document, FPDF_BYTESTRING name); -// ----------------------------------------------------------------------------- -// Outlines / bookmarks -// ----------------------------------------------------------------------------- - -// Experimental EmbedPDF Outline API. -// Create a new top-level bookmark appended to the end of the outline. -// -// document - handle to the document. -// title - UTF-16LE encoded title string; may be empty. -// -// Returns a handle to the created bookmark, or NULL on error. -// -// Notes: -// * Creates the document's /Outlines dictionary if it does not exist. -// * The new node is inserted as the last top-level item. -// * The bookmark is created without a destination or action; use -// EPDFBookmark_SetDest() or EPDFBookmark_SetAction() to set a target. -FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV -EPDFBookmark_Create(FPDF_DOCUMENT document, FPDF_WIDESTRING title); - -// Experimental EmbedPDF Outline API. -// Delete a bookmark subtree rooted at |bookmark|. -// -// document - handle to the document. -// bookmark - handle to the root of the subtree to delete. -// -// Returns true on success. -// -// Notes: -// * Removes the node from its parent's list and deletes the entire subtree, -// fixing /First, /Last, /Prev, /Next as needed. -// * Fails if |bookmark| does not belong to |document|. -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFBookmark_Delete(FPDF_DOCUMENT document, - FPDF_BOOKMARK bookmark); - -// Experimental EmbedPDF Outline API. -// Create and append a new child bookmark under |parent|. -// -// document - handle to the document. -// parent - handle to the parent bookmark; NULL inserts at top level. -// title - UTF-16LE encoded title string; may be empty. -// -// Returns a handle to the created bookmark, or NULL on error. -// -// Notes: -// * If |parent| is NULL, the node is appended as a top-level item. -// * The bookmark is created without a destination or action; set one -// with EPDFBookmark_SetDest() or EPDFBookmark_SetAction(). -FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV -EPDFBookmark_AppendChild(FPDF_DOCUMENT document, - FPDF_BOOKMARK parent, - FPDF_WIDESTRING title); - -// Experimental EmbedPDF Outline API. -// Create and insert a new child under |parent| right after |after_sibling|. -// -// document - handle to the document. -// parent - handle to the parent bookmark; NULL means top level. -// after_sibling - handle to the sibling to insert after; if NULL, inserts -// as the first child under |parent|. -// title - UTF-16LE encoded title string; may be empty. -// -// Returns a handle to the created bookmark, or NULL on error. -// -// Notes: -// * |after_sibling| (if non-NULL) must be a direct child of |parent|. -// * The bookmark is created without a destination or action; set one -// with EPDFBookmark_SetDest() or EPDFBookmark_SetAction(). -FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV -EPDFBookmark_InsertAfter(FPDF_DOCUMENT document, - FPDF_BOOKMARK parent, - FPDF_BOOKMARK after_sibling, - FPDF_WIDESTRING title); - -// Experimental EmbedPDF Extension API. -// Set a bookmark's UTF-16LE title. -// -// bookmark - handle to the bookmark. -// title - UTF-16LE encoded title string; may be empty. -// -// Returns true on success. -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_SetTitle(FPDF_BOOKMARK bookmark, FPDF_WIDESTRING title); - -// Experimental EmbedPDF Extension API. -// Set the target of |bookmark| to |dest| (clears any existing action). -// -// document - handle to the document that owns |bookmark| and |dest|. -// bookmark - handle to the bookmark. -// dest - handle to an in-document destination (created via EPDFDest_*()). -// -// Returns true on success. -// -// Notes: -// * On success, /Dest is set to an indirect reference to |dest| and any /A is -// removed. -// * |dest| must belong to |document| and be indirect; otherwise the call -// fails. -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFBookmark_SetDest(FPDF_DOCUMENT document, - FPDF_BOOKMARK bookmark, - FPDF_DEST dest); - -// Experimental EmbedPDF Extension API. -// Set the target of |bookmark| to |action| (clears any existing destination). -// -// document - handle to the document that owns |bookmark| and |action|. -// bookmark - handle to the bookmark. -// action - handle to an action dictionary (e.g. from EPDFAction_Create*()). -// -// Returns true on success. -// -// Notes: -// * On success, /A is set to an indirect reference to |action| and any /Dest -// is removed. -// * |action| must belong to |document| and be indirect; otherwise the call -// fails. -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_SetAction(FPDF_DOCUMENT document, - FPDF_BOOKMARK bookmark, - FPDF_ACTION action); - -// Experimental EmbedPDF Extension API. -// Clear any target from |bookmark| (removes both /Dest and /A). -// -// bookmark - handle to the bookmark. -// -// Returns true on success. -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV -EPDFBookmark_ClearTarget(FPDF_BOOKMARK bookmark); - -// Experimental EmbedPDF Outline API. -// Clear all bookmarks from the document. -// -// document - handle to the document. -// -// Returns true on success. -FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFBookmark_Clear(FPDF_DOCUMENT document); - #ifdef __cplusplus } // extern "C" #endif // __cplusplus diff --git a/public/epdf_outline.h b/public/epdf_outline.h new file mode 100644 index 000000000..c727ca619 --- /dev/null +++ b/public/epdf_outline.h @@ -0,0 +1,195 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#ifndef PUBLIC_EPDF_OUTLINE_H_ +#define PUBLIC_EPDF_OUTLINE_H_ + +#include + +// NOLINTNEXTLINE(build/include) +#include "fpdf_doc.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// The document outline (/Root /Outlines, "bookmarks"), addressed by object +// number. The readers of public/fpdf_doc.h (FPDFBookmark_GetFirstChild() and +// the rest) read it; these functions name its items and write it. +// +// An item is an outline item dictionary, which ISO 32000 requires to be an +// indirect object: its object number names it, and a layer never gives that +// number to anything else. A |parent| is an item's number, or 0 for the +// outline itself, whose children are the top-level items. An item is in the +// outline when each step up its /Parent chain is among the parent's children, +// up to /Root /Outlines: a deleted item is not, even where its dictionary can +// still be read. +// +// A parent's children are its /First and the /Next that follow, a loop ending +// the list. Every write keeps the outline well formed: +// * It rewrites /Parent, /Prev, /Next, /First and /Last of every list it +// changes, from that walk, so a wrong /Last or /Prev never misleads it. It +// writes only the keys whose value changes. +// * It keeps /Count: every ancestor of what changed counts its visible +// descendants again, from the tree and not from the counts it held, +// keeping its sign (positive when open). The outline's own /Count is the +// number of items visible when the document opens. An item without +// children has no /Count. +// * It refuses, changing nothing, when a list it would change holds an item +// that isn't an indirect object, or when /Root /Outlines is a direct +// dictionary. +// Every function here works on a layer inside or outside a transaction +// (EPDFLayer_BeginTransaction()), and on a plain document. + +// Experimental EmbedPDF Extension API. +// The object number of |bookmark|, a handle from the readers of fpdf_doc.h. +// +// Returns 0 for NULL, or when the item isn't an indirect object. +FPDF_EXPORT uint32_t FPDF_CALLCONV +EPDFOutline_GetObjectNumber(FPDF_BOOKMARK bookmark); + +// Experimental EmbedPDF Extension API. +// A handle to the item |objnum|, for the readers of fpdf_doc.h. It reads the +// item as the document holds it now; take a new one after a write. +// +// document - handle to the document. +// objnum - the item's object number. +// +// Returns NULL when |objnum| is not an item in the outline. +FPDF_EXPORT FPDF_BOOKMARK FPDF_CALLCONV +EPDFOutline_GetItem(FPDF_DOCUMENT document, uint32_t objnum); + +// Experimental EmbedPDF Extension API. +// Create an item with |title| and no target, closed, under |parent|, right +// after its child |after|. Creates /Root /Outlines when the document has +// none, at the next free number. +// +// document - handle to the document. +// objnum - the object number the item gets, or 0 for the next free one. +// A number other than 0 needs a layer transaction and must be +// free (EPDFLayer_RaiseLastObjectNumber()). +// parent - the item it goes under, or 0 for the top level. +// after - the child of |parent| it follows, or 0 to be the first. +// title - UTF-16LE encoded; NULL for an empty title. /Title is always +// written. +// +// Returns the new item's object number, or 0 when |parent| is not an item in +// the outline, |after| is not a child of |parent|, the number isn't free, or +// on error. +FPDF_EXPORT uint32_t FPDF_CALLCONV +EPDFOutline_CreateItem(FPDF_DOCUMENT document, + uint32_t objnum, + uint32_t parent, + uint32_t after, + FPDF_WIDESTRING title); + +// Experimental EmbedPDF Extension API. +// Move the item |objnum|, with its descendants, under |parent|, right after +// its child |after|. The item and everything under it keep their numbers and +// every key; a closed item stays closed. +// +// document - handle to the document. +// objnum - the item to move. +// parent - the item it goes under, or 0 for the top level. +// after - the child of |parent| it follows, or 0 to be the first. +// +// Returns true on success. False when |objnum| or |parent| is not an item in +// the outline, |parent| is |objnum| or one of its descendants, |after| is not +// a child of |parent|, or |after| is |objnum|. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_MoveItem(FPDF_DOCUMENT document, + uint32_t objnum, + uint32_t parent, + uint32_t after); + +// Experimental EmbedPDF Extension API. +// Delete the item |objnum| and its descendants: unlinked from its parent, +// their dictionaries deleted. Actions and destinations they refer to stay +// until nothing reaches them (a layer save drops them then). When the last +// item goes, /Root /Outlines stays, without /First, /Last and /Count. +// +// document - handle to the document. +// objnum - the item to delete. +// +// Returns true on success, false when |objnum| is not an item in the outline. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFOutline_DeleteItem(FPDF_DOCUMENT document, uint32_t objnum); + +// Experimental EmbedPDF Extension API. +// Set the item's /Title. +// +// document - handle to the document. +// objnum - the item. +// title - UTF-16LE encoded; NULL for an empty title. +// +// Returns true on success, false when |objnum| is not an item in the outline. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_SetTitle(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_WIDESTRING title); + +// Experimental EmbedPDF Extension API. +// Set whether the item shows its children when the document opens: the sign +// of its /Count. Its ancestors count again. An item without children has no +// open state, and nothing is written. +// +// document - handle to the document. +// objnum - the item. +// open - true for open. +// +// Returns true on success, false when |objnum| is not an item in the outline. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_SetOpen(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_BOOL open); + +// Experimental EmbedPDF Extension API. +// Set the item's /C, the colour its title is drawn in. +// +// document - handle to the document. +// objnum - the item. +// has_color - false removes /C (the default: black); |r|, |g| and |b| are +// then ignored. +// r, g, b - the DeviceRGB components, each from 0 to 1. +// +// Returns true on success. False when |objnum| is not an item in the outline, +// or a component is outside 0 to 1. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_SetColor(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_BOOL has_color, + float r, + float g, + float b); + +// Experimental EmbedPDF Extension API. +// Set the item's /F, the style its title is drawn in: bit 1 italic, bit 2 +// bold (ISO 32000-2 Table 152). FPDFBookmark_GetStyle() reads it. +// +// document - handle to the document. +// objnum - the item. +// flags - the flags, written as given; 0 removes /F. +// +// Returns true on success. False when |objnum| is not an item in the outline, +// or |flags| is negative. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFOutline_SetStyle(FPDF_DOCUMENT document, + uint32_t objnum, + int flags); + +// Experimental EmbedPDF Extension API. +// Set the item's target to |action|: /A refers to it and /Dest is removed, +// since an item has one or the other. NULL removes both. +// +// document - handle to the document. +// objnum - the item. +// action - an action of |document| that is an indirect object, such as +// one EPDFAction_CreateGoTo() made; or NULL. +// +// Returns true on success. False when |objnum| is not an item in the outline, +// or |action| belongs to another document or isn't indirect. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFOutline_SetAction(FPDF_DOCUMENT document, + uint32_t objnum, + FPDF_ACTION action); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif // PUBLIC_EPDF_OUTLINE_H_