From e7494b7b21482104a38e9a2d922563f019b85252 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Tue, 3 Nov 2026 20:21:31 +0200 Subject: [PATCH] Change an annotation's kind in place EPDFAnnot_SetSubtype writes /Subtype and nothing else. The annotation keeps its dictionary, object number, place in /Annots and every other entry, and its /AP until the caller draws the new kind. It is a mechanic: which kinds may become which is the caller's decision. ISO 32000-2 describes the four text markup subtypes with one table of entries, and Square and Circle with another. A widget belongs to its form field and a popup to its parent, so neither changes kind and nothing becomes one. A subtype the annotation API doesn't create is refused too. FPDFAnnot_GetSubtype() reads the subtype from the dictionary, and each render builds a new annotation list, so there is nothing to drop. A form-fill page view loaded before the change keeps the old subtype until the page is loaded again, as after FPDFPage_CreateAnnot(). Tests: on a new document, the refusals, the entries and drawing kept, a redraw that draws the new kind, and a save round trip; on a layer, an aborted transaction leaves the highlight, and a committed one keeps its object number and survives a reopen from the saved delta. --- fpdfsdk/epdf_annot.cpp | 29 ++++++ fpdfsdk/epdf_annot_embeddertest.cpp | 146 ++++++++++++++++++++++++++++ fpdfsdk/epdf_c_api_test.c | 1 + public/epdf_annot.h | 24 +++++ 4 files changed, 200 insertions(+) diff --git a/fpdfsdk/epdf_annot.cpp b/fpdfsdk/epdf_annot.cpp index cbbe66308..12a22d581 100644 --- a/fpdfsdk/epdf_annot.cpp +++ b/fpdfsdk/epdf_annot.cpp @@ -2870,6 +2870,35 @@ FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFAnnot_IsValid(FPDF_ANNOTATION annot) { return !!CPDFAnnotContextFromFPDFAnnotation(annot); } +namespace { + +// A subtype EPDFAnnot_SetSubtype() can change from and to: one the +// annotation API creates, other than the two that belong to a structure +// beside their page (a widget to its field, a popup to its parent). +bool IsRetypableSubtype(FPDF_ANNOTATION_SUBTYPE subtype) { + return FPDFAnnot_IsSupportedSubtype(subtype) && + subtype != FPDF_ANNOT_WIDGET && subtype != FPDF_ANNOT_POPUP; +} + +} // namespace + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAnnot_SetSubtype(FPDF_ANNOTATION annot, FPDF_ANNOTATION_SUBTYPE subtype) { + ScopedFPDFAnnotationView annot_view(annot); + RetainPtr dict = + GetMutableAnnotDictFromFPDFAnnotation(annot); + if (!dict || !IsRetypableSubtype(FPDFAnnot_GetSubtype(annot)) || + !IsRetypableSubtype(subtype)) { + return false; + } + // The name only: the caller draws the new appearance, and every other + // entry stays as the annotation had it. + dict->SetNewFor(pdfium::annotation::kSubtype, + CPDF_Annot::AnnotSubtypeToString( + static_cast(subtype))); + return true; +} + FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFAnnot_SetName(FPDF_ANNOTATION annot, FPDF_BYTESTRING name) { ScopedFPDFAnnotationView annot_view(annot); diff --git a/fpdfsdk/epdf_annot_embeddertest.cpp b/fpdfsdk/epdf_annot_embeddertest.cpp index 8a345188e..a3f7f7381 100644 --- a/fpdfsdk/epdf_annot_embeddertest.cpp +++ b/fpdfsdk/epdf_annot_embeddertest.cpp @@ -7349,6 +7349,93 @@ TEST_F(EPDFAnnotEmbedderTest, SetNameAcceptsAnyNameAndKeepsAppearance) { EXPECT_STREQ(kAcrobatId, buf.data()); } +TEST_F(EPDFAnnotEmbedderTest, SetSubtypeChangesTheKindInPlace) { + embedpdf_test::ScopedTestDocument doc(NewTestDocument()); + ASSERT_TRUE(doc); + ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 400, 400)); + ASSERT_TRUE(page); + + ScopedFPDFAnnotation mark( + FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_HIGHLIGHT)); + ASSERT_TRUE(mark); + const FS_QUADPOINTSF quad{50, 330, 250, 330, 50, 310, 250, 310}; + ASSERT_TRUE(FPDFAnnot_AppendAttachmentPoints(mark.get(), &quad)); + const FS_RECTF rect{50.0f, 330.0f, 250.0f, 310.0f}; + ASSERT_TRUE(FPDFAnnot_SetRect(mark.get(), &rect)); + ASSERT_TRUE(FPDFAnnot_SetColor(mark.get(), FPDFANNOT_COLORTYPE_Color, 0, 0, + 255, 255)); + ScopedFPDFWideString contents = GetFPDFWideString(L"Check this"); + ASSERT_TRUE(FPDFAnnot_SetStringValue(mark.get(), "Contents", contents.get())); + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(mark.get())); + const std::wstring highlight_ap = GetNormalAppearance(mark.get()); + + ScopedFPDFAnnotation widget( + FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_WIDGET)); + ASSERT_TRUE(widget); + ScopedFPDFAnnotation popup( + FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_POPUP)); + ASSERT_TRUE(popup); + const int count = FPDFPage_GetAnnotCount(page.get()); + const int index = FPDFPage_GetAnnotIndex(page.get(), mark.get()); + ASSERT_GE(index, 0); + + // Refusals: no handle, a subtype the annotation API doesn't create, and a + // widget or a popup, either way. + EXPECT_FALSE(EPDFAnnot_SetSubtype(nullptr, FPDF_ANNOT_SQUIGGLY)); + EXPECT_FALSE(EPDFAnnot_SetSubtype(mark.get(), FPDF_ANNOT_UNKNOWN)); + EXPECT_FALSE(EPDFAnnot_SetSubtype(mark.get(), FPDF_ANNOT_SOUND)); + EXPECT_FALSE(EPDFAnnot_SetSubtype(mark.get(), FPDF_ANNOT_WIDGET)); + EXPECT_FALSE(EPDFAnnot_SetSubtype(mark.get(), FPDF_ANNOT_POPUP)); + EXPECT_FALSE(EPDFAnnot_SetSubtype(widget.get(), FPDF_ANNOT_SQUARE)); + EXPECT_FALSE(EPDFAnnot_SetSubtype(popup.get(), FPDF_ANNOT_TEXT)); + EXPECT_EQ(FPDF_ANNOT_HIGHLIGHT, FPDFAnnot_GetSubtype(mark.get())); + EXPECT_EQ(FPDF_ANNOT_WIDGET, FPDFAnnot_GetSubtype(widget.get())); + EXPECT_EQ(FPDF_ANNOT_POPUP, FPDFAnnot_GetSubtype(popup.get())); + + // The name only: the same annotation in the same place, every entry kept, + // the old drawing too. + ASSERT_TRUE(EPDFAnnot_SetSubtype(mark.get(), FPDF_ANNOT_SQUIGGLY)); + EXPECT_EQ(FPDF_ANNOT_SQUIGGLY, FPDFAnnot_GetSubtype(mark.get())); + EXPECT_EQ(count, FPDFPage_GetAnnotCount(page.get())); + EXPECT_EQ(index, FPDFPage_GetAnnotIndex(page.get(), mark.get())); + EXPECT_EQ(1u, FPDFAnnot_CountAttachmentPoints(mark.get())); + { + unsigned int r = 0, g = 0, b = 0; + ASSERT_TRUE( + EPDFAnnot_GetColor(mark.get(), FPDFANNOT_COLORTYPE_Color, &r, &g, &b)); + EXPECT_EQ(255u, b); + const unsigned long len = + FPDFAnnot_GetStringValue(mark.get(), "Contents", nullptr, 0); + ASSERT_GT(len, 0u); + std::vector buf = GetFPDFWideStringBuffer(len); + FPDFAnnot_GetStringValue(mark.get(), "Contents", buf.data(), len); + EXPECT_EQ(L"Check this", GetPlatformWString(buf.data())); + } + EXPECT_EQ(highlight_ap, GetNormalAppearance(mark.get())); + + // Drawn again, it draws as its new kind; a new handle reads the new kind. + ASSERT_TRUE(EPDFAnnot_GenerateAppearance(mark.get())); + EXPECT_NE(highlight_ap, GetNormalAppearance(mark.get())); + { + ScopedFPDFAnnotation again(FPDFPage_GetAnnot(page.get(), index)); + ASSERT_TRUE(again); + EXPECT_EQ(FPDF_ANNOT_SQUIGGLY, FPDFAnnot_GetSubtype(again.get())); + } + + // Round trip through a save. + ASSERT_TRUE(FPDFPage_GenerateContent(page.get())); + ClearString(); + ASSERT_TRUE(FPDF_SaveAsCopy(doc.get(), this, 0)); + ScopedSavedDoc saved_doc = OpenScopedSavedDocument(); + ASSERT_TRUE(saved_doc); + ScopedFPDFPage saved_page(FPDF_LoadPage(saved_doc.get(), 0)); + ASSERT_TRUE(saved_page); + ASSERT_EQ(count, FPDFPage_GetAnnotCount(saved_page.get())); + ScopedFPDFAnnotation reloaded(FPDFPage_GetAnnot(saved_page.get(), index)); + ASSERT_TRUE(reloaded); + EXPECT_EQ(FPDF_ANNOT_SQUIGGLY, FPDFAnnot_GetSubtype(reloaded.get())); +} + TEST_F(EPDFAnnotEmbedderTest, GenerateFileAttachmentAppearance) { embedpdf_test::ScopedTestDocument doc(NewTestDocument()); ASSERT_TRUE(doc); @@ -7694,6 +7781,65 @@ TEST_F(EPDFAnnotEmbedderTest, LayerRemoveAnnotByNamePromotesOnlyThePage) { EXPECT_FALSE(EPDFLayer_IsObjectPromoted(doc.layer, other)); } +TEST_F(EPDFAnnotEmbedderTest, LayerSetSubtypeKeepsTheObjectAndAborts) { + LayerFixture doc; + ASSERT_TRUE(doc.OpenFresh(kTwoAnnots)); + int index = -1; + uint32_t obj_num = 0; + { + ScopedFPDFPage page(FPDF_LoadPage(doc.layer, 0)); + ASSERT_TRUE(page); + for (int i = 0; i < FPDFPage_GetAnnotCount(page.get()); ++i) { + ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), i)); + ASSERT_TRUE(annot); + if (FPDFAnnot_GetSubtype(annot.get()) == FPDF_ANNOT_HIGHLIGHT) { + index = i; + break; + } + } + ASSERT_GE(index, 0); + obj_num = AnnotObjNumAt(page.get(), index); + ASSERT_NE(0u, obj_num); + + // An aborted transaction leaves the highlight as it was. + ASSERT_TRUE(EPDFLayer_BeginTransaction(doc.layer)); + { + ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), index)); + ASSERT_TRUE(annot); + ASSERT_TRUE(EPDFAnnot_SetSubtype(annot.get(), FPDF_ANNOT_UNDERLINE)); + EXPECT_EQ(FPDF_ANNOT_UNDERLINE, FPDFAnnot_GetSubtype(annot.get())); + } + ASSERT_TRUE(EPDFLayer_AbortTransaction(doc.layer)); + { + ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), index)); + ASSERT_TRUE(annot); + EXPECT_EQ(FPDF_ANNOT_HIGHLIGHT, FPDFAnnot_GetSubtype(annot.get())); + } + + // A committed one keeps the object: its number and its place. + ASSERT_TRUE(EPDFLayer_BeginTransaction(doc.layer)); + { + ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), index)); + ASSERT_TRUE(annot); + ASSERT_TRUE(EPDFAnnot_SetSubtype(annot.get(), FPDF_ANNOT_UNDERLINE)); + } + ASSERT_TRUE(EPDFLayer_CommitTransaction(doc.layer)); + EXPECT_EQ(obj_num, AnnotObjNumAt(page.get(), index)); + EXPECT_TRUE(EPDFLayer_IsObjectPromoted(doc.layer, obj_num)); + } + + // The saved delta carries it. + std::vector delta = doc.SaveDelta(); + ASSERT_FALSE(delta.empty()); + ASSERT_TRUE(doc.Reopen(std::move(delta))); + ScopedFPDFPage page(FPDF_LoadPage(doc.layer, 0)); + ASSERT_TRUE(page); + ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), index)); + ASSERT_TRUE(annot); + EXPECT_EQ(FPDF_ANNOT_UNDERLINE, FPDFAnnot_GetSubtype(annot.get())); + EXPECT_EQ(obj_num, AnnotObjNumAt(page.get(), index)); +} + TEST_F(EPDFAnnotEmbedderTest, LayerRemoveAnnotByIndexPromotesOnlyThePage) { LayerFixture doc; ASSERT_TRUE(doc.OpenFresh(kTwoAnnots)); diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index 9e5a57c14..ec6cca25e 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -183,6 +183,7 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFAnnot_SetRotate); CHK(EPDFAnnot_SetStampDrawing); CHK(EPDFAnnot_SetStampOpacity); + CHK(EPDFAnnot_SetSubtype); CHK(EPDFAnnot_SetTextAlignment); CHK(EPDFAnnot_SetVertices); CHK(EPDFAnnot_UpdateAppearanceToRect); diff --git a/public/epdf_annot.h b/public/epdf_annot.h index 26a5e44aa..87d0a80bb 100644 --- a/public/epdf_annot.h +++ b/public/epdf_annot.h @@ -947,6 +947,30 @@ FPDF_EXPORT int FPDF_CALLCONV EPDFPage_PromoteInlineAnnotsRaw(FPDF_DOCUMENT doc, // Returns true if the handle is valid. FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFAnnot_IsValid(FPDF_ANNOTATION annot); +// Experimental EmbedPDF Extension API. +// Change an annotation's kind in place: write its /Subtype and nothing else. +// It keeps its dictionary, object number, place in /Annots and every other +// entry. /AP is left as it is, so draw the new kind with +// EPDFAnnot_GenerateAppearance(). Which kinds may become which is the +// caller's decision; ISO 32000-2 describes some subtypes with one table of +// entries (the four text markup subtypes; Square and Circle), and only those +// share all of them. +// +// A widget belongs to its form field and a popup to its parent, so neither +// changes kind, and no annotation becomes one. FPDFAnnot_GetSubtype() reads +// the new subtype at once; a form-fill environment's page view loaded before +// the change keeps the old one until the page is loaded again, as after +// FPDFPage_CreateAnnot(). +// +// annot - handle to an annotation. +// subtype - the new subtype. +// +// Returns true on success; false for a null or invalid handle, or when the +// current or the new subtype is one FPDFAnnot_IsSupportedSubtype() refuses, +// a widget or a popup. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV +EPDFAnnot_SetSubtype(FPDF_ANNOTATION annot, FPDF_ANNOTATION_SUBTYPE subtype); + // Experimental EmbedPDF Extension API. // Set the /Name entry of an annotation: the icon name of a text, file // attachment, or sound annotation, or a stamp's identifier (ISO 32000-2