M3: copy with formatting: keep columns apart, name families by their last part

- A line change into a block of another style is a wrap only when the next
  line starts within half a line below the last one; a line far above it
  (the next column) starts a paragraph. The size compare treats NaN as
  "not the same size".
- A smaller block must overlap a block's interior, not just touch its edge,
  to stop the block being used as a hint.
- font_family drops only the part after the last hyphen, and only when it
  names a style (Segoe-UI-Bold gives Segoe UI).
- text_extract's description and html_truncated doc now cover a page's own
  copy caps and say that rect HTML is in line order.
- Parity: the new tests are evidence; column selection HTML is listed as
  missing (plain text only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqmHYx8FiWM5PcsoiErgp9
This commit is contained in:
KenM76andClaude Opus 5.5 committed 2026-11-05 07:43:57 -05:00
1 parent 7866009709
commit d3ca6f6609
5 files changed
+28 -9

No files matched your search

+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -202,7 +202,7 @@ pub fn tools() -> Vec<ToolDef> {
.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(
+22 -4
View File
@@ -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<bool>], 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<bool>], 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<usize>, steps: &mut usize) -> Option<Option<usize>> {
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<pdfcraft_export::ClipSpan>| p.iter().map(|s| s.text.as_str()).collect::<String>();
let paragraphs: Vec<String> = 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 \
+2 -1
View File
@@ -143,7 +143,7 @@ pub fn font_family(base_font: &str) -> Vec<String> {
_ => 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());
+2 -2
View File
@@ -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"