Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ The release run heads these entries with the version and opens a fresh
`<table:table-header-rows>` LibreOffice writes for a repeating header row.
They were dropped from the output entirely, in text documents and
spreadsheets alike.
- Text copied out of a pdf laid out glyph by glyph reads as words, not as
`L a g e`. A word break also survives a run with nothing extractable in it.

## v6.9.0 - 2026-08-18

Expand Down
10 changes: 7 additions & 3 deletions src/odr/internal/html/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1546,19 +1546,23 @@ class HtmlServiceImpl final : public HtmlService {
// covered by the spacer span, not by the run text.
std::string core = starts_space ? text.text.substr(1) : text.text;

bool new_sel_line =
!sel_have_prev || is_matrix || sel_prev_was_matrix;
const bool matrix_break = is_matrix || sel_prev_was_matrix;
bool new_sel_line = !sel_have_prev || matrix_break;
bool sel_gap = false;
if (sel_have_prev && sel_prev_font_pt > 0 && !new_sel_line) {
new_sel_line = starts_new_line(baseline, sel_prev_baseline, ox,
sel_prev_end, sel_prev_font_pt);
sel_gap = ox - sel_prev_end > 0.25 * sel_prev_font_pt;
}
// The extractor's leading space is the break; a block the matrix
// path opens is not.
const bool break_space = !matrix_break || starts_space;

if (new_sel_line) {
// Close the previous line with a trailing space. `sg`, not `sr`:
// it carries no PDF-derived width, just the space.
if (sel_cur_line >= 0 && sel_have_prev && !sel_prev_ends_space) {
if (sel_cur_line >= 0 && sel_have_prev && !sel_prev_ends_space &&
break_space) {
Comment thread
andiwand marked this conversation as resolved.
std::string space_cls = "sg";
add_class(space_cls, "f",
pt_decl("font-size", sel_prev_font_size_pt));
Expand Down
16 changes: 13 additions & 3 deletions src/odr/internal/pdf/pdf_page_extractor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,14 @@ SegmentAdvances segment_advances(const GraphicsState::Text &text,
/// inter-word spaces PDFs routinely omit. `position` is the segment
/// origin *after* its advance; `direction`/`em` give the writing line and scale
/// the gap threshold; `trailing_space` suppresses a doubled space.
/// `pending_space` carries a break a segment with nothing extractable could
/// not.
struct Pen {
std::array<double, 2> position{0, 0};
std::array<double, 2> direction{1, 0};
double em{0};
bool trailing_space{false};
bool pending_space{false};
};

/// Whether a space should be inferred before a segment starting at `start`
Expand Down Expand Up @@ -183,8 +186,10 @@ void show(std::vector<PageElement> &out, GraphicsState &state,
const std::array<double, 2> direction =
basis > 0 ? std::array<double, 2>{m.a / basis, m.b / basis}
: std::array<double, 2>{1, 0};
if (!element.text.empty() && element.text.front() != ' ' && pen.has_value() &&
infer_space(*pen, start)) {
// Nothing extractable can carry no break, so hold it on the pen.
const bool break_here =
pen.has_value() && (infer_space(*pen, start) || pen->pending_space);
if (!element.text.empty() && element.text.front() != ' ' && break_here) {
element.text.insert(element.text.begin(), ' ');
element.leading_space_inferred = true;
}
Expand All @@ -197,13 +202,18 @@ void show(std::vector<PageElement> &out, GraphicsState &state,
: element.text.back() == ' ';

const double advance = element.width;
const bool element_text_empty = element.text.empty();
out.push_back(std::move(element));
state.advance_text(advance, 0);

// Record the pen at the true post-advance origin (so `TJ` adjustments and any
// explicit repositioning before the next segment fold into the next gap).
const util::math::Transform2D after = state.text_placement_transform();
pen = Pen{{after.e, after.f}, direction, text.size * basis, trailing_space};
pen = Pen{{after.e, after.f},
direction,
text.size * basis,
trailing_space,
element_text_empty && break_here};
}

/// Resolve a colour-space operator (`cs`/`CS`): set the active colour space on
Expand Down
6 changes: 3 additions & 3 deletions test/data.cmake
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,14 @@ odr_test_data(
odr_test_data(
PATH "input/odr-private"
URL "https://github.com/opendocument-app/OpenDocument.test-private.git"
REVISION "b1deaf20eb08054cf88fcc4cae33d0e90e185da3")
REVISION "112cf18f0ef246dfa31ac21f95f5167d0aaa2bcc")

odr_test_data(
PATH "reference-output/odr-public"
URL "https://github.com/opendocument-app/OpenDocument.test.output.git"
REVISION "c7258ff998e22aaedf514ef2c1475dc3c22d0a96")
REVISION "e352a4626b8e7cffa7f7a077d07af4edc8d46809")

odr_test_data(
PATH "reference-output/odr-private"
URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git"
REVISION "21f9c3f49de727acee20768d6177980577423764")
REVISION "460544f350ea0f7c4e009721a184e4f496ff2733")
31 changes: 31 additions & 0 deletions test/src/internal/pdf/pdf_file.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,20 @@ std::string info_mini_pdf() {
return builder.trailer("/Root 1 0 R /Info 5 0 R").build_classic();
}

/// A one-page mini-PDF drawing `content` with a non-embedded Helvetica as
/// `/F1`, whose standard-14 metrics give the glyph advances without a font
/// program.
std::string text_mini_pdf(const std::string &content) {
PdfFileBuilder builder;
builder.object("<< /Type /Catalog /Pages 2 0 R >>")
.object("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
.object("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>")
.stream_object("", content)
.object("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
return builder.trailer("/Root 1 0 R").build_classic();
}

} // namespace

// `/Info` document-information strings and the page count surface through
Expand Down Expand Up @@ -154,6 +168,23 @@ TEST(PdfFile, link_annotations_render_as_anchors) {
}
}

// A `Tm` scaling x and y differently takes the CSS matrix path: glyphs shown
// one `Tj` at a time read as one word, and a real gap keeps its space.
TEST(PdfFile, anisotropic_placement_does_not_space_out_glyphs) {
const std::string tight = render_html(
text_mini_pdf("BT /F1 12 Tf 0.9 0 0 1 72 700 Tm (H) Tj (i) Tj ET"),
PdfTextMode::dual_layer);
EXPECT_TRUE(contains(tight, ">H</span>"));
EXPECT_TRUE(contains(tight, ">i</span>"));
EXPECT_FALSE(contains(tight, R"(<span class="sg)"));

const std::string spaced =
render_html(text_mini_pdf("BT /F1 12 Tf 0.9 0 0 1 72 700 Tm (H) Tj (i) "
"Tj 0.9 0 0 1 200 700 Tm (t) Tj (o) Tj ET"),
PdfTextMode::dual_layer);
EXPECT_TRUE(contains(spaced, R"(<span class="sg)"));
}

// A standalone page view (`page{index}.html`) resolves internal links to the
// target's page view file instead of a `#pN` anchor; the page div keeps its
// document-global `id`.
Expand Down
21 changes: 21 additions & 0 deletions test/src/internal/pdf/pdf_page_extractor.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,27 @@ TEST(PdfPageExtractor, no_unicode_marks_composite_without_tounicode) {
EXPECT_EQ(texts[0].codes, std::string("\x00\x01", 2));
}

// The pen holds a break a `no_unicode` run cannot carry.
TEST(PdfPageExtractor, break_survives_a_segment_with_no_text) {
Font simple = simple_font(0x41, {500, 500}); // A, B = 0.5 em
Font opaque;
opaque.composite = true; // no cmap, no encoding: no recoverable Unicode
Resources res;
res.font["F1"] = &simple;
res.font["F2"] = &opaque;

// `A` at 0, the opaque glyph a wide gap later, then `B` right behind it.
const auto texts = run("BT /F1 10 Tf 1 0 0 1 0 0 Tm (A) Tj "
"1 0 0 1 40 0 Tm /F2 10 Tf <0001> Tj "
"/F1 10 Tf (B) Tj ET",
res);
ASSERT_EQ(texts.size(), 3);
EXPECT_EQ(texts[0].text, "A");
EXPECT_TRUE(texts[1].text.empty());
EXPECT_EQ(texts[2].text, " B");
EXPECT_TRUE(texts[2].leading_space_inferred);
}

// `/ActualText` on a marked-content sequence overrides the per-glyph text for
// extraction (ligatures, reordered glyphs); a literal string is taken as-is.
TEST(PdfPageExtractor, actual_text_overrides_segment) {
Expand Down
Loading