From 97c0ae7474802169b32edf657837b693272b077d Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Wed, 28 Oct 2026 14:27:08 +0200 Subject: [PATCH 1/2] Copy pages with the form fields of their widgets FPDF_ImportPagesByIndex copies a page's widgets but not their fields: the page copier skips /Parent, so each copied widget keeps naming the source's field by its number, which names nothing (or something else) once saved, and the destination gets no /AcroForm. EPDFDoc_ImportPages copies the pages the same way, then the fields above the copied widgets: each once, its /Kids limited to what was copied, the widgets' and fields' /Parent pointed at the copies. The copied top-level fields join the destination's /AcroForm /Fields (made when it has none), with the source's /DR resources (a font whose name the destination uses for another font gets a free name, and the copied /DA strings follow), its /DA and /Q where the destination says otherwise, /NeedAppearances, the copied fields' /CO places and SignaturesExist. A copied pop-up's /Parent names its annotation's copy. Names are kept: EPDFDoc_GetImportFieldRoots and EPDFForm_GetFieldPartialName let the caller rename first. CPDF_PageOrganizer gains three public members backing it, tagged epdf-api. --- BUILD.gn | 2 + core/fpdfapi/edit/cpdf_pageorganizer.cpp | 5 + core/fpdfapi/edit/cpdf_pageorganizer.h | 13 + fpdfsdk/BUILD.gn | 4 + fpdfsdk/epdf_c_api_test.c | 6 + fpdfsdk/epdf_form.cpp | 16 + fpdfsdk/epdf_ppo.cpp | 774 +++++++++++++++++++++++ fpdfsdk/epdf_ppo_embeddertest.cpp | 385 +++++++++++ public/epdf_form.h | 13 + public/epdf_ppo.h | 83 +++ testing/resources/form_page_import.in | 174 +++++ testing/resources/form_page_import.pdf | Bin 0 -> 3497 bytes 12 files changed, 1475 insertions(+) create mode 100644 fpdfsdk/epdf_ppo.cpp create mode 100644 fpdfsdk/epdf_ppo_embeddertest.cpp create mode 100644 public/epdf_ppo.h create mode 100644 testing/resources/form_page_import.in create mode 100644 testing/resources/form_page_import.pdf diff --git a/BUILD.gn b/BUILD.gn index 5ca57836c4..34d01752bf 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -237,6 +237,8 @@ source_set("pdfium_public_headers_impl") { "public/epdf_pieceinfo.h", # EmbedPDF: PNG encoding of RGBA bitmaps. "public/epdf_png.h", + # EmbedPDF: what we add to public/fpdf_ppo.h. + "public/epdf_ppo.h", # EmbedPDF: what we add to public/fpdf_progressive.h. "public/epdf_progressive.h", # EmbedPDF: typed measurement, viewport and caption API. diff --git a/core/fpdfapi/edit/cpdf_pageorganizer.cpp b/core/fpdfapi/edit/cpdf_pageorganizer.cpp index ae50efe007..833b894fce 100644 --- a/core/fpdfapi/edit/cpdf_pageorganizer.cpp +++ b/core/fpdfapi/edit/cpdf_pageorganizer.cpp @@ -126,6 +126,11 @@ bool CPDF_PageOrganizer::UpdateReference(RetainPtr obj) { } } +// EmbedPDF(epdf-api): see the header. +bool CPDF_PageOrganizer::RetargetReferences(RetainPtr obj) { + return UpdateReference(std::move(obj)); +} + uint32_t CPDF_PageOrganizer::GetNewObjId(CPDF_Reference* ref) { if (!ref) { return 0; diff --git a/core/fpdfapi/edit/cpdf_pageorganizer.h b/core/fpdfapi/edit/cpdf_pageorganizer.h index e9623a4d96..bfb24e1f33 100644 --- a/core/fpdfapi/edit/cpdf_pageorganizer.h +++ b/core/fpdfapi/edit/cpdf_pageorganizer.h @@ -21,6 +21,19 @@ class CPDF_Object; class CPDF_Reference; class CPDF_PageOrganizer { + public: + // EmbedPDF(epdf-api): EPDFDoc_ImportPages() (fpdfsdk/epdf_ppo.cpp) copies + // the form fields above the copied widgets: it names its copies before the + // pages are copied, finds the copies the pages made, and points the + // references in its own copies at the destination. + void MapObjectNumber(uint32_t src_obj_num, uint32_t dest_obj_num) { + AddObjectMapping(src_obj_num, dest_obj_num); + } + const std::map& object_number_map() const { + return object_number_map_; + } + bool RetargetReferences(RetainPtr obj); + protected: CPDF_PageOrganizer(CPDF_Document* dest_doc, CPDF_Document* src_doc); ~CPDF_PageOrganizer(); diff --git a/fpdfsdk/BUILD.gn b/fpdfsdk/BUILD.gn index 69bc28458b..cfd462ec41 100644 --- a/fpdfsdk/BUILD.gn +++ b/fpdfsdk/BUILD.gn @@ -75,6 +75,8 @@ source_set("fpdfsdk") { # EmbedPDF: what a parsed page costs, and whether it is still current. "epdf_page_residency.cpp", "epdf_png_shim.cpp", + # EmbedPDF: pages copied with their form fields (public/epdf_ppo.h). + "epdf_ppo.cpp", "epdf_redact.cpp", "epdf_threading.cpp", "epdf_wrapped_appearance.cpp", @@ -274,6 +276,8 @@ pdfium_embeddertest_source_set("embeddertests") { "epdf_sliced_load_embeddertest.cpp", # EmbedPDF: covers the /Names /Pages and /Templates name-tree APIs. "epdf_named_pages_embeddertest.cpp", + # EmbedPDF: covers pages copied with their form fields (public/epdf_ppo.h). + "epdf_ppo_embeddertest.cpp", # EmbedPDF: a content rewrite keeps what it did not change. "epdf_content_rewrite_embeddertest.cpp", "epdf_redact_embeddertest.cpp", diff --git a/fpdfsdk/epdf_c_api_test.c b/fpdfsdk/epdf_c_api_test.c index 99c0ec32c5..7c1c0fe1d4 100644 --- a/fpdfsdk/epdf_c_api_test.c +++ b/fpdfsdk/epdf_c_api_test.c @@ -27,6 +27,7 @@ #include "public/epdf_outline.h" #include "public/epdf_pieceinfo.h" #include "public/epdf_png.h" +#include "public/epdf_ppo.h" #include "public/epdf_progressive.h" #include "public/epdf_redact.h" #include "public/epdf_save.h" @@ -329,6 +330,7 @@ int CheckEmbedPDFCApi(void) { CHK(EPDFForm_GetFieldOptionLabel); CHK(EPDFForm_GetFieldOptionValue); CHK(EPDFForm_GetFieldOrigin); + CHK(EPDFForm_GetFieldPartialName); CHK(EPDFForm_GetFieldTopIndex); CHK(EPDFForm_GetFieldValueAt); CHK(EPDFForm_GetFieldValueKind); @@ -502,6 +504,10 @@ int CheckEmbedPDFCApi(void) { // epdf_png.h CHK(EPDF_PNG_EncodeRGBA); + // epdf_ppo.h + CHK(EPDFDoc_GetImportFieldRoots); + CHK(EPDFDoc_ImportPages); + // epdf_progressive.h CHK(EPDF_RenderPageBitmapWithMatrix_Start); CHK(EPDF_RenderPage_Continue); diff --git a/fpdfsdk/epdf_form.cpp b/fpdfsdk/epdf_form.cpp index 3ae921c2d1..846a93fcae 100644 --- a/fpdfsdk/epdf_form.cpp +++ b/fpdfsdk/epdf_form.cpp @@ -2973,6 +2973,22 @@ EPDFForm_SetFieldName(FPDF_DOCUMENT document, return true; } +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFForm_GetFieldPartialName(FPDF_DOCUMENT document, + uint32_t field_objnum, + FPDF_WCHAR* buffer, + unsigned long buflen) { + ScopedFPDFDocumentView document_view(document); + RetainPtr field = + ResolveFieldDict(document_view.Get(), field_objnum); + if (!field || !field->KeyExist(pdfium::form_fields::kT)) { + return 0; + } + return Utf16EncodeMaybeCopyAndReturnLength( + field->GetUnicodeTextFor(pdfium::form_fields::kT), + UNSAFE_BUFFERS(SpanFromFPDFApiArgs(buffer, buflen))); +} + FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFForm_SetFieldFlags(FPDF_DOCUMENT document, uint32_t field_objnum, diff --git a/fpdfsdk/epdf_ppo.cpp b/fpdfsdk/epdf_ppo.cpp new file mode 100644 index 0000000000..b5d19d03c3 --- /dev/null +++ b/fpdfsdk/epdf_ppo.cpp @@ -0,0 +1,774 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "public/epdf_ppo.h" + +#include + +#include +#include +#include +#include +#include +#include + +#include "constants/form_fields.h" +#include "core/fpdfapi/edit/cpdf_pageexporter.h" +#include "core/fpdfapi/parser/cpdf_array.h" +#include "core/fpdfapi/parser/cpdf_boolean.h" +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_document_view_scope.h" +#include "core/fpdfapi/parser/cpdf_layer_document.h" +#include "core/fpdfapi/parser/cpdf_number.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_stream.h" +#include "core/fpdfapi/parser/cpdf_string.h" +#include "core/fpdfapi/parser/fpdf_parser_utility.h" +#include "core/fxcrt/bytestring.h" +#include "core/fxcrt/compiler_specific.h" +#include "core/fxcrt/numerics/safe_conversions.h" +#include "core/fxcrt/retain_ptr.h" +#include "core/fxcrt/span.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "fpdfsdk/epdf_form_helpers.h" +#include "fpdfsdk/epdf_object_helpers.h" + +namespace { + +using pdfium::form_fields::kDA; +using pdfium::form_fields::kFT; +using pdfium::form_fields::kKids; +using pdfium::form_fields::kParent; +using pdfium::form_fields::kQ; + +constexpr char kAcroForm[] = "AcroForm"; +constexpr char kAnnots[] = "Annots"; +constexpr char kCO[] = "CO"; +constexpr char kDR[] = "DR"; +constexpr char kFields[] = "Fields"; +constexpr char kFont[] = "Font"; +constexpr char kNeedAppearances[] = "NeedAppearances"; +constexpr char kSigFlags[] = "SigFlags"; + +// The deepest field tree followed, as CPDF_InteractiveForm loads one. +constexpr int kMaxFieldDepth = 32; + +// ISO 32000-2 table 224. +constexpr int kSignaturesExist = 1; + +// What the pages bring of the source's form, by the source's object numbers. +struct FieldPlan { + // The widgets the pages' /Annots name, each once, in page order. + std::vector widgets; + std::set widget_set; + // The fields above them, each once, in the order they were reached. + std::vector fields; + std::set field_set; + // The field above each widget and each field; none at the top. + std::map parent; + // The top-level fields, in the order they were reached: a field, or a + // widget that is its own field. + std::vector roots; +}; + +RetainPtr AcroFormOf(const CPDF_Document* doc) { + const CPDF_Dictionary* root = doc->GetRoot(); + return root ? root->GetDictFor(kAcroForm) : nullptr; +} + +// The parent each field of the source's /AcroForm /Fields tree lists in its +// /Kids: the parent of a member whose own /Parent names none. +std::map ParentsByKids(CPDF_Document* doc) { + std::map parents; + RetainPtr acro_form = AcroFormOf(doc); + RetainPtr fields = + acro_form ? acro_form->GetArrayFor(kFields) : nullptr; + if (!fields) { + return parents; + } + std::set visited; + std::vector> pending; + for (size_t i = 0; i < fields->size(); ++i) { + RetainPtr ref = ToReference(fields->GetObjectAt(i)); + if (ref) { + pending.emplace_back(ref->GetRefObjNum(), 0); + } + } + while (!pending.empty()) { + const auto [objnum, depth] = pending.back(); + pending.pop_back(); + if (depth >= kMaxFieldDepth || !visited.insert(objnum).second) { + continue; + } + RetainPtr node = epdf::ResolveFieldDict(doc, objnum); + RetainPtr kids = + node ? node->GetArrayFor(kKids) : nullptr; + if (!kids) { + continue; + } + for (size_t i = 0; i < kids->size(); ++i) { + RetainPtr ref = ToReference(kids->GetObjectAt(i)); + if (!ref || ref->GetRefObjNum() == objnum) { + continue; + } + parents.try_emplace(ref->GetRefObjNum(), objnum); + pending.emplace_back(ref->GetRefObjNum(), depth + 1); + } + } + return parents; +} + +// The field above |dict| (|objnum|): the one its /Parent names, else the one +// whose /Kids lists it. 0 for none. +uint32_t ParentOf(const CPDF_Dictionary* dict, + uint32_t objnum, + const std::map& parents_by_kids) { + RetainPtr ref = + ToReference(dict->GetObjectFor(kParent)); + if (ref && ref->GetRefObjNum() != objnum && ToDictionary(ref->GetDirect())) { + return ref->GetRefObjNum(); + } + const auto it = parents_by_kids.find(objnum); + return it != parents_by_kids.end() ? it->second : 0; +} + +bool IsWidget(const CPDF_Dictionary* dict) { + return dict && dict->GetNameFor("Subtype") == "Widget"; +} + +FieldPlan PlanFields(CPDF_Document* doc, + pdfium::span page_indices) { + FieldPlan plan; + const std::map parents_by_kids = ParentsByKids(doc); + for (uint32_t page_index : page_indices) { + RetainPtr page = + doc->GetPageDictionary(pdfium::checked_cast(page_index)); + RetainPtr annots = + page ? page->GetArrayFor(kAnnots) : nullptr; + if (!annots) { + continue; + } + for (size_t i = 0; i < annots->size(); ++i) { + // An annotation written into /Annots is no field's member: /Kids can't + // name it. + RetainPtr ref = ToReference(annots->GetObjectAt(i)); + RetainPtr widget = + ref ? ToDictionary(ref->GetDirect()) : nullptr; + if (!IsWidget(widget.Get()) || + !plan.widget_set.insert(ref->GetRefObjNum()).second) { + continue; + } + const uint32_t widget_objnum = ref->GetRefObjNum(); + plan.widgets.push_back(widget_objnum); + + uint32_t parent = ParentOf(widget.Get(), widget_objnum, parents_by_kids); + if (!parent) { + if (widget->KeyExist(kFT)) { + plan.roots.push_back(widget_objnum); + } + continue; + } + plan.parent[widget_objnum] = parent; + // Up to the top, or to a field reached before (its way up is known). + for (int depth = 0; parent && depth < kMaxFieldDepth; ++depth) { + if (plan.widget_set.count(parent) || + !plan.field_set.insert(parent).second) { + break; + } + plan.fields.push_back(parent); + RetainPtr field = + epdf::ResolveFieldDict(doc, parent); + const uint32_t above = + field ? ParentOf(field.Get(), parent, parents_by_kids) : 0; + if (!above) { + plan.roots.push_back(parent); + break; + } + plan.parent[parent] = above; + parent = above; + } + } + } + return plan; +} + +// The destination's number for the source's |objnum|, or 0. +uint32_t CopyOf(const CPDF_PageExporter& exporter, uint32_t objnum) { + const auto& map = exporter.object_number_map(); + const auto it = map.find(objnum); + return it != map.end() ? it->second : 0; +} + +RetainPtr MutableDictOf(CPDF_Document* doc, uint32_t objnum) { + RetainPtr object = + objnum ? doc->GetMutableIndirectObject(objnum) : nullptr; + if (!object) { + return nullptr; + } + if (CPDF_Stream* stream = object->AsMutableStream()) { + return stream->GetMutableDict(); + } + return ToDictionary(std::move(object)); +} + +// The dictionary |dict| holds under |key|, ready to write; a new direct one +// when it has none. |dict| must already be writable. +RetainPtr MutableDictMember(CPDF_Document* doc, + CPDF_Dictionary* dict, + const ByteString& key) { + RetainPtr entry = dict->GetObjectFor(key.AsStringView()); + if (!entry) { + return dict->SetNewFor(key); + } + if (const CPDF_Reference* ref = entry->AsReference()) { + return ToDictionary(doc->GetMutableIndirectObject(ref->GetRefObjNum())); + } + return dict->GetMutableDictFor(key.AsStringView()); +} + +// The copy names what its source named, as a copy in the destination; a +// /Parent naming what wasn't copied is removed: the page copy leaves every +// /Parent naming an object of the source. +void RetargetParent(CPDF_Document* dest, + const CPDF_PageExporter& exporter, + CPDF_Dictionary* copy) { + RetainPtr parent = copy->GetMutableObjectFor(kParent); + if (!parent) { + return; + } + CPDF_Reference* ref = parent->AsMutableReference(); + const uint32_t target = ref ? CopyOf(exporter, ref->GetRefObjNum()) : 0; + if (target) { + ref->SetRef(dest, target); + } else { + copy->RemoveFor(kParent); + } +} + +// A field copied with what is below it among the copies: its /Kids in its +// order, then the copies whose field it is that its /Kids doesn't list. +void CopyField(CPDF_Document* src, + CPDF_Document* dest, + CPDF_PageExporter& exporter, + const FieldPlan& plan, + const std::map>& children, + uint32_t field) { + RetainPtr source = epdf::ResolveFieldDict(src, field); + RetainPtr copy = + MutableDictOf(dest, CopyOf(exporter, field)); + if (!source || !copy) { + return; + } + { + CPDF_DictionaryLocker locker(source); + for (const auto& [key, value] : locker) { + if (key != kParent && key != kKids) { + copy->SetFor(key, value->Clone()); + } + } + } + exporter.RetargetReferences(copy); + + const auto parent = plan.parent.find(field); + if (parent != plan.parent.end()) { + copy->SetNewFor(kParent, dest, + CopyOf(exporter, parent->second)); + } + const auto below = children.find(field); + if (below == children.end()) { + return; + } + std::vector kids; + std::set listed; + RetainPtr source_kids = source->GetArrayFor(kKids); + for (size_t i = 0; source_kids && i < source_kids->size(); ++i) { + RetainPtr ref = + ToReference(source_kids->GetObjectAt(i)); + if (ref && listed.insert(ref->GetRefObjNum()).second) { + kids.push_back(ref->GetRefObjNum()); + } + } + const std::set members(below->second.begin(), below->second.end()); + auto copied_kids = copy->SetNewFor(kKids); + for (uint32_t kid : kids) { + if (members.count(kid) && CopyOf(exporter, kid)) { + copied_kids->AppendNew(dest, CopyOf(exporter, kid)); + } + } + for (uint32_t member : below->second) { + if (!listed.count(member) && CopyOf(exporter, member)) { + copied_kids->AppendNew(dest, CopyOf(exporter, member)); + } + } +} + +// Whether two fonts' /Encoding entries are the same: none, the same name, or +// dictionaries with the same base encoding and /Differences (Acrobat gives +// every /Helv the same one). +bool IsSameEncoding(const CPDF_Object* a, const CPDF_Object* b) { + if (!a || !b) { + return !a && !b; + } + if (a->IsName() || b->IsName()) { + return a->IsName() && b->IsName() && a->GetString() == b->GetString(); + } + const CPDF_Dictionary* dict_a = a->AsDictionary(); + const CPDF_Dictionary* dict_b = b->AsDictionary(); + if (!dict_a || !dict_b || + dict_a->GetNameFor("BaseEncoding") != + dict_b->GetNameFor("BaseEncoding")) { + return false; + } + RetainPtr differences_a = + dict_a->GetArrayFor("Differences"); + RetainPtr differences_b = + dict_b->GetArrayFor("Differences"); + if (!differences_a || !differences_b) { + return !differences_a && !differences_b; + } + if (differences_a->size() != differences_b->size()) { + return false; + } + for (size_t i = 0; i < differences_a->size(); ++i) { + RetainPtr entry_a = differences_a->GetDirectObjectAt(i); + RetainPtr entry_b = differences_b->GetDirectObjectAt(i); + if (!entry_a || !entry_b || entry_a->GetType() != entry_b->GetType() || + entry_a->GetString() != entry_b->GetString()) { + return false; + } + } + return true; +} + +// Whether two /DR fonts draw the same: the same standard (not embedded) +// simple font, in the same encoding. +bool IsSameStandardFont(const CPDF_Dictionary* a, const CPDF_Dictionary* b) { + auto standard = [](const CPDF_Dictionary* font) { + if (!font || font->GetNameFor("Subtype") == "Type0") { + return false; + } + RetainPtr descriptor = + font->GetDictFor("FontDescriptor"); + return !descriptor || !(descriptor->KeyExist("FontFile") || + descriptor->KeyExist("FontFile2") || + descriptor->KeyExist("FontFile3")); + }; + if (!standard(a) || !standard(b) || + a->GetNameFor("Subtype") != b->GetNameFor("Subtype") || + a->GetNameFor("BaseFont") != b->GetNameFor("BaseFont")) { + return false; + } + return IsSameEncoding(a->GetDirectObjectFor("Encoding").Get(), + b->GetDirectObjectFor("Encoding").Get()); +} + +// The source's /DR entries the destination's /DR doesn't name, copied in. A +// font under a name the destination gives another font gets a free name, +// returned old name to new. +std::map MergeResources( + CPDF_Document* dest, + CPDF_PageExporter& exporter, + CPDF_Dictionary* dest_form, + const CPDF_Dictionary* src_resources) { + std::map renamed_fonts; + RetainPtr dest_resources = + MutableDictMember(dest, dest_form, kDR); + if (!dest_resources) { + return renamed_fonts; + } + CPDF_DictionaryLocker categories(src_resources); + for (const auto& [category, value] : categories) { + RetainPtr src_entries = + ToDictionary(value->GetDirect()); + if (!src_entries) { + continue; + } + RetainPtr dest_entries = + MutableDictMember(dest, dest_resources.Get(), category); + if (!dest_entries) { + continue; + } + CPDF_DictionaryLocker entries(src_entries); + for (const auto& [name, entry] : entries) { + ByteString target = name; + if (dest_entries->KeyExist(name.AsStringView())) { + if (category != kFont || + IsSameStandardFont(dest_entries->GetDictFor(name.AsStringView()), + ToDictionary(entry->GetDirect()))) { + continue; + } + for (int n = 1; dest_entries->KeyExist(target.AsStringView()); ++n) { + target = name + "_" + ByteString::FormatInteger(n); + } + renamed_fonts[name] = target; + } + RetainPtr copy = entry->Clone(); + if (exporter.RetargetReferences(copy)) { + dest_entries->SetFor(target, std::move(copy)); + } + } + } + return renamed_fonts; +} + +// |appearance| (a /DA string) with the font it sets renamed as |renamed| +// says: the name operand of its Tf. +ByteString RenameAppearanceFont( + const ByteString& appearance, + const std::map& renamed) { + if (renamed.empty()) { + return appearance; + } + // The tokens, as [start, end) offsets. + std::vector> tokens; + const ByteStringView text = appearance.AsStringView(); + for (size_t i = 0; i < text.GetLength();) { + const uint8_t c = text[i]; + if (PDFCharIsWhitespace(c)) { + ++i; + continue; + } + size_t end = i + 1; + if (c == '/' || !PDFCharIsDelimiter(c)) { + while (end < text.GetLength() && !PDFCharIsWhitespace(text[end]) && + !PDFCharIsDelimiter(text[end])) { + ++end; + } + } + tokens.emplace_back(i, end); + i = end; + } + ByteString result; + size_t written = 0; + for (size_t t = 2; t < tokens.size(); ++t) { + const auto [start, end] = tokens[t - 2]; + if (text.Substr(tokens[t].first, tokens[t].second - tokens[t].first) != + "Tf" || + text[start] != '/') { + continue; + } + const auto it = + renamed.find(PDF_NameDecode(text.Substr(start + 1, end - start - 1))); + if (it == renamed.end()) { + continue; + } + result += text.Substr(written, start - written); + result += ByteString("/") + PDF_NameEncode(it->second); + written = end; + } + result += text.Substr(written); + return result; +} + +void RenameFontsIn(CPDF_Dictionary* dict, + const std::map& renamed) { + if (renamed.empty() || !dict->KeyExist(kDA)) { + return; + } + const ByteString appearance = dict->GetByteStringFor(kDA); + const ByteString changed = RenameAppearanceFont(appearance, renamed); + if (changed != appearance) { + dict->SetNewFor(kDA, changed); + } +} + +// The destination's /AcroForm, ready to write; an empty one when it has +// none (and |*created| is set). +RetainPtr MutableAcroForm(CPDF_Document* dest, bool* created) { + if (RetainPtr acro_form = epdf::GetMutableAcroForm( + dest, /*create_if_missing=*/false, nullptr)) { + return acro_form; + } + RetainPtr root = dest->GetMutableRoot(); + if (!root) { + return nullptr; + } + auto acro_form = dest->NewIndirect(); + root->SetNewFor(kAcroForm, dest, acro_form->GetObjNum()); + *created = true; + return acro_form; +} + +// The copied top-level fields into the destination's /AcroForm, with what +// the source's /AcroForm gave them. +void LinkFields(CPDF_Document* src, + CPDF_Document* dest, + CPDF_PageExporter& exporter, + const FieldPlan& plan) { + std::vector roots; + for (uint32_t root : plan.roots) { + if (CopyOf(exporter, root)) { + roots.push_back(CopyOf(exporter, root)); + } + } + if (roots.empty()) { + return; + } + bool created = false; + RetainPtr dest_form = MutableAcroForm(dest, &created); + RetainPtr fields = + dest_form ? epdf::GetMutableArrayMember(dest, dest_form.Get(), kFields, + /*create_if_missing=*/true) + : nullptr; + if (!fields) { + return; + } + for (uint32_t root : roots) { + fields->AppendNew(dest, root); + } + + // Every copy the form names, the fields and the widgets. + std::vector> copies; + for (const std::vector* members : {&plan.fields, &plan.widgets}) { + for (uint32_t member : *members) { + if (RetainPtr copy = + MutableDictOf(dest, CopyOf(exporter, member))) { + copies.push_back(std::move(copy)); + } + } + } + if (std::any_of(copies.begin(), copies.end(), [](const auto& copy) { + return copy->GetNameFor(kFT) == pdfium::form_fields::kSig; + })) { + dest_form->SetNewFor( + kSigFlags, dest_form->GetIntegerFor(kSigFlags) | kSignaturesExist); + } + + RetainPtr src_form = AcroFormOf(src); + if (!src_form) { + return; + } + std::map renamed_fonts; + if (RetainPtr src_resources = + src_form->GetDictFor(kDR)) { + renamed_fonts = + MergeResources(dest, exporter, dest_form.Get(), src_resources.Get()); + } + for (const auto& copy : copies) { + RenameFontsIn(copy.Get(), renamed_fonts); + } + + // What the source's /AcroForm gives fields that say nothing themselves. + const ByteString src_appearance = + RenameAppearanceFont(src_form->GetByteStringFor(kDA), renamed_fonts); + const int src_quadding = src_form->GetIntegerFor(kQ, 0); + if (created) { + if (!src_appearance.IsEmpty()) { + dest_form->SetNewFor(kDA, src_appearance); + } + if (src_form->KeyExist(kQ)) { + dest_form->SetNewFor(kQ, src_quadding); + } + } + const ByteString dest_appearance = dest_form->GetByteStringFor(kDA); + const int dest_quadding = dest_form->GetIntegerFor(kQ, 0); + for (uint32_t root : roots) { + RetainPtr copy = MutableDictOf(dest, root); + if (!copy) { + continue; + } + if (!src_appearance.IsEmpty() && src_appearance != dest_appearance && + !copy->KeyExist(kDA)) { + copy->SetNewFor(kDA, src_appearance); + } + if (src_quadding != dest_quadding && !copy->KeyExist(kQ)) { + copy->SetNewFor(kQ, src_quadding); + } + } + + if (src_form->GetBooleanFor(kNeedAppearances, false)) { + dest_form->SetNewFor(kNeedAppearances, true); + } + + RetainPtr src_order = src_form->GetArrayFor(kCO); + std::vector order; + for (size_t i = 0; src_order && i < src_order->size(); ++i) { + RetainPtr ref = + ToReference(src_order->GetObjectAt(i)); + const uint32_t objnum = ref ? ref->GetRefObjNum() : 0; + if ((plan.field_set.count(objnum) || plan.widget_set.count(objnum)) && + CopyOf(exporter, objnum)) { + order.push_back(CopyOf(exporter, objnum)); + } + } + if (!order.empty()) { + RetainPtr dest_order = epdf::GetMutableArrayMember( + dest, dest_form.Get(), kCO, /*create_if_missing=*/true); + for (uint32_t objnum : order) { + if (dest_order) { + dest_order->AppendNew(dest, objnum); + } + } + } +} + +// The copies' /Parent entries, now that every copy is made: a widget's names +// its field's copy, a field's its parent's (CopyField), and any other copy's +// what its source's named when that was copied. +void FixParents(CPDF_Document* src, + CPDF_Document* dest, + CPDF_PageExporter& exporter, + const FieldPlan& plan, + pdfium::span page_indices) { + for (uint32_t widget : plan.widgets) { + RetainPtr copy = + MutableDictOf(dest, CopyOf(exporter, widget)); + if (!copy) { + continue; + } + const auto parent = plan.parent.find(widget); + const uint32_t field = + parent != plan.parent.end() ? CopyOf(exporter, parent->second) : 0; + if (field) { + copy->SetNewFor(kParent, dest, field); + } else { + copy->RemoveFor(kParent); + } + } + + // Copied in the loop below: a copy of a page holds its own /Parent. + std::set pages; + for (uint32_t page_index : page_indices) { + RetainPtr page = + src->GetPageDictionary(pdfium::checked_cast(page_index)); + if (page && CopyOf(exporter, page->GetObjNum())) { + pages.insert(page->GetObjNum()); + } + } + for (const auto& [source, copied] : exporter.object_number_map()) { + if (pages.count(source) || plan.field_set.count(source) || + plan.widget_set.count(source)) { + continue; + } + if (RetainPtr copy = MutableDictOf(dest, copied)) { + RetargetParent(dest, exporter, copy.Get()); + } + } + // Annotations written into a copied page's /Annots. + for (uint32_t page : pages) { + RetainPtr copy = + MutableDictOf(dest, CopyOf(exporter, page)); + RetainPtr annots = + copy ? epdf::GetMutableArrayMember(dest, copy.Get(), kAnnots, + /*create_if_missing=*/false) + : nullptr; + for (size_t i = 0; annots && i < annots->size(); ++i) { + RetainPtr annot = + ToDictionary(annots->GetMutableObjectAt(i)); + if (annot) { + RetargetParent(dest, exporter, annot.Get()); + } + } + } +} + +std::vector PageIndices(const CPDF_Document* doc, + const int* page_indices, + unsigned long length, + bool* valid) { + const int page_count = doc->GetPageCount(); + *valid = true; + if (!page_indices) { + std::vector all(page_count); + std::iota(all.begin(), all.end(), 0); + return all; + } + // SAFETY: required from caller. + auto span = UNSAFE_BUFFERS( + pdfium::span(page_indices, pdfium::checked_cast(length))); + std::vector indices; + indices.reserve(span.size()); + for (int page_index : span) { + if (page_index < 0 || page_index >= page_count) { + *valid = false; + return {}; + } + indices.push_back(static_cast(page_index)); + } + return indices; +} + +} // namespace + +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFDoc_ImportPages(FPDF_DOCUMENT dest_doc, + FPDF_DOCUMENT src_doc, + const int* page_indices, + unsigned long length, + int index) { + CPDF_Document* dest = CPDFDocumentFromFPDFDocument(dest_doc); + CPDF_Document* src = CPDFDocumentFromFPDFDocument(src_doc); + if (!dest || !src || dest == src || (page_indices && length == 0)) { + return false; + } + // One document is read through its layer: the source when an extract + // copies a session's pages out, else the destination. + CPDF_DocumentViewScope view(CPDF_LayerDocument::FromDocument(src) ? src + : dest); + bool valid = false; + const std::vector indices = + PageIndices(src, page_indices, length, &valid); + if (!valid || indices.empty()) { + return false; + } + + const FieldPlan plan = PlanFields(src, indices); + CPDF_PageExporter exporter(dest, src); + // The fields' copies are named before the pages are copied, so what the + // pages reach of a field (a reset action naming it) reaches its copy. + for (uint32_t field : plan.fields) { + exporter.MapObjectNumber(field, + dest->NewIndirect()->GetObjNum()); + } + if (!exporter.ExportPages(indices, index)) { + return false; + } + if (CPDF_Document::Extension* extension = dest->GetExtension()) { + extension->PagesInserted(index, indices.size()); + } + + std::map> children; + for (const std::vector* members : {&plan.fields, &plan.widgets}) { + for (uint32_t member : *members) { + const auto parent = plan.parent.find(member); + if (parent != plan.parent.end()) { + children[parent->second].push_back(member); + } + } + } + for (uint32_t field : plan.fields) { + CopyField(src, dest, exporter, plan, children, field); + } + FixParents(src, dest, exporter, plan, indices); + LinkFields(src, dest, exporter, plan); + return true; +} + +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFDoc_GetImportFieldRoots(FPDF_DOCUMENT document, + const int* page_indices, + unsigned long length, + uint32_t* buffer, + unsigned long buffer_count) { + ScopedFPDFDocumentView document_view(document); + CPDF_Document* doc = document_view.Get(); + if (!doc) { + return 0; + } + bool valid = false; + const std::vector indices = + PageIndices(doc, page_indices, length, &valid); + if (!valid) { + return 0; + } + const FieldPlan plan = PlanFields(doc, indices); + if (buffer) { + // SAFETY: required from caller. + auto out = UNSAFE_BUFFERS( + pdfium::span(buffer, pdfium::checked_cast(buffer_count))); + for (size_t i = 0; i < out.size() && i < plan.roots.size(); ++i) { + out[i] = plan.roots[i]; + } + } + return pdfium::checked_cast(plan.roots.size()); +} diff --git a/fpdfsdk/epdf_ppo_embeddertest.cpp b/fpdfsdk/epdf_ppo_embeddertest.cpp new file mode 100644 index 0000000000..9a80353dea --- /dev/null +++ b/fpdfsdk/epdf_ppo_embeddertest.cpp @@ -0,0 +1,385 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#include "public/epdf_ppo.h" + +#include +#include +#include + +#include "core/fpdfapi/parser/cpdf_array.h" +#include "core/fpdfapi/parser/cpdf_dictionary.h" +#include "core/fpdfapi/parser/cpdf_document.h" +#include "core/fpdfapi/parser/cpdf_name.h" +#include "core/fpdfapi/parser/cpdf_number.h" +#include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_string.h" +#include "fpdfsdk/cpdfsdk_helpers.h" +#include "public/cpp/fpdf_scopers.h" +#include "public/epdf_form.h" +#include "public/epdf_layer.h" +#include "public/epdf_save.h" +#include "public/fpdf_edit.h" +#include "public/fpdf_save.h" +#include "public/fpdfview.h" +#include "testing/embedder_test.h" +#include "testing/fx_string_testhelpers.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "testing/utils/file_util.h" +#include "testing/utils/path_service.h" + +namespace { + +// form_page_import.pdf's objects. +constexpr uint32_t kPerson = 5; +constexpr uint32_t kEmail = 8; +constexpr uint32_t kPlan = 10; +constexpr uint32_t kAgree = 12; + +// A field as the form model reads it. +struct Field { + std::wstring value; + int widgets = 0; + bool declared = false; // in /AcroForm /Fields, not found on a page only + + bool operator==(const Field& other) const { + return value == other.value && widgets == other.widgets && + declared == other.declared; + } +}; + +std::map ReadFields(FPDF_DOCUMENT document) { + std::map fields; + EPDF_FORM_MODEL model = EPDFForm_LoadModel(document); + EXPECT_TRUE(model); + if (!model) { + return fields; + } + for (int i = 0; i < EPDFForm_CountFields(model); ++i) { + unsigned long length = EPDFForm_GetFieldName(model, i, nullptr, 0); + std::vector name = GetFPDFWideStringBuffer(length); + EPDFForm_GetFieldName(model, i, name.data(), length); + Field field; + if (EPDFForm_CountFieldValues(model, i) > 0) { + length = EPDFForm_GetFieldValueAt(model, i, 0, nullptr, 0); + std::vector value = GetFPDFWideStringBuffer(length); + EPDFForm_GetFieldValueAt(model, i, 0, value.data(), length); + field.value = GetPlatformWString(value.data()); + } + field.widgets = EPDFForm_CountFieldWidgets(model, i); + field.declared = + EPDFForm_GetFieldOrigin(model, i) == EPDF_FORMFIELD_ORIGIN_ACROFORM; + fields[GetPlatformWString(name.data())] = field; + } + EPDFForm_CloseModel(model); + return fields; +} + +// What form_page_import.pdf's pages bring, all of them. +std::map AllPagesFields() { + return { + {L"person.name", {L"Ann", 1, true}}, + {L"person.email", {L"ann@example.com", 1, true}}, + {L"plan", {L"yearly", 2, true}}, + {L"agree", {L"Yes", 1, true}}, + }; +} + +std::wstring PartialName(FPDF_DOCUMENT document, uint32_t objnum) { + const unsigned long length = + EPDFForm_GetFieldPartialName(document, objnum, nullptr, 0); + if (length == 0) { + return std::wstring(); + } + std::vector buffer = GetFPDFWideStringBuffer(length); + EXPECT_EQ(length, EPDFForm_GetFieldPartialName(document, objnum, + buffer.data(), length)); + return GetPlatformWString(buffer.data()); +} + +std::vector ImportRoots(FPDF_DOCUMENT document, + const std::vector& pages) { + const int* indices = pages.empty() ? nullptr : pages.data(); + const unsigned long count = + EPDFDoc_GetImportFieldRoots(document, indices, pages.size(), nullptr, 0); + std::vector roots(count); + EXPECT_EQ(count, EPDFDoc_GetImportFieldRoots(document, indices, pages.size(), + roots.data(), roots.size())); + return roots; +} + +RetainPtr AcroFormOf(FPDF_DOCUMENT document) { + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(document); + return doc->GetRoot()->GetDictFor("AcroForm"); +} + +class EPDFPpoEmbedderTest : public EmbedderTest { + protected: + // |document| saved in full and opened again. + ScopedFPDFDocument SaveAndReopen(FPDF_DOCUMENT document) { + unsigned long size = 0; + void* buffer = + EPDF_SaveDocumentToOwnedBuffer(document, FPDF_NO_INCREMENTAL, &size); + EXPECT_TRUE(buffer); + saved_.emplace_back(static_cast(buffer), + static_cast(buffer) + size); + EPDF_FreeBuffer(buffer); + const std::string& bytes = saved_.back(); + return ScopedFPDFDocument(FPDF_LoadMemDocument( + bytes.data(), static_cast(bytes.size()), nullptr)); + } + + struct LayerDoc { + std::vector bytes; + EPDF_BASE_DOCUMENT base = nullptr; + FPDF_DOCUMENT layer = nullptr; + + ~LayerDoc() { + if (layer) { + FPDF_CloseDocument(layer); + } + if (base) { + EPDF_ReleaseBaseDocument(base); + } + } + }; + + bool OpenLayer(const char* file_name, LayerDoc* out) { + std::string file_path = PathService::GetTestFilePath(file_name); + out->bytes = GetFileContents(file_path.c_str()); + if (out->bytes.empty()) { + return false; + } + out->base = EPDF_LoadMemBaseDocument( + out->bytes.data(), static_cast(out->bytes.size()), nullptr); + if (!out->base) { + return false; + } + EPDFLayerOpenStatus status; + out->layer = EPDFLayer_OpenLayer(out->base, nullptr, nullptr, &status); + return out->layer && status == EPDFLayerOpenStatus_kSuccess; + } + + private: + std::vector saved_; +}; + +} // namespace + +TEST_F(EPDFPpoEmbedderTest, NamesTheTopLevelFieldsThePagesBring) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + EXPECT_EQ((std::vector{kPerson, kPlan, kAgree}), + ImportRoots(document(), {})); + EXPECT_EQ((std::vector{kPerson, kPlan}), + ImportRoots(document(), {0})); + EXPECT_EQ((std::vector{kPerson, kPlan, kAgree}), + ImportRoots(document(), {1})); + EXPECT_TRUE(ImportRoots(document(), {2}).empty()); + + EXPECT_EQ(L"person", PartialName(document(), kPerson)); + EXPECT_EQ(L"agree", PartialName(document(), kAgree)); + EXPECT_EQ(L"email", PartialName(document(), kEmail)); + EXPECT_EQ(L"", PartialName(document(), 3)); // a page + EXPECT_EQ(0ul, EPDFForm_GetFieldPartialName(document(), 99, nullptr, 0)); +} + +TEST_F(EPDFPpoEmbedderTest, BringsTheFieldsOfEveryPage) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + ScopedFPDFDocument dest(FPDF_CreateNewDocument()); + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), document(), nullptr, 0, 0)); + EXPECT_EQ(2, FPDF_GetPageCount(dest.get())); + EXPECT_EQ(AllPagesFields(), ReadFields(dest.get())); + + // The source's /AcroForm settings, on the one made for them. + RetainPtr acro_form = AcroFormOf(dest.get()); + ASSERT_TRUE(acro_form); + EXPECT_EQ(3u, acro_form->GetArrayFor("Fields")->size()); + EXPECT_EQ("/Helv 0 Tf 0 g", acro_form->GetByteStringFor("DA")); + EXPECT_EQ(1, acro_form->GetIntegerFor("Q")); + EXPECT_TRUE(acro_form->GetBooleanFor("NeedAppearances", false)); + EXPECT_FALSE(acro_form->KeyExist("SigFlags")); + RetainPtr fonts = + acro_form->GetDictFor("DR")->GetDictFor("Font"); + EXPECT_EQ("Helvetica", fonts->GetDictFor("Helv")->GetNameFor("BaseFont")); + EXPECT_EQ("Times-Roman", fonts->GetDictFor("F1")->GetNameFor("BaseFont")); + RetainPtr order = acro_form->GetArrayFor("CO"); + ASSERT_TRUE(order); + ASSERT_EQ(1u, order->size()); + EXPECT_EQ(L"email", order->GetDictAt(0)->GetUnicodeTextFor("T")); + // Their own settings, so none is set on the top-level fields. + RetainPtr person = + acro_form->GetArrayFor("Fields")->GetDictAt(0); + EXPECT_FALSE(person->KeyExist("DA")); + EXPECT_FALSE(person->KeyExist("Q")); + + ScopedFPDFDocument reopened = SaveAndReopen(dest.get()); + ASSERT_TRUE(reopened); + EXPECT_EQ(AllPagesFields(), ReadFields(reopened.get())); +} + +TEST_F(EPDFPpoEmbedderTest, BringsOnlyWhatIsAboveThePagesWidgets) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + ScopedFPDFDocument dest(FPDF_CreateNewDocument()); + const int second_page = 1; + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), document(), &second_page, 1, 0)); + const std::map expected = { + {L"person.email", {L"ann@example.com", 1, true}}, + {L"plan", {L"yearly", 1, true}}, + {L"agree", {L"Yes", 1, true}}, + }; + EXPECT_EQ(expected, ReadFields(dest.get())); + + // "person" lists only what came: email, not name. + RetainPtr person = + AcroFormOf(dest.get())->GetArrayFor("Fields")->GetDictAt(0); + ASSERT_TRUE(person); + EXPECT_EQ(L"person", person->GetUnicodeTextFor("T")); + ASSERT_EQ(1u, person->GetArrayFor("Kids")->size()); + + ScopedFPDFDocument reopened = SaveAndReopen(dest.get()); + ASSERT_TRUE(reopened); + EXPECT_EQ(expected, ReadFields(reopened.get())); +} + +TEST_F(EPDFPpoEmbedderTest, PointsCopiedParentsAtTheCopies) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + ScopedFPDFDocument dest(FPDF_CreateNewDocument()); + // A page first, so the copies' numbers differ from the source's. + FPDF_ClosePage(FPDFPage_New(dest.get(), 0, 300, 300)); + const int first_page = 0; + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), document(), &first_page, 1, 1)); + + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(dest.get()); + RetainPtr annots = + doc->GetPageDictionary(1)->GetArrayFor("Annots"); + ASSERT_EQ(4u, annots->size()); + // The name widget, found only through name's /Kids, has name's copy as its + // /Parent. + RetainPtr name = + annots->GetDictAt(0)->GetDictFor("Parent"); + ASSERT_TRUE(name); + EXPECT_EQ(L"name", name->GetUnicodeTextFor("T")); + EXPECT_EQ(annots->GetDictAt(0)->GetObjNum(), + name->GetArrayFor("Kids")->GetDictAt(0)->GetObjNum()); + // The pop-up's /Parent is the note's copy. + const CPDF_Reference* popup_parent = + annots->GetDictAt(3)->GetObjectFor("Parent")->AsReference(); + ASSERT_TRUE(popup_parent); + EXPECT_EQ(annots->GetDictAt(2)->GetObjNum(), popup_parent->GetRefObjNum()); +} + +TEST_F(EPDFPpoEmbedderTest, AddsToAFormAndRenamesAFontItUses) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + ScopedFPDFDocument dest(FPDF_CreateNewDocument()); + FPDF_ClosePage(FPDFPage_New(dest.get(), 0, 300, 300)); + // A form with "person", /F1 another font, and a /DA of its own. + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(dest.get()); + auto courier = doc->NewIndirect(); + courier->SetNewFor("Type", "Font"); + courier->SetNewFor("Subtype", "Type1"); + courier->SetNewFor("BaseFont", "Courier"); + auto existing = doc->NewIndirect(); + existing->SetNewFor("FT", "Tx"); + existing->SetNewFor("T", "person"); + auto acro_form = + doc->GetMutableRoot()->SetNewFor("AcroForm"); + acro_form->SetNewFor("Fields")->AppendNew( + doc, existing->GetObjNum()); + acro_form->SetNewFor("DR") + ->SetNewFor("Font") + ->SetNewFor("F1", doc, courier->GetObjNum()); + acro_form->SetNewFor("DA", "/F1 12 Tf 0 g"); + + // The caller renames the source's "person" first. + EXPECT_FALSE(EPDFForm_SetFieldName(document(), kPerson, + GetFPDFWideString(L"plan").get())); + ASSERT_TRUE(EPDFForm_SetFieldName(document(), kPerson, + GetFPDFWideString(L"person_2").get())); + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), document(), nullptr, 0, 1)); + + std::map expected = { + {L"person", {L"", 0, true}}, + {L"person_2.name", {L"Ann", 1, true}}, + {L"person_2.email", {L"ann@example.com", 1, true}}, + {L"plan", {L"yearly", 2, true}}, + {L"agree", {L"Yes", 1, true}}, + }; + EXPECT_EQ(expected, ReadFields(dest.get())); + + RetainPtr fonts_after = + AcroFormOf(dest.get())->GetDictFor("DR")->GetDictFor("Font"); + EXPECT_EQ("Courier", fonts_after->GetDictFor("F1")->GetNameFor("BaseFont")); + EXPECT_EQ("Times-Roman", + fonts_after->GetDictFor("F1_1")->GetNameFor("BaseFont")); + EXPECT_TRUE(fonts_after->KeyExist("Helv")); + RetainPtr fields = + AcroFormOf(dest.get())->GetArrayFor("Fields"); + ASSERT_EQ(4u, fields->size()); + // The source's /DA and /Q on its top-level fields, which said nothing. + RetainPtr person = fields->GetDictAt(1); + EXPECT_EQ(L"person_2", person->GetUnicodeTextFor("T")); + EXPECT_EQ("/Helv 0 Tf 0 g", person->GetByteStringFor("DA")); + EXPECT_EQ(1, person->GetIntegerFor("Q")); + // email's own /DA names the renamed font. + RetainPtr email = + person->GetArrayFor("Kids")->GetDictAt(1); + EXPECT_EQ(L"email", email->GetUnicodeTextFor("T")); + EXPECT_EQ("/F1_1 10 Tf 0 g", email->GetByteStringFor("DA")); + + ScopedFPDFDocument reopened = SaveAndReopen(dest.get()); + ASSERT_TRUE(reopened); + EXPECT_EQ(expected, ReadFields(reopened.get())); +} + +TEST_F(EPDFPpoEmbedderTest, CopiesIntoALayerInATransaction) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + LayerDoc dest; + ASSERT_TRUE(OpenLayer("hello_world.pdf", &dest)); + ASSERT_TRUE(EPDFLayer_BeginTransaction(dest.layer)); + ASSERT_TRUE(EPDFDoc_ImportPages(dest.layer, document(), nullptr, 0, 1)); + ASSERT_TRUE(EPDFLayer_CommitTransaction(dest.layer)); + EXPECT_EQ(3, FPDF_GetPageCount(dest.layer)); + EXPECT_EQ(AllPagesFields(), ReadFields(dest.layer)); + + ScopedFPDFDocument reopened = SaveAndReopen(dest.layer); + ASSERT_TRUE(reopened); + EXPECT_EQ(AllPagesFields(), ReadFields(reopened.get())); +} + +TEST_F(EPDFPpoEmbedderTest, CopiesOutOfALayerAsTheLayerReadsIt) { + LayerDoc src; + ASSERT_TRUE(OpenLayer("form_page_import.pdf", &src)); + ASSERT_TRUE(EPDFLayer_BeginTransaction(src.layer)); + ASSERT_TRUE(EPDFForm_SetFieldName(src.layer, kPerson, + GetFPDFWideString(L"applicant").get())); + ASSERT_TRUE(EPDFLayer_CommitTransaction(src.layer)); + + ScopedFPDFDocument dest(FPDF_CreateNewDocument()); + const int first_page = 0; + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), src.layer, &first_page, 1, 0)); + const std::map expected = { + {L"applicant.name", {L"Ann", 1, true}}, + {L"plan", {L"yearly", 1, true}}, + }; + EXPECT_EQ(expected, ReadFields(dest.get())); + ScopedFPDFDocument reopened = SaveAndReopen(dest.get()); + ASSERT_TRUE(reopened); + EXPECT_EQ(expected, ReadFields(reopened.get())); +} + +TEST_F(EPDFPpoEmbedderTest, RefusesBadArguments) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + ScopedFPDFDocument dest(FPDF_CreateNewDocument()); + const int out_of_range[] = {0, 2}; + EXPECT_FALSE(EPDFDoc_ImportPages(dest.get(), document(), out_of_range, 2, 0)); + const int negative = -1; + EXPECT_FALSE(EPDFDoc_ImportPages(dest.get(), document(), &negative, 1, 0)); + EXPECT_FALSE(EPDFDoc_ImportPages(dest.get(), document(), &negative, 0, 0)); + EXPECT_FALSE(EPDFDoc_ImportPages(document(), document(), nullptr, 0, 0)); + EXPECT_FALSE(EPDFDoc_ImportPages(nullptr, document(), nullptr, 0, 0)); + // Nothing was copied. + EXPECT_EQ(0, FPDF_GetPageCount(dest.get())); + EXPECT_FALSE(AcroFormOf(dest.get())); + EXPECT_EQ(0ul, EPDFDoc_GetImportFieldRoots(document(), out_of_range, 2, + nullptr, 0)); +} diff --git a/public/epdf_form.h b/public/epdf_form.h index 5e6f3c579b..ca75afe86c 100644 --- a/public/epdf_form.h +++ b/public/epdf_form.h @@ -692,6 +692,19 @@ EPDFForm_SetFieldName(FPDF_DOCUMENT document, uint32_t field_objnum, FPDF_WIDESTRING partial_name); +// Experimental EmbedPDF Extension API. +// Copy the field's own /T segment (the name EPDFForm_SetFieldName() sets, not +// the dotted path) of the field dictionary |field_objnum| into |buffer| as +// UTF-16LE, including the trailing NUL: for a field that isn't in a form +// model, such as a top-level field EPDFDoc_GetImportFieldRoots() names. Returns +// the byte length of the string, or 0 when the dictionary has no /T. |buffer| +// may be NULL to query the length. +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFForm_GetFieldPartialName(FPDF_DOCUMENT document, + uint32_t field_objnum, + FPDF_WCHAR* buffer, + unsigned long buflen); + // Experimental EmbedPDF Extension API. // Masked /Ff update: bits in |set_bits| are set, bits in |clear_bits| are // cleared. Family-DEFINING bits (Radio, Pushbutton, Combo) are immutable - diff --git a/public/epdf_ppo.h b/public/epdf_ppo.h new file mode 100644 index 0000000000..2a06a71548 --- /dev/null +++ b/public/epdf_ppo.h @@ -0,0 +1,83 @@ +// Copyright 2026 CloudPDF LTD +// SPDX-License-Identifier: Apache-2.0 + +#ifndef PUBLIC_EPDF_PPO_H_ +#define PUBLIC_EPDF_PPO_H_ + +#include + +// NOLINTNEXTLINE(build/include) +#include "fpdf_ppo.h" + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +// EmbedPDF's additions to public/fpdf_ppo.h: copying pages with the form +// fields of their widgets. +// +// A widget is a view of a form field: the field holds the name and the value, +// its widgets sit on pages. FPDF_ImportPagesByIndex() copies a page's widgets +// but not their fields, and leaves each copied widget's /Parent naming an +// object of the source document. +// +// The fields some pages bring are those above the widgets on them: each +// widget's field, that field's parent, and so on up to a top-level field. A +// widget with no parent and a field type (/FT) is its own top-level field. A +// widget's field is the one its /Parent names, or, when it names none, the +// field whose /Kids in the source's /AcroForm lists it. A field with no +// widget on those pages stays behind. + +// Experimental EmbedPDF Extension API. +// FPDF_ImportPagesByIndex() that brings the pages' form fields along: +// +// - Each field above the copied widgets is copied once. A copied field's +// /Kids lists what was copied below it: the widgets on the copied pages, +// and the fields above those. Each /Parent names a copy. +// - Each copied top-level field is added at the end of |dest_doc|'s +// /AcroForm /Fields, which is made when |dest_doc| has none. Names are +// kept: renaming a top-level field whose name |dest_doc| already uses is +// the caller's, in |src_doc| before the copy (EPDFForm_SetFieldName()). +// - What the source's /AcroForm gives its fields comes along: the /DR +// resources |dest_doc|'s /DR doesn't name (a font under a name +// |dest_doc| uses for another font gets a free name, and the /DA of the +// copied fields and widgets uses it); its /DA and /Q as a copied +// top-level field's own, when the field has none and |dest_doc|'s +// /AcroForm says otherwise; /NeedAppearances when it is set; the copied +// fields in /CO, in their order, after |dest_doc|'s; and SignaturesExist +// in /SigFlags when a signature field is copied. Never its /XFA. +// - A copied annotation's /Parent names the copy of what it named, or is +// removed when that wasn't copied: none names an object of |src_doc|. +// +// |page_indices| (|length| of them, 0-based) are the pages of |src_doc| to +// copy, in order; NULL copies every page, and |length| is then ignored. +// |index| is where the first goes in |dest_doc|. The documents must differ. +// As with FPDF_ImportPagesByIndex(), the copies may still read streams from +// |src_doc|: keep it open while |dest_doc| is in use. +// +// Returns TRUE on success. On FALSE, pages may already have been copied: on a +// layer document, the caller aborts its transaction. +FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV EPDFDoc_ImportPages(FPDF_DOCUMENT dest_doc, + FPDF_DOCUMENT src_doc, + const int* page_indices, + unsigned long length, + int index); + +// Experimental EmbedPDF Extension API. +// The top-level form fields EPDFDoc_ImportPages() brings with the pages at +// |page_indices| of |document| (as there; NULL: every page): the object +// numbers of their dictionaries, in the order it adds them to /Fields. Writes +// up to |buffer_count| of them to |buffer|, which may be NULL, and returns how +// many there are; 0 also for an index out of range. +FPDF_EXPORT unsigned long FPDF_CALLCONV +EPDFDoc_GetImportFieldRoots(FPDF_DOCUMENT document, + const int* page_indices, + unsigned long length, + uint32_t* buffer, + unsigned long buffer_count); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif // PUBLIC_EPDF_PPO_H_ diff --git a/testing/resources/form_page_import.in b/testing/resources/form_page_import.in new file mode 100644 index 0000000000..993851601b --- /dev/null +++ b/testing/resources/form_page_import.in @@ -0,0 +1,174 @@ +{{header}} +% A two-page form for copying pages with their fields (public/epdf_ppo.h). +% - "person" (5) holds "name" (6), whose widget (7, page 1) has no /Parent: +% only name's /Kids lists it, and "email" (8), merged with its widget +% (page 2), with a /DA of its own using /F1 and a calculate script (in /CO). +% - "plan" (10), a radio group with a widget on each page: monthly (11, +% page 1) and yearly (19, page 2), which is on. +% - "agree" (12), a check box merged with its widget (page 2), checked. +% - "hidden" (14), a text field with no widget: no page brings it. +% - A note (16, page 1) with a pop-up (17), whose /Parent is the note. +{{object 1 0}} << + /Type /Catalog + /Pages 2 0 R + /AcroForm << + /Fields [5 0 R 10 0 R 12 0 R 14 0 R] + /DA (/Helv 0 Tf 0 g) + /Q 1 + /DR << /Font << /Helv 20 0 R /F1 21 0 R >> >> + /NeedAppearances true + /CO [8 0 R] + >> +>> +endobj +{{object 2 0}} << + /Type /Pages + /Count 2 + /Kids [3 0 R 4 0 R] +>> +endobj +{{object 3 0}} << + /Type /Page + /Parent 2 0 R + /MediaBox [0 0 300 300] + /Annots [7 0 R 11 0 R 16 0 R 17 0 R] +>> +endobj +{{object 4 0}} << + /Type /Page + /Parent 2 0 R + /MediaBox [0 0 300 300] + /Annots [8 0 R 19 0 R 12 0 R] +>> +endobj +{{object 5 0}} << + /T (person) + /Kids [6 0 R 8 0 R] +>> +endobj +{{object 6 0}} << + /FT /Tx + /T (name) + /V (Ann) + /Parent 5 0 R + /Kids [7 0 R] +>> +endobj +{{object 7 0}} << + /Type /Annot + /Subtype /Widget + /Rect [20 250 280 280] + /P 3 0 R +>> +endobj +{{object 8 0}} << + /Type /Annot + /Subtype /Widget + /FT /Tx + /T (email) + /V (ann@example.com) + /DA (/F1 10 Tf 0 g) + /Parent 5 0 R + /Rect [20 250 280 280] + /P 4 0 R + /AA << /C << /S /JavaScript /JS (event.value = 1;) >> >> +>> +endobj +{{object 10 0}} << + /FT /Btn + /Ff 49152 + /T (plan) + /V /yearly + /Kids [11 0 R 19 0 R] +>> +endobj +{{object 11 0}} << + /Type /Annot + /Subtype /Widget + /Parent 10 0 R + /Rect [20 200 40 220] + /P 3 0 R + /AS /Off + /AP << /N << /monthly 22 0 R /Off 23 0 R >> >> +>> +endobj +{{object 12 0}} << + /Type /Annot + /Subtype /Widget + /FT /Btn + /T (agree) + /V /Yes + /AS /Yes + /Rect [20 150 40 170] + /P 4 0 R + /AP << /N << /Yes 22 0 R /Off 23 0 R >> >> +>> +endobj +{{object 14 0}} << + /FT /Tx + /T (hidden) + /V (kept back) +>> +endobj +{{object 16 0}} << + /Type /Annot + /Subtype /Text + /Rect [250 20 270 40] + /Contents (A note) + /P 3 0 R + /Popup 17 0 R +>> +endobj +{{object 17 0}} << + /Type /Annot + /Subtype /Popup + /Rect [150 20 250 80] + /Parent 16 0 R + /P 3 0 R +>> +endobj +{{object 19 0}} << + /Type /Annot + /Subtype /Widget + /Parent 10 0 R + /Rect [20 200 40 220] + /P 4 0 R + /AS /yearly + /AP << /N << /yearly 22 0 R /Off 23 0 R >> >> +>> +endobj +{{object 20 0}} << + /Type /Font + /Subtype /Type1 + /BaseFont /Helvetica +>> +endobj +{{object 21 0}} << + /Type /Font + /Subtype /Type1 + /BaseFont /Times-Roman +>> +endobj +{{object 22 0}} << + /Type /XObject + /Subtype /Form + /BBox [0 0 20 20] + {{streamlen}} +>> +stream +0 g 4 4 12 12 re f +endstream +endobj +{{object 23 0}} << + /Type /XObject + /Subtype /Form + /BBox [0 0 20 20] + {{streamlen}} +>> +stream +endstream +endobj +{{xref}} +{{trailer}} +{{startxref}} +%%EOF diff --git a/testing/resources/form_page_import.pdf b/testing/resources/form_page_import.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0bae05d4be132210d04609632aa6642773fe32ea GIT binary patch literal 3497 zcmb_f>yFx15dP0oj8Hy^&%8i2m1id8XwCxd&$%EN1w3j z19WDNjm;(5Bq|FmjOWbEH}lPmIiI{5w?w<&IRERPzyEEV!wB*R-Ad$2!BVFidIMi4 zPhq?Ql4kG_=4;5;DokM+swl|7O16tA^lg;{%RiGux7W=!nrgvWqS8#qXJC0vSZmTf zi{(b4px3;BhqcZW+6z`H2djU<4nUx)%plfaPh_g%{1R;e=s0=;I{Zxr_D><2ioz_< zAj~g7#sQqEjSM5SJV47Em9A7^LJd*L_+UF0V{^zo0g+(88i8J-oecCt4BL#L+GD|1 zCHOM(w~@>hWPTbZ7-$%SeSNco7)b&sI%rWsDuYnND%IPhv|r%UF(~P;8RI2vbeyjd zkR`+ghs9ALbb|7vWXjiuMM^l@b%>`!3^Fc~D_5zaR0_I`fv%N*2Mhi95%gFlswye7 zstO7_>o5ouV{q9}t{!t!S_1(qVK7`$QP{{L#UxSgjfoLnzyw*MSJkk{ZlaSG=E4$v zv~3Drl$Ed`jreH0A)o_#@g82hXaLx=r$k|rWiBJVBEf{I=b+M*zK;Au|EQvSl+Bj-wrZLSzknz-rf3x{VuknvD?7#_(ZZon zr||M6KFx`@N(G}N!K%sF#}3KUtuj}xZ(u$sy`wrlRU9DB28DKrmqFq|`&w_&xkDeB z>GLkTEO1mtNPNl&9W=cIyOh0AK`5_~9dm-x?eL3|Vvl01UIz0%I}rw+=$UUUd{+yc zg82V@a&kZnjWZ6m?u^P=bA}Y=fkGuKGGoFla$IGG#MQy^3@P z5|6$TlZ@l<)uY@bk!t&T!(wJVau*w;B7I4b9qLbn%hV%CqY*d7HNW42{aW73TQeB# z*SBD)dz{$qdl_vNdX=1`p|gV|v$& zi&_>=4S-K^sg@_q4bT2EIe(~(L&2zukGo0=BpzZA{Sre}$a>&-3~?d_rwr_)LGL&J z9tX`9DM@&za8oX%f7h&WoM+1OwVC1msTG1+2cLZc!iBiT$&4h!`wp{r*k{>MSe=PZ zaN`K>-n#eC>s~%xxu*mc4Lm4v$TuZm1|%t#u4~05T0Rf78WdkZ!m5B+?y4P+B z5T8*|-fM@E(&{5%yNO0!MBCB6l9}Sg$SYLkp)YG0=FRvu#%AG0Wv!{+$hdCqREzw6 zvv`kFbuT=+6X57}>y0XAs`ayqS9qHz24pzPQzbVIT>9YRpK$1z;+-yS<>F$wpHJ=9 zuXNrLHRng39#gf%kQ}$s**&1=d0h{dP|A8PQi>iB?_`6)v25t!BH7myoj!8rKqdx= z??C3@pg5Fu9o(b`dM>rrzKp*e#4LIq-b(E2iN1^5=0G+SN3pQRfjy@)Ji>8OPk2Z1 zInFWOlV`ibqZ~M1?^xFB9{cFiNMNhG8aV^EmV-RSUnnZYRY7c>YMle$Y%!U`4|s3x V8luQFXI3~v7k` Date: Wed, 28 Oct 2026 14:49:05 +0200 Subject: [PATCH 2/2] Share a /DR font only when it is defined the same way Two review findings in EPDFDoc_ImportPages: - Fonts were compared by subtype, base font and encoding only, so a font with other /Widths (or descriptor metrics, or a /ToUnicode map) was taken for the destination's and the copied fields drew with the wrong widths. A font is now shared only when both definitions match key for key, resolved all the way down; anything with a stream counts as different. Acrobat's identical /Helv stays shared. - The /DA rewrite had its own tokenizer, which read a comment between the font name and Tf as words: "/F1 % note" then "10 Tf" kept /F1 while the resource was renamed. It now reads with CPDF_SimpleParser, which skips comments as a viewer does. Regression tests for both, and one that a font defined the same way stays shared. --- fpdfsdk/epdf_ppo.cpp | 155 ++++++++++++++---------------- fpdfsdk/epdf_ppo_embeddertest.cpp | 120 +++++++++++++++++++---- 2 files changed, 172 insertions(+), 103 deletions(-) diff --git a/fpdfsdk/epdf_ppo.cpp b/fpdfsdk/epdf_ppo.cpp index b5d19d03c3..468f02e6f0 100644 --- a/fpdfsdk/epdf_ppo.cpp +++ b/fpdfsdk/epdf_ppo.cpp @@ -22,6 +22,7 @@ #include "core/fpdfapi/parser/cpdf_layer_document.h" #include "core/fpdfapi/parser/cpdf_number.h" #include "core/fpdfapi/parser/cpdf_reference.h" +#include "core/fpdfapi/parser/cpdf_simple_parser.h" #include "core/fpdfapi/parser/cpdf_stream.h" #include "core/fpdfapi/parser/cpdf_string.h" #include "core/fpdfapi/parser/fpdf_parser_utility.h" @@ -54,6 +55,9 @@ constexpr char kSigFlags[] = "SigFlags"; // The deepest field tree followed, as CPDF_InteractiveForm loads one. constexpr int kMaxFieldDepth = 32; +// The deepest a /DR font is compared (font, descriptor, an array in it). +constexpr int kMaxDefinitionDepth = 8; + // ISO 32000-2 table 224. constexpr int kSignaturesExist = 1; @@ -303,69 +307,65 @@ void CopyField(CPDF_Document* src, } } -// Whether two fonts' /Encoding entries are the same: none, the same name, or -// dictionaries with the same base encoding and /Differences (Acrobat gives -// every /Helv the same one). -bool IsSameEncoding(const CPDF_Object* a, const CPDF_Object* b) { - if (!a || !b) { - return !a && !b; - } - if (a->IsName() || b->IsName()) { - return a->IsName() && b->IsName() && a->GetString() == b->GetString(); - } - const CPDF_Dictionary* dict_a = a->AsDictionary(); - const CPDF_Dictionary* dict_b = b->AsDictionary(); - if (!dict_a || !dict_b || - dict_a->GetNameFor("BaseEncoding") != - dict_b->GetNameFor("BaseEncoding")) { - return false; - } - RetainPtr differences_a = - dict_a->GetArrayFor("Differences"); - RetainPtr differences_b = - dict_b->GetArrayFor("Differences"); - if (!differences_a || !differences_b) { - return !differences_a && !differences_b; - } - if (differences_a->size() != differences_b->size()) { +// Whether |a| and |b|, objects of two documents, define the same thing: the +// same values all the way down, references compared by what they resolve +// to. A stream never is (an embedded font program, a /ToUnicode map), nor +// anything nested deeper than kMaxDefinitionDepth. So two /DR fonts are +// shared only when every key agrees, /Widths and the descriptor's metrics +// included: Acrobat writes the same /Helv into every form. +bool IsSameDefinition(RetainPtr a, + RetainPtr b, + int depth) { + a = a ? a->GetDirect() : nullptr; + b = b ? b->GetDirect() : nullptr; + if (!a || !b || depth > kMaxDefinitionDepth || a->GetType() != b->GetType()) { return false; } - for (size_t i = 0; i < differences_a->size(); ++i) { - RetainPtr entry_a = differences_a->GetDirectObjectAt(i); - RetainPtr entry_b = differences_b->GetDirectObjectAt(i); - if (!entry_a || !entry_b || entry_a->GetType() != entry_b->GetType() || - entry_a->GetString() != entry_b->GetString()) { - return false; + switch (a->GetType()) { + case CPDF_Object::kBoolean: + case CPDF_Object::kNumber: + case CPDF_Object::kString: + case CPDF_Object::kName: + return a->GetString() == b->GetString(); + case CPDF_Object::kNullobj: + return true; + case CPDF_Object::kArray: { + const CPDF_Array* array_a = a->AsArray(); + const CPDF_Array* array_b = b->AsArray(); + if (array_a->size() != array_b->size()) { + return false; + } + for (size_t i = 0; i < array_a->size(); ++i) { + if (!IsSameDefinition(array_a->GetObjectAt(i), array_b->GetObjectAt(i), + depth + 1)) { + return false; + } + } + return true; } - } - return true; -} - -// Whether two /DR fonts draw the same: the same standard (not embedded) -// simple font, in the same encoding. -bool IsSameStandardFont(const CPDF_Dictionary* a, const CPDF_Dictionary* b) { - auto standard = [](const CPDF_Dictionary* font) { - if (!font || font->GetNameFor("Subtype") == "Type0") { - return false; + case CPDF_Object::kDictionary: { + const CPDF_Dictionary* dict_a = a->AsDictionary(); + const CPDF_Dictionary* dict_b = b->AsDictionary(); + if (dict_a->size() != dict_b->size()) { + return false; + } + CPDF_DictionaryLocker locker(dict_a); + for (const auto& [key, value] : locker) { + if (!IsSameDefinition(value, dict_b->GetObjectFor(key.AsStringView()), + depth + 1)) { + return false; + } + } + return true; } - RetainPtr descriptor = - font->GetDictFor("FontDescriptor"); - return !descriptor || !(descriptor->KeyExist("FontFile") || - descriptor->KeyExist("FontFile2") || - descriptor->KeyExist("FontFile3")); - }; - if (!standard(a) || !standard(b) || - a->GetNameFor("Subtype") != b->GetNameFor("Subtype") || - a->GetNameFor("BaseFont") != b->GetNameFor("BaseFont")) { - return false; + default: + return false; } - return IsSameEncoding(a->GetDirectObjectFor("Encoding").Get(), - b->GetDirectObjectFor("Encoding").Get()); } // The source's /DR entries the destination's /DR doesn't name, copied in. A -// font under a name the destination gives another font gets a free name, -// returned old name to new. +// font under a name the destination gives a font defined otherwise gets a +// free name, returned old name to new. std::map MergeResources( CPDF_Document* dest, CPDF_PageExporter& exporter, @@ -394,8 +394,8 @@ std::map MergeResources( ByteString target = name; if (dest_entries->KeyExist(name.AsStringView())) { if (category != kFont || - IsSameStandardFont(dest_entries->GetDictFor(name.AsStringView()), - ToDictionary(entry->GetDirect()))) { + IsSameDefinition(dest_entries->GetObjectFor(name.AsStringView()), + entry, 0)) { continue; } for (int n = 1; dest_entries->KeyExist(target.AsStringView()); ++n) { @@ -413,49 +413,36 @@ std::map MergeResources( } // |appearance| (a /DA string) with the font it sets renamed as |renamed| -// says: the name operand of its Tf. +// says: the name operand of its Tf. Read with PDFium's content tokenizer, so a +// comment between the operands is skipped as a viewer skips it. ByteString RenameAppearanceFont( const ByteString& appearance, const std::map& renamed) { if (renamed.empty()) { return appearance; } - // The tokens, as [start, end) offsets. - std::vector> tokens; - const ByteStringView text = appearance.AsStringView(); - for (size_t i = 0; i < text.GetLength();) { - const uint8_t c = text[i]; - if (PDFCharIsWhitespace(c)) { - ++i; - continue; - } - size_t end = i + 1; - if (c == '/' || !PDFCharIsDelimiter(c)) { - while (end < text.GetLength() && !PDFCharIsWhitespace(text[end]) && - !PDFCharIsDelimiter(text[end])) { - ++end; - } - } - tokens.emplace_back(i, end); - i = end; + // Each word, with the offset it starts at. + std::vector> words; + CPDF_SimpleParser parser(appearance.unsigned_span()); + for (ByteStringView word = parser.GetWord(); !word.IsEmpty(); + word = parser.GetWord()) { + words.emplace_back(word, parser.GetCurrentPosition() - word.GetLength()); } + const ByteStringView text = appearance.AsStringView(); ByteString result; size_t written = 0; - for (size_t t = 2; t < tokens.size(); ++t) { - const auto [start, end] = tokens[t - 2]; - if (text.Substr(tokens[t].first, tokens[t].second - tokens[t].first) != - "Tf" || - text[start] != '/') { + for (size_t i = 2; i < words.size(); ++i) { + const auto [font, start] = words[i - 2]; + if (words[i].first != "Tf" || font[0] != '/') { continue; } - const auto it = - renamed.find(PDF_NameDecode(text.Substr(start + 1, end - start - 1))); + const auto it = renamed.find(PDF_NameDecode(font.Substr(1))); if (it == renamed.end()) { continue; } result += text.Substr(written, start - written); result += ByteString("/") + PDF_NameEncode(it->second); - written = end; + written = start + font.GetLength(); } result += text.Substr(written); return result; diff --git a/fpdfsdk/epdf_ppo_embeddertest.cpp b/fpdfsdk/epdf_ppo_embeddertest.cpp index 9a80353dea..6d41b22093 100644 --- a/fpdfsdk/epdf_ppo_embeddertest.cpp +++ b/fpdfsdk/epdf_ppo_embeddertest.cpp @@ -113,6 +113,58 @@ RetainPtr AcroFormOf(FPDF_DOCUMENT document) { return doc->GetRoot()->GetDictFor("AcroForm"); } +// A Type 1 font of |doc| named |base_font|. +RetainPtr NewFont(CPDF_Document* doc, + const ByteString& base_font) { + auto font = doc->NewIndirect(); + font->SetNewFor("Type", "Font"); + font->SetNewFor("Subtype", "Type1"); + font->SetNewFor("BaseFont", base_font); + return font; +} + +// |font| told that its one character, the space, is |width| wide. +void SetSpaceWidth(CPDF_Dictionary* font, int width) { + font->SetNewFor("FirstChar", 32); + font->SetNewFor("LastChar", 32); + font->SetNewFor("Widths")->AppendNew(width); +} + +// A new one-page document with a form: a "person" field, |font| as /F1 and a +// /DA of its own. +ScopedFPDFDocument NewTargetForm(const ByteString& base_font, int width = 0) { + ScopedFPDFDocument dest(FPDF_CreateNewDocument()); + FPDF_ClosePage(FPDFPage_New(dest.get(), 0, 300, 300)); + CPDF_Document* doc = CPDFDocumentFromFPDFDocument(dest.get()); + RetainPtr font = NewFont(doc, base_font); + if (width) { + SetSpaceWidth(font.Get(), width); + } + auto existing = doc->NewIndirect(); + existing->SetNewFor("FT", "Tx"); + existing->SetNewFor("T", "person"); + auto acro_form = + doc->GetMutableRoot()->SetNewFor("AcroForm"); + acro_form->SetNewFor("Fields")->AppendNew( + doc, existing->GetObjNum()); + acro_form->SetNewFor("DR") + ->SetNewFor("Font") + ->SetNewFor("F1", doc, font->GetObjNum()); + acro_form->SetNewFor("DA", "/F1 12 Tf 0 g"); + return dest; +} + +// The /DA of the copy of "email", under the copied "person_2". +ByteString CopiedEmailAppearance(FPDF_DOCUMENT document) { + RetainPtr email = AcroFormOf(document) + ->GetArrayFor("Fields") + ->GetDictAt(1) + ->GetArrayFor("Kids") + ->GetDictAt(1); + EXPECT_EQ(L"email", email->GetUnicodeTextFor("T")); + return email->GetByteStringFor("DA"); +} + class EPDFPpoEmbedderTest : public EmbedderTest { protected: // |document| saved in full and opened again. @@ -270,25 +322,7 @@ TEST_F(EPDFPpoEmbedderTest, PointsCopiedParentsAtTheCopies) { TEST_F(EPDFPpoEmbedderTest, AddsToAFormAndRenamesAFontItUses) { ASSERT_TRUE(OpenDocument("form_page_import.pdf")); - ScopedFPDFDocument dest(FPDF_CreateNewDocument()); - FPDF_ClosePage(FPDFPage_New(dest.get(), 0, 300, 300)); - // A form with "person", /F1 another font, and a /DA of its own. - CPDF_Document* doc = CPDFDocumentFromFPDFDocument(dest.get()); - auto courier = doc->NewIndirect(); - courier->SetNewFor("Type", "Font"); - courier->SetNewFor("Subtype", "Type1"); - courier->SetNewFor("BaseFont", "Courier"); - auto existing = doc->NewIndirect(); - existing->SetNewFor("FT", "Tx"); - existing->SetNewFor("T", "person"); - auto acro_form = - doc->GetMutableRoot()->SetNewFor("AcroForm"); - acro_form->SetNewFor("Fields")->AppendNew( - doc, existing->GetObjNum()); - acro_form->SetNewFor("DR") - ->SetNewFor("Font") - ->SetNewFor("F1", doc, courier->GetObjNum()); - acro_form->SetNewFor("DA", "/F1 12 Tf 0 g"); + ScopedFPDFDocument dest = NewTargetForm("Courier"); // The caller renames the source's "person" first. EXPECT_FALSE(EPDFForm_SetFieldName(document(), kPerson, @@ -383,3 +417,51 @@ TEST_F(EPDFPpoEmbedderTest, RefusesBadArguments) { EXPECT_EQ(0ul, EPDFDoc_GetImportFieldRoots(document(), out_of_range, 2, nullptr, 0)); } + +TEST_F(EPDFPpoEmbedderTest, SharesAFontDefinedTheSameWay) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + // /F1 is Times-Roman in both, with the same keys. + ScopedFPDFDocument dest = NewTargetForm("Times-Roman"); + ASSERT_TRUE(EPDFForm_SetFieldName(document(), kPerson, + GetFPDFWideString(L"person_2").get())); + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), document(), nullptr, 0, 1)); + + RetainPtr fonts = + AcroFormOf(dest.get())->GetDictFor("DR")->GetDictFor("Font"); + EXPECT_FALSE(fonts->KeyExist("F1_1")); + EXPECT_EQ("/F1 10 Tf 0 g", CopiedEmailAppearance(dest.get())); +} + +TEST_F(EPDFPpoEmbedderTest, RenamesAFontWithOtherWidths) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + // The same Times-Roman, but its widths differ: 900 here, 200 there. + CPDF_Document* src = CPDFDocumentFromFPDFDocument(document()); + SetSpaceWidth(ToDictionary(src->GetMutableIndirectObject(21)).Get(), 900); + ScopedFPDFDocument dest = NewTargetForm("Times-Roman", 200); + ASSERT_TRUE(EPDFForm_SetFieldName(document(), kPerson, + GetFPDFWideString(L"person_2").get())); + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), document(), nullptr, 0, 1)); + + RetainPtr fonts = + AcroFormOf(dest.get())->GetDictFor("DR")->GetDictFor("Font"); + EXPECT_EQ(200, + fonts->GetDictFor("F1")->GetArrayFor("Widths")->GetIntegerAt(0)); + ASSERT_TRUE(fonts->KeyExist("F1_1")); + EXPECT_EQ(900, + fonts->GetDictFor("F1_1")->GetArrayFor("Widths")->GetIntegerAt(0)); + EXPECT_EQ("/F1_1 10 Tf 0 g", CopiedEmailAppearance(dest.get())); +} + +TEST_F(EPDFPpoEmbedderTest, RenamesTheFontOfAnAppearanceWithAComment) { + ASSERT_TRUE(OpenDocument("form_page_import.pdf")); + CPDF_Document* src = CPDFDocumentFromFPDFDocument(document()); + ToDictionary(src->GetMutableIndirectObject(kEmail)) + ->SetNewFor("DA", "/F1 % the field's font\n10 Tf 0 g"); + ScopedFPDFDocument dest = NewTargetForm("Courier"); + ASSERT_TRUE(EPDFForm_SetFieldName(document(), kPerson, + GetFPDFWideString(L"person_2").get())); + ASSERT_TRUE(EPDFDoc_ImportPages(dest.get(), document(), nullptr, 0, 1)); + + EXPECT_EQ("/F1_1 % the field's font\n10 Tf 0 g", + CopiedEmailAppearance(dest.get())); +}