diff --git a/crates/automation/src/pages.rs b/crates/automation/src/pages.rs index 8b076b088c..6db58ab475 100644 --- a/crates/automation/src/pages.rs +++ b/crates/automation/src/pages.rs @@ -21,7 +21,7 @@ pub(crate) const FIND_MAX_WARNINGS: usize = 100; pub(crate) const FIND_MAX_WARNING_BYTES: usize = 1024; /// HTML that one `text_extract` with `format: "html"` returns across all its pages; a page past -/// it gets none and the result says `html_truncated`. +/// it gets none. `html_truncated` covers this and any page whose own copy hit its caps. const MAX_EXTRACT_HTML_BYTES: usize = 32 << 20; impl Automation { diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index 05dff3ab60..51ced90975 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -202,7 +202,7 @@ pub fn tools() -> Vec { .with(schema(json!({ "doc": doc(), "pages": pages("to count (default: all)"), "exclude_tables": { "type": "boolean" }, "exclude_code": { "type": "boolean" }, "exclude_images": { "type": "boolean" } }), &["doc"])), - t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete, and the result has html_truncated.") + t("text_extract", "Extract text", "Extract the text of some or all pages, in reading order. With rect, only the text inside that rectangle on each page, as Column select takes it: one row per visual line, side-by-side pieces (table cells) separated by a tab. With format \"html\", each page also carries html, the same text as Copy with formatting puts on the clipboard (paragraphs, bold, italic, size, colour and font family), and styles_incomplete when some styles had to be left out. With rect, the html is that text in line order, not split into the column's rows and cells. The HTML of one call is capped at 32 MiB in total; pages past the cap carry empty html with styles_incomplete. html_truncated is set when that cap is reached or when a page's own copy was cut at its span or size cap.") .ro() .cmd("edit.column_select") .with(schema( diff --git a/crates/engine/src/rich_copy.rs b/crates/engine/src/rich_copy.rs index f29ced8142..2e579adb19 100644 --- a/crates/engine/src/rich_copy.rs +++ b/crates/engine/src/rich_copy.rs @@ -131,12 +131,14 @@ fn finish(b: ClipBuilder, styles_incomplete: bool) -> RichCopy { /// Two blocks of the same style, a change of size, or a gap of over half a line are paragraphs. fn wraps(prev: Option<&ClipStyle>, next: Option<&ClipStyle>, prev_rect: [f32; 4], rect: [f32; 4]) -> bool { let (Some(prev), Some(next)) = (prev, next) else { return false }; - if prev == next || (prev.size - next.size).abs() > 0.01 { + let diff = (prev.size - next.size).abs(); + if prev == next || diff.is_nan() || diff > 0.01 { return false; } let height = (prev_rect[3] - prev_rect[1]).max(rect[3] - rect[1]); + // The next line starts just below the last one; a line far above it is another column. let gap = rect[1] - prev_rect[3]; - height > 0.0 && gap.is_finite() && gap <= height / 2.0 + height > 0.0 && gap.is_finite() && gap >= -height / 2.0 && gap <= height / 2.0 } /// Whether a block smaller than block `k` overlaps it, so a glyph inside `k` may belong to that @@ -148,7 +150,7 @@ fn holds_smaller(blocks: &[StyledBlock], nested: &mut [Option], k: usize, } let Some(outer) = blocks.get(k) else { return true }; let area = |r: &[f32; 4]| (r[2] - r[0]) * (r[3] - r[1]); - let overlaps = |r: &[f32; 4]| r[0] <= outer.rect[2] && r[2] >= outer.rect[0] && r[1] <= outer.rect[3] && r[3] >= outer.rect[1]; + let overlaps = |r: &[f32; 4]| r[0] < outer.rect[2] && r[2] > outer.rect[0] && r[1] < outer.rect[3] && r[3] > outer.rect[1]; let mut found = false; for (j, b) in blocks.iter().enumerate() { if *steps == 0 { @@ -167,7 +169,8 @@ fn holds_smaller(blocks: &[StyledBlock], nested: &mut [Option], k: usize, } /// The block whose box holds the centre of `glyph` (view space): `hint` first (a block no -/// smaller one overlaps), then the smallest that holds it. Each block tested costs a step; `None` when the steps ran out first. +/// smaller one overlaps), then the smallest that holds it. Each block tested costs a step; +/// `None` when the steps ran out first. fn block_at(blocks: &[StyledBlock], glyph: [f32; 4], hint: Option, steps: &mut usize) -> Option> { let (x, y) = ((glyph[0] + glyph[2]) / 2.0, (glyph[1] + glyph[3]) / 2.0); let holds = |r: &[f32; 4]| x >= r[0] - SLACK && x <= r[2] + SLACK && y >= r[1] - SLACK && y <= r[3] + SLACK; @@ -309,6 +312,21 @@ mod tests { assert!(paragraphs.iter().any(|p| p.contains("Another") && !p.contains("continues")), "a wide gap is still a paragraph: {paragraphs:?}"); } + #[test] + fn a_style_change_into_the_next_column_starts_a_paragraph() { + let content = b"BT /F2 12 Tf 20 200 Td (left top) Tj ET \ + BT /F2 12 Tf 20 186 Td (left end) Tj ET \ + BT /F1 12 Tf 170 200 Td (Right head) Tj ET \ + BT /F2 12 Tf 170 186 Td (right body) Tj ET"; + let mut s = Session::new(); + let id = s.open("columns.pdf", None, Arc::new(page_with(content)), None).unwrap(); + let copy = copy_all(&s, id); + let text = |p: &Vec| p.iter().map(|s| s.text.as_str()).collect::(); + let paragraphs: Vec = copy.text.paragraphs.iter().map(text).collect(); + assert!(paragraphs.iter().any(|p| p.contains("Right")), "{paragraphs:?}"); + assert!(!paragraphs.iter().any(|p| p.contains("left end") && p.contains("Right")), "{paragraphs:?}"); + } + #[test] fn a_styled_word_inside_a_paragraph_keeps_its_style() { let content = b"BT /F2 12 Tf 20 200 Td (first row of the text) Tj ET \ diff --git a/crates/export/src/clip.rs b/crates/export/src/clip.rs index 70a29c0026..eeac6d2ddb 100644 --- a/crates/export/src/clip.rs +++ b/crates/export/src/clip.rs @@ -143,7 +143,7 @@ pub fn font_family(base_font: &str) -> Vec { _ => base_font, }; let name = name.split_once(',').map_or(name, |(family, _)| family); - let family = match name.split_once('-') { + let family = match name.rsplit_once('-') { Some((family, style)) if STYLE_WORDS.iter().any(|w| style.starts_with(w)) => family, _ => name, }; @@ -345,6 +345,7 @@ mod tests { assert_eq!(f("Arial-BoldMT"), ["Arial"]); assert_eq!(f("Helvetica-Oblique"), ["Helvetica"]); assert_eq!(f("MS-Gothic"), ["MS Gothic", "MS-Gothic"], "a hyphen before a family word stays in the name"); + assert_eq!(f("Segoe-UI-Bold"), ["Segoe UI", "Segoe-UI"], "the style is the last part"); assert_eq!(f("SimSun"), ["Sim Sun", "SimSun"], "the name as spelt is tried too"); assert_eq!(f("abcdef+Foo"), ["abcdefFoo"], "a lower-case prefix is not a subset tag"); assert!(f("").is_empty()); diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index 949c1c472b..673481a04e 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -1574,8 +1574,8 @@ milestone = "M3" status = "partial" commands = ["edit.copy"] tools = ["text_extract"] -evidence = ["crates/export/src/clip.rs::heading_body_and_coloured_span_keep_their_formatting", "crates/export/src/clip.rs::special_characters_are_escaped_and_cannot_leave_the_style", "crates/export/src/clip.rs::empty_and_huge_selections_are_bounded", "crates/export/src/clip.rs::invisible_controls_and_bidi_overrides_are_dropped", "crates/export/src/clip.rs::font_sizes_stay_within_range", "crates/engine/src/rich_copy.rs::copy_keeps_headings_body_and_colour_as_html", "crates/engine/src/rich_copy.rs::copy_with_formatting_is_refused_when_copying_is_withheld", "crates/engine/src/rich_copy.rs::running_out_of_match_steps_copies_the_rest_unstyled", "crates/automation/tests/it/automation.rs::text_extract_as_html_keeps_the_formatting", "crates/automation/src/tests.rs::text_extract_html_has_one_budget_across_pages", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_the_formatting_as_html", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_falls_back_to_plain_text_when_html_is_refused", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_with_formatting_honours_permissions"] -notes = "Copy of a page-text selection also puts HTML on the system clipboard (paragraphs, bold, italic, size, colour, font family; plain text as the alternative), and text_extract format html returns the same. Missing: RTF, underline and strike-through, tables, Select All of the whole document (plain text only), the web build (plain text only), and styles for glyphs outside the page's reading blocks (copied unstyled)" +evidence = ["crates/export/src/clip.rs::heading_body_and_coloured_span_keep_their_formatting", "crates/export/src/clip.rs::special_characters_are_escaped_and_cannot_leave_the_style", "crates/export/src/clip.rs::empty_and_huge_selections_are_bounded", "crates/export/src/clip.rs::invisible_controls_and_bidi_overrides_are_dropped", "crates/export/src/clip.rs::font_sizes_stay_within_range", "crates/engine/src/rich_copy.rs::copy_keeps_headings_body_and_colour_as_html", "crates/engine/src/rich_copy.rs::copy_with_formatting_is_refused_when_copying_is_withheld", "crates/engine/src/rich_copy.rs::running_out_of_match_steps_copies_the_rest_unstyled", "crates/engine/src/rich_copy.rs::a_style_change_at_a_line_end_stays_in_its_paragraph", "crates/engine/src/rich_copy.rs::a_style_change_into_the_next_column_starts_a_paragraph", "crates/engine/src/rich_copy.rs::a_styled_word_inside_a_paragraph_keeps_its_style", "crates/engine/src/rich_copy.rs::a_capped_copy_is_marked_truncated", "crates/engine/src/rich_copy.rs::without_an_editor_the_styles_are_incomplete", "crates/ui-egui/tests/it/column_select.rs::a_column_selection_copies_plain_text_only", "crates/automation/tests/it/automation.rs::text_extract_as_html_keeps_the_formatting", "crates/automation/src/tests.rs::text_extract_html_has_one_budget_across_pages", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_keeps_the_formatting_as_html", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_falls_back_to_plain_text_when_html_is_refused", "crates/ui-egui/tests/it/text_select.rs::selected_text_copy_with_formatting_honours_permissions"] +notes = "Copy of a page-text selection also puts HTML on the system clipboard (paragraphs, bold, italic, size, colour, font family; plain text as the alternative), and text_extract format html returns the same. Missing: RTF, underline and strike-through, tables, Select All of the whole document (plain text only), the web build (plain text only), styles for glyphs outside the page's reading blocks (copied unstyled), and HTML for a column selection (plain text only; text_extract with rect gives its HTML in line order, not as the column's rows)" [[feature]] id = "view.snapshot"