diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 12915bc3ce..af4ba5913a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -622,7 +622,11 @@ impl Document { /// The styles the document's text is set in, the largest first, for bookmarks from text /// styles (#1693). + /// Empty when the document doesn't allow copying: the samples are its text. pub fn text_styles(&self) -> Vec { + if !self.allows_copying() { + return Vec::new(); + } self.editor.as_ref().map(|e| text_style_bookmarks::styles(&e.cos)).unwrap_or_default() } @@ -2348,7 +2352,6 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed | Edit::SetBookmarkColor { .. } | Edit::SetBookmarkStyle { .. } | Edit::BookmarksFromStructure - | Edit::BookmarksFromTextStyles { .. } | Edit::NumberPages { .. } => { if p.assemble() { Ok(()) @@ -2356,6 +2359,17 @@ fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Result<(), Ed Err(EditError::NotPermitted("page changes")) } } + // Creates bookmarks (assembly), and their titles are the document's text, copied out of it + // (Table 22, bit 5), as an export of it would be. + Edit::BookmarksFromTextStyles { .. } => { + if !p.assemble() { + Err(EditError::NotPermitted("page changes")) + } else if !p.copy() { + Err(EditError::NotPermitted("copying text and images")) + } else { + Ok(()) + } + } Edit::AddMeasurement(_) | Edit::AddAnnotation(_) | Edit::AddCustomStamp { .. } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 0458cac519..1e7b3020fb 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1924,6 +1924,52 @@ fn bookmarks_from_text_styles_nest_the_headings_and_undo() { assert!(s.get(id).unwrap().info.outline.is_empty()); } +/// CodeRabbit on #4560: bookmark titles from text styles are the document's text, so a document +/// that forbids copying lists no styles and refuses the edit, as it refuses other extraction. +#[test] +fn bookmarks_from_text_styles_need_copying_permission() { + let mut doc = pdfcraft_cos::Document::open(Arc::new(styled_headings_fixture())).unwrap(); + doc.set_encryption(&pdfcraft_cos::NewEncryption { + algorithm: pdfcraft_cos::Algorithm::Aes256, + user_password: "", + owner_password: "owner", + permissions: -1 ^ 0b1_0000, + encrypt_metadata: true, + seed: [7; 32], + }) + .unwrap(); + let bytes = Arc::new(pdfcraft_cos::write_full(&doc, &Default::default()).unwrap()); + let mut s = Session::new(); + let id = s.open("r.pdf", None, bytes.clone(), None).unwrap(); + assert!(s.get(id).unwrap().allows_assembly() && !s.get(id).unwrap().allows_copying()); + assert!(s.get(id).unwrap().text_styles().is_empty(), "no samples of text that may not be copied"); + let style = text_style_bookmarks::TextStyle { font: "Helvetica-Bold".into(), size: 18.0, bold: true, italic: false }; + let err = s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style.clone()] }).unwrap_err(); + assert_eq!(err, EditError::NotPermitted("copying text and images")); + let owner = s.open("r.pdf", None, bytes, Some("owner")).unwrap(); + assert!(!s.get(owner).unwrap().text_styles().is_empty()); + assert!(s.apply(owner, Edit::BookmarksFromTextStyles { levels: vec![style] }).is_ok(), "the owner may"); +} + +/// CodeRabbit on #4560: two headings in one style in two columns, the right one a little lower, +/// are two bookmarks, not one heading run on across the columns. +#[test] +fn headings_in_two_columns_are_two_bookmarks() { + let page = "BT /FB 14 Tf 72 700 Td (Left heading) Tj ET BT /FB 14 Tf 320 690 Td (Right heading) Tj ET"; + let bytes = pdfcraft_testkit::Pdf::new() + .obj("<< /Type /Catalog /Pages 2 0 R >>") + .obj("<< /Type /Pages /Kids [3 0 R] /Count 1 /MediaBox [0 0 612 792] /Resources << /Font << /FB 5 0 R >> >> >>") + .obj("<< /Type /Page /Parent 2 0 R /Contents 4 0 R >>") + .stream("", page) + .obj("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>") + .build(); + let mut s = Session::new(); + let id = s.open("cols.pdf", None, Arc::new(bytes), None).unwrap(); + let style = s.get(id).unwrap().text_styles()[0].style.clone(); + s.apply(id, Edit::BookmarksFromTextStyles { levels: vec![style] }).unwrap(); + assert_eq!(outline_titles(&s.get(id).unwrap().info.outline), ["Untitled→0[Left heading→1,Right heading→1]"]); +} + /// `style` at another size. fn pdfcraft_engine_style(style: &text_style_bookmarks::TextStyle, size: f64) -> text_style_bookmarks::TextStyle { text_style_bookmarks::TextStyle { size, ..style.clone() } diff --git a/crates/engine/src/text_style_bookmarks.rs b/crates/engine/src/text_style_bookmarks.rs index 937752fa4c..07a0cacd78 100644 --- a/crates/engine/src/text_style_bookmarks.rs +++ b/crates/engine/src/text_style_bookmarks.rs @@ -60,10 +60,11 @@ struct Line { page: usize, text: String, style: TextStyle, - /// Top, bottom and left of its box in user space. + /// Top, bottom, left and right of its box in user space. top: f64, bottom: f64, left: f64, + right: f64, } /// `name` without a subset tag: six capital letters and a `+` (ISO 32000-2 §9.9.2). @@ -102,6 +103,7 @@ fn lines(doc: &Document) -> Vec { top: y0.max(y1), bottom: y0.min(y1), left: x0.min(x1), + right: x0.max(x1), }) }) .collect(); @@ -117,8 +119,21 @@ fn lines(doc: &Document) -> Vec { /// are usually larger than the text around them. pub fn styles(doc: &Document) -> Vec { let mut uses: Vec<(TextStyleUse, Option)> = Vec::new(); + // The styles by font, weight, slant and size in quarter points, so a line is matched against + // the few styles near its size rather than every style found (a page can hold thousands). + // A size matches within a quarter point: the bucket's neighbours are looked at too. + let mut index: std::collections::HashMap<(String, bool, bool, i64), Vec> = std::collections::HashMap::new(); + let bucket = |size: f64| (size / SIZE_TOLERANCE).floor() as i64; for line in lines(doc) { - match uses.iter_mut().find(|(u, _)| u.style.matches(&line.style)) { + let key = |b: i64| (line.style.font.clone(), line.style.bold, line.style.italic, b); + let b = bucket(line.style.size); + let found = [b.saturating_sub(1), b, b.saturating_add(1)] + .into_iter() + .filter_map(|b| index.get(&key(b))) + .flatten() + .copied() + .find(|&i| uses.get(i).is_some_and(|(u, _)| u.style.matches(&line.style))); + match found.and_then(|i| uses.get_mut(i)) { Some((u, last_page)) => { u.lines = u.lines.saturating_add(1); if *last_page != Some(line.page) { @@ -128,6 +143,7 @@ pub fn styles(doc: &Document) -> Vec { } None => { let sample = line.text.chars().take(SAMPLE_CHARS).collect(); + index.entry(key(b)).or_default().push(uses.len()); uses.push((TextStyleUse { style: line.style, lines: 1, pages: 1, sample }, Some(line.page))); } } @@ -144,19 +160,21 @@ pub fn styles(doc: &Document) -> Vec { /// is one bookmark. Each goes to the top of its heading. pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { let mut entries: Vec = Vec::new(); - // The heading the previous line belonged to: its level, and the top and bottom of its last - // line. - let mut open: Option<(usize, f64, f64)> = None; + // The heading the previous line belonged to: its level, and the top, bottom, left and right + // of its last line. + let mut open: Option<(usize, f64, f64, f64, f64)> = None; for line in lines(doc) { let Some(level) = levels.iter().position(|s| s.matches(&line.style)) else { open = None; continue; }; - // A heading goes on to the next row down, close below its last line. A line on the same - // row (two headings side by side) is a heading of its own. - let continues = open.is_some_and(|(l, top, bottom)| { + // A heading goes on to the next row down, close below its last line and under it. A line + // on the same row (two headings side by side), or in another column, is a heading of + // its own. + let continues = open.is_some_and(|(l, top, bottom, left, right)| { let size = line.style.size; - l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 + let under = line.left < right && left < line.right; + l == level && line.top < top - size * 0.5 && bottom - line.top < size * 0.6 && under }) && entries.last().is_some_and(|e| e.page == line.page); if continues { if let Some(e) = entries.last_mut() @@ -181,7 +199,7 @@ pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec { top: Some(line.top), }); } - open = Some((level, line.top, line.bottom)); + open = Some((level, line.top, line.bottom, line.left, line.right)); } entries }