M4: bookmarks from text styles need copying permission and keep columns apart (#1693)

CodeRabbit on #4560:
- The bookmark titles are the document's text, so the edit now needs
  copying permission as well as assembly, and Document::text_styles (the
  dialog's samples) is empty when copying is withheld; the automation
  text_styles tool already checked it.
- A heading went on to the next row whenever it was close below in the
  same style, so same-style headings in two columns on staggered rows ran
  together into one bookmark. A line continues a heading only when it lies
  under it.
- Lines found their style by a scan over every style so far; they are now
  indexed by font, weight, slant and quarter-point size, so a page with
  thousands of styles no longer makes the scan quadratic.

Regression tests for the first two (each fails without its fix).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LymhVaGZCWwB9HNEbLzeMM
This commit is contained in:
Claude
2026-10-25 01:14:19 +00:00
parent 4ce68f517b
commit 4c09747773
3 changed files with 89 additions and 11 deletions
+15 -1
View File
@@ -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<text_style_bookmarks::TextStyleUse> {
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 { .. }
+46
View File
@@ -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() }
+28 -10
View File
@@ -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<Line> {
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<Line> {
/// are usually larger than the text around them.
pub fn styles(doc: &Document) -> Vec<TextStyleUse> {
let mut uses: Vec<(TextStyleUse, Option<usize>)> = 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<usize>> = 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<TextStyleUse> {
}
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<TextStyleUse> {
/// is one bookmark. Each goes to the top of its heading.
pub fn headings(doc: &Document, levels: &[TextStyle]) -> Vec<OutlineEntry> {
let mut entries: Vec<OutlineEntry> = 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<OutlineEntry> {
top: Some(line.top),
});
}
open = Some((level, line.top, line.bottom));
open = Some((level, line.top, line.bottom, line.left, line.right));
}
entries
}