Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 103 additions & 27 deletions core/fpdfdoc/cpdf_richtextwriter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,45 @@ WideString SpanStyle(const CPDF_RichTextStyleDelta& delta) {
return css;
}

// Acrobat's XHTML envelope around the paragraphs, the body styled with
// |body_css| when it says anything.
WideString RichContent(const WideString& body_css,
const CPDF_RichTextDocument& document) {
WideString xml =
L"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" "
L"xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" "
L"xfa:APIVersion=\"";
xml += kApiVersion;
xml += L"\" xfa:spec=\"2.0.2\"";
if (!body_css.IsEmpty()) {
xml += L" style=\"" + EscapeXml(body_css) + L"\"";
}
xml += L">";
for (const CPDF_RichTextParagraph& paragraph : document.paragraphs) {
xml += L"<p dir=\"";
xml += paragraph.props.rtl ? L"rtl" : L"ltr";
xml += L"\"";
const WideString css =
ParagraphStyle(paragraph.props, document.body_paragraph);
if (!css.IsEmpty()) {
xml += L" style=\"" + EscapeXml(css) + L"\"";
}
xml += L">";
for (const CPDF_RichTextRun& run : paragraph.runs) {
const WideString span_css = SpanStyle(run.style);
if (span_css.IsEmpty()) {
xml += EscapeXml(run.text);
} else {
xml += L"<span style=\"" + EscapeXml(span_css) + L"\">" +
EscapeXml(run.text) + L"</span>";
}
}
xml += L"</p>";
}
xml += L"</body>";
return xml;
}

} // namespace

CPDF_RichTextWriter::Prepared::Prepared() = default;
Expand Down Expand Up @@ -277,36 +316,61 @@ WideString CPDF_RichTextWriter::SerializeRC(
if (!extra_paragraph.IsEmpty()) {
body_css += L";" + extra_paragraph;
}
return RichContent(body_css, document);
}

