diff --git a/CHANGELOG.md b/CHANGELOG.md index 577841291..9d3755cfb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -89,6 +89,9 @@ The release run heads these entries with the version and opens a fresh ~50ms. - A pdf image placed on several pages is one image, and the pdf view honors `HtmlConfig::embed_images`, which it used to ignore. +- Text in a pdf no longer comes out in giant overlapping type on a browser with + a minimum font size, android's 8px included. Such a run is also placed more + accurately. ## v6.10.1 - 2026-08-21 diff --git a/src/odr/internal/html/pdf_file.cpp b/src/odr/internal/html/pdf_file.cpp index 167f55b68..69a9ac6af 100644 --- a/src/odr/internal/html/pdf_file.cpp +++ b/src/odr/internal/html/pdf_file.cpp @@ -1533,6 +1533,9 @@ class HtmlServiceImpl final : public HtmlService { const auto [m, invisible, is_matrix, asc, scale, ox, baseline, extent, font_pt, font_size_pt] = run_geometry(text, to_box); + // `add_position_classes` divides through the matrix, which carries + // `scale`; the uniform branch multiplies by `m.a`. + const double ascent_pt = asc * text.size * (is_matrix ? scale : 1.0); const std::string color_suffix = color_class(text, invisible, styles); // --- Visual layer --------------------------------------------------- @@ -1564,7 +1567,7 @@ class HtmlServiceImpl final : public HtmlService { if (new_vis_line) { std::string line_base = "t"; add_position_classes(line_base, add_class, m, is_matrix, ox, - baseline, asc * text.size); + baseline, ascent_pt); VisLineOut line_out; line_out.classes = std::move(line_base); page_out.vis_items.push_back(std::move(line_out)); @@ -1636,11 +1639,11 @@ class HtmlServiceImpl final : public HtmlService { const double tz = text.horizontal_scaling / 100.0; // In the block's frame the unit is text space: `text.width` carries // the horizontal scaling the CSS matrix applies again. - const double local_extent = tz != 0 ? text.width / tz : 0; + const double local_extent = tz != 0 ? text.width * scale / tz : 0; double sel_ox = is_matrix ? 0 : ox; double sel_baseline = is_matrix ? 0 : baseline; const double sel_extent = is_matrix ? local_extent : extent; - const double sel_font_pt = is_matrix ? text.size : font_pt; + const double sel_font_pt = is_matrix ? text.size * scale : font_pt; bool sel_frame_kept = sel_have_prev && !is_matrix && !sel_prev_was_matrix; @@ -1695,7 +1698,7 @@ class HtmlServiceImpl final : public HtmlService { } std::string sel_base = "t"; add_position_classes(sel_base, add_class, m, is_matrix, ox, - baseline, asc * text.size); + baseline, ascent_pt); sel_base += " i"; // transparent page_out.sel_lines.push_back(SelLineOut{std::move(sel_base), {}}); sel_cur_line = static_cast(page_out.sel_lines.size()) - 1; @@ -2125,6 +2128,9 @@ class HtmlServiceImpl final : public HtmlService { const auto [m, invisible, is_matrix, asc, scale, ox, baseline, extent, font_pt, font_size_pt] = run_geometry(text, to_box); + // `add_position_classes` divides through the matrix, which carries + // `scale`; the uniform branch multiplies by `m.a`. + const double ascent_pt = asc * text.size * (is_matrix ? scale : 1.0); const double cs_pt = round2(text.char_spacing * scale); const double ws_pt = round2(text.word_spacing * scale); const std::string color_suffix = color_class(text, invisible, styles); @@ -2207,7 +2213,7 @@ class HtmlServiceImpl final : public HtmlService { if (new_line) { std::string base = "t"; add_position_classes(base, add_class, m, is_matrix, ox, baseline, - asc * text.size); + ascent_pt); add_class(base, "f", pt_decl("font-size", font_size_pt)); const bool spacing_one_to_one = font != 0 || @@ -2443,6 +2449,12 @@ class HtmlServiceImpl final : public HtmlService { double font_size_pt; ///< CSS font-size in px }; + /// A matrix run's font size is the PDF's `Tf`, routinely `1`, which a + /// browser's minimum font size clamps up before the matrix multiplies it. + /// Laying out this much larger and dividing the matrix by it keeps clear of + /// the clamp. Constant, not per run: one block's runs share the space. + static constexpr double matrix_local_scale = 48; + static RunGeometry run_geometry(const pdf::TextElement &text, const util::math::Transform2D &to_box) { constexpr util::math::Transform2D flip_glyph = @@ -2455,18 +2467,24 @@ class HtmlServiceImpl final : public HtmlService { // fast path, where it would feed a negative `m.a` into the placement math. const bool is_matrix = !(m.b == 0 && m.c == 0 && m.a == m.d && m.a > 0); const double tz = text.horizontal_scaling / 100.0; + // off the original matrix: `extent`/`font_pt` stay page space const double axis = tz != 0 ? std::hypot(m.a, m.b) / tz : 0; + const double scale = is_matrix ? matrix_local_scale : m.a; + const util::math::Transform2D placed = + is_matrix ? util::math::Transform2D(m.a / scale, m.b / scale, + m.c / scale, m.d / scale, m.e, m.f) + : m; return RunGeometry{ - .m = m, + .m = placed, .invisible = invisible, .is_matrix = is_matrix, .asc = ascent_em(text.font), - .scale = is_matrix ? 1.0 : m.a, - .ox = m.e, - .baseline = m.f, + .scale = scale, + .ox = placed.e, + .baseline = placed.f, .extent = text.width * axis, .font_pt = text.size * axis, - .font_size_pt = round2(is_matrix ? text.size : m.a * text.size), + .font_size_pt = round2(text.size * scale), }; } diff --git a/test/data.cmake b/test/data.cmake index 5742c25d7..b76a20944 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "280cafc1747882ea600cb5ff96a68ced3af81712") + REVISION "c08282c6561ae91fd9eb3dd3ca37fa49a4bda672") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "e813a20df7b0623768e8eb2afb83b80f83d43f42") + REVISION "b83c7a7fd85af624f6172fce6bdc923b60708f40") diff --git a/test/src/internal/pdf/pdf_file.cpp b/test/src/internal/pdf/pdf_file.cpp index 33639fea2..41d5dd0fa 100644 --- a/test/src/internal/pdf/pdf_file.cpp +++ b/test/src/internal/pdf/pdf_file.cpp @@ -445,8 +445,7 @@ TEST(PdfFile, views_agree_on_an_image_name) { // path -> the bytes served under it, across every view std::map bytes_by_path; - for (const std::string &view : - {"document.html", "page0.html", "page1.html"}) { + for (const auto *const view : {"document.html", "page0.html", "page1.html"}) { std::ostringstream out; for (const auto &[resource, location] : service.write_html(view, out)) { if (resource.type() != HtmlResourceType::image) { @@ -464,3 +463,26 @@ TEST(PdfFile, views_agree_on_an_image_name) { EXPECT_EQ(bytes_by_path.size(), 2); } + +// A `1 Tf` run with its size in `Tm` must not come out at `font-size:1pt`, +// which a browser's minimum font size would clamp up. +TEST(PdfFile, matrix_run_font_size_clears_a_minimum_font_size) { + const std::string html = + render_html(text_mini_pdf("BT /F1 1 Tf 10 0 0 20 50 700 Tm (Hi) Tj ET"), + PdfTextMode::dual_layer); + + EXPECT_FALSE(contains(html, "font-size:1pt")); + EXPECT_TRUE(contains(html, "font-size:48pt")); + // 10/48 and 20/48: the size the file asks for survives the blow-up. + EXPECT_TRUE(contains(html, "matrix(0.208333,0,0,0.416667,0,0)")); +} + +// The uniform branch states the real size and needs no such treatment. +TEST(PdfFile, uniform_run_keeps_its_own_font_size) { + const std::string html = + render_html(text_mini_pdf("BT /F1 12 Tf 1 0 0 1 72 700 Tm (Hi) Tj ET"), + PdfTextMode::dual_layer); + + EXPECT_TRUE(contains(html, "font-size:12pt")); + EXPECT_FALSE(contains(html, "font-size:48pt")); +}