| // Copyright 2026 The PDFium Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| #include "core/fpdfdoc/cpvt_section.h" | |
| #include <memory> | |
| #include "core/fpdfapi/font/cpdf_font.h" | |
| #include "core/fpdfapi/page/cpdf_pagemodule.h" | |
| #include "core/fpdfapi/parser/cpdf_test_document.h" | |
| #include "core/fpdfdoc/cpvt_fontmap.h" | |
| #include "core/fpdfdoc/cpvt_stub_provider.h" | |
| #include "core/fpdfdoc/cpvt_variabletext.h" | |
| #include "core/fxcrt/cfx_bidi_resolver.h" | |
| #include "core/fxcrt/check_op.h" | |
| #include "core/fxcrt/fx_codepage.h" | |
| #include "core/fxcrt/retain_ptr.h" | |
| #include "testing/gtest/include/gtest/gtest.h" | |
| namespace { | |
| constexpr uint16_t kHebrewAlef = 0x05D0; | |
| constexpr uint16_t kHebrewBet = 0x05D1; | |
| constexpr uint16_t kHebrewGimel = 0x05D2; | |
| enum class TextContent { kEnglishAndHebrew, kHebrewAndEnglish }; | |
| struct LayoutTestData { | |
| TextContent content; | |
| CFX_BidiResolver::ParagraphDirection direction; | |
| pdfium::span<const int> expected_visual_order; | |
| }; | |
| // For `TextContent::kEnglishAndHebrew`: `kAuto` uses the Unicode Bidirectional | |
| // Algorithm, which resolves to LTR because the first strong character is | |
| // English. The RTL run [w4, sp4, w5] (indices 6, 7, 8) has its words ordered | |
| // from right to left. | |
| constexpr int kEnglishAndHebrewLtr[] = {0, 1, 2, 3, 4, 5, 8, 7, 6}; | |
| constexpr int kEnglishAndHebrewRtl[] = {8, 7, 6, 5, 0, 1, 2, 3, 4}; | |
| // For `TextContent::kHebrewAndEnglish`: `kAuto` uses the Unicode Bidirectional | |
| // Algorithm, which resolves to RTL because the first strong character is | |
| // Hebrew. The RTL runs [w0, sp0, w1] and [w4, sp4, w5] have their words ordered | |
| // from right to left. | |
| constexpr int kHebrewAndEnglishRtl[] = {6, 7, 8, 5, 4, 3, 2, 1, 0}; | |
| constexpr int kHebrewAndEnglishLtr[] = {4, 3, 2, 1, 0, 5, 6, 7, 8}; | |
| } // namespace | |
| class CPVT_SectionTest : public testing::Test { | |
| public: | |
| void SetUp() override { | |
| pdfium::InitializePageModule(); | |
| test_doc_ = std::make_unique<CPDF_TestDocument>(); | |
| font_ = CPDF_Font::GetStockFont(test_doc_.get(), "Helvetica"); | |
| font_map_ = std::make_unique<CPVT_FontMap>(test_doc_.get(), nullptr, font_, | |
| "Helvetica"); | |
| provider_ = std::make_unique<CPVT_StubProvider>(font_map_.get()); | |
| } | |
| void TearDown() override { | |
| provider_.reset(); | |
| font_map_.reset(); | |
| font_.Reset(); | |
| test_doc_.reset(); | |
| pdfium::DestroyPageModule(); | |
| } | |
| protected: | |
| void PopulateSectionWithText(CPVT_Section& section, | |
| const std::vector<uint32_t>& text) { | |
| section.SetPlace(CPVT_WordPlace(0, 0, -1)); | |
| for (size_t i = 0; i < text.size(); ++i) { | |
| section.AddWord(CPVT_WordPlace(0, 0, i), | |
| CPVT_WordInfo(text[i], FX_Charset::kDefault, 0)); | |
| } | |
| } | |
| void PopulateSectionWithEnglishAndHebrew(CPVT_Section& section) { | |
| // "A B C " (3 English words + spaces) + "H1 H2" (Alef + space + Bet) | |
| PopulateSectionWithText( | |
| section, {'A', ' ', 'B', ' ', 'C', ' ', kHebrewAlef, ' ', kHebrewBet}); | |
| } | |
| void PopulateSectionWithHebrewAndEnglish(CPVT_Section& section) { | |
| // "H1 H2 H3 " (3 Hebrew words + spaces) + "A B" (2 English words) | |
| PopulateSectionWithText(section, {kHebrewAlef, ' ', kHebrewBet, ' ', | |
| kHebrewGimel, ' ', 'A', ' ', 'B'}); | |
| } | |
| void SetVariableTextDefaults(CPVT_VariableText& vt) { | |
| vt.SetFontSize(10.0f); | |
| vt.SetPlateRect(CFX_FloatRect(0, 0, 1000, 1000)); | |
| } | |
| void PopulateSectionWithHello(CPVT_Section& section) { | |
| section.SetPlace(CPVT_WordPlace(0, 0, -1)); | |
| section.AddWord(CPVT_WordPlace(0, 0, 0), | |
| CPVT_WordInfo('h', FX_Charset::kDefault, 0)); | |
| section.AddWord(CPVT_WordPlace(0, 0, 1), | |
| CPVT_WordInfo('e', FX_Charset::kDefault, 0)); | |
| section.AddWord(CPVT_WordPlace(0, 0, 2), | |
| CPVT_WordInfo('l', FX_Charset::kDefault, 0)); | |
| section.AddWord(CPVT_WordPlace(0, 0, 3), | |
| CPVT_WordInfo('l', FX_Charset::kDefault, 0)); | |
| section.AddWord(CPVT_WordPlace(0, 0, 4), | |
| CPVT_WordInfo('o', FX_Charset::kDefault, 0)); | |
| CPVT_LineInfo lineinfo; | |
| lineinfo.nBeginWordIndex = 0; | |
| lineinfo.nEndWordIndex = 4; | |
| section.AddLine(lineinfo); | |
| section.ResetLinePlace(); | |
| } | |
| std::unique_ptr<CPDF_TestDocument> test_doc_; | |
| RetainPtr<CPDF_Font> font_; | |
| std::unique_ptr<CPVT_FontMap> font_map_; | |
| std::unique_ptr<CPVT_StubProvider> provider_; | |
| }; | |
| class CPVT_SectionLayoutTest | |
| : public CPVT_SectionTest, | |
| public testing::WithParamInterface<LayoutTestData> {}; | |
| TEST_F(CPVT_SectionTest, ClearLeftWords) { | |
| CPVT_VariableText vt(provider_.get()); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| PopulateSectionWithHello(section); | |
| // Clear "hel" [0, 2] by passing in a placeholder for the previous section. | |
| section.ClearWords( | |
| CPVT_WordRange(CPVT_WordPlace(-1, 0, -1), CPVT_WordPlace(0, 0, 2))); | |
| ASSERT_EQ(2, section.GetWordArraySize()); | |
| const CPVT_WordInfo* word0 = section.GetWordFromArray(0); | |
| ASSERT_TRUE(word0); | |
| EXPECT_EQ('l', word0->Word); | |
| const CPVT_WordInfo* word1 = section.GetWordFromArray(1); | |
| ASSERT_TRUE(word1); | |
| EXPECT_EQ('o', word1->Word); | |
| } | |
| TEST_F(CPVT_SectionTest, ClearRightWords) { | |
| CPVT_VariableText vt(provider_.get()); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| PopulateSectionWithHello(section); | |
| // Clear "llo" [2, 4] by passing in a placeholder for the next section. | |
| section.ClearWords( | |
| CPVT_WordRange(CPVT_WordPlace(0, 0, 1), CPVT_WordPlace(1, 0, -1))); | |
| ASSERT_EQ(2, section.GetWordArraySize()); | |
| const CPVT_WordInfo* word0 = section.GetWordFromArray(0); | |
| ASSERT_TRUE(word0); | |
| EXPECT_EQ('h', word0->Word); | |
| const CPVT_WordInfo* word1 = section.GetWordFromArray(1); | |
| ASSERT_TRUE(word1); | |
| EXPECT_EQ('e', word1->Word); | |
| } | |
| TEST_F(CPVT_SectionTest, ClearMidWords) { | |
| CPVT_VariableText vt(provider_.get()); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| PopulateSectionWithHello(section); | |
| // Clear "ell" [1, 3] by passing in the word range (0, 3]. | |
| section.ClearWords( | |
| CPVT_WordRange(CPVT_WordPlace(0, 0, 0), CPVT_WordPlace(0, 0, 3))); | |
| ASSERT_EQ(2, section.GetWordArraySize()); | |
| const CPVT_WordInfo* word0 = section.GetWordFromArray(0); | |
| ASSERT_TRUE(word0); | |
| EXPECT_EQ('h', word0->Word); | |
| const CPVT_WordInfo* word1 = section.GetWordFromArray(1); | |
| ASSERT_TRUE(word1); | |
| EXPECT_EQ('o', word1->Word); | |
| } | |
| TEST_F(CPVT_SectionTest, ClearAllWords) { | |
| CPVT_VariableText vt(provider_.get()); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| PopulateSectionWithHello(section); | |
| // Clear all words by passing in placeholders for the previous and next | |
| // sections. | |
| section.ClearWords( | |
| CPVT_WordRange(CPVT_WordPlace(-1, 0, -1), CPVT_WordPlace(1, 0, -1))); | |
| EXPECT_EQ(0, section.GetWordArraySize()); | |
| } | |
| TEST_P(CPVT_SectionLayoutTest, OutputLines) { | |
| const LayoutTestData& data = GetParam(); | |
| CPVT_VariableText vt(provider_.get()); | |
| SetVariableTextDefaults(vt); | |
| vt.SetTextDirection(data.direction); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| if (data.content == TextContent::kEnglishAndHebrew) { | |
| PopulateSectionWithEnglishAndHebrew(section); | |
| } else { | |
| CHECK_EQ(data.content, TextContent::kHebrewAndEnglish); | |
| PopulateSectionWithHebrewAndEnglish(section); | |
| } | |
| section.Rearrange(); | |
| ASSERT_EQ(static_cast<int>(data.expected_visual_order.size()), | |
| section.GetWordArraySize()); | |
| for (size_t i = 0; i < data.expected_visual_order.size() - 1; ++i) { | |
| EXPECT_LT( | |
| section.GetWordFromArray(data.expected_visual_order[i])->fWordX, | |
| section.GetWordFromArray(data.expected_visual_order[i + 1])->fWordX); | |
| } | |
| } | |
| INSTANTIATE_TEST_SUITE_P( | |
| CPVT_SectionTest, | |
| CPVT_SectionLayoutTest, | |
| testing::Values( | |
| LayoutTestData{TextContent::kEnglishAndHebrew, | |
| CFX_BidiResolver::ParagraphDirection::kAuto, | |
| kEnglishAndHebrewLtr}, | |
| LayoutTestData{TextContent::kEnglishAndHebrew, | |
| CFX_BidiResolver::ParagraphDirection::kLeftToRight, | |
| kEnglishAndHebrewLtr}, | |
| LayoutTestData{TextContent::kEnglishAndHebrew, | |
| CFX_BidiResolver::ParagraphDirection::kRightToLeft, | |
| kEnglishAndHebrewRtl}, | |
| LayoutTestData{TextContent::kHebrewAndEnglish, | |
| CFX_BidiResolver::ParagraphDirection::kAuto, | |
| kHebrewAndEnglishRtl}, | |
| LayoutTestData{TextContent::kHebrewAndEnglish, | |
| CFX_BidiResolver::ParagraphDirection::kLeftToRight, | |
| kHebrewAndEnglishLtr}, | |
| LayoutTestData{TextContent::kHebrewAndEnglish, | |
| CFX_BidiResolver::ParagraphDirection::kRightToLeft, | |
| kHebrewAndEnglishRtl})); | |
| TEST_F(CPVT_SectionTest, OutputLines_Multiline_EnglishAndHebrew) { | |
| CPVT_VariableText vt(provider_.get()); | |
| vt.SetFontSize(10.0f); | |
| // Narrow width to force word wrap (stub font chars are 0.1 wide) | |
| vt.SetPlateRect(CFX_FloatRect(0, 0, 0.45f, 1000)); | |
| vt.SetAutoReturn(true); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| PopulateSectionWithEnglishAndHebrew(section); | |
| section.Rearrange(); | |
| // With SetAutoReturn(true) and a constrained plate width, the text wraps | |
| // into 3 lines: | |
| // Line 1: "A B " | |
| // Line 2: "C H1 " | |
| // Line 3: "H2" | |
| // The Unicode Bidirectional Algorithm resolves the paragraph direction to LTR | |
| // (based on the first strong character 'A'). | |
| // When resolving visual runs for physical line 2, the algorithm maintains | |
| // the paragraph's overall LTR context. The spaces are resolved as LTR, and | |
| // the X coordinates correctly progress LTR (X increases). | |
| // Line 1: LTR | |
| for (int i = 0; i < 3; ++i) { | |
| EXPECT_LT(section.GetWordFromArray(i)->fWordX, | |
| section.GetWordFromArray(i + 1)->fWordX); | |
| } | |
| // Line 2: Correctly resolved as LTR using paragraph context | |
| for (int i = 4; i < 7; ++i) { | |
| EXPECT_LT(section.GetWordFromArray(i)->fWordX, | |
| section.GetWordFromArray(i + 1)->fWordX); | |
| } | |
| } | |
| TEST_F(CPVT_SectionTest, OutputLines_EmptySection) { | |
| CPVT_VariableText vt(provider_.get()); | |
| SetVariableTextDefaults(vt); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| EXPECT_EQ(0, section.GetWordArraySize()); | |
| EXPECT_EQ(0, section.GetLineArraySize()); | |
| CPVT_FloatRect rect = section.Rearrange(); | |
| // OutputLines() computes non-zero line height based on font metrics. | |
| EXPECT_GT(rect.Height(), 0.0f); | |
| // After calling Rearrange(), there is now an empty line, even though there | |
| // are no words. | |
| EXPECT_EQ(0, section.GetWordArraySize()); | |
| EXPECT_EQ(1, section.GetLineArraySize()); | |
| const CPVT_Section::Line* line = section.GetLineFromArray(0); | |
| ASSERT_TRUE(line); | |
| EXPECT_EQ(-1, line->line_info_.nBeginWordIndex); | |
| EXPECT_EQ(-1, line->line_info_.nEndWordIndex); | |
| EXPECT_FLOAT_EQ(0.0f, line->line_info_.fLineWidth); | |
| } | |
| TEST_F(CPVT_SectionTest, OutputLines_ParagraphSeparator) { | |
| CPVT_VariableText vt(provider_.get()); | |
| SetVariableTextDefaults(vt); | |
| vt.Initialize(); | |
| CPVT_Section section(&vt); | |
| // 0x001D (Group Separator) has Bidi_Class=Paragraph_Separator in Unicode. | |
| // When processed by ICU within a section line, ubidi_countRuns() resolves to | |
| // zero visual runs. | |
| PopulateSectionWithText(section, {0x001D, 0x1D1D}); | |
| section.Rearrange(); | |
| ASSERT_EQ(2, section.GetWordArraySize()); | |
| // Layout should safely fall back to sequential LTR ordering (monotonically | |
| // increasing X coordinates) rather than failing BiDi run extraction. | |
| EXPECT_LT(section.GetWordFromArray(0)->fWordX, | |
| section.GetWordFromArray(1)->fWordX); | |
| } | |
| TEST_F(CPVT_SectionTest, OutputLines_Multiline_CurrencySymbol) { | |
| CPVT_VariableText vt(provider_.get()); | |
| vt.SetFontSize(10.0f); | |
| // Narrow width to force word wrap (stub font chars are 0.1 wide at font size | |
| // 10). | |
| vt.SetPlateRect(CFX_FloatRect(0, 0, 0.35f, 1000)); | |
| vt.SetAutoReturn(true); | |
| vt.Initialize(); | |
| // Test '$' (0x0024): prefix currency symbol does not divide from '1'. | |
| // With "A $1", "A " fits on line 0 (width 0.2), but adding "$1" (width 0.2) | |
| // exceeds 0.35. Since '$' is a prefix symbol, it stays with '1' on line 1. | |
| { | |
| CPVT_Section section(&vt); | |
| PopulateSectionWithText(section, {'A', ' ', '$', '1'}); | |
| section.Rearrange(); | |
| ASSERT_EQ(2, section.GetLineArraySize()); | |
| const CPVT_Section::Line* line0 = section.GetLineFromArray(0); | |
| ASSERT_TRUE(line0); | |
| EXPECT_EQ(0, line0->line_info_.nBeginWordIndex); | |
| EXPECT_EQ(1, line0->line_info_.nEndWordIndex); | |
| const CPVT_Section::Line* line1 = section.GetLineFromArray(1); | |
| ASSERT_TRUE(line1); | |
| EXPECT_EQ(2, line1->line_info_.nBeginWordIndex); | |
| EXPECT_EQ(3, line1->line_info_.nEndWordIndex); | |
| } | |
| // Test Euro (0x0080): defined as a currency symbol in IsCurrencySymbol(). | |
| // Like '$', it does not divide from '1' and stays with '1' on line 1. | |
| { | |
| CPVT_Section section(&vt); | |
| PopulateSectionWithText(section, {'A', ' ', 0x0080, '1'}); | |
| section.Rearrange(); | |
| ASSERT_EQ(2, section.GetLineArraySize()); | |
| const CPVT_Section::Line* line0 = section.GetLineFromArray(0); | |
| ASSERT_TRUE(line0); | |
| EXPECT_EQ(0, line0->line_info_.nBeginWordIndex); | |
| EXPECT_EQ(1, line0->line_info_.nEndWordIndex); | |
| const CPVT_Section::Line* line1 = section.GetLineFromArray(1); | |
| ASSERT_TRUE(line1); | |
| EXPECT_EQ(2, line1->line_info_.nBeginWordIndex); | |
| EXPECT_EQ(3, line1->line_info_.nEndWordIndex); | |
| } | |
| } |