diff --git a/core/fpdftext/cpdf_textpage.cpp b/core/fpdftext/cpdf_textpage.cpp index cba1d1b6ac..f34a289d13 100644 --- a/core/fpdftext/cpdf_textpage.cpp +++ b/core/fpdftext/cpdf_textpage.cpp @@ -279,6 +279,48 @@ float GetFontSize(const CPDF_TextObject* text_object) { return has_font ? text_object->GetFontSize() : kDefaultFontSize; } +// EmbedPDF(bug-fix): the top and bottom of a character's loose box, in glyph +// units from its origin: the font's ascent and descent, bounded by /FontBBox +// (upstream ff5e04acc, 2558d5d97). A Type3 font takes /FontBBox itself, as the +// loose box did before ff5e04acc. A Type3 font declares no ascent or descent; +// CPDF_Font::CheckFontMetrics() takes the glyphs of char codes 65 and 103 ('A' +// and 'g'), but a Type3 font's codes are its own (Chrome and Skia number their +// glyphs), so those are any two glyphs, and the boxes of one line get uneven +// heights. /FontBBox may be given in either corner order. +std::pair LooseAscentDescent(const CPDF_Font& font) { + const FX_RECT& font_bbox = font.GetFontBBox(); + if (font.IsType3Font() && font_bbox.top != font_bbox.bottom) { + return {std::max(font_bbox.top, font_bbox.bottom), + std::min(font_bbox.top, font_bbox.bottom)}; + } + int ascent = font.GetTypeAscent(); + int descent = font.GetTypeDescent(); + if (font_bbox.top > font_bbox.bottom) { + ascent = std::min(ascent, font_bbox.top); + descent = std::max(descent, font_bbox.bottom); + } + return {ascent, descent}; +} + +// EmbedPDF(bug-fix): whether `text_obj` draws only spaces. A space has no ink, +// so its object has no width, but it separates words: a producer that draws +// each character as its own object (Chrome and Skia do) draws every space that +// way. Two spaces in a row collapse in CloseTempLine(). +bool DrawsOnlySpaces(const CPDF_TextObject* text_obj) { + RetainPtr font = text_obj->GetFont(); + if (!font || text_obj->CharCount() == 0) { + return false; + } + for (size_t i = 0; i < text_obj->CharCount(); ++i) { + const uint32_t char_code = text_obj->GetCharCode(i); + if (char_code == CPDF_Font::kInvalidCharCode || + font->UnicodeFromCharCode(char_code) != L" ") { + return false; + } + } + return true; +} + // EmbedPDF(text-geometry): a character's loose and tight boxes in its text // object's space. The tight box is upstream's char box, moved here from // ProcessTextObjectItems() so the text page and EPDFText_GetCharGeometry @@ -321,15 +363,8 @@ std::optional GetCharLocalBoxes( local_origin.x + offset_x, local_origin.y + offset_y + height, local_origin.x + offset_x + font_size, local_origin.y + offset_y); } else { - // GetLooseBounds()'s ascent and descent from the glyph's origin, bounded - // by /FontBBox (upstream ff5e04acc, 2558d5d97). - int ascent = font->GetTypeAscent(); - int descent = font->GetTypeDescent(); - const FX_RECT& font_bbox = font->GetFontBBox(); - if (font_bbox.top > font_bbox.bottom) { - ascent = std::min(ascent, font_bbox.top); - descent = std::max(descent, font_bbox.bottom); - } + // GetLooseBounds()'s ascent and descent from the glyph's origin. + const auto [ascent, descent] = LooseAscentDescent(*font); if (ascent != descent) { const float width = text_object->GetCharWidth(char_code); loose_box = CFX_FloatRect( @@ -373,13 +408,8 @@ CFX_FloatRect GetLooseBounds(const CPDF_TextPage::CharInfo& charinfo) { return char_box; } - int ascent = font->GetTypeAscent(); - int descent = font->GetTypeDescent(); - const FX_RECT& font_bbox = font->GetFontBBox(); - if (font_bbox.top > font_bbox.bottom) { - ascent = std::min(ascent, font_bbox.top); - descent = std::max(descent, font_bbox.bottom); - } + // EmbedPDF(bug-fix): /FontBBox for a Type3 font. + const auto [ascent, descent] = LooseAscentDescent(*font); if (ascent != descent) { // Compute `left` and `right` based on the individual character's `width`. float width = text_object->GetCharWidth(charinfo.char_code()); @@ -1022,7 +1052,11 @@ void CPDF_TextPage::ProcessTextObject( const CFX_Matrix& form_matrix, const CPDF_PageObjectHolder* obj_list, CPDF_PageObjectHolder::const_iterator obj_iter) { - if (fabs(text_obj->GetRect().Width()) < kSizeEpsilon) { + // EmbedPDF(bug-fix): a space-only object joins its line though it has no + // width, which upstream 49042621c drops it for. Where it doesn't separate + // text, ProcessTransformedTextObjects() drops it after all. + if (fabs(text_obj->GetRect().Width()) < kSizeEpsilon && + !DrawsOnlySpaces(text_obj)) { return; } @@ -1223,7 +1257,11 @@ void CPDF_TextPage::ReverseTempTextBufs(size_t char_list_index, void CPDF_TextPage::ProcessTransformedTextObjects() { for (const TransformedTextObject& obj : text_objects_) { CPDF_TextObject* const text_obj = obj.text_obj_; - if (fabs(text_obj->GetRect().Width()) < kSizeEpsilon) { + // EmbedPDF(bug-fix): a space-only object stays where it separates text + // on its line; at either end of the line it goes, as upstream drops it. + if (fabs(text_obj->GetRect().Width()) < kSizeEpsilon && + (!DrawsOnlySpaces(text_obj) || &obj == &text_objects_.front() || + &obj == &text_objects_.back())) { continue; } diff --git a/fpdfsdk/epdf_text_embeddertest.cpp b/fpdfsdk/epdf_text_embeddertest.cpp index 41d4da9ddd..b8c64a5815 100644 --- a/fpdfsdk/epdf_text_embeddertest.cpp +++ b/fpdfsdk/epdf_text_embeddertest.cpp @@ -99,7 +99,8 @@ TEST_F(EPDFTextEmbedderTest, GetCharGeometry) { // A text object with no width is left out of the text page (upstream // 49042621c, Feb 2026), so a zero font size gives no characters to measure. -// An empty character box is covered by FPDFTextEmbedderTest.Bug399689604. +// Only a space between words stays (ChromeType3TextKeepsSpaces). An empty +// character box is covered by FPDFTextEmbedderTest.Bug399689604. TEST_F(EPDFTextEmbedderTest, ZeroFontSizeTextHasNoChars) { ASSERT_TRUE(OpenDocument("font_size_zero.pdf")); ScopedPage page = LoadScopedPage(0); @@ -110,6 +111,56 @@ TEST_F(EPDFTextEmbedderTest, ZeroFontSizeTextHasNoChars) { EXPECT_EQ(0, FPDFText_CountChars(text_page.get())); } +// Chrome and Skia print each glyph as its own text object, so a space is an +// object without ink, and so without width. It still separates the words. +TEST_F(EPDFTextEmbedderTest, ChromeType3TextKeepsSpaces) { + ASSERT_TRUE(OpenDocument("embedpdf_chrome_type3_text.pdf")); + ScopedPage page = LoadScopedPage(0); + ASSERT_TRUE(page); + + ScopedFPDFTextPage text_page(FPDFText_LoadPage(page.get())); + ASSERT_TRUE(text_page); + + static constexpr char kExpected[] = "to pixels"; + static constexpr int kExpectedSize = std::size(kExpected); + ASSERT_EQ(kExpectedSize - 1, FPDFText_CountChars(text_page.get())); + unsigned short buffer[16] = {}; + ASSERT_EQ(kExpectedSize, + FPDFText_GetText(text_page.get(), 0, std::size(buffer), buffer)); + EXPECT_THAT(pdfium::span(buffer).first(), + ElementsAreArray(kExpected)); +} + +// A Type3 font has no ascent or descent, so its characters' loose boxes take +// the top and bottom of /FontBBox: one line, one height, whatever each glyph's +// ink. The space between the words has the same box. +TEST_F(EPDFTextEmbedderTest, ChromeType3TextLooseBoxesShareTheLine) { + ASSERT_TRUE(OpenDocument("embedpdf_chrome_type3_text.pdf")); + ScopedPage page = LoadScopedPage(0); + ASSERT_TRUE(page); + + ScopedFPDFTextPage text_page(FPDFText_LoadPage(page.get())); + ASSERT_TRUE(text_page); + + // 20pt text on a baseline at y = 50; /FontBBox runs from 200 below the + // baseline to 750 above it, in thousandths of the font size. + static constexpr float kTop = 65.0f; + static constexpr float kBottom = 46.0f; + const int char_count = FPDFText_CountChars(text_page.get()); + ASSERT_GT(char_count, 0); + for (int i = 0; i < char_count; ++i) { + FS_RECTF loose; + ASSERT_TRUE(FPDFText_GetLooseCharBox(text_page.get(), i, &loose)); + EXPECT_FLOAT_EQ(kTop, loose.top) << i; + EXPECT_FLOAT_EQ(kBottom, loose.bottom) << i; + + EPDF_CHAR_GEOMETRY geometry = {}; + ASSERT_TRUE(EPDFText_GetCharGeometry(text_page.get(), i, &geometry)); + EXPECT_FLOAT_EQ(kTop, geometry.loose_box.top) << i; + EXPECT_FLOAT_EQ(kBottom, geometry.loose_box.bottom) << i; + } +} + TEST_F(EPDFTextEmbedderTest, GetRotatedCharGeometry) { ASSERT_TRUE(OpenDocument("rotated_text.pdf")); ScopedPage page = LoadScopedPage(0); diff --git a/fpdfsdk/fpdf_text_embeddertest.cpp b/fpdfsdk/fpdf_text_embeddertest.cpp index 697b0803d6..9adbfc7fae 100644 --- a/fpdfsdk/fpdf_text_embeddertest.cpp +++ b/fpdfsdk/fpdf_text_embeddertest.cpp @@ -2464,15 +2464,15 @@ TEST_F(FPDFTextEmbedderTest, Bug444176962) { ScopedFPDFTextPage textpage(FPDFText_LoadPage(page.get())); ASSERT_TRUE(textpage); - // TODO(crbug.com/444176962): FPDFText_GetText() should return 10. - // kMissingSpaceResult should be replaced with kResult as "local act" + // EmbedPDF(bug-fix): the space drawn as its own object between the words + // stays (crbug.com/444176962), so FPDFText_GetText() returns 10. unsigned short buffer[128] = {}; - static constexpr char kMissingSpaceResult[] = "localact"; - static constexpr int kExpectedSize = std::size(kMissingSpaceResult); + static constexpr char kResult[] = "local act"; + static constexpr int kExpectedSize = std::size(kResult); ASSERT_EQ(kExpectedSize, FPDFText_GetText(textpage.get(), 0, std::size(buffer), buffer)); EXPECT_THAT(pdfium::span(buffer).first(), - ElementsAreArray(kMissingSpaceResult)); + ElementsAreArray(kResult)); } TEST_F(FPDFTextEmbedderTest, Bug491161396) { diff --git a/testing/resources/embedpdf_chrome_type3_text.in b/testing/resources/embedpdf_chrome_type3_text.in new file mode 100644 index 0000000000..7825634d12 --- /dev/null +++ b/testing/resources/embedpdf_chrome_type3_text.in @@ -0,0 +1,205 @@ +{{header}} +{{object 1 0}} << + /Type /Catalog + /Pages 2 0 R +>> +endobj +{{object 2 0}} << + /Type /Pages + /Count 1 + /Kids [3 0 R] +>> +endobj +{{object 3 0}} << + /Type /Page + /Parent 2 0 R + /MediaBox [0 0 200 100] + /Resources << + /Font << + /F1 4 0 R + >> + >> + /Contents 8 0 R +>> +endobj +% Printed the way Chrome and Skia print text: a Type3 font in a y-down glyph +% space whose char codes number its glyphs, a descriptor without /Ascent or +% /Descent, one glyph per Tj, and a space drawn as an inkless glyph of its +% own. The heading's CSS letter-spacing (-0.03em) puts each glyph 0.6 short of +% its /Widths advance. +{{object 4 0}} << + /Type /Font + /Subtype /Type3 + /FontMatrix [0.0005 0 0 -0.0005 0 0] + /FontBBox [0 400 1200 -1500] + /FirstChar 65 + /LastChar 231 + /Widths [ + 1200 0 0 0 1200 0 0 600 0 0 0 600 0 0 0 1200 0 0 1100 0 0 0 0 0 + 0 1100 0 0 0 0 0 0 0 0 0 0 0 0 1100 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 + 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 400 + ] + /Encoding << + /Type /Encoding + /Differences [65 /g41 69 /g45 72 /g48 76 /g4C 80 /g50 83 /g53 90 /g5A 103 /g67 231 /gE7] + >> + /CharProcs 5 0 R + /ToUnicode 6 0 R + /FontDescriptor 7 0 R + /Resources << >> +>> +endobj +{{object 5 0}} << + /g41 10 0 R + /g45 11 0 R + /g48 12 0 R + /g4C 13 0 R + /g50 14 0 R + /g53 15 0 R + /g5A 16 0 R + /g67 17 0 R + /gE7 18 0 R +>> +endobj +{{object 6 0}} << + {{streamlen}} +>> +stream +/CIDInit /ProcSet findresource begin +12 dict begin +begincmap +/CIDSystemInfo << + /Registry (Adobe) + /Ordering (UCS) + /Supplement 0 +>> def +/CMapName /Adobe-Identity-UCS def +/CMapType 2 def +1 begincodespacerange +<00> +endcodespacerange +9 beginbfchar +<41> <006F> +<45> <0065> +<48> <0069> +<4C> <006C> +<50> <0070> +<53> <0073> +<5A> <0074> +<67> <0078> + <0020> +endbfchar +endcmap +CMapName currentdict /CMap defineresource pop +end +end +endstream +endobj +{{object 7 0}} << + /Type /FontDescriptor + /FontName /AAAAAA+ChromeType3 + /Flags 4 + /ItalicAngle 0 + /CapHeight 1400 + /StemV 80 +>> +endobj +{{object 8 0}} << + {{streamlen}} +>> +stream +1 0 0 -1 0 100 cm +BT +/F1 20 Tf +1 0 0 -1 10 50 Tm +<5A> Tj +10.4 0 Td <41> Tj +11.4 0 Td Tj +3.4 0 Td <50> Tj +11.4 0 Td <48> Tj +5.4 0 Td <67> Tj +10.4 0 Td <45> Tj +11.4 0 Td <4C> Tj +5.4 0 Td <53> Tj +ET +endstream +endobj +{{object 10 0}} << + {{streamlen}} +>> +stream +1200 0 d0 +100 -1000 1000 1000 re f +endstream +endobj +{{object 11 0}} << + {{streamlen}} +>> +stream +1200 0 d0 +100 -1000 1000 1000 re f +endstream +endobj +{{object 12 0}} << + {{streamlen}} +>> +stream +600 0 d0 +100 -1400 400 1400 re f +endstream +endobj +{{object 13 0}} << + {{streamlen}} +>> +stream +600 0 d0 +100 -1500 400 1500 re f +endstream +endobj +{{object 14 0}} << + {{streamlen}} +>> +stream +1200 0 d0 +100 -1000 1000 1400 re f +endstream +endobj +{{object 15 0}} << + {{streamlen}} +>> +stream +1100 0 d0 +100 -1000 900 1000 re f +endstream +endobj +{{object 16 0}} << + {{streamlen}} +>> +stream +1100 0 d0 +100 -1300 900 1300 re f +endstream +endobj +{{object 17 0}} << + {{streamlen}} +>> +stream +1100 0 d0 +100 -1000 900 1000 re f +endstream +endobj +{{object 18 0}} << + {{streamlen}} +>> +stream +400 0 d0 +endstream +endobj +{{xref}} +{{trailer}} +{{startxref}} +%%EOF diff --git a/testing/resources/embedpdf_chrome_type3_text.pdf b/testing/resources/embedpdf_chrome_type3_text.pdf new file mode 100644 index 0000000000..1f2c2e5736 Binary files /dev/null and b/testing/resources/embedpdf_chrome_type3_text.pdf differ