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] 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 {