WideString xml =
L"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" "
L"xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" "
L"xfa:APIVersion=\"";
xml += kApiVersion;
xml += L"\" xfa:spec=\"2.0.2\" style=\"" + EscapeXml(body_css) + L"\">";
for (const CPDF_RichTextParagraph& paragraph : document.paragraphs) {
xml += L"<p dir=\"";
xml += paragraph.props.rtl ? L"rtl" : L"ltr";
xml += L"\"";
const WideString css =
ParagraphStyle(paragraph.props, document.body_paragraph);
if (!css.IsEmpty()) {
xml += L" style=\"" + EscapeXml(css) + L"\"";
}
xml += L">";
for (const CPDF_RichTextRun& run : paragraph.runs) {
const WideString span_css = SpanStyle(run.style);
if (span_css.IsEmpty()) {
xml += EscapeXml(run.text);
} else {
xml += L"<span style=\"" + EscapeXml(span_css) + L"\">" +
EscapeXml(run.text) + L"</span>";
}
// static
WideString CPDF_RichTextWriter::SerializeCommentRC(
const CPDF_RichTextDocument& document) {
// What the parser reads from a comment that states nothing.
const CPDF_RichTextStyle assumed;
const CPDF_RichTextParagraphProps assumed_paragraph;
const CPDF_RichTextStyle& body = document.body;
WideString body_css;
if (body.size != assumed.size) {
AppendDeclaration(&body_css, L"font-size", Points(body.size));
}
if (document.body_paragraph.align != assumed_paragraph.align) {
AppendDeclaration(&body_css, L"text-align",
AlignName(document.body_paragraph.align));
}
if (body.color != assumed.color) {
AppendDeclaration(&body_css, L"color", HexColor(body.color));
}
if (body.weight != assumed.weight) {
AppendDeclaration(&body_css, L"font-weight", WeightName(body.weight));
}
if (body.italic != assumed.italic) {
AppendDeclaration(&body_css, L"font-style", L"italic");
}
if (body.family != assumed.family) {
AppendDeclaration(&body_css, L"font-family", FamilyValue(body.family));
}
if (body.decoration) {
AppendDeclaration(&body_css, L"text-decoration",
DecorationValue(body.decoration));
}
if (body.letter_spacing != assumed.letter_spacing) {
AppendDeclaration(&body_css, L"letter-spacing",
Points(body.letter_spacing));
}
if (body.horz_scale != assumed.horz_scale) {
AppendDeclaration(
&body_css, L"xfa-font-horizontal-scale",
WideString::Format(L"%d",
static_cast<int>(body.horz_scale * 100 + 0.5f)));
}
CPDF_RichTextParagraphProps without_align = document.body_paragraph;
without_align.align = assumed_paragraph.align;
const WideString extra_paragraph =
ParagraphStyle(without_align, assumed_paragraph);
if (!extra_paragraph.IsEmpty()) {
if (!body_css.IsEmpty()) {
body_css += L";";
}
xml += L"</p>";
body_css += extra_paragraph;
}
xml += L"</body>";
return xml;
return RichContent(body_css, document);
}

// static
Expand Down Expand Up @@ -444,3 +508,15 @@ bool CPDF_RichTextWriter::Apply(CPDF_Document* doc,
Publish(doc, annot_dict, std::move(prepared));
return true;
}

// static
void CPDF_RichTextWriter::ApplyComment(CPDF_Dictionary* annot_dict,
const CPDF_RichTextDocument& document) {
if (!annot_dict) {
return;
}
annot_dict->SetNewFor<CPDF_String>(
"RC", SerializeCommentRC(document).AsStringView());
annot_dict->SetNewFor<CPDF_String>("Contents",
ProjectPlainText(document).AsStringView());
}
11 changes: 11 additions & 0 deletions core/fpdfdoc/cpdf_richtextwriter.h
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// (Acrobat's XHTML envelope), /DS, /DA, /Contents and the appearance, in the
// two halves of the Phase C note §6. Prepare builds everything off to the
// side and touches nothing; Publish writes it all, or nothing was written.
// Any other markup annotation's rich text is its comment (ISO 32000-2
// §12.5.6.2), which draws nothing: ApplyComment writes /RC and /Contents.

#ifndef CORE_FPDFDOC_CPDF_RICHTEXTWRITER_H_
#define CORE_FPDFDOC_CPDF_RICHTEXTWRITER_H_
Expand Down Expand Up @@ -54,13 +56,22 @@ class CPDF_RichTextWriter {
CPDF_Dictionary* annot_dict,
const CPDF_RichTextDocument& document,
bool* degraded);
// A markup annotation's comment: /RC (SerializeCommentRC) and its plain
// projection in /Contents. The appearance stays: a comment draws nothing.
static void ApplyComment(CPDF_Dictionary* annot_dict,
const CPDF_RichTextDocument& document);

// Test-only: forwards to CPDF_AnnotFontMap::FailAfterStagedFontsForTesting.
static void FailAfterStagedFontsForTesting(int staged_count);

// The four forms, exposed for tests. |da_color| is the annotation's DA
// (border) colour, which DS repeats by Acrobat's convention.
static WideString SerializeRC(const CPDF_RichTextDocument& document);
// A comment's /RC, as Acrobat writes a note's: the body states only what
// differs from what a reader assumes when it states nothing (its own face,
// 12 pt, black, left-aligned), so a comment written with the body it was
// read with reads back the same.
static WideString SerializeCommentRC(const CPDF_RichTextDocument& document);
static WideString BuildDefaultStyle(const CPDF_RichTextDocument& document,
FX_ARGB da_color);
static WideString ProjectPlainText(const CPDF_RichTextDocument& document);
Expand Down
32 changes: 29 additions & 3 deletions fpdfsdk/epdf_annot.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1777,18 +1777,44 @@ EPDFAnnot_GetRichTextJSON(FPDF_ANNOTATION annot,

namespace {

// The rich text setters share one path: build the document, then write
// all four forms and the appearance through CPDF_RichTextWriter, or nothing.
// The kinds ISO 32000-2 Table 171 marks as markup: the ones /RC is an entry
// of (§12.5.6.2).
bool IsMarkupSubtype(const ByteString& subtype) {
static constexpr const char* kMarkup[] = {
"Text", "FreeText", "Line", "Square", "Circle",
"Polygon", "PolyLine", "Highlight", "Underline", "Squiggly",
"StrikeOut", "Caret", "Stamp", "Ink", "FileAttachment",
"Sound", "Redact", "Projection"};
for (const char* name : kMarkup) {
if (subtype == name) {
return true;
}
}
return false;
}

// The rich text setters share one path. A free text draws its rich text:
// all four forms and the appearance through CPDF_RichTextWriter, or
// nothing. Another markup annotation's is its comment: /RC and /Contents.
// Every other kind has none.
bool ApplyRichTextDocument(FPDF_ANNOTATION annot,
const CPDF_RichTextDocument& document) {
CPDF_AnnotContext* context = CPDFAnnotContextFromFPDFAnnotation(annot);
if (!context) {
return false;
}
RetainPtr<CPDF_Dictionary> annot_dict = context->GetMutableAnnotDict();
if (!annot_dict || annot_dict->GetNameFor("Subtype") != "FreeText") {
if (!annot_dict) {
return false;
}
const ByteString subtype = annot_dict->GetNameFor("Subtype");
if (subtype != "FreeText") {
if (!IsMarkupSubtype(subtype)) {
return false;
}
CPDF_RichTextWriter::ApplyComment(annot_dict.Get(), document);
return true;
}
CPDF_Document* doc = context->GetPage()->GetDocument();
if (!doc) {
return false;
Expand Down
124 changes: 124 additions & 0 deletions fpdfsdk/epdf_annot_embeddertest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4164,6 +4164,130 @@ TEST_F(EPDFAnnotEmbedderTest, SetRichTextXHTMLImportsOverDefaults) {
EXPECT_NE(std::string::npos, json.find("\"size\":16"));
}

// /RC is every markup annotation's comment (ISO 32000-2 §12.5.6.2). Acrobat
// 26 writes a note's with no body style, Enter as "&#10;" inside a span, and
// "font-size:-14.0pt" on every span, a size nothing can be: the body's
// stays.
TEST_F(EPDFAnnotEmbedderTest, AcrobatNoteCommentReadsAsRichText) {
ASSERT_TRUE(OpenDocument("text_rich_comment_acrobat.pdf"));
ScopedPage page = LoadScopedPage(0);
ASSERT_TRUE(page);
ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), 0));
ASSERT_EQ(FPDF_ANNOT_TEXT, FPDFAnnot_GetSubtype(annot.get()));
const std::string json = GetRichTextJson(annot.get());
EXPECT_NE(std::string::npos, json.find("\"source\":\"rc\"")) << json;
EXPECT_NE(std::string::npos,
json.find("\"body\":{\"family\":\"\",\"weight\":400,"
"\"italic\":false,\"size\":12,"))
<< json;
EXPECT_NE(std::string::npos,
json.find("{\"text\":\"test\",\"style\":{\"weight\":700,"
"\"italic\":false}}"))
<< json;
EXPECT_NE(std::string::npos,
json.find("{\"text\":\"you\",\"style\":{\"weight\":400,"
"\"italic\":false,\"decoration\":[\"underline\"]}}"))
<< json;
EXPECT_NE(std::string::npos, json.find("{\"text\":\"?\\r\\r\",")) << json;
EXPECT_NE(std::string::npos,
json.find("{\"text\":\"Test\",\"style\":{\"weight\":400,"
"\"italic\":true}}"))
<< json;
}

// Any other markup annotation's rich text is its comment, which draws
// nothing: /RC and /Contents, as Acrobat writes a note's, and no /DS, /DA or
// appearance. The body states only what differs from a comment that states
// nothing, so the one it was read with is written as nothing.
TEST_F(EPDFAnnotEmbedderTest, SetRichTextJSONWritesACommentAsAcrobatDoes) {
embedpdf_test::ScopedTestDocument doc(NewTestDocument());
ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 400, 400));
ScopedFPDFAnnotation annot(FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_TEXT));
ASSERT_TRUE(annot);
const FS_RECTF rect{50.0f, 320.0f, 70.0f, 300.0f};
ASSERT_TRUE(FPDFAnnot_SetRect(annot.get(), &rect));

ASSERT_TRUE(EPDFAnnot_SetRichTextJSON(
annot.get(),
"{\"paragraphs\":[{\"runs\":[{\"text\":\"Hello \"},"
"{\"text\":\"bold\",\"style\":{\"weight\":700}}]},"
"{\"runs\":[{\"text\":\"two\\rlines\"}]}]}"));
CPDF_AnnotContext* context = CPDFAnnotContextFromFPDFAnnotation(annot.get());
const CPDF_Dictionary* dict = context->GetAnnotDict();
EXPECT_EQ(
L"<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\" "
L"xmlns:xfa=\"http://www.xfa.org/schema/xfa-data/1.0/\" "
L"xfa:APIVersion=\"EmbedPDF:1.0\" xfa:spec=\"2.0.2\">"
L"<p dir=\"ltr\">Hello <span style=\"font-weight:bold\">bold</span></p>"
L"<p dir=\"ltr\">two&#13;lines</p></body>",
dict->GetUnicodeTextFor("RC"));
EXPECT_EQ(L"Hello bold\rtwo\rlines", dict->GetUnicodeTextFor("Contents"));
EXPECT_FALSE(dict->KeyExist("DS"));
EXPECT_FALSE(dict->KeyExist("DA"));
EXPECT_FALSE(dict->KeyExist("AP"));

const std::string json = GetRichTextJson(annot.get());
EXPECT_NE(std::string::npos, json.find("\"source\":\"rc\"")) << json;
EXPECT_NE(std::string::npos, json.find("\"family\":\"\",")) << json;
EXPECT_NE(std::string::npos,
json.find("{\"text\":\"bold\",\"style\":{\"weight\":700}}"))
<< json;

// A body states what differs from that: here its size and colour.
ScopedFPDFAnnotation square(
FPDFPage_CreateAnnot(page.get(), FPDF_ANNOT_SQUARE));
ASSERT_TRUE(square);
ASSERT_TRUE(EPDFAnnot_SetRichTextJSON(
square.get(),
"{\"body\":{\"size\":10,\"color\":\"#FF0000\"},"
"\"paragraphs\":[{\"runs\":[{\"text\":\"Note\"}]}]}"));
EXPECT_TRUE(CPDFAnnotContextFromFPDFAnnotation(square.get())
->GetAnnotDict()
->GetUnicodeTextFor("RC")
.Contains(L"xfa:spec=\"2.0.2\" "
L"style=\"font-size:10.0pt;color:#FF0000\">"
L"<p dir=\"ltr\">Note</p></body>"));
}

// Acrobat's comment, read and written back, reads the same: the redundant
// "normal" on its spans kept, its impossible size gone.
TEST_F(EPDFAnnotEmbedderTest, AcrobatCommentWrittenBackReadsTheSame) {
ASSERT_TRUE(OpenDocument("text_rich_comment_acrobat.pdf"));
ScopedPage page = LoadScopedPage(0);
ASSERT_TRUE(page);
ScopedFPDFAnnotation annot(FPDFPage_GetAnnot(page.get(), 0));
ASSERT_TRUE(annot);
const std::string before = GetRichTextJson(annot.get());
ASSERT_TRUE(EPDFAnnot_SetRichTextJSON(annot.get(), before.c_str()));
const std::string after = GetRichTextJson(annot.get());
// The diagnostics differ: the size Acrobat wrote is no longer there.
EXPECT_EQ(before.substr(0, before.find("\"diagnostics\"")),
after.substr(0, after.find("\"diagnostics\"")));
const CPDF_Dictionary* dict =
CPDFAnnotContextFromFPDFAnnotation(annot.get())->GetAnnotDict();
EXPECT_FALSE(dict->GetUnicodeTextFor("RC").Contains(L"-14.0pt"));
EXPECT_EQ(L"test test how are you?\r\rTest",
dict->GetUnicodeTextFor("Contents"));
}

// Only markup annotations have rich text: a link's or a popup's write is
// refused and changes nothing.
TEST_F(EPDFAnnotEmbedderTest, SetRichTextJSONRefusesKindsThatArentMarkup) {
embedpdf_test::ScopedTestDocument doc(NewTestDocument());
ScopedFPDFPage page(FPDFPage_New(doc.get(), 0, 400, 400));
for (FPDF_ANNOTATION_SUBTYPE subtype : {FPDF_ANNOT_LINK, FPDF_ANNOT_POPUP}) {
SCOPED_TRACE(testing::Message() << "subtype " << subtype);
ScopedFPDFAnnotation annot(FPDFPage_CreateAnnot(page.get(), subtype));
ASSERT_TRUE(annot);
const CPDF_Dictionary* dict =
CPDFAnnotContextFromFPDFAnnotation(annot.get())->GetAnnotDict();
const std::string before = SerializeObject(dict);
EXPECT_FALSE(EPDFAnnot_SetRichTextJSON(
annot.get(), "{\"paragraphs\":[{\"runs\":[{\"text\":\"a\"}]}]}"));
EXPECT_EQ(before, SerializeObject(dict));
}
}

TEST_F(EPDFAnnotEmbedderTest, SetRichTextJSONWithRegisteredBodyFont) {
ScopedRegisteredFonts scoped_fonts;
std::vector<uint8_t> roboto = LoadRobotoFontData();
Expand Down
13 changes: 10 additions & 3 deletions public/epdf_annot.h
Original file line number Diff line number Diff line change
Expand Up @@ -493,8 +493,9 @@ EPDFAnnot_GetRichContent(FPDF_ANNOTATION annot,
unsigned long buflen);

// Experimental EmbedPDF Extension API.
// The rich text of a FreeText annotation (/RC over /DS and /DA) or of a rich
// text field (/RV), as UTF-8 JSON: {"source":"rc"|"contents","body":{...},
// The rich text of a FreeText annotation (/RC over /DS and /DA), of another
// markup annotation's comment (/RC), or of a rich text field (/RV), as UTF-8
// JSON: {"source":"rc"|"contents","body":{...},
// "paragraphs":[{"align","dir","runs":[{"text","style":{deltas}}]}],
// "diagnostics":[{"code","detail"}]}. When there is no rich text the document
// is synthesised from /Contents (one paragraph per line break) and "source" is
Expand All @@ -521,7 +522,13 @@ EPDFAnnot_GetRichTextJSON(FPDF_ANNOTATION annot,
// change nothing. An unresolvable family is not a failure: it substitutes
// and the run reads back "degraded".
//
// annot - handle to a FreeText annotation.
// Any other markup annotation's rich text is its comment (ISO 32000-2
// §12.5.6.2), which draws nothing: /RC and /Contents are written, the body
// stating only what differs from a comment that states nothing (its reader's
// face, 12 pt, black, left-aligned), and the appearance stays. A kind that
// isn't markup (link, popup, widget, ...) returns false.
//
// annot - handle to a markup annotation.
// json_utf8 - the document, UTF-8 JSON.
FPDF_EXPORT FPDF_BOOL FPDF_CALLCONV
EPDFAnnot_SetRichTextJSON(FPDF_ANNOTATION annot, FPDF_BYTESTRING json_utf8);
Expand Down
Loading