Some checks failed
nigig-build (CAD) / supply-chain (push) Has been cancelled
nigig-build (CAD) / cad-module (push) Has been cancelled
nigig-build (CAD) / full-crate-check (push) Has been cancelled
nigig-build (CAD) / cad-engine-coverage (push) Has been cancelled
nigig-build (CAD) / doc-workspace-coverage (push) Has been cancelled
nigig-build (CAD) / cad-widget-coverage (push) Has been cancelled
repo hygiene / hygiene (push) Has been cancelled
spreadsheet / engine-coverage (push) Has been cancelled
spreadsheet / ui-controller-coverage (push) Has been cancelled
doc-engine / engine (push) Has been cancelled
doc-engine / coverage (push) Has been cancelled
sms / robius-sms (push) Has been cancelled
sms / android (push) Has been cancelled
sms / nigig-sms (push) Has been cancelled
sms / supply-chain (push) Has been cancelled
doc-engine / consumer (push) Has been cancelled
email / gates (push) Has been cancelled
email / email-domain (push) Has been cancelled
email / nigig-email (push) Has been cancelled
email / supply-chain (push) Has been cancelled
nigig-map / test (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
sms / gates (push) Has been cancelled
Phase 8 bullets one and two. Probing the existing code first, as the
workflow requires, found five defects rather than the one the plan names:
SPLIT MATCH 'Hello': 0 hits
plain_text: "Hello"
PRECOMPOSED 'café': 0 hits
COLUMNS plain_text: "LeftTopRightTop\nLeftBotRightBot"
OUT OF ORDER plain_text: "second\nfirst"
`PageText::find` searched one run at a time and documented that as a known
limitation. It is a limitation from inside the code and a broken feature
from outside it: a writer starts a new run wherever it adjusts kerning, so
an ordinary word arrives as two runs, and the find bar says a word plainly
visible on the page is not there.
`search.rs` indexes the page as one flattened string with a map back to
(run, character), so a cross-run match is found and highlighted with one
rectangle per run — never a merged box, which across a line break covers
half the paragraph.
The separator between two runs is a geometric question with three answers:
abutting runs join with nothing (one word, split by kerning), separated
runs with a space, and a different line or column with a newline. The
newline matters as much as the empty join: joining lines with a space lets
"one Right" match across a column gutter, text that appears nowhere.
Whether two runs share a column is *asked* of the layout analysis rather
than re-derived, or the extracted text and the searched text disagree about
where a column ends — the original defect wearing a different hat.
NFD, never NFC: composition needs the next character, so an NFC fold
applied per character composes nothing and the two spellings of an accent
stay different. That was a real bug in the first draft. And case *folding*,
not lowercasing — Rust lowercases ß to ß, so "Strasse" never found
"Straße".
Columns are detected before lines, because two columns share their
baselines; that is what makes them columns. Bands are separated by a gutter
rather than by bare non-overlap, since two abutting runs on a line do not
overlap either.
Also fixed, found by running the gates rather than by looking: a stream
reader trimmed a trailing CR before `endstream` as if it were the writer's
separator. Binary data ends in CR about one time in 256, and when it did
the reader returned a stream one byte short — no longer AES-block-aligned,
so decryption produced garbage and Flate failed. Roughly one encrypted
document in 250 was silently corrupt on read. The test failed once under
coverage, passed five times in isolation, and failed 2 in 40 when actually
counted. A /Length consistent with the file is now the authority; both
stream readers are fixed and a test reads one file through each.
1477 tests pass (was 1426), coverage 88.37%, all floors met, external
readers pass. 10 mutations across the two modules, all killed.
ADR 0034.
521 lines
19 KiB
Rust
521 lines
19 KiB
Rust
//! Phase 8 exit criterion: search and selection integration tests.
|
|
//!
|
|
//! `search.rs`'s unit tests build runs directly. These start from a real
|
|
//! PDF and go through the whole pipeline — parse, interpret, extract,
|
|
//! index, search — because the defects this module fixes were all
|
|
//! *emergent*: each individual stage was doing what it said, and the
|
|
//! answer at the end was still wrong.
|
|
//!
|
|
//! The two that matter most here:
|
|
//!
|
|
//! - A word split across runs by a kerning adjustment was unfindable,
|
|
//! while `plain_text()` showed it plainly on the page.
|
|
//! - A two-column page was read across rather than down, because the two
|
|
//! columns share their baselines and line grouping came first.
|
|
//!
|
|
//! See `REVIEWS/adr/0034-pdf-text-search-and-layout.md`.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use nigig_pdf_document::PdfDocument;
|
|
use nigig_pdf_graphics::content::parse_content_stream;
|
|
use nigig_pdf_graphics::recording::RecordingDevice;
|
|
use nigig_pdf_graphics::search::{
|
|
detect_columns_from_segments, detect_lines, layout_text, reading_order, search, SearchOptions,
|
|
};
|
|
use nigig_pdf_graphics::text::PageText;
|
|
|
|
fn corpus(relative: &str) -> Vec<u8> {
|
|
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
|
.join("../tests/corpus")
|
|
.join(relative);
|
|
std::fs::read(&path).unwrap_or_else(|e| panic!("missing fixture {}: {e}", path.display()))
|
|
}
|
|
|
|
/// Parse a fixture and extract page 0's text the way the application does.
|
|
///
|
|
/// The fonts matter: without a width table every advance is zero, every
|
|
/// run lands on top of the last, and the layout tests below would be
|
|
/// measuring nothing. Registering them is what makes this an integration
|
|
/// test rather than a longer unit test.
|
|
fn page_text(relative: &str) -> PageText {
|
|
let data = corpus(relative);
|
|
let mut doc = PdfDocument::parse(&data).expect("fixture parses");
|
|
let page = doc.page(0).expect("page 0");
|
|
|
|
let mut device = RecordingDevice::new();
|
|
for (name, resource) in &page.fonts {
|
|
// The width table matters: without one every advance is zero,
|
|
// every run lands on top of the last, and the layout assertions
|
|
// below would be measuring nothing. The fixtures use base-14
|
|
// fonts, whose metrics are built in.
|
|
// Keyed by the *resource* name (`/F1`), which is what the
|
|
// interpreter looks up — `register_standard_font` keys by the base
|
|
// font name, which never matches a `Tf` operand.
|
|
device.register_font(
|
|
name,
|
|
nigig_pdf_graphics::font::GlyphWidths::from_standard_font(&resource.base_font),
|
|
);
|
|
}
|
|
let ops = parse_content_stream(&page.content_data).expect("content parses");
|
|
nigig_pdf_graphics::content::interpret_ops(&ops, &mut device).expect("interprets");
|
|
PageText::from_commands(&device.into_commands())
|
|
}
|
|
|
|
// ------------------------------------------------------------- search
|
|
|
|
#[test]
|
|
fn a_word_split_by_kerning_is_found_in_a_real_document() {
|
|
// The headline defect. `two_columns.pdf` emits "Hy" and "phen" as
|
|
// separate runs at the same baseline, which is what a typesetter does
|
|
// whenever it adjusts a pair.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
|
|
assert!(
|
|
page.find("Hyphen").is_empty(),
|
|
"this test is meaningless if the old per-run search already found it"
|
|
);
|
|
|
|
let hits = search(&page, "Hyphen", SearchOptions::default());
|
|
assert_eq!(hits.len(), 1, "the split word was not found");
|
|
assert_eq!(hits[0].text, "Hyphen");
|
|
assert!(hits[0].is_split(), "the hit must span the two runs");
|
|
assert_eq!(
|
|
hits[0].rects.len(),
|
|
2,
|
|
"one highlight rectangle per run, not one merged box"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_split_words_highlight_covers_both_halves_and_nothing_else() {
|
|
let page = page_text("basic/two_columns.pdf");
|
|
let hits = search(&page, "Hyphen", SearchOptions::default());
|
|
let rects = &hits[0].rects;
|
|
|
|
// The runs are placed at x=50 and x=61.13 in the fixture, so the two
|
|
// rectangles must start there and be adjacent.
|
|
assert!(
|
|
(rects[0][0] - 50.0).abs() < 0.5,
|
|
"the first half should start at the run origin, got {rects:?}"
|
|
);
|
|
assert!(
|
|
(rects[1][0] - 61.13).abs() < 0.5,
|
|
"the second half should start at the second run, got {rects:?}"
|
|
);
|
|
// The second run starts slightly *before* the first one's advance
|
|
// ends: that is the kerning adjustment that split the word in the
|
|
// first place. What matters is that the halves are adjacent and in
|
|
// order, not that they are disjoint to the point.
|
|
assert!(
|
|
rects[1][0] > rects[0][0] && rects[1][0] < rects[0][2] + 1.0,
|
|
"the halves must be adjacent and in order: {rects:?}"
|
|
);
|
|
// Both on the same baseline, so the same vertical extent.
|
|
assert!((rects[0][1] - rects[1][1]).abs() < 1e-6, "{rects:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn search_finds_text_that_plain_extraction_shows() {
|
|
// The invariant behind the whole module: anything the page displays is
|
|
// findable. A search that misses a word the reader can see is the
|
|
// defect, whatever the internal reason.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
let text = layout_text(&page);
|
|
|
|
for word in ["Annual", "Report", "Left", "Right", "Hyphen"] {
|
|
assert!(
|
|
text.contains(word),
|
|
"extraction lost {word:?}; got {text:?}"
|
|
);
|
|
assert!(
|
|
!search(&page, word, SearchOptions::default()).is_empty(),
|
|
"{word:?} is in the extracted text but not findable"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_search_across_a_column_gutter_finds_nothing() {
|
|
// "Left one Right one" is not text that appears on the page; it is an
|
|
// artefact of reading across the gutter.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
assert!(
|
|
search(&page, "one Right", SearchOptions::default()).is_empty(),
|
|
"text spanning the gutter must not match"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn case_and_accent_options_survive_the_whole_pipeline() {
|
|
let page = page_text("basic/two_columns.pdf");
|
|
assert_eq!(
|
|
search(&page, "annual report", SearchOptions::default()).len(),
|
|
1,
|
|
"the default search is case-insensitive"
|
|
);
|
|
let exact = SearchOptions {
|
|
case_insensitive: false,
|
|
..SearchOptions::default()
|
|
};
|
|
assert!(
|
|
search(&page, "annual report", exact).is_empty(),
|
|
"case sensitivity must be honourable end to end"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_hit_rectangle_lies_on_the_page() {
|
|
// A highlight off the edge of the page is the visible symptom of an
|
|
// offset computed against the wrong origin.
|
|
let data = corpus("basic/two_columns.pdf");
|
|
let mut doc = PdfDocument::parse(&data).expect("parses");
|
|
let media = doc.page(0).expect("page 0").media_box;
|
|
let page = page_text("basic/two_columns.pdf");
|
|
|
|
for word in ["Annual", "Left", "Right", "Hyphen"] {
|
|
for hit in search(&page, word, SearchOptions::default()) {
|
|
for rect in &hit.rects {
|
|
assert!(
|
|
rect[0] >= media[0] - 1.0 && rect[2] <= media[2] + 1.0,
|
|
"{word:?} highlight {rect:?} is outside the page {media:?}"
|
|
);
|
|
assert!(
|
|
rect[2] > rect[0],
|
|
"{word:?} highlight {rect:?} has no width"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn the_extracted_text_and_the_searched_text_agree() {
|
|
// The invariant that ties the two halves of this module together.
|
|
// `layout_text` and the search index each decide where a word ends,
|
|
// and if they decide differently then a user searching for what they
|
|
// can see gets nothing — which is the original defect wearing a
|
|
// different hat.
|
|
//
|
|
// Every word of the extracted text must be findable, and no run of
|
|
// words spanning a break may be.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
let text = layout_text(&page);
|
|
|
|
for line in text.lines().filter(|l| !l.trim().is_empty()) {
|
|
assert!(
|
|
!search(&page, line.trim(), SearchOptions::default()).is_empty(),
|
|
"the extracted line {line:?} is not findable"
|
|
);
|
|
}
|
|
|
|
// And the joins the extraction made are real: "Hyphen" is one word in
|
|
// the output because the two runs abut, not because they were glued.
|
|
assert!(
|
|
text.contains("Hyphen"),
|
|
"the kerning-split word must extract as one word, got {text:?}"
|
|
);
|
|
assert!(
|
|
!text.contains("Hy phen"),
|
|
"an abutting split must not become a space, got {text:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_real_page_survives_the_separator_rules() {
|
|
// The three separator cases, driven from a document rather than from
|
|
// hand-built runs: abutting runs join, spaced runs get a space, and a
|
|
// new line or column gets a newline.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
let index = nigig_pdf_graphics::search::SearchIndex::for_page(&page, SearchOptions::default());
|
|
let flat = index.text();
|
|
|
|
assert!(flat.contains("Hyphen"), "abutting runs must join: {flat:?}");
|
|
assert!(
|
|
flat.contains("Annual Report"),
|
|
"a word space must survive: {flat:?}"
|
|
);
|
|
// Two runs on one line separated by a real word space. Unlike
|
|
// "Annual Report", which is one run, this can only read correctly if
|
|
// the *separator* rule inserted the space.
|
|
assert!(
|
|
flat.contains("alpha beta"),
|
|
"a space between two runs on a line must survive: {flat:?}"
|
|
);
|
|
assert!(
|
|
!flat.contains("alphabeta"),
|
|
"separated runs must not be glued: {flat:?}"
|
|
);
|
|
assert!(
|
|
flat.contains('\n'),
|
|
"line and column breaks must be newlines: {flat:?}"
|
|
);
|
|
assert!(
|
|
!flat.contains("one Right"),
|
|
"no separator may let a query span a column: {flat:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_whole_word_search_on_a_real_page_rejects_substrings() {
|
|
let page = page_text("basic/two_columns.pdf");
|
|
// "one" appears in "Left one" and "Right one" as a whole word.
|
|
let words = SearchOptions {
|
|
whole_words: true,
|
|
..SearchOptions::default()
|
|
};
|
|
assert_eq!(search(&page, "one", words).len(), 2);
|
|
// "ne" is a substring of both and a word in neither.
|
|
assert!(search(&page, "ne", words).is_empty());
|
|
assert_eq!(
|
|
search(&page, "ne", SearchOptions::default()).len(),
|
|
2,
|
|
"without the option it is still a substring match"
|
|
);
|
|
|
|
// The page also carries "gamma" and "gam": as a substring "gam"
|
|
// matches both, as a whole word only one.
|
|
assert_eq!(
|
|
search(&page, "gam", SearchOptions::default()).len(),
|
|
2,
|
|
"substring search must match inside gamma"
|
|
);
|
|
assert_eq!(
|
|
search(&page, "gam", words).len(),
|
|
1,
|
|
"whole-word search must reject the gamma prefix"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_overlapping_match_on_a_real_page_is_counted_once() {
|
|
// The page carries "aaa". Advancing by one character after a hit
|
|
// instead of past it reports two overlapping matches, and the two
|
|
// highlights then paint over each other.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
assert_eq!(
|
|
search(&page, "aa", SearchOptions::default()).len(),
|
|
1,
|
|
"an overlapping match must be reported once"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_column_gutter_on_a_real_page_is_wider_than_a_word_space() {
|
|
// The threshold the whole layout analysis turns on. The fixture has
|
|
// both: a 4-unit word space between "alpha" and "beta", and a 260-unit
|
|
// gutter between the columns. A threshold that cannot tell them apart
|
|
// either glues the columns or splits every line.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
let columns = detect_columns_from_segments(page.segments());
|
|
assert_eq!(
|
|
columns.len(),
|
|
2,
|
|
"the page has exactly two columns, got {}",
|
|
columns.len()
|
|
);
|
|
// "alpha" and "beta" must be in the same column despite their gap.
|
|
let text = layout_text(&page);
|
|
assert!(
|
|
text.contains("alpha beta"),
|
|
"a word space must not read as a column break: {text:?}"
|
|
);
|
|
// "wide" and "space" are further apart still — their boxes do not
|
|
// overlap at all — and are on one line.
|
|
//
|
|
// Mutating the gutter threshold to zero does **not** break this page,
|
|
// and that is worth stating rather than hiding: a longer line
|
|
// elsewhere in the column ("aaa gamma gam") spans both runs and
|
|
// merges the bands anyway, so band merging makes the threshold
|
|
// non-load-bearing on a realistic page. The unit test
|
|
// `a_line_split_into_runs_stays_one_line` isolates it, on a page with
|
|
// nothing else to bridge the gap. Both tests are kept: one shows the
|
|
// rule, the other shows the algorithm tolerates the rule being wrong.
|
|
assert!(
|
|
text.contains("wide space"),
|
|
"a wide word space must not read as a column break: {text:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn repeated_text_on_a_real_page_is_found_once_per_occurrence() {
|
|
let page = page_text("basic/two_columns.pdf");
|
|
// "Right" begins both right-column lines.
|
|
let hits = search(&page, "Right", SearchOptions::default());
|
|
assert_eq!(hits.len(), 2, "both occurrences must be reported");
|
|
// On different lines, so at different heights.
|
|
assert!(
|
|
(hits[0].rects[0][1] - hits[1].rects[0][1]).abs() > 1.0,
|
|
"the two hits should be on different lines: {hits:?}"
|
|
);
|
|
}
|
|
|
|
// ------------------------------------------------------------- layout
|
|
|
|
#[test]
|
|
fn a_two_column_page_is_read_down_each_column() {
|
|
// The other headline defect. The fixture emits left, right, left,
|
|
// right — as a typesetter does — and the columns share baselines, so
|
|
// grouping into lines first produces "Left one Right one".
|
|
let page = page_text("basic/two_columns.pdf");
|
|
|
|
let naive = page.plain_text();
|
|
assert!(
|
|
naive.contains("Left one") && naive.contains("Right one"),
|
|
"sanity: the runs are all present, got {naive:?}"
|
|
);
|
|
|
|
let text = layout_text(&page);
|
|
let left = text.find("Left one").expect("left column");
|
|
let hyphen = text.find("Hyphen").expect("second left line");
|
|
let right = text.find("Right one").expect("right column");
|
|
|
|
assert!(
|
|
left < hyphen,
|
|
"the left column must be read top to bottom, got {text:?}"
|
|
);
|
|
assert!(
|
|
hyphen < right,
|
|
"the whole left column must precede the right one, got {text:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_columns_of_a_real_page_are_detected_from_its_geometry() {
|
|
let page = page_text("basic/two_columns.pdf");
|
|
let columns = detect_columns_from_segments(page.segments());
|
|
|
|
// The heading spans both columns, so it bridges them into one band —
|
|
// which is right: a page with a full-width heading is not two
|
|
// independent columns, and reading it top to bottom is what a reader
|
|
// expects. What must not happen is losing text.
|
|
let counted: usize = columns.iter().map(|c| c.segments.len()).sum();
|
|
assert_eq!(
|
|
counted,
|
|
page.segments().len(),
|
|
"every run must belong to exactly one column"
|
|
);
|
|
|
|
let mut seen: Vec<usize> = columns.iter().flat_map(|c| c.segments.clone()).collect();
|
|
seen.sort_unstable();
|
|
seen.dedup();
|
|
assert_eq!(
|
|
seen.len(),
|
|
page.segments().len(),
|
|
"a run was double-counted"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reading_order_is_a_permutation_of_the_runs() {
|
|
// Whatever the layout analysis decides, it may not drop or duplicate a
|
|
// run. Losing one silently is how text vanishes from a copy-paste.
|
|
for fixture in [
|
|
"basic/text.pdf",
|
|
"basic/two_columns.pdf",
|
|
"basic/multipage.pdf",
|
|
] {
|
|
let page = page_text(fixture);
|
|
let order = reading_order(page.segments());
|
|
let mut sorted = order.clone();
|
|
sorted.sort_unstable();
|
|
let expected: Vec<usize> = (0..page.segments().len()).collect();
|
|
assert_eq!(
|
|
sorted, expected,
|
|
"{fixture}: reading order is not a permutation of the runs"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_single_column_page_reads_in_document_order() {
|
|
// `basic/text.pdf` is two lines, one under the other. The layout
|
|
// analysis must not disturb the simple case.
|
|
let page = page_text("basic/text.pdf");
|
|
let text = layout_text(&page);
|
|
assert_eq!(
|
|
text, "Hello World\nSecond line",
|
|
"a simple page must survive layout analysis unchanged"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn lines_of_a_real_page_group_by_baseline() {
|
|
let page = page_text("basic/text.pdf");
|
|
let lines = detect_lines(page.segments());
|
|
assert_eq!(lines.len(), 2, "two Td-separated lines");
|
|
assert!(
|
|
lines[0].baseline > lines[1].baseline,
|
|
"lines are ordered top to bottom in PDF coordinates"
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------- selection
|
|
|
|
#[test]
|
|
fn selecting_across_two_lines_takes_the_text_between_them() {
|
|
let page = page_text("basic/text.pdf");
|
|
let segments = page.segments();
|
|
assert!(segments.len() >= 2, "the fixture has two runs");
|
|
|
|
let first = &segments[0];
|
|
let second = &segments[1];
|
|
// From the middle of the first line to the middle of the second.
|
|
let from = (first.origin[0] + first.advance / 2.0, first.origin[1] + 1.0);
|
|
let to = (
|
|
second.origin[0] + second.advance / 2.0,
|
|
second.origin[1] + 1.0,
|
|
);
|
|
|
|
let selected = page.text_in_range(from, to);
|
|
assert!(
|
|
!selected.is_empty(),
|
|
"a drag across two lines must select something"
|
|
);
|
|
assert!(
|
|
selected.len() < page.plain_text().len(),
|
|
"a partial drag must not select the whole page: {selected:?}"
|
|
);
|
|
// It must be a contiguous slice of the page's text, not a rearranged
|
|
// one.
|
|
let whole = page.plain_text().replace('\n', "");
|
|
let flattened = selected.replace('\n', "");
|
|
assert!(
|
|
whole.contains(&flattened),
|
|
"the selection {flattened:?} is not a substring of the page {whole:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_click_lands_on_the_run_under_it() {
|
|
let page = page_text("basic/two_columns.pdf");
|
|
let segments = page.segments();
|
|
|
|
for (index, segment) in segments.iter().enumerate() {
|
|
// A point inside this run's own box must resolve to this run.
|
|
let point = (
|
|
segment.origin[0] + segment.advance / 2.0,
|
|
segment.origin[1] + 1.0,
|
|
);
|
|
let (found, _) = page
|
|
.segment_at(point.0, point.1)
|
|
.unwrap_or_else(|| panic!("run {index} ({:?}) is not hit-testable", segment.text));
|
|
assert_eq!(
|
|
found, index,
|
|
"a click in the middle of {:?} resolved to {:?}",
|
|
segment.text, segments[found].text
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_click_in_the_gutter_hits_nothing() {
|
|
// Between the columns there is no text, and reporting a hit there
|
|
// would start a selection the user did not ask for.
|
|
let page = page_text("basic/two_columns.pdf");
|
|
// The fixture's columns are at x=50 and x=350; x=250 is empty.
|
|
assert!(
|
|
page.segment_at(250.0, 651.0).is_none(),
|
|
"the gutter must not hit-test to a run"
|
|
);
|
|
}
|