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/3] 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/3] 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/3] 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) {