| // 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); |
| } |