From 2f4e200beb3b1c18d50f2f7be8cf42552f1c2628 Mon Sep 17 00:00:00 2001 From: KleiKodesh <211884665+KleiKodesh@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:31:46 +0200 Subject: [PATCH 1/4] Draw a plain borderless free text box as PDF.js does A free text box without /BS took the default 1 pt border even when its /Border array says 0, so a box PDF.js wrote (/Border [0 0 0], no /AP, no /RC) got a frame and a text plate inset by 2 pt on every side. Its rect fits the text as PDF.js measured it, so the text then wrapped and the second line was clipped away. - Without /BS, the box's border width is the /Border array's, as the callout already reads it. - A plain box (no /RC) whose border is 0 lays out as PDF.js draws it: lines break only at line breaks, text too wide or too tall for the box is drawn smaller to fit (CPDF_RichTextLayout::Options:: shrink_to_fit), and each paragraph reads in its text's direction. --- .../cpdf_appearancegenerator_freetext.cpp | 19 ++++++++-- .../cpdf_appearancegenerator_unittest.cpp | 35 +++++++++++++++++++ core/fpdfdoc/cpdf_richtextlayout.cpp | 12 +++++++ core/fpdfdoc/cpdf_richtextlayout.h | 4 +++ 4 files changed, 68 insertions(+), 2 deletions(-) diff --git a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp index 3c65f94bf..0d32fe656 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp @@ -413,8 +413,15 @@ PrepareRichFreeTextAPInternal(CPDF_Document* doc, prepared->footprint = inline_rotation ? envelope.box_rot.matrix.TransformRect(box) : box; } else { - const BorderStyleInfo border_style_info = - GetBorderStyleInfo(annot_dict->GetDictFor("BS")); + RetainPtr border_style = + annot_dict->GetDictFor("BS"); + BorderStyleInfo border_style_info = + GetBorderStyleInfo(border_style.Get()); + if (!border_style) { + // No /BS: the /Border array's width (ISO 32000-1 12.5.2), as the + // callout reads it. PDF.js writes /Border [0 0 0] for no border. + border_style_info.width = GetBorderWidth(annot_dict); + } const ShapeRotationInfo rot_info = GetShapeRotationInfo(annot_dict); const CFX_FloatRect rect = rot_info.bbox; const float half_border_width = border_style_info.width / 2.0f; @@ -448,6 +455,14 @@ PrepareRichFreeTextAPInternal(CPDF_Document* doc, if (text_area.Width() > 0 && text_area.Height() > 0) { CPDF_RichTextLayout::Options options; options.typographic_features = doc->GetTypographicFeaturesEnabled(); + // A plain box (no /RC) without a border is how PDF.js writes free text, + // with no appearance: draw it as PDF.js does, its lines broken only at + // line breaks and shrunk to fit, reading in its text's direction. + if (in.document->source == CPDF_RichTextDocument::Source::kContents && + !annot_dict->KeyExist("RC") && text_area == box) { + options.shrink_to_fit = true; + options.direction_from_text = true; + } CPDF_RichTextLayout layout(&map, options); arranged = layout.Arrange(*in.document, text_area, GetVerticalAlign(annot_dict)); diff --git a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp index 953e7558f..3886de064 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp @@ -17,6 +17,7 @@ #include "core/fpdfapi/parser/cpdf_stream.h" #include "core/fpdfapi/parser/cpdf_string.h" #include "core/fpdfapi/parser/cpdf_test_document.h" +#include "core/fxcrt/fx_string.h" #include "core/fxge/cfx_color.h" #include "testing/gtest/include/gtest/gtest.h" @@ -207,6 +208,40 @@ TEST_F(CPDFGenerateAPTest, GenerateEphemeralFreeTextAPDoesNotCreateAcroForm) { EXPECT_FALSE(annot_dict->KeyExist(pdfium::annotation::kAP)); } +// A plain box with /Border [0 0 0], as PDF.js writes free text: no border, +// and a line too wide for the box is drawn smaller instead of wrapping. +TEST_F(CPDFGenerateAPTest, FreeTextWithoutBorderShrinksToFit) { + CPDF_TestDocument doc; + doc.CreateNewDoc(); + auto annot_dict = MakeAnnotDict("FreeText", CFX_FloatRect(0, 0, 40, 16)); + annot_dict->SetNewFor("DA", "/Helv 12 Tf 0 g"); + annot_dict->SetNewFor(pdfium::annotation::kContents, + "hello world"); + annot_dict->SetFor(pdfium::annotation::kBorder, MakeNumberArray({0, 0, 0})); + + std::optional generated = + CPDF_AppearanceGenerator::GenerateEphemeralAnnotAP( + &doc, annot_dict.Get(), CPDF_Annot::Subtype::FREETEXT); + ASSERT_TRUE(generated.has_value()); + ASSERT_TRUE(generated->normal_stream); + ASSERT_TRUE(generated->normal_stream->IsMemoryBased()); + const pdfium::span data = + generated->normal_stream->GetInMemoryRawData(); + const ByteString content(data.data(), data.size()); + + EXPECT_FALSE(content.Contains(" w\n")); // no border stroke + // Drawn below 12 pt. + const std::optional tf = content.Find(" Tf\n"); + ASSERT_TRUE(tf.has_value()); + const ByteString before_tf = content.First(tf.value()); + const std::optional space = before_tf.ReverseFind(' '); + ASSERT_TRUE(space.has_value()); + const float size = + StringToFloat(before_tf.Substr(space.value() + 1).AsStringView()); + EXPECT_GT(size, 0.0f); + EXPECT_LT(size, 12.0f); +} + TEST_F(CPDFGenerateAPTest, GeneratePersistentFreeTextAPAfterDefaultAppearanceColorUpdate) { CPDF_TestDocument doc; diff --git a/core/fpdfdoc/cpdf_richtextlayout.cpp b/core/fpdfdoc/cpdf_richtextlayout.cpp index 1854e4b29..e2aef5f31 100644 --- a/core/fpdfdoc/cpdf_richtextlayout.cpp +++ b/core/fpdfdoc/cpdf_richtextlayout.cpp @@ -793,6 +793,7 @@ CPDF_RichTextLayout::Result CPDF_RichTextLayout::Arrange( } else { candidate_sizes.push_back(document.body.size); } + const bool shrink_to_fit = options_.shrink_to_fit && !auto_size; // Faces resolve once per request (family, weight, italic) over every // character styled with it, whatever the size attempt (C note §1.3): a @@ -939,6 +940,7 @@ CPDF_RichTextLayout::Result CPDF_RichTextLayout::Arrange( // The furthest opportunity whose ink fits. size_t best = length; bool fits_whole = + shrink_to_fit || Impl::InkWidth(segment, pos, length) <= avail + kEpsilon; if (!fits_whole) { best = 0; @@ -1130,6 +1132,16 @@ CPDF_RichTextLayout::Result CPDF_RichTextLayout::Arrange( widest > area_width + kEpsilon)) { continue; // try the next smaller size } + if (shrink_to_fit && attempt == 0 && content_height > 0 && widest > 0 && + (content_height > area_height + kEpsilon || + widest > area_width + kEpsilon)) { + const float scale = + std::min(area_width / widest, area_height / content_height); + if (scale > 0) { + candidate_sizes.push_back(body_size * scale); + continue; // once more at the size that fits + } + } // 7. Vertical alignment of the block, then absolute baselines. float top = 0; diff --git a/core/fpdfdoc/cpdf_richtextlayout.h b/core/fpdfdoc/cpdf_richtextlayout.h index e54a38c96..fd2976d19 100644 --- a/core/fpdfdoc/cpdf_richtextlayout.h +++ b/core/fpdfdoc/cpdf_richtextlayout.h @@ -77,6 +77,10 @@ class CPDF_RichTextLayout { // A paragraph reads in the direction of its first strong character (left // to right when it has none), as a form field does, instead of its dir. bool direction_from_text = false; + // Lines break only at line breaks, and a text wider or taller than the + // area is drawn smaller to fit, by the smaller of the two ratios (as + // PDF.js draws a plain free text box). Not for an auto size (size 0). + bool shrink_to_fit = false; }; struct Result { From 73844816698a1bbc3afcfcad77ea950fac4cd44a Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Sun, 15 Nov 2026 12:14:15 +0200 Subject: [PATCH 2/4] Keep the free text /Border width fix, drop the PDF.js layout A borderless plain box lays out like every other box: its lines wrap in the box. Choosing a layout from the border width would stop every borderless plain box from wrapping, whoever wrote it. The test now checks the width rule: /Border draws as the same width in /BS does, frame and text plate alike; 1 pt with neither; /BS first. --- .../cpdf_appearancegenerator_freetext.cpp | 14 +--- .../cpdf_appearancegenerator_unittest.cpp | 83 +++++++++++-------- core/fpdfdoc/cpdf_richtextlayout.cpp | 12 --- core/fpdfdoc/cpdf_richtextlayout.h | 4 - 4 files changed, 53 insertions(+), 60 deletions(-) diff --git a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp index 0d32fe656..52a47ebb7 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp @@ -415,11 +415,11 @@ PrepareRichFreeTextAPInternal(CPDF_Document* doc, } else { RetainPtr border_style = annot_dict->GetDictFor("BS"); - BorderStyleInfo border_style_info = - GetBorderStyleInfo(border_style.Get()); + BorderStyleInfo border_style_info = GetBorderStyleInfo(border_style.Get()); if (!border_style) { // No /BS: the /Border array's width (ISO 32000-1 12.5.2), as the - // callout reads it. PDF.js writes /Border [0 0 0] for no border. + // callout and the border reader take it; 1 pt when it has none. + // PDF.js writes /Border [0 0 0] for a box with no border. border_style_info.width = GetBorderWidth(annot_dict); } const ShapeRotationInfo rot_info = GetShapeRotationInfo(annot_dict); @@ -455,14 +455,6 @@ PrepareRichFreeTextAPInternal(CPDF_Document* doc, if (text_area.Width() > 0 && text_area.Height() > 0) { CPDF_RichTextLayout::Options options; options.typographic_features = doc->GetTypographicFeaturesEnabled(); - // A plain box (no /RC) without a border is how PDF.js writes free text, - // with no appearance: draw it as PDF.js does, its lines broken only at - // line breaks and shrunk to fit, reading in its text's direction. - if (in.document->source == CPDF_RichTextDocument::Source::kContents && - !annot_dict->KeyExist("RC") && text_area == box) { - options.shrink_to_fit = true; - options.direction_from_text = true; - } CPDF_RichTextLayout layout(&map, options); arranged = layout.Arrange(*in.document, text_area, GetVerticalAlign(annot_dict)); diff --git a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp index 3886de064..2d601222f 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp @@ -17,7 +17,6 @@ #include "core/fpdfapi/parser/cpdf_stream.h" #include "core/fpdfapi/parser/cpdf_string.h" #include "core/fpdfapi/parser/cpdf_test_document.h" -#include "core/fxcrt/fx_string.h" #include "core/fxge/cfx_color.h" #include "testing/gtest/include/gtest/gtest.h" @@ -208,38 +207,56 @@ TEST_F(CPDFGenerateAPTest, GenerateEphemeralFreeTextAPDoesNotCreateAcroForm) { EXPECT_FALSE(annot_dict->KeyExist(pdfium::annotation::kAP)); } -// A plain box with /Border [0 0 0], as PDF.js writes free text: no border, -// and a line too wide for the box is drawn smaller instead of wrapping. -TEST_F(CPDFGenerateAPTest, FreeTextWithoutBorderShrinksToFit) { - CPDF_TestDocument doc; - doc.CreateNewDoc(); - auto annot_dict = MakeAnnotDict("FreeText", CFX_FloatRect(0, 0, 40, 16)); - annot_dict->SetNewFor("DA", "/Helv 12 Tf 0 g"); - annot_dict->SetNewFor(pdfium::annotation::kContents, - "hello world"); - annot_dict->SetFor(pdfium::annotation::kBorder, MakeNumberArray({0, 0, 0})); - - std::optional generated = - CPDF_AppearanceGenerator::GenerateEphemeralAnnotAP( - &doc, annot_dict.Get(), CPDF_Annot::Subtype::FREETEXT); - ASSERT_TRUE(generated.has_value()); - ASSERT_TRUE(generated->normal_stream); - ASSERT_TRUE(generated->normal_stream->IsMemoryBased()); - const pdfium::span data = - generated->normal_stream->GetInMemoryRawData(); - const ByteString content(data.data(), data.size()); - - EXPECT_FALSE(content.Contains(" w\n")); // no border stroke - // Drawn below 12 pt. - const std::optional tf = content.Find(" Tf\n"); - ASSERT_TRUE(tf.has_value()); - const ByteString before_tf = content.First(tf.value()); - const std::optional space = before_tf.ReverseFind(' '); - ASSERT_TRUE(space.has_value()); - const float size = - StringToFloat(before_tf.Substr(space.value() + 1).AsStringView()); - EXPECT_GT(size, 0.0f); - EXPECT_LT(size, 12.0f); +// Without /BS, a free text box takes its border width from /Border (ISO +// 32000-1 12.5.2), as a callout does: it draws exactly as the same width in +// /BS does, frame and text plate alike. With neither, the border is 1 pt. +// PDF.js writes /Border [0 0 0] for a box with no border. +TEST_F(CPDFGenerateAPTest, FreeTextBorderWidthFromBorderArray) { + auto generate = [](std::optional border_width, + std::optional bs_width) { + CPDF_TestDocument doc; + doc.CreateNewDoc(); + auto annot_dict = MakeAnnotDict("FreeText", CFX_FloatRect(0, 0, 100, 30)); + annot_dict->SetNewFor("DA", "/Helv 12 Tf 0 g"); + annot_dict->SetNewFor(pdfium::annotation::kContents, + "hello world"); + if (border_width.has_value()) { + annot_dict->SetFor(pdfium::annotation::kBorder, + MakeNumberArray({0, 0, border_width.value()})); + } + if (bs_width.has_value()) { + auto border_style = annot_dict->SetNewFor("BS"); + border_style->SetNewFor("W", bs_width.value()); + } + std::optional generated = + CPDF_AppearanceGenerator::GenerateEphemeralAnnotAP( + &doc, annot_dict.Get(), CPDF_Annot::Subtype::FREETEXT); + if (!generated || !generated->normal_stream || + !generated->normal_stream->IsMemoryBased()) { + return ByteString(); + } + const pdfium::span data = + generated->normal_stream->GetInMemoryRawData(); + return ByteString(data.data(), data.size()); + }; + + // No border: no frame, and the text plate is the whole box. + const ByteString no_border = generate(0.0f, std::nullopt); + ASSERT_FALSE(no_border.IsEmpty()); + EXPECT_FALSE(no_border.Contains("re f*")); + EXPECT_TRUE(no_border.Contains("0 0 100 30 re W n\n")); + EXPECT_EQ(generate(std::nullopt, 0.0f), no_border); + + EXPECT_EQ(generate(3.0f, std::nullopt), generate(std::nullopt, 3.0f)); + + // Neither key: the 1 pt default, framed, the plate inset 2 pt. + const ByteString default_border = generate(std::nullopt, std::nullopt); + EXPECT_TRUE(default_border.Contains("re f*")); + EXPECT_TRUE(default_border.Contains("2 2 96 26 re W n\n")); + EXPECT_EQ(generate(std::nullopt, 1.0f), default_border); + + // /BS wins over /Border. + EXPECT_EQ(generate(0.0f, 3.0f), generate(std::nullopt, 3.0f)); } TEST_F(CPDFGenerateAPTest, diff --git a/core/fpdfdoc/cpdf_richtextlayout.cpp b/core/fpdfdoc/cpdf_richtextlayout.cpp index e2aef5f31..1854e4b29 100644 --- a/core/fpdfdoc/cpdf_richtextlayout.cpp +++ b/core/fpdfdoc/cpdf_richtextlayout.cpp @@ -793,7 +793,6 @@ CPDF_RichTextLayout::Result CPDF_RichTextLayout::Arrange( } else { candidate_sizes.push_back(document.body.size); } - const bool shrink_to_fit = options_.shrink_to_fit && !auto_size; // Faces resolve once per request (family, weight, italic) over every // character styled with it, whatever the size attempt (C note §1.3): a @@ -940,7 +939,6 @@ CPDF_RichTextLayout::Result CPDF_RichTextLayout::Arrange( // The furthest opportunity whose ink fits. size_t best = length; bool fits_whole = - shrink_to_fit || Impl::InkWidth(segment, pos, length) <= avail + kEpsilon; if (!fits_whole) { best = 0; @@ -1132,16 +1130,6 @@ CPDF_RichTextLayout::Result CPDF_RichTextLayout::Arrange( widest > area_width + kEpsilon)) { continue; // try the next smaller size } - if (shrink_to_fit && attempt == 0 && content_height > 0 && widest > 0 && - (content_height > area_height + kEpsilon || - widest > area_width + kEpsilon)) { - const float scale = - std::min(area_width / widest, area_height / content_height); - if (scale > 0) { - candidate_sizes.push_back(body_size * scale); - continue; // once more at the size that fits - } - } // 7. Vertical alignment of the block, then absolute baselines. float top = 0; diff --git a/core/fpdfdoc/cpdf_richtextlayout.h b/core/fpdfdoc/cpdf_richtextlayout.h index fd2976d19..e54a38c96 100644 --- a/core/fpdfdoc/cpdf_richtextlayout.h +++ b/core/fpdfdoc/cpdf_richtextlayout.h @@ -77,10 +77,6 @@ class CPDF_RichTextLayout { // A paragraph reads in the direction of its first strong character (left // to right when it has none), as a form field does, instead of its dir. bool direction_from_text = false; - // Lines break only at line breaks, and a text wider or taller than the - // area is drawn smaller to fit, by the smaller of the two ratios (as - // PDF.js draws a plain free text box). Not for an auto size (size 0). - bool shrink_to_fit = false; }; struct Result { From 92d8743d6a5d6f0bf1242ea362038a3577a25fb9 Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Sun, 15 Nov 2026 12:21:07 +0200 Subject: [PATCH 3/4] Describe the free text border width by ISO 32000 alone --- core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp | 5 ++--- core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp index 52a47ebb7..535954161 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp @@ -417,9 +417,8 @@ PrepareRichFreeTextAPInternal(CPDF_Document* doc, annot_dict->GetDictFor("BS"); BorderStyleInfo border_style_info = GetBorderStyleInfo(border_style.Get()); if (!border_style) { - // No /BS: the /Border array's width (ISO 32000-1 12.5.2), as the - // callout and the border reader take it; 1 pt when it has none. - // PDF.js writes /Border [0 0 0] for a box with no border. + // No /BS: the /Border array's width (ISO 32000-1 12.5.2, default + // [0 0 1]), as the callout and the border reader take it. border_style_info.width = GetBorderWidth(annot_dict); } const ShapeRotationInfo rot_info = GetShapeRotationInfo(annot_dict); diff --git a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp index 2d601222f..a1e6b3aaf 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp @@ -209,8 +209,8 @@ TEST_F(CPDFGenerateAPTest, GenerateEphemeralFreeTextAPDoesNotCreateAcroForm) { // Without /BS, a free text box takes its border width from /Border (ISO // 32000-1 12.5.2), as a callout does: it draws exactly as the same width in -// /BS does, frame and text plate alike. With neither, the border is 1 pt. -// PDF.js writes /Border [0 0 0] for a box with no border. +// /BS does, frame and text plate alike. With neither, the border is the +// default 1 pt. TEST_F(CPDFGenerateAPTest, FreeTextBorderWidthFromBorderArray) { auto generate = [](std::optional border_width, std::optional bs_width) { From 6bd0043d7e2b02907cd26a3ab404c19a7e96811f Mon Sep 17 00:00:00 2001 From: Bob Singor Date: Sun, 15 Nov 2026 12:31:12 +0200 Subject: [PATCH 4/4] Draw a free text border's dash from /BS or /Border The free text generator drew every border solid unless a plain box had a dashed /BS: a callout never dashed, and a plain box ignored a /Border dash array. The border reader and the viewer's drawing dash both. GetAnnotBorderStyleInfo reads an annotation's border as ISO 32000-1 12.5.2 does: its /BS, or else its /Border width and dash array (an empty one draws solid). The plain box draws its frame with it. A callout takes GetDashPatternString for its leader, the leader's ending and its box, as a line annotation dashes its line and endings, then resets the dash so the text's underlines draw solid. --- .../cpdf_appearancegenerator_common.cpp | 24 +++ .../cpdf_appearancegenerator_freetext.cpp | 20 +-- .../cpdf_appearancegenerator_internal.h | 1 + .../cpdf_appearancegenerator_unittest.cpp | 146 +++++++++++++++--- 4 files changed, 162 insertions(+), 29 deletions(-) diff --git a/core/fpdfdoc/cpdf_appearancegenerator_common.cpp b/core/fpdfdoc/cpdf_appearancegenerator_common.cpp index 6de4bfad6..b78910cd3 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_common.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_common.cpp @@ -323,6 +323,30 @@ BorderStyleInfo GetBorderStyleInfo(const CPDF_Dictionary* border_style_dict) { return border_style_info; } +// An annotation's border as ISO 32000-1 12.5.2 reads it: its /BS, or else +// its /Border array, whose width (default 1) and dash array draw as +// GetBorderWidth and GetDashPatternString take them; an empty dash array +// draws a solid line. +BorderStyleInfo GetAnnotBorderStyleInfo(const CPDF_Dictionary* annot_dict) { + RetainPtr border_style_dict = + annot_dict->GetDictFor("BS"); + if (border_style_dict) { + return GetBorderStyleInfo(border_style_dict.Get()); + } + BorderStyleInfo border_style_info; + border_style_info.width = GetBorderWidth(annot_dict); + RetainPtr border_array = + annot_dict->GetArrayFor(pdfium::annotation::kBorder); + if (border_array && border_array->size() == 4) { + std::vector dash = DashValues(border_array->GetArrayAt(3).Get()); + if (!dash.empty()) { + border_style_info.style = BorderStyle::kDash; + border_style_info.dash_pattern = std::move(dash); + } + } + return border_style_info; +} + AnnotationDimensionsAndColor GetAnnotationDimensionsAndColor( const CPDF_Dictionary* annot_dict) { const AppearanceCharacteristics appearance_characteristics = diff --git a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp index 535954161..0d14f13ab 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_freetext.cpp @@ -66,9 +66,10 @@ using appearance_generator::GenerateEphemeralDefaultAcroFormDict; using appearance_generator::GenerateExtGStateDict; using appearance_generator::GenerateResourcesDict; using appearance_generator::GenerateSquareAP; -using appearance_generator::GetBorderStyleInfo; +using appearance_generator::GetAnnotBorderStyleInfo; using appearance_generator::GetBorderWidth; using appearance_generator::GetColorStringWithDefault; +using appearance_generator::GetDashPatternString; using appearance_generator::GetDefaultAppearanceInfo; using appearance_generator::GetFontFromDrFontDictOrGenerateFallback; using appearance_generator::GetPaintOperatorString; @@ -144,6 +145,10 @@ CalloutEnvelope AppendCalloutEnvelope(fxcrt::ostringstream& appearance_stream, if (border_w > 0) { appearance_stream << border_w << " w\n"; } + // The border's dash (/BS, or else /Border) draws the leader, its ending and + // the box alike, as a line annotation's dash draws its line and endings. + const ByteString dash = GetDashPatternString(annot_dict); + appearance_stream << dash; // (e) Draw callout polyline. // Extend conn along the incoming segment direction by half the border @@ -236,6 +241,9 @@ CalloutEnvelope AppendCalloutEnvelope(fxcrt::ostringstream& appearance_stream, text_box_stroke.Deflate(half_bw, half_bw); WriteRect(appearance_stream, text_box_stroke) << " re " << GetPaintOperatorString(is_stroke_rect, is_fill_rect) << "\n"; + if (!dash.IsEmpty()) { + appearance_stream << "[] 0 d\n"; // the text's underlines draw solid + } return {text_box, border_w, box_rot}; } @@ -413,14 +421,8 @@ PrepareRichFreeTextAPInternal(CPDF_Document* doc, prepared->footprint = inline_rotation ? envelope.box_rot.matrix.TransformRect(box) : box; } else { - RetainPtr border_style = - annot_dict->GetDictFor("BS"); - BorderStyleInfo border_style_info = GetBorderStyleInfo(border_style.Get()); - if (!border_style) { - // No /BS: the /Border array's width (ISO 32000-1 12.5.2, default - // [0 0 1]), as the callout and the border reader take it. - border_style_info.width = GetBorderWidth(annot_dict); - } + const BorderStyleInfo border_style_info = + GetAnnotBorderStyleInfo(annot_dict); const ShapeRotationInfo rot_info = GetShapeRotationInfo(annot_dict); const CFX_FloatRect rect = rot_info.bbox; const float half_border_width = border_style_info.width / 2.0f; diff --git a/core/fpdfdoc/cpdf_appearancegenerator_internal.h b/core/fpdfdoc/cpdf_appearancegenerator_internal.h index 602ee8a49..53c38817f 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_internal.h +++ b/core/fpdfdoc/cpdf_appearancegenerator_internal.h @@ -147,6 +147,7 @@ CFX_PointF Midpoint(const CFX_PointF& first, const CFX_PointF& second); ByteString BlendModeToPDFName(BlendMode bm); BlendMode DefaultBlendModeFor(CPDF_Annot::Subtype subtype); BorderStyleInfo GetBorderStyleInfo(const CPDF_Dictionary* border_style_dict); +BorderStyleInfo GetAnnotBorderStyleInfo(const CPDF_Dictionary* annot_dict); AnnotationDimensionsAndColor GetAnnotationDimensionsAndColor( const CPDF_Dictionary* annot_dict); ShapeRotationInfo GetShapeRotationInfo(const CPDF_Dictionary* annot_dict); diff --git a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp index a1e6b3aaf..fd91f3047 100644 --- a/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp +++ b/core/fpdfdoc/cpdf_appearancegenerator_unittest.cpp @@ -3,6 +3,7 @@ #include "core/fpdfdoc/cpdf_appearancegenerator.h" +#include #include #include "constants/annotation_common.h" @@ -207,6 +208,69 @@ TEST_F(CPDFGenerateAPTest, GenerateEphemeralFreeTextAPDoesNotCreateAcroForm) { EXPECT_FALSE(annot_dict->KeyExist(pdfium::annotation::kAP)); } +namespace { + +// A free text box at |rect| reading "hello world" in 12 pt Helvetica. +RetainPtr MakeFreeTextDict(const CFX_FloatRect& rect) { + auto annot_dict = MakeAnnotDict("FreeText", rect); + annot_dict->SetNewFor("DA", "/Helv 12 Tf 0 g"); + annot_dict->SetNewFor(pdfium::annotation::kContents, + "hello world"); + return annot_dict; +} + +// A callout's free text box, its leader running from (10, 10) through a knee +// at (40, 40) to the box, ending in an open arrow. +RetainPtr MakeCalloutDict() { + auto annot_dict = MakeFreeTextDict(CFX_FloatRect(0, 0, 200, 100)); + annot_dict->SetNewFor("IT", "FreeTextCallout"); + annot_dict->SetFor("CL", MakeNumberArray({10, 10, 40, 40, 60, 40})); + annot_dict->SetNewFor("LE", "OpenArrow"); + return annot_dict; +} + +// /Border [0 0 |width|], with |dash| as its dash array when given. +void SetBorderArray(CPDF_Dictionary* annot_dict, + float width, + std::optional> dash = std::nullopt) { + RetainPtr border = MakeNumberArray({0, 0, width}); + if (dash.has_value()) { + border->Append(MakeNumberArray(dash.value())); + } + annot_dict->SetFor(pdfium::annotation::kBorder, std::move(border)); +} + +// /BS << /W |width| >>, dashed by |dash| when given. +void SetBorderStyle(CPDF_Dictionary* annot_dict, + float width, + std::optional> dash = std::nullopt) { + auto border_style = annot_dict->SetNewFor("BS"); + border_style->SetNewFor("W", width); + if (dash.has_value()) { + border_style->SetNewFor("S", "D"); + border_style->SetFor("D", MakeNumberArray(dash.value())); + } +} + +// The content an in-memory free text appearance draws, each in a document +// of its own; empty when none is drawn. +ByteString GenerateFreeTextAPContent(CPDF_Dictionary* annot_dict) { + CPDF_TestDocument doc; + doc.CreateNewDoc(); + std::optional generated = + CPDF_AppearanceGenerator::GenerateEphemeralAnnotAP( + &doc, annot_dict, CPDF_Annot::Subtype::FREETEXT); + if (!generated || !generated->normal_stream || + !generated->normal_stream->IsMemoryBased()) { + return ByteString(); + } + const pdfium::span data = + generated->normal_stream->GetInMemoryRawData(); + return ByteString(data.data(), data.size()); +} + +} // namespace + // Without /BS, a free text box takes its border width from /Border (ISO // 32000-1 12.5.2), as a callout does: it draws exactly as the same width in // /BS does, frame and text plate alike. With neither, the border is the @@ -214,30 +278,14 @@ TEST_F(CPDFGenerateAPTest, GenerateEphemeralFreeTextAPDoesNotCreateAcroForm) { TEST_F(CPDFGenerateAPTest, FreeTextBorderWidthFromBorderArray) { auto generate = [](std::optional border_width, std::optional bs_width) { - CPDF_TestDocument doc; - doc.CreateNewDoc(); - auto annot_dict = MakeAnnotDict("FreeText", CFX_FloatRect(0, 0, 100, 30)); - annot_dict->SetNewFor("DA", "/Helv 12 Tf 0 g"); - annot_dict->SetNewFor(pdfium::annotation::kContents, - "hello world"); + auto annot_dict = MakeFreeTextDict(CFX_FloatRect(0, 0, 100, 30)); if (border_width.has_value()) { - annot_dict->SetFor(pdfium::annotation::kBorder, - MakeNumberArray({0, 0, border_width.value()})); + SetBorderArray(annot_dict.Get(), border_width.value()); } if (bs_width.has_value()) { - auto border_style = annot_dict->SetNewFor("BS"); - border_style->SetNewFor("W", bs_width.value()); + SetBorderStyle(annot_dict.Get(), bs_width.value()); } - std::optional generated = - CPDF_AppearanceGenerator::GenerateEphemeralAnnotAP( - &doc, annot_dict.Get(), CPDF_Annot::Subtype::FREETEXT); - if (!generated || !generated->normal_stream || - !generated->normal_stream->IsMemoryBased()) { - return ByteString(); - } - const pdfium::span data = - generated->normal_stream->GetInMemoryRawData(); - return ByteString(data.data(), data.size()); + return GenerateFreeTextAPContent(annot_dict.Get()); }; // No border: no frame, and the text plate is the whole box. @@ -259,6 +307,64 @@ TEST_F(CPDFGenerateAPTest, FreeTextBorderWidthFromBorderArray) { EXPECT_EQ(generate(0.0f, 3.0f), generate(std::nullopt, 3.0f)); } +// Without /BS, a free text box's /Border dash array dashes its frame, as the +// same pattern in /BS does (ISO 32000-1 12.5.2); an empty one draws solid. +TEST_F(CPDFGenerateAPTest, FreeTextBorderDashFromBorderArray) { + auto with_border = [](std::optional> dash) { + auto annot_dict = MakeFreeTextDict(CFX_FloatRect(0, 0, 100, 30)); + SetBorderArray(annot_dict.Get(), 2, dash); + return GenerateFreeTextAPContent(annot_dict.Get()); + }; + auto with_border_style = [](std::optional> dash) { + auto annot_dict = MakeFreeTextDict(CFX_FloatRect(0, 0, 100, 30)); + SetBorderStyle(annot_dict.Get(), 2, dash); + return GenerateFreeTextAPContent(annot_dict.Get()); + }; + + const ByteString dashed = with_border(std::vector{4, 2}); + ASSERT_FALSE(dashed.IsEmpty()); + EXPECT_TRUE(dashed.Contains("[4 2 ] 0 d\n")); + EXPECT_EQ(with_border_style(std::vector{4, 2}), dashed); + + const ByteString empty_dash = with_border(std::vector{}); + EXPECT_FALSE(empty_dash.Contains(" 0 d\n")); + EXPECT_EQ(with_border_style(std::nullopt), empty_dash); +} + +// A callout's border dash draws its leader, the leader's ending and its box +// alike, from /BS or else /Border, as a line annotation's dash draws its line +// and endings. The text's underlines then draw solid. +TEST_F(CPDFGenerateAPTest, FreeTextCalloutBorderDash) { + auto callout = MakeCalloutDict(); + SetBorderStyle(callout.Get(), 1, std::vector{3, 1}); + const ByteString dashed = GenerateFreeTextAPContent(callout.Get()); + ASSERT_FALSE(dashed.IsEmpty()); + + const std::optional dash = dashed.Find("[3 1 ] 0 d\n"); + const std::optional leader = dashed.Find(" l S\n"); + const std::optional ending = dashed.Find(" cm\n"); + const std::optional solid = dashed.Find("[] 0 d\n"); + const std::optional text = dashed.Find("BT\n"); + ASSERT_TRUE(dash.has_value()); + ASSERT_TRUE(leader.has_value()); + ASSERT_TRUE(ending.has_value()); + ASSERT_TRUE(solid.has_value()); + ASSERT_TRUE(text.has_value()); + EXPECT_LT(dash.value(), leader.value()); + EXPECT_LT(leader.value(), ending.value()); + EXPECT_LT(ending.value(), solid.value()); + EXPECT_LT(solid.value(), text.value()); + + auto from_border = MakeCalloutDict(); + SetBorderArray(from_border.Get(), 1, std::vector{3, 1}); + EXPECT_EQ(GenerateFreeTextAPContent(from_border.Get()), dashed); + + auto solid_callout = MakeCalloutDict(); + SetBorderStyle(solid_callout.Get(), 1); + EXPECT_FALSE( + GenerateFreeTextAPContent(solid_callout.Get()).Contains(" 0 d\n")); +} + TEST_F(CPDFGenerateAPTest, GeneratePersistentFreeTextAPAfterDefaultAppearanceColorUpdate) { CPDF_TestDocument doc;