From 13e012bc781b586c889421cbb966dba8396c6d1b Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Mon, 2 Nov 2026 00:55:14 +0200 Subject: [PATCH] Read where an annotation sits in its document's structure tree EPDFAnnot_GetStructurePosition() gives an annotation's place in the logical structure (the tags, ISO 32000-2 14.7): from the structure tree root down to its object reference (/OBJR), the index of each step among its parent's /K kids. Compared index by index, two positions give the tags' reading order, the order a page's /Tabs /S gives its annotations focus in, which most tagged forms ask for (the USCIS forms, Acrobat's, Word's, a browser's print). It's found from the annotation up: its /StructParent, the parent tree, then each element's /P, so no page is loaded and nothing else in the tree is read. An annotation the tree doesn't reach has none: no /StructParent, a parent tree that doesn't lead to an element listing it, or a broken way up (a missing /P, a parent that doesn't list its kid, a cycle, more than 256 levels). PDFium's structure tree API can't answer this: FPDF_StructTree_GetForPage() needs the page loaded, builds the page's tree only from its content (a /Form element holding just a widget's /OBJR isn't in it), and has no way to read an object reference's target. Tests: structure_position.in (a nested parent tree, a /K that is one kid, an /OBJR after a marked-content kid; untagged, unlisted, cyclic and missing entries), read from raw and loaded pages, a buffer shorter than the position, an untagged document; the C API list. Layer mode: the reads write nothing. --- fpdfsdk/epdf_annot.cpp | 119 +++++++++++++ fpdfsdk/epdf_annot_embeddertest.cpp | 79 +++++++++ fpdfsdk/epdf_c_api_test.c | 1 + public/epdf_annot.h | 23 +++ testing/resources/structure_position.in | 211 +++++++++++++++++++++++ testing/resources/structure_position.pdf | Bin 0 -> 4014 bytes 6 files changed, 433 insertions(+) create mode 100644 testing/resources/structure_position.in create mode 100644 testing/resources/structure_position.pdf diff --git a/fpdfsdk/epdf_annot.cpp b/fpdfsdk/epdf_annot.cpp index d0113926c..cbbe66308 100644 --- a/fpdfsdk/epdf_annot.cpp +++ b/fpdfsdk/epdf_annot.cpp @@ -50,6 +50,7 @@ #include "core/fpdfdoc/cpdf_fontface.h" #include "core/fpdfdoc/cpdf_formfield.h" #include "core/fpdfdoc/cpdf_interactiveform.h" +#include "core/fpdfdoc/cpdf_numbertree.h" #include "core/fpdfdoc/cpdf_richtextjson.h" #include "core/fpdfdoc/cpdf_richtextparser.h" #include "core/fpdfdoc/cpdf_richtextwriter.h" @@ -4719,6 +4720,124 @@ EPDFAnnot_GetObjectNumber(FPDF_ANNOTATION annot) { return dict->GetObjNum(); } +namespace { + +// How deep EPDFAnnot_GetStructurePosition() climbs before it gives up. +constexpr size_t kMaxStructureDepth = 256; + +// The index of the first of |node|'s /K kids that |is_it| accepts, or nullopt. +// /K is one kid or an array of them (ISO 32000-2 Table 355). +template +std::optional KidIndex(const CPDF_Dictionary* node, + Predicate is_it) { + RetainPtr kids = node->GetDirectObjectFor("K"); + if (!kids) { + return std::nullopt; + } + const CPDF_Array* array = kids->AsArray(); + if (!array) { + return is_it(kids.Get()) ? std::optional(0) : std::nullopt; + } + for (size_t i = 0; i < array->size(); ++i) { + RetainPtr kid = array->GetDirectObjectAt(i); + if (kid && is_it(kid.Get())) { + return static_cast(i); + } + } + return std::nullopt; +} + +// |annot|'s place in the structure tree, the root's index first; empty when it +// isn't in the tree. See EPDFAnnot_GetStructurePosition(). +std::vector StructurePositionOf(const CPDF_Document* doc, + const CPDF_Dictionary* annot) { + RetainPtr key = + ToNumber(annot->GetDirectObjectFor("StructParent")); + const CPDF_Dictionary* catalog = doc->GetRoot(); + if (!key || !key->IsInteger() || key->GetInteger() < 0 || !catalog) { + return {}; + } + RetainPtr tree_root = + catalog->GetDictFor("StructTreeRoot"); + RetainPtr parent_tree = + tree_root ? tree_root->GetDictFor("ParentTree") : nullptr; + if (!parent_tree) { + return {}; + } + // An annotation's entry in the parent tree is the element whose /K holds its + // object reference (ISO 32000-2 14.7.5.4). + RetainPtr entry = + CPDF_NumberTree(std::move(parent_tree)).LookupValue(key->GetInteger()); + RetainPtr element = + ToDictionary(entry ? entry->GetDirect() : nullptr); + if (!element) { + return {}; + } + const uint32_t annot_obj_num = annot->GetObjNum(); + std::optional step = + KidIndex(element.Get(), [annot_obj_num](const CPDF_Object* kid) { + const CPDF_Dictionary* objr = kid->AsDictionary(); + if (!objr || objr->GetNameFor("Type") != "OBJR") { + return false; + } + RetainPtr target = + ToReference(objr->GetObjectFor("Obj")); + return target && annot_obj_num && + target->GetRefObjNum() == annot_obj_num; + }); + if (!step) { + return {}; + } + + std::vector steps = {*step}; + std::set seen = {element.Get()}; + RetainPtr node = element; + while (steps.size() <= kMaxStructureDepth) { + RetainPtr parent = node->GetDictFor("P"); + if (!parent || !seen.insert(parent.Get()).second) { + return {}; + } + const CPDF_Dictionary* child = node.Get(); + step = KidIndex(parent.Get(), [child](const CPDF_Object* kid) { + return kid->AsDictionary() == child; + }); + if (!step) { + return {}; + } + steps.push_back(*step); + if (parent == tree_root) { + std::reverse(steps.begin(), steps.end()); + return steps; + } + node = std::move(parent); + } + return {}; +} + +} // namespace + +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFAnnot_GetStructurePosition(FPDF_ANNOTATION annot, + unsigned int* buffer, + unsigned long buflen) { + ScopedFPDFAnnotationView annot_view(annot); + CPDF_AnnotContext* ctx = CPDFAnnotContextFromFPDFAnnotation(annot); + if (!ctx) { + return 0; + } + CPDF_Document* doc = ctx->GetPage()->GetDocument(); + CPDF_DocumentViewScope document_view(doc); + const CPDF_Dictionary* annot_dict = ctx->GetAnnotDict(); + if (!annot_dict || !doc) { + return 0; + } + const std::vector steps = StructurePositionOf(doc, annot_dict); + if (buffer) { + std::copy_n(steps.begin(), std::min(steps.size(), buflen), buffer); + } + return static_cast(steps.size()); +} + FPDF_EXPORT FPDF_ANNOTATION FPDF_CALLCONV EPDFPage_GetAnnotByObjectNumber(FPDF_PAGE page, unsigned int obj_num) { if (!page || obj_num == 0) { diff --git a/fpdfsdk/epdf_annot_embeddertest.cpp b/fpdfsdk/epdf_annot_embeddertest.cpp index 8e8f8a548..8a345188e 100644 --- a/fpdfsdk/epdf_annot_embeddertest.cpp +++ b/fpdfsdk/epdf_annot_embeddertest.cpp @@ -9594,3 +9594,82 @@ INSTANTIATE_TEST_SUITE_P(AppearanceSharing, "SharedStates"}; return kNames[info.param]; })); + +// Where an annotation sits in its document's structure tree +// (structure_position.in): the page lists its fields a to g, and its +// structure reads c, a, b. +class EPDFAnnotStructureEmbedderTest : public EmbedderTest { + protected: + // The position of the first page's annotation at |index|, read from the + // page's dictionary without loading the page, as the engine reads it. + std::vector PositionOf(int index) { + ScopedFPDFAnnotation annot(EPDFPage_GetAnnotRaw(document(), 0, index)); + EXPECT_TRUE(annot); + const unsigned long count = + EPDFAnnot_GetStructurePosition(annot.get(), nullptr, 0); + std::vector steps(count); + EXPECT_EQ(count, EPDFAnnot_GetStructurePosition(annot.get(), steps.data(), + steps.size())); + return steps; + } +}; + +TEST_F(EPDFAnnotStructureEmbedderTest, + FindsEachTaggedAnnotationFromItsElementUp) { + ASSERT_TRUE(OpenDocument("structure_position.pdf")); + // a: the first section's third kid; its reference follows a marked-content + // kid in its element. + EXPECT_THAT(PositionOf(0), ::testing::ElementsAre(0u, 0u, 2u, 1u)); + // b: the second section's first kid; its element's /K is the reference + // itself, not an array. + EXPECT_THAT(PositionOf(1), ::testing::ElementsAre(0u, 1u, 0u, 0u)); + // c: the first section's first kid. + EXPECT_THAT(PositionOf(2), ::testing::ElementsAre(0u, 0u, 0u, 0u)); +} + +TEST_F(EPDFAnnotStructureEmbedderTest, + ComparedIndexByIndexPositionsReadInTheTreesOrder) { + ASSERT_TRUE(OpenDocument("structure_position.pdf")); + std::vector, char>> tagged = { + {PositionOf(0), 'a'}, {PositionOf(1), 'b'}, {PositionOf(2), 'c'}}; + std::sort(tagged.begin(), tagged.end()); + std::string order; + for (const auto& [position, name] : tagged) { + order += name; + } + EXPECT_EQ("cab", order); +} + +TEST_F(EPDFAnnotStructureEmbedderTest, + AnAnnotationTheTreeDoesNotReachHasNoPosition) { + ASSERT_TRUE(OpenDocument("structure_position.pdf")); + // d: no /StructParent. + EXPECT_TRUE(PositionOf(3).empty()); + // e: the parent tree names an element that doesn't list it. + EXPECT_TRUE(PositionOf(4).empty()); + // f: its element's way up is a cycle that never reaches the root. + EXPECT_TRUE(PositionOf(5).empty()); + // g: a /StructParent the parent tree doesn't have. + EXPECT_TRUE(PositionOf(6).empty()); +} + +TEST_F(EPDFAnnotStructureEmbedderTest, + ReadsTheSameOnALoadedPageAndCountsPastTheBuffer) { + ASSERT_TRUE(OpenDocument("structure_position.pdf")); + ScopedPage page = LoadScopedPage(0); + ASSERT_TRUE(page); + ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), 0)); + ASSERT_TRUE(annot); + std::array first_two = {99u, 99u}; + EXPECT_EQ(4u, EPDFAnnot_GetStructurePosition(annot.get(), first_two.data(), + first_two.size())); + EXPECT_THAT(first_two, ::testing::ElementsAre(0u, 0u)); + EXPECT_EQ(0u, EPDFAnnot_GetStructurePosition(nullptr, nullptr, 0)); +} + +TEST_F(EPDFAnnotStructureEmbedderTest, AnUntaggedDocumentHasNoPositions) { + ASSERT_TRUE(OpenDocument("annotation_highlight_long_content.pdf")); + ScopedFPDFAnnotation annot(EPDFPage_GetAnnotRaw(document(), 0, 0)); + ASSERT_TRUE(annot); + EXPECT_EQ(0u, EPDFAnnot_GetStructurePosition(annot.get(), nullptr, 0)); +} diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index eb28c82c2..9e5a57c14 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -138,6 +138,7 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFAnnot_GetRichTextJSON); CHK(EPDFAnnot_GetRotate); CHK(EPDFAnnot_GetStampDrawing); + CHK(EPDFAnnot_GetStructurePosition); CHK(EPDFAnnot_GetTextAlignment); CHK(EPDFAnnot_HasAppearanceStream); CHK(EPDFAnnot_HasEmbedMetadata); diff --git a/public/epdf_annot.h b/public/epdf_annot.h index a6848ed52..26a5e44aa 100644 --- a/public/epdf_annot.h +++ b/public/epdf_annot.h @@ -1635,6 +1635,29 @@ EPDFAnnot_SetCalloutLine(FPDF_ANNOTATION annot, FPDF_EXPORT unsigned int FPDF_CALLCONV EPDFAnnot_GetObjectNumber(FPDF_ANNOTATION annot); +// Experimental EmbedPDF Extension API. +// Where an annotation sits in its document's logical structure (the tags, +// ISO 32000-2 14.7): from the structure tree root down to the annotation's +// object reference (/OBJR), the index of each step among its parent's /K +// kids. Compared index by index, two positions give the structure's reading +// order, the order a page's /Tabs /S gives its annotations focus in. It is +// found from the annotation up: its /StructParent, the parent tree, then each +// element's /P. No page is loaded, and nothing else in the tree is read. +// +// annot - handle to an annotation. +// buffer - receives up to |buflen| indexes, the root's first; may be NULL. +// buflen - capacity of |buffer|, in entries. +// +// Returns the number of indexes, which may exceed |buflen|; 0 when the +// annotation isn't in the structure tree: it has no /StructParent, the parent +// tree doesn't lead to an element that lists it, or the way up is broken (a +// missing /P, a parent that doesn't list its kid, a cycle, or more than 256 +// levels). +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFAnnot_GetStructurePosition(FPDF_ANNOTATION annot, + unsigned int* buffer, + unsigned long buflen); + // Experimental EmbedPDF Extension API. // Get an annotation on a page by its PDF indirect object number. // Similar to EPDFPage_GetAnnotByName() but keyed by object number. diff --git a/testing/resources/structure_position.in b/testing/resources/structure_position.in new file mode 100644 index 000000000..c04af338b --- /dev/null +++ b/testing/resources/structure_position.in @@ -0,0 +1,211 @@ +{{header}} +{{object 1 0}} << + /Type /Catalog + /Pages 2 0 R + /MarkInfo << /Marked true >> + /StructTreeRoot 10 0 R + /AcroForm << /Fields [20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R] >> +>> +endobj +{{object 2 0}} << + /Type /Pages + /Count 1 + /Kids [3 0 R] +>> +endobj +% The page lists its fields a, b, c, d, e, f, g; its structure reads c, a, b. +{{object 3 0}} << + /Type /Page + /Parent 2 0 R + /MediaBox [0 0 300 300] + /Tabs /S + /StructParents 5 + /Contents 4 0 R + /Annots [20 0 R 21 0 R 22 0 R 23 0 R 24 0 R 25 0 R 26 0 R] +>> +endobj +{{object 4 0}} << + {{streamlen}} +>> +stream +/P << /MCID 0 >> BDC +EMC +/Span << /MCID 1 >> BDC +EMC +endstream +endobj +% The structure tree. The root's /K is one element, not an array. +{{object 10 0}} << + /Type /StructTreeRoot + /K 11 0 R + /ParentTree 15 0 R + /ParentTreeNextKey 10 +>> +endobj +{{object 11 0}} << + /Type /StructElem + /S /Document + /P 10 0 R + /K [12 0 R 13 0 R] +>> +endobj +{{object 12 0}} << + /Type /StructElem + /S /Sect + /P 11 0 R + /K [32 0 R 14 0 R 30 0 R] +>> +endobj +{{object 13 0}} << + /Type /StructElem + /S /Sect + /P 11 0 R + /K [31 0 R 33 0 R] +>> +endobj +{{object 14 0}} << + /Type /StructElem + /S /P + /P 12 0 R + /Pg 3 0 R + /K 0 +>> +endobj +% The parent tree, nested: two leaves under /Kids with their /Limits. +{{object 15 0}} << + /Kids [16 0 R 17 0 R] +>> +endobj +{{object 16 0}} << + /Limits [0 1] + /Nums [0 30 0 R 1 31 0 R] +>> +endobj +{{object 17 0}} << + /Limits [2 5] + /Nums [2 32 0 R 3 33 0 R 4 34 0 R 5 [14 0 R 30 0 R]] +>> +endobj +% a: its object reference comes after a marked-content kid. +{{object 30 0}} << + /Type /StructElem + /S /Form + /P 12 0 R + /Pg 3 0 R + /K [1 << /Type /OBJR /Obj 20 0 R >>] +>> +endobj +% b: /K is the object reference itself, not an array. +{{object 31 0}} << + /Type /StructElem + /S /Form + /P 13 0 R + /K << /Type /OBJR /Pg 3 0 R /Obj 21 0 R >> +>> +endobj +% c +{{object 32 0}} << + /Type /StructElem + /S /Form + /P 12 0 R + /K [<< /Type /OBJR /Obj 22 0 R >>] +>> +endobj +% e: the parent tree names this element, but it doesn't list e. +{{object 33 0}} << + /Type /StructElem + /S /Form + /P 13 0 R + /K [] +>> +endobj +% f: its element and a /Div are each other's parent, and never reach the root. +{{object 34 0}} << + /Type /StructElem + /S /Form + /P 35 0 R + /K [<< /Type /OBJR /Obj 25 0 R >> 35 0 R] +>> +endobj +{{object 35 0}} << + /Type /StructElem + /S /Div + /P 34 0 R + /K [34 0 R] +>> +endobj +{{object 20 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (a) + /Rect [10 270 110 290] + /P 3 0 R + /StructParent 0 +>> +endobj +{{object 21 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (b) + /Rect [10 230 110 250] + /P 3 0 R + /StructParent 1 +>> +endobj +{{object 22 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (c) + /Rect [10 190 110 210] + /P 3 0 R + /StructParent 2 +>> +endobj +% d: not tagged. +{{object 23 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (d) + /Rect [10 150 110 170] + /P 3 0 R +>> +endobj +{{object 24 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (e) + /Rect [10 110 110 130] + /P 3 0 R + /StructParent 3 +>> +endobj +{{object 25 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (f) + /Rect [10 70 110 90] + /P 3 0 R + /StructParent 4 +>> +endobj +% g: a /StructParent the parent tree doesn't have. +{{object 26 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (g) + /Rect [10 30 110 50] + /P 3 0 R + /StructParent 9 +>> +endobj +{{xref}} +{{trailer}} +{{startxref}} +%%EOF diff --git a/testing/resources/structure_position.pdf b/testing/resources/structure_position.pdf new file mode 100644 index 0000000000000000000000000000000000000000..1070407cf72db1119c30e56afb4e4a49d16cd8f2 GIT binary patch literal 4014 zcmb_f+iv1W5PkPo)C-zji3xNYbIFd7CM1z&n3*7gv=1l`jopBEz>aK_N%m{@VZU&- zAFx%`#yQqo2gr zakAxo*P-47Da<8LgCq!3kn9AcYu-BM%QIhH`nn@8_@|=)5j(WLik#sRl2N>i5CA=n zLvqGu@2u?&n5{M3_3% zK1NZD@25x7iUS!tApfGHRkj9C(h5ASR$HevG3g$Cy2Q1^AzWOJoU5-Rr*(U;qQji9 z=P*h6_B>rC-iH99ZGFZ$NRTV<@Z4hvQ-~uCdZV{^&?eyN06nNAQBP+M)9C=!aMi?V zPdN(87z8tlo=yB{76enNJorsNW@G(?M709KyN|oMLSSU!7Fw6Fzax-r&|>-+=E5*p z)F{UjFIv9U{@!YF4z%j#R!w2JTstx<5iBWVY5J4txo>Sf)q?O6K9Pro;uS z1c)BaO-rGniNk7RgsM+))qUG-v4*G$X(I@5IT|GkWV3ETf@fKfkmtw+6G$vn*QCos zR+JKF8*hL*XLUgbn0u6#rN;318vJ;R!BEQ#gP~xH;;V!AzL5d=9tNm8g!usMC;1`i zeLh8V!F6o*-&}m2LhA;*LN3w6VHxk@Jg-(v3wbZ2#ti8VYG%25IjE-~wFpGr#)h`1 zR}RvUx6~XO;5(d+Q#=;*#d52<)wOj7Sp{+k5Uw*u8K~q#ux9 zDD^l`2O&L1T(7G91GC&hUw;-o)621cn58y$9(geccoM^6Q&mP>=H|t=#;mPc<6cQy zZg-1}uQ|Vm!Ai5#UeAzUk2E=hU(~PkFeOgUvE|4f>I8ic+Fa|CgR+_4lec$U~mjQ@D4nRB)fbR@Nw?{!I0m#&9rBSH(UiW51K?RX# z5Q$zHk##vd#@<&`Kv&#h`Y8j#ExRK!@r5v_fNN)<+@Xwmy&Ax>3?QfA3k>X7?i;W= zr{XW+tE$vqiq_b3F+=4pc_QLf8F9|p7brWh5IoJhBSBt&uv}^dJSNDNX235bZhq6+^A_ttwrM%dZqR<}6X+20N_ziEVxv(@m3`QTV4O&hxJ=m1JB6 Ou9O|8(YU&~cK!pIR`3=8 literal 0 HcmV?d00001