From 6488ec234fd85cc5edff8f20a41d9ec742a3e10a Mon Sep 17 00:00:00 2001 From: KleiKodesh <211884665+KleiKodesh@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:35:43 +0200 Subject: [PATCH 1/5] Read and write an annotation's alt text in the structure tree PDF.js writes a stamp's alternate description into the structure tree: a /Figure element with /Alt and an object reference to the stamp, the stamp's /StructParent, and a parent tree entry. There was no way to write that, so an engine saving PDF.js-style alt text had nowhere to put it. - EPDFAnnot_GetAltText() reads the /Alt of the element that lists the annotation, found through its /StructParent. - EPDFAnnot_SetAltText() writes it there. An annotation not yet in the tree gets a new /Figure element (under the tree's /Document element when that is the root's one kid), a /StructParent at the next parent tree key, and the catalog's /StructTreeRoot and /ParentTree when they are missing. An empty text removes the /Alt. A parent tree with /Kids is refused: only a flat /Nums is written. Writes go through the document, so in a layer they promote exactly the objects they change. --- fpdfsdk/epdf_annot.cpp | 265 ++++++++++++++++++++++++++++ fpdfsdk/epdf_annot_embeddertest.cpp | 50 ++++++ fpdfsdk/epdf_c_api_test.c | 2 + public/epdf_annot.h | 40 +++++ 4 files changed, 357 insertions(+) diff --git a/fpdfsdk/epdf_annot.cpp b/fpdfsdk/epdf_annot.cpp index 9cb934875..fadd77d6a 100644 --- a/fpdfsdk/epdf_annot.cpp +++ b/fpdfsdk/epdf_annot.cpp @@ -4921,6 +4921,271 @@ EPDFAnnot_GetStructurePosition(FPDF_ANNOTATION annot, return static_cast(steps.size()); } +namespace { + +// The structure element |annot_dict|'s /StructParent leads to, if it lists the +// annotation, and its object number (0 for a direct one). +struct AltElement { + RetainPtr element; + uint32_t objnum = 0; +}; + +std::optional AltElementOf(const CPDF_Document* doc, + const CPDF_Dictionary* annot_dict) { + RetainPtr key = + ToNumber(annot_dict->GetDirectObjectFor("StructParent")); + const CPDF_Dictionary* catalog = doc->GetRoot(); + if (!key || !key->IsInteger() || key->GetInteger() < 0 || !catalog) { + return std::nullopt; + } + RetainPtr tree_root = + catalog->GetDictFor("StructTreeRoot"); + RetainPtr parent_tree = + tree_root ? tree_root->GetDictFor("ParentTree") : nullptr; + if (!parent_tree) { + return std::nullopt; + } + RetainPtr entry = + CPDF_NumberTree(std::move(parent_tree)).LookupValue(key->GetInteger()); + if (!entry) { + return std::nullopt; + } + RetainPtr element = ToDictionary(entry->GetDirect()); + if (!element) { + return std::nullopt; + } + const uint32_t annot_obj_num = annot_dict->GetObjNum(); + const bool lists_it = + 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; + }).has_value(); + if (!lists_it) { + return std::nullopt; + } + const CPDF_Reference* ref = entry->AsReference(); + return AltElement{element, ref ? ref->GetRefObjNum() : 0}; +} + +// |holder|'s |key| as a dictionary to write: an indirect one through the +// document (so a layer copies it up first), a direct one in place. +RetainPtr WritableDictFor(CPDF_Document* doc, + CPDF_Dictionary* holder, + const ByteString& key) { + RetainPtr value = holder->GetMutableObjectFor(key); + if (!value) { + return nullptr; + } + if (const CPDF_Reference* ref = value->AsReference()) { + return ToDictionary(doc->GetMutableIndirectObject(ref->GetRefObjNum())); + } + return ToDictionary(std::move(value)); +} + +// Appends |objnum| to |parent|'s /K, which becomes an array when it isn't. +void AppendStructKid(CPDF_Document* doc, + CPDF_Dictionary* parent, + uint32_t objnum) { + RetainPtr kids = parent->GetMutableObjectFor("K"); + RetainPtr array; + if (const CPDF_Reference* ref = kids ? kids->AsReference() : nullptr) { + // An indirect array is written through the document; a reference to + // anything else is the one kid. + if (ToArray(ref->GetDirect())) { + array = ToArray(doc->GetMutableIndirectObject(ref->GetRefObjNum())); + } + } else if (kids) { + array = ToArray(kids); + } + if (kids && !array) { + RetainPtr only = kids->Clone(); + array = parent->SetNewFor("K"); + array->Append(std::move(only)); + } else if (!array) { + array = parent->SetNewFor("K"); + } + array->AppendNew(doc, objnum); +} + +} // namespace + +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFAnnot_GetAltText(FPDF_ANNOTATION annot, + FPDF_WCHAR* buffer, + unsigned long buflen) { + ScopedFPDFAnnotationView annot_view(annot); + CPDF_AnnotContext* ctx = CPDFAnnotContextFromFPDFAnnotation(annot); + if (!ctx) { + return 0; + } + CPDF_Document* doc = ctx->GetPage()->GetDocument(); + const CPDF_Dictionary* annot_dict = ctx->GetAnnotDict(); + if (!doc || !annot_dict) { + return 0; + } + CPDF_DocumentViewScope document_view(doc); + std::optional found = AltElementOf(doc, annot_dict); + if (!found || !found->element->KeyExist("Alt")) { + return 0; + } + // SAFETY: required from caller. + return Utf16EncodeMaybeCopyAndReturnLength( + found->element->GetUnicodeTextFor("Alt"), + UNSAFE_BUFFERS(SpanFromFPDFApiArgs(buffer, buflen))); +} + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAnnot_SetAltText(FPDF_ANNOTATION annot, FPDF_WIDESTRING alt) { + ScopedFPDFAnnotationView annot_view(annot); + CPDF_AnnotContext* ctx = CPDFAnnotContextFromFPDFAnnotation(annot); + if (!ctx) { + return false; + } + CPDF_Document* doc = ctx->GetPage()->GetDocument(); + if (!doc) { + return false; + } + CPDF_DocumentViewScope document_view(doc); + RetainPtr annot_dict = + GetMutableAnnotDictFromFPDFAnnotation(annot); + const uint32_t annot_obj_num = annot_dict ? annot_dict->GetObjNum() : 0; + CPDF_Page* page = ctx->GetPage()->AsPDFPage(); + RetainPtr page_dict = page ? page->GetDict() : nullptr; + const uint32_t page_obj_num = page_dict ? page_dict->GetObjNum() : 0; + if (!annot_obj_num) { + return false; + } + // SAFETY: required from caller. + const WideString text = + alt ? UNSAFE_BUFFERS(WideStringFromFPDFWideString(alt)) : WideString(); + + // Already in the tree: the element that lists it takes the text. + if (std::optional found = AltElementOf(doc, annot_dict.Get())) { + if (!found->objnum) { + return false; + } + RetainPtr element = + ToDictionary(doc->GetMutableIndirectObject(found->objnum)); + if (!element) { + return false; + } + if (text.IsEmpty()) { + element->RemoveFor("Alt"); + } else { + element->SetNewFor("Alt", text.AsStringView()); + } + return true; + } + if (text.IsEmpty()) { + return true; + } + + // Everything is checked before anything is written: a refusal changes + // nothing. + RetainPtr catalog = doc->GetMutableRoot(); + if (!catalog) { + return false; + } + RetainPtr existing_root = + catalog->GetDirectObjectFor("StructTreeRoot"); + if (existing_root && !existing_root->IsDictionary()) { + return false; + } + if (existing_root) { + RetainPtr existing_tree = + existing_root->AsDictionary()->GetDirectObjectFor("ParentTree"); + if (existing_tree && (!existing_tree->IsDictionary() || + existing_tree->AsDictionary()->KeyExist("Kids"))) { + return false; + } + } + + RetainPtr tree_root; + if (existing_root) { + tree_root = WritableDictFor(doc, catalog.Get(), "StructTreeRoot"); + } else { + tree_root = doc->NewIndirect(); + tree_root->SetNewFor("Type", "StructTreeRoot"); + catalog->SetNewFor("StructTreeRoot", doc, + tree_root->GetObjNum()); + } + if (!tree_root) { + return false; + } + RetainPtr parent_tree = + WritableDictFor(doc, tree_root.Get(), "ParentTree"); + if (!parent_tree) { + parent_tree = doc->NewIndirect(); + tree_root->SetNewFor("ParentTree", doc, + parent_tree->GetObjNum()); + } + RetainPtr nums = parent_tree->GetMutableArrayFor("Nums"); + if (!nums) { + nums = parent_tree->SetNewFor("Nums"); + } + + // The key: /ParentTreeNextKey, past every key in use. + int key = 0; + if (RetainPtr next = + ToNumber(tree_root->GetDirectObjectFor("ParentTreeNextKey")); + next && next->IsInteger() && next->GetInteger() > 0) { + key = next->GetInteger(); + } + for (size_t i = 0; i + 1 < nums->size(); i += 2) { + key = std::max(key, nums->GetIntegerAt(i) + 1); + } + + // The element goes into the tree's one /Document element, if it has one. + RetainPtr parent = tree_root; + if (RetainPtr kids = tree_root->GetObjectFor("K"); + kids && kids->AsReference()) { + RetainPtr only = ToDictionary(kids->GetDirect()); + if (only && only->GetNameFor("S") == "Document") { + RetainPtr document_element = ToDictionary( + doc->GetMutableIndirectObject(kids->AsReference()->GetRefObjNum())); + if (document_element) { + parent = std::move(document_element); + } + } + } + + RetainPtr element = doc->NewIndirect(); + element->SetNewFor("Type", "StructElem"); + element->SetNewFor("S", "Figure"); + element->SetNewFor("P", doc, parent->GetObjNum()); + if (page_obj_num) { + element->SetNewFor("Pg", doc, page_obj_num); + } + element->SetNewFor("Alt", text.AsStringView()); + RetainPtr objr = element->SetNewFor("K"); + objr->SetNewFor("Type", "OBJR"); + objr->SetNewFor("Obj", doc, annot_obj_num); + if (page_obj_num) { + objr->SetNewFor("Pg", doc, page_obj_num); + } + AppendStructKid(doc, parent.Get(), element->GetObjNum()); + + // The parent tree keeps its keys in order. + size_t at = nums->size() - nums->size() % 2; + for (size_t i = 0; i + 1 < nums->size(); i += 2) { + if (nums->GetIntegerAt(i) > key) { + at = i; + break; + } + } + nums->InsertNewAt(at, key); + nums->InsertNewAt(at + 1, doc, element->GetObjNum()); + tree_root->SetNewFor("ParentTreeNextKey", key + 1); + annot_dict->SetNewFor("StructParent", key); + return true; +} + 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 c67ccef71..acefe03b0 100644 --- a/fpdfsdk/epdf_annot_embeddertest.cpp +++ b/fpdfsdk/epdf_annot_embeddertest.cpp @@ -10410,3 +10410,53 @@ TEST_F(EPDFAnnotStructureEmbedderTest, AnUntaggedDocumentHasNoPositions) { ASSERT_TRUE(annot); EXPECT_EQ(0u, EPDFAnnot_GetStructurePosition(annot.get(), nullptr, 0)); } + +// Alt text lives in the structure tree, as PDF.js writes a stamp's: a /Figure +// element with the /Alt, listing the annotation, which gets a /StructParent. +TEST_F(EPDFAnnotEmbedderTest, AltTextRoundTripsThroughTheStructureTree) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 612, 792)); + ASSERT_TRUE(page); + ScopedFPDFAnnotation stamp( + FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_STAMP)); + ASSERT_TRUE(stamp); + ScopedFPDFAnnotation square( + FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_SQUARE)); + ASSERT_TRUE(square); + + auto alt_of = [](FPDF_ANNOTATION annot) { + const unsigned long length = EPDFAnnot_GetAltText(annot, nullptr, 0); + if (length == 0) { + return std::wstring(); + } + std::vector buffer = GetFPDFWideStringBuffer(length); + EXPECT_EQ(length, EPDFAnnot_GetAltText(annot, buffer.data(), length)); + return GetPlatformWString(buffer.data()); + }; + + EXPECT_EQ(L"", alt_of(stamp.get())); + ScopedFPDFWideString first = GetFPDFWideString(L"A red stamp"); + ASSERT_TRUE(EPDFAnnot_SetAltText(stamp.get(), first.get())); + EXPECT_EQ(L"A red stamp", alt_of(stamp.get())); + ScopedFPDFWideString second = GetFPDFWideString(L"A square"); + ASSERT_TRUE(EPDFAnnot_SetAltText(square.get(), second.get())); + EXPECT_EQ(L"A square", alt_of(square.get())); + EXPECT_EQ(L"A red stamp", alt_of(stamp.get())); + + // Both are /Figure kids of the root, at their own parent tree keys. + std::vector steps(4); + EXPECT_EQ(2u, EPDFAnnot_GetStructurePosition(stamp.get(), steps.data(), 4)); + EXPECT_EQ(0u, steps[0]); + EXPECT_EQ(2u, EPDFAnnot_GetStructurePosition(square.get(), steps.data(), 4)); + EXPECT_EQ(1u, steps[0]); + + // Setting it again rewrites the element; empty removes the /Alt. + ScopedFPDFWideString changed = GetFPDFWideString(L"A blue stamp"); + ASSERT_TRUE(EPDFAnnot_SetAltText(stamp.get(), changed.get())); + EXPECT_EQ(L"A blue stamp", alt_of(stamp.get())); + EXPECT_EQ(2u, EPDFAnnot_GetStructurePosition(stamp.get(), steps.data(), 4)); + EXPECT_EQ(0u, steps[0]); + ASSERT_TRUE(EPDFAnnot_SetAltText(stamp.get(), nullptr)); + EXPECT_EQ(L"", alt_of(stamp.get())); +} diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index deb090996..1ee13cf71 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -77,6 +77,8 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFAction_NodeHasJavaScript); CHK(EPDFAction_SetNext); CHK(EPDFAnnot_GetActionModel); + CHK(EPDFAnnot_GetAltText); + CHK(EPDFAnnot_SetAltText); CHK(EPDFAnnot_SetEventAction); CHK(EPDFDoc_GetAdditionalActionModel); CHK(EPDFDoc_GetNamedJavaScriptActionModel); diff --git a/public/epdf_annot.h b/public/epdf_annot.h index 77ee661ea..72eabbb93 100644 --- a/public/epdf_annot.h +++ b/public/epdf_annot.h @@ -1709,6 +1709,46 @@ EPDFAnnot_GetStructurePosition(FPDF_ANNOTATION annot, unsigned int* buffer, unsigned long buflen); +// Experimental EmbedPDF Extension API. +// Get the alternate description of an annotation: the /Alt of the structure +// element that lists it (ISO 32000-2 14.9.3), found through its +// /StructParent and the parent tree. +// +// annot - handle to an annotation. +// buffer - receives the text as UTF-16LE with a terminating NUL; may be +// NULL. +// buflen - size of |buffer| in bytes. +// +// Returns the number of bytes the text needs, including the NUL; 0 when the +// annotation isn't in the structure tree or its element has no /Alt. +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFAnnot_GetAltText(FPDF_ANNOTATION annot, + FPDF_WCHAR* buffer, + unsigned long buflen); + +// Experimental EmbedPDF Extension API. +// Set the alternate description of an annotation, as PDF.js writes a stamp's +// alt text: in the structure tree, not in the annotation. +// +// An annotation already in the structure tree gets the /Alt on the element +// that lists it (an empty |alt| removes it). Otherwise a non-empty |alt| +// creates a /Figure element with the /Alt and an object reference (/OBJR) to +// the annotation, appended to the tree's /Document element when the root's +// one kid is one, else to the root; the annotation gets a /StructParent and +// the parent tree an entry for it. The catalog's /StructTreeRoot and its +// /ParentTree are created when missing. +// +// annot - handle to an annotation; its dictionary must be an indirect +// object. +// alt - the description, UTF-16LE with a terminating NUL; NULL or empty +// removes it. +// +// Returns TRUE on success; FALSE for a direct annotation dictionary, a parent +// tree with /Kids (only a flat /Nums is written), or a structure tree that +// isn't dictionaries where it needs them. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAnnot_SetAltText(FPDF_ANNOTATION annot, FPDF_WIDESTRING alt); + // 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. From 9f9ba407bb041d83956eef82240ddd52464ff304 Mon Sep 17 00:00:00 2001 From: KleiKodesh <211884665+KleiKodesh@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:26:46 +0200 Subject: [PATCH 2/5] Take the dictionary key as a ByteStringView --- fpdfsdk/epdf_annot.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fpdfsdk/epdf_annot.cpp b/fpdfsdk/epdf_annot.cpp index fadd77d6a..422562e21 100644 --- a/fpdfsdk/epdf_annot.cpp +++ b/fpdfsdk/epdf_annot.cpp @@ -4977,7 +4977,7 @@ std::optional AltElementOf(const CPDF_Document* doc, // document (so a layer copies it up first), a direct one in place. RetainPtr WritableDictFor(CPDF_Document* doc, CPDF_Dictionary* holder, - const ByteString& key) { + ByteStringView key) { RetainPtr value = holder->GetMutableObjectFor(key); if (!value) { return nullptr; From d10c835d59b9e8f933740303e6df4c5b5dc844d5 Mon Sep 17 00:00:00 2001 From: KleiKodesh <211884665+KleiKodesh@users.noreply.github.com> Date: Tue, 10 Nov 2026 09:58:27 +0200 Subject: [PATCH 3/5] Make an inline annotation an object before listing it in the structure tree --- fpdfsdk/epdf_annot.cpp | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/fpdfsdk/epdf_annot.cpp b/fpdfsdk/epdf_annot.cpp index 422562e21..62f980852 100644 --- a/fpdfsdk/epdf_annot.cpp +++ b/fpdfsdk/epdf_annot.cpp @@ -5054,17 +5054,33 @@ EPDFAnnot_SetAltText(FPDF_ANNOTATION annot, FPDF_WIDESTRING alt) { CPDF_DocumentViewScope document_view(doc); RetainPtr annot_dict = GetMutableAnnotDictFromFPDFAnnotation(annot); - const uint32_t annot_obj_num = annot_dict ? annot_dict->GetObjNum() : 0; + if (!annot_dict) { + return false; + } CPDF_Page* page = ctx->GetPage()->AsPDFPage(); RetainPtr page_dict = page ? page->GetDict() : nullptr; const uint32_t page_obj_num = page_dict ? page_dict->GetObjNum() : 0; - if (!annot_obj_num) { - return false; - } // SAFETY: required from caller. const WideString text = alt ? UNSAFE_BUFFERS(WideStringFromFPDFWideString(alt)) : WideString(); + // An inline annotation is in no tree, and the tree can only list an + // object: it becomes one first. + if (!annot_dict->GetObjNum()) { + if (text.IsEmpty()) { + return true; + } + const int page_index = page_obj_num ? doc->GetPageIndex(page_obj_num) : -1; + if (page_index < 0 || PromoteInlineAnnots(doc, page_index) < 0) { + return false; + } + annot_dict = GetMutableAnnotDictFromFPDFAnnotation(annot); + if (!annot_dict || !annot_dict->GetObjNum()) { + return false; + } + } + const uint32_t annot_obj_num = annot_dict->GetObjNum(); + // Already in the tree: the element that lists it takes the text. if (std::optional found = AltElementOf(doc, annot_dict.Get())) { if (!found->objnum) { From d2421093c7f041f53136a9761a4ee691217d16f1 Mon Sep 17 00:00:00 2001 From: KleiKodesh <211884665+KleiKodesh@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:14:11 +0200 Subject: [PATCH 4/5] Take the alt text element's number from the element --- fpdfsdk/epdf_annot.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/fpdfsdk/epdf_annot.cpp b/fpdfsdk/epdf_annot.cpp index 62f980852..b21fae4da 100644 --- a/fpdfsdk/epdf_annot.cpp +++ b/fpdfsdk/epdf_annot.cpp @@ -4969,8 +4969,9 @@ std::optional AltElementOf(const CPDF_Document* doc, if (!lists_it) { return std::nullopt; } - const CPDF_Reference* ref = entry->AsReference(); - return AltElement{element, ref ? ref->GetRefObjNum() : 0}; + // The number tree hands back the element itself, not the reference to it. + const uint32_t element_obj_num = element->GetObjNum(); + return AltElement{element, element_obj_num}; } // |holder|'s |key| as a dictionary to write: an indirect one through the From 1804f2cdb31db7dff48f9f7508ba80af42b2296a Mon Sep 17 00:00:00 2001 From: KleiKodesh <211884665+KleiKodesh@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:37:12 +0200 Subject: [PATCH 5/5] List the alt text test among the layer mode writers --- testing/embedpdf_layer_mode_writers.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/testing/embedpdf_layer_mode_writers.txt b/testing/embedpdf_layer_mode_writers.txt index ea34ac46e..36e1ec1de 100644 --- a/testing/embedpdf_layer_mode_writers.txt +++ b/testing/embedpdf_layer_mode_writers.txt @@ -50,6 +50,7 @@ EPDFActionEmbedderTest.OpenActionDestinationForms 3 EPDFActionEmbedderTest.ResetFormThreeStates 1 EPDFActionEmbedderTest.SetNextChainsActions 3 EPDFAnnotEmbedderTest.AcrobatCommentWrittenBackReadsTheSame 1 +EPDFAnnotEmbedderTest.AltTextRoundTripsThroughTheStructureTree 9 EPDFAnnotEmbedderTest.AnEmptyDashPatternIsASolidLine 15 EPDFAnnotEmbedderTest.AnnotFontMapStagesEveryNamedFont 16 EPDFAnnotEmbedderTest.ApplyPageRedactionsCountsRemovedAnnotations 6